Files
mule-image/frontend/src/components/heaps/HeapsPanel.tsx
dtoro e8e1adcf37 feat(auth): Authentik OIDC sign-in + Gravatar avatars
Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:06:32 +02:00

590 lines
23 KiB
TypeScript

import { useState } from 'react'
import {
ShoppingBasket,
Plus,
Target,
ChevronDown,
ChevronRight,
FolderOutput,
MoreHorizontal,
Pencil,
Copy,
Trash2,
Users,
Eye,
LogOut,
Download as DownloadIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import {
useSharedHeapsQuery,
SHARED_HEAPS_KEY,
} from '../../hooks/useSharingQueries'
import {
heaps as heapsApi,
downloads,
sharing as sharingApi,
type Heap,
} from '../../services/api'
import { Avatar } from '../sharing/Avatar'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { formatApiError } from '../../lib/apiError'
import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
* basket icon, lets the user create a new heap, click one to filter the
* timeline to its contents, set one as the "active" target for the T
* shortcut, and delete heaps.
*
* Heap state:
* - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row)
*/
export function HeapsPanel() {
const { data: heaps = [] } = useHeapsQuery()
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const [sharingHeap, setSharingHeap] = useState<Heap | null>(null)
const { data: sharedHeaps = [] } = useSharedHeapsQuery()
// Inline rename state for heap rows: stores the heap id being edited and
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
// Which heap's burger menu is currently open. Drives the trigger's
// hover-visible state via DropdownMenu's open prop — Radix handles
// outside-click + Escape dismissal internally.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
}
const createMutation = useMutation({
mutationFn: (name: string) => heapsApi.create(name),
onSuccess: () => {
invalidate()
setNewName('')
setCreating(false)
},
onError: (e: any) =>
toast.error('Failed to create heap', formatApiError(e)),
})
const setActiveMutation = useMutation({
mutationFn: (heapId: string) =>
heapsApi.update(heapId, { is_active: true }),
onSuccess: (heap) => {
invalidate()
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
},
onError: (e: any) =>
toast.error('Failed to set active', formatApiError(e)),
})
const deleteMutation = useMutation({
mutationFn: (heapId: string) => heapsApi.delete(heapId),
onSuccess: (_, heapId) => {
invalidate()
// If we were viewing this heap, snap back to all-photos.
if (currentSection === `heap-${heapId}`) {
navigateToSection('all-photos', {})
}
},
onError: (e: any) =>
toast.error('Failed to delete heap', formatApiError(e)),
})
const renameMutation = useMutation({
mutationFn: ({ heapId, name }: { heapId: string; name: string }) =>
heapsApi.update(heapId, { name }),
onSuccess: () => invalidate(),
onError: (e: any) =>
toast.error('Failed to rename heap', formatApiError(e)),
})
const duplicateMutation = useMutation({
mutationFn: (heapId: string) => heapsApi.duplicate(heapId),
onSuccess: (heap) => {
invalidate()
toast.success('Heap duplicated', heap.name)
},
onError: (e: any) =>
toast.error('Failed to duplicate heap', formatApiError(e)),
})
// Drop handler: add the dragged photos to the target heap. Optimistically
// updates the membership cache so the basket affordance flips immediately,
// mirroring the keyboard P-toggle pattern.
const dropMutation = useMutation({
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
heapsApi.addPhotos(heapId, photoIds),
onMutate: ({ heapId, photoIds }) => {
const key = ['heap-photo-ids', heapId] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
photoIds.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (e: any, vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
}
toast.error('Failed to add to heap', formatApiError(e))
},
onSuccess: (data, vars) => {
const heap = heaps.find((h) => h.id === vars.heapId)
const heapName = heap?.name ?? 'heap'
const added = data?.added ?? 0
const already = data?.already_present ?? 0
if (added > 0) {
toast.success(
`Added to ${heapName}`,
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
)
} else if (already > 0) {
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
}
},
onSettled: (_d, _e, vars) => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleCreate = () => {
const name = newName.trim()
if (!name) return
createMutation.mutate(name)
}
return (
<div>
{/* Section header — mirrors the Views/Folders section eyebrows
* in LeftSidebar so Heaps sits alongside them at the same
* visual tier, including the click-to-collapse chevron. */}
<div
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
role="button"
>
<button
className="rounded p-0.5 hover:bg-surface-offset"
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
aria-label="Toggle heaps"
>
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
<span className="flex-1 truncate">Heaps</span>
<button
onClick={(e) => {
e.stopPropagation()
setCreating(true)
setExpanded(true)
}}
className="invisible rounded p-0.5 hover:bg-surface-offset hover:text-text group-hover:visible"
title="New heap"
>
<Plus className="h-3 w-3" />
</button>
</div>
{expanded && (
<div>
{/* Inline create form */}
{creating && (
<div
className="flex items-center gap-1 px-2 py-0.5"
style={{ paddingLeft: '20px' }}
>
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<Input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') {
setCreating(false)
setNewName('')
}
}}
placeholder="Heap name"
className="h-6 flex-1 bg-bg px-2 text-[11px]"
/>
<Button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
size="sm"
className="h-6 px-2 text-[11px]"
>
Add
</Button>
</div>
)}
{heaps.length === 0 && !creating && (
<div
className="flex items-center gap-1 px-2 py-0.5 text-[11px] text-text-faint"
style={{ paddingLeft: '20px' }}
>
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span>No heaps yet</span>
</div>
)}
{heaps.map((heap) => {
const isFiltered = currentSection === `heap-${heap.id}`
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
const isRenaming = renamingId === heap.id
const isMenuOpen = openMenuId === heap.id
const commitRename = () => {
const next = renameDraft.trim()
if (next && next !== heap.name) {
renameMutation.mutate({ heapId: heap.id, name: next })
}
setRenamingId(null)
}
return (
<div
key={heap.id}
className={cn(
'group relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
// Active-heap row gets a soft primary wash so the
// user always knows where Select / T will land, even
// when viewing a different section.
isActive && !isFiltered && 'bg-primary/8 text-text',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '20px' }}
onClick={() => {
if (isRenaming) return
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
}}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === heap.id) setDropTargetId(null)
}
}}
onDrop={(e) => {
e.preventDefault()
setDropTargetId(null)
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
if (!raw) return
try {
const ids = JSON.parse(raw) as string[]
if (Array.isArray(ids) && ids.length > 0) {
dropMutation.mutate({ heapId: heap.id, photoIds: ids })
}
} catch {
// Bad payload — ignore.
}
}}
>
{/* Chevron-slot spacer — heap rows don't expand, but this
* reserves the same width that leaf folder rows use so
* icons and labels line up across the two hierarchies. */}
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<ShoppingBasket
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
{isRenaming ? (
<Input
autoFocus
type="text"
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={commitRename}
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('truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
</span>
{isActive && (
<span
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
title="Active heap — Select (S) and the basket badge on photos point here"
>
Active
</span>
)}
<span className="flex-1" />
</>
)}
{/* Right cluster. Count is the rightmost element in the
* resting state — set-active and kebab use display:none
* (not invisible) so they reserve no width until hover,
* keeping the count column aligned with the rest of the
* sidebar. The active heap is signaled by font-semibold
* on the name above; the standalone Target indicator
* was making heap counts sit left of the others. */}
{heap.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">
{heap.photo_count}
</span>
) : (
<span className="h-4 min-w-[20px] flex-shrink-0" aria-hidden="true" />
)}
{!isActive && (
<button
onClick={(e) => {
e.stopPropagation()
setActiveMutation.mutate(heap.id)
}}
className="hidden flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:block"
title="Set as active heap (T target)"
aria-label="Set as active heap"
>
<Target className="h-3 w-3" />
</button>
)}
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? heap.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 heap actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => duplicateMutation.mutate(heap.id)}
>
<Copy className="h-3.5 w-3.5 text-text-muted" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConvertingHeap(heap)}>
<FolderOutput className="h-3.5 w-3.5 text-text-muted" />
Move to folder
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingHeap(heap)}>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.heapUrl(heap.id))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
)}
{/* Shared with me — mirrors the folder treatment in LeftSidebar:
* owner avatar + Eye/Pencil permission icon + right-click
* context menu with Open / Leave. */}
{sharedHeaps.length > 0 && expanded && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-text-faint">
Shared with me
</div>
{sharedHeaps.map((sh) => {
const isFiltered = currentSection === `heap-${sh.id}`
const PermissionIcon = sh.permission === 'write' ? Pencil : Eye
return (
<ContextMenu key={sh.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group flex h-[24px] cursor-pointer items-center gap-1.5 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
style={{ paddingLeft: '20px' }}
onClick={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<Avatar
name={sh.owner_username}
imageUrl={sh.owner_avatar_url}
size="xs"
/>
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_display_name || sh.owner_username})`}>
{sh.name}
</span>
<PermissionIcon
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
aria-label={sh.permission === 'write' ? 'Can edit' : 'Can view'}
/>
{sh.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">
{sh.photo_count}
</span>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<ShoppingBasket 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.revokeHeapShare(sh.id, sh.share_id)
queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY })
toast.success(`Left ${sh.name}`)
} catch (err) {
toast.error('Could not leave', formatApiError(err))
}
}}
>
<LogOut className="h-3.5 w-3.5" />
Leave
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
<ShareDialog
isOpen={!!sharingHeap}
type="heap"
targetId={sharingHeap?.id ?? ''}
targetName={sharingHeap?.name ?? ''}
onClose={() => setSharingHeap(null)}
/>
</div>
)
}