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:
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderScopeDropdown />
|
||||
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
||||
{hasResults && selectedCount === 0 && !deleting && (
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
const ACCENTS = [
|
||||
"#60a5fa",
|
||||
@@ -317,7 +318,9 @@ export function TagCloud() {
|
||||
: "No tags — run the AI tagger or add tags manually"}
|
||||
</p>
|
||||
</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">
|
||||
<FolderScopeDropdown />
|
||||
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||
<button
|
||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
@@ -337,6 +340,7 @@ export function TagCloud() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "date_desc", label: "Newest first" },
|
||||
@@ -162,6 +163,7 @@ export function Toolbar() {
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||
|
||||
@@ -269,6 +271,8 @@ export function Toolbar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@@ -337,6 +337,7 @@ interface GalleryState {
|
||||
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||
selectFolder: (folderId: number | null) => void;
|
||||
setViewFolderScope: (folderId: number | null) => void;
|
||||
loadImages: (reset?: boolean) => Promise<void>;
|
||||
loadMoreImages: () => Promise<void>;
|
||||
setSearch: (search: string) => void;
|
||||
@@ -755,6 +756,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
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) => {
|
||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
||||
const parsedSearch = parseSearchValue(search);
|
||||
|
||||
Reference in New Issue
Block a user