freat: renaming
This commit is contained in:
@@ -57,7 +57,7 @@ This doc summarizes recent improvements and suggested next steps for readability
|
|||||||
- **StoredGraphState** – shape for save/load (version + nodes + edges).
|
- **StoredGraphState** – shape for save/load (version + nodes + edges).
|
||||||
- **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections.
|
- **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections.
|
||||||
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
|
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
|
||||||
- **projectGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
|
- **recollectionGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
|
||||||
- **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle.
|
- **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle.
|
||||||
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
|
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
|
||||||
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
|
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
* Menubar for the canvas page: Recollection (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -24,7 +24,7 @@ export type CanvasMenubarProps = {
|
|||||||
onExport: () => void
|
onExport: () => void
|
||||||
onSave?: () => void
|
onSave?: () => void
|
||||||
canSave?: boolean
|
canSave?: boolean
|
||||||
/** Shown next to project title: unsaved | saving | saved */
|
/** Shown next to recollection title: unsaved | saving | saved */
|
||||||
saveStatus?: SaveStatus
|
saveStatus?: SaveStatus
|
||||||
undo: () => void
|
undo: () => void
|
||||||
redo: () => void
|
redo: () => void
|
||||||
@@ -64,14 +64,14 @@ export function CanvasMenubar({
|
|||||||
canCopy = false,
|
canCopy = false,
|
||||||
onFitView,
|
onFitView,
|
||||||
}: CanvasMenubarProps) {
|
}: CanvasMenubarProps) {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { projects, renameProject } = usePlatform()
|
const { recollections, renameRecollection } = usePlatform()
|
||||||
const projectName = useMemo(
|
const recollectionName = useMemo(
|
||||||
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
|
||||||
[projectId, projects]
|
[recollectionId, recollections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [isRenamingProject, setIsRenamingProject] = useState(false)
|
const [isRenamingRecollection, setIsRenamingRecollection] = useState(false)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -107,27 +107,27 @@ export function CanvasMenubar({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRenamingProject) {
|
if (isRenamingRecollection) {
|
||||||
setRenameValue(projectName ?? '')
|
setRenameValue(recollectionName ?? '')
|
||||||
ignoreNextBlurRef.current = true
|
ignoreNextBlurRef.current = true
|
||||||
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
|
// Delay focus so the Recollection dropdown can close first and not steal focus back (which would trigger blur)
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
renameInputRef.current?.focus()
|
renameInputRef.current?.focus()
|
||||||
renameInputRef.current?.select()
|
renameInputRef.current?.select()
|
||||||
}, 100)
|
}, 100)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [isRenamingProject, projectName])
|
}, [isRenamingRecollection, recollectionName])
|
||||||
|
|
||||||
const applyRename = useCallback(() => {
|
const applyRename = useCallback(() => {
|
||||||
if (!projectId || !renameProject) return
|
if (!recollectionId || !renameRecollection) return
|
||||||
const trimmed = renameValue.trim()
|
const trimmed = renameValue.trim()
|
||||||
if (trimmed) renameProject(projectId, trimmed)
|
if (trimmed) renameRecollection(recollectionId, trimmed)
|
||||||
setIsRenamingProject(false)
|
setIsRenamingRecollection(false)
|
||||||
}, [projectId, renameProject, renameValue])
|
}, [recollectionId, renameRecollection, renameValue])
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancelRename = useCallback(() => {
|
||||||
setIsRenamingProject(false)
|
setIsRenamingRecollection(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleRenameBlur = useCallback(() => {
|
const handleRenameBlur = useCallback(() => {
|
||||||
@@ -172,16 +172,16 @@ export function CanvasMenubar({
|
|||||||
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
||||||
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
|
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
|
||||||
<Link
|
<Link
|
||||||
to="/projects"
|
to="/recollections"
|
||||||
aria-label="Back to projects"
|
aria-label="Back to recollections"
|
||||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
|
<MenubarTrigger className="font-normal text-muted-foreground">Recollection</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
{projectId && (
|
{recollectionId && (
|
||||||
<>
|
<>
|
||||||
{onSave != null && (
|
{onSave != null && (
|
||||||
<>
|
<>
|
||||||
@@ -198,7 +198,7 @@ export function CanvasMenubar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
onClick={() => setIsRenamingProject(true)}
|
onClick={() => setIsRenamingRecollection(true)}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
@@ -290,9 +290,9 @@ export function CanvasMenubar({
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
{projectId && (
|
{recollectionId && (
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
|
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
|
||||||
{isRenamingProject ? (
|
{isRenamingRecollection ? (
|
||||||
<Input
|
<Input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -309,12 +309,12 @@ export function CanvasMenubar({
|
|||||||
}}
|
}}
|
||||||
onBlur={handleRenameBlur}
|
onBlur={handleRenameBlur}
|
||||||
className="h-7 text-sm font-medium text-center font-serif"
|
className="h-7 text-sm font-medium text-center font-serif"
|
||||||
aria-label="Project name"
|
aria-label="Recollection name"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
||||||
{projectName ?? 'Untitled'}
|
{recollectionName ?? 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
|
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
|
||||||
* Rendered inside the platform when a project is selected.
|
* Rendered inside the platform when a recollection is selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -66,7 +66,7 @@ import {
|
|||||||
} from '@/lib/graph/nodeRegistry'
|
} from '@/lib/graph/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PROJECT_FILE_EXT, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
@@ -149,11 +149,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional project id for future per-project graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
projectId?: string
|
recollectionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ projectId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -172,7 +172,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
setStateImmediate,
|
setStateImmediate,
|
||||||
save,
|
save,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
} = useCanvasGraph(projectId)
|
} = useCanvasGraph(recollectionId)
|
||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||||
@@ -323,19 +323,19 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||||||
|
|
||||||
const handleExportProject = useCallback(() => {
|
const handleExportRecollection = useCallback(() => {
|
||||||
const state = { version: PROJECT_VERSION, nodes, edges }
|
const state = { version: RECOLLECTION_VERSION, nodes, edges }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `project${PROJECT_FILE_EXT}`
|
a.download = `recollection${RECOLLECTION_FILE_EXT}`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
toast.success('Project exported')
|
toast.success('Recollection exported')
|
||||||
}, [nodes, edges])
|
}, [nodes, edges])
|
||||||
|
|
||||||
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
|
const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])
|
||||||
|
|
||||||
const handleLoadExample = useCallback(() => {
|
const handleLoadExample = useCallback(() => {
|
||||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||||
@@ -360,10 +360,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const nodes = state.nodes as AppNode[]
|
const nodes = state.nodes as AppNode[]
|
||||||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||||||
setStateImmediate({ nodes, edges })
|
setStateImmediate({ nodes, edges })
|
||||||
if (state.version != null && state.version > PROJECT_VERSION) {
|
if (state.version != null && state.version > RECOLLECTION_VERSION) {
|
||||||
toast.error('Project was created with a newer app version')
|
toast.error('Recollection was created with a newer app version')
|
||||||
} else {
|
} else {
|
||||||
toast.success('Project loaded')
|
toast.success('Recollection loaded')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Invalid file: not valid JSON')
|
toast.error('Invalid file: not valid JSON')
|
||||||
@@ -534,8 +534,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||||
try {
|
try {
|
||||||
const inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } }
|
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
|
||||||
const screenToFlow = inst.screenToFlowPosition ?? inst.project
|
const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
|
||||||
|
const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
|
||||||
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
|
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
|
||||||
return p ? snapToGrid(p.x, p.y) : null
|
return p ? snapToGrid(p.x, p.y) : null
|
||||||
} catch {
|
} catch {
|
||||||
@@ -646,17 +647,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<CanvasMenubar
|
<CanvasMenubar
|
||||||
onImport={handleImportProject}
|
onImport={handleImportRecollection}
|
||||||
onExport={handleExportProject}
|
onExport={handleExportRecollection}
|
||||||
onSave={
|
onSave={
|
||||||
projectId
|
recollectionId
|
||||||
? () => {
|
? () => {
|
||||||
save()
|
save()
|
||||||
toast.success('Saved')
|
toast.success('Saved')
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
canSave={Boolean(projectId)}
|
canSave={Boolean(recollectionId)}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
undo={undo}
|
undo={undo}
|
||||||
redo={redo}
|
redo={redo}
|
||||||
@@ -694,11 +695,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
Right‑click to add nodes. <br />
|
Right‑click to add nodes. <br />
|
||||||
Import a project or paste a node.
|
Import a recollection or paste a node.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||||
<Button onClick={handleImportProject} variant="outline" size="sm">
|
<Button onClick={handleImportRecollection} variant="outline" size="sm">
|
||||||
<FolderOpen className="size-4" />
|
<FolderOpen className="size-4" />
|
||||||
Import…
|
Import…
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
* Route wrapper for the canvas: resolves recollectionId from URL and updates lastEditedAt on open.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
@@ -8,29 +8,29 @@ import { CanvasPage } from './CanvasPage'
|
|||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
|
||||||
export function CanvasRoute() {
|
export function CanvasRoute() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { projects, updateLastEdited } = usePlatform()
|
const { recollections, updateLastEdited } = usePlatform()
|
||||||
const updateLastEditedRef = useRef(updateLastEdited)
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
updateLastEditedRef.current = updateLastEdited
|
updateLastEditedRef.current = updateLastEdited
|
||||||
|
|
||||||
const project = projects.find((p) => p.id === projectId)
|
const recollection = recollections.find((p) => p.id === recollectionId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) updateLastEditedRef.current(projectId)
|
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||||
}, [projectId])
|
}, [recollectionId])
|
||||||
|
|
||||||
if (!projectId) return null
|
if (!recollectionId) return null
|
||||||
if (!project) {
|
if (!recollection) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
<p className="text-sm text-muted-foreground">Project not found.</p>
|
<p className="text-sm text-muted-foreground">Recollection not found.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<CanvasPage key={projectId} projectId={projectId} />
|
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
import { loadGraphFromStorage } from '@/app/recollections/recollectionGraphStorage'
|
||||||
|
|
||||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||||
export function backfillEdgeTargetTypes(
|
export function backfillEdgeTargetTypes(
|
||||||
@@ -70,9 +70,9 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
|||||||
return { nodes, edges }
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
if (projectId) {
|
if (recollectionId) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||||
const nodes = stored.nodes as AppNode[]
|
const nodes = stored.nodes as AppNode[]
|
||||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
||||||
* with initial graph from project storage (or example). Save is explicit via save().
|
* with initial graph from recollection storage (or example). Save is explicit via save().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||||
|
|
||||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
||||||
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
|
/** Persist current nodes/edges to storage. No-op when recollectionId is missing. */
|
||||||
save: () => void
|
save: () => void
|
||||||
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
|
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
|
||||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
|
||||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
const { nodes, edges } = result
|
const { nodes, edges } = result
|
||||||
|
|
||||||
@@ -41,14 +41,14 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
|
|||||||
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
|
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
|
||||||
|
|
||||||
const save = useCallback(() => {
|
const save = useCallback(() => {
|
||||||
if (!projectId) return
|
if (!recollectionId) return
|
||||||
const snapshot = JSON.stringify({
|
const snapshot = JSON.stringify({
|
||||||
nodes: nodesRef.current,
|
nodes: nodesRef.current,
|
||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
})
|
})
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
saveGraphToStorage(projectId, {
|
saveGraphToStorage(recollectionId, {
|
||||||
version: PROJECT_VERSION,
|
version: RECOLLECTION_VERSION,
|
||||||
nodes: nodesRef.current,
|
nodes: nodesRef.current,
|
||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
})
|
})
|
||||||
@@ -57,7 +57,7 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
|
|||||||
setLastSavedSerialized(snapshot)
|
setLastSavedSerialized(snapshot)
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}, SAVING_DISPLAY_MS)
|
}, SAVING_DISPLAY_MS)
|
||||||
}, [projectId])
|
}, [recollectionId])
|
||||||
|
|
||||||
return { ...result, save, saveStatus }
|
return { ...result, save, saveStatus }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Keroma page. Rendered at /keroma.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export function KeromaPage() {
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-1 flex-col min-h-0 p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
|
||||||
Keroma
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
|
||||||
<p className="text-muted-foreground">Welcome to Keroma.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Platform context: projects list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
|
* Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
|
||||||
* Used by AppSidebar, ProjectsTablePage, and canvas route.
|
* Used by AppSidebar, RecollectionsPage, and canvas route.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import type { Project } from './types'
|
import type { Recollection } from './types'
|
||||||
import { saveGraphToStorage } from '@/app/pleroma/projectGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||||
import { PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_projects'
|
const STORAGE_KEY = 'zui_platform_recollections'
|
||||||
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
|
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
||||||
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
|
const RECENT_STORAGE_KEY = 'zui_platform_recent_recollection_ids'
|
||||||
|
|
||||||
|
const LEGACY_STORAGE_KEY = 'zui_platform_emanations'
|
||||||
|
const LEGACY_ORDER_STORAGE_KEY = 'zui_platform_emanation_order'
|
||||||
|
const LEGACY_RECENT_STORAGE_KEY = 'zui_platform_recent_emanation_ids'
|
||||||
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
|
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
|
||||||
const AI_CONNECTION_KEY = 'zui_ai_connection'
|
const AI_CONNECTION_KEY = 'zui_ai_connection'
|
||||||
const RECENT_MAX = 5
|
const RECENT_MAX = 5
|
||||||
@@ -72,8 +75,15 @@ function saveShowMinimap(value: boolean) {
|
|||||||
|
|
||||||
function loadOrder(): string[] {
|
function loadOrder(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
let raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_ORDER_STORAGE_KEY)
|
||||||
|
if (legacy) {
|
||||||
|
localStorage.setItem(ORDER_STORAGE_KEY, legacy)
|
||||||
|
localStorage.removeItem(LEGACY_ORDER_STORAGE_KEY)
|
||||||
|
raw = legacy
|
||||||
|
} else return []
|
||||||
|
}
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,8 +97,15 @@ function saveOrder(ids: string[]) {
|
|||||||
|
|
||||||
function loadRecentIds(): string[] {
|
function loadRecentIds(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
let raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_RECENT_STORAGE_KEY)
|
||||||
|
if (legacy) {
|
||||||
|
localStorage.setItem(RECENT_STORAGE_KEY, legacy)
|
||||||
|
localStorage.removeItem(LEGACY_RECENT_STORAGE_KEY)
|
||||||
|
raw = legacy
|
||||||
|
} else return []
|
||||||
|
}
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
|
||||||
} catch {
|
} catch {
|
||||||
@@ -100,41 +117,48 @@ function saveRecentIds(ids: string[]) {
|
|||||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProjects(): Project[] {
|
function loadRecollections(): Recollection[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
let raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
|
||||||
|
if (legacy) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, legacy)
|
||||||
|
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
||||||
|
raw = legacy
|
||||||
|
} else return []
|
||||||
|
}
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
if (!Array.isArray(parsed)) return []
|
if (!Array.isArray(parsed)) return []
|
||||||
return parsed
|
return parsed
|
||||||
.filter(
|
.filter(
|
||||||
(p): p is Project =>
|
(p): p is Recollection =>
|
||||||
p &&
|
p &&
|
||||||
typeof p === 'object' &&
|
typeof p === 'object' &&
|
||||||
typeof (p as Project).id === 'string' &&
|
typeof (p as Recollection).id === 'string' &&
|
||||||
typeof (p as Project).name === 'string' &&
|
typeof (p as Recollection).name === 'string' &&
|
||||||
typeof (p as Project).iconId === 'string' &&
|
typeof (p as Recollection).iconId === 'string' &&
|
||||||
typeof (p as Project).createdAt === 'number'
|
typeof (p as Recollection).createdAt === 'number'
|
||||||
)
|
)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
lastEditedAt: typeof (p as Project).lastEditedAt === 'number' ? (p as Project).lastEditedAt : p.createdAt,
|
lastEditedAt: typeof (p as Recollection).lastEditedAt === 'number' ? (p as Recollection).lastEditedAt : p.createdAt,
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProjects(projects: Project[]) {
|
function saveRecollections(recollections: Recollection[]) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
|
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
|
||||||
|
|
||||||
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */
|
/** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */
|
||||||
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] {
|
export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] {
|
||||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
const byId = new Map(recollections.map((p) => [p.id, p]))
|
||||||
const ordered: Project[] = []
|
const ordered: Recollection[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
const p = byId.get(id)
|
const p = byId.get(id)
|
||||||
@@ -143,27 +167,27 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
|
|||||||
seen.add(id)
|
seen.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rest = projects
|
const rest = recollections
|
||||||
.filter((p) => !seen.has(p.id))
|
.filter((p) => !seen.has(p.id))
|
||||||
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
||||||
return [...ordered, ...rest]
|
return [...ordered, ...rest]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KosmosContextValue = {
|
export type KosmosContextValue = {
|
||||||
projects: Project[]
|
recollections: Recollection[]
|
||||||
projectOrder: string[]
|
recollectionOrder: string[]
|
||||||
/** Projects in display order (sidebar order, then lastEditedAt) */
|
/** Recollections in display order (sidebar order, then lastEditedAt) */
|
||||||
orderedProjects: Project[]
|
orderedRecollections: Recollection[]
|
||||||
/** Last RECENT_MAX accessed project IDs (most recent first) */
|
/** Last RECENT_MAX accessed recollection IDs (most recent first) */
|
||||||
recentProjectIds: string[]
|
recentRecollectionIds: string[]
|
||||||
recordProjectAccess: (id: string) => void
|
recordRecollectionAccess: (id: string) => void
|
||||||
persist: (next: Project[]) => void
|
persist: (next: Recollection[]) => void
|
||||||
createProject: (project: Project) => void
|
createRecollection: (recollection: Recollection) => void
|
||||||
deleteProject: (id: string) => void
|
deleteRecollection: (id: string) => void
|
||||||
renameProject: (id: string, name: string) => void
|
renameRecollection: (id: string, name: string) => void
|
||||||
updateLastEdited: (id: string) => void
|
updateLastEdited: (id: string) => void
|
||||||
reorderProjects: (orderedIds: string[]) => void
|
reorderRecollections: (orderedIds: string[]) => void
|
||||||
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
|
restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
|
||||||
/** Canvas: show React Flow minimap (persisted) */
|
/** Canvas: show React Flow minimap (persisted) */
|
||||||
showMinimap: boolean
|
showMinimap: boolean
|
||||||
setShowMinimap: (value: boolean) => void
|
setShowMinimap: (value: boolean) => void
|
||||||
@@ -175,9 +199,9 @@ export type KosmosContextValue = {
|
|||||||
const KosmosContext = createContext<KosmosContextValue | null>(null)
|
const KosmosContext = createContext<KosmosContextValue | null>(null)
|
||||||
|
|
||||||
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
|
||||||
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
|
||||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
|
||||||
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
||||||
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
|
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
|
||||||
|
|
||||||
@@ -191,128 +215,128 @@ export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
|||||||
saveAiConnection(value)
|
saveAiConnection(value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const persist = useCallback((next: Project[]) => {
|
const persist = useCallback((next: Recollection[]) => {
|
||||||
setProjects(next)
|
setRecollections(next)
|
||||||
saveProjects(next)
|
saveRecollections(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createProject = useCallback(
|
const createRecollection = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
const withEdited = { ...recollection, lastEditedAt: recollection.createdAt }
|
||||||
persist([...projects, withEdited])
|
persist([...recollections, withEdited])
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
|
||||||
saveOrder(next)
|
saveOrder(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteProject = useCallback(
|
const deleteRecollection = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const next = projects.filter((p) => p.id !== id)
|
const next = recollections.filter((p) => p.id !== id)
|
||||||
persist(next)
|
persist(next)
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const nextOrder = prev.filter((oid) => oid !== id)
|
const nextOrder = prev.filter((oid) => oid !== id)
|
||||||
saveOrder(nextOrder)
|
saveOrder(nextOrder)
|
||||||
return nextOrder
|
return nextOrder
|
||||||
})
|
})
|
||||||
setRecentProjectIds((prev) => {
|
setRecentRecollectionIds((prev) => {
|
||||||
const next = prev.filter((oid) => oid !== id)
|
const next = prev.filter((oid) => oid !== id)
|
||||||
saveRecentIds(next)
|
saveRecentIds(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const renameProject = useCallback(
|
const renameRecollection = useCallback(
|
||||||
(id: string, name: string) => {
|
(id: string, name: string) => {
|
||||||
const next = projects.map((p) => (p.id === id ? { ...p, name } : p))
|
const next = recollections.map((p) => (p.id === id ? { ...p, name } : p))
|
||||||
persist(next)
|
persist(next)
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateLastEdited = useCallback(
|
const updateLastEdited = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const next = projects.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
|
const next = recollections.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
|
||||||
persist(next)
|
persist(next)
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const reorderProjects = useCallback((orderedIds: string[]) => {
|
const reorderRecollections = useCallback((orderedIds: string[]) => {
|
||||||
setProjectOrder(orderedIds)
|
setRecollectionOrder(orderedIds)
|
||||||
saveOrder(orderedIds)
|
saveOrder(orderedIds)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const recordProjectAccess = useCallback((id: string) => {
|
const recordRecollectionAccess = useCallback((id: string) => {
|
||||||
setRecentProjectIds((prev) => {
|
setRecentRecollectionIds((prev) => {
|
||||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
|
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
|
||||||
saveRecentIds(next)
|
saveRecentIds(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const restoreProject = useCallback(
|
const restoreRecollection = useCallback(
|
||||||
(project: Project, graphSnapshot: GraphSnapshot | null) => {
|
(recollection: Recollection, graphSnapshot: GraphSnapshot | null) => {
|
||||||
persist([...projects, project])
|
persist([...recollections, recollection])
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
|
||||||
saveOrder(next)
|
saveOrder(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (graphSnapshot) {
|
if (graphSnapshot) {
|
||||||
saveGraphToStorage(project.id, {
|
saveGraphToStorage(recollection.id, {
|
||||||
version: PROJECT_VERSION,
|
version: RECOLLECTION_VERSION,
|
||||||
nodes: graphSnapshot.nodes,
|
nodes: graphSnapshot.nodes,
|
||||||
edges: graphSnapshot.edges,
|
edges: graphSnapshot.edges,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const orderedProjects = useMemo(
|
const orderedRecollections = useMemo(
|
||||||
() => sortProjectsByOrder(projects, projectOrder),
|
() => sortRecollectionsByOrder(recollections, recollectionOrder),
|
||||||
[projects, projectOrder]
|
[recollections, recollectionOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value: KosmosContextValue = useMemo(
|
const value: KosmosContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
projects,
|
recollections,
|
||||||
projectOrder,
|
recollectionOrder,
|
||||||
orderedProjects,
|
orderedRecollections,
|
||||||
recentProjectIds,
|
recentRecollectionIds,
|
||||||
recordProjectAccess,
|
recordRecollectionAccess,
|
||||||
persist,
|
persist,
|
||||||
createProject,
|
createRecollection,
|
||||||
deleteProject,
|
deleteRecollection,
|
||||||
renameProject,
|
renameRecollection,
|
||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
reorderProjects,
|
reorderRecollections,
|
||||||
restoreProject,
|
restoreRecollection,
|
||||||
showMinimap,
|
showMinimap,
|
||||||
setShowMinimap,
|
setShowMinimap,
|
||||||
aiConnection,
|
aiConnection,
|
||||||
setAiConnection,
|
setAiConnection,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
projects,
|
recollections,
|
||||||
projectOrder,
|
recollectionOrder,
|
||||||
orderedProjects,
|
orderedRecollections,
|
||||||
recentProjectIds,
|
recentRecollectionIds,
|
||||||
recordProjectAccess,
|
recordRecollectionAccess,
|
||||||
persist,
|
persist,
|
||||||
createProject,
|
createRecollection,
|
||||||
deleteProject,
|
deleteRecollection,
|
||||||
renameProject,
|
renameRecollection,
|
||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
reorderProjects,
|
reorderRecollections,
|
||||||
restoreProject,
|
restoreRecollection,
|
||||||
showMinimap,
|
showMinimap,
|
||||||
setShowMinimap,
|
setShowMinimap,
|
||||||
aiConnection,
|
aiConnection,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Platform: main layout with sidebar and header. Renders child routes (projects list or canvas) via Outlet.
|
* Platform: main layout with sidebar and header. Renders child routes (recollections list or canvas) via Outlet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
@@ -9,13 +9,13 @@ import { KosmosProvider, usePlatform } from './KosmosContext'
|
|||||||
import { KosmosSidebar } from './KosmosSidebar'
|
import { KosmosSidebar } from './KosmosSidebar'
|
||||||
|
|
||||||
function KosmosLayoutInner() {
|
function KosmosLayoutInner() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { recordProjectAccess } = usePlatform()
|
const { recordRecollectionAccess } = usePlatform()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) recordProjectAccess(projectId)
|
if (recollectionId) recordRecollectionAccess(recollectionId)
|
||||||
}, [projectId, recordProjectAccess])
|
}, [recollectionId, recordRecollectionAccess])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Platform sidebar: All projects, Recently used, New project.
|
* Platform sidebar: Recollections, Recently used, New recollection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
@@ -21,32 +21,30 @@ import { Plus, Settings, Triangle } from 'lucide-react'
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { usePlatform } from './KosmosContext'
|
import { usePlatform } from './KosmosContext'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewRecollectionDialog } from './NewRecollectionDialog'
|
||||||
import { SettingsDialog } from './SettingsDialog'
|
import { SettingsDialog } from './SettingsDialog'
|
||||||
import type { Project } from './types'
|
import type { Recollection } from './types'
|
||||||
|
|
||||||
export function KosmosSidebar() {
|
export function KosmosSidebar() {
|
||||||
const { state, setOpen } = useSidebar()
|
const { state, setOpen } = useSidebar()
|
||||||
const isCollapsed = state === 'collapsed'
|
const isCollapsed = state === 'collapsed'
|
||||||
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
|
const { orderedRecollections, createRecollection, recentRecollectionIds } = usePlatform()
|
||||||
const recentProjects = useMemo(() => {
|
const recentRecollections = useMemo(() => {
|
||||||
const byId = new Map(orderedProjects.map((p) => [p.id, p]))
|
const byId = new Map(orderedRecollections.map((p) => [p.id, p]))
|
||||||
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
|
return recentRecollectionIds.map((id) => byId.get(id)).filter((p): p is Recollection => p != null)
|
||||||
}, [orderedProjects, recentProjectIds])
|
}, [orderedRecollections, recentRecollectionIds])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const isKeroma = location.pathname === '/keroma'
|
const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
|
||||||
|
|
||||||
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
|
const handleCreateRecollection = useCallback(
|
||||||
|
(recollection: Parameters<typeof createRecollection>[0]) => {
|
||||||
const handleCreateProject = useCallback(
|
createRecollection(recollection)
|
||||||
(project: Parameters<typeof createProject>[0]) => {
|
navigate(`/recollections/${recollection.id}`)
|
||||||
createProject(project)
|
|
||||||
navigate(`/projects/${project.id}`)
|
|
||||||
},
|
},
|
||||||
[createProject, navigate]
|
[createRecollection, navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,7 +97,7 @@ export function KosmosSidebar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
||||||
<Link to="/projects">
|
<Link to="/recollections">
|
||||||
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
|
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
|
||||||
<span
|
<span
|
||||||
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
||||||
@@ -132,39 +130,31 @@ export function KosmosSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
|
<SidebarMenuButton asChild tooltip="Recollections" isActive={location.pathname === '/recollections' && !selectedRecollectionId}>
|
||||||
<Link to="/projects">
|
<Link to="/recollections">
|
||||||
<Triangle className="size-4" />
|
<Triangle className="size-4" />
|
||||||
<span>Pleroma</span>
|
<span>Recollections</span>
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
|
|
||||||
<Link to="/keroma">
|
|
||||||
<Triangle className="size-4 rotate-180" />
|
|
||||||
<span>Keroma</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
{recentProjects.length > 0 && (
|
{recentRecollections.length > 0 && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{recentProjects.map((project) => {
|
{recentRecollections.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const isActive = selectedProjectId === project.id
|
const isActive = selectedRecollectionId === recollection.id
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={project.id}>
|
<SidebarMenuItem key={recollection.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={project.name}
|
tooltip={recollection.name}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onClick={() => handleSelectProject(project.id)}
|
onClick={() => handleSelectRecollection(recollection.id)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
<span>{project.name}</span>
|
<span>{recollection.name}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
@@ -175,13 +165,13 @@ export function KosmosSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
<span>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span>
|
<span>{orderedRecollections.length === 0 ? 'Create your first recollection' : 'New recollection'}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Dialog to create a new project: name + icon.
|
* Dialog to create a new recollection: name + icon.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
@@ -16,20 +16,20 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
|
import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
|
|
||||||
type NewProjectDialogProps = {
|
type NewRecollectionDialogProps = {
|
||||||
onCreate: (project: Project) => void
|
onCreate: (recollection: Recollection) => void
|
||||||
trigger?: React.ReactNode
|
trigger?: React.ReactNode
|
||||||
/** Other project names to check for duplicates (case-insensitive warning only) */
|
/** Other recollection names to check for duplicates (case-insensitive warning only) */
|
||||||
existingNames?: string[]
|
existingNames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) {
|
export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
const [iconId, setIconId] = useState<RecollectionIconId>('cat')
|
||||||
|
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
|
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
|
||||||
@@ -37,13 +37,13 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
const project: Project = {
|
const recollection: Recollection = {
|
||||||
id: `proj_${Date.now()}`,
|
id: `recollection_${Date.now()}`,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
iconId,
|
iconId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}
|
}
|
||||||
onCreate(project)
|
onCreate(recollection)
|
||||||
setName('')
|
setName('')
|
||||||
setIconId('cat')
|
setIconId('cat')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -55,41 +55,41 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
|
|||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New project
|
New recollection
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New project</DialogTitle>
|
<DialogTitle>New recollection</DialogTitle>
|
||||||
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
|
<DialogDescription>Create a recollection to start editing a graph canvas.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="project-name" className="text-sm font-medium">
|
<label htmlFor="recollection-name" className="text-sm font-medium">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="project-name"
|
id="recollection-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="My project"
|
placeholder="My recollection"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{isDuplicate && (
|
{isDuplicate && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-500">A recollection with this name already exists.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm font-medium">Icon</label>
|
<label className="text-sm font-medium">Icon</label>
|
||||||
<Select value={iconId} onValueChange={(v) => setIconId(v as ProjectIconId)}>
|
<Select value={iconId} onValueChange={(v) => setIconId(v as RecollectionIconId)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{PROJECT_ICON_IDS.map((id) => {
|
{RECOLLECTION_ICON_IDS.map((id) => {
|
||||||
const Icon = getProjectIcon(id)
|
const Icon = getRecollectionIcon(id)
|
||||||
return (
|
return (
|
||||||
<SelectItem key={id} value={id}>
|
<SelectItem key={id} value={id}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Platform types: projects and sidebar state.
|
* Platform types: recollections and sidebar state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export type Project = {
|
export type Recollection = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
/** Icon identifier: key of PROJECT_ICONS map */
|
/** Icon identifier: key of RECOLLECTION_ICONS map */
|
||||||
iconId: string
|
iconId: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
/** Last time the project was opened/edited; used for sorting. Defaults to createdAt if missing. */
|
/** Last time the recollection was opened/edited; used for sorting. Defaults to createdAt if missing. */
|
||||||
lastEditedAt?: number
|
lastEditedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project icon ids: Lucide "Animals" category only */
|
/** Recollection icon ids: Lucide "Animals" category only */
|
||||||
export const PROJECT_ICON_IDS = [
|
export const RECOLLECTION_ICON_IDS = [
|
||||||
'bird',
|
'bird',
|
||||||
'bug',
|
'bug',
|
||||||
'cat',
|
'cat',
|
||||||
@@ -30,4 +30,4 @@ export const PROJECT_ICON_IDS = [
|
|||||||
'egg'
|
'egg'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]
|
export type RecollectionIconId = (typeof RECOLLECTION_ICON_IDS)[number]
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-project graph persistence (localStorage).
|
|
||||||
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { StoredGraphState } from '@/lib/graph/state'
|
|
||||||
|
|
||||||
export type { StoredGraphState }
|
|
||||||
export const PROJECT_FILE_EXT = '.zui.json'
|
|
||||||
export const PROJECT_VERSION = 1
|
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
|
||||||
|
|
||||||
export function getGraphStorageKey(projectId: string): string {
|
|
||||||
return `${GRAPH_KEY_PREFIX}${projectId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadGraphFromStorage(projectId: string): StoredGraphState | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getGraphStorageKey(projectId))
|
|
||||||
if (!raw) return null
|
|
||||||
const data = JSON.parse(raw) as unknown
|
|
||||||
if (!data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
|
|
||||||
return null
|
|
||||||
return data as StoredGraphState
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveGraphToStorage(projectId: string, state: StoredGraphState): void {
|
|
||||||
localStorage.setItem(getGraphStorageKey(projectId), JSON.stringify(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeGraphFromStorage(projectId: string): void {
|
|
||||||
localStorage.removeItem(getGraphStorageKey(projectId))
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Projects list page: table or cards view with search, sort, pagination, actions.
|
* Recollections list page: table or cards view with search, sort, pagination, actions.
|
||||||
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -56,18 +56,18 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
import {
|
import {
|
||||||
loadGraphFromStorage,
|
loadGraphFromStorage,
|
||||||
saveGraphToStorage,
|
saveGraphToStorage,
|
||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
PROJECT_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
} from './projectGraphStorage'
|
} from './recollectionGraphStorage'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewProjectDialog } from '@/app/kosmos/NewProjectDialog'
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||||
import { ProjectsPageBackground } from './ProjectsPageBackground'
|
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
||||||
import type { Project } from '@/app/kosmos/types'
|
import type { Recollection } from '@/app/kosmos/types'
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||||
type SortKey = 'lastEdited' | 'name' | 'created'
|
type SortKey = 'lastEdited' | 'name' | 'created'
|
||||||
@@ -98,8 +98,8 @@ function getRelativeTime(ts: number): string {
|
|||||||
return 'Just now'
|
return 'Just now'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
function getGraphCounts(recollectionId: string): { nodes: number; edges: number } {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
if (!stored) return { nodes: 0, edges: 0 }
|
if (!stored) return { nodes: 0, edges: 0 }
|
||||||
return {
|
return {
|
||||||
nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0,
|
nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0,
|
||||||
@@ -110,7 +110,7 @@ function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
|||||||
type NodeLike = { id: string; position?: { x: number; y: number } }
|
type NodeLike = { id: string; position?: { x: number; y: number } }
|
||||||
type EdgeLike = { id?: string; source: string; target: string }
|
type EdgeLike = { id?: string; source: string; target: string }
|
||||||
|
|
||||||
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New project placeholder. */
|
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New recollection placeholder. */
|
||||||
const THUMBNAIL_GRID = {
|
const THUMBNAIL_GRID = {
|
||||||
baseFill: 'hsl(var(--muted) / 0.4)',
|
baseFill: 'hsl(var(--muted) / 0.4)',
|
||||||
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
|
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
|
||||||
@@ -124,13 +124,13 @@ const thumbnailGridStyle: React.CSSProperties = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
||||||
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
function GraphThumbnail({ recollectionId, className }: { recollectionId: string; className?: string }) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
||||||
const edges = (stored?.edges ?? []) as EdgeLike[]
|
const edges = (stored?.edges ?? []) as EdgeLike[]
|
||||||
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
||||||
|
|
||||||
const dotGridPatternId = `dotgrid-${projectId.replace(/\W/g, '-')}`
|
const dotGridPatternId = `dotgrid-${recollectionId.replace(/\W/g, '-')}`
|
||||||
|
|
||||||
if (withPos.length === 0) {
|
if (withPos.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -207,47 +207,47 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectActionsMenuProps = {
|
type RecollectionActionsMenuProps = {
|
||||||
project: Project
|
recollection: Recollection
|
||||||
onOpen: (id: string) => void
|
onOpen: (id: string) => void
|
||||||
onRenameOpen: (project: Project) => void
|
onRenameOpen: (recollection: Recollection) => void
|
||||||
onDuplicateOpen: (project: Project) => void
|
onDuplicateOpen: (recollection: Recollection) => void
|
||||||
onExport: (project: Project) => void
|
onExport: (recollection: Recollection) => void
|
||||||
onDeleteOpen: (project: Project) => void
|
onDeleteOpen: (recollection: Recollection) => void
|
||||||
trigger: React.ReactNode
|
trigger: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectActionsMenu({
|
function RecollectionActionsMenu({
|
||||||
project,
|
recollection,
|
||||||
onOpen,
|
onOpen,
|
||||||
onRenameOpen,
|
onRenameOpen,
|
||||||
onDuplicateOpen,
|
onDuplicateOpen,
|
||||||
onExport,
|
onExport,
|
||||||
onDeleteOpen,
|
onDeleteOpen,
|
||||||
trigger,
|
trigger,
|
||||||
}: ProjectActionsMenuProps) {
|
}: RecollectionActionsMenuProps) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => onOpen(project.id)}>
|
<DropdownMenuItem onClick={() => onOpen(recollection.id)}>
|
||||||
<FolderOpen className="size-4" />
|
<FolderOpen className="size-4" />
|
||||||
Open
|
Open
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onRenameOpen(project)}>
|
<DropdownMenuItem onClick={() => onRenameOpen(recollection)}>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Rename
|
Rename
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}>
|
<DropdownMenuItem onClick={() => onDuplicateOpen(recollection)}>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onExport(project)}>
|
<DropdownMenuItem onClick={() => onExport(recollection)}>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Export
|
Export
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(project)}>
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(recollection)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -258,8 +258,8 @@ function ProjectActionsMenu({
|
|||||||
|
|
||||||
export type ViewMode = 'table' | 'cards'
|
export type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function RecollectionsPage() {
|
||||||
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -267,21 +267,21 @@ export function ProjectsPage() {
|
|||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||||
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
|
||||||
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null)
|
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
|
||||||
const [duplicateName, setDuplicateName] = useState('')
|
const [duplicateName, setDuplicateName] = useState('')
|
||||||
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
|
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Recollection[] | null>(null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return orderedProjects
|
if (!q) return orderedRecollections
|
||||||
return orderedProjects.filter((p) => p.name.toLowerCase().includes(q))
|
return orderedRecollections.filter((p) => p.name.toLowerCase().includes(q))
|
||||||
}, [orderedProjects, search])
|
}, [orderedRecollections, search])
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const arr = [...filtered]
|
const arr = [...filtered]
|
||||||
@@ -312,15 +312,15 @@ export function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpen = useCallback(
|
const handleOpen = useCallback(
|
||||||
(projectId: string) => {
|
(recollectionId: string) => {
|
||||||
navigate(`/projects/${projectId}`)
|
navigate(`/recollections/${recollectionId}`)
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRenameOpen = useCallback((project: Project) => {
|
const handleRenameOpen = useCallback((recollection: Recollection) => {
|
||||||
setRenameTarget(project)
|
setRenameTarget(recollection)
|
||||||
setRenameValue(project.name)
|
setRenameValue(recollection.name)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -339,47 +339,47 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
const handleRenameSubmit = useCallback(() => {
|
const handleRenameSubmit = useCallback(() => {
|
||||||
if (renameTarget && renameValue.trim()) {
|
if (renameTarget && renameValue.trim()) {
|
||||||
renameProject(renameTarget.id, renameValue.trim())
|
renameRecollection(renameTarget.id, renameValue.trim())
|
||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
setRenameValue('')
|
setRenameValue('')
|
||||||
}
|
}
|
||||||
}, [renameTarget, renameValue, renameProject])
|
}, [renameTarget, renameValue, renameRecollection])
|
||||||
|
|
||||||
const handleDeleteOpen = useCallback((project: Project) => {
|
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||||
setDeleteTarget(project)
|
setDeleteTarget(recollection)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(() => {
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
const project = deleteTarget
|
const recollection = deleteTarget
|
||||||
const graphSnapshot = loadGraphFromStorage(project.id)
|
const graphSnapshot = loadGraphFromStorage(recollection.id)
|
||||||
const snapshot =
|
const snapshot =
|
||||||
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
||||||
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
||||||
: null
|
: null
|
||||||
removeGraphFromStorage(project.id)
|
removeGraphFromStorage(recollection.id)
|
||||||
deleteProject(project.id)
|
deleteRecollection(recollection.id)
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
navigate('/projects', { replace: true })
|
navigate('/recollections', { replace: true })
|
||||||
toast(`"${project.name}" deleted`, {
|
toast(`"${recollection.name}" deleted`, {
|
||||||
action: {
|
action: {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
onClick: () => restoreProject(project, snapshot),
|
onClick: () => restoreRecollection(recollection, snapshot),
|
||||||
},
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
})
|
})
|
||||||
}, [deleteTarget, deleteProject, navigate, restoreProject])
|
}, [deleteTarget, deleteRecollection, navigate, restoreRecollection])
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
const stored = loadGraphFromStorage(project.id)
|
const stored = loadGraphFromStorage(recollection.id)
|
||||||
const state = stored
|
const state = stored
|
||||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
@@ -389,40 +389,40 @@ export function ProjectsPage() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCreateProject = useCallback(
|
const handleCreateRecollection = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
createProject(project)
|
createRecollection(recollection)
|
||||||
navigate(`/projects/${project.id}`)
|
navigate(`/recollections/${recollection.id}`)
|
||||||
},
|
},
|
||||||
[createProject, navigate]
|
[createRecollection, navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDuplicateOpen = useCallback((project: Project) => {
|
const handleDuplicateOpen = useCallback((recollection: Recollection) => {
|
||||||
setDuplicateTarget(project)
|
setDuplicateTarget(recollection)
|
||||||
setDuplicateName(`${project.name} (copy)`)
|
setDuplicateName(`${recollection.name} (copy)`)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDuplicateConfirm = useCallback(() => {
|
const handleDuplicateConfirm = useCallback(() => {
|
||||||
if (!duplicateTarget || !duplicateName.trim()) return
|
if (!duplicateTarget || !duplicateName.trim()) return
|
||||||
const name = duplicateName.trim()
|
const name = duplicateName.trim()
|
||||||
const newId = `proj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
const newId = `recollection_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const newProject: Project = {
|
const newRecollection: Recollection = {
|
||||||
id: newId,
|
id: newId,
|
||||||
name,
|
name,
|
||||||
iconId: duplicateTarget.iconId,
|
iconId: duplicateTarget.iconId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastEditedAt: now,
|
lastEditedAt: now,
|
||||||
}
|
}
|
||||||
createProject(newProject)
|
createRecollection(newRecollection)
|
||||||
const graph = loadGraphFromStorage(duplicateTarget.id)
|
const graph = loadGraphFromStorage(duplicateTarget.id)
|
||||||
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
||||||
saveGraphToStorage(newId, { version: PROJECT_VERSION, nodes: graph.nodes, edges: graph.edges })
|
saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
|
||||||
}
|
}
|
||||||
toast.success('Project duplicated')
|
toast.success('Recollection duplicated')
|
||||||
setDuplicateTarget(null)
|
setDuplicateTarget(null)
|
||||||
setDuplicateName('')
|
setDuplicateName('')
|
||||||
}, [duplicateTarget, duplicateName, createProject])
|
}, [duplicateTarget, duplicateName, createRecollection])
|
||||||
|
|
||||||
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
||||||
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
||||||
@@ -462,33 +462,33 @@ export function ProjectsPage() {
|
|||||||
const handleBulkDeleteConfirm = useCallback(() => {
|
const handleBulkDeleteConfirm = useCallback(() => {
|
||||||
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
||||||
const count = bulkDeleteTargets.length
|
const count = bulkDeleteTargets.length
|
||||||
bulkDeleteTargets.forEach((project) => {
|
bulkDeleteTargets.forEach((recollection) => {
|
||||||
removeGraphFromStorage(project.id)
|
removeGraphFromStorage(recollection.id)
|
||||||
deleteProject(project.id)
|
deleteRecollection(recollection.id)
|
||||||
})
|
})
|
||||||
setBulkDeleteTargets(null)
|
setBulkDeleteTargets(null)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
navigate('/projects', { replace: true })
|
navigate('/recollections', { replace: true })
|
||||||
toast(`${count} project${count === 1 ? '' : 's'} deleted`)
|
toast(`${count} recollection${count === 1 ? '' : 's'} deleted`)
|
||||||
}, [bulkDeleteTargets, deleteProject, navigate])
|
}, [bulkDeleteTargets, deleteRecollection, navigate])
|
||||||
|
|
||||||
const handleBulkExport = useCallback(() => {
|
const handleBulkExport = useCallback(() => {
|
||||||
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
||||||
toExport.forEach((project) => {
|
toExport.forEach((recollection) => {
|
||||||
const stored = loadGraphFromStorage(project.id)
|
const stored = loadGraphFromStorage(recollection.id)
|
||||||
const state = stored
|
const state = stored
|
||||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
})
|
})
|
||||||
toast.success(`Exported ${toExport.length} project${toExport.length === 1 ? '' : 's'}`)
|
toast.success(`Exported ${toExport.length} recollection${toExport.length === 1 ? '' : 's'}`)
|
||||||
}, [sorted, selectedIds])
|
}, [sorted, selectedIds])
|
||||||
|
|
||||||
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
||||||
@@ -498,7 +498,7 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col min-h-0">
|
<div className="relative flex flex-1 flex-col min-h-0">
|
||||||
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
|
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -511,14 +511,14 @@ export function ProjectsPage() {
|
|||||||
<div className="relative w-64 shrink-0">
|
<div className="relative w-64 shrink-0">
|
||||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search projects"
|
placeholder="Search recollections"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setPage(0)
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
className="h-9 w-full pl-8"
|
className="h-9 w-full pl-8"
|
||||||
aria-label="Search projects by name"
|
aria-label="Search recollections by name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex h-9 items-center gap-2">
|
<div className="ml-auto flex h-9 items-center gap-2">
|
||||||
@@ -562,9 +562,9 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||||
@@ -576,15 +576,15 @@ export function ProjectsPage() {
|
|||||||
; (e.currentTarget as HTMLElement).click()
|
; (e.currentTarget as HTMLElement).click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Create new project"
|
aria-label="Create new recollection"
|
||||||
>
|
>
|
||||||
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||||
<Plus className="size-12 text-muted-foreground" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<span className="font-medium text-muted-foreground">New project</span>
|
<span className="font-medium text-muted-foreground">New recollection</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{search.trim() ? 'No projects match your search.' : 'Create a new project'}
|
{search.trim() ? 'No recollections match your search.' : 'Create a new recollection'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,42 +663,42 @@ export function ProjectsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="text-xs">
|
<TableBody className="text-xs">
|
||||||
{pageItems.map((project) => {
|
{pageItems.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const counts = getGraphCounts(project.id)
|
const counts = getGraphCounts(recollection.id)
|
||||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||||
const isSelected = selectedIds.has(project.id)
|
const isSelected = selectedIds.has(recollection.id)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
key={recollection.id}
|
||||||
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
||||||
onClick={() => handleOpen(project.id)}
|
onClick={() => handleOpen(recollection.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleOpen(project.id)
|
handleOpen(recollection.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`Open ${project.name}`}
|
aria-label={`Open ${recollection.name}`}
|
||||||
>
|
>
|
||||||
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={() => toggleSelection(project.id)}
|
onCheckedChange={() => toggleSelection(recollection.id)}
|
||||||
aria-label={`Select ${project.name}`}
|
aria-label={`Select ${recollection.name}`}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-2 py-1.5 min-w-0">
|
<TableCell className="px-2 py-1.5 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium truncate">{project.name}</span>
|
<span className="font-medium truncate">{recollection.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
||||||
<span className="truncate block" title={formatDate(project.createdAt)}>
|
<span className="truncate block" title={formatDate(recollection.createdAt)}>
|
||||||
{formatDate(project.createdAt)}
|
{formatDate(recollection.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
|
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
|
||||||
@@ -716,15 +716,15 @@ export function ProjectsPage() {
|
|||||||
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
|
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ProjectActionsMenu
|
<RecollectionActionsMenu
|
||||||
project={project}
|
recollection={recollection}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onRenameOpen={handleRenameOpen}
|
onRenameOpen={handleRenameOpen}
|
||||||
onDuplicateOpen={handleDuplicateOpen}
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
onDeleteOpen={handleDeleteOpen}
|
onDeleteOpen={handleDeleteOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
|
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${recollection.name}`}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -741,7 +741,7 @@ export function ProjectsPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} of {totalPages}
|
Page {page + 1} of {totalPages}
|
||||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -787,9 +787,9 @@ export function ProjectsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||||
@@ -801,44 +801,44 @@ export function ProjectsPage() {
|
|||||||
; (e.currentTarget as HTMLElement).click()
|
; (e.currentTarget as HTMLElement).click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Create new project"
|
aria-label="Create new recollection"
|
||||||
>
|
>
|
||||||
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||||
<Plus className="size-12 text-muted-foreground" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<span className="font-medium text-muted-foreground">New project</span>
|
<span className="font-medium text-muted-foreground">New recollection</span>
|
||||||
<span className="text-xs text-muted-foreground">Create a new project</span>
|
<span className="text-xs text-muted-foreground">Create a new recollection</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pageItems.map((project) => {
|
{pageItems.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={recollection.id}
|
||||||
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||||
onClick={() => handleOpen(project.id)}
|
onClick={() => handleOpen(recollection.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleOpen(project.id)
|
handleOpen(recollection.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Open ${project.name}`}
|
aria-label={`Open ${recollection.name}`}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
||||||
<GraphThumbnail projectId={project.id} className="h-full w-full object-cover" />
|
<GraphThumbnail recollectionId={recollection.id} className="h-full w-full object-cover" />
|
||||||
<div
|
<div
|
||||||
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ProjectActionsMenu
|
<RecollectionActionsMenu
|
||||||
project={project}
|
recollection={recollection}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onRenameOpen={handleRenameOpen}
|
onRenameOpen={handleRenameOpen}
|
||||||
onDuplicateOpen={handleDuplicateOpen}
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
@@ -849,7 +849,7 @@ export function ProjectsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 rounded-full shadow-sm"
|
className="size-7 rounded-full shadow-sm"
|
||||||
aria-label={`Actions for ${project.name}`}
|
aria-label={`Actions for ${recollection.name}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
@@ -861,7 +861,7 @@ export function ProjectsPage() {
|
|||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate font-medium font-serif">{project.name}</span>
|
<span className="truncate font-medium font-serif">{recollection.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -878,7 +878,7 @@ export function ProjectsPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} of {totalPages}
|
Page {page + 1} of {totalPages}
|
||||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -928,8 +928,8 @@ export function ProjectsPage() {
|
|||||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rename project</DialogTitle>
|
<DialogTitle>Rename recollection</DialogTitle>
|
||||||
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input
|
<Input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
@@ -939,11 +939,11 @@ export function ProjectsPage() {
|
|||||||
if (e.key === 'Enter') handleRenameSubmit()
|
if (e.key === 'Enter') handleRenameSubmit()
|
||||||
if (e.key === 'Escape') setRenameTarget(null)
|
if (e.key === 'Escape') setRenameTarget(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Project name"
|
placeholder="Recollection name"
|
||||||
aria-label="Project name"
|
aria-label="Recollection name"
|
||||||
/>
|
/>
|
||||||
{renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && (
|
{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">A project with this name already exists.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||||
@@ -960,8 +960,8 @@ export function ProjectsPage() {
|
|||||||
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
||||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Duplicate project</DialogTitle>
|
<DialogTitle>Duplicate recollection</DialogTitle>
|
||||||
<DialogDescription>Enter a name for the duplicate project.</DialogDescription>
|
<DialogDescription>Enter a name for the duplicate recollection.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input
|
<Input
|
||||||
ref={duplicateInputRef}
|
ref={duplicateInputRef}
|
||||||
@@ -971,11 +971,11 @@ export function ProjectsPage() {
|
|||||||
if (e.key === 'Enter') handleDuplicateConfirm()
|
if (e.key === 'Enter') handleDuplicateConfirm()
|
||||||
if (e.key === 'Escape') setDuplicateTarget(null)
|
if (e.key === 'Escape') setDuplicateTarget(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Project name"
|
placeholder="Recollection name"
|
||||||
aria-label="Duplicate project name"
|
aria-label="Duplicate recollection name"
|
||||||
/>
|
/>
|
||||||
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
{duplicateTarget && duplicateName.trim() && orderedRecollections.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
||||||
@@ -992,7 +992,7 @@ export function ProjectsPage() {
|
|||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete project</DialogTitle>
|
<DialogTitle>Delete recollection</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{deleteTarget
|
{deleteTarget
|
||||||
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
|
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
|
||||||
@@ -1014,9 +1014,9 @@ export function ProjectsPage() {
|
|||||||
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
|
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} projects</DialogTitle>
|
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} recollections</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete these projects? This cannot be undone.
|
Are you sure you want to delete these recollections? This cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Subtle animated dot grid background for ProjectsPage.
|
* Subtle animated dot grid background for RecollectionsPage.
|
||||||
* Matches canvas grid (20px gap), with gentle wave movement.
|
* Matches canvas grid (20px gap), with gentle wave movement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ function getDotColor(): string {
|
|||||||
return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
|
return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsPageBackground({ className }: { className?: string }) {
|
export function RecollectionsPageBackground({ className }: { className?: string }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const timeRef = useRef(0)
|
const timeRef = useRef(0)
|
||||||
const rafRef = useRef<number>(0)
|
const rafRef = useRef<number>(0)
|
||||||
37
frontend/src/app/recollections/recollectionGraphStorage.ts
Normal file
37
frontend/src/app/recollections/recollectionGraphStorage.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Per-recollection graph persistence (localStorage).
|
||||||
|
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StoredGraphState } from '@/lib/graph/state'
|
||||||
|
|
||||||
|
export type { StoredGraphState }
|
||||||
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
|
export const RECOLLECTION_VERSION = 1
|
||||||
|
|
||||||
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
|
|
||||||
|
export function getGraphStorageKey(recollectionId: string): string {
|
||||||
|
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGraphFromStorage(recollectionId: string): StoredGraphState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getGraphStorageKey(recollectionId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
|
||||||
|
return null
|
||||||
|
return data as StoredGraphState
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGraphToStorage(recollectionId: string, state: StoredGraphState): void {
|
||||||
|
localStorage.setItem(getGraphStorageKey(recollectionId), JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGraphFromStorage(recollectionId: string): void {
|
||||||
|
localStorage.removeItem(getGraphStorageKey(recollectionId))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Map project icon ids to Lucide icons for the sidebar.
|
* Map recollection icon ids to Lucide icons for the sidebar.
|
||||||
* Only Lucide "Animals" category icons are allowed for projects.
|
* Only Lucide "Animals" category icons are allowed for recollections.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
Turtle,
|
Turtle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ProjectIconId } from '@/app/kosmos/types'
|
import type { RecollectionIconId } from '@/app/kosmos/types'
|
||||||
|
|
||||||
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
|
export const RECOLLECTION_ICON_MAP: Record<RecollectionIconId, LucideIcon> = {
|
||||||
bird: Bird,
|
bird: Bird,
|
||||||
bug: Bug,
|
bug: Bug,
|
||||||
cat: Cat,
|
cat: Cat,
|
||||||
@@ -35,6 +35,6 @@ export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
|
|||||||
egg: Egg
|
egg: Egg
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectIcon(iconId: string): LucideIcon {
|
export function getRecollectionIcon(iconId: string): LucideIcon {
|
||||||
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? Cat
|
return RECOLLECTION_ICON_MAP[iconId as RecollectionIconId] ?? Cat
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { ThemeProvider } from './lib/themeContext'
|
|||||||
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
||||||
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
||||||
import { KosmosPage } from './app/kosmos/KosmosPage'
|
import { KosmosPage } from './app/kosmos/KosmosPage'
|
||||||
import { ProjectsPage } from './app/pleroma/PleromaPage'
|
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
||||||
import { KeromaPage } from './app/keroma/KeromaPage'
|
|
||||||
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
||||||
import './lib/prismSetup'
|
import './lib/prismSetup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
@@ -23,10 +22,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<KosmosPage />}>
|
<Route path="/" element={<KosmosPage />}>
|
||||||
<Route index element={<Navigate to="/projects" replace />} />
|
<Route index element={<Navigate to="/recollections" replace />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="recollections" element={<RecollectionsPage />} />
|
||||||
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
<Route path="recollections/:recollectionId" element={<CanvasRoute />} />
|
||||||
<Route path="keroma" element={<KeromaPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
Reference in New Issue
Block a user