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;