feat(views): in-view folder scope switching

Timeline, Explore, and Duplicates get a folder-scope dropdown in their
headers (Timeline via the shared Toolbar), so changing scope no longer
means leaving the feature and bouncing through the sidebar. The new
setViewFolderScope store action updates selectedFolderId while
preserving activeView: Timeline/Gallery reload images, Explore reloads
via its existing selectedFolderId effect, and Duplicates loads the
cached results for the new scope (fresh scans remain an explicit
Rescan). Sidebar behavior is unchanged — clicking a library still
opens its gallery.
This commit is contained in:
2026-06-12 12:39:04 +01:00
parent cd7dd89f00
commit b02bf1da2b
5 changed files with 154 additions and 17 deletions
+2
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store"; import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */} {/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && ( {hasResults && selectedCount === 0 && !deleting && (
<button <button
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
/**
* In-view folder scope picker for feature views (Timeline / Explore /
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
* current view active — unlike sidebar folder clicks, which jump to Gallery.
*/
export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const currentLabel =
selectedFolderId === null
? "All Media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
}`}
title="Change folder scope"
>
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{currentLabel}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
<button
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
+21 -17
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion"; import { motion } 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";
const ACCENTS = [ const ACCENTS = [
"#60a5fa", "#60a5fa",
@@ -317,23 +318,26 @@ export function TagCloud() {
: "No tags — run the AI tagger or add tags manually"} : "No tags — run the AI tagger or add tags manually"}
</p> </p>
</div> </div>
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5"> <div className="flex shrink-0 items-center gap-2">
<button <FolderScopeDropdown />
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ <div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" <button
}`} className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
onClick={() => setExploreMode("visual")} exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
> }`}
Clusters onClick={() => setExploreMode("visual")}
</button> >
<button Clusters
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ </button>
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" <button
}`} className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
onClick={() => setExploreMode("tags")} exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
> }`}
Tag Cloud onClick={() => setExploreMode("tags")}
</button> >
Tag Cloud
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
@@ -162,6 +163,7 @@ export function Toolbar() {
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
@@ -269,6 +271,8 @@ export function Toolbar() {
)} )}
</div> </div>
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
<div className="flex-1" /> <div className="flex-1" />
{/* Search */} {/* Search */}
+29
View File
@@ -337,6 +337,7 @@ interface GalleryState {
renameFolder: (folderId: number, newName: string) => Promise<void>; renameFolder: (folderId: number, newName: string) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>; updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
selectFolder: (folderId: number | null) => void; selectFolder: (folderId: number | null) => void;
setViewFolderScope: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>; loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>; loadMoreImages: () => Promise<void>;
setSearch: (search: string) => void; setSearch: (search: string) => void;
@@ -755,6 +756,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true); void get().loadImages(true);
}, },
// Change folder scope from inside a feature view (Timeline/Explore/Duplicates)
// without leaving it — unlike selectFolder, activeView is preserved.
setViewFolderScope: (folderId) => {
const { activeView, selectedFolderId } = get();
if (folderId === selectedFolderId) return;
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
if (activeView === "duplicates") {
const { duplicateScanFolderId } = get();
if (duplicateScanFolderId !== folderId) {
set({
duplicateGroups: [],
duplicateLastScanned: null,
duplicateScanFolderId: undefined,
duplicateScanWarning: null,
});
void get().loadDuplicateScanCache(folderId);
}
return;
}
// Explore reloads itself via TagCloud's useEffect on selectedFolderId.
if (activeView === "explore") return;
void get().loadImages(true);
},
loadImages: async (reset = false) => { loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get(); const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search);