Shares used to activate instantly on the owner's side with no notice to
the recipient. Introduce a pending/accepted lifecycle so a recipient
gets a bell notification on login and explicitly Accept or Decline
before the shared item lands in their sidebar.
Backend
- Migration 0014 adds `status` + `accepted_at` to heap_shares and
folder_shares; pre-existing rows are backfilled to 'accepted' so
nothing disappears from anyone's current sidebar. One-migration trick:
server_default 'accepted' during add_column, then strip so new inserts
fall through to the Python model default 'pending'.
- New recipient-only endpoints: POST /sharing/{heaps|folders}/{id}/accept
(idempotent) and /decline (hard delete, so re-invites are clean).
- New GET /sharing/pending returning {heaps, folders} of outstanding
invites with target_name + owner_username + permission.
- list_shared_{heaps,folders} now filter to status='accepted' and carry
share_id so the recipient can Leave without a second lookup.
- ShareResponse exposes status so the owner sees pending invites.
Frontend
- NotificationBell lives in the LeftSidebar user row: a Popover
triggered by Bell with a count badge. Each row shows owner avatar,
"{owner} shared {heap|folder} {name}" with a permission subtitle,
and Accept / Decline inline. Polls /sharing/pending every 60s.
- Shared Avatar helper extracted to sharing/Avatar.tsx — used by
ShareDialog, NotificationBell, and the sidebar shared rows so one
user's identity colour is stable everywhere.
- Sidebar shared-row polish: owner avatar bubble + Eye/Pencil
permission icon (was uppercase pill). Right-click opens a context
menu with Open / Leave; Leave calls the existing recipient-revoke
DELETE and invalidates the shared-{heaps,folders} query.
- ShareDialog shows an amber "Invited" pill next to pending recipients.
- New shadcn context-menu primitive (radix dep already installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1044 lines
41 KiB
TypeScript
1044 lines
41 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
FolderPlus,
|
|
Image,
|
|
Star,
|
|
Trash2,
|
|
HardDrive,
|
|
Copy,
|
|
Tag as TagIcon,
|
|
Palette,
|
|
MapPin,
|
|
Layers2,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Settings,
|
|
Users,
|
|
Eye,
|
|
EyeOff,
|
|
User as UserIcon,
|
|
LogOut,
|
|
Shield,
|
|
Clock,
|
|
Upload as UploadIcon,
|
|
Download as DownloadIcon,
|
|
} from 'lucide-react'
|
|
import { DateRangePicker } from '../filter/DateRangePicker'
|
|
import { Footer } from './Footer'
|
|
import { cn } from '@/lib/utils'
|
|
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from '../ToastContainer'
|
|
import { useFilterStore } from '../../store/filterStore'
|
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
|
import { usePhotoStore } from '../../store/photoStore'
|
|
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
|
import {
|
|
useLibraryStatsQuery,
|
|
LIBRARY_STATS_QUERY_KEY,
|
|
} from '../../hooks/useLibraryStatsQuery'
|
|
import { registerUndoable } from '../../store/undoStore'
|
|
import { formatApiError } from '../../lib/apiError'
|
|
import type { Photo } from '../../types/photo'
|
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
|
import { ShareDialog } from '../sharing/ShareDialog'
|
|
import { UploadModal } from '../upload/UploadModal'
|
|
import { sharing as sharingApi } from '../../services/api'
|
|
import {
|
|
useSharedFoldersQuery,
|
|
SHARED_FOLDERS_KEY,
|
|
} from '../../hooks/useSharingQueries'
|
|
import { NotificationBell } from '../sharing/NotificationBell'
|
|
import { Avatar } from '../sharing/Avatar'
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu'
|
|
import { useAuth } from '../../contexts/AuthContext'
|
|
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
|
|
import { useScanActivity } from '../../hooks/useScanActivity'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
|
|
interface TreeItem {
|
|
id: string
|
|
label: string
|
|
icon?: React.ReactNode
|
|
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
|
|
/** For folder rows only: filesystem path. Used to match against the
|
|
* scan-status `current_folder` so we can show an inline spinner on
|
|
* the row that's actively being scanned. */
|
|
path?: string
|
|
}
|
|
|
|
export function LeftSidebar() {
|
|
const { user, isAdmin, logout } = useAuth()
|
|
const scanActivity = useScanActivity()
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
|
// Library pane collapse. Its body holds Views, Folders, Shared, and
|
|
// Heaps, so a single toggle hides the whole navigation area.
|
|
// Inline rename state for source-root rows. Stores the id being edited
|
|
// and the draft name. Double-click a folder row to start.
|
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameDraft, setRenameDraft] = useState('')
|
|
|
|
const queryClient = useQueryClient()
|
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
|
const currentSection = useFilterStore((s) => s.currentSection)
|
|
const { data: allTags = [] } = useTagsQuery()
|
|
const { data: stats } = useLibraryStatsQuery()
|
|
const { data: featuresMap } = useFeaturesQuery()
|
|
const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true
|
|
const tagsOn = true
|
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
|
|
|
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
|
|
// or "folders" for the section header). Radix DropdownMenu handles
|
|
// outside-click + Escape dismissal for us — we only track which row's
|
|
// menu is open so the trigger stays visible while it's open (the
|
|
// trigger is hover-hidden by default on non-hovered rows).
|
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
|
|
|
// "Create new folder under {parent}" inline state. parentId is the
|
|
// Folder.id (no "folder-" prefix).
|
|
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
|
|
const [createDraft, setCreateDraft] = useState('')
|
|
|
|
// Folder being deleted, drives the DeleteFolderDialog mounted below.
|
|
const [deletingFolder, setDeletingFolder] = useState<{
|
|
id: string
|
|
name: string
|
|
photoCount?: number
|
|
} | null>(null)
|
|
const [sharingFolder, setSharingFolder] = useState<{
|
|
id: string
|
|
name: string
|
|
} | null>(null)
|
|
// Upload modal state. `uploadTarget` stores the pre-selected
|
|
// destination Folder/SourceRoot id so "Upload here…" on a folder row
|
|
// drops files straight into that folder; null means the general
|
|
// Library-level button (defaults to the first source root).
|
|
const [uploadTarget, setUploadTarget] = useState<{ open: boolean; folderId: string | null }>({
|
|
open: false,
|
|
folderId: null,
|
|
})
|
|
const { data: sharedFolders = [] } = useSharedFoldersQuery()
|
|
|
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
|
const discardDropMutation = useMutation({
|
|
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
|
// Optimistically pull the dropped photos out of the timeline so the
|
|
// grid reflows the moment the drop lands, instead of waiting for
|
|
// the network round-trip + invalidation refetch.
|
|
onMutate: (photoIds) => {
|
|
usePhotoStore.getState().removePhotosFromTimeline(photoIds)
|
|
stripPhotosFromCache(queryClient, photoIds)
|
|
},
|
|
onSuccess: (_data, photoIds) => {
|
|
registerUndoable(
|
|
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
|
|
async () => {
|
|
await photosApi.bulkRestore(photoIds)
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
|
}
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Discard failed', formatApiError(e)),
|
|
})
|
|
|
|
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
|
// captures each photo's source folder before issuing the move so the
|
|
// undo path can put them back exactly where they came from (different
|
|
// sources end up in different undo subgroups).
|
|
const moveDropMutation = useMutation({
|
|
mutationFn: async ({
|
|
targetId,
|
|
photoIds,
|
|
}: {
|
|
targetId: string
|
|
photoIds: string[]
|
|
}) => {
|
|
// Snapshot per-photo source folder ids from the photos cache. We
|
|
// walk every cached ['photos', ...] entry because the user could
|
|
// be in any section / filter combination, and we don't know the
|
|
// exact key offhand.
|
|
const sourceMap = new Map<string, string>()
|
|
const photoCaches = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
|
|
for (const [, list] of photoCaches) {
|
|
if (!list) continue
|
|
for (const p of list) {
|
|
if (photoIds.includes(p.id) && p.folder_id && !sourceMap.has(p.id)) {
|
|
sourceMap.set(p.id, p.folder_id)
|
|
}
|
|
}
|
|
}
|
|
const result = await photosApi.move(photoIds, targetId)
|
|
return { result, sourceMap }
|
|
},
|
|
onSuccess: ({ result, sourceMap }) => {
|
|
const moved = result?.moved ?? 0
|
|
const errCount = result?.errors?.length ?? 0
|
|
if (moved > 0) {
|
|
// Group photos by their source folder so we can issue one move
|
|
// call per group when undoing. Photos whose source folder we
|
|
// couldn't recover get dropped from the undo (they'll just stay
|
|
// where the move put them).
|
|
const groups = new Map<string, string[]>()
|
|
for (const [photoId, src] of sourceMap.entries()) {
|
|
const arr = groups.get(src) ?? []
|
|
arr.push(photoId)
|
|
groups.set(src, arr)
|
|
}
|
|
if (groups.size > 0) {
|
|
registerUndoable(
|
|
`Moved ${moved} photo${moved === 1 ? '' : 's'}`,
|
|
async () => {
|
|
for (const [src, ids] of groups.entries()) {
|
|
await photosApi.move(ids, src)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
}
|
|
)
|
|
} else {
|
|
toast.success(
|
|
'Moved',
|
|
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
|
)
|
|
}
|
|
} else if (errCount > 0) {
|
|
toast.error(
|
|
'Move failed',
|
|
`${errCount} file${errCount > 1 ? 's' : ''} could not be moved`
|
|
)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Move failed', formatApiError(e)),
|
|
})
|
|
|
|
// Bulk copy mutation — Alt-drag uses this instead of move.
|
|
const copyDropMutation = useMutation({
|
|
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
|
|
photosApi.copy(photoIds, targetId),
|
|
onSuccess: (data) => {
|
|
const copied = data?.copied ?? 0
|
|
const errCount = data?.errors?.length ?? 0
|
|
if (copied > 0) {
|
|
toast.success(
|
|
'Copied',
|
|
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
|
)
|
|
} else if (errCount > 0) {
|
|
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Copy failed', formatApiError(e)),
|
|
})
|
|
|
|
// Reads the dragged ids out of a drop event payload.
|
|
const readDragIds = (e: React.DragEvent): string[] | null => {
|
|
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
|
if (!raw) return null
|
|
try {
|
|
const parsed = JSON.parse(raw) as string[]
|
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Map a library tree id to a section navigation. Each "virtual node" in
|
|
// the library tree is its own section, with its own remembered filter
|
|
// state. The preset is the section's intrinsic filter (the thing that
|
|
// makes it that section); user-added filters from the FilterBar layer
|
|
// on top and are saved when the user navigates away.
|
|
const applyLibraryNode = (id: string) => {
|
|
switch (id) {
|
|
case 'all-photos':
|
|
navigateToSection('all-photos', {})
|
|
break
|
|
case 'rated':
|
|
navigateToSection('rated', { ratingMin: 1, groupBy: 'rating' })
|
|
break
|
|
case 'discarded':
|
|
navigateToSection('discarded', { flag: 'discarded' })
|
|
break
|
|
case 'duplicates':
|
|
navigateToSection('duplicates', { duplicates: true })
|
|
break
|
|
case 'tags':
|
|
navigateToSection('tags', { groupBy: 'tag' })
|
|
break
|
|
case 'needs-review':
|
|
navigateToSection('needs-review', { needsReview: true })
|
|
break
|
|
case 'colors':
|
|
navigateToSection('colors', { groupBy: 'color' })
|
|
break
|
|
case 'map':
|
|
navigateToSection('map', {})
|
|
break
|
|
case 'memories':
|
|
navigateToSection('memories', {})
|
|
break
|
|
default:
|
|
if (id.startsWith('folder-')) {
|
|
const folderId = id.slice('folder-'.length)
|
|
navigateToSection(`folder-${folderId}`, { folderId })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch the recursive folder tree (one root per active source root).
|
|
const { data: folderTree = [] } = useFolderTreeQuery()
|
|
|
|
const renameMutation = useMutation({
|
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
|
sourceFolders.rename(id, name),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Rename failed', formatApiError(e)),
|
|
})
|
|
|
|
const createFolderMutation = useMutation({
|
|
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
|
|
sourceFolders.create(parentId, name),
|
|
onSuccess: (data) => {
|
|
toast.success('Folder created', data.name)
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
setCreatingUnder(null)
|
|
setCreateDraft('')
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Create failed', formatApiError(e)),
|
|
})
|
|
|
|
// 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', formatApiError(e)),
|
|
})
|
|
|
|
const deleteFolderMutation = useMutation({
|
|
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
|
|
sourceFolders.delete(id, mode),
|
|
onSuccess: (data) => {
|
|
if (data.mode === 'discard') {
|
|
toast.success(
|
|
'Folder photos discarded',
|
|
`${data.discarded ?? 0} moved to discard pile`
|
|
)
|
|
} else {
|
|
toast.success(
|
|
'Folder deleted',
|
|
`${data.deleted_photos ?? 0} photos removed from disk`
|
|
)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
|
// If we were viewing the deleted folder, snap back to all-photos.
|
|
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
|
|
navigateToSection('all-photos', {})
|
|
}
|
|
setDeletingFolder(null)
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Delete failed', formatApiError(e)),
|
|
})
|
|
|
|
const toggleExpanded = (id: string) => {
|
|
const newExpanded = new Set(expandedItems)
|
|
if (newExpanded.has(id)) {
|
|
newExpanded.delete(id)
|
|
} else {
|
|
newExpanded.add(id)
|
|
}
|
|
setExpandedItems(newExpanded)
|
|
}
|
|
|
|
// Recursively map a backend FolderTreeNode into our generic TreeItem.
|
|
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
|
|
id: `folder-${node.id}`,
|
|
label: node.name,
|
|
icon: <Folder className="h-4 w-4" />,
|
|
count: node.photo_count,
|
|
type: 'folder',
|
|
isHidden: node.is_hidden,
|
|
path: node.path,
|
|
children: node.children.length > 0
|
|
? node.children.map(folderNodeToTreeItem)
|
|
: undefined,
|
|
})
|
|
|
|
// Total tag count for the badge on the Tags entry (user tags only).
|
|
const userTags = allTags.filter((t) => t.kind === 'user')
|
|
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
|
|
|
|
const libraryTree: TreeItem[] = [
|
|
{
|
|
id: 'library',
|
|
label: 'Views',
|
|
icon: <Layers2 className="h-4 w-4" />,
|
|
children: [
|
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
|
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
|
|
...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }] : []),
|
|
...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: <Users className="h-4 w-4" />, count: stats?.needs_review ?? 0 }] : []),
|
|
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
|
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
|
|
{ id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },
|
|
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
|
],
|
|
},
|
|
{
|
|
id: 'folders',
|
|
label: 'Folders',
|
|
icon: <HardDrive className="h-4 w-4" />,
|
|
children: folderTree.map(folderNodeToTreeItem),
|
|
},
|
|
]
|
|
|
|
// 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.
|
|
// Active highlight is now driven entirely by currentSection. Each
|
|
// library node and folder row maps 1:1 to a section id.
|
|
const isItemActive = (id: string): boolean => {
|
|
if (id.startsWith('folder-')) {
|
|
return currentSection === id
|
|
}
|
|
return currentSection === id
|
|
}
|
|
|
|
// Which tree items accept photo drops, and what each does on drop.
|
|
const isDropTarget = (id: string): boolean => {
|
|
return id === 'discarded' || id.startsWith('folder-')
|
|
}
|
|
|
|
const handleDrop = (id: string, ids: string[], copy: boolean) => {
|
|
if (id === 'discarded') {
|
|
discardDropMutation.mutate(ids)
|
|
return
|
|
}
|
|
if (id.startsWith('folder-')) {
|
|
const targetId = id.slice('folder-'.length)
|
|
if (copy) {
|
|
copyDropMutation.mutate({ targetId, photoIds: ids })
|
|
} else {
|
|
moveDropMutation.mutate({ targetId, photoIds: ids })
|
|
}
|
|
}
|
|
}
|
|
|
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
|
const hasChildren = item.children && item.children.length > 0
|
|
const isExpanded = expandedItems.has(item.id)
|
|
const isSelected = isItemActive(item.id)
|
|
const acceptsDrop = isDropTarget(item.id)
|
|
const isDropHover = dropTargetId === item.id
|
|
// Top-level entries (Views, Folders) render as small uppercase
|
|
// section eyebrows rather than another tree row, so the panel reads
|
|
// as distinct sections with the actual items beneath them.
|
|
const isSectionHeader = depth === 0
|
|
|
|
return (
|
|
<div key={item.id}>
|
|
<div
|
|
className={cn(
|
|
'group flex cursor-pointer items-center gap-1',
|
|
isSectionHeader
|
|
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
|
|
: cn(
|
|
// Fixed h-[24px] (not min-h) locks the row height so the
|
|
// hover-only kebab button can't grow the row vertically.
|
|
'h-[24px] rounded px-2 text-[12px] leading-none',
|
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2'
|
|
),
|
|
isDropHover && (item.id === 'discarded'
|
|
? 'ring-2 ring-reject bg-reject/10'
|
|
: 'ring-2 ring-primary bg-primary/10')
|
|
)}
|
|
style={
|
|
isSectionHeader
|
|
? undefined
|
|
: { paddingLeft: `${depth * 20}px` }
|
|
}
|
|
onClick={() => {
|
|
if (renamingId === item.id) return
|
|
// Folder rows are always filterable, parent or leaf — clicking
|
|
// anywhere on the row applies the filter and the chevron
|
|
// (separate button below) handles expansion. Other group
|
|
// headers (Library, Folders) just toggle expansion since
|
|
// they have no associated section.
|
|
if (item.id.startsWith('folder-')) {
|
|
applyLibraryNode(item.id)
|
|
} else if (hasChildren) {
|
|
toggleExpanded(item.id)
|
|
} else {
|
|
applyLibraryNode(item.id)
|
|
}
|
|
}}
|
|
onDoubleClick={
|
|
item.id.startsWith('folder-')
|
|
? (e) => {
|
|
e.stopPropagation()
|
|
setRenamingId(item.id)
|
|
setRenameDraft(item.label)
|
|
}
|
|
: undefined
|
|
}
|
|
onDragOver={acceptsDrop ? (e) => {
|
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
|
e.preventDefault()
|
|
// Alt held → copy (only meaningful for folder targets;
|
|
// discarding doesn't copy).
|
|
const wantCopy = e.altKey && item.id.startsWith('folder-')
|
|
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
|
|
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
|
}
|
|
} : undefined}
|
|
onDragLeave={acceptsDrop ? (e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
if (dropTargetId === item.id) setDropTargetId(null)
|
|
}
|
|
} : undefined}
|
|
onDrop={acceptsDrop ? (e) => {
|
|
e.preventDefault()
|
|
setDropTargetId(null)
|
|
const ids = readDragIds(e)
|
|
if (ids) handleDrop(item.id, ids, e.altKey)
|
|
} : undefined}
|
|
>
|
|
{/* Expand/Collapse Icon */}
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleExpanded(item.id)
|
|
}}
|
|
className="rounded p-0.5 hover:bg-surface-offset"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3 w-3" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
!isSectionHeader && <div className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
|
|
{/* Item Icon — section headers drop their icon in favor of the
|
|
* 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={cn(
|
|
'flex-shrink-0',
|
|
isSelected
|
|
? 'text-primary'
|
|
: item.isHidden
|
|
? 'text-text-muted/60'
|
|
: 'text-text-muted'
|
|
)}
|
|
>
|
|
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">
|
|
{item.isHidden ? <EyeOff className="h-4 w-4" /> : item.icon}
|
|
</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* Label (or inline rename input for folder rows) */}
|
|
{renamingId === item.id ? (
|
|
<Input
|
|
autoFocus
|
|
type="text"
|
|
value={renameDraft}
|
|
onChange={(e) => setRenameDraft(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onBlur={() => {
|
|
const next = renameDraft.trim()
|
|
const id = item.id.slice('folder-'.length)
|
|
if (next && next !== item.label) {
|
|
renameMutation.mutate({ id, name: next })
|
|
}
|
|
setRenamingId(null)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.currentTarget.blur()
|
|
} else if (e.key === 'Escape') {
|
|
setRenamingId(null)
|
|
}
|
|
}}
|
|
className="h-6 flex-1 bg-bg px-1 text-[13px]"
|
|
/>
|
|
) : (
|
|
<span
|
|
className={cn(
|
|
'flex-1 truncate',
|
|
item.isHidden && !isSelected && 'italic text-text-muted/80'
|
|
)}
|
|
title={item.isHidden ? `${item.label} — hidden from views` : undefined}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
)}
|
|
|
|
{/* Activity spinner — shown on the FOLDERS section header
|
|
* whenever any background scan/processing is happening, and
|
|
* on a folder row whose path is the one currently being
|
|
* scanned. Replaces the old bottom-right ScanProgress popup. */}
|
|
{(() => {
|
|
const showOnFolders =
|
|
isSectionHeader && item.id === 'folders' && scanActivity.active
|
|
const showOnFolderRow =
|
|
scanActivity.isScanning &&
|
|
!!item.path &&
|
|
!!scanActivity.currentFolder &&
|
|
scanActivity.currentFolder.startsWith(item.path)
|
|
if (!showOnFolders && !showOnFolderRow) return null
|
|
return (
|
|
<span
|
|
className="ml-1 inline-block h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border border-primary/30 border-t-primary"
|
|
aria-label="Background activity in progress"
|
|
/>
|
|
)
|
|
})()}
|
|
|
|
{/* Count Badge — fixed-width slot so counts line up in a column
|
|
* across rows regardless of digit count. Section headers skip
|
|
* the badge entirely (they're labels, not navigable rows). */}
|
|
{!isSectionHeader && (
|
|
item.count !== undefined && item.count > 0 ? (
|
|
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
|
{item.count}
|
|
</span>
|
|
) : (
|
|
<span className="h-4 min-w-[20px] flex-shrink-0" aria-hidden="true" />
|
|
)
|
|
)}
|
|
|
|
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
|
|
* until hover so it reserves NO width in the resting state and
|
|
* the count column stays aligned across folder + non-folder
|
|
* rows. On hover it appears to the right, pushing the count
|
|
* left to make room. */}
|
|
{item.id.startsWith('folder-') &&
|
|
(() => {
|
|
const folderId = item.id.slice('folder-'.length)
|
|
const isMenuOpen = openMenuId === item.id
|
|
return (
|
|
<DropdownMenu
|
|
open={isMenuOpen}
|
|
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'relative flex-shrink-0',
|
|
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
|
)}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
title="More actions"
|
|
aria-label="More folder actions"
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
</div>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="min-w-[180px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setUploadTarget({ open: true, folderId })
|
|
}
|
|
>
|
|
<UploadIcon className="h-3.5 w-3.5 text-text-muted" />
|
|
Upload here…
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setCreatingUnder(folderId)
|
|
setCreateDraft('')
|
|
if (!expandedItems.has(item.id)) {
|
|
toggleExpanded(item.id)
|
|
}
|
|
}}
|
|
>
|
|
<FolderPlus className="h-3.5 w-3.5 text-text-muted" />
|
|
New sub-folder
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setRenamingId(item.id)
|
|
setRenameDraft(item.label)
|
|
}}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5 text-text-muted" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
toggleHiddenMutation.mutate({
|
|
id: folderId,
|
|
hidden: !item.isHidden,
|
|
})
|
|
}
|
|
>
|
|
{item.isHidden ? (
|
|
<Eye className="h-3.5 w-3.5 text-text-muted" />
|
|
) : (
|
|
<EyeOff className="h-3.5 w-3.5 text-text-muted" />
|
|
)}
|
|
{item.isHidden ? 'Show in views' : 'Hide from views'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setSharingFolder({ id: folderId, name: item.label })
|
|
}
|
|
>
|
|
<Users className="h-3.5 w-3.5 text-text-muted" />
|
|
Share…
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
downloads.trigger(downloads.folderUrl(folderId))
|
|
}
|
|
>
|
|
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
|
|
Download as zip
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setDeletingFolder({
|
|
id: folderId,
|
|
name: item.label,
|
|
photoCount: item.count,
|
|
})
|
|
}
|
|
className="text-reject focus:bg-reject/10 focus:text-reject"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete folder…
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
})()}
|
|
|
|
</div>
|
|
|
|
{/* Inline "create new sub-folder" input. Renders just below the
|
|
* parent row when its create state is active. */}
|
|
{item.id.startsWith('folder-') &&
|
|
creatingUnder === item.id.slice('folder-'.length) && (
|
|
<div
|
|
className="flex items-center gap-1 px-2 py-1"
|
|
style={{ paddingLeft: `${(depth + 1) * 20}px` }}
|
|
>
|
|
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={createDraft}
|
|
placeholder="New folder name"
|
|
onChange={(e) => setCreateDraft(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const name = createDraft.trim()
|
|
if (name) {
|
|
createFolderMutation.mutate({
|
|
parentId: item.id.slice('folder-'.length),
|
|
name,
|
|
})
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setCreatingUnder(null)
|
|
setCreateDraft('')
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
// Don't auto-commit on blur — empty/escaped renames
|
|
// close the input but don't fire the request.
|
|
if (!createFolderMutation.isPending) {
|
|
setCreatingUnder(null)
|
|
setCreateDraft('')
|
|
}
|
|
}}
|
|
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Render Children */}
|
|
{hasChildren && isExpanded && (
|
|
<div>
|
|
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-surface">
|
|
{/* Date range calendar — always visible, collapsible. The
|
|
* from/to range inputs live on the top filter bar; this block
|
|
* just hosts the calendar visualisation and click-to-jump. */}
|
|
<DateFilterSection />
|
|
|
|
{/* Library — the sole navigation section. Contains Views,
|
|
* Folders, Shared-with-me, and Heaps in a single scroll area. */}
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="group flex h-9 flex-shrink-0 items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
|
<span className="flex-1 truncate">Library</span>
|
|
<button
|
|
onClick={() => setUploadTarget({ open: true, folderId: null })}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Upload photos"
|
|
aria-label="Upload photos"
|
|
>
|
|
<UploadIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
|
{libraryTree.map((item) => renderTreeItem(item))}
|
|
|
|
{/* Shared with me — folders shared by other users. Each row
|
|
* shows the owner's avatar bubble (same hash-tinted palette
|
|
* as ShareDialog + the notification bell) and an Eye/Pencil
|
|
* icon for permission, so the vocabulary stays consistent
|
|
* across every sharing surface. */}
|
|
{sharedFolders.length > 0 && (
|
|
<div className="mt-1">
|
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
|
Shared with me
|
|
</div>
|
|
{sharedFolders.map((sf) => {
|
|
const isSelected = currentSection === `folder-${sf.id}`
|
|
const PermissionIcon = sf.permission === 'write' ? Pencil : Eye
|
|
return (
|
|
<ContextMenu key={sf.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
'flex h-[24px] cursor-pointer items-center gap-1.5 rounded px-2 text-[12px] leading-none',
|
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
)}
|
|
style={{ paddingLeft: '20px' }}
|
|
onClick={() =>
|
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
|
}
|
|
>
|
|
<Avatar name={sf.owner_username} size="xs" />
|
|
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
|
{sf.name}
|
|
</span>
|
|
<PermissionIcon
|
|
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
|
|
aria-label={sf.permission === 'write' ? 'Can edit' : 'Can view'}
|
|
/>
|
|
{sf.photo_count > 0 && (
|
|
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
|
{sf.photo_count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
onSelect={() =>
|
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
|
}
|
|
>
|
|
<Folder className="h-3.5 w-3.5 text-text-muted" />
|
|
Open
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
className="text-reject focus:bg-reject/10 focus:text-reject"
|
|
onSelect={async () => {
|
|
try {
|
|
await sharingApi.revokeFolderShare(sf.id, sf.share_id)
|
|
queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY })
|
|
toast.success(`Left ${sf.name}`)
|
|
} catch (err) {
|
|
toast.error('Could not leave', formatApiError(err))
|
|
}
|
|
}}
|
|
>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
Leave
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Heaps — nested inside the Library section. */}
|
|
<HeapsPanel />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
|
<div className="border-t border-border p-1.5 space-y-0.5">
|
|
{/* User row */}
|
|
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
|
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span className="flex-1 truncate text-text">{user?.username}</span>
|
|
{isAdmin && (
|
|
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
|
|
<Shield className="inline h-2.5 w-2.5" />
|
|
</span>
|
|
)}
|
|
{/* Share-invite bell — click opens a popover listing any
|
|
* pending invites this user has. Sits here (rather than the
|
|
* TopBar) per the user's preference to keep the identity
|
|
* controls grouped. */}
|
|
<NotificationBell />
|
|
<button
|
|
onClick={logout}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
|
|
title="Sign out"
|
|
>
|
|
<LogOut className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Settings — admin only, navigates to the settings section */}
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => navigateToSection('settings', {})}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
|
|
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
|
|
)}
|
|
title="Settings"
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
Settings
|
|
</button>
|
|
)}
|
|
|
|
<Footer />
|
|
</div>
|
|
|
|
<DeleteFolderDialog
|
|
isOpen={!!deletingFolder}
|
|
folderName={deletingFolder?.name ?? ''}
|
|
photoCount={deletingFolder?.photoCount}
|
|
onClose={() => setDeletingFolder(null)}
|
|
onConfirm={(mode) => {
|
|
if (deletingFolder) {
|
|
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
|
|
}
|
|
}}
|
|
/>
|
|
<ShareDialog
|
|
isOpen={!!sharingFolder}
|
|
type="folder"
|
|
targetId={sharingFolder?.id ?? ''}
|
|
targetName={sharingFolder?.name ?? ''}
|
|
onClose={() => setSharingFolder(null)}
|
|
/>
|
|
<UploadModal
|
|
isOpen={uploadTarget.open}
|
|
initialFolderId={uploadTarget.folderId}
|
|
onClose={() => setUploadTarget({ open: false, folderId: null })}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Always-visible calendar block at the top of the left sidebar.
|
|
* Drives the same global dateFrom/dateTo filter-store fields as the
|
|
* topbar Date pill, and visualises photo density per day. */
|
|
function DateFilterSection() {
|
|
const dateFrom = useFilterStore((s) => s.dateFrom)
|
|
const dateTo = useFilterStore((s) => s.dateTo)
|
|
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
|
const setDateTo = useFilterStore((s) => s.setDateTo)
|
|
return (
|
|
<div className="flex-shrink-0 border-b border-border px-2 py-2">
|
|
<DateRangePicker
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onFromChange={setDateFrom}
|
|
onToChange={setDateTo}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|