From 797a64126352903431cfe4a6a749a042a12f69a2 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 12 Oct 2025 14:07:02 +0200 Subject: [PATCH] frontend --- frontend/public/config.js | 1 + frontend/src/index.jsx | 333 ++++++++++++++++++++++++++++++++++---- frontend/src/styles.css | 28 ++++ 3 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 frontend/public/config.js diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 0000000..f6a4f62 --- /dev/null +++ b/frontend/public/config.js @@ -0,0 +1 @@ +window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || ''; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 657f33e..cd064bc 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -266,6 +266,33 @@ const IconDownload = ({ className }) => ( ); +const IconEdit = ({ className }) => ( + +); + const IconFolder = ({ className }) => ( false, }) => { const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map(); const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const detail = singleDoc ? lookup.get(singleDoc.id) || null : null; + const [titleEditDocId, setTitleEditDocId] = useState(null); + const [titleDraft, setTitleDraft] = useState(''); + const [titleSaving, setTitleSaving] = useState(false); + const [titleError, setTitleError] = useState(null); + + useEffect(() => { + if (!singleDoc) { + setTitleEditDocId(null); + setTitleDraft(''); + setTitleError(null); + setTitleSaving(false); + return; + } + + if (titleEditDocId && titleEditDocId !== singleDoc.id) { + setTitleEditDocId(null); + setTitleDraft(''); + setTitleError(null); + setTitleSaving(false); + } + }, [singleDoc, titleEditDocId]); + + const startTitleEdit = useCallback(() => { + if (!singleDoc) return; + setTitleEditDocId(singleDoc.id); + setTitleDraft(singleDoc.title || singleDoc.original_name || ''); + setTitleError(null); + }, [singleDoc]); + + const cancelTitleEdit = useCallback(() => { + setTitleEditDocId(null); + setTitleDraft(''); + setTitleError(null); + setTitleSaving(false); + }, []); + + const submitTitleEdit = useCallback( + async (event) => { + event.preventDefault(); + if (!singleDoc) return; + const trimmed = titleDraft.trim(); + if (!trimmed) { + setTitleError('Title cannot be empty.'); + return; + } + setTitleSaving(true); + try { + const ok = await onUpdateTitle(singleDoc.id, trimmed); + if (ok) { + setTitleEditDocId(null); + setTitleDraft(''); + setTitleError(null); + } else { + setTitleError('Failed to update title.'); + } + } finally { + setTitleSaving(false); + } + }, + [singleDoc, titleDraft, onUpdateTitle], + ); + const handlePreviewActivate = useCallback( (docId) => { if (!docId) return; @@ -928,6 +1018,7 @@ const DetailPanel = ({ const downloadHref = singleDoc.download_path ? resolveApiPath(singleDoc.download_path) : null; + const isEditingTitle = titleEditDocId === singleDoc.id; return ( <> @@ -943,7 +1034,54 @@ const DetailPanel = ({ /> -

{displayName}

+
+ {isEditingTitle ? ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + /> + + +
+ ) : ( + <> +

{displayName}

+ + + )} +
+ {titleError ?
{titleError}
: null}
Uploaded:{' '} @@ -2907,57 +3045,118 @@ const AppLayout = () => { ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { - if ( - !dataTransfer || - !dataTransfer.items || - !window.isSecureContext || - !Array.from(dataTransfer.items).some((item) => typeof item.getAsFileSystemHandle === 'function') - ) { - throw new Error('File System Access API is required for uploads.'); + if (!dataTransfer) { + throw new Error('No drop payload found.'); } + const items = Array.from(dataTransfer.items || []); const results = []; const pushFile = (file, ancestors) => { if (file) { results.push({ file, - segments: ancestors.filter(Boolean), + segments: (ancestors || []).filter(Boolean), }); } }; - const walkDirectoryHandle = async (handle, ancestors) => { - const nextAncestors = handle.name ? [...ancestors, handle.name] : [...ancestors]; - for await (const child of handle.values()) { - if (child.kind === 'file') { - const file = await child.getFile(); - pushFile(file, nextAncestors); - } else if (child.kind === 'directory') { + const supportsFileSystemAccess = + window.isSecureContext && + items.some((item) => typeof item.getAsFileSystemHandle === 'function'); + const supportsWebkitEntries = items.some( + (item) => typeof item.webkitGetAsEntry === 'function', + ); + + if (supportsFileSystemAccess) { + const walkDirectoryHandle = async (handle, ancestors) => { + const nextAncestors = handle.name ? [...ancestors, handle.name] : [...ancestors]; + for await (const child of handle.values()) { + if (child.kind === 'file') { + const file = await child.getFile(); + pushFile(file, nextAncestors); + } else if (child.kind === 'directory') { + // eslint-disable-next-line no-await-in-loop + await walkDirectoryHandle(child, nextAncestors); + } + } + }; + + for (const item of items) { + if (item.kind !== 'file') continue; + const getHandle = item.getAsFileSystemHandle?.bind(item); + if (!getHandle) { + continue; + } + + // eslint-disable-next-line no-await-in-loop + const handle = await getHandle(); + if (!handle) continue; + + if (handle.kind === 'file') { // eslint-disable-next-line no-await-in-loop - await walkDirectoryHandle(child, nextAncestors); + const file = await handle.getFile(); + pushFile(file, []); + } else if (handle.kind === 'directory') { + // eslint-disable-next-line no-await-in-loop + await walkDirectoryHandle(handle, []); } } - }; + } else if (supportsWebkitEntries) { + const walkWebkitEntry = async (entry, ancestors) => { + if (!entry) return; + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); + pushFile(file, ancestors); + return; + } + if (entry.isDirectory) { + const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; + const reader = entry.createReader(); + const readEntries = () => + new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); - for (const item of Array.from(dataTransfer.items)) { - if (item.kind !== 'file') continue; - const getHandle = item.getAsFileSystemHandle?.bind(item); - if (!getHandle) { - throw new Error('File System Access API handle missing.'); - } + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const batch = await readEntries(); + if (!batch.length) { + break; + } + // eslint-disable-next-line no-await-in-loop + for (const child of batch) { + // eslint-disable-next-line no-await-in-loop + await walkWebkitEntry(child, nextAncestors); + } + } + } + }; - // eslint-disable-next-line no-await-in-loop - const handle = await getHandle(); - if (!handle) continue; - - if (handle.kind === 'file') { - const file = await handle.getFile(); - pushFile(file, []); - } else if (handle.kind === 'directory') { + for (const item of items) { + const entry = + typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; + if (!entry) continue; // eslint-disable-next-line no-await-in-loop - await walkDirectoryHandle(handle, []); + await walkWebkitEntry(entry, []); } + } else { + const files = Array.from(dataTransfer.files || []); + files.forEach((file) => { + if (!file) return; + const relativePath = + typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + pushFile(file, segments); + }); } if (!results.length) { @@ -3226,6 +3425,71 @@ const AppLayout = () => { setStatusMessage, ]); + const handleDocumentTitleUpdate = useCallback( + async (documentId, nextTitle) => { + const trimmed = nextTitle.trim(); + if (!trimmed) { + setStatusMessage('Document title cannot be empty.', 'error'); + return false; + } + + setLoading(true); + try { + const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); + + setDocumentDetails((prev) => { + const next = new Map(prev); + next.set(documentId, data); + documentDetailsRef.current = next; + return next; + }); + + setDocuments((prev) => + prev.map((doc) => (doc.id === documentId ? { ...doc, title: trimmed } : doc)), + ); + + setSearchResults((prev) => + prev + ? prev.map((doc) => (doc.id === documentId ? { ...doc, title: trimmed } : doc)) + : null, + ); + + setFolderContents((prev) => { + let changed = false; + const next = new Map(); + prev.forEach((contents, key) => { + if (contents?.documents?.some((doc) => doc.id === documentId)) { + changed = true; + next.set(key, { + ...contents, + documents: contents.documents.map((doc) => + doc.id === documentId ? { ...doc, title: trimmed } : doc, + ), + }); + } else { + next.set(key, contents); + } + }); + if (!changed) { + return prev; + } + return next; + }); + + setStatusMessage('Document title updated.', 'success'); + return true; + } catch (error) { + console.error(error); + const message = error.response?.data?.error || 'Failed to update document title.'; + setStatusMessage(message, 'error'); + return false; + } finally { + setLoading(false); + } + }, + [setStatusMessage, setDocuments, setSearchResults, setFolderContents], + ); + const handleTagRemove = useCallback( async (documentId, tagId) => { try { @@ -3999,6 +4263,7 @@ const AppLayout = () => { defaultMoveTarget, onPromoteSelection: promoteSelectionOrder, activePreviewId, + onUpdateTitle: handleDocumentTitleUpdate, }; const contextValue = useMemo( @@ -4066,7 +4331,7 @@ const AppLayout = () => {

Papercrate

- {appStatus === 'bootstrapping' + {appStatus === 'bootstrapping' && loading ? 'Loading your library…' : previewActive ? 'Viewing document preview. Press ← Back to return to the library.' diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6f80a24..b52c7b8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -901,6 +901,34 @@ button.icon-button.ghost:hover:not([disabled]) { color: var(--muted); } +.detail-panel .doc-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0.25rem 0 0.5rem; +} + +.detail-panel .doc-title-edit { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; +} + +.detail-panel .doc-title-edit input { + flex: 1; + min-width: 0; +} + +.status-inline { + font-size: 0.85rem; + margin-top: 0.2rem; +} + +.status-inline.error { + color: var(--danger); +} + .bulk-detail-actions { display: flex; flex-direction: column;