Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55a476d97 | ||
|
|
6724634870 | ||
|
|
797a641263 | ||
|
|
a2d7caa6e4 |
@@ -6,6 +6,7 @@
|
|||||||
/backend/.env
|
/backend/.env
|
||||||
/backend/.env.*
|
/backend/.env.*
|
||||||
/backend/.cargo/
|
/backend/.cargo/
|
||||||
|
backend/libpdfium.*
|
||||||
|
|
||||||
# Node/Frontend
|
# Node/Frontend
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN apt-get update \
|
|||||||
libpq-dev \
|
libpq-dev \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
@@ -28,11 +29,25 @@ WORKDIR /app
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
curl \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
libpng16-16 \
|
libpng16-16 \
|
||||||
|
ocrmypdf \
|
||||||
|
tesseract-ocr \
|
||||||
|
ghostscript \
|
||||||
|
qpdf \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /usr/local/lib \
|
||||||
|
&& curl -fsSL https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-arm64.tgz -o /tmp/pdfium.tgz \
|
||||||
|
&& mkdir -p /tmp/pdfium \
|
||||||
|
&& tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1 \
|
||||||
|
&& pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)" \
|
||||||
|
&& [ -n "${pdfium_so}" ] \
|
||||||
|
&& mv "${pdfium_so}" /usr/local/lib/libpdfium.so \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/pdfium.tgz /tmp/pdfium \
|
||||||
&& useradd --system --create-home --uid 10001 appuser
|
&& useradd --system --create-home --uid 10001 appuser
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||||
|
|||||||
Binary file not shown.
@@ -145,6 +145,11 @@ pub struct BulkMoveRequest {
|
|||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateDocumentRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct BulkMoveResponse {
|
pub struct BulkMoveResponse {
|
||||||
pub updated: usize,
|
pub updated: usize,
|
||||||
@@ -627,6 +632,70 @@ pub async fn delete_document(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_document(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(document_id): Path<Uuid>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<UpdateDocumentRequest>,
|
||||||
|
) -> AppResult<Json<DocumentDetailResponse>> {
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
|
let mut document: Document = documents::table.find(document_id).first(&mut conn)?;
|
||||||
|
if document.deleted_at.is_some() {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_title = match payload.title {
|
||||||
|
Some(ref title) => {
|
||||||
|
let trimmed = title.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::bad_request("title must not be empty"));
|
||||||
|
}
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_title.is_none() {
|
||||||
|
return Err(AppError::bad_request("no changes provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(title) = new_title {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
diesel::update(documents::table.find(document_id))
|
||||||
|
.set((documents::title.eq(title), documents::updated_at.eq(now)))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
document = documents::table.find(document_id).first(&mut conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_version: DocumentVersion = document_versions::table
|
||||||
|
.filter(document_versions::document_id.eq(document_id))
|
||||||
|
.filter(document_versions::version_number.eq(document.current_version))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
|
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||||
|
let version_id = current_version.id;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
|
let thumbnail = assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.asset_type == "thumbnail")
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
Ok(Json(DocumentDetailResponse {
|
||||||
|
document: to_document_response(
|
||||||
|
&state,
|
||||||
|
user.user_id,
|
||||||
|
document,
|
||||||
|
tags_map.get(&document_id).cloned(),
|
||||||
|
thumbnail,
|
||||||
|
)?,
|
||||||
|
current_version: to_version_response(current_version),
|
||||||
|
assets,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn move_document(
|
pub async fn move_document(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/:id",
|
"/:id",
|
||||||
get(documents::get_document).delete(documents::delete_document),
|
get(documents::get_document)
|
||||||
|
.delete(documents::delete_document)
|
||||||
|
.patch(documents::update_document),
|
||||||
)
|
)
|
||||||
.route("/:id/download", get(documents::download_document))
|
.route("/:id/download", get(documents::download_document))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ use async_trait::async_trait;
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Method, Request, StatusCode};
|
use axum::http::{Method, Request, StatusCode};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use diesel::connection::SimpleConnection;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use diesel::PgConnection;
|
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
use backend::db::{self, PgPool};
|
||||||
@@ -21,6 +15,12 @@ use backend::models::{Job, NewUser};
|
|||||||
use backend::routes;
|
use backend::routes;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
|
use diesel::connection::SimpleConnection;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::PgConnection;
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -238,9 +238,7 @@ impl TestApp {
|
|||||||
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
|
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
|
||||||
let ty = ty.to_string();
|
let ty = ty.to_string();
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
use backend::schema::jobs::dsl::{
|
use backend::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
|
||||||
job_type as job_type_col, jobs as jobs_table,
|
|
||||||
};
|
|
||||||
let rows = jobs_table
|
let rows = jobs_table
|
||||||
.filter(job_type_col.eq(&ty))
|
.filter(job_type_col.eq(&ty))
|
||||||
.load::<Job>(conn)
|
.load::<Job>(conn)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
||||||
+299
-34
@@ -266,6 +266,33 @@ const IconDownload = ({ className }) => (
|
|||||||
</svg>
|
</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 }) => (
|
const IconFolder = ({ className }) => (
|
||||||
<svg
|
<svg
|
||||||
className={className ? `icon ${className}` : 'icon'}
|
className={className ? `icon ${className}` : 'icon'}
|
||||||
@@ -832,12 +859,75 @@ const DetailPanel = ({
|
|||||||
defaultMoveTarget = 'root',
|
defaultMoveTarget = 'root',
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
activePreviewId = null,
|
activePreviewId = null,
|
||||||
|
onUpdateTitle = async () => false,
|
||||||
}) => {
|
}) => {
|
||||||
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
|
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||||
const detail = singleDoc ? lookup.get(singleDoc.id) || null : 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(
|
const handlePreviewActivate = useCallback(
|
||||||
(docId) => {
|
(docId) => {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -928,6 +1018,7 @@ const DetailPanel = ({
|
|||||||
const downloadHref = singleDoc.download_path
|
const downloadHref = singleDoc.download_path
|
||||||
? resolveApiPath(singleDoc.download_path)
|
? resolveApiPath(singleDoc.download_path)
|
||||||
: null;
|
: null;
|
||||||
|
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -943,7 +1034,54 @@ const DetailPanel = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 className="meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>Uploaded:</strong>{' '}
|
<strong>Uploaded:</strong>{' '}
|
||||||
@@ -2907,57 +3045,118 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||||
if (
|
if (!dataTransfer) {
|
||||||
!dataTransfer ||
|
throw new Error('No drop payload found.');
|
||||||
!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.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = Array.from(dataTransfer.items || []);
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
const pushFile = (file, ancestors) => {
|
const pushFile = (file, ancestors) => {
|
||||||
if (file) {
|
if (file) {
|
||||||
results.push({
|
results.push({
|
||||||
file,
|
file,
|
||||||
segments: ancestors.filter(Boolean),
|
segments: (ancestors || []).filter(Boolean),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const walkDirectoryHandle = async (handle, ancestors) => {
|
const supportsFileSystemAccess =
|
||||||
const nextAncestors = handle.name ? [...ancestors, handle.name] : [...ancestors];
|
window.isSecureContext &&
|
||||||
for await (const child of handle.values()) {
|
items.some((item) => typeof item.getAsFileSystemHandle === 'function');
|
||||||
if (child.kind === 'file') {
|
const supportsWebkitEntries = items.some(
|
||||||
const file = await child.getFile();
|
(item) => typeof item.webkitGetAsEntry === 'function',
|
||||||
pushFile(file, nextAncestors);
|
);
|
||||||
} else if (child.kind === 'directory') {
|
|
||||||
|
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
|
// 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)) {
|
// eslint-disable-next-line no-constant-condition
|
||||||
if (item.kind !== 'file') continue;
|
while (true) {
|
||||||
const getHandle = item.getAsFileSystemHandle?.bind(item);
|
// eslint-disable-next-line no-await-in-loop
|
||||||
if (!getHandle) {
|
const batch = await readEntries();
|
||||||
throw new Error('File System Access API handle missing.');
|
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
|
for (const item of items) {
|
||||||
const handle = await getHandle();
|
const entry =
|
||||||
if (!handle) continue;
|
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
||||||
|
if (!entry) continue;
|
||||||
if (handle.kind === 'file') {
|
|
||||||
const file = await handle.getFile();
|
|
||||||
pushFile(file, []);
|
|
||||||
} else if (handle.kind === 'directory') {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// 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) {
|
if (!results.length) {
|
||||||
@@ -3226,6 +3425,71 @@ const AppLayout = () => {
|
|||||||
setStatusMessage,
|
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(
|
const handleTagRemove = useCallback(
|
||||||
async (documentId, tagId) => {
|
async (documentId, tagId) => {
|
||||||
try {
|
try {
|
||||||
@@ -3999,6 +4263,7 @@ const AppLayout = () => {
|
|||||||
defaultMoveTarget,
|
defaultMoveTarget,
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
@@ -4066,7 +4331,7 @@ const AppLayout = () => {
|
|||||||
<div className="app-bar__meta">
|
<div className="app-bar__meta">
|
||||||
<h1>Papercrate</h1>
|
<h1>Papercrate</h1>
|
||||||
<span className="app-bar__hint">
|
<span className="app-bar__hint">
|
||||||
{appStatus === 'bootstrapping'
|
{appStatus === 'bootstrapping' && loading
|
||||||
? 'Loading your library…'
|
? 'Loading your library…'
|
||||||
: previewActive
|
: previewActive
|
||||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||||
|
|||||||
@@ -901,6 +901,34 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
color: var(--muted);
|
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 {
|
.bulk-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user