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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user