ui(layout): move Date filter inputs to topbar, Active Heap to right sidebar

- FilterBar: new Date pill hosts from/to inputs; calendar stays in left
  sidebar (always visible, no collapse) with reduced padding and a
  taller MONTH_HEIGHT so 6-week months render fully.
- LeftSidebar: drop Library collapse; Heaps regains its chevron toggle
  to match Views/Folders.
- RightSidebar: render ActiveHeapCard above the Metadata header (with
  its own eyebrow); preview overlay reuses RightSidebar so the active
  heap stays visible there too.
- Toaster: top-right, more compact (smaller padding, font, gap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 21:21:57 +02:00
parent c5582ffc65
commit e6ca78881f
8 changed files with 107 additions and 230 deletions

View File

@@ -1,21 +1,16 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Filter, X } from 'lucide-react'
import { DayPicker } from 'react-day-picker' import { DayPicker } from 'react-day-picker'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
// Fixed month height so snap points are uniform regardless of whether // Fixed month height so snap points are uniform regardless of whether
// a given month lays out as 5 or 6 weeks. Sized to fit the tallest // a given month lays out as 5 or 6 weeks. Sized to fit the tallest
// (6-week) case: caption ~18px + weekday head ~14px + 6 rows × 24px + // (6-week) case: caption ~20px + weekday head ~14px + 6 rows × 24px
// padding ~12px. // + 5 × 2px row gap = 154px. Plus a few px slack so the last row
// never gets clipped on months that span 6 weeks (e.g. Nov 2025).
const MONTH_HEIGHT = 200 const MONTH_HEIGHT = 200
/** /**
@@ -214,9 +209,9 @@ export function DateRangePicker({
const next = photosByDate[lo] const next = photosByDate[lo]
const best = const best =
!prev ? next !prev ? next
: !next ? prev : !next ? prev
: target - prev.t <= next.t - target ? prev : target - prev.t <= next.t - target ? prev
: next : next
if (best) jumpToPhoto(best.id) if (best) jumpToPhoto(best.id)
}, },
[photosByDate, jumpToPhoto] [photosByDate, jumpToPhoto]
@@ -224,14 +219,8 @@ export function DateRangePicker({
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-end gap-1"> {hasSelection && (
<DateFilterPopover <div className="flex items-center justify-end">
from={from}
to={to}
onFromChange={onFromChange}
onToChange={onToChange}
/>
{hasSelection && (
<button <button
onClick={() => { onClick={() => {
onFromChange(null) onFromChange(null)
@@ -241,8 +230,8 @@ export function DateRangePicker({
> >
Clear Clear
</button> </button>
)} </div>
</div> )}
{/* Single-month viewport — container height matches exactly one {/* Single-month viewport — container height matches exactly one
* month so the previous / next months stay off-screen. Uses * month so the previous / next months stay off-screen. Uses
* scroll-snap so a wheel tick or drag settles on a whole month * scroll-snap so a wheel tick or drag settles on a whole month
@@ -256,7 +245,7 @@ export function DateRangePicker({
* sets scrollTop even when overflow is hidden. */} * sets scrollTop even when overflow is hidden. */}
<div <div
ref={scrollRef} ref={scrollRef}
className="overflow-hidden rounded border border-border bg-bg" className="overflow-hidden"
style={{ height: MONTH_HEIGHT }} style={{ height: MONTH_HEIGHT }}
> >
<div <div
@@ -272,7 +261,7 @@ export function DateRangePicker({
return ( return (
<div <div
key={k} key={k}
className="flex items-start justify-center px-1 py-1" className="flex items-start justify-center"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -341,7 +330,7 @@ const MonthGrid = memo(function MonthGrid({
month={monthDate} month={monthDate}
disableNavigation disableNavigation
showOutsideDays={false} showOutsideDays={false}
className="p-1" className="p-0"
classNames={{ classNames={{
months: 'flex flex-col', months: 'flex flex-col',
month: 'flex flex-col gap-1', month: 'flex flex-col gap-1',
@@ -392,82 +381,6 @@ const MonthGrid = memo(function MonthGrid({
) )
}) })
/** Compact popover with two native date inputs for setting a range
* explicitly. Lives behind the "Filter" button next to the calendar. */
function DateFilterPopover({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
const [open, setOpen] = useState(false)
const active = !!(from || to)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wider',
active
? 'border-primary/60 bg-primary/10 text-primary'
: 'border-border text-text-muted hover:bg-surface-2 hover:text-text',
)}
>
<Filter className="h-3 w-3" />
Filter
</PopoverTrigger>
<PopoverContent className="w-56 space-y-2 p-3" side="bottom" align="end">
<div className="flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-text">
Date range
</h3>
{active && (
<button
onClick={() => {
onFromChange(null)
onToChange(null)
}}
className="flex items-center gap-0.5 rounded px-1 py-0.5 text-[10px] text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear range"
>
<X className="h-3 w-3" />
Clear
</button>
)}
</div>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
From
</span>
<input
type="date"
value={from ?? ''}
onChange={(e) => onFromChange(e.target.value || null)}
max={to ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
To
</span>
<input
type="date"
value={to ?? ''}
onChange={(e) => onToChange(e.target.value || null)}
min={from ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
</PopoverContent>
</Popover>
)
}
function isInRange(d: Date, from?: Date, to?: Date): boolean { function isInRange(d: Date, from?: Date, to?: Date): boolean {
if (!from && !to) return false if (!from && !to) return false
const t = d.getTime() const t = d.getTime()

View File

@@ -77,6 +77,10 @@ export function FilterBar({
const tagIds = useFilterStore((s) => s.tagIds) const tagIds = useFilterStore((s) => s.tagIds)
const needsReview = useFilterStore((s) => s.needsReview) const needsReview = useFilterStore((s) => s.needsReview)
const setNeedsReview = useFilterStore((s) => s.setNeedsReview) const setNeedsReview = useFilterStore((s) => s.setNeedsReview)
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has // Only the Flag pill is hidden inside the Discarded section. Flag has
@@ -173,8 +177,48 @@ export function FilterBar({
{/* Pills — left side, scroll horizontally if they overflow. */} {/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto"> <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date filter is rendered inline at the top of the right {/* Date */}
* sidebar (always visible) — no pill needed here. */} <FilterPill
label="Date"
value={
dateFrom || dateTo
? dateFrom && dateTo && dateFrom === dateTo
? dateFrom
: `${dateFrom ?? '…'}${dateTo ?? '…'}`
: null
}
isActive={dateFrom !== null || dateTo !== null}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
contentClassName="w-56 space-y-2 p-3"
>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
From
</span>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
max={dateTo ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
To
</span>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
min={dateFrom ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
</FilterPill>
{/* Type */} {/* Type */}
<FilterPill <FilterPill

View File

@@ -56,6 +56,10 @@ export function ActiveHeapCard() {
})) }))
return ( return (
<>
<div className="flex h-9 flex-shrink-0 items-center border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
Active Heap
</div>
<div className="m-1.5 rounded-md border border-border bg-surface-2 shadow-sm"> <div className="m-1.5 rounded-md border border-border bg-surface-2 shadow-sm">
{/* Header — clickable, navigates to the heap section. */} {/* Header — clickable, navigates to the heap section. */}
<button <button
@@ -135,6 +139,7 @@ export function ActiveHeapCard() {
)} )}
</div> </div>
</div> </div>
</>
) )
} }

View File

@@ -44,27 +44,13 @@ import {
* - filter heapId: which heap is currently filtered to (visual) * - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row) * - heap.is_active: which heap T adds to (server-side, single per row)
*/ */
interface HeapsPanelProps { export function HeapsPanel() {
/** Controlled expand state. When both props are supplied the panel
* defers to the parent for collapse/expand, so the sidebar's pane
* sizing layer can flex the Heaps pane only when it is open. */
expanded?: boolean
onExpandedChange?: (expanded: boolean) => void
}
export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPanelProps = {}) {
const { data: heaps = [] } = useHeapsQuery() const { data: heaps = [] } = useHeapsQuery()
const navigateToSection = useFilterStore((s) => s.navigateToSection) const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [expandedInternal, setExpandedInternal] = useState(true) const [expanded, setExpanded] = useState(true)
const expanded = expandedProp ?? expandedInternal
const setExpanded = (next: boolean | ((prev: boolean) => boolean)) => {
const resolved = typeof next === 'function' ? (next as (p: boolean) => boolean)(expanded) : next
if (onExpandedChange) onExpandedChange(resolved)
else setExpandedInternal(resolved)
}
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render // Which heap row is currently being hovered with a drag — used to render
@@ -188,9 +174,9 @@ export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPa
return ( return (
<div> <div>
{/* Section header — mirrors the Folders section eyebrow in {/* Section header — mirrors the Views/Folders section eyebrows
* LeftSidebar so Heaps sits alongside it at the same visual * in LeftSidebar so Heaps sits alongside them at the same
* tier, with heap rows indented the same as folder rows. */} * visual tier, including the click-to-collapse chevron. */}
<div <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" 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)} onClick={() => setExpanded((v) => !v)}
@@ -226,7 +212,7 @@ export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPa
</div> </div>
{expanded && ( {expanded && (
<div> <div>
{/* Inline create form */} {/* Inline create form */}
{creating && ( {creating && (
<div <div
@@ -490,7 +476,7 @@ export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPa
</div> </div>
) )
})} })}
</div> </div>
)} )}
{/* Shared with me */} {/* Shared with me */}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
@@ -33,7 +33,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel' import { HeapsPanel } from '../heaps/HeapsPanel'
import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
@@ -84,7 +83,6 @@ export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps'])) const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Library pane collapse. Its body holds Views, Folders, Shared, and // Library pane collapse. Its body holds Views, Folders, Shared, and
// Heaps, so a single toggle hides the whole navigation area. // Heaps, so a single toggle hides the whole navigation area.
const [libraryPaneOpen, setLibraryPaneOpen] = useState(true)
// Inline rename state for source-root rows. Stores the id being edited // Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start. // and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState<string | null>(null) const [renamingId, setRenamingId] = useState<string | null>(null)
@@ -838,40 +836,18 @@ export function LeftSidebar() {
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
{/* Active heap card — pinned at the very top so it stays visible {/* Date range calendar — always visible, collapsible. The
* even when the date filter is expanded into a tall calendar. * from/to range inputs live on the top filter bar; this block
* Returns null when no heap is active, so the layout collapses * just hosts the calendar visualisation and click-to-jump. */}
* cleanly. */}
<ActiveHeapCard />
{/* Date range filter — always visible, collapsible. Drives the
* global dateFrom/dateTo on the filter store, so it applies to
* every section regardless of which tree item is selected. */}
<DateFilterSection /> <DateFilterSection />
{/* Resizable two-pane region: Library (Views + Folders + Shared) {/* Library — the sole navigation section. Contains Views,
* on top, Heaps on the bottom. Each pane collapses to its * Folders, Shared-with-me, and Heaps in a single scroll area. */}
* header; when both are expanded a drag divider splits the <div className="flex min-h-0 flex-1 flex-col">
* vertical space between them (persisted to localStorage). */} <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">
{/* Library pane — the sole navigation section. Contains Views,
* Folders, Shared-with-me, and Heaps in a single scroll area;
* collapses to its header when hidden. */}
<div
className="flex min-h-0 flex-col"
style={{ flex: libraryPaneOpen ? '1 1 0' : '0 0 auto' }}
>
<div
className="group flex h-9 flex-shrink-0 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
onClick={() => setLibraryPaneOpen((v) => !v)}
aria-expanded={libraryPaneOpen}
role="button"
>
<span className="flex-1 truncate">Library</span> <span className="flex-1 truncate">Library</span>
<button <button
onClick={(e) => { onClick={() => setUploadTarget({ open: true, folderId: null })}
e.stopPropagation()
setUploadTarget({ open: true, folderId: null })
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text" className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Upload photos" title="Upload photos"
aria-label="Upload photos" aria-label="Upload photos"
@@ -879,8 +855,7 @@ export function LeftSidebar() {
<UploadIcon className="h-3.5 w-3.5" /> <UploadIcon className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
{libraryPaneOpen && ( <div className="min-h-0 flex-1 overflow-y-auto pb-2">
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
{libraryTree.map((item) => renderTreeItem(item))} {libraryTree.map((item) => renderTreeItem(item))}
{/* Shared with me — folders shared by other users */} {/* Shared with me — folders shared by other users */}
@@ -929,11 +904,9 @@ export function LeftSidebar() {
</div> </div>
)} )}
{/* Heaps — nested inside the Library section; HeapsPanel owns {/* Heaps — nested inside the Library section. */}
* its own eyebrow header + collapse state. */}
<HeapsPanel /> <HeapsPanel />
</div> </div>
)}
</div> </div>
{/* Bottom panel — user identity + settings, pinned below the tree. */} {/* Bottom panel — user identity + settings, pinned below the tree. */}
@@ -999,69 +972,22 @@ export function LeftSidebar() {
) )
} }
/** Collapsible date-range filter block. Lives at the top of the left /** Always-visible calendar block at the top of the left sidebar.
* sidebar and drives the global dateFrom/dateTo filter-store fields, * Drives the same global dateFrom/dateTo filter-store fields as the
* so it applies across every section. The calendar component itself * topbar Date pill, and visualises photo density per day. */
* supports range selection and decorates days that have photos.
* Open state persists across sessions via localStorage. */
const DATE_FILTER_OPEN_KEY = 'mulita:dateFilterOpen'
function DateFilterSection() { function DateFilterSection() {
const dateFrom = useFilterStore((s) => s.dateFrom) const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo) const dateTo = useFilterStore((s) => s.dateTo)
const setDateFrom = useFilterStore((s) => s.setDateFrom) const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo) const setDateTo = useFilterStore((s) => s.setDateTo)
const active = dateFrom !== null || dateTo !== null
// Default open; remembered across sessions. localStorage is read
// lazily inside the initialiser so SSR / disabled-storage fall back
// cleanly to the default.
const [open, setOpen] = useState<boolean>(() => {
try {
const v = localStorage.getItem(DATE_FILTER_OPEN_KEY)
if (v === null) return true
return v === '1'
} catch {
return true
}
})
useEffect(() => {
try {
localStorage.setItem(DATE_FILTER_OPEN_KEY, open ? '1' : '0')
} catch {
// Ignore — quota / disabled storage is non-fatal.
}
}, [open])
const summary = active
? dateFrom && dateTo && dateFrom === dateTo
? dateFrom
: `${dateFrom ?? '…'}${dateTo ?? '…'}`
: 'Any date'
return ( return (
<div className="flex-shrink-0"> <div className="flex-shrink-0 border-b border-border px-2 py-2">
<div <DateRangePicker
onClick={() => setOpen((v) => !v)} from={dateFrom}
className="group flex h-9 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text" to={dateTo}
aria-expanded={open} onFromChange={setDateFrom}
role="button" onToChange={setDateTo}
> />
<span className="flex-1 truncate">
{active ? summary : 'Date'}
</span>
{active && (
<span className="rounded bg-primary/20 px-1 py-px text-[9px] uppercase tracking-wider text-primary">
Filtering
</span>
)}
</div>
{open && (
<div className="border-b border-border px-3 py-3">
<DateRangePicker
from={dateFrom}
to={dateTo}
onFromChange={setDateFrom}
onToChange={setDateTo}
/>
</div>
)}
</div> </div>
) )
} }

