This commit is contained in:
2025-10-12 14:07:02 +02:00
parent a2d7caa6e4
commit 797a641263
3 changed files with 328 additions and 34 deletions
+1
View File
@@ -0,0 +1 @@
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
+299 -34
View File
@@ -266,6 +266,33 @@ const IconDownload = ({ className }) => (
</svg>
);
const IconEdit = ({ className }) => (
<svg
className={className ? `icon ${className}` : 'icon'}
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path
d="M12 20h9"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const IconFolder = ({ className }) => (
<svg
className={className ? `icon ${className}` : 'icon'}
@@ -832,12 +859,75 @@ const DetailPanel = ({
defaultMoveTarget = 'root',
onPromoteSelection,
activePreviewId = null,
onUpdateTitle = async () => 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 = ({
/>
</div>
</div>
<h3 style={{ margin: 0 }}>{displayName}</h3>
<div className="doc-title-row">
{isEditingTitle ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 style={{ margin: 0 }}>{displayName}</h3>
<button
type="button"
className="icon-button ghost"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<IconEdit className="icon-inline" />
</button>
</>
)}
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="meta">
<div>
<strong>Uploaded:</strong>{' '}
@@ -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 = () => {
<div className="app-bar__meta">
<h1>Papercrate</h1>
<span className="app-bar__hint">
{appStatus === 'bootstrapping'
{appStatus === 'bootstrapping' && loading
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
+28
View File
@@ -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;