diff --git a/frontend/src/components/heaps/ActiveHeapCard.tsx b/frontend/src/components/heaps/ActiveHeapCard.tsx deleted file mode 100644 index 4cb6149..0000000 --- a/frontend/src/components/heaps/ActiveHeapCard.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { motion, AnimatePresence } from 'framer-motion' -import { ShoppingBasket } from 'lucide-react' -import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' -import { useFilterStore } from '../../store/filterStore' -import { photos as photosApi, heaps as heapsApi } from '../../services/api' -import cardBg from '../../assets/card.png' - -/** How many thumbnails fan out across the stack at once. The newest is - * drawn last (top), older ones fan back-left and back-right. */ -const STACK_SIZE = 5 - -/** - * Pinned card at the bottom of the LeftSidebar showing the currently - * active heap. Renders nothing when no heap is active — the parent - * layout collapses around it cleanly. - * - * The card is a fast nav shortcut + a satisfying landing spot for the P - * pick action: every new pick optimistically updates the - * ['heap-photo-ids', heapId] cache that the existing pick mutation - * already maintains, so we just subscribe to the same query and let - * framer-motion's AnimatePresence handle the entrance/exit animation - * when ids appear or disappear. - */ -export function ActiveHeapCard() { - const { activeHeap } = useActiveHeapMembers() - const navigateToSection = useFilterStore((s) => s.navigateToSection) - - // Subscribes to the same cache key the pick mutation optimistically - // updates. The data is already fetched (and kept fresh) by - // useActiveHeapMembers above; this useQuery just gives us a render- - // dependency on the array contents and stable insertion order. - const { data: orderedIds = [] } = useQuery({ - queryKey: ['heap-photo-ids', activeHeap?.id], - queryFn: () => heapsApi.photoIds(activeHeap!.id), - enabled: !!activeHeap, - staleTime: 30_000, - }) - - if (!activeHeap) return null - - // Show the latest STACK_SIZE photos. The backend returns ids in - // insertion order so the last one in the array is the most recently - // picked — that's the one we want at the front of the stack. - const visible = orderedIds.slice(-STACK_SIZE) - // We render newest LAST so it draws on top via z-index. Reverse so - // index 0 is the back card and index N-1 is the front. - const stack = visible.map((id, i) => ({ - id, - // Symmetric fan: front card has rotate=0, x=0; cards behind it - // alternate left/right as you walk back through the stack. - rotate: stackRotate(i, visible.length), - x: stackOffsetX(i, visible.length), - y: stackOffsetY(i, visible.length), - z: i, - })) - - return ( - <> -
- Active Heap -
-
- {/* Header — clickable, navigates to the heap section. */} - - - {/* Stack row. Re-keyed on activeHeap.id so switching heaps tears - * the animation context down cleanly instead of trying to - * crossfade unrelated photos. The desert scene sits behind the - * fanned thumbnails — `cover` + `bottom` keeps the dunes anchored - * so the cacti frame the photos rather than the (transparent) sky. */} -
- {visible.length === 0 ? ( -
- - Select photos with{' '} - - S - {' '} - to fill the heap - -
- ) : ( -
- - {stack.map((item) => ( - - ))} - -
- )} -
-
- - ) -} - -// ── Stack geometry ─────────────────────────────────────────────────────── -// -// `i` is the position in the visible array (0 = oldest, last = newest). -// We want the newest card at center (rotate 0, x 0) and earlier cards -// fanning symmetrically outward — so we score each card by how far it -// is from the front, alternating sign. - -const X_STEP = 18 // pixels per fan step -const Y_STEP = 2 // tiny vertical lift so the back cards peek above -const ROTATE_STEP = 6 // degrees per fan step - -function stackRotate(i: number, len: number): number { - // Distance from the front (newest). Front card → 0, then alternating - // -1, +1, -2, +2 ... to spread cards outward. - const fromFront = len - 1 - i - if (fromFront === 0) return 0 - const sign = fromFront % 2 === 1 ? -1 : 1 - const magnitude = Math.ceil(fromFront / 2) - return sign * magnitude * ROTATE_STEP -} - -function stackOffsetX(i: number, len: number): number { - const fromFront = len - 1 - i - if (fromFront === 0) return 0 - const sign = fromFront % 2 === 1 ? -1 : 1 - const magnitude = Math.ceil(fromFront / 2) - return sign * magnitude * X_STEP -} - -function stackOffsetY(i: number, len: number): number { - // Back cards lift up a couple pixels so they're visible above the - // front card's top edge — gives the stack its sense of depth. - const fromFront = len - 1 - i - return fromFront * -Y_STEP -} diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 943a365..3d4a60d 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -14,7 +14,6 @@ import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { toast } from '../ToastContainer' -import { ActiveHeapCard } from '../heaps/ActiveHeapCard' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor' import { BulkTagsEditor } from '../sidebar/BulkTagsEditor' @@ -209,7 +208,6 @@ export function RightSidebar() { role="region" aria-label="Photo metadata" > -
@@ -236,9 +234,9 @@ export function RightSidebar() { } // ── Single-photo: full editor via PhotoInfoPanel ──────────────────── - // Heap card + header stay pinned at the top; the edit fields and - // readonly metadata sections scroll together in a single overflow - // region below. + // No heap card and no separate title strip: PhotoInfoPanel's own + // collapsible headers ("Metadata", "Camera") are the visible + // section titles. The whole content area is one scroll region. if (selectedPhotos.length === 1) { const id = activePhotoId ?? selectedPhotos[0] return ( @@ -247,8 +245,6 @@ export function RightSidebar() { role="region" aria-label="Photo metadata" > - -
diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index 29bfef1..a9dadf7 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -143,7 +143,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro const queryClient = useQueryClient() const [expandedSections, setExpandedSections] = useState>( - new Set(['edit', 'metadata', 'basic', 'camera', 'location']) + new Set(['metadata', 'camera']) ) const toggleSection = (section: string) => { const next = new Set(expandedSections) @@ -380,9 +380,12 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro : 'border-border bg-bg text-text placeholder-text-faint focus:border-primary' ) - // Note: no h-full / flex-1 here — the parent (RightSidebar) owns the - // scroll container so the edit fields and readonly metadata scroll - // together as one block beneath the pinned heap + header. + // Parent (RightSidebar) owns the scroll container; this panel is a + // pair of stacked collapsibles. Metadata holds readonly file/photo + // facts plus the editable form (separated by a thin
). Camera + // is isolated at the bottom so a long EXIF block doesn't push the + // primary form off-screen. + const hasGps = photo.latitude != null && photo.longitude != null return (
- {/* Edit fields — collapsible group so the user can hide the - * editable form (filename, title, notes, rating, color, flag) - * the same way they can hide the readonly metadata block below. */} - toggleSection('edit')} - className="border-b border-border" - > - - Edit - {expandedSections.has('edit') ? ( - - ) : ( - - )} - - -
-
- - setFilenameDraft(e.target.value)} - onBlur={commitFilename} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } else if (e.key === 'Escape') { - setFilenameDraft(photo.filename ?? '') - e.currentTarget.blur() - } - }} - className={monoInputClass} - /> -
- -
- - setTitleDraft(e.target.value)} - onBlur={commitTitle} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } else if (e.key === 'Escape') { - setTitleDraft(photo.user_title ?? '') - e.currentTarget.blur() - } - }} - placeholder="No title" - className={inputClass} - /> -
- - - -
- -