An encrypted personal cloud built on the AT Protocol.

Add web directory creation/deletion and image file icons [CL-254] [CL-258]

Wire New Folder button to encrypted directory creation via WASM worker,
add recursive folder deletion with descendant count confirmation dialog,
and add distinct icon/color for image file types. Extract shared
directory entry helpers and refactor delete.ts to reuse them.

+537 -96
+1 -1
web/src/components/ConfirmDialog.tsx
··· 8 8 type ReactNode, 9 9 } from "react"; 10 10 11 - const MODAL_TRANSITION_MS = 200; 11 + export const MODAL_TRANSITION_MS = 200; 12 12 13 13 export interface ConfirmDialogHandle { 14 14 /** Show the dialog. `key` is passed to onConfirm; `label` is passed to the render function. */
+61
web/src/components/cabinet/DeleteFolderConfirmDialog.tsx
··· 1 + import { forwardRef, useImperativeHandle, useRef, useState } from "react"; 2 + import { WarningIcon } from "@phosphor-icons/react"; 3 + import { ConfirmDialog, type ConfirmDialogHandle } from "@/components/ConfirmDialog"; 4 + 5 + export interface DeleteFolderDialogHandle { 6 + readonly show: (uri: string, name: string, documents: number, directories: number) => void; 7 + } 8 + 9 + interface DeleteFolderConfirmDialogProps { 10 + readonly onConfirm: (uri: string) => void; 11 + } 12 + 13 + interface DescendantCounts { 14 + readonly documents: number; 15 + readonly directories: number; 16 + } 17 + 18 + export const DeleteFolderConfirmDialog = forwardRef< 19 + DeleteFolderDialogHandle, 20 + DeleteFolderConfirmDialogProps 21 + >(function DeleteFolderConfirmDialog({ onConfirm }, ref) { 22 + const innerRef = useRef<ConfirmDialogHandle>(null); 23 + const [counts, setCounts] = useState<DescendantCounts>({ documents: 0, directories: 0 }); 24 + 25 + useImperativeHandle(ref, () => ({ 26 + show: (uri: string, name: string, documents: number, directories: number) => { 27 + setCounts({ documents, directories }); 28 + innerRef.current?.show(uri, name); 29 + }, 30 + })); 31 + 32 + const contentsDescription = (() => { 33 + const parts = [ 34 + counts.documents > 0 ? `${counts.documents} file${counts.documents === 1 ? "" : "s"}` : null, 35 + counts.directories > 0 36 + ? `${counts.directories} folder${counts.directories === 1 ? "" : "s"}` 37 + : null, 38 + ].filter((p): p is string => p !== null); 39 + return parts.length > 0 ? `Contains ${parts.join(" and ")}. ` : ""; 40 + })(); 41 + 42 + return ( 43 + <ConfirmDialog 44 + ref={innerRef} 45 + title="Delete folder?" 46 + icon={WarningIcon} 47 + iconClassName="text-error" 48 + iconBgClassName="bg-error/10" 49 + confirmLabel="Delete" 50 + confirmClassName="btn btn-error btn-sm rounded-lg text-xs" 51 + onConfirm={onConfirm} 52 + > 53 + {(folderName) => ( 54 + <p> 55 + <span className="text-base-content font-medium">{folderName}</span> and all its contents 56 + will be permanently deleted. {contentsDescription}This cannot be undone. 57 + </p> 58 + )} 59 + </ConfirmDialog> 60 + ); 61 + });
+23 -10
web/src/components/cabinet/FileActionMenu.tsx
··· 7 7 readonly item: FileItem; 8 8 readonly onDownload?: () => void; 9 9 readonly onDelete?: () => void; 10 + readonly onDeleteFolder?: () => void; 10 11 } 11 12 12 - export function FileActionMenu({ item, onDownload, onDelete }: FileActionMenuProps) { 13 + export function FileActionMenu({ 14 + item, 15 + onDownload, 16 + onDelete, 17 + onDeleteFolder, 18 + }: FileActionMenuProps) { 13 19 const isFolder = item.kind === "folder"; 14 20 const downloading = useAppStore((s) => s.isLoading(`download:${item.uri}`)); 15 21 const deleting = useAppStore((s) => s.isLoading(`delete:${item.uri}`)); 16 22 17 - if (isFolder || !item.decrypted || item.name === "[Keyring encrypted]") return null; 23 + if (!isFolder && (!item.decrypted || item.name === "[Keyring encrypted]")) return null; 18 24 19 25 if (downloading || deleting) { 20 26 return ( ··· 26 32 ); 27 33 } 28 34 29 - return ( 30 - <DropdownMenu 31 - triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 32 - trigger={<DotsThreeVerticalIcon size={24} weight="bold" className="text-base-content" />} 33 - align="right" 34 - items={[ 35 + const items = isFolder 36 + ? [{ icon: TrashIcon, label: "Delete", onClick: onDeleteFolder }] 37 + : [ 35 38 { icon: DownloadSimpleIcon, label: "Download", onClick: onDownload }, 36 39 { icon: TrashIcon, label: "Delete", onClick: onDelete }, 37 - ]} 38 - /> 40 + ]; 41 + 42 + return ( 43 + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- stopPropagation wrapper to prevent folder row navigation 44 + <div onClick={(e) => e.stopPropagation()}> 45 + <DropdownMenu 46 + triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 47 + trigger={<DotsThreeVerticalIcon size={24} weight="bold" className="text-base-content" />} 48 + align="right" 49 + items={items} 50 + /> 51 + </div> 39 52 ); 40 53 }
+14 -2
web/src/components/cabinet/FileGridCard.tsx
··· 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 11 readonly onDelete?: () => void; 12 + readonly onDeleteFolder?: () => void; 12 13 } 13 14 14 - export function FileGridCard({ item, onClick, onDownload, onDelete }: FileGridCardProps) { 15 + export function FileGridCard({ 16 + item, 17 + onClick, 18 + onDownload, 19 + onDelete, 20 + onDeleteFolder, 21 + }: FileGridCardProps) { 15 22 const { bg, text } = fileIconColors(item); 16 23 const isFolder = item.kind === "folder"; 17 24 ··· 44 51 {fileIconElement(item, 17)} 45 52 </div> 46 53 <div className="flex items-center gap-1"> 47 - <FileActionMenu item={item} onDownload={onDownload} onDelete={onDelete} /> 54 + <FileActionMenu 55 + item={item} 56 + onDownload={onDownload} 57 + onDelete={onDelete} 58 + onDeleteFolder={onDeleteFolder} 59 + /> 48 60 <LockIcon size={11} className="text-text-faint" /> 49 61 </div> 50 62 </div>
+4
web/src/components/cabinet/FileIcons.tsx
··· 2 2 FolderIcon, 3 3 FileTextIcon, 4 4 FileIcon, 5 + FileImageIcon, 5 6 BookOpenIcon, 6 7 ArchiveIcon, 7 8 } from "@phosphor-icons/react"; ··· 20 21 pdf: { bg: "bg-file-pdf-bg", text: "text-file-pdf" }, 21 22 note: { bg: "bg-accent", text: "text-file-note" }, 22 23 code: { bg: "bg-file-code-bg", text: "text-file-code" }, 24 + image: { bg: "bg-file-image-bg", text: "text-file-image" }, 23 25 archive: { bg: "bg-bg-stone", text: "text-text-muted" }, 24 26 }; 25 27 ··· 37 39 return <FileTextIcon size={size} />; 38 40 case "spreadsheet": 39 41 return <FileIcon size={size} />; 42 + case "image": 43 + return <FileImageIcon size={size} />; 40 44 case "note": 41 45 return <BookOpenIcon size={size} />; 42 46 case "archive":
+14 -2
web/src/components/cabinet/FileListRow.tsx
··· 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 11 readonly onDelete?: () => void; 12 + readonly onDeleteFolder?: () => void; 12 13 } 13 14 14 - export function FileListRow({ item, onClick, onDownload, onDelete }: FileListRowProps) { 15 + export function FileListRow({ 16 + item, 17 + onClick, 18 + onDownload, 19 + onDelete, 20 + onDeleteFolder, 21 + }: FileListRowProps) { 15 22 const { bg, text } = fileIconColors(item); 16 23 const isFolder = item.kind === "folder"; 17 24 ··· 41 48 > 42 49 {/* Actions */} 43 50 <div className="w-6"> 44 - <FileActionMenu item={item} onDownload={onDownload} onDelete={onDelete} /> 51 + <FileActionMenu 52 + item={item} 53 + onDownload={onDownload} 54 + onDelete={onDelete} 55 + onDeleteFolder={onDeleteFolder} 56 + /> 45 57 </div> 46 58 47 59 {/* Icon */}
+88
web/src/components/cabinet/NewFolderDialog.tsx
··· 1 + import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; 2 + import { FolderPlusIcon } from "@phosphor-icons/react"; 3 + import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; 4 + 5 + export interface NewFolderDialogHandle { 6 + readonly show: () => void; 7 + } 8 + 9 + interface NewFolderDialogProps { 10 + readonly onConfirm: (name: string) => void; 11 + } 12 + 13 + export const NewFolderDialog = forwardRef<NewFolderDialogHandle, NewFolderDialogProps>( 14 + function NewFolderDialog({ onConfirm }, ref) { 15 + const dialogRef = useRef<HTMLDialogElement>(null); 16 + const inputRef = useRef<HTMLInputElement>(null); 17 + const [name, setName] = useState(""); 18 + 19 + const dismiss = useCallback(() => { 20 + dialogRef.current?.close(); 21 + setTimeout(() => setName(""), MODAL_TRANSITION_MS); 22 + }, []); 23 + 24 + const handleConfirm = useCallback(() => { 25 + const trimmed = name.trim(); 26 + if (trimmed.length === 0) return; 27 + onConfirm(trimmed); 28 + dismiss(); 29 + }, [name, onConfirm, dismiss]); 30 + 31 + const handleKeyDown = useCallback( 32 + (e: React.KeyboardEvent) => { 33 + if (e.key === "Enter") { 34 + e.preventDefault(); 35 + handleConfirm(); 36 + } 37 + }, 38 + [handleConfirm], 39 + ); 40 + 41 + useImperativeHandle(ref, () => ({ 42 + show: () => { 43 + setName(""); 44 + dialogRef.current?.showModal(); 45 + // Auto-focus after modal animation 46 + setTimeout(() => inputRef.current?.focus(), 50); 47 + }, 48 + })); 49 + 50 + return ( 51 + <dialog ref={dialogRef} className="modal" aria-label="New folder"> 52 + <div className="modal-box max-w-sm"> 53 + <div className="flex flex-col items-center gap-3 text-center"> 54 + <div className="bg-accent/60 flex size-11 items-center justify-center rounded-full"> 55 + <FolderPlusIcon size={20} className="text-primary" /> 56 + </div> 57 + <h3 className="text-base-content text-sm font-semibold">New folder</h3> 58 + <input 59 + ref={inputRef} 60 + type="text" 61 + value={name} 62 + onChange={(e) => setName(e.target.value)} 63 + onKeyDown={handleKeyDown} 64 + placeholder="Folder name" 65 + className="input input-bordered input-sm w-full rounded-lg text-xs" 66 + aria-label="Folder name" 67 + /> 68 + </div> 69 + <div className="modal-action justify-center gap-2"> 70 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 71 + Cancel 72 + </button> 73 + <button 74 + onClick={handleConfirm} 75 + disabled={name.trim().length === 0} 76 + className="btn btn-primary btn-sm rounded-lg text-xs" 77 + > 78 + Create 79 + </button> 80 + </div> 81 + </div> 82 + <form method="dialog" className="modal-backdrop"> 83 + <button aria-label="Close">close</button> 84 + </form> 85 + </dialog> 86 + ); 87 + }, 88 + );
+24 -1
web/src/components/cabinet/PanelContent.tsx
··· 4 4 import { FileGridCard } from "./FileGridCard"; 5 5 import type { ConfirmDialogHandle } from "@/components/ConfirmDialog"; 6 6 import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; 7 + import { 8 + DeleteFolderConfirmDialog, 9 + type DeleteFolderDialogHandle, 10 + } from "./DeleteFolderConfirmDialog"; 11 + import { getCryptoWorker } from "@/lib/worker"; 7 12 import type { FileItem } from "./types"; 8 13 9 14 interface PanelContentProps { ··· 12 17 readonly onOpen: (item: FileItem) => void; 13 18 readonly onDownload: (uri: string) => void; 14 19 readonly onDelete: (uri: string) => void; 20 + readonly onDeleteFolder: (uri: string) => void; 15 21 } 16 22 17 - export function PanelContent({ items, viewMode, onOpen, onDownload, onDelete }: PanelContentProps) { 23 + export function PanelContent({ 24 + items, 25 + viewMode, 26 + onOpen, 27 + onDownload, 28 + onDelete, 29 + onDeleteFolder, 30 + }: PanelContentProps) { 18 31 const deleteDialogRef = useRef<ConfirmDialogHandle>(null); 32 + const deleteFolderDialogRef = useRef<DeleteFolderDialogHandle>(null); 33 + 34 + const handleDeleteFolderClick = async (item: FileItem) => { 35 + const worker = getCryptoWorker(); 36 + const counts = await worker.treeCountDescendants(item.uri); 37 + deleteFolderDialogRef.current?.show(item.uri, item.name, counts.documents, counts.directories); 38 + }; 19 39 20 40 if (items.length === 0) { 21 41 return ( ··· 41 61 onClick={() => item.kind === "folder" && onOpen(item)} 42 62 onDownload={() => onDownload(item.uri)} 43 63 onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 64 + onDeleteFolder={() => void handleDeleteFolderClick(item)} 44 65 /> 45 66 ))} 46 67 </div> ··· 53 74 onClick={() => item.kind === "folder" && onOpen(item)} 54 75 onDownload={() => onDownload(item.uri)} 55 76 onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 77 + onDeleteFolder={() => void handleDeleteFolderClick(item)} 56 78 /> 57 79 ))} 58 80 </div> 59 81 )} 60 82 61 83 <DeleteConfirmDialog ref={deleteDialogRef} onConfirm={onDelete} /> 84 + <DeleteFolderConfirmDialog ref={deleteFolderDialogRef} onConfirm={onDeleteFolder} /> 62 85 </div> 63 86 ); 64 87 }
+2
web/src/index.css
··· 80 80 --color-file-note: #8a6a30; 81 81 --color-file-code: #7a6a98; 82 82 --color-file-code-bg: #f0eef5; 83 + --color-file-image: #8a5a8a; 84 + --color-file-image-bg: #f2eef2; 83 85 84 86 /* Elevation */ 85 87 --shadow-panel-sm: 0 1px 8px oklch(0.35 0.05 60 / 0.07);
+4 -30
web/src/lib/delete.ts
··· 1 1 // Delete orchestration — remove document record from PDS and update parent directory. 2 2 3 - import { 4 - authenticatedGetRecord, 5 - authenticatedPutRecord, 6 - authenticatedDeleteRecord, 7 - } from "@/lib/api"; 3 + import { authenticatedDeleteRecord } from "@/lib/api"; 8 4 import { rkeyFromUri } from "@/lib/atUri"; 9 - import type { DirectoryRecord } from "@/lib/pdsTypes"; 5 + import { removeEntryFromDirectory } from "@/lib/directory"; 10 6 import type { Session } from "@/lib/storageTypes"; 11 7 12 8 /** ··· 16 12 */ 17 13 export async function deleteDocument( 18 14 documentUri: string, 19 - parentDirectoryRkey: string, 15 + parentDirectoryUri: string | null, 20 16 pdsUrl: string, 21 17 did: string, 22 18 session: Session, 23 19 ): Promise<void> { 24 - // 1. Fetch current parent directory record 25 - const parentRecord = await authenticatedGetRecord<DirectoryRecord>( 26 - { pdsUrl, did, collection: "app.opake.directory", rkey: parentDirectoryRkey }, 27 - session, 28 - ); 29 - 30 - // 2. Remove the document URI from entries + delete the document record concurrently 31 - const updatedRecord: DirectoryRecord = { 32 - ...parentRecord.value, 33 - entries: parentRecord.value.entries.filter((uri) => uri !== documentUri), 34 - modifiedAt: new Date().toISOString(), 35 - }; 36 - 37 20 await Promise.all([ 38 - authenticatedPutRecord( 39 - { 40 - pdsUrl, 41 - did, 42 - collection: "app.opake.directory", 43 - rkey: parentDirectoryRkey, 44 - record: updatedRecord, 45 - }, 46 - session, 47 - ), 21 + removeEntryFromDirectory(parentDirectoryUri, documentUri, pdsUrl, did, session), 48 22 authenticatedDeleteRecord( 49 23 { pdsUrl, did, collection: "app.opake.document", rkey: rkeyFromUri(documentUri) }, 50 24 session,
+162
web/src/lib/directory.ts
··· 1 + // Directory orchestration — create/delete folders, manage directory entries. 2 + 3 + import { 4 + authenticatedCreateRecord, 5 + authenticatedDeleteRecord, 6 + authenticatedGetRecord, 7 + authenticatedPutRecord, 8 + } from "@/lib/api"; 9 + import { uint8ArrayToBase64 } from "@/lib/encoding"; 10 + import { rkeyFromUri } from "@/lib/atUri"; 11 + import { getCryptoWorker } from "@/lib/worker"; 12 + import type { DirectoryRecord } from "@/lib/pdsTypes"; 13 + import type { Session } from "@/lib/storageTypes"; 14 + 15 + // --------------------------------------------------------------------------- 16 + // Shared: add/remove entries from a directory record 17 + // --------------------------------------------------------------------------- 18 + 19 + export async function addEntryToDirectory( 20 + directoryUri: string | null, 21 + entryUri: string, 22 + modifiedAt: string, 23 + pdsUrl: string, 24 + did: string, 25 + session: Session, 26 + ): Promise<void> { 27 + const rkey = directoryUri ? rkeyFromUri(directoryUri) : "self"; 28 + 29 + const response = await authenticatedGetRecord<DirectoryRecord>( 30 + { pdsUrl, did, collection: "app.opake.directory", rkey }, 31 + session, 32 + ); 33 + 34 + const updatedRecord: DirectoryRecord = { 35 + ...response.value, 36 + entries: [...response.value.entries, entryUri], 37 + modifiedAt, 38 + }; 39 + 40 + await authenticatedPutRecord( 41 + { pdsUrl, did, collection: "app.opake.directory", rkey, record: updatedRecord }, 42 + session, 43 + ); 44 + } 45 + 46 + export async function removeEntryFromDirectory( 47 + directoryUri: string | null, 48 + entryUri: string, 49 + pdsUrl: string, 50 + did: string, 51 + session: Session, 52 + ): Promise<void> { 53 + const rkey = directoryUri ? rkeyFromUri(directoryUri) : "self"; 54 + 55 + const response = await authenticatedGetRecord<DirectoryRecord>( 56 + { pdsUrl, did, collection: "app.opake.directory", rkey }, 57 + session, 58 + ); 59 + 60 + const updatedRecord: DirectoryRecord = { 61 + ...response.value, 62 + entries: response.value.entries.filter((uri) => uri !== entryUri), 63 + modifiedAt: new Date().toISOString(), 64 + }; 65 + 66 + await authenticatedPutRecord( 67 + { pdsUrl, did, collection: "app.opake.directory", rkey, record: updatedRecord }, 68 + session, 69 + ); 70 + } 71 + 72 + // --------------------------------------------------------------------------- 73 + // Create directory 74 + // --------------------------------------------------------------------------- 75 + 76 + export async function createDirectory( 77 + name: string, 78 + parentDirectoryUri: string | null, 79 + pdsUrl: string, 80 + did: string, 81 + publicKey: Uint8Array, 82 + session: Session, 83 + ): Promise<string> { 84 + const worker = getCryptoWorker(); 85 + 86 + // Generate content key, then encrypt metadata + wrap key + schema version concurrently 87 + const contentKey = await worker.generateContentKey(); 88 + const [encryptedMeta, wrappedKey, opakeVersion] = await Promise.all([ 89 + worker.encryptDirectoryMetadata(contentKey, { name }), 90 + worker.wrapKey(contentKey, publicKey, did), 91 + worker.schemaVersion(), 92 + ]); 93 + const now = new Date().toISOString(); 94 + 95 + const directoryRecord: DirectoryRecord = { 96 + opakeVersion, 97 + encryption: { 98 + $type: "app.opake.document#directEncryption", 99 + envelope: { 100 + algo: "aes-256-gcm", 101 + nonce: { $bytes: uint8ArrayToBase64(encryptedMeta.nonce) }, 102 + keys: [wrappedKey], 103 + }, 104 + }, 105 + encryptedMetadata: { 106 + ciphertext: { $bytes: uint8ArrayToBase64(encryptedMeta.ciphertext) }, 107 + nonce: { $bytes: uint8ArrayToBase64(encryptedMeta.nonce) }, 108 + }, 109 + entries: [], 110 + createdAt: now, 111 + modifiedAt: null, 112 + }; 113 + 114 + const { uri: newDirectoryUri } = await authenticatedCreateRecord( 115 + { pdsUrl, did, collection: "app.opake.directory", record: directoryRecord }, 116 + session, 117 + ); 118 + 119 + await addEntryToDirectory(parentDirectoryUri, newDirectoryUri, now, pdsUrl, did, session); 120 + 121 + return newDirectoryUri; 122 + } 123 + 124 + // --------------------------------------------------------------------------- 125 + // Delete directory (recursive) 126 + // --------------------------------------------------------------------------- 127 + 128 + interface DescendantEntry { 129 + readonly uri: string; 130 + readonly kind: string; 131 + } 132 + 133 + export async function deleteDirectory( 134 + directoryUri: string, 135 + parentDirectoryUri: string | null, 136 + descendants: readonly DescendantEntry[], 137 + pdsUrl: string, 138 + did: string, 139 + session: Session, 140 + ): Promise<void> { 141 + const collectionForKind = (kind: string) => 142 + kind === "document" ? "app.opake.document" : "app.opake.directory"; 143 + 144 + // Delete all descendants in parallel (PDS records are independent) 145 + await Promise.all( 146 + descendants.map((d) => 147 + authenticatedDeleteRecord( 148 + { pdsUrl, did, collection: collectionForKind(d.kind), rkey: rkeyFromUri(d.uri) }, 149 + session, 150 + ), 151 + ), 152 + ); 153 + 154 + // Delete the directory record itself + remove from parent concurrently 155 + await Promise.all([ 156 + authenticatedDeleteRecord( 157 + { pdsUrl, did, collection: "app.opake.directory", rkey: rkeyFromUri(directoryUri) }, 158 + session, 159 + ), 160 + removeEntryFromDirectory(parentDirectoryUri, directoryUri, pdsUrl, did, session), 161 + ]); 162 + }
+3 -37
web/src/lib/upload.ts
··· 1 1 // Upload orchestration — encrypt client-side, upload blob, create record, add to directory. 2 2 3 - import { 4 - authenticatedBlobUpload, 5 - authenticatedCreateRecord, 6 - authenticatedGetRecord, 7 - authenticatedPutRecord, 8 - } from "@/lib/api"; 3 + import { authenticatedBlobUpload, authenticatedCreateRecord } from "@/lib/api"; 9 4 import { uint8ArrayToBase64 } from "@/lib/encoding"; 10 - import { rkeyFromUri } from "@/lib/atUri"; 11 5 import { getCryptoWorker } from "@/lib/worker"; 12 - import type { DocumentRecord, DirectoryRecord } from "@/lib/pdsTypes"; 6 + import { addEntryToDirectory } from "@/lib/directory"; 7 + import type { DocumentRecord } from "@/lib/pdsTypes"; 13 8 import type { Session } from "@/lib/storageTypes"; 14 9 15 10 export async function uploadDocument( ··· 74 69 75 70 return documentUri; 76 71 } 77 - 78 - async function addEntryToDirectory( 79 - directoryUri: string | null, 80 - entryUri: string, 81 - modifiedAt: string, 82 - pdsUrl: string, 83 - did: string, 84 - session: Session, 85 - ): Promise<void> { 86 - const rkey = directoryUri ? rkeyFromUri(directoryUri) : "self"; 87 - 88 - // Fetch current directory record 89 - const response = await authenticatedGetRecord<DirectoryRecord>( 90 - { pdsUrl, did, collection: "app.opake.directory", rkey }, 91 - session, 92 - ); 93 - 94 - // Append new entry 95 - const updatedRecord: DirectoryRecord = { 96 - ...response.value, 97 - entries: [...response.value.entries, entryUri], 98 - modifiedAt, 99 - }; 100 - 101 - await authenticatedPutRecord( 102 - { pdsUrl, did, collection: "app.opake.directory", rkey, record: updatedRecord }, 103 - session, 104 - ); 105 - }
+2
web/src/routes/cabinet/files/$.tsx
··· 21 21 const viewMode = useDocumentsStore((s) => s.viewMode); 22 22 const downloadFile = useDocumentsStore((s) => s.downloadFile); 23 23 const deleteFile = useDocumentsStore((s) => s.deleteFile); 24 + const deleteFolder = useDocumentsStore((s) => s.deleteFolder); 24 25 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 25 26 26 27 useEffect(() => { ··· 44 45 onOpen={handleOpen} 45 46 onDownload={(uri) => void downloadFile(uri)} 46 47 onDelete={(uri) => void deleteFile(uri)} 48 + onDeleteFolder={(uri) => void deleteFolder(uri)} 47 49 /> 48 50 ); 49 51 }
+2
web/src/routes/cabinet/files/index.tsx
··· 12 12 const viewMode = useDocumentsStore((s) => s.viewMode); 13 13 const downloadFile = useDocumentsStore((s) => s.downloadFile); 14 14 const deleteFile = useDocumentsStore((s) => s.deleteFile); 15 + const deleteFolder = useDocumentsStore((s) => s.deleteFolder); 15 16 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 16 17 17 18 useEffect(() => { ··· 32 33 onOpen={handleOpen} 33 34 onDownload={(uri) => void downloadFile(uri)} 34 35 onDelete={(uri) => void deleteFile(uri)} 36 + onDeleteFolder={(uri) => void deleteFolder(uri)} 35 37 /> 36 38 ); 37 39 }
+12 -1
web/src/routes/cabinet/files/route.tsx
··· 20 20 import { PanelShell } from "@/components/cabinet/PanelShell"; 21 21 import { PanelSkeleton } from "@/components/cabinet/PanelSkeleton"; 22 22 import { TagFilterBar } from "@/components/cabinet/TagFilterBar"; 23 + import { NewFolderDialog, type NewFolderDialogHandle } from "@/components/cabinet/NewFolderDialog"; 23 24 import { useDocumentsStore } from "@/stores/documents"; 24 25 import { useAuthStore } from "@/stores/auth"; 25 26 import { useAppStore } from "@/stores/app"; ··· 28 29 function FileBrowserLayout() { 29 30 const navigate = useNavigate(); 30 31 const fileInputRef = useRef<HTMLInputElement>(null); 32 + const newFolderDialogRef = useRef<NewFolderDialogHandle>(null); 31 33 const uploadFile = useDocumentsStore((s) => s.uploadFile); 34 + const createFolder = useDocumentsStore((s) => s.createFolder); 32 35 33 36 // Determine current directory from child splat route params 34 37 const splatMatch = useMatch({ ··· 149 152 label: "Upload file", 150 153 onClick: () => fileInputRef.current?.click(), 151 154 }, 152 - { icon: FolderIcon, label: "New folder" }, 155 + { 156 + icon: FolderIcon, 157 + label: "New folder", 158 + onClick: () => newFolderDialogRef.current?.show(), 159 + }, 153 160 { icon: FileTextIcon, label: "New document" }, 154 161 { icon: BookOpenIcon, label: "New note" }, 155 162 ]} ··· 210 217 className="hidden" 211 218 onChange={handleFileSelected} 212 219 aria-hidden="true" 220 + /> 221 + <NewFolderDialog 222 + ref={newFolderDialogRef} 223 + onConfirm={(name) => void createFolder(name, currentDirectoryUri)} 213 224 /> 214 225 </PanelShell> 215 226 );
+106 -12
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 22 import { storage, fetchAllRecords } from "./fetch"; 22 23 import { decryptDocumentRecord, markDecryptionFailed } from "./decrypt"; 23 24 import { directoryItemFromSnapshot, documentPlaceholder, applyTagFilter } from "./file-items"; ··· 49 50 readonly downloadFile: (documentUri: string) => Promise<void>; 50 51 readonly deleteFile: (documentUri: string) => Promise<void>; 51 52 readonly uploadFile: (file: File, directoryUri: string | null) => Promise<void>; 53 + readonly createFolder: (name: string, directoryUri: string | null) => Promise<void>; 54 + readonly deleteFolder: (directoryUri: string) => Promise<void>; 52 55 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Helpers 60 + // --------------------------------------------------------------------------- 61 + 62 + /** Find the parent directory URI for a given entry URI within a tree snapshot. */ 63 + function findParentUri( 64 + snapshot: DirectoryTreeSnapshot | null, 65 + entryUri: string, 66 + ): string | undefined { 67 + if (!snapshot) return undefined; 68 + return Object.entries(snapshot.directories).find(([, entry]) => 69 + entry.entries.includes(entryUri), 70 + )?.[0]; 53 71 } 54 72 55 73 // --------------------------------------------------------------------------- ··· 273 291 274 292 // Find parent directory from tree snapshot 275 293 const { treeSnapshot } = get(); 276 - const parentUri = treeSnapshot 277 - ? Object.entries(treeSnapshot.directories).find(([, entry]) => 278 - entry.entries.includes(documentUri), 279 - )?.[0] 280 - : undefined; 281 - 282 - const parentRkey = parentUri 283 - ? parentUri === treeSnapshot?.rootUri 284 - ? "self" 285 - : rkeyFromUri(parentUri) 286 - : "self"; 294 + const parentUri = findParentUri(treeSnapshot, documentUri); 287 295 288 - await deleteDocument(documentUri, parentRkey, pdsUrl, did, session); 296 + await deleteDocument(documentUri, parentUri ?? null, pdsUrl, did, session); 289 297 290 298 // Optimistic removal from store — reuse cached parentUri 291 299 set((draft) => { ··· 332 340 await get().fetchAll(); 333 341 } catch (error) { 334 342 console.error("[documents] upload failed:", error); 343 + done(); 344 + } 345 + }, 346 + 347 + createFolder: async (name: string, directoryUri: string | null) => { 348 + const authState = useAuthStore.getState(); 349 + if (authState.session.status !== "active") return; 350 + 351 + const done = loading("create-folder"); 352 + 353 + try { 354 + const { did, pdsUrl } = authState.session; 355 + const session = await storage.loadSession(did); 356 + const identity = await storage.loadIdentity(did); 357 + const publicKey = base64ToUint8Array(identity.public_key); 358 + 359 + await createDirectory(name, directoryUri, pdsUrl, did, publicKey, session); 360 + 361 + done(); 362 + await get().fetchAll(); 363 + } catch (error) { 364 + console.error("[documents] createFolder failed:", error); 365 + done(); 366 + } 367 + }, 368 + 369 + deleteFolder: async (folderUri: string) => { 370 + const authState = useAuthStore.getState(); 371 + if (authState.session.status !== "active") return; 372 + 373 + const done = loading(`delete:${folderUri}`); 374 + 375 + try { 376 + const { did, pdsUrl } = authState.session; 377 + const session = await storage.loadSession(did); 378 + 379 + // Find parent directory 380 + const { treeSnapshot } = get(); 381 + const parentUri = findParentUri(treeSnapshot, folderUri); 382 + 383 + // Collect all descendants from the WASM tree 384 + const worker = getCryptoWorker(); 385 + const descendants = await worker.treeCollectDescendants(folderUri); 386 + 387 + await deleteDirectory(folderUri, parentUri ?? null, descendants, pdsUrl, did, session); 388 + 389 + // Optimistic removal — remove the folder + all descendants from store 390 + const descendantUris = new Set(descendants.map((d) => d.uri)); 391 + set((draft) => { 392 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 393 + delete draft.items[folderUri]; 394 + // eslint-disable-next-line functional/no-loop-statements -- immer draft mutation requires imperative delete 395 + for (const uri of descendantUris) { 396 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 397 + delete draft.items[uri]; 398 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 399 + delete draft.documentRecords[uri]; 400 + } 401 + 402 + if (draft.treeSnapshot) { 403 + // Remove from parent entries 404 + if (parentUri) { 405 + const parentDir = draft.treeSnapshot.directories[parentUri]; 406 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 407 + if (parentDir) { 408 + const index = parentDir.entries.indexOf(folderUri); 409 + if (index !== -1) { 410 + // eslint-disable-next-line functional/immutable-data -- immer draft mutation 411 + parentDir.entries.splice(index, 1); 412 + } 413 + } 414 + } 415 + 416 + // Remove the directory + subdirectories from snapshot 417 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 418 + delete draft.treeSnapshot.directories[folderUri]; 419 + // eslint-disable-next-line functional/no-loop-statements -- immer draft mutation requires imperative delete 420 + for (const uri of descendantUris) { 421 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 422 + delete draft.treeSnapshot.directories[uri]; 423 + } 424 + } 425 + }); 426 + } catch (error) { 427 + console.error("[documents] deleteFolder failed:", folderUri, error); 428 + } finally { 335 429 done(); 336 430 } 337 431 },
+15
web/src/workers/crypto.worker.ts
··· 11 11 unwrapContentKeyFromKeyring, 12 12 encryptMetadata as wasmEncryptMetadata, 13 13 decryptMetadata as wasmDecryptMetadata, 14 + encryptDirectoryMetadata as wasmEncryptDirectoryMetadata, 14 15 decryptDirectoryMetadata as wasmDecryptDirectoryMetadata, 15 16 generateDpopKeyPair as wasmGenerateDpopKeyPair, 16 17 createDpopProof as wasmCreateDpopProof, ··· 97 98 return wasmDecryptMetadata(key, ciphertext, nonce) as DocumentMetadata; 98 99 }, 99 100 101 + encryptDirectoryMetadata(key: Uint8Array, metadata: DirectoryMetadata): EncryptedPayload { 102 + return wasmEncryptDirectoryMetadata(key, metadata) as EncryptedPayload; 103 + }, 104 + 100 105 decryptDirectoryMetadata( 101 106 key: Uint8Array, 102 107 ciphertext: Uint8Array, ··· 178 183 179 184 treeFindParent(uri: string): string | undefined { 180 185 return directoryTree?.findParent(uri); 186 + }, 187 + 188 + treeCountDescendants(uri: string): { documents: number; directories: number } { 189 + if (!directoryTree) return { documents: 0, directories: 0 }; 190 + return directoryTree.countDescendants(uri) as { documents: number; directories: number }; 191 + }, 192 + 193 + treeCollectDescendants(uri: string): readonly { uri: string; kind: string }[] { 194 + if (!directoryTree) return []; 195 + return directoryTree.collectDescendants(uri) as { uri: string; kind: string }[]; 181 196 }, 182 197 183 198 destroyDirectoryTree(): void {