feat: improvements
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -37,8 +36,8 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { useRecollectionTitleData } from '@/app/recollections/RecollectionTitleDataContext'
|
||||
import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent'
|
||||
import { useRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
||||
import type { StoredGraphState } from '@/app/recollections/recollectionStore'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||
@@ -68,7 +67,7 @@ import {
|
||||
} from '@/lib/graph/nodeRegistry'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||
import { RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
@@ -176,7 +175,6 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
saveStatus,
|
||||
} = useCanvasGraph(recollectionId)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
||||
@@ -325,57 +323,12 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||||
|
||||
const handleExportRecollection = useCallback(() => {
|
||||
const state = { version: RECOLLECTION_VERSION, nodes, edges }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `recollection${RECOLLECTION_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Recollection exported')
|
||||
}, [nodes, edges])
|
||||
|
||||
const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])
|
||||
|
||||
const handleLoadExample = useCallback(() => {
|
||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||
toast.success('Example loaded')
|
||||
}, [setStateImmediate])
|
||||
|
||||
const onImportFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = reader.result as string
|
||||
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
||||
toast.error('Invalid file: expected nodes and edges arrays')
|
||||
return
|
||||
}
|
||||
const nodes = state.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||||
setStateImmediate({ nodes, edges })
|
||||
if (state.version != null && state.version > RECOLLECTION_VERSION) {
|
||||
toast.error('Recollection was created with a newer app version')
|
||||
} else {
|
||||
toast.success('Recollection loaded')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid file: not valid JSON')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[setStateImmediate]
|
||||
)
|
||||
|
||||
const selectedNodes = useMemo(
|
||||
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
||||
[nodes]
|
||||
@@ -429,56 +382,56 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const { pathname } = useLocation()
|
||||
const isFluxActive = pathname.endsWith('/flux')
|
||||
const { setTitleData } = useRecollectionTitleData()
|
||||
const { setFluxSlot, onImport } = useRecollectionActions()
|
||||
|
||||
const onRefreshFromStore = useCallback(
|
||||
(graph: StoredGraphState) => {
|
||||
const nodes = graph.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, graph.edges as AppEdge[])
|
||||
setStateImmediate({ nodes, edges })
|
||||
},
|
||||
[setStateImmediate]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTitleData({
|
||||
const slot = {
|
||||
saveStatus,
|
||||
onSave: recollectionId
|
||||
? () => {
|
||||
save()
|
||||
toast.success('Saved')
|
||||
}
|
||||
: undefined,
|
||||
: () => {},
|
||||
canSave: Boolean(recollectionId),
|
||||
onImport: handleImportRecollection,
|
||||
onExport: handleExportRecollection,
|
||||
})
|
||||
return () => setTitleData(null)
|
||||
}, [
|
||||
setTitleData,
|
||||
saveStatus,
|
||||
recollectionId,
|
||||
save,
|
||||
handleImportRecollection,
|
||||
handleExportRecollection,
|
||||
])
|
||||
|
||||
const fluxMenubarProps = useMemo(
|
||||
() => ({
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRefreshFromStore,
|
||||
onDuplicate: handleDuplicate,
|
||||
onCopy: handleCopy,
|
||||
onPaste: handlePaste,
|
||||
canDuplicate: selectedNodes.length > 0,
|
||||
canCopy: selectedNodes.length === 1,
|
||||
onFitView: () => flowActionsRef.current?.fitView?.(),
|
||||
}),
|
||||
[
|
||||
canUndo,
|
||||
canRedo,
|
||||
selectedNodes.length,
|
||||
}
|
||||
setFluxSlot(slot)
|
||||
return () => setFluxSlot(null)
|
||||
}, [
|
||||
setFluxSlot,
|
||||
saveStatus,
|
||||
recollectionId,
|
||||
save,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRefreshFromStore,
|
||||
handleDuplicate,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
]
|
||||
)
|
||||
selectedNodes.length,
|
||||
])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||
@@ -688,18 +641,9 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
{isFluxActive && <FluxMenubarContent {...fluxMenubarProps} />}
|
||||
<div role="status" aria-live="polite" aria-atomic className="sr-only">
|
||||
{ariaAnnouncement}
|
||||
</div>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,.zui.json,application/json"
|
||||
className="hidden"
|
||||
onChange={onImportFileChange}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<GraphContext.Provider value={graphContextValue}>
|
||||
@@ -729,7 +673,7 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||
<Button onClick={handleImportRecollection} variant="outline" size="sm">
|
||||
<Button onClick={onImport} variant="outline" size="sm">
|
||||
<FolderOpen className="size-4" />
|
||||
Import…
|
||||
</Button>
|
||||
|
||||
224
frontend/src/app/recollections/RecollectionActionsContext.tsx
Normal file
224
frontend/src/app/recollections/RecollectionActionsContext.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Context for shared recollection actions: two slots (flux and logos) and layout-level import/export.
|
||||
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useRef, useState } from 'react'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
||||
import {
|
||||
getGraph,
|
||||
setGraph,
|
||||
getLogosContent,
|
||||
setLogosContent,
|
||||
RECOLLECTION_FILE_EXT,
|
||||
RECOLLECTION_VERSION,
|
||||
type StoredGraphState,
|
||||
type StoredLogosContent,
|
||||
} from './recollectionStore'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export type { SaveStatus }
|
||||
|
||||
/** Parsed recollection file format (import/export). */
|
||||
export type RecollectionFilePayload = {
|
||||
version?: number
|
||||
graph?: { nodes: unknown[]; edges: unknown[] }
|
||||
logos?: StoredLogosContent
|
||||
}
|
||||
|
||||
export type FluxSlot = {
|
||||
saveStatus: SaveStatus
|
||||
onSave: () => void
|
||||
canSave: boolean
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onRefreshFromStore: (graph: StoredGraphState) => void
|
||||
onDuplicate?: () => void
|
||||
onCopy?: () => void
|
||||
onPaste?: () => void
|
||||
canDuplicate?: boolean
|
||||
canCopy?: boolean
|
||||
onFitView?: () => void
|
||||
}
|
||||
|
||||
export type LogosSlot = {
|
||||
saveStatus: SaveStatus
|
||||
onSave: () => void
|
||||
canSave: boolean
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onRefreshFromStore: () => void
|
||||
}
|
||||
|
||||
export type RecollectionActionsContextValue = {
|
||||
flux: FluxSlot | null
|
||||
logos: LogosSlot | null
|
||||
setFluxSlot: (slot: FluxSlot | null) => void
|
||||
setLogosSlot: (slot: LogosSlot | null) => void
|
||||
/** Whether the Flux view is active (pathname ends with /flux). */
|
||||
isFluxActive: boolean
|
||||
/** Whether the Logos view is active. */
|
||||
isLogosActive: boolean
|
||||
/** Active slot (flux or logos by pathname). */
|
||||
activeSlot: FluxSlot | LogosSlot | null
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
}
|
||||
|
||||
const RecollectionActionsContext = createContext<RecollectionActionsContextValue | null>(null)
|
||||
|
||||
function validateAndWritePayload(
|
||||
recollectionId: string,
|
||||
payload: RecollectionFilePayload,
|
||||
backfillEdges: (nodes: AppNode[], edges: AppEdge[]) => AppEdge[]
|
||||
): { graph?: StoredGraphState; logos?: StoredLogosContent } {
|
||||
const result: { graph?: StoredGraphState; logos?: StoredLogosContent } = {}
|
||||
if (payload.graph && Array.isArray(payload.graph.nodes) && Array.isArray(payload.graph.edges)) {
|
||||
const nodes = payload.graph.nodes as AppNode[]
|
||||
const edges = backfillEdges(nodes, payload.graph.edges as AppEdge[])
|
||||
const graphState: StoredGraphState = {
|
||||
version: payload.version ?? RECOLLECTION_VERSION,
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
setGraph(recollectionId, graphState)
|
||||
result.graph = graphState
|
||||
}
|
||||
if (payload.logos != null && Array.isArray(payload.logos)) {
|
||||
if (payload.logos.every((item) => item != null && typeof item === 'object')) {
|
||||
setLogosContent(recollectionId, payload.logos as StoredLogosContent)
|
||||
result.logos = payload.logos as StoredLogosContent
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const [flux, setFluxSlotState] = useState<FluxSlot | null>(null)
|
||||
const [logos, setLogosSlotState] = useState<LogosSlot | null>(null)
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const fluxRef = useRef<FluxSlot | null>(null)
|
||||
const logosRef = useRef<LogosSlot | null>(null)
|
||||
const pathnameRef = useRef(pathname)
|
||||
fluxRef.current = flux
|
||||
logosRef.current = logos
|
||||
pathnameRef.current = pathname
|
||||
|
||||
const isFluxActive = pathname.endsWith('/flux')
|
||||
const isLogosActive = pathname.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
|
||||
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
|
||||
|
||||
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
|
||||
setFluxSlotState(() => slot)
|
||||
}, [])
|
||||
const setLogosSlot = useCallback((slot: LogosSlot | null) => {
|
||||
setLogosSlotState(() => slot)
|
||||
}, [])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (!recollectionId) return
|
||||
const graph = getGraph(recollectionId)
|
||||
const logosContent = getLogosContent(recollectionId)
|
||||
const payload: RecollectionFilePayload = {
|
||||
version: RECOLLECTION_VERSION,
|
||||
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
|
||||
...(logosContent && { logos: logosContent }),
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `recollection${RECOLLECTION_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Recollection exported')
|
||||
}, [recollectionId])
|
||||
|
||||
const onImport = useCallback(() => {
|
||||
importInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const onImportFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !recollectionId) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = reader.result as string
|
||||
const payload = JSON.parse(text) as RecollectionFilePayload
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
toast.error('Invalid file: not valid JSON')
|
||||
return
|
||||
}
|
||||
const written = validateAndWritePayload(recollectionId, payload, (nodes, edges) =>
|
||||
backfillEdgeTargetTypes(nodes, edges)
|
||||
)
|
||||
const currentPath = pathnameRef.current
|
||||
const fluxActive = currentPath.endsWith('/flux')
|
||||
const logosActive = currentPath.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
|
||||
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
|
||||
fluxRef.current.onRefreshFromStore(written.graph)
|
||||
}
|
||||
if ((written.graph != null || written.logos != null) && logosActive && logosRef.current?.onRefreshFromStore) {
|
||||
logosRef.current.onRefreshFromStore()
|
||||
}
|
||||
if (payload.version != null && payload.version > RECOLLECTION_VERSION) {
|
||||
toast.error('Recollection was created with a newer app version')
|
||||
} else {
|
||||
toast.success('Recollection loaded')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid file: not valid JSON')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[recollectionId]
|
||||
)
|
||||
|
||||
const value: RecollectionActionsContextValue = React.useMemo(
|
||||
() => ({
|
||||
flux,
|
||||
logos,
|
||||
setFluxSlot,
|
||||
setLogosSlot,
|
||||
isFluxActive,
|
||||
isLogosActive,
|
||||
activeSlot,
|
||||
onImport,
|
||||
onExport,
|
||||
}),
|
||||
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport]
|
||||
)
|
||||
|
||||
return (
|
||||
<RecollectionActionsContext.Provider value={value}>
|
||||
{children}
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
onChange={onImportFileChange}
|
||||
/>
|
||||
</RecollectionActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRecollectionActions(): RecollectionActionsContextValue {
|
||||
const ctx = useContext(RecollectionActionsContext)
|
||||
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
|
||||
return ctx
|
||||
}
|
||||
145
frontend/src/app/recollections/RecollectionEditViewMenus.tsx
Normal file
145
frontend/src/app/recollections/RecollectionEditViewMenus.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Shared Edit and View menus for recollections. Always rendered in the menubar;
|
||||
* uses the active slot (flux or logos by pathname) for undo, redo, and Flux-only actions.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function RecollectionEditViewMenus() {
|
||||
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
|
||||
|
||||
const fluxSlot = isFluxActive ? flux : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSlot) return
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS) && activeSlot.canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
activeSlot.undo()
|
||||
} else if (matchKey(ev, REDO_KEYS) && activeSlot.canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
activeSlot.redo()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [activeSlot])
|
||||
|
||||
const menus = useMemo(() => {
|
||||
if (!activeSlot) return null
|
||||
const hasFluxOnly =
|
||||
fluxSlot &&
|
||||
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
|
||||
return (
|
||||
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
Undo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + ⇧ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
{hasFluxOnly && (
|
||||
<>
|
||||
<MenubarSeparator />
|
||||
{fluxSlot!.onDuplicate != null && (
|
||||
<MenubarItem
|
||||
onClick={fluxSlot!.onDuplicate}
|
||||
disabled={!fluxSlot!.canDuplicate}
|
||||
className="gap-2"
|
||||
>
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘D</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{fluxSlot!.onCopy != null && (
|
||||
<MenubarItem
|
||||
onClick={fluxSlot!.onCopy}
|
||||
disabled={!fluxSlot!.canCopy}
|
||||
className="gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘C</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{fluxSlot!.onPaste != null && (
|
||||
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Paste
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘V</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{fluxSlot?.onFitView && (
|
||||
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘0</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
}, [activeSlot, fluxSlot])
|
||||
|
||||
return menus
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import React, { useEffect } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { RecollectionMenubarProvider } from './RecollectionMenubarContext'
|
||||
import { RecollectionTitleDataProvider } from './RecollectionTitleDataContext'
|
||||
import { RecollectionActionsProvider } from './RecollectionActionsContext'
|
||||
import { RecollectionTitleContent } from './RecollectionTitleContent'
|
||||
import { RecollectionMenubar } from './RecollectionMenubar'
|
||||
import { FlippingCardView } from './FlippingCardView'
|
||||
@@ -35,7 +35,7 @@ export function RecollectionLayout() {
|
||||
|
||||
return (
|
||||
<RecollectionMenubarProvider>
|
||||
<RecollectionTitleDataProvider>
|
||||
<RecollectionActionsProvider>
|
||||
<RecollectionTitleContent />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RecollectionMenubar />
|
||||
@@ -43,7 +43,7 @@ export function RecollectionLayout() {
|
||||
<FlippingCardView />
|
||||
</div>
|
||||
</div>
|
||||
</RecollectionTitleDataProvider>
|
||||
</RecollectionActionsProvider>
|
||||
</RecollectionMenubarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { FluxIcon, LogosIcon } from '@/lib/icons'
|
||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
||||
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
||||
|
||||
export function RecollectionMenubar() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections } = usePlatform()
|
||||
const { titleContent, customContent } = useRecollectionMenubar()
|
||||
const { titleContent } = useRecollectionMenubar()
|
||||
|
||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
@@ -33,7 +34,7 @@ export function RecollectionMenubar() {
|
||||
{titleContent ?? defaultTitle}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
|
||||
{customContent}
|
||||
<RecollectionEditViewMenus />
|
||||
</div>
|
||||
{base && (
|
||||
<nav className="flex shrink-0 items-center gap-0.5" aria-label="Recollection pages">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Context for the recollection menubar:
|
||||
* - Title (next to back): optional title + status, e.g. recollection name and save state when on Flux.
|
||||
* - Middle: registrable menus (Edit, View when on Flux). Set via customContent.
|
||||
* - Title (next to back): optional title + status, e.g. recollection name and save state.
|
||||
* - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||
@@ -10,25 +10,18 @@ export type RecollectionMenubarContextValue = {
|
||||
/** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */
|
||||
titleContent: React.ReactNode
|
||||
setTitleContent: (content: React.ReactNode) => void
|
||||
/** Registrable menus in the middle of the bar (e.g. Edit, View when on Flux). */
|
||||
customContent: React.ReactNode
|
||||
setCustomContent: (content: React.ReactNode) => void
|
||||
}
|
||||
|
||||
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
|
||||
|
||||
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
|
||||
const [customContent, setCustomContentState] = useState<React.ReactNode>(null)
|
||||
const setTitleContent = useCallback((content: React.ReactNode) => {
|
||||
setTitleContentState(() => content)
|
||||
}, [])
|
||||
const setCustomContent = useCallback((content: React.ReactNode) => {
|
||||
setCustomContentState(() => content)
|
||||
}, [])
|
||||
const value: RecollectionMenubarContextValue = React.useMemo(
|
||||
() => ({ titleContent, setTitleContent, customContent, setCustomContent }),
|
||||
[titleContent, setTitleContent, customContent, setCustomContent]
|
||||
() => ({ titleContent, setTitleContent }),
|
||||
[titleContent, setTitleContent]
|
||||
)
|
||||
return (
|
||||
<RecollectionMenubarContext.Provider value={value}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared title + save status block (same for Logos and Flux). Registered with the menubar
|
||||
* from the layout; gets save/import/export from RecollectionTitleDataContext (set by CanvasPage).
|
||||
* 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, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
||||
import { useRecollectionTitleData } from './RecollectionTitleDataContext'
|
||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||
import { CheckCircle2, ChevronDown, CircleDot, Download, FolderOpen, Loader2, Pencil, Save } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const SAVE_KEYS = { key: 's', shiftKey: false }
|
||||
@@ -31,7 +31,7 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
|
||||
export function RecollectionTitleContent() {
|
||||
const { setTitleContent } = useRecollectionMenubar()
|
||||
const { titleData } = useRecollectionTitleData()
|
||||
const { activeSlot, onImport, onExport } = useRecollectionActions()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections, renameRecollection } = usePlatform()
|
||||
const recollectionName = useMemo(
|
||||
@@ -39,14 +39,13 @@ export function RecollectionTitleContent() {
|
||||
[recollectionId, recollections]
|
||||
)
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const ignoreNextBlurRef = useRef(false)
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false)
|
||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
||||
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevSaveStatusRef = useRef<string>('saved')
|
||||
const saveStatus = titleData?.saveStatus ?? 'saved'
|
||||
const saveStatus = activeSlot?.saveStatus ?? 'saved'
|
||||
const onSave = activeSlot?.onSave
|
||||
const canSave = activeSlot?.canSave ?? false
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'unsaved') {
|
||||
@@ -72,65 +71,20 @@ export function RecollectionTitleContent() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setRenameValue(recollectionName ?? '')
|
||||
ignoreNextBlurRef.current = true
|
||||
const t = setTimeout(() => {
|
||||
renameInputRef.current?.focus()
|
||||
renameInputRef.current?.select()
|
||||
}, 100)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [isRenaming, recollectionName])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!recollectionId || !renameRecollection) return
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed) renameRecollection(recollectionId, trimmed)
|
||||
setIsRenaming(false)
|
||||
}, [recollectionId, renameRecollection, renameValue])
|
||||
|
||||
const handleRenameBlur = useCallback(() => {
|
||||
if (ignoreNextBlurRef.current) {
|
||||
ignoreNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
applyRename()
|
||||
}, [applyRename])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, SAVE_KEYS) && titleData?.onSave && (titleData?.canSave !== false)) {
|
||||
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
titleData.onSave()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [titleData?.onSave, titleData?.canSave])
|
||||
}, [onSave, canSave])
|
||||
|
||||
const trigger = useMemo(() => {
|
||||
if (recollectionId && isRenaming) {
|
||||
return (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); applyRename() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); setIsRenaming(false) }
|
||||
}}
|
||||
onBlur={handleRenameBlur}
|
||||
className="h-7 text-sm font-medium font-serif w-[140px]"
|
||||
aria-label="Recollection name"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
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'}
|
||||
@@ -158,18 +112,19 @@ export function RecollectionTitleContent() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
),
|
||||
[recollectionName, saveStatus, showSavedBriefly]
|
||||
)
|
||||
}, [recollectionId, isRenaming, renameValue, recollectionName, saveStatus, showSavedBriefly, applyRename, handleRenameBlur])
|
||||
|
||||
const dropdownContent = useMemo(() => (
|
||||
<>
|
||||
{recollectionId && (
|
||||
<>
|
||||
{titleData?.onSave != null && (
|
||||
{onSave != null && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={titleData.onSave}
|
||||
disabled={titleData.canSave === false}
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
@@ -179,23 +134,23 @@ export function RecollectionTitleContent() {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setIsRenaming(true)} className="gap-2">
|
||||
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={titleData?.onImport} className="gap-2" disabled={!titleData?.onImport}>
|
||||
<DropdownMenuItem onClick={onImport} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={titleData?.onExport} className="gap-2" disabled={!titleData?.onExport}>
|
||||
<DropdownMenuItem onClick={onExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export…
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
), [recollectionId, titleData?.onSave, titleData?.canSave, titleData?.onImport, titleData?.onExport])
|
||||
), [recollectionId, onSave, canSave, onImport, onExport])
|
||||
|
||||
const titleNode = useMemo(() => (
|
||||
<DropdownMenu>
|
||||
@@ -216,5 +171,17 @@ export function RecollectionTitleContent() {
|
||||
return () => setTitleContent(null)
|
||||
}, [titleNode, setTitleContent])
|
||||
|
||||
return null
|
||||
return (
|
||||
<RenameRecollectionDialog
|
||||
open={renameModalOpen}
|
||||
onOpenChange={setRenameModalOpen}
|
||||
recollectionId={recollectionId ?? ''}
|
||||
initialName={recollectionName ?? ''}
|
||||
recollections={recollections}
|
||||
onRename={(id, newName) => {
|
||||
renameRecollection(id, newName)
|
||||
setRenameModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Context for the shared recollection title + save block (used by both Logos and Flux).
|
||||
* CanvasPage sets save status and actions; layout consumes them to render the title slot.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
||||
|
||||
export type RecollectionTitleData = {
|
||||
saveStatus: SaveStatus
|
||||
onSave?: () => void
|
||||
canSave?: boolean
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
}
|
||||
|
||||
export type RecollectionTitleDataContextValue = {
|
||||
titleData: RecollectionTitleData | null
|
||||
setTitleData: (data: RecollectionTitleData | null) => void
|
||||
}
|
||||
|
||||
const RecollectionTitleDataContext = createContext<RecollectionTitleDataContextValue | null>(null)
|
||||
|
||||
export function RecollectionTitleDataProvider({ children }: { children: React.ReactNode }) {
|
||||
const [titleData, setTitleDataState] = useState<RecollectionTitleData | null>(null)
|
||||
const setTitleData = useCallback((data: RecollectionTitleData | null) => {
|
||||
setTitleDataState(() => data)
|
||||
}, [])
|
||||
const value = React.useMemo(
|
||||
() => ({ titleData, setTitleData }),
|
||||
[titleData, setTitleData]
|
||||
)
|
||||
return (
|
||||
<RecollectionTitleDataContext.Provider value={value}>
|
||||
{children}
|
||||
</RecollectionTitleDataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRecollectionTitleData(): RecollectionTitleDataContextValue {
|
||||
const ctx = useContext(RecollectionTitleDataContext)
|
||||
if (!ctx) throw new Error('useRecollectionTitleData must be used within RecollectionTitleDataProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { getLogosContent, setLogosContent } from './recollectionStore'
|
||||
import { toast } from 'sonner'
|
||||
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
||||
import type { Recollection } from '@/app/kosmos/types'
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||
@@ -269,8 +270,6 @@ export function RecollectionsPage() {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
|
||||
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
|
||||
const [duplicateName, setDuplicateName] = useState('')
|
||||
@@ -321,16 +320,8 @@ export function RecollectionsPage() {
|
||||
|
||||
const handleRenameOpen = useCallback((recollection: Recollection) => {
|
||||
setRenameTarget(recollection)
|
||||
setRenameValue(recollection.name)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (renameTarget) {
|
||||
const t = setTimeout(() => renameInputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [renameTarget])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (duplicateTarget) {
|
||||
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
||||
@@ -338,14 +329,6 @@ export function RecollectionsPage() {
|
||||
}
|
||||
}, [duplicateTarget])
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
if (renameTarget && renameValue.trim()) {
|
||||
renameRecollection(renameTarget.id, renameValue.trim())
|
||||
setRenameTarget(null)
|
||||
setRenameValue('')
|
||||
}
|
||||
}, [renameTarget, renameValue, renameRecollection])
|
||||
|
||||
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||
setDeleteTarget(recollection)
|
||||
}, [])
|
||||
@@ -929,37 +912,17 @@ export function RecollectionsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename recollection</DialogTitle>
|
||||
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameSubmit()
|
||||
if (e.key === 'Escape') setRenameTarget(null)
|
||||
<RenameRecollectionDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => !open && setRenameTarget(null)}
|
||||
recollectionId={renameTarget?.id ?? ''}
|
||||
initialName={renameTarget?.name ?? ''}
|
||||
recollections={sorted}
|
||||
onRename={(id, newName) => {
|
||||
renameRecollection(id, newName)
|
||||
setRenameTarget(null)
|
||||
}}
|
||||
placeholder="Recollection name"
|
||||
aria-label="Recollection name"
|
||||
/>
|
||||
{renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Duplicate dialog */}
|
||||
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
||||
|
||||
94
frontend/src/app/recollections/RenameRecollectionDialog.tsx
Normal file
94
frontend/src/app/recollections/RenameRecollectionDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type RenameRecollectionDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
recollectionId: string
|
||||
initialName: string
|
||||
recollections: { id: string; name: string }[]
|
||||
onRename: (id: string, newName: string) => void
|
||||
}
|
||||
|
||||
export function RenameRecollectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
recollectionId,
|
||||
initialName,
|
||||
recollections,
|
||||
onRename,
|
||||
}: RenameRecollectionDialogProps) {
|
||||
const [value, setValue] = useState(initialName)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialName)
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [open, initialName])
|
||||
|
||||
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
onRename(recollectionId, trimmed)
|
||||
onOpenChange(false)
|
||||
}, [recollectionId, value, onRename, onOpenChange])
|
||||
|
||||
const isDuplicateName =
|
||||
!!value.trim() &&
|
||||
recollections.some(
|
||||
(p) => p.id !== recollectionId && p.name.toLowerCase() === value.trim().toLowerCase()
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename recollection</DialogTitle>
|
||||
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmit()
|
||||
if (e.key === 'Escape') handleClose()
|
||||
}}
|
||||
placeholder="Recollection name"
|
||||
aria-label="Recollection name"
|
||||
/>
|
||||
{isDuplicateName && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
A recollection with this name already exists.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!value.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Flux-specific menubar: registers Edit and View menus in the middle slot.
|
||||
* Title + save status are shared and registered by the layout (RecollectionTitleContent).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
|
||||
|
||||
export type FluxMenubarContentProps = {
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onDuplicate?: () => void
|
||||
onCopy?: () => void
|
||||
onPaste?: () => void
|
||||
canDuplicate?: boolean
|
||||
canCopy?: boolean
|
||||
onFitView?: () => void
|
||||
}
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function FluxMenubarContent(props: FluxMenubarContentProps) {
|
||||
const { setCustomContent } = useRecollectionMenubar()
|
||||
|
||||
const menus = useMemo(
|
||||
() => (
|
||||
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={props.undo} disabled={!props.canUndo} className="gap-2">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
Undo
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘ + Z</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={props.redo} disabled={!props.canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘ + ⇧ + Z</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
{(props.onDuplicate != null || props.onCopy != null || props.onPaste != null) && <MenubarSeparator />}
|
||||
{props.onDuplicate != null && (
|
||||
<MenubarItem onClick={props.onDuplicate} disabled={!props.canDuplicate} className="gap-2">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘D</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{props.onCopy != null && (
|
||||
<MenubarItem onClick={props.onCopy} disabled={!props.canCopy} className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘C</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{props.onPaste != null && (
|
||||
<MenubarItem onClick={props.onPaste} className="gap-2">
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Paste
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘V</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{props.onFitView && (
|
||||
<MenubarItem onClick={props.onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘0</Kbd></KbdGroup></span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
[
|
||||
props.undo,
|
||||
props.redo,
|
||||
props.canUndo,
|
||||
props.canRedo,
|
||||
props.onDuplicate,
|
||||
props.onCopy,
|
||||
props.onPaste,
|
||||
props.canDuplicate,
|
||||
props.canCopy,
|
||||
props.onFitView,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS) && props.canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
props.undo()
|
||||
} else if (matchKey(ev, REDO_KEYS) && props.canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
props.redo()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [props.undo, props.redo, props.canUndo, props.canRedo])
|
||||
|
||||
useEffect(() => {
|
||||
setCustomContent(menus)
|
||||
return () => setCustomContent(null)
|
||||
}, [menus, setCustomContent])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
* Layout and styling aligned with Flux (same flex/overflow, bg-background, theme).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, forwardRef } from 'react'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/shadcn'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { useRecollectionActions } from '../RecollectionActionsContext'
|
||||
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
||||
const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typeof BlockNoteView>>(
|
||||
@@ -20,9 +23,6 @@ const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typ
|
||||
)
|
||||
}
|
||||
)
|
||||
import { useRecollectionMenubar } from '../RecollectionMenubarContext'
|
||||
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400
|
||||
|
||||
/** Known React ref warning from BlockNote/Radix internals; we can't fix it in our code. Suppress once at load so it's active before first BlockNote render. */
|
||||
@@ -46,41 +46,42 @@ function patchBlockNoteRefWarning() {
|
||||
}
|
||||
|
||||
export function LogosPage() {
|
||||
const { pathname } = useLocation()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { theme } = useTheme()
|
||||
const { setCustomContent } = useRecollectionMenubar()
|
||||
const isLogosActive = pathname.endsWith('/logos') || pathname.match(/\/recollections\/[^/]+\/?$/)
|
||||
const { setLogosSlot } = useRecollectionActions()
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
||||
|
||||
const initialContent = useMemo(
|
||||
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
|
||||
[recollectionId]
|
||||
[recollectionId, reloadKey]
|
||||
)
|
||||
|
||||
const editor = useCreateBlockNote({ initialContent }, [recollectionId])
|
||||
const editor = useCreateBlockNote({ initialContent }, [recollectionId, reloadKey])
|
||||
|
||||
const persistContent = useCallback(() => {
|
||||
if (!recollectionId || !editor) return
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
const doc = editor.document
|
||||
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
||||
setLogosContent(recollectionId, serialized)
|
||||
setSaveStatus('saved')
|
||||
} catch {
|
||||
// ignore serialize errors
|
||||
setSaveStatus('unsaved')
|
||||
}
|
||||
}, [recollectionId, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogosActive) return
|
||||
setCustomContent(null)
|
||||
return () => setCustomContent(null)
|
||||
}, [isLogosActive, setCustomContent])
|
||||
const onSave = useCallback(() => {
|
||||
persistContent()
|
||||
toast.success('Saved')
|
||||
}, [persistContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !recollectionId) return
|
||||
const handleChange = () => {
|
||||
setSaveStatus('unsaved')
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveTimeoutRef.current = null
|
||||
@@ -96,6 +97,28 @@ export function LogosPage() {
|
||||
}
|
||||
}, [editor, recollectionId, persistContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !recollectionId) return
|
||||
const slot = {
|
||||
saveStatus,
|
||||
onSave,
|
||||
canSave: true,
|
||||
undo: () => (editor as { undo?: () => void }).undo?.(),
|
||||
redo: () => (editor as { redo?: () => void }).redo?.(),
|
||||
canUndo: true,
|
||||
canRedo: true,
|
||||
onRefreshFromStore: () => setReloadKey((k) => k + 1),
|
||||
}
|
||||
setLogosSlot(slot)
|
||||
return () => setLogosSlot(null)
|
||||
}, [
|
||||
setLogosSlot,
|
||||
saveStatus,
|
||||
onSave,
|
||||
editor,
|
||||
recollectionId,
|
||||
])
|
||||
|
||||
if (!recollectionId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user