Compare commits
7 Commits
e7d9c39fd1
...
d55e4c7502
| 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
|
delete albums in one go. Deleting an album never touches your files — only the
|
||||||
grouping is removed.
|
grouping is removed.
|
||||||
- **Multi-select & bulk actions in the gallery** — hover a thumbnail's top-left
|
- **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
|
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
|
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
|
delete the whole selection at once. Works in similar-image, region, and album
|
||||||
views too.
|
views too.
|
||||||
- **Color search** — filter the gallery (and Timeline) by dominant color via a
|
- **Colour search** — filter the gallery (and Timeline) by dominant colour via a
|
||||||
collapsible swatch palette plus a custom color picker in the toolbar; existing
|
collapsible swatch palette plus a custom colour picker in the toolbar; existing
|
||||||
libraries are backfilled automatically in the background.
|
libraries are backfilled automatically in the background.
|
||||||
- **Album-scoped similar search** — when finding visually similar images or
|
- **Album-scoped similar search** — when finding visually similar images or
|
||||||
searching by a selected region from an album, you can now keep results scoped
|
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.
|
without re-indexing.
|
||||||
- **What's New** — after updating, Phokus now greets you with a "What's new"
|
- **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
|
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
|
changes grouped into collapsible Added / Changed / Fixed sections.
|
||||||
sourced from the bundled changelog (so it works offline) and can be reopened
|
|
||||||
any time from Settings → Updates → What's new.
|
|
||||||
- **Build badge in Settings** — the version line in Settings → Updates now shows
|
- **Build badge in Settings** — the version line in Settings → Updates now shows
|
||||||
whether the running build is the CPU or CUDA (GPU-accelerated) variant.
|
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.
|
click with no confirmation or warning.
|
||||||
- The updater now shows a real download progress bar with a percentage in
|
- The updater now shows a real download progress bar with a percentage in
|
||||||
Settings → Updates (previously it only said "Downloading").
|
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
|
### 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
|
- Subtle Light theme — fixed several surfaces and buttons that stayed dark or
|
||||||
became unreadable on hover, including new dialogs and the green action buttons
|
became unreadable on hover, including new dialogs and the green action buttons
|
||||||
across the updater and onboarding.
|
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
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ pnpm dev:app
|
|||||||
# Frontend only (no Tauri window)
|
# Frontend only (no Tauri window)
|
||||||
pnpm dev:vite
|
pnpm dev:vite
|
||||||
|
|
||||||
# Production build
|
# Production build (CPU)
|
||||||
pnpm build:app
|
pnpm build:app:cpu
|
||||||
|
|
||||||
|
# Production build (CUDA / GPU-accelerated)
|
||||||
|
pnpm build:app:cuda
|
||||||
|
|
||||||
# Type-check frontend
|
# Type-check frontend
|
||||||
pnpm build:vite
|
pnpm build:vite
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ pnpm dev:app
|
|||||||
# Frontend only
|
# Frontend only
|
||||||
pnpm dev:vite
|
pnpm dev:vite
|
||||||
|
|
||||||
# Production build
|
# Production build (CPU)
|
||||||
pnpm build:app
|
pnpm build:app:cpu
|
||||||
|
|
||||||
|
# Production build (CUDA / GPU-accelerated)
|
||||||
|
pnpm build:app:cuda
|
||||||
|
|
||||||
# Type-check the frontend
|
# Type-check the frontend
|
||||||
pnpm build:vite
|
pnpm build:vite
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:app": "tauri build",
|
|
||||||
"build:app:cpu": "tauri build -- --no-default-features",
|
"build:app:cpu": "tauri build -- --no-default-features",
|
||||||
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
|
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
|
||||||
"build:vite": "tsc && vite build",
|
"build:vite": "tsc && vite build",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS: &[&str] = &[
|
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<()> {
|
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 existing_entries = {
|
||||||
let conn = pool.get()?;
|
let conn = pool.get()?;
|
||||||
db::get_folder_media_index(&conn, folder_id)?
|
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)
|
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
||||||
.follow_links(true)
|
.follow_links(true)
|
||||||
.into_iter()
|
.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 {
|
.filter_map(|entry| match entry {
|
||||||
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
|
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
|
||||||
Some(e.path().to_path_buf())
|
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.
|
// Spawn the debounce loop on its own thread.
|
||||||
let folder_map_thread = Arc::clone(&folder_map);
|
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 || {
|
std::thread::spawn(move || {
|
||||||
// path → deadline: the earliest instant at which this path should be processed.
|
// path → deadline: the earliest instant at which this path should be processed.
|
||||||
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
|
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 old = event.paths[0].clone();
|
||||||
let new = event.paths[1].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
|
// Remove either side from regular pending so it isn't
|
||||||
// processed as an independent delete/create.
|
// processed as an independent delete/create.
|
||||||
pending.remove(&old);
|
pending.remove(&old);
|
||||||
@@ -1648,7 +1714,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for path in event.paths {
|
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);
|
pending.insert(path, now + WATCHER_DEBOUNCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.setup(|app| {
|
.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
|
let app_dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface Task {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stages: TaskStage[];
|
stages: TaskStage[];
|
||||||
|
isActive: boolean;
|
||||||
hasFailedEmbeddings: boolean;
|
hasFailedEmbeddings: boolean;
|
||||||
hasFailedTagging: boolean;
|
hasFailedTagging: boolean;
|
||||||
hasFailedCaptions: boolean;
|
hasFailedCaptions: boolean;
|
||||||
@@ -156,6 +157,18 @@ export function BackgroundTasks() {
|
|||||||
const captionReady = jobs?.caption_ready ?? 0;
|
const captionReady = jobs?.caption_ready ?? 0;
|
||||||
const captionFailed = jobs?.caption_failed ?? 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 pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
|
||||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||||
@@ -262,6 +275,7 @@ export function BackgroundTasks() {
|
|||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
stages,
|
stages,
|
||||||
|
isActive,
|
||||||
hasFailedEmbeddings,
|
hasFailedEmbeddings,
|
||||||
hasFailedTagging,
|
hasFailedTagging,
|
||||||
hasFailedCaptions,
|
hasFailedCaptions,
|
||||||
@@ -273,8 +287,12 @@ export function BackgroundTasks() {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((t): t is Task => t !== null)
|
.filter((t): t is Task => t !== null)
|
||||||
.filter((t) => dismissed[t.id] !== t.snapshot);
|
.filter((t) => dismissed[t.id] !== t.snapshot)
|
||||||
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
|
// 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
|
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
|
||||||
const duplicateScanTask: Task | null = duplicateScanning ? {
|
const duplicateScanTask: Task | null = duplicateScanning ? {
|
||||||
@@ -294,6 +312,7 @@ export function BackgroundTasks() {
|
|||||||
: null,
|
: null,
|
||||||
failed: false,
|
failed: false,
|
||||||
}],
|
}],
|
||||||
|
isActive: true,
|
||||||
hasFailedEmbeddings: false,
|
hasFailedEmbeddings: false,
|
||||||
hasFailedTagging: false,
|
hasFailedTagging: false,
|
||||||
hasFailedCaptions: false,
|
hasFailedCaptions: false,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function ColorFilter() {
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
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
|
{/* Trigger — a single palette icon; shows the active color as a dot when a
|
||||||
filter is applied so the collapsed state still communicates it. */}
|
filter is applied so the collapsed state still communicates it. */}
|
||||||
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
|
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
|
||||||
@@ -85,70 +85,74 @@ export function ColorFilter() {
|
|||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{open ? (
|
{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
|
<motion.div
|
||||||
initial={{ width: 0, opacity: 0 }}
|
initial={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||||
animate={{ width: "auto", opacity: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ width: 0, opacity: 0 }}
|
exit={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||||
// Horizontal/vertical padding gives the active swatch's ring + scale
|
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"
|
||||||
// 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"
|
|
||||||
>
|
>
|
||||||
{SWATCHES.map((swatch) => {
|
<div className="grid grid-cols-7 gap-1.5">
|
||||||
const active = rgbEquals(colorFilter, swatch.rgb);
|
{SWATCHES.map((swatch) => {
|
||||||
return (
|
const active = rgbEquals(colorFilter, swatch.rgb);
|
||||||
<button
|
return (
|
||||||
key={swatch.name}
|
<button
|
||||||
title={swatch.name}
|
key={swatch.name}
|
||||||
aria-label={`Filter by ${swatch.name}`}
|
title={swatch.name}
|
||||||
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${
|
aria-label={`Filter by ${swatch.name}`}
|
||||||
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
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) }}
|
}`}
|
||||||
onClick={() => setColorFilter(active ? null : swatch.rgb)}
|
style={{ backgroundColor: toHex(swatch.rgb) }}
|
||||||
|
onClick={() => setColorFilter(active ? null : swatch.rgb)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Custom color picker — rainbow until a custom color is chosen. */}
|
||||||
|
<label
|
||||||
|
title="Custom color"
|
||||||
|
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={
|
||||||
|
isCustom
|
||||||
|
? { backgroundColor: toHex(colorFilter as Rgb) }
|
||||||
|
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="absolute inset-0 cursor-pointer opacity-0"
|
||||||
|
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
|
||||||
|
onChange={(event) => setColorFilter(fromHex(event.target.value))}
|
||||||
/>
|
/>
|
||||||
);
|
</label>
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
{/* Custom color picker — rainbow until a custom color is chosen. */}
|
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
|
||||||
<label
|
<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">
|
||||||
title="Custom color"
|
{colorBackfill && colorBackfill.total > 0 ? (
|
||||||
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
|
<span
|
||||||
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
|
className="text-[10px] text-gray-600"
|
||||||
}`}
|
title="Sampling colors from existing thumbnails — color search fills in as this runs"
|
||||||
style={
|
>
|
||||||
isCustom
|
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
|
||||||
? { backgroundColor: toHex(colorFilter as Rgb) }
|
</span>
|
||||||
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
|
) : <span />}
|
||||||
}
|
{isActive ? (
|
||||||
>
|
<button
|
||||||
<input
|
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
|
||||||
type="color"
|
onClick={() => setColorFilter(null)}
|
||||||
className="absolute inset-0 cursor-pointer opacity-0"
|
title="Clear color filter"
|
||||||
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
|
>
|
||||||
onChange={(event) => setColorFilter(fromHex(event.target.value))}
|
Clear
|
||||||
/>
|
</button>
|
||||||
</label>
|
) : null}
|
||||||
|
</div>
|
||||||
{isActive ? (
|
|
||||||
<button
|
|
||||||
className="ml-0.5 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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export function Lightbox() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</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="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
||||||
@@ -534,7 +534,7 @@ export function Lightbox() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<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
|
canFindSimilar
|
||||||
? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
|
? "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"
|
: "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);
|
void findSimilar(selectedImage.id, selectedImage.folder_id);
|
||||||
}}
|
}}
|
||||||
disabled={!canFindSimilar}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
|
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
|
||||||
@@ -949,7 +956,7 @@ export function Lightbox() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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}
|
disabled={currentIndex >= images.length - 1 || regionSelectMode}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -829,7 +829,7 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
|
<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>
|
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
|
||||||
|
|||||||
+100
-52
@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from "framer-motion";
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
const ACCENTS = [
|
const ACCENTS = [
|
||||||
"#60a5fa",
|
"#60a5fa",
|
||||||
@@ -17,6 +18,21 @@ const ACCENTS = [
|
|||||||
"#f87171",
|
"#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));
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
|
||||||
function seeded(n: number): number {
|
function seeded(n: number): number {
|
||||||
@@ -31,6 +47,7 @@ interface PlacedNode {
|
|||||||
y: number;
|
y: number;
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
|
zIndex: number;
|
||||||
accent: string;
|
accent: string;
|
||||||
driftX: number;
|
driftX: number;
|
||||||
driftY: 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 maxCount = Math.max(...entries.map((e) => e.count));
|
||||||
const cx = containerW / 2;
|
const cx = containerW / 2;
|
||||||
const cy = containerH / 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 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 nodes: PlacedNode[] = entries.map((entry, i) => {
|
||||||
const ratio = Math.max(entry.count / maxCount, 0.08);
|
const w = rawWidth(entry.count) * fit;
|
||||||
// Cards scale from 110px to 230px wide; height is 3/4 of width
|
const h = w * ASPECT;
|
||||||
const w = 110 + Math.sqrt(ratio) * 120;
|
|
||||||
const h = w * 0.75;
|
|
||||||
const radialRatio = Math.sqrt((i + 0.5) / n);
|
const radialRatio = Math.sqrt((i + 0.5) / n);
|
||||||
const angle = i * GOLDEN_ANGLE;
|
const angle = i * GOLDEN_ANGLE;
|
||||||
|
|
||||||
@@ -65,6 +99,9 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
|
|||||||
y: cy + Math.sin(angle) * radialRatio * spreadY,
|
y: cy + Math.sin(angle) * radialRatio * spreadY,
|
||||||
w,
|
w,
|
||||||
h,
|
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],
|
accent: ACCENTS[i % ACCENTS.length],
|
||||||
driftX: (seeded(i + 11) - 0.5) * 18,
|
driftX: (seeded(i + 11) - 0.5) * 18,
|
||||||
driftY: (seeded(i + 17) - 0.5) * 14,
|
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
|
// 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
|
||||||
const PAD = 24;
|
// pass so edge cards settle in-bounds instead of being shoved out and
|
||||||
for (let iter = 0; iter < 80; iter++) {
|
// re-overlapping there.
|
||||||
|
const marginX = 14;
|
||||||
|
const marginY = 14;
|
||||||
|
for (let iter = 0; iter < 160; iter++) {
|
||||||
for (let a = 0; a < nodes.length; a++) {
|
for (let a = 0; a < nodes.length; a++) {
|
||||||
const na = nodes[a];
|
const na = nodes[a];
|
||||||
for (let b = a + 1; b < nodes.length; b++) {
|
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 overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
|
||||||
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
|
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
|
||||||
if (overlapX <= 0 || overlapY <= 0) continue;
|
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) {
|
if (overlapX < overlapY) {
|
||||||
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
|
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
|
||||||
nb.x += push;
|
nb.x += push;
|
||||||
na.x -= push;
|
na.x -= push;
|
||||||
} else {
|
} else {
|
||||||
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
|
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
|
||||||
nb.y += push;
|
nb.y += push;
|
||||||
na.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;
|
for (const node of nodes) {
|
||||||
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
|
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;
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
|
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 (
|
return (
|
||||||
<motion.button
|
<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"
|
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 }}
|
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
|
||||||
animate={
|
animate={
|
||||||
animated
|
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) } }
|
: { 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)}
|
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 ? (
|
{src ? (
|
||||||
<img
|
<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>
|
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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` }}
|
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
@@ -203,35 +240,45 @@ function TagWord({
|
|||||||
logRange: number;
|
logRange: number;
|
||||||
onSearch: (tag: string) => void;
|
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 ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
|
||||||
const fontSize = 11 + ratio * 28; // 11px – 39px
|
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;
|
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 (
|
return (
|
||||||
<motion.button
|
<Tooltip
|
||||||
initial={{ opacity: 0, scale: 0.6 }}
|
label={`${entry.tag} — ${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
|
||||||
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
|
followCursor
|
||||||
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
|
delay={250}
|
||||||
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
|
<motion.button
|
||||||
className="font-medium leading-none"
|
initial={{ opacity: 0, scale: 0.6 }}
|
||||||
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }}
|
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)}
|
||||||
>
|
>
|
||||||
{entry.tag}
|
<span
|
||||||
</span>
|
className="font-medium leading-none"
|
||||||
<span
|
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
|
||||||
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
|
>
|
||||||
style={{ backgroundColor: `${accent}22`, color: accent }}
|
{entry.tag}
|
||||||
>
|
</span>
|
||||||
{entry.count.toLocaleString()}
|
<span
|
||||||
</span>
|
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
</motion.button>
|
style={{ backgroundColor: `${accent}22`, color: accent }}
|
||||||
|
>
|
||||||
|
{entry.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +325,7 @@ function ClusterCloud({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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]" />
|
<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) => (
|
{nodes.map((node) => (
|
||||||
<CloudCard
|
<CloudCard
|
||||||
@@ -484,8 +531,9 @@ export function TagCloud() {
|
|||||||
|
|
||||||
return (
|
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]">
|
<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 */}
|
{/* Header — `relative z-10` keeps the folder-scope dropdown above the
|
||||||
<div className="explore-header shrink-0 border-b border-white/[0.05] px-6 py-4">
|
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="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
|
<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";
|
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
|
||||||
return (
|
return (
|
||||||
<button
|
<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
|
active
|
||||||
? activeClass
|
? 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"
|
: "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 (
|
return (
|
||||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||||
{/* Primary row */}
|
{/* 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 */}
|
{/* Title + count */}
|
||||||
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
|
<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">
|
<span className="text-xs text-gray-600 shrink-0">
|
||||||
{loadedCount < totalImages
|
{loadedCount < totalImages
|
||||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||||
@@ -322,7 +322,7 @@ export function Toolbar() {
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setSearchPanelOpen(true)}
|
onFocus={() => setSearchPanelOpen(true)}
|
||||||
placeholder="Search files, or use /s /t"
|
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 ? (
|
{searchCommand !== null ? (
|
||||||
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||||
@@ -452,8 +452,10 @@ export function Toolbar() {
|
|||||||
</div>
|
</div>
|
||||||
</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 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="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="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); }} />
|
<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)}
|
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 />
|
<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>
|
||||||
</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 {
|
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(
|
background: linear-gradient(
|
||||||
to top,
|
to top,
|
||||||
rgb(251 250 246 / 0.9),
|
rgb(17 18 22 / 0.82),
|
||||||
rgb(251 250 246 / 0.52) 34%,
|
rgb(17 18 22 / 0.34) 38%,
|
||||||
rgb(251 250 246 / 0.06) 68%,
|
transparent 66%
|
||||||
transparent
|
|
||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="subtle-light"] .explore-cluster-label {
|
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 {
|
html[data-theme="subtle-light"] .explore-cluster-count {
|
||||||
color: #111827 !important;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="subtle-light"] .explore-tag-word:hover {
|
html[data-theme="subtle-light"] .explore-tag-word:hover {
|
||||||
background: #e8e2d6 !important;
|
background: #e8e2d6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="subtle-light"] .explore-tag-word span:first-child {
|
|
||||||
color: #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="subtle-light"] .explore-spinner {
|
html[data-theme="subtle-light"] .explore-spinner {
|
||||||
border-color: rgb(17 24 39 / 0.18) !important;
|
border-color: rgb(17 24 39 / 0.18) !important;
|
||||||
border-top-color: rgb(17 24 39 / 0.55) !important;
|
border-top-color: rgb(17 24 39 / 0.55) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user