View File

@@ -14,6 +14,7 @@ import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor' import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor' import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
@@ -221,6 +222,7 @@ export function RightSidebar() {
role="region" role="region"
aria-label="Photo metadata" aria-label="Photo metadata"
> >
<ActiveHeapCard />
<Header /> <Header />
<div className="flex flex-1 items-center justify-center p-4 text-center"> <div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted"> <div className="text-text-muted">
@@ -255,6 +257,7 @@ export function RightSidebar() {
role="region" role="region"
aria-label="Photo metadata" aria-label="Photo metadata"
> >
<ActiveHeapCard />
<Header /> <Header />
<PhotoInfoPanel photoId={id} /> <PhotoInfoPanel photoId={id} />
</div> </div>

View File

@@ -11,7 +11,7 @@ import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage' import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip' import { PreviewFilmstrip } from './PreviewFilmstrip'
import { getPreviewImageSrc, isVideo } from './previewSrc' import { getPreviewImageSrc, isVideo } from './previewSrc'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { RightSidebar } from '../layout/RightSidebar'
import { KeyboardHints } from '../KeyboardHints' import { KeyboardHints } from '../KeyboardHints'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -309,7 +309,7 @@ export function PreviewView() {
* but lives inside the preview overlay so it isn't covered by it. */} * but lives inside the preview overlay so it isn't covered by it. */}
{infoPanelOpen && ( {infoPanelOpen && (
<aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface"> <aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface">
<PhotoInfoPanel photoId={currentPhoto.id} /> <RightSidebar />
</aside> </aside>
)} )}
</div> </div>

View File

@@ -8,22 +8,22 @@ type ToasterProps = React.ComponentProps<typeof Sonner>
// existing call sites don't need edits. // existing call sites don't need edits.
const Toaster = ({ ...props }: ToasterProps) => ( const Toaster = ({ ...props }: ToasterProps) => (
<Sonner <Sonner
position="bottom-left" position="top-right"
theme="dark" theme="dark"
// Compact toasts: tight padding, smaller gap, single-line title+desc // Compact toasts: tight padding, smaller gap, single-line title+desc
// so they don't hog the corner of the screen. // so they don't hog the corner of the screen.
gap={6} gap={4}
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
'group toast !gap-1.5 !p-2 !min-h-0 bg-surface/90 border border-border backdrop-blur-md text-text shadow', 'group toast !gap-1 !p-1.5 !min-h-0 !w-auto !min-w-0 bg-surface/90 border border-border backdrop-blur-md text-text shadow',
title: 'text-text text-xs font-medium leading-tight', title: 'text-text text-[11px] font-medium leading-tight',
description: 'text-text-muted text-[11px] leading-tight', description: 'text-text-muted text-[10px] leading-tight',
actionButton: actionButton:
'!h-6 bg-primary text-bg hover:bg-primary/90 rounded-md px-1.5 text-[11px] font-medium', '!h-5 bg-primary text-bg hover:bg-primary/90 rounded px-1.5 text-[10px] font-medium',
cancelButton: cancelButton:
'!h-6 bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-1.5 text-[11px]', '!h-5 bg-surface-2 text-text-muted hover:bg-surface-offset rounded px-1.5 text-[10px]',
icon: '!size-3.5 !mr-1', icon: '!size-3 !mr-1',
success: 'border-l-2 border-l-pick', success: 'border-l-2 border-l-pick',
error: 'border-l-2 border-l-reject', error: 'border-l-2 border-l-reject',
info: 'border-l-2 border-l-primary', info: 'border-l-2 border-l-primary',