An encrypted personal cloud built on the AT Protocol.

Add toast notifications, metadata editing, file moving, and directory renaming

- Toast notification system with auto-dismiss, FIFO eviction, and ARIA
live regions; retrofit all existing store operations with user feedback
- Document metadata editing dialog (name, tags, description) with
encrypted re-persist via content key unwrap
- File and directory moving with recursive cycle detection and
non-atomic PDS recovery
- Directory renaming with encrypted metadata re-persist
- Rename "The Cabinet" to "Your Cabinet" throughout UI

[CL-266] [CL-252] [CL-253]

sans-self.org f8a177c1 112a0379

Waiting for spindle ...
+1179 -15
+6
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add web file and directory moving [#253](https://issues.opake.app/issues/253.html) 16 + - Add web metadata management for documents [#252](https://issues.opake.app/issues/252.html) 17 + - Add error toast notifications for user feedback [#266](https://issues.opake.app/issues/266.html) 18 + - Add distinct icons for image file types [#258](https://issues.opake.app/issues/258.html) 19 + - Add web directory creation and deletion [#254](https://issues.opake.app/issues/254.html) 15 20 - Add web file deletion [#148](https://issues.opake.app/issues/148.html) 16 21 - Add web file upload with client-side encryption [#146](https://issues.opake.app/issues/146.html) 17 22 - Integrate app store loading tracker into document fetching and downloads [#251](https://issues.opake.app/issues/251.html) ··· 102 107 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 103 108 104 109 ### Changed 110 + - Add web file deletion with confirmation dialog [#255](https://issues.opake.app/issues/255.html) 105 111 - Add cn() utility (clsx + tailwind-merge) [#208](https://issues.opake.app/issues/208.html) 106 112 - Add cn() utility (clsx + tailwind-merge) [#208](https://issues.opake.app/issues/208.html) 107 113 - Add metadata CLI command with tag and description subcommands [#190](https://issues.opake.app/issues/190.html)
+98
web/src/components/ToastContainer.tsx
··· 1 + // Toast notification renderer — fixed-position overlay with ARIA live region. 2 + 3 + import { useEffect, useRef } from "react"; 4 + import { 5 + CheckCircleIcon, 6 + InfoIcon, 7 + WarningCircleIcon, 8 + WarningIcon, 9 + XIcon, 10 + } from "@phosphor-icons/react"; 11 + import { useToastStore } from "@/stores/toast"; 12 + 13 + // --------------------------------------------------------------------------- 14 + // Variant config 15 + // --------------------------------------------------------------------------- 16 + 17 + const VARIANT_STYLES = { 18 + success: { alertClass: "alert-success", Icon: CheckCircleIcon }, 19 + error: { alertClass: "alert-error", Icon: WarningCircleIcon }, 20 + warning: { alertClass: "alert-warning", Icon: WarningIcon }, 21 + info: { alertClass: "alert-info", Icon: InfoIcon }, 22 + } as const; 23 + 24 + // --------------------------------------------------------------------------- 25 + // Toast item 26 + // --------------------------------------------------------------------------- 27 + 28 + interface ToastItemProps { 29 + readonly id: string; 30 + readonly variant: keyof typeof VARIANT_STYLES; 31 + readonly message: string; 32 + readonly duration: number; 33 + } 34 + 35 + function ToastItem({ id, variant, message, duration }: ToastItemProps) { 36 + const dismiss = useToastStore((s) => s.dismissToast); 37 + const timerRef = useRef<ReturnType<typeof setTimeout>>(null); 38 + 39 + useEffect(() => { 40 + timerRef.current = setTimeout(() => dismiss(id), duration); 41 + return () => { 42 + if (timerRef.current) clearTimeout(timerRef.current); 43 + }; 44 + }, [id, duration, dismiss]); 45 + 46 + const { alertClass, Icon } = VARIANT_STYLES[variant]; 47 + 48 + return ( 49 + <div 50 + role="status" 51 + className={`alert ${alertClass} animate-toast-enter shadow-panel-md pointer-events-auto flex items-center gap-2 rounded-xl px-4 py-3`} 52 + > 53 + <Icon size={18} weight="fill" className="shrink-0" /> 54 + <span className="text-ui flex-1">{message}</span> 55 + <button 56 + onClick={() => dismiss(id)} 57 + className="btn btn-ghost btn-xs btn-square rounded-md" 58 + aria-label="Dismiss notification" 59 + > 60 + <XIcon size={14} /> 61 + </button> 62 + </div> 63 + ); 64 + } 65 + 66 + // --------------------------------------------------------------------------- 67 + // Container 68 + // --------------------------------------------------------------------------- 69 + 70 + export function ToastContainer() { 71 + const toasts = useToastStore((s) => s.toasts); 72 + 73 + return ( 74 + <div 75 + role="region" 76 + aria-label="Notifications" 77 + className="pointer-events-none fixed right-4 bottom-4 z-200 flex w-80 flex-col gap-2" 78 + > 79 + {/* Screen reader live region — visually hidden */} 80 + <div className="sr-only" aria-live="polite" aria-atomic="false"> 81 + {toasts.map((t) => ( 82 + <div key={t.id}>{t.message}</div> 83 + ))} 84 + </div> 85 + 86 + {/* Visible toasts */} 87 + {toasts.map((t) => ( 88 + <ToastItem 89 + key={t.id} 90 + id={t.id} 91 + variant={t.variant} 92 + message={t.message} 93 + duration={t.duration} 94 + /> 95 + ))} 96 + </div> 97 + ); 98 + }
+20 -2
web/src/components/cabinet/FileActionMenu.tsx
··· 1 - import { DotsThreeVerticalIcon, DownloadSimpleIcon, TrashIcon } from "@phosphor-icons/react"; 1 + import { 2 + ArrowBendUpRightIcon, 3 + DotsThreeVerticalIcon, 4 + DownloadSimpleIcon, 5 + PencilSimpleIcon, 6 + TrashIcon, 7 + } from "@phosphor-icons/react"; 2 8 import { DropdownMenu } from "@/components/DropdownMenu"; 3 9 import { useAppStore } from "@/stores/app"; 4 10 import type { FileItem } from "./types"; 5 11 6 12 interface FileActionMenuProps { 7 13 readonly item: FileItem; 14 + readonly onEditMetadata?: () => void; 15 + readonly onRename?: () => void; 16 + readonly onMove?: () => void; 8 17 readonly onDownload?: () => void; 9 18 readonly onDelete?: () => void; 10 19 readonly onDeleteFolder?: () => void; ··· 12 21 13 22 export function FileActionMenu({ 14 23 item, 24 + onEditMetadata, 25 + onRename, 26 + onMove, 15 27 onDownload, 16 28 onDelete, 17 29 onDeleteFolder, ··· 33 45 } 34 46 35 47 const items = isFolder 36 - ? [{ icon: TrashIcon, label: "Delete", onClick: onDeleteFolder }] 48 + ? [ 49 + { icon: PencilSimpleIcon, label: "Rename", onClick: onRename }, 50 + { icon: ArrowBendUpRightIcon, label: "Move to\u2026", onClick: onMove }, 51 + { icon: TrashIcon, label: "Delete", onClick: onDeleteFolder }, 52 + ] 37 53 : [ 54 + { icon: PencilSimpleIcon, label: "Edit details", onClick: onEditMetadata }, 55 + { icon: ArrowBendUpRightIcon, label: "Move to\u2026", onClick: onMove }, 38 56 { icon: DownloadSimpleIcon, label: "Download", onClick: onDownload }, 39 57 { icon: TrashIcon, label: "Delete", onClick: onDelete }, 40 58 ];
+9
web/src/components/cabinet/FileGridCard.tsx
··· 7 7 interface FileGridCardProps { 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 + readonly onEditMetadata?: () => void; 11 + readonly onRename?: () => void; 12 + readonly onMove?: () => void; 10 13 readonly onDownload?: () => void; 11 14 readonly onDelete?: () => void; 12 15 readonly onDeleteFolder?: () => void; ··· 15 18 export function FileGridCard({ 16 19 item, 17 20 onClick, 21 + onEditMetadata, 22 + onRename, 23 + onMove, 18 24 onDownload, 19 25 onDelete, 20 26 onDeleteFolder, ··· 53 59 <div className="flex items-center gap-1"> 54 60 <FileActionMenu 55 61 item={item} 62 + onEditMetadata={onEditMetadata} 63 + onRename={onRename} 64 + onMove={onMove} 56 65 onDownload={onDownload} 57 66 onDelete={onDelete} 58 67 onDeleteFolder={onDeleteFolder}
+9
web/src/components/cabinet/FileListRow.tsx
··· 7 7 interface FileListRowProps { 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 + readonly onEditMetadata?: () => void; 11 + readonly onRename?: () => void; 12 + readonly onMove?: () => void; 10 13 readonly onDownload?: () => void; 11 14 readonly onDelete?: () => void; 12 15 readonly onDeleteFolder?: () => void; ··· 15 18 export function FileListRow({ 16 19 item, 17 20 onClick, 21 + onEditMetadata, 22 + onRename, 23 + onMove, 18 24 onDownload, 19 25 onDelete, 20 26 onDeleteFolder, ··· 50 56 <div className="w-6"> 51 57 <FileActionMenu 52 58 item={item} 59 + onEditMetadata={onEditMetadata} 60 + onRename={onRename} 61 + onMove={onMove} 53 62 onDownload={onDownload} 54 63 onDelete={onDelete} 55 64 onDeleteFolder={onDeleteFolder}
+195
web/src/components/cabinet/MetadataEditDialog.tsx
··· 1 + // Dialog for editing document metadata — name, tags, description. 2 + 3 + import { 4 + forwardRef, 5 + useCallback, 6 + useImperativeHandle, 7 + useRef, 8 + useState, 9 + type KeyboardEvent, 10 + } from "react"; 11 + import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react"; 12 + import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; 13 + import type { MetadataChanges } from "@/lib/metadata"; 14 + import type { FileItem } from "./types"; 15 + 16 + export type { MetadataChanges }; 17 + 18 + // --------------------------------------------------------------------------- 19 + // Handle 20 + // --------------------------------------------------------------------------- 21 + 22 + export interface MetadataEditDialogHandle { 23 + readonly show: (item: FileItem) => void; 24 + } 25 + 26 + interface MetadataEditDialogProps { 27 + readonly onSave: (uri: string, changes: MetadataChanges) => void; 28 + } 29 + 30 + // --------------------------------------------------------------------------- 31 + // Constants 32 + // --------------------------------------------------------------------------- 33 + 34 + const MAX_TAG_LENGTH = 32; 35 + const MAX_DESCRIPTION_LENGTH = 500; 36 + 37 + // --------------------------------------------------------------------------- 38 + // Component 39 + // --------------------------------------------------------------------------- 40 + 41 + export const MetadataEditDialog = forwardRef<MetadataEditDialogHandle, MetadataEditDialogProps>( 42 + function MetadataEditDialog({ onSave }, ref) { 43 + const dialogRef = useRef<HTMLDialogElement>(null); 44 + const [item, setItem] = useState<FileItem | null>(null); 45 + 46 + // Form state 47 + const [name, setName] = useState(""); 48 + const [tags, setTags] = useState<string[]>([]); 49 + const [tagInput, setTagInput] = useState(""); 50 + const [description, setDescription] = useState(""); 51 + 52 + const dismiss = useCallback(() => { 53 + dialogRef.current?.close(); 54 + setTimeout(() => { 55 + setItem(null); 56 + setTagInput(""); 57 + }, MODAL_TRANSITION_MS); 58 + }, []); 59 + 60 + useImperativeHandle(ref, () => ({ 61 + show: (fileItem: FileItem) => { 62 + setItem(fileItem); 63 + setName(fileItem.name); 64 + setTags([...fileItem.tags]); 65 + setDescription(fileItem.description ?? ""); 66 + setTagInput(""); 67 + dialogRef.current?.showModal(); 68 + }, 69 + })); 70 + 71 + const addTag = useCallback(() => { 72 + const trimmed = tagInput.trim().slice(0, MAX_TAG_LENGTH); 73 + if (trimmed && !tags.includes(trimmed)) { 74 + setTags((prev) => [...prev, trimmed]); 75 + } 76 + setTagInput(""); 77 + }, [tagInput, tags]); 78 + 79 + const removeTag = useCallback((tag: string) => { 80 + setTags((prev) => prev.filter((t) => t !== tag)); 81 + }, []); 82 + 83 + const handleTagKeyDown = useCallback( 84 + (e: KeyboardEvent<HTMLInputElement>) => { 85 + if (e.key === "Enter" || e.key === ",") { 86 + e.preventDefault(); 87 + addTag(); 88 + } 89 + }, 90 + [addTag], 91 + ); 92 + 93 + const handleSave = useCallback(() => { 94 + if (!item || !name.trim()) return; 95 + const changes: MetadataChanges = { 96 + name: name.trim(), 97 + tags: tags.length > 0 ? tags : undefined, 98 + description: description.trim() || undefined, 99 + }; 100 + onSave(item.uri, changes); 101 + dismiss(); 102 + }, [item, name, tags, description, onSave, dismiss]); 103 + 104 + const canSave = name.trim().length > 0; 105 + 106 + return ( 107 + <dialog ref={dialogRef} className="modal" aria-label="Edit file metadata"> 108 + <div className="modal-box max-w-sm"> 109 + <div className="flex flex-col items-center gap-3 text-center"> 110 + <div className="bg-accent flex size-11 items-center justify-center rounded-full"> 111 + <PencilSimpleIcon size={20} className="text-accent-content" /> 112 + </div> 113 + <h3 className="text-base-content text-sm font-semibold">Edit details</h3> 114 + </div> 115 + 116 + <div className="mt-4 flex flex-col gap-3"> 117 + {/* Name */} 118 + <label className="flex flex-col gap-1"> 119 + <span className="text-caption text-text-muted font-medium">Name</span> 120 + <input 121 + type="text" 122 + value={name} 123 + onChange={(e) => setName(e.target.value)} 124 + className="input input-bordered input-sm text-ui w-full rounded-lg" 125 + placeholder="File name" 126 + required 127 + /> 128 + </label> 129 + 130 + {/* Tags */} 131 + <div className="flex flex-col gap-1"> 132 + <span className="text-caption text-text-muted font-medium">Tags</span> 133 + <div className="flex flex-wrap items-center gap-1.5"> 134 + {tags.map((tag) => ( 135 + <span 136 + key={tag} 137 + className="badge badge-sm badge-ghost text-text-faint border-base-300/50 gap-1 border" 138 + > 139 + {tag} 140 + <button 141 + onClick={() => removeTag(tag)} 142 + className="hover:text-base-content" 143 + aria-label={`Remove tag ${tag}`} 144 + > 145 + <XIcon size={10} /> 146 + </button> 147 + </span> 148 + ))} 149 + <input 150 + type="text" 151 + value={tagInput} 152 + onChange={(e) => setTagInput(e.target.value)} 153 + onKeyDown={handleTagKeyDown} 154 + onBlur={addTag} 155 + className="input input-bordered input-xs w-20 rounded-md text-xs" 156 + placeholder="Add tag…" 157 + maxLength={MAX_TAG_LENGTH} 158 + /> 159 + </div> 160 + </div> 161 + 162 + {/* Description */} 163 + <label className="flex flex-col gap-1"> 164 + <span className="text-caption text-text-muted font-medium">Description</span> 165 + <textarea 166 + value={description} 167 + onChange={(e) => setDescription(e.target.value)} 168 + className="textarea textarea-bordered textarea-sm text-ui w-full rounded-lg" 169 + placeholder="Optional description" 170 + maxLength={MAX_DESCRIPTION_LENGTH} 171 + rows={3} 172 + /> 173 + </label> 174 + </div> 175 + 176 + <div className="modal-action justify-center gap-2"> 177 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 178 + Cancel 179 + </button> 180 + <button 181 + onClick={handleSave} 182 + disabled={!canSave} 183 + className="btn btn-primary btn-sm rounded-lg text-xs" 184 + > 185 + Save 186 + </button> 187 + </div> 188 + </div> 189 + <form method="dialog" className="modal-backdrop"> 190 + <button aria-label="Close">close</button> 191 + </form> 192 + </dialog> 193 + ); 194 + }, 195 + );
+248
web/src/components/cabinet/MoveDialog.tsx
··· 1 + // Dialog for moving a file or folder to a different directory. 2 + 3 + import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; 4 + import { ArrowBendUpRightIcon, FolderIcon, HouseIcon } from "@phosphor-icons/react"; 5 + import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; 6 + import { useDocumentsStore } from "@/stores/documents"; 7 + import type { DirectoryTreeSnapshot } from "@/lib/pdsTypes"; 8 + 9 + // --------------------------------------------------------------------------- 10 + // Handle 11 + // --------------------------------------------------------------------------- 12 + 13 + export interface MoveDialogHandle { 14 + readonly show: ( 15 + entryUri: string, 16 + entryName: string, 17 + entryKind: "file" | "folder", 18 + currentParent: string | null, 19 + disabledUris: ReadonlySet<string>, 20 + ) => void; 21 + } 22 + 23 + interface MoveDialogProps { 24 + readonly onMove: (entryUri: string, targetDirectoryUri: string | null) => void; 25 + } 26 + 27 + // --------------------------------------------------------------------------- 28 + // Tree node 29 + // --------------------------------------------------------------------------- 30 + 31 + interface TreeNodeProps { 32 + readonly uri: string; 33 + readonly name: string; 34 + readonly depth: number; 35 + readonly selectedUri: string | null; 36 + readonly disabledUris: ReadonlySet<string>; 37 + readonly currentParentUri: string | null; 38 + readonly snapshot: DirectoryTreeSnapshot; 39 + readonly onSelect: (uri: string | null) => void; 40 + } 41 + 42 + function TreeNode({ 43 + uri, 44 + name, 45 + depth, 46 + selectedUri, 47 + disabledUris, 48 + currentParentUri, 49 + snapshot, 50 + onSelect, 51 + }: TreeNodeProps) { 52 + const isDisabled = disabledUris.has(uri); 53 + const isSelected = selectedUri === uri; 54 + const isCurrent = uri === currentParentUri; 55 + 56 + // Child directories: entries that exist in the snapshot's directories map 57 + const dirEntry = snapshot.directories[uri]; 58 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 59 + const childDirUris = dirEntry ? dirEntry.entries.filter((e) => e in snapshot.directories) : []; 60 + 61 + return ( 62 + <li role="treeitem" aria-selected={isSelected} aria-disabled={isDisabled || undefined}> 63 + <button 64 + onClick={() => !isDisabled && onSelect(uri)} 65 + disabled={isDisabled} 66 + className={`text-ui flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors ${ 67 + isSelected ? "bg-accent text-accent-content" : "hover:bg-bg-hover" 68 + } ${isDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"}`} 69 + style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }} 70 + > 71 + <FolderIcon size={15} weight={isSelected ? "fill" : "regular"} className="shrink-0" /> 72 + <span className="truncate">{name}</span> 73 + {isCurrent && ( 74 + <span className="text-caption text-text-faint ml-auto shrink-0">(current)</span> 75 + )} 76 + </button> 77 + 78 + {childDirUris.length > 0 && ( 79 + <ul role="group"> 80 + {childDirUris.map((childUri) => { 81 + const child = snapshot.directories[childUri]; 82 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 83 + if (!child) return null; 84 + return ( 85 + <TreeNode 86 + key={childUri} 87 + uri={childUri} 88 + name={child.name} 89 + depth={depth + 1} 90 + selectedUri={selectedUri} 91 + disabledUris={disabledUris} 92 + currentParentUri={currentParentUri} 93 + snapshot={snapshot} 94 + onSelect={onSelect} 95 + /> 96 + ); 97 + })} 98 + </ul> 99 + )} 100 + </li> 101 + ); 102 + } 103 + 104 + // --------------------------------------------------------------------------- 105 + // Component 106 + // --------------------------------------------------------------------------- 107 + 108 + export const MoveDialog = forwardRef<MoveDialogHandle, MoveDialogProps>(function MoveDialog( 109 + { onMove }, 110 + ref, 111 + ) { 112 + const dialogRef = useRef<HTMLDialogElement>(null); 113 + const [entryUri, setEntryUri] = useState<string | null>(null); 114 + const [entryName, setEntryName] = useState(""); 115 + const [entryKind, setEntryKind] = useState<"file" | "folder">("file"); 116 + const [selectedTarget, setSelectedTarget] = useState<string | null>(null); 117 + const [disabledUris, setDisabledUris] = useState<ReadonlySet<string>>(new Set()); 118 + const [currentParentUri, setCurrentParentUri] = useState<string | null>(null); 119 + 120 + const treeSnapshot = useDocumentsStore((s) => s.treeSnapshot); 121 + 122 + const dismiss = useCallback(() => { 123 + dialogRef.current?.close(); 124 + setTimeout(() => { 125 + setEntryUri(null); 126 + setDisabledUris(new Set()); 127 + }, MODAL_TRANSITION_MS); 128 + }, []); 129 + 130 + useImperativeHandle(ref, () => ({ 131 + show: ( 132 + uri: string, 133 + name: string, 134 + kind: "file" | "folder", 135 + currentParent: string | null, 136 + disabled: ReadonlySet<string>, 137 + ) => { 138 + setEntryUri(uri); 139 + setEntryName(name); 140 + setEntryKind(kind); 141 + setSelectedTarget(null); 142 + setCurrentParentUri(currentParent); 143 + setDisabledUris(disabled); 144 + dialogRef.current?.showModal(); 145 + }, 146 + })); 147 + 148 + const handleMove = useCallback(() => { 149 + if (!entryUri) return; 150 + // selectedTarget is null for root, or a directory URI 151 + // Resolve null if root is selected (rootUri maps to null in the store) 152 + const targetUri = selectedTarget === treeSnapshot?.rootUri ? null : selectedTarget; 153 + onMove(entryUri, targetUri); 154 + dismiss(); 155 + }, [entryUri, selectedTarget, treeSnapshot, onMove, dismiss]); 156 + 157 + // Can move if a target is selected and it's different from current parent 158 + const canMove = 159 + selectedTarget !== null && 160 + selectedTarget !== currentParentUri && 161 + !(currentParentUri === null && selectedTarget === treeSnapshot?.rootUri); 162 + 163 + // Root directory children for the tree 164 + const rootDir = treeSnapshot?.rootUri ? treeSnapshot.directories[treeSnapshot.rootUri] : null; 165 + const rootChildren = rootDir 166 + ? rootDir.entries.filter((e) => treeSnapshot && e in treeSnapshot.directories) 167 + : []; 168 + 169 + return ( 170 + <dialog ref={dialogRef} className="modal" aria-label={`Move ${entryKind}`}> 171 + <div className="modal-box max-w-sm"> 172 + <div className="flex flex-col items-center gap-3 text-center"> 173 + <div className="bg-accent flex size-11 items-center justify-center rounded-full"> 174 + <ArrowBendUpRightIcon size={20} className="text-accent-content" /> 175 + </div> 176 + <h3 className="text-base-content text-sm font-semibold"> 177 + Move &ldquo;{entryName}&rdquo; 178 + </h3> 179 + </div> 180 + 181 + {/* Directory tree */} 182 + <div className="border-base-300/50 mt-4 max-h-64 overflow-y-auto rounded-lg border p-1"> 183 + <ul role="tree" aria-label="Choose destination folder"> 184 + {/* Root */} 185 + <li 186 + role="treeitem" 187 + aria-selected={treeSnapshot ? selectedTarget === treeSnapshot.rootUri : false} 188 + > 189 + <button 190 + onClick={() => treeSnapshot?.rootUri && setSelectedTarget(treeSnapshot.rootUri)} 191 + className={`text-ui flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors ${ 192 + selectedTarget === treeSnapshot?.rootUri 193 + ? "bg-accent text-accent-content" 194 + : "hover:bg-bg-hover" 195 + } cursor-pointer`} 196 + > 197 + <HouseIcon size={15} className="shrink-0" /> 198 + <span>Your Cabinet</span> 199 + {currentParentUri === null && ( 200 + <span className="text-caption text-text-faint ml-auto shrink-0">(current)</span> 201 + )} 202 + </button> 203 + 204 + {treeSnapshot && rootChildren.length > 0 && ( 205 + <ul role="group"> 206 + {rootChildren.map((childUri) => { 207 + const child = treeSnapshot.directories[childUri]; 208 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 209 + if (!child) return null; 210 + return ( 211 + <TreeNode 212 + key={childUri} 213 + uri={childUri} 214 + name={child.name} 215 + depth={1} 216 + selectedUri={selectedTarget} 217 + disabledUris={disabledUris} 218 + currentParentUri={currentParentUri} 219 + snapshot={treeSnapshot} 220 + onSelect={setSelectedTarget} 221 + /> 222 + ); 223 + })} 224 + </ul> 225 + )} 226 + </li> 227 + </ul> 228 + </div> 229 + 230 + <div className="modal-action justify-center gap-2"> 231 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 232 + Cancel 233 + </button> 234 + <button 235 + onClick={handleMove} 236 + disabled={!canMove} 237 + className="btn btn-primary btn-sm rounded-lg text-xs" 238 + > 239 + Move here 240 + </button> 241 + </div> 242 + </div> 243 + <form method="dialog" className="modal-backdrop"> 244 + <button aria-label="Close">close</button> 245 + </form> 246 + </dialog> 247 + ); 248 + });
+45
web/src/components/cabinet/PanelContent.tsx
··· 8 8 DeleteFolderConfirmDialog, 9 9 type DeleteFolderDialogHandle, 10 10 } from "./DeleteFolderConfirmDialog"; 11 + import { 12 + MetadataEditDialog, 13 + type MetadataEditDialogHandle, 14 + type MetadataChanges, 15 + } from "./MetadataEditDialog"; 16 + import { MoveDialog, type MoveDialogHandle } from "./MoveDialog"; 17 + import { RenameDialog, type RenameDialogHandle } from "./RenameDialog"; 18 + import { useDocumentsStore } from "@/stores/documents"; 11 19 import { getCryptoWorker } from "@/lib/worker"; 12 20 import type { FileItem } from "./types"; 13 21 ··· 18 26 readonly onDownload: (uri: string) => void; 19 27 readonly onDelete: (uri: string) => void; 20 28 readonly onDeleteFolder: (uri: string) => void; 29 + readonly onUpdateMetadata: (uri: string, changes: MetadataChanges) => void; 30 + readonly onMoveEntry: (entryUri: string, targetDirectoryUri: string | null) => void; 31 + readonly onRenameDirectory: (directoryUri: string, newName: string) => void; 21 32 } 22 33 23 34 export function PanelContent({ ··· 27 38 onDownload, 28 39 onDelete, 29 40 onDeleteFolder, 41 + onUpdateMetadata, 42 + onMoveEntry, 43 + onRenameDirectory, 30 44 }: PanelContentProps) { 31 45 const deleteDialogRef = useRef<ConfirmDialogHandle>(null); 32 46 const deleteFolderDialogRef = useRef<DeleteFolderDialogHandle>(null); 47 + const metadataDialogRef = useRef<MetadataEditDialogHandle>(null); 48 + const moveDialogRef = useRef<MoveDialogHandle>(null); 49 + const renameDialogRef = useRef<RenameDialogHandle>(null); 33 50 34 51 const handleDeleteFolderClick = async (item: FileItem) => { 35 52 const worker = getCryptoWorker(); ··· 37 54 deleteFolderDialogRef.current?.show(item.uri, item.name, counts.documents, counts.directories); 38 55 }; 39 56 57 + const handleMoveClick = async (item: FileItem) => { 58 + const snapshot = useDocumentsStore.getState().treeSnapshot; 59 + const currentParent = snapshot 60 + ? (Object.entries(snapshot.directories).find(([, entry]) => 61 + entry.entries.includes(item.uri), 62 + )?.[0] ?? null) 63 + : null; 64 + 65 + const disabled: ReadonlySet<string> = 66 + item.kind === "folder" 67 + ? new Set([ 68 + item.uri, 69 + ...(await getCryptoWorker().treeCollectDescendants(item.uri)).map((d) => d.uri), 70 + ]) 71 + : new Set(); 72 + 73 + moveDialogRef.current?.show(item.uri, item.name, item.kind, currentParent, disabled); 74 + }; 75 + 40 76 if (items.length === 0) { 41 77 return ( 42 78 <div className="hero py-16"> ··· 59 95 key={item.id} 60 96 item={item} 61 97 onClick={() => item.kind === "folder" && onOpen(item)} 98 + onEditMetadata={() => metadataDialogRef.current?.show(item)} 99 + onRename={() => renameDialogRef.current?.show(item.uri, item.name)} 100 + onMove={() => void handleMoveClick(item)} 62 101 onDownload={() => onDownload(item.uri)} 63 102 onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 64 103 onDeleteFolder={() => void handleDeleteFolderClick(item)} ··· 72 111 key={item.id} 73 112 item={item} 74 113 onClick={() => item.kind === "folder" && onOpen(item)} 114 + onEditMetadata={() => metadataDialogRef.current?.show(item)} 115 + onRename={() => renameDialogRef.current?.show(item.uri, item.name)} 116 + onMove={() => void handleMoveClick(item)} 75 117 onDownload={() => onDownload(item.uri)} 76 118 onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 77 119 onDeleteFolder={() => void handleDeleteFolderClick(item)} ··· 82 124 83 125 <DeleteConfirmDialog ref={deleteDialogRef} onConfirm={onDelete} /> 84 126 <DeleteFolderConfirmDialog ref={deleteFolderDialogRef} onConfirm={onDeleteFolder} /> 127 + <MetadataEditDialog ref={metadataDialogRef} onSave={onUpdateMetadata} /> 128 + <MoveDialog ref={moveDialogRef} onMove={onMoveEntry} /> 129 + <RenameDialog ref={renameDialogRef} onSave={onRenameDirectory} /> 85 130 </div> 86 131 ); 87 132 }
+100
web/src/components/cabinet/RenameDialog.tsx
··· 1 + // Dialog for renaming a directory. 2 + 3 + import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; 4 + import { PencilSimpleIcon } from "@phosphor-icons/react"; 5 + import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; 6 + 7 + // --------------------------------------------------------------------------- 8 + // Handle 9 + // --------------------------------------------------------------------------- 10 + 11 + export interface RenameDialogHandle { 12 + readonly show: (uri: string, currentName: string) => void; 13 + } 14 + 15 + interface RenameDialogProps { 16 + readonly onSave: (uri: string, newName: string) => void; 17 + } 18 + 19 + // --------------------------------------------------------------------------- 20 + // Component 21 + // --------------------------------------------------------------------------- 22 + 23 + export const RenameDialog = forwardRef<RenameDialogHandle, RenameDialogProps>(function RenameDialog( 24 + { onSave }, 25 + ref, 26 + ) { 27 + const dialogRef = useRef<HTMLDialogElement>(null); 28 + const [uri, setUri] = useState<string | null>(null); 29 + const [name, setName] = useState(""); 30 + 31 + const dismiss = useCallback(() => { 32 + dialogRef.current?.close(); 33 + setTimeout(() => setUri(null), MODAL_TRANSITION_MS); 34 + }, []); 35 + 36 + useImperativeHandle(ref, () => ({ 37 + show: (entryUri: string, currentName: string) => { 38 + setUri(entryUri); 39 + setName(currentName); 40 + dialogRef.current?.showModal(); 41 + }, 42 + })); 43 + 44 + const handleSave = useCallback(() => { 45 + if (!uri || !name.trim()) return; 46 + onSave(uri, name.trim()); 47 + dismiss(); 48 + }, [uri, name, onSave, dismiss]); 49 + 50 + const canSave = name.trim().length > 0; 51 + 52 + return ( 53 + <dialog ref={dialogRef} className="modal" aria-label="Rename folder"> 54 + <div className="modal-box max-w-sm"> 55 + <div className="flex flex-col items-center gap-3 text-center"> 56 + <div className="bg-accent flex size-11 items-center justify-center rounded-full"> 57 + <PencilSimpleIcon size={20} className="text-accent-content" /> 58 + </div> 59 + <h3 className="text-base-content text-sm font-semibold">Rename folder</h3> 60 + </div> 61 + 62 + <div className="mt-4"> 63 + <label className="flex flex-col gap-1"> 64 + <span className="text-caption text-text-muted font-medium">Name</span> 65 + <input 66 + type="text" 67 + value={name} 68 + onChange={(e) => setName(e.target.value)} 69 + onKeyDown={(e) => { 70 + if (e.key === "Enter" && canSave) { 71 + e.preventDefault(); 72 + handleSave(); 73 + } 74 + }} 75 + className="input input-bordered input-sm text-ui rounded-lg" 76 + placeholder="Folder name" 77 + required 78 + /> 79 + </label> 80 + </div> 81 + 82 + <div className="modal-action justify-center gap-2"> 83 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 84 + Cancel 85 + </button> 86 + <button 87 + onClick={handleSave} 88 + disabled={!canSave} 89 + className="btn btn-primary btn-sm rounded-lg text-xs" 90 + > 91 + Rename 92 + </button> 93 + </div> 94 + </div> 95 + <form method="dialog" className="modal-backdrop"> 96 + <button aria-label="Close">close</button> 97 + </form> 98 + </dialog> 99 + ); 100 + });
+1 -1
web/src/components/cabinet/Sidebar.tsx
··· 12 12 import { SidebarItem } from "./SidebarItem"; 13 13 14 14 const MAIN_NAV = [ 15 - { to: "/cabinet/files" as const, icon: FolderIcon, label: "The Cabinet" }, 15 + { to: "/cabinet/files" as const, icon: FolderIcon, label: "Your Cabinet" }, 16 16 { to: "/cabinet/encrypted" as const, icon: LockIcon, label: "Encrypted" }, 17 17 { to: "/cabinet/shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 18 18 ];
+8
web/src/index.css
··· 125 125 translate: 0 0; 126 126 } 127 127 } 128 + 129 + --animate-toast-enter: toast-enter 0.25s ease-out both; 130 + @keyframes toast-enter { 131 + from { 132 + opacity: 0; 133 + translate: 1rem 0; 134 + } 135 + } 128 136 } 129 137 130 138 /* ─── Utilities ──────────────────────────────────────────────────────────── */
+55 -1
web/src/lib/directory.ts
··· 9 9 import { uint8ArrayToBase64 } from "@/lib/encoding"; 10 10 import { rkeyFromUri } from "@/lib/atUri"; 11 11 import { getCryptoWorker } from "@/lib/worker"; 12 - import type { DirectoryRecord } from "@/lib/pdsTypes"; 12 + import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 13 + import type { DirectoryRecord, DirectoryMetadata } from "@/lib/pdsTypes"; 13 14 import type { Session } from "@/lib/storageTypes"; 14 15 15 16 // --------------------------------------------------------------------------- ··· 119 120 await addEntryToDirectory(parentDirectoryUri, newDirectoryUri, now, pdsUrl, did, session); 120 121 121 122 return newDirectoryUri; 123 + } 124 + 125 + // --------------------------------------------------------------------------- 126 + // Rename directory 127 + // --------------------------------------------------------------------------- 128 + 129 + export async function renameDirectory( 130 + directoryUri: string, 131 + newName: string, 132 + pdsUrl: string, 133 + did: string, 134 + privateKey: Uint8Array, 135 + session: Session, 136 + ): Promise<void> { 137 + const rkey = rkeyFromUri(directoryUri); 138 + 139 + const record = await authenticatedGetRecord<DirectoryRecord>( 140 + { pdsUrl, did, collection: "app.opake.directory", rkey }, 141 + session, 142 + ); 143 + 144 + const encryption = record.value.encryption; 145 + if (encryption.$type !== "app.opake.document#directEncryption") { 146 + throw new Error("Renaming keyring-encrypted directories is not yet supported"); 147 + } 148 + 149 + const worker = getCryptoWorker(); 150 + const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); 151 + 152 + // Decrypt existing metadata to preserve description 153 + const { ciphertext, nonce } = decryptEnvelope(record.value.encryptedMetadata); 154 + const existing: DirectoryMetadata = await worker.decryptDirectoryMetadata( 155 + contentKey, 156 + ciphertext, 157 + nonce, 158 + ); 159 + 160 + const updated: DirectoryMetadata = { ...existing, name: newName }; 161 + const encryptedMeta = await worker.encryptDirectoryMetadata(contentKey, updated); 162 + 163 + const updatedRecord: DirectoryRecord = { 164 + ...record.value, 165 + encryptedMetadata: { 166 + ciphertext: { $bytes: uint8ArrayToBase64(encryptedMeta.ciphertext) }, 167 + nonce: { $bytes: uint8ArrayToBase64(encryptedMeta.nonce) }, 168 + }, 169 + modifiedAt: new Date().toISOString(), 170 + }; 171 + 172 + await authenticatedPutRecord( 173 + { pdsUrl, did, collection: "app.opake.directory", rkey, record: updatedRecord }, 174 + session, 175 + ); 122 176 } 123 177 124 178 // ---------------------------------------------------------------------------
+74
web/src/lib/metadata.ts
··· 1 + // Metadata update orchestration — decrypt content key, merge changes, re-encrypt, persist to PDS. 2 + 3 + import { authenticatedPutRecord } from "@/lib/api"; 4 + import { uint8ArrayToBase64 } from "@/lib/encoding"; 5 + import { rkeyFromUri } from "@/lib/atUri"; 6 + import { getCryptoWorker } from "@/lib/worker"; 7 + import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 8 + import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 9 + import type { Session } from "@/lib/storageTypes"; 10 + 11 + /** User-editable metadata fields. mimeType and size are preserved from the original. */ 12 + export interface MetadataChanges { 13 + readonly name: string; 14 + readonly tags?: string[]; 15 + readonly description?: string; 16 + } 17 + 18 + /** 19 + * Update a document's metadata: decrypt existing, merge user changes (preserving 20 + * mimeType and size from the original), re-encrypt, and putRecord. 21 + */ 22 + export async function updateDocumentMetadata( 23 + record: PdsRecord<DocumentRecord>, 24 + changes: MetadataChanges, 25 + pdsUrl: string, 26 + did: string, 27 + privateKey: Uint8Array, 28 + session: Session, 29 + ): Promise<DocumentRecord> { 30 + const encryption = record.value.encryption; 31 + if (encryption.$type !== "app.opake.document#directEncryption") { 32 + throw new Error("Metadata editing for keyring-encrypted documents is not yet supported"); 33 + } 34 + 35 + const worker = getCryptoWorker(); 36 + const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); 37 + 38 + // Decrypt existing metadata to preserve mimeType and size 39 + const { ciphertext, nonce } = decryptEnvelope(record.value.encryptedMetadata); 40 + const existing: DocumentMetadata = await worker.decryptMetadata(contentKey, ciphertext, nonce); 41 + 42 + // Merge: user-editable fields from changes, immutable fields from existing 43 + const merged: DocumentMetadata = { 44 + name: changes.name, 45 + mimeType: existing.mimeType, 46 + size: existing.size, 47 + tags: changes.tags, 48 + description: changes.description, 49 + }; 50 + 51 + const encryptedMeta = await worker.encryptMetadata(contentKey, merged); 52 + 53 + const updatedRecord: DocumentRecord = { 54 + ...record.value, 55 + encryptedMetadata: { 56 + ciphertext: { $bytes: uint8ArrayToBase64(encryptedMeta.ciphertext) }, 57 + nonce: { $bytes: uint8ArrayToBase64(encryptedMeta.nonce) }, 58 + }, 59 + modifiedAt: new Date().toISOString(), 60 + }; 61 + 62 + await authenticatedPutRecord( 63 + { 64 + pdsUrl, 65 + did, 66 + collection: "app.opake.document", 67 + rkey: rkeyFromUri(record.uri), 68 + record: updatedRecord, 69 + }, 70 + session, 71 + ); 72 + 73 + return updatedRecord; 74 + }
+39
web/src/lib/move.ts
··· 1 + // Move orchestration — relocate an entry between directories via two PDS operations. 2 + 3 + import { removeEntryFromDirectory, addEntryToDirectory } from "@/lib/directory"; 4 + import type { Session } from "@/lib/storageTypes"; 5 + 6 + /** 7 + * Move an entry (document or directory) from one parent to another. 8 + * 9 + * NOT atomic — two separate putRecord calls. If the add fails after the 10 + * remove succeeded, attempts recovery by re-adding to the source. If 11 + * recovery also fails, the entry is orphaned (still exists as a record, 12 + * appears at root level on next tree rebuild). 13 + */ 14 + export async function moveEntry( 15 + entryUri: string, 16 + sourceDirectoryUri: string | null, 17 + targetDirectoryUri: string | null, 18 + pdsUrl: string, 19 + did: string, 20 + session: Session, 21 + ): Promise<void> { 22 + const modifiedAt = new Date().toISOString(); 23 + 24 + // Step 1: Remove from source 25 + await removeEntryFromDirectory(sourceDirectoryUri, entryUri, pdsUrl, did, session); 26 + 27 + // Step 2: Add to target — if this fails, attempt recovery 28 + try { 29 + await addEntryToDirectory(targetDirectoryUri, entryUri, modifiedAt, pdsUrl, did, session); 30 + } catch (addError) { 31 + console.error("[move] add to target failed, attempting recovery:", addError); 32 + try { 33 + await addEntryToDirectory(sourceDirectoryUri, entryUri, modifiedAt, pdsUrl, did, session); 34 + } catch (recoveryError) { 35 + console.error("[move] recovery failed — entry may be orphaned:", recoveryError); 36 + } 37 + throw addError; 38 + } 39 + }
+7 -1
web/src/main.tsx
··· 7 7 import type { RouterContext } from "@/routes/__root"; 8 8 import "./index.css"; 9 9 import { getCryptoWorker } from "@/lib/worker"; 10 + import { ToastContainer } from "@/components/ToastContainer"; 10 11 11 12 enableMapSet(); 12 13 enableArrayMethods(); ··· 40 41 return null; 41 42 } 42 43 43 - return <RouterProvider router={router} context={{ auth: { session, identity } }} />; 44 + return ( 45 + <> 46 + <RouterProvider router={router} context={{ auth: { session, identity } }} /> 47 + <ToastContainer /> 48 + </> 49 + ); 44 50 } 45 51 46 52 const rootElement = document.getElementById("root");
+6
web/src/routes/cabinet/files/$.tsx
··· 22 22 const downloadFile = useDocumentsStore((s) => s.downloadFile); 23 23 const deleteFile = useDocumentsStore((s) => s.deleteFile); 24 24 const deleteFolder = useDocumentsStore((s) => s.deleteFolder); 25 + const updateMetadata = useDocumentsStore((s) => s.updateMetadata); 26 + const moveEntry = useDocumentsStore((s) => s.moveEntry); 27 + const renameDirectory = useDocumentsStore((s) => s.renameDirectory); 25 28 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 26 29 27 30 useEffect(() => { ··· 46 49 onDownload={(uri) => void downloadFile(uri)} 47 50 onDelete={(uri) => void deleteFile(uri)} 48 51 onDeleteFolder={(uri) => void deleteFolder(uri)} 52 + onUpdateMetadata={(uri, changes) => void updateMetadata(uri, changes)} 53 + onMoveEntry={(uri, target) => void moveEntry(uri, target)} 54 + onRenameDirectory={(uri, name) => void renameDirectory(uri, name)} 49 55 /> 50 56 ); 51 57 }
+6
web/src/routes/cabinet/files/index.tsx
··· 13 13 const downloadFile = useDocumentsStore((s) => s.downloadFile); 14 14 const deleteFile = useDocumentsStore((s) => s.deleteFile); 15 15 const deleteFolder = useDocumentsStore((s) => s.deleteFolder); 16 + const updateMetadata = useDocumentsStore((s) => s.updateMetadata); 17 + const moveEntry = useDocumentsStore((s) => s.moveEntry); 18 + const renameDirectory = useDocumentsStore((s) => s.renameDirectory); 16 19 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 17 20 18 21 useEffect(() => { ··· 34 37 onDownload={(uri) => void downloadFile(uri)} 35 38 onDelete={(uri) => void deleteFile(uri)} 36 39 onDeleteFolder={(uri) => void deleteFolder(uri)} 40 + onUpdateMetadata={(uri, changes) => void updateMetadata(uri, changes)} 41 + onMoveEntry={(uri, target) => void moveEntry(uri, target)} 42 + onRenameDirectory={(uri, name) => void renameDirectory(uri, name)} 37 43 /> 38 44 ); 39 45 }
+2 -2
web/src/routes/cabinet/files/route.tsx
··· 178 178 {rkey ? ( 179 179 <li> 180 180 <Link to="/cabinet/files" className="text-text-faint"> 181 - The Cabinet 181 + Your Cabinet 182 182 </Link> 183 183 </li> 184 184 ) : ( 185 - <BreadcrumbActive>The Cabinet</BreadcrumbActive> 185 + <BreadcrumbActive>Your Cabinet</BreadcrumbActive> 186 186 )} 187 187 {ancestors.map((ancestor, index) => ( 188 188 <li key={ancestor.uri}>
+164 -8
web/src/stores/documents/store.ts
··· 18 18 import { downloadDocument } from "@/lib/download"; 19 19 import { deleteDocument } from "@/lib/delete"; 20 20 import { uploadDocument } from "@/lib/upload"; 21 - import { createDirectory, deleteDirectory } from "@/lib/directory"; 21 + import { 22 + createDirectory, 23 + deleteDirectory, 24 + renameDirectory as renameDirectoryOnPds, 25 + } from "@/lib/directory"; 26 + import { moveEntry as moveEntryOnPds } from "@/lib/move"; 27 + import { updateDocumentMetadata } from "@/lib/metadata"; 28 + import type { MetadataChanges } from "@/lib/metadata"; 29 + import { toastSuccess, toastError, toastInfo } from "@/stores/toast"; 22 30 import { storage, fetchAllRecords } from "./fetch"; 23 31 import { decryptDocumentRecord, markDecryptionFailed } from "./decrypt"; 24 32 import { directoryItemFromSnapshot, documentPlaceholder, applyTagFilter } from "./file-items"; ··· 52 60 readonly uploadFile: (file: File, directoryUri: string | null) => Promise<void>; 53 61 readonly createFolder: (name: string, directoryUri: string | null) => Promise<void>; 54 62 readonly deleteFolder: (directoryUri: string) => Promise<void>; 63 + readonly updateMetadata: (documentUri: string, changes: MetadataChanges) => Promise<void>; 64 + readonly moveEntry: (entryUri: string, targetDirectoryUri: string | null) => Promise<void>; 65 + readonly renameDirectory: (directoryUri: string, newName: string) => Promise<void>; 55 66 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 56 67 } 57 68 ··· 137 148 // Create placeholder FileItems for all documents 138 149 const documentItems = documentRecords.map((r) => [r.uri, documentPlaceholder(r)] as const); 139 150 140 - const items: Record<string, FileItem> = Object.fromEntries([ 151 + const items: Readonly<Record<string, FileItem>> = Object.fromEntries([ 141 152 ...directoryItems, 142 153 ...documentItems, 143 154 ]); 144 155 145 - const docRecordsMap: Record<string, PdsRecord<DocumentRecord>> = Object.fromEntries( 146 - documentRecords.map((r) => [r.uri, r] as const), 147 - ); 156 + const docRecordsMap: Readonly<Record<string, PdsRecord<DocumentRecord>>> = 157 + Object.fromEntries(documentRecords.map((r) => [r.uri, r] as const)); 148 158 149 159 set((draft) => { 150 160 draft.items = items; ··· 158 168 await get().ensureDirectoryDecrypted(null); 159 169 } catch (error) { 160 170 console.error("[documents] fetchAll failed:", error); 171 + toastError("Failed to load documents"); 161 172 done(); 162 173 set((draft) => { 163 174 draft.error = error instanceof Error ? error.message : String(error); ··· 272 283 const privateKey = base64ToUint8Array(identity.private_key); 273 284 274 285 await downloadDocument(record, pdsUrl, did, privateKey, session); 286 + toastSuccess("Download started"); 275 287 } catch (error) { 276 288 console.error("[documents] download failed:", documentUri, error); 289 + toastError("Download failed"); 277 290 } finally { 278 291 done(); 279 292 } ··· 294 307 const parentUri = findParentUri(treeSnapshot, documentUri); 295 308 296 309 await deleteDocument(documentUri, parentUri ?? null, pdsUrl, did, session); 310 + toastSuccess("File deleted"); 297 311 298 312 // Optimistic removal from store — reuse cached parentUri 299 313 set((draft) => { ··· 316 330 }); 317 331 } catch (error) { 318 332 console.error("[documents] delete failed:", documentUri, error); 333 + toastError("Failed to delete file"); 319 334 } finally { 320 335 done(); 321 336 } ··· 336 351 await uploadDocument(file, directoryUri, pdsUrl, did, publicKey, session); 337 352 338 353 // Refresh the entire tree so the new file appears 339 - done(); 340 354 await get().fetchAll(); 355 + toastSuccess("File uploaded"); 341 356 } catch (error) { 342 357 console.error("[documents] upload failed:", error); 358 + toastError("Upload failed"); 359 + } finally { 343 360 done(); 344 361 } 345 362 }, ··· 358 375 359 376 await createDirectory(name, directoryUri, pdsUrl, did, publicKey, session); 360 377 361 - done(); 362 378 await get().fetchAll(); 379 + toastSuccess("Folder created"); 363 380 } catch (error) { 364 381 console.error("[documents] createFolder failed:", error); 382 + toastError("Failed to create folder"); 383 + } finally { 365 384 done(); 366 385 } 367 386 }, ··· 423 442 } 424 443 } 425 444 }); 445 + toastSuccess("Folder deleted"); 426 446 } catch (error) { 427 447 console.error("[documents] deleteFolder failed:", folderUri, error); 448 + toastError("Failed to delete folder"); 449 + } finally { 450 + done(); 451 + } 452 + }, 453 + 454 + updateMetadata: async (documentUri: string, changes: MetadataChanges) => { 455 + const authState = useAuthStore.getState(); 456 + if (authState.session.status !== "active") return; 457 + 458 + const record = get().documentRecords[documentUri]; 459 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 460 + if (!record) return; 461 + 462 + if (record.value.encryption.$type !== "app.opake.document#directEncryption") { 463 + toastError("Cannot edit keyring-encrypted documents yet"); 464 + return; 465 + } 466 + 467 + const done = loading(`metadata:${documentUri}`); 468 + 469 + try { 470 + const { did, pdsUrl } = authState.session; 471 + const session = await storage.loadSession(did); 472 + const identity = await storage.loadIdentity(did); 473 + const privateKey = base64ToUint8Array(identity.private_key); 474 + 475 + const updatedRecord = await updateDocumentMetadata( 476 + record, 477 + changes, 478 + pdsUrl, 479 + did, 480 + privateKey, 481 + session, 482 + ); 483 + 484 + // Update store item + cached record in place 485 + set((draft) => { 486 + const item = draft.items[documentUri]; 487 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 488 + if (item) { 489 + /* eslint-disable functional/immutable-data -- immer draft mutation */ 490 + item.name = changes.name; 491 + item.tags = changes.tags ?? []; 492 + item.description = changes.description; 493 + /* eslint-enable functional/immutable-data */ 494 + } 495 + const storedRecord = draft.documentRecords[documentUri]; 496 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 497 + if (storedRecord) { 498 + // eslint-disable-next-line functional/immutable-data -- immer draft mutation 499 + storedRecord.value = castDraft(updatedRecord); 500 + } 501 + }); 502 + 503 + toastSuccess("Metadata updated"); 504 + } catch (error) { 505 + console.error("[documents] updateMetadata failed:", documentUri, error); 506 + toastError("Failed to update metadata"); 507 + } finally { 508 + done(); 509 + } 510 + }, 511 + 512 + moveEntry: async (entryUri: string, targetDirectoryUri: string | null) => { 513 + const authState = useAuthStore.getState(); 514 + if (authState.session.status !== "active") return; 515 + 516 + const { treeSnapshot, items } = get(); 517 + const currentParentUri = findParentUri(treeSnapshot, entryUri) ?? null; 518 + 519 + // Noop if already in target 520 + if (currentParentUri === targetDirectoryUri) { 521 + toastInfo("Already in that folder"); 522 + return; 523 + } 524 + 525 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 526 + const entryName = items[entryUri]?.name ?? "item"; 527 + const done = loading(`move:${entryUri}`); 528 + 529 + try { 530 + const { did, pdsUrl } = authState.session; 531 + const session = await storage.loadSession(did); 532 + 533 + await moveEntryOnPds(entryUri, currentParentUri, targetDirectoryUri, pdsUrl, did, session); 534 + 535 + // Rebuild tree to reflect the move 536 + await get().fetchAll(); 537 + toastSuccess(`Moved "${entryName}"`); 538 + } catch (error) { 539 + console.error("[documents] moveEntry failed:", entryUri, error); 540 + toastError(`Failed to move "${entryName}"`); 541 + // Rebuild tree to recover from potential partial state 542 + await get().fetchAll(); 543 + } finally { 544 + done(); 545 + } 546 + }, 547 + 548 + renameDirectory: async (directoryUri: string, newName: string) => { 549 + const authState = useAuthStore.getState(); 550 + if (authState.session.status !== "active") return; 551 + 552 + const done = loading(`rename:${directoryUri}`); 553 + 554 + try { 555 + const { did, pdsUrl } = authState.session; 556 + const session = await storage.loadSession(did); 557 + const identity = await storage.loadIdentity(did); 558 + const privateKey = base64ToUint8Array(identity.private_key); 559 + 560 + await renameDirectoryOnPds(directoryUri, newName, pdsUrl, did, privateKey, session); 561 + 562 + // Update store item + tree snapshot in place 563 + set((draft) => { 564 + const item = draft.items[directoryUri]; 565 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 566 + if (item) { 567 + // eslint-disable-next-line functional/immutable-data -- immer draft mutation 568 + item.name = newName; 569 + } 570 + if (draft.treeSnapshot) { 571 + const dirEntry = draft.treeSnapshot.directories[directoryUri]; 572 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 573 + if (dirEntry) { 574 + // eslint-disable-next-line functional/immutable-data -- immer draft mutation 575 + dirEntry.name = newName; 576 + } 577 + } 578 + }); 579 + 580 + toastSuccess("Folder renamed"); 581 + } catch (error) { 582 + console.error("[documents] renameDirectory failed:", directoryUri, error); 583 + toastError("Failed to rename folder"); 428 584 } finally { 429 585 done(); 430 586 } ··· 449 605 const parentUri = findParent(current); 450 606 if (!parentUri) return acc; 451 607 452 - // Stop before adding root — root is always rendered as "The Cabinet" 608 + // Stop before adding root — root is always rendered as "Your Cabinet" 453 609 if (parentUri === treeSnapshot.rootUri) return acc; 454 610 455 611 const parentEntry = treeSnapshot.directories[parentUri];
+87
web/src/stores/toast.ts
··· 1 + // Toast notification store — transient feedback for user-facing operations. 2 + 3 + import { create } from "zustand"; 4 + import { immer } from "zustand/middleware/immer"; 5 + 6 + // --------------------------------------------------------------------------- 7 + // Types 8 + // --------------------------------------------------------------------------- 9 + 10 + type ToastVariant = "success" | "error" | "warning" | "info"; 11 + 12 + interface Toast { 13 + readonly id: string; 14 + readonly variant: ToastVariant; 15 + readonly message: string; 16 + readonly duration: number; 17 + } 18 + 19 + interface ToastState { 20 + toasts: Toast[]; 21 + readonly addToast: (variant: ToastVariant, message: string, duration?: number) => string; 22 + readonly dismissToast: (id: string) => void; 23 + } 24 + 25 + // --------------------------------------------------------------------------- 26 + // Defaults 27 + // --------------------------------------------------------------------------- 28 + 29 + const DEFAULT_DURATIONS: Readonly<Record<ToastVariant, number>> = { 30 + success: 4000, 31 + error: 8000, 32 + warning: 6000, 33 + info: 5000, 34 + }; 35 + 36 + const MAX_VISIBLE = 5; 37 + 38 + // --------------------------------------------------------------------------- 39 + // Store 40 + // --------------------------------------------------------------------------- 41 + 42 + export const useToastStore = create<ToastState>()( 43 + immer((set) => ({ 44 + toasts: [], 45 + 46 + addToast: (variant, message, duration) => { 47 + const id = crypto.randomUUID(); 48 + const resolvedDuration = duration ?? DEFAULT_DURATIONS[variant]; 49 + 50 + set((draft) => { 51 + // Evict oldest when at capacity 52 + if (draft.toasts.length >= MAX_VISIBLE) { 53 + draft.toasts.shift(); 54 + } 55 + draft.toasts.push({ id, variant, message, duration: resolvedDuration }); 56 + }); 57 + 58 + return id; 59 + }, 60 + 61 + dismissToast: (id) => { 62 + set((draft) => { 63 + draft.toasts = draft.toasts.filter((t) => t.id !== id); 64 + }); 65 + }, 66 + })), 67 + ); 68 + 69 + // --------------------------------------------------------------------------- 70 + // Convenience functions — callable outside React components 71 + // --------------------------------------------------------------------------- 72 + 73 + export function toastSuccess(message: string, duration?: number): string { 74 + return useToastStore.getState().addToast("success", message, duration); 75 + } 76 + 77 + export function toastError(message: string, duration?: number): string { 78 + return useToastStore.getState().addToast("error", message, duration); 79 + } 80 + 81 + export function toastWarning(message: string, duration?: number): string { 82 + return useToastStore.getState().addToast("warning", message, duration); 83 + } 84 + 85 + export function toastInfo(message: string, duration?: number): string { 86 + return useToastStore.getState().addToast("info", message, duration); 87 + }