feat: hide-from-views flag on folders

Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.

Schema (migration 0007_folder_hidden):
- folders.is_hidden   user-set toggle, default false
- photos.is_hidden    denormalized effective flag (true iff any
                      ancestor folder is hidden), indexed so cross-
                      cutting queries stay on the existing planner
                      paths

The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
  memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
  WITH RECURSIVE CTE to recompute every folder's effective state in
  one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
  in ~10 ms on a 13k-photo library.

Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
  folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
  contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
  legs join photos so rankings don't include hidden results

Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)

Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
  mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
  label italic/muted so the state is visible at a glance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-11 10:55:25 +02:00
parent 07b1e5e02a
commit 339e1be510
12 changed files with 414 additions and 20 deletions

View File

@@ -18,6 +18,8 @@ import {
PanelLeftClose,
Settings,
Users,
Eye,
EyeOff,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -46,6 +48,9 @@ interface TreeItem {
count?: number
children?: TreeItem[]
type?: 'folder' | 'heap' | 'special'
/** For folder rows only: the user-set "hide from views" flag. Drives
* the muted styling + eye-off badge + menu item label. */
isHidden?: boolean
}
interface LeftSidebarProps {
@@ -303,6 +308,32 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Toggle folder hide-from-views. Invalidates every query that could
// include photos from the affected folder subtree — the sidebar
// tree (for the counts + badge), the photos timeline, library
// stats (sidebar badges), tags (count subquery), and duplicates
// (source for dup groups). All of these respect the new flag on
// the server side; the invalidation is just cache bust.
const toggleHiddenMutation = useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
sourceFolders.setHidden(id, hidden),
onSuccess: (data) => {
toast.success(
data.is_hidden ? 'Folder hidden' : 'Folder visible',
data.is_hidden
? `${data.name} is now excluded from cross-cutting views`
: `${data.name} is back in cross-cutting views`
)
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['tags'] })
},
onError: (e: any) =>
toast.error('Toggle failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const deleteFolderMutation = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
sourceFolders.delete(id, mode),
@@ -349,6 +380,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
icon: <Folder className="h-4 w-4" />,
count: node.photo_count,
type: 'folder',
isHidden: node.is_hidden,
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
@@ -515,15 +547,23 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
)}
{/* Item Icon — section headers drop their icon in favor of the
* uppercase eyebrow label. */}
* uppercase eyebrow label. Hidden folders swap the folder
* icon for an EyeOff so the user sees the state at a glance
* without hunting through the kebab menu. */}
{item.icon && !isSectionHeader && (
<span
className={clsx(
'flex-shrink-0',
isSelected ? 'text-primary' : 'text-text-muted'
isSelected
? 'text-primary'
: item.isHidden
? 'text-text-muted/60'
: 'text-text-muted'
)}
>
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">{item.icon}</span>
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">
{item.isHidden ? <EyeOff className="h-4 w-4" /> : item.icon}
</span>
</span>
)}
@@ -553,7 +593,15 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span className="flex-1 truncate">{item.label}</span>
<span
className={clsx(
'flex-1 truncate',
item.isHidden && !isSelected && 'italic text-text-muted/80'
)}
title={item.isHidden ? `${item.label} — hidden from views` : undefined}
>
{item.label}
</span>
)}
{/* Count Badge — fixed-width slot so counts line up in a column
@@ -629,6 +677,23 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
setRenameDraft(item.label)
}}
/>
<FolderMenuItem
icon={
item.isHidden ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)
}
label={item.isHidden ? 'Show in views' : 'Hide from views'}
onClick={() => {
setOpenMenuId(null)
toggleHiddenMutation.mutate({
id: folderId,
hidden: !item.isHidden,
})
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}

View File

@@ -22,6 +22,11 @@ export interface FolderTreeNode {
name: string
path: string
photo_count: number
/** User-set "hide from cross-cutting views" flag. When true, photos
* in this folder (and descendants) are excluded from All Photos,
* Map, Tags, People, Search and sidebar counts, but remain visible
* when the user navigates directly into the folder. */
is_hidden: boolean
children: FolderTreeNode[]
}
@@ -60,6 +65,23 @@ export const sourceFolders = {
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Toggle "hide from views" on a folder or source root. Hidden
* folders still index + thumbnail their photos, but those photos
* are excluded from cross-cutting views (All Photos, Map, Tags,
* People, Search, sidebar counts). Navigating directly into the
* folder still shows them. Propagates to every descendant folder. */
setHidden: async (folderId: string, hidden: boolean) => {
const response = await api.post(`/folders/${folderId}/hide`, {
hidden,
})
return response.data as {
id: string
name: string
path: string
is_hidden: boolean
}
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and