An encrypted personal cloud built on the AT Protocol.

Add web file deletion with confirmation dialog [CL-148]

Delete documents from the web UI with a two-step flow: remove entry
from parent directory, then delete the record (parallelized). Generic
ConfirmDialog component with imperative show(key, label) API handles
transition-safe dismiss. Also extracts authenticatedGetRecord and
authenticatedDeleteRecord into api.ts for reuse.

sans-self.org 3d01b1b7 b5329c1b

Waiting for spindle ...
+322 -19
+1
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 deletion [#148](https://issues.opake.app/issues/148.html) 15 16 - Add web file upload with client-side encryption [#146](https://issues.opake.app/issues/146.html) 16 17 - Integrate app store loading tracker into document fetching and downloads [#251](https://issues.opake.app/issues/251.html) 17 18 - Add web file download with client-side decryption [#147](https://issues.opake.app/issues/147.html)
+98
web/src/components/ConfirmDialog.tsx
··· 1 + import { 2 + forwardRef, 3 + useCallback, 4 + useImperativeHandle, 5 + useRef, 6 + useState, 7 + type ComponentType, 8 + type ReactNode, 9 + } from "react"; 10 + 11 + const MODAL_TRANSITION_MS = 200; 12 + 13 + export interface ConfirmDialogHandle { 14 + /** Show the dialog. `key` is passed to onConfirm; `label` is passed to the render function. */ 15 + readonly show: (key: string, label: string) => void; 16 + } 17 + 18 + interface ConfirmDialogProps { 19 + readonly title: string; 20 + readonly icon?: ComponentType<{ readonly size: number; readonly className?: string }>; 21 + readonly iconClassName?: string; 22 + readonly iconBgClassName?: string; 23 + readonly children: (label: string) => ReactNode; 24 + readonly confirmLabel?: string; 25 + readonly confirmClassName?: string; 26 + readonly cancelLabel?: string; 27 + readonly onConfirm: (key: string) => void; 28 + } 29 + 30 + export const ConfirmDialog = forwardRef<ConfirmDialogHandle, ConfirmDialogProps>( 31 + function ConfirmDialog( 32 + { 33 + title, 34 + icon: Icon, 35 + iconClassName, 36 + iconBgClassName, 37 + children, 38 + confirmLabel = "Confirm", 39 + confirmClassName = "btn btn-primary btn-sm rounded-lg text-xs", 40 + cancelLabel = "Cancel", 41 + onConfirm, 42 + }, 43 + ref, 44 + ) { 45 + const dialogRef = useRef<HTMLDialogElement>(null); 46 + const [pending, setPending] = useState<{ readonly key: string; readonly label: string } | null>( 47 + null, 48 + ); 49 + 50 + const dismiss = useCallback(() => { 51 + dialogRef.current?.close(); 52 + setTimeout(() => setPending(null), MODAL_TRANSITION_MS); 53 + }, []); 54 + 55 + const handleConfirm = useCallback(() => { 56 + if (pending) { 57 + onConfirm(pending.key); 58 + } 59 + dismiss(); 60 + }, [pending, onConfirm, dismiss]); 61 + 62 + useImperativeHandle(ref, () => ({ 63 + show: (key: string, label: string) => { 64 + setPending({ key, label }); 65 + dialogRef.current?.showModal(); 66 + }, 67 + })); 68 + 69 + return ( 70 + <dialog ref={dialogRef} className="modal" aria-label={title}> 71 + <div className="modal-box max-w-sm"> 72 + <div className="flex flex-col items-center gap-3 text-center"> 73 + {Icon && ( 74 + <div 75 + className={`flex size-11 items-center justify-center rounded-full ${iconBgClassName ?? ""}`} 76 + > 77 + <Icon size={20} className={iconClassName} /> 78 + </div> 79 + )} 80 + <h3 className="text-base-content text-sm font-semibold">{title}</h3> 81 + {pending && <div className="text-text-muted text-xs">{children(pending.label)}</div>} 82 + </div> 83 + <div className="modal-action justify-center gap-2"> 84 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 85 + {cancelLabel} 86 + </button> 87 + <button onClick={handleConfirm} className={confirmClassName}> 88 + {confirmLabel} 89 + </button> 90 + </div> 91 + </div> 92 + <form method="dialog" className="modal-backdrop"> 93 + <button aria-label="Close">close</button> 94 + </form> 95 + </dialog> 96 + ); 97 + }, 98 + );
+31
web/src/components/cabinet/DeleteConfirmDialog.tsx
··· 1 + import { forwardRef } from "react"; 2 + import { WarningIcon } from "@phosphor-icons/react"; 3 + import { ConfirmDialog, type ConfirmDialogHandle } from "@/components/ConfirmDialog"; 4 + 5 + interface DeleteConfirmDialogProps { 6 + readonly onConfirm: (uri: string) => void; 7 + } 8 + 9 + export const DeleteConfirmDialog = forwardRef<ConfirmDialogHandle, DeleteConfirmDialogProps>( 10 + function DeleteConfirmDialog({ onConfirm }, ref) { 11 + return ( 12 + <ConfirmDialog 13 + ref={ref} 14 + title="Delete file?" 15 + icon={WarningIcon} 16 + iconClassName="text-error" 17 + iconBgClassName="bg-error/10" 18 + confirmLabel="Delete" 19 + confirmClassName="btn btn-error btn-sm rounded-lg text-xs" 20 + onConfirm={onConfirm} 21 + > 22 + {(fileName) => ( 23 + <p> 24 + <span className="text-base-content font-medium">{fileName}</span> will be permanently 25 + deleted. This cannot be undone. 26 + </p> 27 + )} 28 + </ConfirmDialog> 29 + ); 30 + }, 31 + );
+10 -5
web/src/components/cabinet/FileActionMenu.tsx
··· 1 - import { DotsThreeVerticalIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; 1 + import { DotsThreeVerticalIcon, DownloadSimpleIcon, TrashIcon } from "@phosphor-icons/react"; 2 2 import { DropdownMenu } from "@/components/DropdownMenu"; 3 3 import { useAppStore } from "@/stores/app"; 4 4 import type { FileItem } from "./types"; ··· 6 6 interface FileActionMenuProps { 7 7 readonly item: FileItem; 8 8 readonly onDownload?: () => void; 9 + readonly onDelete?: () => void; 9 10 } 10 11 11 - export function FileActionMenu({ item, onDownload }: FileActionMenuProps) { 12 + export function FileActionMenu({ item, onDownload, onDelete }: FileActionMenuProps) { 12 13 const isFolder = item.kind === "folder"; 13 14 const downloading = useAppStore((s) => s.isLoading(`download:${item.uri}`)); 15 + const deleting = useAppStore((s) => s.isLoading(`delete:${item.uri}`)); 14 16 15 17 if (isFolder || !item.decrypted || item.name === "[Keyring encrypted]") return null; 16 18 17 - if (downloading) { 19 + if (downloading || deleting) { 18 20 return ( 19 21 <span 20 22 className="loading loading-spinner loading-xs text-text-faint" 21 23 role="status" 22 - aria-label={`Downloading ${item.name}`} 24 + aria-label={deleting ? `Deleting ${item.name}` : `Downloading ${item.name}`} 23 25 /> 24 26 ); 25 27 } ··· 29 31 triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 30 32 trigger={<DotsThreeVerticalIcon size={24} weight="bold" className="text-base-content" />} 31 33 align="right" 32 - items={[{ icon: DownloadSimpleIcon, label: "Download", onClick: onDownload }]} 34 + items={[ 35 + { icon: DownloadSimpleIcon, label: "Download", onClick: onDownload }, 36 + { icon: TrashIcon, label: "Delete", onClick: onDelete }, 37 + ]} 33 38 /> 34 39 ); 35 40 }
+3 -2
web/src/components/cabinet/FileGridCard.tsx
··· 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 + readonly onDelete?: () => void; 11 12 } 12 13 13 - export function FileGridCard({ item, onClick, onDownload }: FileGridCardProps) { 14 + export function FileGridCard({ item, onClick, onDownload, onDelete }: FileGridCardProps) { 14 15 const { bg, text } = fileIconColors(item); 15 16 const isFolder = item.kind === "folder"; 16 17 ··· 43 44 {fileIconElement(item, 17)} 44 45 </div> 45 46 <div className="flex items-center gap-1"> 46 - <FileActionMenu item={item} onDownload={onDownload} /> 47 + <FileActionMenu item={item} onDownload={onDownload} onDelete={onDelete} /> 47 48 <LockIcon size={11} className="text-text-faint" /> 48 49 </div> 49 50 </div>
+3 -2
web/src/components/cabinet/FileListRow.tsx
··· 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 + readonly onDelete?: () => void; 11 12 } 12 13 13 - export function FileListRow({ item, onClick, onDownload }: FileListRowProps) { 14 + export function FileListRow({ item, onClick, onDownload, onDelete }: FileListRowProps) { 14 15 const { bg, text } = fileIconColors(item); 15 16 const isFolder = item.kind === "folder"; 16 17 ··· 40 41 > 41 42 {/* Actions */} 42 43 <div className="w-6"> 43 - <FileActionMenu item={item} onDownload={onDownload} /> 44 + <FileActionMenu item={item} onDownload={onDownload} onDelete={onDelete} /> 44 45 </div> 45 46 46 47 {/* Icon */}
+11 -1
web/src/components/cabinet/PanelContent.tsx
··· 1 + import { useRef } from "react"; 1 2 import { FolderIcon } from "@phosphor-icons/react"; 2 3 import { FileListRow } from "./FileListRow"; 3 4 import { FileGridCard } from "./FileGridCard"; 5 + import type { ConfirmDialogHandle } from "@/components/ConfirmDialog"; 6 + import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; 4 7 import type { FileItem } from "./types"; 5 8 6 9 interface PanelContentProps { ··· 8 11 readonly viewMode: "list" | "grid"; 9 12 readonly onOpen: (item: FileItem) => void; 10 13 readonly onDownload: (uri: string) => void; 14 + readonly onDelete: (uri: string) => void; 11 15 } 12 16 13 - export function PanelContent({ items, viewMode, onOpen, onDownload }: PanelContentProps) { 17 + export function PanelContent({ items, viewMode, onOpen, onDownload, onDelete }: PanelContentProps) { 18 + const deleteDialogRef = useRef<ConfirmDialogHandle>(null); 19 + 14 20 if (items.length === 0) { 15 21 return ( 16 22 <div className="hero py-16"> ··· 34 40 item={item} 35 41 onClick={() => item.kind === "folder" && onOpen(item)} 36 42 onDownload={() => onDownload(item.uri)} 43 + onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 37 44 /> 38 45 ))} 39 46 </div> ··· 45 52 item={item} 46 53 onClick={() => item.kind === "folder" && onOpen(item)} 47 54 onDownload={() => onDownload(item.uri)} 55 + onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 48 56 /> 49 57 ))} 50 58 </div> 51 59 )} 60 + 61 + <DeleteConfirmDialog ref={deleteDialogRef} onConfirm={onDelete} /> 52 62 </div> 53 63 ); 54 64 }
+49 -1
web/src/lib/api.ts
··· 2 2 3 3 import type { OAuthSession, Session } from "@/lib/storageTypes"; 4 4 import type { TokenResponse } from "@/lib/oauth"; 5 - import type { BlobRef } from "@/lib/pdsTypes"; 5 + import type { BlobRef, PdsRecord } from "@/lib/pdsTypes"; 6 6 import { getCryptoWorker } from "@/lib/worker"; 7 7 import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 8 8 ··· 267 267 }, 268 268 session, 269 269 )) as RecordRef; 270 + } 271 + 272 + // --------------------------------------------------------------------------- 273 + // Authenticated record fetch + delete 274 + // --------------------------------------------------------------------------- 275 + 276 + interface GetRecordParams { 277 + pdsUrl: string; 278 + did: string; 279 + collection: string; 280 + rkey: string; 281 + } 282 + 283 + export async function authenticatedGetRecord<T>( 284 + params: GetRecordParams, 285 + session: Session, 286 + ): Promise<PdsRecord<T>> { 287 + const { pdsUrl, did, collection, rkey } = params; 288 + return (await authenticatedXrpc( 289 + { 290 + pdsUrl, 291 + lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, 292 + }, 293 + session, 294 + )) as PdsRecord<T>; 295 + } 296 + 297 + interface DeleteRecordParams { 298 + pdsUrl: string; 299 + did: string; 300 + collection: string; 301 + rkey: string; 302 + } 303 + 304 + export async function authenticatedDeleteRecord( 305 + params: DeleteRecordParams, 306 + session: Session, 307 + ): Promise<void> { 308 + const { pdsUrl, did, collection, rkey } = params; 309 + await authenticatedXrpc( 310 + { 311 + pdsUrl, 312 + lexicon: "com.atproto.repo.deleteRecord", 313 + method: "POST", 314 + body: { repo: did, collection, rkey }, 315 + }, 316 + session, 317 + ); 270 318 } 271 319 272 320 // ---------------------------------------------------------------------------
+53
web/src/lib/delete.ts
··· 1 + // Delete orchestration — remove document record from PDS and update parent directory. 2 + 3 + import { 4 + authenticatedGetRecord, 5 + authenticatedPutRecord, 6 + authenticatedDeleteRecord, 7 + } from "@/lib/api"; 8 + import { rkeyFromUri } from "@/lib/atUri"; 9 + import type { DirectoryRecord } from "@/lib/pdsTypes"; 10 + import type { Session } from "@/lib/storageTypes"; 11 + 12 + /** 13 + * Delete a document from the PDS. Two-step: remove the entry from its parent 14 + * directory's entries array, then delete the document record itself. The blob 15 + * becomes orphaned and will be garbage-collected by the PDS. 16 + */ 17 + export async function deleteDocument( 18 + documentUri: string, 19 + parentDirectoryRkey: string, 20 + pdsUrl: string, 21 + did: string, 22 + session: Session, 23 + ): 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 + 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 + ), 48 + authenticatedDeleteRecord( 49 + { pdsUrl, did, collection: "app.opake.document", rkey: rkeyFromUri(documentUri) }, 50 + session, 51 + ), 52 + ]); 53 + }
+5 -8
web/src/lib/upload.ts
··· 3 3 import { 4 4 authenticatedBlobUpload, 5 5 authenticatedCreateRecord, 6 - authenticatedXrpc, 6 + authenticatedGetRecord, 7 7 authenticatedPutRecord, 8 8 } from "@/lib/api"; 9 9 import { uint8ArrayToBase64 } from "@/lib/encoding"; 10 10 import { rkeyFromUri } from "@/lib/atUri"; 11 11 import { getCryptoWorker } from "@/lib/worker"; 12 - import type { DocumentRecord, DirectoryRecord, PdsRecord } from "@/lib/pdsTypes"; 12 + import type { DocumentRecord, DirectoryRecord } from "@/lib/pdsTypes"; 13 13 import type { Session } from "@/lib/storageTypes"; 14 14 15 15 export async function uploadDocument( ··· 86 86 const rkey = directoryUri ? rkeyFromUri(directoryUri) : "self"; 87 87 88 88 // Fetch current directory record 89 - const response = (await authenticatedXrpc( 90 - { 91 - pdsUrl, 92 - lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.opake.directory&rkey=${encodeURIComponent(rkey)}`, 93 - }, 89 + const response = await authenticatedGetRecord<DirectoryRecord>( 90 + { pdsUrl, did, collection: "app.opake.directory", rkey }, 94 91 session, 95 - )) as PdsRecord<DirectoryRecord>; 92 + ); 96 93 97 94 // Append new entry 98 95 const updatedRecord: DirectoryRecord = {
+2
web/src/routes/cabinet/files/$.tsx
··· 20 20 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 21 21 const viewMode = useDocumentsStore((s) => s.viewMode); 22 22 const downloadFile = useDocumentsStore((s) => s.downloadFile); 23 + const deleteFile = useDocumentsStore((s) => s.deleteFile); 23 24 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 24 25 25 26 useEffect(() => { ··· 42 43 viewMode={viewMode} 43 44 onOpen={handleOpen} 44 45 onDownload={(uri) => void downloadFile(uri)} 46 + onDelete={(uri) => void deleteFile(uri)} 45 47 /> 46 48 ); 47 49 }
+2
web/src/routes/cabinet/files/index.tsx
··· 11 11 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 12 12 const viewMode = useDocumentsStore((s) => s.viewMode); 13 13 const downloadFile = useDocumentsStore((s) => s.downloadFile); 14 + const deleteFile = useDocumentsStore((s) => s.deleteFile); 14 15 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 15 16 16 17 useEffect(() => { ··· 30 31 viewMode={viewMode} 31 32 onOpen={handleOpen} 32 33 onDownload={(uri) => void downloadFile(uri)} 34 + onDelete={(uri) => void deleteFile(uri)} 33 35 /> 34 36 ); 35 37 }
+54
web/src/stores/documents/store.ts
··· 16 16 } from "@/lib/pdsTypes"; 17 17 import { rkeyFromUri } from "@/lib/atUri"; 18 18 import { downloadDocument } from "@/lib/download"; 19 + import { deleteDocument } from "@/lib/delete"; 19 20 import { uploadDocument } from "@/lib/upload"; 20 21 import { storage, fetchAllRecords } from "./fetch"; 21 22 import { decryptDocumentRecord, markDecryptionFailed } from "./decrypt"; ··· 46 47 readonly setTagFilters: (tags: string[]) => void; 47 48 readonly setViewMode: (mode: "list" | "grid") => void; 48 49 readonly downloadFile: (documentUri: string) => Promise<void>; 50 + readonly deleteFile: (documentUri: string) => Promise<void>; 49 51 readonly uploadFile: (file: File, directoryUri: string | null) => Promise<void>; 50 52 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 51 53 } ··· 254 256 await downloadDocument(record, pdsUrl, did, privateKey, session); 255 257 } catch (error) { 256 258 console.error("[documents] download failed:", documentUri, error); 259 + } finally { 260 + done(); 261 + } 262 + }, 263 + 264 + deleteFile: async (documentUri: string) => { 265 + const authState = useAuthStore.getState(); 266 + if (authState.session.status !== "active") return; 267 + 268 + const done = loading(`delete:${documentUri}`); 269 + 270 + try { 271 + const { did, pdsUrl } = authState.session; 272 + const session = await storage.loadSession(did); 273 + 274 + // Find parent directory from tree snapshot 275 + 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"; 287 + 288 + await deleteDocument(documentUri, parentRkey, pdsUrl, did, session); 289 + 290 + // Optimistic removal from store — reuse cached parentUri 291 + set((draft) => { 292 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 293 + delete draft.items[documentUri]; 294 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- immer draft mutation 295 + delete draft.documentRecords[documentUri]; 296 + 297 + if (draft.treeSnapshot && parentUri) { 298 + const parentDir = draft.treeSnapshot.directories[parentUri]; 299 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 300 + if (parentDir) { 301 + const index = parentDir.entries.indexOf(documentUri); 302 + if (index !== -1) { 303 + // eslint-disable-next-line functional/immutable-data -- immer draft mutation 304 + parentDir.entries.splice(index, 1); 305 + } 306 + } 307 + } 308 + }); 309 + } catch (error) { 310 + console.error("[documents] delete failed:", documentUri, error); 257 311 } finally { 258 312 done(); 259 313 }