Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d55e4c7502 | |||
| a40a2e8d12 | |||
| 2ce1547844 | |||
| 623aabbb51 | |||
| f65fd350cc | |||
| 86a1a53289 | |||
| ebf16e8cb9 |
+33
-6
@@ -17,13 +17,13 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
delete albums in one go. Deleting an album never touches your files — only the
|
||||
grouping is removed.
|
||||
- **Multi-select & bulk actions in the gallery** — hover a thumbnail's top-left
|
||||
corner to reveal a selection checkbox (or click it to start selecting); while
|
||||
corner to reveal a selection checkbox (click it to start selecting); while
|
||||
selecting, click tiles to toggle and double-click to open. A floating action
|
||||
bar then lets you tag (with autocomplete), rate, favorite, add to an album, or
|
||||
delete the whole selection at once. Works in similar-image, region, and album
|
||||
views too.
|
||||
- **Color search** — filter the gallery (and Timeline) by dominant color via a
|
||||
collapsible swatch palette plus a custom color picker in the toolbar; existing
|
||||
- **Colour search** — filter the gallery (and Timeline) by dominant colour via a
|
||||
collapsible swatch palette plus a custom colour picker in the toolbar; existing
|
||||
libraries are backfilled automatically in the background.
|
||||
- **Album-scoped similar search** — when finding visually similar images or
|
||||
searching by a selected region from an album, you can now keep results scoped
|
||||
@@ -39,9 +39,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
without re-indexing.
|
||||
- **What's New** — after updating, Phokus now greets you with a "What's new"
|
||||
toast that opens an in-app release-notes screen for the new version, with the
|
||||
changes grouped into collapsible Added / Changed / Fixed sections. It's
|
||||
sourced from the bundled changelog (so it works offline) and can be reopened
|
||||
any time from Settings → Updates → What's new.
|
||||
changes grouped into collapsible Added / Changed / Fixed sections.
|
||||
- **Build badge in Settings** — the version line in Settings → Updates now shows
|
||||
whether the running build is the CPU or CUDA (GPU-accelerated) variant.
|
||||
|
||||
@@ -53,6 +51,14 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
click with no confirmation or warning.
|
||||
- The updater now shows a real download progress bar with a percentage in
|
||||
Settings → Updates (previously it only said "Downloading").
|
||||
- **Smaller-screen layout** — the toolbar adapts on narrow windows: the filter
|
||||
chips scroll horizontally instead of squashing (so "Similar: Folder/All" no
|
||||
longer stack), the search box shrinks, and the colour-search swatches now open
|
||||
in a compact popover rather than expanding inline and running off-screen. The
|
||||
sidebar and the lightbox info panel also narrow to give the gallery more room.
|
||||
- **Explore cluster layout** — clusters are now sized by image count (busier
|
||||
clusters are larger and stack on top) and repositioned to avoid overlapping, so
|
||||
every cluster stays viewable and clickable even in dense libraries.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -66,6 +72,27 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
- Subtle Light theme — fixed several surfaces and buttons that stayed dark or
|
||||
became unreadable on hover, including new dialogs and the green action buttons
|
||||
across the updater and onboarding.
|
||||
- **Endless self-indexing loop** — adding a folder that contains Phokus's own
|
||||
app-data directory (for example, your whole user profile) no longer makes the
|
||||
app index its own thumbnail cache, generate thumbnails of those thumbnails, and
|
||||
loop forever. That directory is now skipped by both the folder scanner and the
|
||||
filesystem watcher.
|
||||
- **Background tasks now lead with the active folder** — when one folder's
|
||||
workers are paused while another is actively processing, the active folder
|
||||
takes the main slot in the background-tasks bar instead of the paused one.
|
||||
- **Oversized window on smaller screens** — on a fresh install the window opened
|
||||
at a fixed size that was taller than the usable area on displays like
|
||||
1366×768, leaving part of it off-screen. It now clamps to the monitor's work
|
||||
area (excluding the taskbar) and centres there when it would overflow; larger
|
||||
displays and any remembered size/position are left untouched.
|
||||
- **Explore readability in Subtle Light** — fixed washed-out and unreadable
|
||||
elements in the Explore view on the light theme. Cluster cards now use a soft
|
||||
dark caption scrim (the previous cream overlay fogged the photos), with a
|
||||
legible label, count, and "Open" button; Tag Cloud words use darker accents and
|
||||
a higher opacity floor instead of near-invisible pale text.
|
||||
- **Explore polish** — the Tag Cloud now uses the in-app tooltip instead of the
|
||||
native browser one (and reads "1 image", not "1 images"), and the folder-scope
|
||||
dropdown no longer opens behind the cluster cards.
|
||||
|
||||
## [0.1.1] — 2026-06-23
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ pnpm dev:app
|
||||
# Frontend only (no Tauri window)
|
||||
pnpm dev:vite
|
||||
|
||||
# Production build
|
||||
pnpm build:app
|
||||
# Production build (CPU)
|
||||
pnpm build:app:cpu
|
||||
|
||||
# Production build (CUDA / GPU-accelerated)
|
||||
pnpm build:app:cuda
|
||||
|
||||
# Type-check frontend
|
||||
pnpm build:vite
|
||||
|
||||
@@ -109,8 +109,11 @@ pnpm dev:app
|
||||
# Frontend only
|
||||
pnpm dev:vite
|
||||
|
||||
# Production build
|
||||
pnpm build:app
|
||||
# Production build (CPU)
|
||||
pnpm build:app:cpu
|
||||
|
||||
# Production build (CUDA / GPU-accelerated)
|
||||
pnpm build:app:cuda
|
||||
|
||||
# Type-check the frontend
|
||||
pnpm build:vite
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:app": "tauri build",
|
||||
"build:app:cpu": "tauri build -- --no-default-features",
|
||||
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
|
||||
"build:vite": "tsc && vite build",
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const IMAGE_EXTENSIONS: &[&str] = &[
|
||||
@@ -354,7 +354,51 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
|
||||
});
|
||||
}
|
||||
|
||||
/// True when `path` lives inside Phokus's own app-data directory (thumbnail
|
||||
/// cache, database, downloaded models). If a user indexes an ancestor of this
|
||||
/// directory — e.g. their whole `Users` folder — the app would otherwise index
|
||||
/// its own thumbnails, generate thumbnails of those thumbnails, and loop
|
||||
/// forever. The whole subtree is pruned from folder scans and ignored by the
|
||||
/// watcher to break that cycle.
|
||||
///
|
||||
/// Comparison is component-aware (so a sibling like `…/phokus-backup` never
|
||||
/// matches) and case-insensitive on Windows — the indexed root may report a
|
||||
/// different casing than `app_data_dir` (e.g. `c:\users\…` vs `C:\Users\…`),
|
||||
/// and a case-sensitive prefix check there would silently let the loop back in.
|
||||
fn is_within_app_data(path: &Path, app_data_dir: Option<&Path>) -> bool {
|
||||
let Some(dir) = app_data_dir else {
|
||||
return false;
|
||||
};
|
||||
let mut base = dir.components();
|
||||
let mut target = path.components();
|
||||
loop {
|
||||
let Some(base_component) = base.next() else {
|
||||
// Consumed every component of the app-data dir while still matching →
|
||||
// `path` is the app-data dir itself or a descendant.
|
||||
return true;
|
||||
};
|
||||
let Some(target_component) = target.next() else {
|
||||
// `path` is shorter than the app-data dir, so it cannot be inside it.
|
||||
return false;
|
||||
};
|
||||
let matches = if cfg!(windows) {
|
||||
base_component
|
||||
.as_os_str()
|
||||
.eq_ignore_ascii_case(target_component.as_os_str())
|
||||
} else {
|
||||
base_component == target_component
|
||||
};
|
||||
if !matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
|
||||
// Resolve our own app-data directory so the walk can skip it (see
|
||||
// `is_within_app_data`). Resolution failure is non-fatal — we just lose the
|
||||
// guard, which only matters when indexing an ancestor of the app data dir.
|
||||
let app_data_dir = app.path().app_data_dir().ok();
|
||||
let existing_entries = {
|
||||
let conn = pool.get()?;
|
||||
db::get_folder_media_index(&conn, folder_id)?
|
||||
@@ -371,6 +415,9 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
|
||||
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
// Prune our own app-data subtree before descending into it, so we never
|
||||
// scan (and re-thumbnail) the thumbnail cache.
|
||||
.filter_entry(|entry| !is_within_app_data(entry.path(), app_data_dir.as_deref()))
|
||||
.filter_map(|entry| match entry {
|
||||
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
|
||||
Some(e.path().to_path_buf())
|
||||
@@ -1597,6 +1644,10 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
|
||||
// Spawn the debounce loop on its own thread.
|
||||
let folder_map_thread = Arc::clone(&folder_map);
|
||||
// `thumb_dir` is `<app data>/thumbnails`; its parent is the app-data root.
|
||||
// Watched roots can be ancestors of it (e.g. a whole user profile), so we
|
||||
// drop any event inside that subtree to avoid re-indexing our own cache.
|
||||
let app_data_dir = thumb_dir.parent().map(Path::to_path_buf);
|
||||
std::thread::spawn(move || {
|
||||
// path → deadline: the earliest instant at which this path should be processed.
|
||||
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
|
||||
@@ -1639,7 +1690,22 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
{
|
||||
let old = event.paths[0].clone();
|
||||
let new = event.paths[1].clone();
|
||||
if is_supported_media(&old) || is_supported_media(&new) {
|
||||
let old_in_app = is_within_app_data(&old, app_data_dir.as_deref());
|
||||
let new_in_app = is_within_app_data(&new, app_data_dir.as_deref());
|
||||
if old_in_app && new_in_app {
|
||||
// Internal app-data churn (e.g. thumbnail cache) — ignore.
|
||||
} else if old_in_app || new_in_app {
|
||||
// Only one side is app-data, so the rename pairing is
|
||||
// meaningless (we don't track app-data files). Handle the
|
||||
// legitimate side as an independent create/delete via the
|
||||
// normal debounce queue: a file moved out of app-data is
|
||||
// indexed as a create; one moved in is processed as a
|
||||
// delete (process_watcher_path sees it no longer exists).
|
||||
let legit = if old_in_app { new } else { old };
|
||||
if is_supported_media(&legit) {
|
||||
pending.insert(legit, now + WATCHER_DEBOUNCE);
|
||||
}
|
||||
} else if is_supported_media(&old) || is_supported_media(&new) {
|
||||
// Remove either side from regular pending so it isn't
|
||||
// processed as an independent delete/create.
|
||||
pending.remove(&old);
|
||||
@@ -1648,7 +1714,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
}
|
||||
} else {
|
||||
for path in event.paths {
|
||||
if is_supported_media(&path) {
|
||||
if is_supported_media(&path)
|
||||
&& !is_within_app_data(&path, app_data_dir.as_deref())
|
||||
{
|
||||
pending.insert(path, now + WATCHER_DEBOUNCE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,32 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
// Fresh installs open at the fixed config size (1280×800) because the
|
||||
// window-state plugin has nothing saved yet — too tall for laptops
|
||||
// like 1366×768. Clamp the window to the monitor's work area (which
|
||||
// already excludes the taskbar) and re-center it there, but only when
|
||||
// it actually overflows, so a restored size/position on a roomier
|
||||
// display is left untouched. Sizes are physical pixels on both sides,
|
||||
// so this stays correct across display scaling.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Ok(Some(monitor)) = window.current_monitor() {
|
||||
let area = monitor.work_area();
|
||||
let max_w = area.size.width.saturating_sub(32);
|
||||
let max_h = area.size.height.saturating_sub(32);
|
||||
if let Ok(size) = window.outer_size() {
|
||||
if size.width > max_w || size.height > max_h {
|
||||
let new_w = size.width.min(max_w);
|
||||
let new_h = size.height.min(max_h);
|
||||
let _ = window.set_size(tauri::PhysicalSize::new(new_w, new_h));
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(
|
||||
area.position.x + (area.size.width as i32 - new_w as i32) / 2,
|
||||
area.position.y + (area.size.height as i32 - new_h as i32) / 2,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
stages: TaskStage[];
|
||||
isActive: boolean;
|
||||
hasFailedEmbeddings: boolean;
|
||||
hasFailedTagging: boolean;
|
||||
hasFailedCaptions: boolean;
|
||||
@@ -156,6 +157,18 @@ export function BackgroundTasks() {
|
||||
const captionReady = jobs?.caption_ready ?? 0;
|
||||
const captionFailed = jobs?.caption_failed ?? 0;
|
||||
|
||||
// A folder is "actively processing" when something is genuinely moving:
|
||||
// an in-progress scan, or pending work on a worker that isn't paused.
|
||||
// Captions have no per-folder pause toggle, so any caption work counts.
|
||||
const paused = workerPaused[folder.id];
|
||||
const isActive =
|
||||
(!!index && !index.done) ||
|
||||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
|
||||
(metadataPending > 0 && !(paused?.metadata ?? false)) ||
|
||||
(embeddingPending > 0 && !(paused?.embedding ?? false)) ||
|
||||
(taggingPending > 0 && !(paused?.tagging ?? false)) ||
|
||||
captionPending > 0;
|
||||
|
||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
|
||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||
@@ -262,6 +275,7 @@ export function BackgroundTasks() {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
stages,
|
||||
isActive,
|
||||
hasFailedEmbeddings,
|
||||
hasFailedTagging,
|
||||
hasFailedCaptions,
|
||||
@@ -273,8 +287,12 @@ export function BackgroundTasks() {
|
||||
};
|
||||
})
|
||||
.filter((t): t is Task => t !== null)
|
||||
.filter((t) => dismissed[t.id] !== t.snapshot);
|
||||
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
|
||||
.filter((t) => dismissed[t.id] !== t.snapshot)
|
||||
// Surface actively-processing folders first so a paused folder never holds
|
||||
// the primary (collapsed) slot while another is genuinely working. Array
|
||||
// sort is stable, so folders within each group keep their existing order.
|
||||
.sort((a, b) => Number(b.isActive) - Number(a.isActive));
|
||||
}, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]);
|
||||
|
||||
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
|
||||
const duplicateScanTask: Task | null = duplicateScanning ? {
|
||||
@@ -294,6 +312,7 @@ export function BackgroundTasks() {
|
||||
: null,
|
||||
failed: false,
|
||||
}],
|
||||
isActive: true,
|
||||
hasFailedEmbeddings: false,
|
||||
hasFailedTagging: false,
|
||||
hasFailedCaptions: false,
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ColorFilter() {
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="ml-1 flex items-center border-l border-white/[0.06] pl-2">
|
||||
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2">
|
||||
{/* Trigger — a single palette icon; shows the active color as a dot when a
|
||||
filter is applied so the collapsed state still communicates it. */}
|
||||
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
|
||||
@@ -85,17 +85,17 @@ export function ColorFilter() {
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
// Right-aligned popover so it never widens the toolbar row or gets
|
||||
// pushed off-screen on narrow windows. Swatches wrap into a compact
|
||||
// grid instead of a single long horizontal strip.
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: "auto", opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
// Horizontal/vertical padding gives the active swatch's ring + scale
|
||||
// room inside the overflow-hidden clip box (needed for the width slide).
|
||||
// py-1 keeps the panel shorter than the always-present filter pills so
|
||||
// opening it never changes the row height (no 1px toolbar shift).
|
||||
className="flex items-center gap-1 overflow-hidden whitespace-nowrap px-1.5 py-1"
|
||||
initial={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50"
|
||||
>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{SWATCHES.map((swatch) => {
|
||||
const active = rgbEquals(colorFilter, swatch.rgb);
|
||||
return (
|
||||
@@ -103,7 +103,7 @@ export function ColorFilter() {
|
||||
key={swatch.name}
|
||||
title={swatch.name}
|
||||
aria-label={`Filter by ${swatch.name}`}
|
||||
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${
|
||||
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
|
||||
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
||||
}`}
|
||||
style={{ backgroundColor: toHex(swatch.rgb) }}
|
||||
@@ -115,7 +115,7 @@ export function ColorFilter() {
|
||||
{/* Custom color picker — rainbow until a custom color is chosen. */}
|
||||
<label
|
||||
title="Custom color"
|
||||
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
|
||||
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
|
||||
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
||||
}`}
|
||||
style={
|
||||
@@ -131,24 +131,28 @@ export function ColorFilter() {
|
||||
onChange={(event) => setColorFilter(fromHex(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40">
|
||||
{colorBackfill && colorBackfill.total > 0 ? (
|
||||
<span
|
||||
className="text-[10px] text-gray-600"
|
||||
title="Sampling colors from existing thumbnails — color search fills in as this runs"
|
||||
>
|
||||
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
|
||||
</span>
|
||||
) : <span />}
|
||||
{isActive ? (
|
||||
<button
|
||||
className="ml-0.5 shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
|
||||
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
|
||||
onClick={() => setColorFilter(null)}
|
||||
title="Clear color filter"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{colorBackfill && colorBackfill.total > 0 ? (
|
||||
<span
|
||||
className="ml-0.5 shrink-0 text-[10px] text-gray-600"
|
||||
title="Sampling colors from existing thumbnails — color search fills in as this runs"
|
||||
>
|
||||
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
@@ -517,7 +517,7 @@ export function Lightbox() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="lightbox-panel flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
|
||||
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
|
||||
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
||||
@@ -534,7 +534,7 @@ export function Lightbox() {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-full border px-3 py-1.5 text-xs ${
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
|
||||
canFindSimilar
|
||||
? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
|
||||
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
|
||||
@@ -544,8 +544,15 @@ export function Lightbox() {
|
||||
void findSimilar(selectedImage.id, selectedImage.folder_id);
|
||||
}}
|
||||
disabled={!canFindSimilar}
|
||||
title={canFindSimilar ? "Find similar images" : "Embeddings not ready"}
|
||||
>
|
||||
{canFindSimilar ? "Similar" : "Embeddings not ready"}
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
|
||||
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
|
||||
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" />
|
||||
</svg>
|
||||
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
|
||||
@@ -949,7 +956,7 @@ export function Lightbox() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||
className="absolute right-72 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 lg:right-80"
|
||||
disabled={currentIndex >= images.length - 1 || regionSelectMode}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -829,7 +829,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
|
||||
<aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
|
||||
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
|
||||
|
||||
+82
-34
@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from "framer-motion";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
const ACCENTS = [
|
||||
"#60a5fa",
|
||||
@@ -17,6 +18,21 @@ const ACCENTS = [
|
||||
"#f87171",
|
||||
];
|
||||
|
||||
// Darker variants of each accent for the light theme — the bright originals are
|
||||
// tuned for dark cards and wash out on the cream background.
|
||||
const LIGHT_ACCENTS = [
|
||||
"#2563eb",
|
||||
"#9333ea",
|
||||
"#16a34a",
|
||||
"#d97706",
|
||||
"#db2777",
|
||||
"#0d9488",
|
||||
"#ea580c",
|
||||
"#7c3aed",
|
||||
"#059669",
|
||||
"#dc2626",
|
||||
];
|
||||
|
||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
function seeded(n: number): number {
|
||||
@@ -31,6 +47,7 @@ interface PlacedNode {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zIndex: number;
|
||||
accent: string;
|
||||
driftX: number;
|
||||
driftY: number;
|
||||
@@ -44,17 +61,34 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
|
||||
const maxCount = Math.max(...entries.map((e) => e.count));
|
||||
const cx = containerW / 2;
|
||||
const cy = containerH / 2;
|
||||
// Spread ellipse shrinks slightly to leave room for card half-widths at the edges
|
||||
const spreadX = containerW * 0.42;
|
||||
const spreadY = containerH * 0.36;
|
||||
const n = entries.length;
|
||||
const ASPECT = 0.72;
|
||||
const PAD = 18;
|
||||
|
||||
// 1. Build initial positions using phyllotaxis spiral
|
||||
// Card width scales with image count; the sub-linear exponent (< 1) widens the
|
||||
// gap so the busiest clusters read as clearly larger and more prominent.
|
||||
const rawWidth = (count: number) => {
|
||||
const ratio = Math.max(count / maxCount, 0.05);
|
||||
return 92 + Math.pow(ratio, 0.65) * 158; // ~92–250px before fit scaling
|
||||
};
|
||||
|
||||
// Shrink every card uniformly when their padded footprint can't fit the
|
||||
// canvas, so overlap resolution can actually pull them apart instead of
|
||||
// settling into a pile. (0.6 leaves headroom for imperfect packing.)
|
||||
const totalArea = entries.reduce((sum, e) => {
|
||||
const w = rawWidth(e.count);
|
||||
return sum + (w + PAD) * (w * ASPECT + PAD);
|
||||
}, 0);
|
||||
const usableArea = containerW * containerH * 0.6;
|
||||
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1;
|
||||
|
||||
const spreadX = containerW * 0.44;
|
||||
const spreadY = containerH * 0.4;
|
||||
|
||||
// 1. Seed positions on a phyllotaxis spiral, sized by count.
|
||||
const nodes: PlacedNode[] = entries.map((entry, i) => {
|
||||
const ratio = Math.max(entry.count / maxCount, 0.08);
|
||||
// Cards scale from 110px to 230px wide; height is 3/4 of width
|
||||
const w = 110 + Math.sqrt(ratio) * 120;
|
||||
const h = w * 0.75;
|
||||
const w = rawWidth(entry.count) * fit;
|
||||
const h = w * ASPECT;
|
||||
const radialRatio = Math.sqrt((i + 0.5) / n);
|
||||
const angle = i * GOLDEN_ANGLE;
|
||||
|
||||
@@ -65,6 +99,9 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
|
||||
y: cy + Math.sin(angle) * radialRatio * spreadY,
|
||||
w,
|
||||
h,
|
||||
// Bigger (busier) clusters stack above smaller ones, so they stay
|
||||
// clickable even if a sliver of overlap survives.
|
||||
zIndex: Math.round(w),
|
||||
accent: ACCENTS[i % ACCENTS.length],
|
||||
driftX: (seeded(i + 11) - 0.5) * 18,
|
||||
driftY: (seeded(i + 17) - 0.5) * 14,
|
||||
@@ -73,9 +110,12 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Iterative overlap resolution — no physics, just push apart
|
||||
const PAD = 24;
|
||||
for (let iter = 0; iter < 80; iter++) {
|
||||
// 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
|
||||
// pass so edge cards settle in-bounds instead of being shoved out and
|
||||
// re-overlapping there.
|
||||
const marginX = 14;
|
||||
const marginY = 14;
|
||||
for (let iter = 0; iter < 160; iter++) {
|
||||
for (let a = 0; a < nodes.length; a++) {
|
||||
const na = nodes[a];
|
||||
for (let b = a + 1; b < nodes.length; b++) {
|
||||
@@ -85,29 +125,26 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
|
||||
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
|
||||
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
|
||||
if (overlapX <= 0 || overlapY <= 0) continue;
|
||||
// Push along the smaller overlap axis
|
||||
// Push along the smaller overlap axis (ternary yields ±1 so coincident
|
||||
// cards still separate rather than stalling at a zero push).
|
||||
if (overlapX < overlapY) {
|
||||
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
|
||||
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
|
||||
nb.x += push;
|
||||
na.x -= push;
|
||||
} else {
|
||||
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
|
||||
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
|
||||
nb.y += push;
|
||||
na.y -= push;
|
||||
}
|
||||
}
|
||||
// Pull gently back toward anchor to prevent runaway drift
|
||||
na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05;
|
||||
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX);
|
||||
node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clamp so cards never poke outside the container
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16),
|
||||
y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16),
|
||||
}));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
|
||||
@@ -124,7 +161,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
|
||||
return (
|
||||
<motion.button
|
||||
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
||||
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
|
||||
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
|
||||
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
|
||||
animate={
|
||||
animated
|
||||
@@ -148,9 +185,9 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
|
||||
}
|
||||
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
|
||||
}
|
||||
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
|
||||
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
|
||||
onClick={() => onOpen(node.entry.image_ids)}
|
||||
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
|
||||
title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
@@ -178,7 +215,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
|
||||
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
className="explore-cluster-open rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
|
||||
>
|
||||
Open
|
||||
@@ -203,25 +240,34 @@ function TagWord({
|
||||
logRange: number;
|
||||
onSearch: (tag: string) => void;
|
||||
}) {
|
||||
const theme = useGalleryStore((state) => state.theme);
|
||||
const isLight = theme === "subtle-light";
|
||||
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
|
||||
const fontSize = 11 + ratio * 28; // 11px – 39px
|
||||
const accent = ACCENTS[index % ACCENTS.length];
|
||||
const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
|
||||
const tilt = (seeded(index + 5) - 0.5) * 7;
|
||||
// Faint low-frequency words read fine as subtle white-on-dark, but the same low
|
||||
// opacity is unreadable on the light theme's cream, so raise the floor there.
|
||||
const minOpacity = isLight ? 0.6 : 0.4;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${entry.tag} — ${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
|
||||
followCursor
|
||||
delay={250}
|
||||
>
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
|
||||
animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
|
||||
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
|
||||
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
|
||||
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
|
||||
style={{ fontSize, rotate: tilt }}
|
||||
onClick={() => onSearch(entry.tag)}
|
||||
title={`${entry.tag} — ${entry.count.toLocaleString()} images`}
|
||||
>
|
||||
<span
|
||||
className="font-medium leading-none"
|
||||
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }}
|
||||
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
|
||||
>
|
||||
{entry.tag}
|
||||
</span>
|
||||
@@ -232,6 +278,7 @@ function TagWord({
|
||||
{entry.count.toLocaleString()}
|
||||
</span>
|
||||
</motion.button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,7 +325,7 @@ function ClusterCloud({
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
|
||||
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
|
||||
{nodes.map((node) => (
|
||||
<CloudCard
|
||||
@@ -484,8 +531,9 @@ export function TagCloud() {
|
||||
|
||||
return (
|
||||
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
|
||||
{/* Header */}
|
||||
<div className="explore-header shrink-0 border-b border-white/[0.05] px-6 py-4">
|
||||
{/* Header — `relative z-10` keeps the folder-scope dropdown above the
|
||||
cluster canvas, whose cards use a high z-index of their own. */}
|
||||
<div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
|
||||
|
||||
@@ -111,7 +111,7 @@ function FilterPill({
|
||||
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
|
||||
return (
|
||||
<button
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
active
|
||||
? activeClass
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
@@ -268,10 +268,10 @@ export function Toolbar() {
|
||||
return (
|
||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
{/* Primary row */}
|
||||
<div className="flex items-center gap-3 px-5 h-12">
|
||||
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
|
||||
{/* Title + count */}
|
||||
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
|
||||
<h2 className="text-[15px] font-semibold text-white truncate max-w-48">{title}</h2>
|
||||
<h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
|
||||
<span className="text-xs text-gray-600 shrink-0">
|
||||
{loadedCount < totalImages
|
||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||
@@ -322,7 +322,7 @@ export function Toolbar() {
|
||||
}}
|
||||
onFocus={() => setSearchPanelOpen(true)}
|
||||
placeholder="Search files, or use /s /t"
|
||||
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
/>
|
||||
{searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||
@@ -452,8 +452,10 @@ export function Toolbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
{/* Filter row — pills scroll horizontally on narrow widths so they never
|
||||
squash or stack; the color filter stays pinned to the right. */}
|
||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
|
||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
@@ -481,8 +483,9 @@ export function Toolbar() {
|
||||
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||
</div>
|
||||
<ColorFilter />
|
||||
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+8
-10
@@ -190,31 +190,29 @@ html[data-theme="subtle-light"] .explore-cluster-card:hover {
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-cluster-overlay {
|
||||
/* A cream wash over the photo read as a faded, foggy haze. Use a gentle dark
|
||||
scrim instead — the same natural "photo caption" vignette the dark theme
|
||||
uses — with light text on top (label/count rules below). */
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(251 250 246 / 0.9),
|
||||
rgb(251 250 246 / 0.52) 34%,
|
||||
rgb(251 250 246 / 0.06) 68%,
|
||||
transparent
|
||||
rgb(17 18 22 / 0.82),
|
||||
rgb(17 18 22 / 0.34) 38%,
|
||||
transparent 66%
|
||||
) !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-cluster-label {
|
||||
color: #6b6257 !important;
|
||||
color: rgb(255 255 255 / 0.62) !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-cluster-count {
|
||||
color: #111827 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-tag-word:hover {
|
||||
background: #e8e2d6 !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-tag-word span:first-child {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .explore-spinner {
|
||||
border-color: rgb(17 24 39 / 0.18) !important;
|
||||
border-top-color: rgb(17 24 39 / 0.55) !important;
|
||||
|
||||
Reference in New Issue
Block a user