frontend
This commit is contained in:
@@ -0,0 +1 @@
|
||||
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
||||
+299
-34
@@ -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.'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user