Files
zui/frontend/src/app/recollections/layout/RecollectionTitleContent.tsx
2026-03-16 09:51:45 +01:00

124 lines
4.6 KiB
TypeScript

/**
* Shared title + save status block (same for Logos and Flux). Gets save status and Save from
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
*/
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { useRecollectionActions } from './RecollectionActionsContext'
import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
export function RecollectionTitleContent() {
const { setTitleContent } = useRecollectionMenubar()
const { activeSlot } = useRecollectionActions()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const recollectionName = useMemo(
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
[recollectionId, recollections]
)
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<string>('saved')
const saveStatus = activeSlot?.saveStatus ?? 'saved'
const onSave = activeSlot?.onSave
const canSave = activeSlot?.canSave ?? false
useEffect(() => {
if (saveStatus === 'unsaved') {
setShowSavedBriefly(false)
if (savedBrieflyTimerRef.current) {
clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = null
}
} else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
setShowSavedBriefly(true)
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = setTimeout(() => {
savedBrieflyTimerRef.current = null
setShowSavedBriefly(false)
}, 2500)
}
prevSaveStatusRef.current = saveStatus
}, [saveStatus])
useEffect(() => {
return () => {
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
}
}, [])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [onSave, canSave])
const trigger = useMemo(
() => (
<span className="flex items-center gap-1.5 truncate">
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
{recollectionName ?? 'Untitled'}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 flex items-center text-muted-foreground cursor-default"
aria-live="polite"
aria-label={
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'
}
>
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
),
[recollectionName, saveStatus, showSavedBriefly]
)
const titleNode = useMemo(
() => (
<span className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm">
{trigger}
</span>
),
[trigger]
)
useLayoutEffect(() => {
setTitleContent(titleNode)
return () => setTitleContent(null)
}, [titleNode, setTitleContent])
return null
}