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:
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user