Compare commits
3 Commits
5f11698907
...
7dcfa8f30d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dcfa8f30d | |||
| 66ffd94c48 | |||
| 61486a503e |
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo, Folder, Tag, PhotoTag
|
from app.models import Photo, Folder, Tag, PhotoTag
|
||||||
|
from app.models.folders import SourceRoot
|
||||||
from app.models.heaps import heap_photos
|
from app.models.heaps import heap_photos
|
||||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -67,8 +68,26 @@ async def list_photos(
|
|||||||
if date_to:
|
if date_to:
|
||||||
filters.append(Photo.taken_at <= date_to)
|
filters.append(Photo.taken_at <= date_to)
|
||||||
|
|
||||||
# Folder filter
|
# Folder filter — the sidebar exposes "source roots" (top-level scan
|
||||||
|
# paths) under the same UI affordance as folders, so the same param has
|
||||||
|
# to accept either a folder id or a source root id. If the value matches
|
||||||
|
# a source root, expand to every folder under that root and use IN.
|
||||||
if folder_id:
|
if folder_id:
|
||||||
|
sr_check = await db.execute(
|
||||||
|
select(SourceRoot.id).where(SourceRoot.id == folder_id)
|
||||||
|
)
|
||||||
|
if sr_check.scalar_one_or_none() is not None:
|
||||||
|
child_folders = await db.execute(
|
||||||
|
select(Folder.id).where(Folder.source_root_id == folder_id)
|
||||||
|
)
|
||||||
|
child_ids = [row[0] for row in child_folders.all()]
|
||||||
|
if child_ids:
|
||||||
|
filters.append(Photo.folder_id.in_(child_ids))
|
||||||
|
else:
|
||||||
|
# Source root with no folder rows yet — match nothing rather
|
||||||
|
# than returning the entire library.
|
||||||
|
filters.append(Photo.id == '__no_match__')
|
||||||
|
else:
|
||||||
filters.append(Photo.folder_id == folder_id)
|
filters.append(Photo.folder_id == folder_id)
|
||||||
|
|
||||||
# Media type filter
|
# Media type filter
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
|
import { sourceFolders, heaps as heapsApi } from '../../services/api'
|
||||||
|
|
||||||
export function ActiveFilterChips() {
|
export function ActiveFilterChips() {
|
||||||
const f = useFilterStore()
|
const f = useFilterStore()
|
||||||
|
|
||||||
|
// Look up names for id-based filters so the chips show something
|
||||||
|
// human-readable instead of opaque uuids.
|
||||||
|
const { data: foldersData } = useQuery({
|
||||||
|
queryKey: ['folders'],
|
||||||
|
queryFn: sourceFolders.list,
|
||||||
|
enabled: f.folderId !== null,
|
||||||
|
})
|
||||||
|
const folder = f.folderId
|
||||||
|
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const { data: heaps = [] } = useQuery({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: heapsApi.list,
|
||||||
|
enabled: f.heapId !== null,
|
||||||
|
})
|
||||||
|
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
|
||||||
|
|
||||||
if (!hasActiveFilters(f)) return null
|
if (!hasActiveFilters(f)) return null
|
||||||
|
|
||||||
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
||||||
@@ -57,6 +77,20 @@ export function ActiveFilterChips() {
|
|||||||
onRemove: () => f.setFlag('any'),
|
onRemove: () => f.setFlag('any'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (f.folderId) {
|
||||||
|
chips.push({
|
||||||
|
key: 'folder',
|
||||||
|
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`,
|
||||||
|
onRemove: () => f.setFolderId(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (f.heapId) {
|
||||||
|
chips.push({
|
||||||
|
key: 'heap',
|
||||||
|
label: `Heap: ${heap?.name ?? f.heapId}`,
|
||||||
|
onRemove: () => f.setHeapId(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export function LeftSidebar() {
|
|||||||
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
|
const setFolderId = useFilterStore((s) => s.setFolderId)
|
||||||
|
const filterFolderId = useFilterStore((s) => s.folderId)
|
||||||
|
|
||||||
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
||||||
// the library tree is just a saved filter preset.
|
// the library tree is just a saved filter preset.
|
||||||
@@ -55,7 +57,16 @@ export function LeftSidebar() {
|
|||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setFlag('discarded')
|
setFlag('discarded')
|
||||||
break
|
break
|
||||||
// 'by-date' is purely visual until we add a date-grouping UI
|
default:
|
||||||
|
if (id.startsWith('folder-')) {
|
||||||
|
// Folder rows: filter to that folder, clear other filters that
|
||||||
|
// would compete (heap, discarded, etc.) so the user sees what they
|
||||||
|
// expect when they click a folder.
|
||||||
|
const folderId = id.slice('folder-'.length)
|
||||||
|
clearAllFilters()
|
||||||
|
setFolderId(folderId)
|
||||||
|
}
|
||||||
|
// 'by-date' is still visual-only.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +161,24 @@ export function LeftSidebar() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Derive whether a tree item is currently the "active" filter target.
|
||||||
|
// Folder rows are selected when the filter store's folderId matches; the
|
||||||
|
// library "All Photos" virtual node is selected when no folder/heap filter
|
||||||
|
// is set.
|
||||||
|
const isItemActive = (id: string): boolean => {
|
||||||
|
if (id.startsWith('folder-')) {
|
||||||
|
return filterFolderId === id.slice('folder-'.length)
|
||||||
|
}
|
||||||
|
if (id === 'all-photos') {
|
||||||
|
return filterFolderId === null && selectedItem === 'all-photos'
|
||||||
|
}
|
||||||
|
return selectedItem === id
|
||||||
|
}
|
||||||
|
|
||||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||||
const hasChildren = item.children && item.children.length > 0
|
const hasChildren = item.children && item.children.length > 0
|
||||||
const isExpanded = expandedItems.has(item.id)
|
const isExpanded = expandedItems.has(item.id)
|
||||||
const isSelected = selectedItem === item.id
|
const isSelected = isItemActive(item.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
|
|||||||
@@ -145,6 +145,20 @@ export function Timeline() {
|
|||||||
[photos, columns, sortBy]
|
[photos, columns, sortBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||||
|
// space, used to drive the sticky-header overlay below.
|
||||||
|
const headerOffsets = useMemo(() => {
|
||||||
|
const result: { offset: number; label: string }[] = []
|
||||||
|
let cumulative = 0
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'header') {
|
||||||
|
result.push({ offset: cumulative, label: item.label })
|
||||||
|
}
|
||||||
|
cumulative += item.height
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [items])
|
||||||
|
|
||||||
// Range-selection helper. Operates on the global photos array, not on
|
// Range-selection helper. Operates on the global photos array, not on
|
||||||
// virtualizer items.
|
// virtualizer items.
|
||||||
const selectRange = (endIndex: number) => {
|
const selectRange = (endIndex: number) => {
|
||||||
@@ -171,6 +185,32 @@ export function Timeline() {
|
|||||||
virtualizer.measure()
|
virtualizer.measure()
|
||||||
}, [items, virtualizer])
|
}, [items, virtualizer])
|
||||||
|
|
||||||
|
// Track scroll position so we can show the current group label as a
|
||||||
|
// pinned overlay at the top of the scroll container. The virtualizer's
|
||||||
|
// items use transform translateY (so CSS position: sticky doesn't work
|
||||||
|
// on the inline headers); the overlay sidesteps that by living outside
|
||||||
|
// the virtualizer's positioned children.
|
||||||
|
const [scrollTop, setScrollTop] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = parentRef.current
|
||||||
|
if (!el) return
|
||||||
|
const onScroll = () => setScrollTop(el.scrollTop)
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => el.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Find the latest header whose start <= scrollTop. That's the label of
|
||||||
|
// the group containing whatever is currently at the top of the viewport.
|
||||||
|
const stickyLabel = useMemo(() => {
|
||||||
|
if (headerOffsets.length === 0) return null
|
||||||
|
let current: string | null = null
|
||||||
|
for (const h of headerOffsets) {
|
||||||
|
if (h.offset <= scrollTop) current = h.label
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}, [headerOffsets, scrollTop])
|
||||||
|
|
||||||
// Measure container width on mount and resize.
|
// Measure container width on mount and resize.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const measureWidth = () => {
|
const measureWidth = () => {
|
||||||
@@ -267,6 +307,18 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
{/* Sticky group-header overlay. Lives outside the virtualizer's
|
||||||
|
* positioned children so it isn't affected by translateY transforms.
|
||||||
|
* Updates as the user scrolls past month boundaries. */}
|
||||||
|
{stickyLabel && (
|
||||||
|
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b border-border bg-bg/90 px-4 py-1 backdrop-blur-sm">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
|
{stickyLabel}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="h-full overflow-auto bg-bg"
|
className="h-full overflow-auto bg-bg"
|
||||||
@@ -343,5 +395,6 @@ export function Timeline() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
const heapId = sp.get('heap_id')
|
const heapId = sp.get('heap_id')
|
||||||
if (heapId) out.heapId = heapId
|
if (heapId) out.heapId = heapId
|
||||||
|
|
||||||
|
const folderId = sp.get('folder_id')
|
||||||
|
if (folderId) out.folderId = folderId
|
||||||
|
|
||||||
const sortBy = sp.get('sort')
|
const sortBy = sp.get('sort')
|
||||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||||
out.sortBy = sortBy as SortField
|
out.sortBy = sortBy as SortField
|
||||||
@@ -91,6 +94,7 @@ function writeUrl(f: FilterState) {
|
|||||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||||
|
if (f.folderId) sp.set('folder_id', f.folderId)
|
||||||
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function usePhotosQuery() {
|
|||||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
const heapId = useFilterStore((s) => s.heapId)
|
const heapId = useFilterStore((s) => s.heapId)
|
||||||
|
const folderId = useFilterStore((s) => s.folderId)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
|
|
||||||
@@ -33,10 +34,11 @@ export function usePhotosQuery() {
|
|||||||
colorLabel,
|
colorLabel,
|
||||||
flag,
|
flag,
|
||||||
heapId,
|
heapId,
|
||||||
|
folderId,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
}),
|
}),
|
||||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, sortBy, sortOrder]
|
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, sortBy, sortOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface FilterState {
|
|||||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||||
* on the heap store — that's the target for the T shortcut. */
|
* on the heap store — that's the target for the T shortcut. */
|
||||||
heapId: string | null
|
heapId: string | null
|
||||||
|
/** When set, restrict to photos in this folder. */
|
||||||
|
folderId: string | null
|
||||||
sortBy: SortField
|
sortBy: SortField
|
||||||
sortOrder: SortOrder
|
sortOrder: SortOrder
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ interface FilterStore extends FilterState {
|
|||||||
setColorLabel: (label: ColorLabel | null) => void
|
setColorLabel: (label: ColorLabel | null) => void
|
||||||
setFlag: (flag: FlagFilter) => void
|
setFlag: (flag: FlagFilter) => void
|
||||||
setHeapId: (id: string | null) => void
|
setHeapId: (id: string | null) => void
|
||||||
|
setFolderId: (id: string | null) => void
|
||||||
setSortBy: (field: SortField) => void
|
setSortBy: (field: SortField) => void
|
||||||
setSortOrder: (order: SortOrder) => void
|
setSortOrder: (order: SortOrder) => void
|
||||||
toggleSortOrder: () => void
|
toggleSortOrder: () => void
|
||||||
@@ -57,6 +60,7 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
colorLabel: null,
|
colorLabel: null,
|
||||||
flag: 'any',
|
flag: 'any',
|
||||||
heapId: null,
|
heapId: null,
|
||||||
|
folderId: null,
|
||||||
sortBy: 'taken_at',
|
sortBy: 'taken_at',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||||
setFlag: (flag) => set({ flag }),
|
setFlag: (flag) => set({ flag }),
|
||||||
setHeapId: (heapId) => set({ heapId }),
|
setHeapId: (heapId) => set({ heapId }),
|
||||||
|
setFolderId: (folderId) => set({ folderId }),
|
||||||
setSortBy: (sortBy) => set({ sortBy }),
|
setSortBy: (sortBy) => set({ sortBy }),
|
||||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||||
toggleSortOrder: () =>
|
toggleSortOrder: () =>
|
||||||
@@ -102,6 +107,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.colorLabel) params.color_label = f.colorLabel
|
if (f.colorLabel) params.color_label = f.colorLabel
|
||||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||||
if (f.heapId) params.heap_id = f.heapId
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
|
if (f.folderId) params.folder_id = f.folderId
|
||||||
params.sort = f.sortBy
|
params.sort = f.sortBy
|
||||||
params.order = f.sortOrder
|
params.order = f.sortOrder
|
||||||
return params
|
return params
|
||||||
@@ -117,6 +123,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.ratingMin > 0 ||
|
f.ratingMin > 0 ||
|
||||||
f.colorLabel !== null ||
|
f.colorLabel !== null ||
|
||||||
f.flag !== 'any' ||
|
f.flag !== 'any' ||
|
||||||
f.heapId !== null
|
f.heapId !== null ||
|
||||||
|
f.folderId !== null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user