This commit is contained in:
2025-10-28 00:01:16 +01:00
parent ed0f0fb759
commit 9be0e3b5c4
8 changed files with 714 additions and 150 deletions
+35 -6
View File
@@ -57,7 +57,8 @@ use uuid::Uuid;
schemas::LoginResponseVariants,
schemas::DocumentResponse,
schemas::DocumentDetailResponse,
schemas::DocumentVersion,
schemas::DocumentVersionResponse,
schemas::DocumentVersionDetailResponse,
schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail,
schemas::DocumentAssetObject,
@@ -238,6 +239,27 @@ mod doc {
)]
pub(super) fn delete_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents"
)]
pub(super) fn list_document_versions() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document_version() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
@@ -650,15 +672,22 @@ pub mod schemas {
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersion {
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub checksum: String,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub metadata: Value,
#[schema(nullable)]
pub assets: Option<Vec<DocumentAssetSummary>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetSummary>,
pub download_path: String,
}
@@ -692,7 +721,7 @@ pub mod schemas {
#[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
pub current_version: Option<DocumentVersionDetailResponse>,
}
#[derive(Serialize, Deserialize, ToSchema)]
+70 -4
View File
@@ -133,7 +133,6 @@ impl From<Tag> for TagResponse {
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
@@ -175,7 +174,7 @@ pub struct DocumentAssetDetailResponse {
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentCurrentVersionResponse {
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -209,7 +208,7 @@ pub struct DocumentResponse {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondentResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_version: Option<DocumentCurrentVersionResponse>,
pub current_version: Option<DocumentVersionDetailResponse>,
}
#[derive(Serialize, ToSchema)]
pub struct DocumentDetailResponse {
@@ -1145,6 +1144,73 @@ pub async fn get_document_asset(
Ok(Json(to_asset_detail_response(asset, object_responses)))
}
pub async fn list_document_versions(
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentVersionResponse>>> {
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.order(document_versions::version_number.asc())
.load(&mut conn)?;
let versions: Vec<DocumentVersionResponse> =
versions.into_iter().map(to_version_response).collect();
Ok(Json(versions))
}
pub async fn get_document_version(
State(state): State<AppState>,
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentVersionDetailResponse>> {
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.find(version_id)
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.first(&mut conn)?;
drop(conn);
let assets = load_asset_responses(&state, tenant_id, version.id).await?;
let download_path = build_download_path(&state, &document, user_id)?;
let version_core = to_version_response(version);
Ok(Json(DocumentVersionDetailResponse {
version: version_core,
assets,
download_path,
}))
}
pub async fn download_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -2574,7 +2640,7 @@ pub(crate) fn to_document_response(
) -> AppResult<DocumentResponse> {
let current_version = if let Some((version, assets)) = current_version {
let download_path = build_download_path(state, &doc, user_id)?;
Some(DocumentCurrentVersionResponse {
Some(DocumentVersionDetailResponse {
version,
assets,
download_path,
@@ -28,7 +28,6 @@ pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
+5
View File
@@ -85,6 +85,11 @@ pub fn create_router(state: AppState) -> Router<()> {
get(documents::list_document_assets).post(documents::request_document_assets),
)
.route("/:id/folder", patch(documents::move_document))
.route("/:id/versions", get(documents::list_document_versions))
.route(
"/:id/versions/:version_id",
get(documents::get_document_version),
)
.route("/:id/restore", post(documents::restore_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
+318 -124
View File
@@ -313,6 +313,7 @@ const createRootNode = () => ({
children: [],
expanded: true,
loaded: false,
hasChildren: false,
});
const StatusBanner = ({ status }) => {
@@ -421,7 +422,9 @@ const AppLayout = () => {
const routeFolderId = folderMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId;
const { status: appStatus, token } = appState;
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
const tenantSlug = tenant?.slug || null;
const currentTenantId = tenant?.tenant_id || null;
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null);
@@ -475,6 +478,7 @@ const AppLayout = () => {
const refreshPromiseRef = useRef(null);
const breadcrumbFetchRef = useRef(new Set());
const tagRemovalCursorActiveRef = useRef(false);
const tenantIdRef = useRef(currentTenantId);
const setTagRemovalCursor = useCallback((active) => {
if (typeof document === 'undefined') {
return;
@@ -584,6 +588,7 @@ const AppLayout = () => {
}
const tagManager = tagManagerRef.current;
const buildTagPayload = useCallback(
({ label, color } = {}) => tagManager.buildPayload({ label, color }),
[tagManager],
@@ -680,7 +685,6 @@ const AppLayout = () => {
const bootstrapInitializedRef = useRef(false);
const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0);
const prefetchedFoldersRef = useRef(new Set(['root']));
const detailFolderFetchRef = useRef(new Set());
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
const selectionOrderRef = useRef(initialRowSelection);
@@ -729,11 +733,11 @@ const AppLayout = () => {
setPreviewEntries(() => new Map());
previewInflightRef.current = new Map();
dragCounterRef.current = 0;
prefetchedFoldersRef.current = new Set(['root']);
breadcrumbFetchRef.current = new Set();
detailFolderFetchRef.current = new Set();
bootstrapInitializedRef.current = false;
selectionInitializedRef.current = false;
tenantIdRef.current = null;
}, [assetManager]);
const tagLookupById = useMemo(() => {
@@ -797,6 +801,10 @@ const AppLayout = () => {
tokenRef.current = token;
}, [token]);
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId]);
useEffect(() => {
const requestInterceptor = api.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
@@ -1401,9 +1409,29 @@ const AppLayout = () => {
const ensureFolderData = useCallback(
async (folderId, { force = false, includeDocuments = true } = {}) => {
if (!force && folderContents.has(folderId)) {
return folderContents.get(folderId);
async (
folderId,
{ force = false, includeDocuments = true, prefetchDepth = 0 } = {},
) => {
const requestTenantId = tenantIdRef.current;
const cached = folderContents.get(folderId);
if (!force && cached) {
const includesDocuments = Boolean(cached.__includesDocuments);
if (!includeDocuments || includesDocuments) {
if (prefetchDepth > 0) {
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
await Promise.allSettled(
subfolders.map((entry) =>
ensureFolderData(entry.id, {
includeDocuments: false,
prefetchDepth: prefetchDepth - 1,
force: false,
}),
),
);
}
return cached;
}
}
const path = folderId === 'root' ? 'root' : folderId;
@@ -1414,6 +1442,12 @@ const AppLayout = () => {
params,
});
const hydrated = assetManager.hydrateFolderContents(data);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
const childIds = childFolders.map((child) => child.id);
if (tenantIdRef.current !== requestTenantId) {
return { ...hydrated, __includesDocuments: includeDocuments };
}
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -1424,9 +1458,9 @@ const AppLayout = () => {
children: [],
expanded: folderId === 'root',
loaded: false,
hasChildren: false,
};
const childIds = (data.subfolders || []).map((child) => child.id);
next.set(folderId, {
...existingNode,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
@@ -1434,62 +1468,96 @@ const AppLayout = () => {
children: childIds,
expanded: folderId === 'root' ? true : existingNode.expanded,
loaded: true,
hasChildren: childIds.length > 0,
});
(data.subfolders || []).forEach((child) => {
childFolders.forEach((child) => {
const childNode = next.get(child.id);
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
const childHasChildren = (() => {
if (childNode?.loaded) {
return previousChildren.length > 0;
}
if (Array.isArray(child?.subfolders)) {
return child.subfolders.length > 0;
}
if (typeof child?.has_children === 'boolean') {
return child.has_children;
}
if (typeof child?.hasChildren === 'boolean') {
return child.hasChildren;
}
if (typeof childNode?.hasChildren === 'boolean') {
return childNode.hasChildren;
}
return false;
})();
next.set(child.id, {
id: child.id,
name: child.name,
parentId: child.parent_id ?? 'root',
children: childNode?.children ?? [],
children: previousChildren,
expanded: childNode?.expanded ?? false,
loaded: childNode?.loaded ?? false,
hasChildren: childHasChildren,
});
});
return next;
});
setFolderContents((prev) => {
const next = new Map(prev);
next.set(folderId, hydrated);
return next;
});
return hydrated;
},
[assetManager, folderContents],
);
const greedyPrefetchFolders = useCallback(
async (startIds) => {
const queue = Array.isArray(startIds) ? [...startIds] : [];
const visited = prefetchedFoldersRef.current;
while (queue.length) {
const nextId = queue.shift();
if (!nextId || visited.has(nextId)) {
continue;
}
visited.add(nextId);
try {
const contents = await ensureFolderData(nextId, {
force: false,
includeDocuments: false,
});
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
subfolders.forEach((entry) => {
if (entry?.id && !visited.has(entry.id)) {
queue.push(entry.id);
}
});
} catch (error) {
console.warn('[Folders] Failed to prefetch folder tree for', nextId, error);
}
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
await Promise.allSettled(
childIds.map((childId) =>
ensureFolderData(childId, {
includeDocuments: false,
force: false,
prefetchDepth: prefetchDepth - 1,
}),
),
);
}
const enriched = {
...hydrated,
__includesDocuments: includeDocuments,
};
if (includeDocuments) {
setFolderContents((prev) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map(prev);
next.set(folderId, enriched);
return next;
});
} else {
setFolderContents((prev) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map(prev);
const existingEntry = next.get(folderId);
if (existingEntry) {
next.set(folderId, {
...existingEntry,
...hydrated,
documents: existingEntry.__includesDocuments
? existingEntry.documents
: hydrated.documents,
__includesDocuments: existingEntry.__includesDocuments || false,
});
} else {
next.set(folderId, enriched);
}
return next;
});
}
return enriched;
},
[ensureFolderData],
[api, assetManager, folderContents],
);
const isInvalidFolderDrop = useCallback(
@@ -1550,9 +1618,13 @@ const AppLayout = () => {
const previousParent = next.get(previousParentKey);
if (previousParent) {
const remainingChildren = (previousParent.children || []).filter(
(childId) => childId !== folderId,
);
next.set(previousParentKey, {
...previousParent,
children: (previousParent.children || []).filter((childId) => childId !== folderId),
children: remainingChildren,
hasChildren: remainingChildren.length > 0,
});
}
@@ -1564,6 +1636,7 @@ const AppLayout = () => {
children: [],
expanded: targetKey === 'root',
loaded: false,
hasChildren: false,
});
}
@@ -1572,6 +1645,7 @@ const AppLayout = () => {
next.set(targetKey, {
...targetNode,
children: [...targetNode.children, folderId],
hasChildren: true,
});
}
@@ -1581,14 +1655,14 @@ const AppLayout = () => {
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
if (key === 'root') {
await ensureFolderData('root', { force: true });
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
} else {
await ensureFolderData(key, { force: true });
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
}
}
if (selectedFolder === folderId) {
await ensureFolderData(folderId, { force: true });
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
setSelectedFolder(folderId);
}
@@ -1600,9 +1674,9 @@ const AppLayout = () => {
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
if (key === 'root') {
await ensureFolderData('root', { force: true });
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
} else {
await ensureFolderData(key, { force: true });
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
}
}
}
@@ -1611,19 +1685,33 @@ const AppLayout = () => {
);
const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
try {
const { data } = await api.get('/tags');
if (tenantIdRef.current !== requestTenantId) {
return;
}
setTags(data || []);
} catch (error) {
if (tenantIdRef.current !== requestTenantId) {
return;
}
notifyApiError(error, 'Unable to load tags.');
}
}, [api, notifyApiError]);
const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
try {
const { data } = await api.get('/correspondents');
if (tenantIdRef.current !== requestTenantId) {
return;
}
setCorrespondents(data || []);
} catch (error) {
if (tenantIdRef.current !== requestTenantId) {
return;
}
notifyApiError(error, 'Unable to load correspondents.');
}
}, [api, notifyApiError]);
@@ -1914,10 +2002,14 @@ const AppLayout = () => {
setSelectedFolder(targetId);
if (showLoading) setLoading(true);
try {
const contents = await ensureFolderData(targetId, { force: true });
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
if (targetId !== 'root') {
try {
await ensureFolderData('root', { force: false });
await ensureFolderData('root', {
force: false,
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
console.warn('Failed to refresh root folder tree', error);
}
@@ -1935,27 +2027,6 @@ const AppLayout = () => {
[ensureFolderData, applySelectedFolder, notifyApiError],
);
useEffect(() => {
if (!folderContents || typeof folderContents.forEach !== 'function') {
return;
}
const pendingIds = [];
folderContents.forEach((contents) => {
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
subfolders.forEach((entry) => {
if (entry?.id && !prefetchedFoldersRef.current.has(entry.id)) {
pendingIds.push(entry.id);
}
});
});
if (!pendingIds.length) {
return;
}
greedyPrefetchFolders(pendingIds).catch(() => {});
}, [folderContents, greedyPrefetchFolders]);
const expandFolderAncestors = useCallback(
(targetId) => {
@@ -2051,7 +2122,11 @@ const AppLayout = () => {
}
if (!folderNodes.has(ancestorId)) {
try {
await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
await ensureFolderData(ancestorId, {
includeDocuments: false,
force: false,
prefetchDepth: 1,
});
} catch (error) {
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
}
@@ -2136,7 +2211,10 @@ const AppLayout = () => {
const refreshCurrentFolder = useCallback(async () => {
setLoading(true);
try {
const contents = await ensureFolderData(selectedFolder, { force: true });
const contents = await ensureFolderData(selectedFolder, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(selectedFolder, contents);
} catch (error) {
notifyApiError(error, 'Failed to refresh folder.');
@@ -2311,7 +2389,9 @@ const AppLayout = () => {
const target = form.get('target')?.toString() || 'root';
const folderId = target === 'root' ? null : target;
setLoading(true);
if (!refreshOnly) {
setLoading(true);
}
try {
await api.post('/documents/bulk/move', {
document_ids: selectedDocumentIds,
@@ -2331,9 +2411,9 @@ const AppLayout = () => {
applySelection([], { anchor: null });
await refreshCurrentFolder();
await refreshCurrentFolder();
if (folderId && folderId !== selectedFolder) {
await ensureFolderData(folderId, { force: true });
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
}
event.currentTarget.reset();
@@ -2433,7 +2513,9 @@ const AppLayout = () => {
notifyApiError(error, message);
return { ok: false, reason: 'request-failed' };
} finally {
setLoading(false);
if (!refreshOnly) {
setLoading(false);
}
}
},
[
@@ -3224,7 +3306,7 @@ const AppLayout = () => {
targetFolderId !== 'root' &&
targetFolderId !== selectedFolder
) {
await ensureFolderData(targetFolderId, { force: true });
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
notifyApiError(error, 'Failed to upload files.');
@@ -3285,7 +3367,7 @@ const AppLayout = () => {
await refreshCurrentFolder();
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true });
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
const message = error.response?.data?.error || 'Failed to move documents.';
@@ -3728,7 +3810,10 @@ const AppLayout = () => {
}
setLoading(true);
try {
const contents = await ensureFolderData(folderId, { force: true });
const contents = await ensureFolderData(folderId, {
force: true,
prefetchDepth: 1,
});
const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) {
@@ -3744,9 +3829,11 @@ const AppLayout = () => {
const parentId = node.parentId || 'root';
const parentNode = next.get(parentId);
if (parentNode) {
const remaining = parentNode.children.filter((id) => id !== folderId);
next.set(parentId, {
...parentNode,
children: parentNode.children.filter((id) => id !== folderId),
children: remaining,
hasChildren: remaining.length > 0,
});
}
}
@@ -3761,11 +3848,14 @@ const AppLayout = () => {
const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root';
setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, { force: true });
const parentContents = await ensureFolderData(parentId, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(parentId, parentContents);
} else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true });
}
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
}
setStatusMessage('Folder deleted.', 'success');
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete folder.';
@@ -3871,6 +3961,7 @@ const AppLayout = () => {
...parentNode,
children: parentNode.children.concat([data.folder.id]),
loaded: true,
hasChildren: true,
});
}
next.set(data.folder.id, {
@@ -3880,10 +3971,11 @@ const AppLayout = () => {
children: [],
expanded: false,
loaded: false,
hasChildren: false,
});
return next;
});
await ensureFolderData(selectedFolder, { force: true });
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
succeeded = true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to create folder.';
@@ -4350,9 +4442,22 @@ const AppLayout = () => {
const folderClickHandlers = {
onToggle: async (folderId) => {
const node = folderNodes.get(folderId);
if (node && !node.loaded) {
const nextExpanded = !(node?.expanded ?? false);
if (nextExpanded) {
try {
await ensureFolderData(folderId, { includeDocuments: false });
await ensureFolderData(folderId, {
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
notifyApiError(error, 'Failed to load folder.');
}
} else if (node && !node.loaded) {
try {
await ensureFolderData(folderId, {
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
notifyApiError(error, 'Failed to load folder.');
}
@@ -4361,7 +4466,7 @@ const AppLayout = () => {
const next = new Map(prev);
const current = next.get(folderId);
if (!current) return prev;
next.set(folderId, { ...current, expanded: !current.expanded });
next.set(folderId, { ...current, expanded: nextExpanded });
return next;
});
},
@@ -4789,38 +4894,6 @@ const AppLayout = () => {
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
const sidebarProps = {
folderNodes,
onToggle: folderClickHandlers.onToggle,
onSelect: folderClickHandlers.onSelect,
onDrop: folderClickHandlers.onDrop,
onDragOver: folderClickHandlers.onDragOver,
onDragLeave: folderClickHandlers.onDragLeave,
onDeleteFolder: handleFolderDelete,
onRenameFolder: handleFolderRename,
selectedFolder,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
tags,
activeTagIds: activeTagFilters,
onToggleTagFilter: toggleTagFilter,
correspondents,
activeCorrespondentIds: activeCorrespondentFilters,
onToggleCorrespondentFilter: toggleCorrespondentFilter,
appStatus,
loading,
previewActive,
searchQuery,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchClear: clearFilters,
isFilterActive,
onLogout: handleLogout,
status,
onCollapse: collapseSidebar,
};
const resolveThumbnailUrlForDoc = useCallback(
(doc) =>
resolveDocumentAssetUrl(doc, 'thumbnail', {
@@ -4842,6 +4915,81 @@ const AppLayout = () => {
});
}, []);
const handleTenantSelect = useCallback(
async (tenantOption, { refreshOnly = false } = {}) => {
const requestedTenantId =
tenantOption?.tenant_id || tenantOption?.tenantId || tenantOption?.id || null;
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
return;
}
setLoading(true);
try {
if (!refreshOnly) {
setStatusMessage('Switching tenant…', 'info');
}
if (refreshOnly) {
const { data } = await api.get('/auth/tenants');
appDispatch({
type: 'SET_TENANTS',
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
});
return;
}
const { data } = await api.post('/auth/select-tenant', { tenant_id: requestedTenantId });
if (!data?.access_token) {
throw new Error('Missing access token in tenant switch response.');
}
appDispatch({ type: 'LOGOUT' });
resetWorkspaceState();
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
tokenRef.current = data.access_token;
tenantIdRef.current = data?.tenant?.tenant_id || null;
if (Array.isArray(data?.tenants)) {
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
}
setWorkspaceMode('table');
navigate('/documents', { replace: true });
await Promise.all([refreshTags(), refreshCorrespondents()]);
await loadFolder('root', { showLoading: false, preserveSearch: false });
setStatusMessage(
`Switched to ${data?.tenant?.slug || data?.tenant?.tenant_id || 'tenant'}.`,
'info',
);
} catch (error) {
notifyApiError(error, 'Failed to switch tenant.');
} finally {
setLoading(false);
}
},
[
currentTenantId,
appDispatch,
notifyApiError,
setStatusMessage,
resetWorkspaceState,
setWorkspaceMode,
navigate,
refreshTags,
refreshCorrespondents,
loadFolder,
],
);
const documentsTableProps = {
currentFolderName,
breadcrumbs,
@@ -4894,6 +5042,52 @@ const AppLayout = () => {
onClearSelection: clearDocumentSelection,
};
const sidebarProps = {
folderNodes,
onToggle: folderClickHandlers.onToggle,
onSelect: folderClickHandlers.onSelect,
onDrop: folderClickHandlers.onDrop,
onDragOver: folderClickHandlers.onDragOver,
onDragLeave: folderClickHandlers.onDragLeave,
onDeleteFolder: handleFolderDelete,
onRenameFolder: handleFolderRename,
selectedFolder,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
tags,
activeTagIds: activeTagFilters,
onToggleTagFilter: toggleTagFilter,
correspondents,
activeCorrespondentIds: activeCorrespondentFilters,
onToggleCorrespondentFilter: toggleCorrespondentFilter,
appStatus,
loading,
previewActive,
searchQuery,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchClear: clearFilters,
isFilterActive,
onLogout: handleLogout,
status,
onCollapse: collapseSidebar,
tenantSlug,
tenants: tenantOptions,
activeTenantId: currentTenantId,
onSelectTenant: handleTenantSelect,
};
const sidebarPropsWithActions = useMemo(
() => ({
...sidebarProps,
onManageTags: openTagsModal,
onManageCorrespondents: openCorrespondentsModal,
onCollapse: collapseSidebar,
}),
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
);
const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments,
tags,
+124 -11
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
ChevronsLeftIcon,
LogoutIcon,
ChevronDownIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
@@ -20,9 +28,9 @@ const FolderNode = ({
draggingFolderId,
}) => {
const isRoot = node.id === 'root';
const hasChildren = node.children.length > 0;
const hasChildren = Boolean(node.hasChildren);
const canToggle = !isRoot && (hasChildren || !node.loaded);
const showChevron = !isRoot && hasChildren;
const showChevron = !isRoot && (hasChildren || !node.loaded);
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
const canDrag = !isRoot;
const isDragging = draggingFolderId === node.id;
@@ -149,6 +157,10 @@ const Sidebar = ({
onLogout,
status,
onCollapse,
tenantSlug,
tenants = [],
activeTenantId = null,
onSelectTenant,
}) => {
const sortedCorrespondents = useMemo(
() =>
@@ -181,7 +193,59 @@ const Sidebar = ({
const handleSearchClear = useCallback(() => {
onSearchClear?.();
}, [onSearchClear]);
const handleLogoutClick = useCallback(() => {
const tenantButtonRef = useRef(null);
const tenantMenuRef = useRef(null);
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
const toggleTenantMenu = useCallback(() => {
const next = !tenantMenuOpen;
setTenantMenuOpen(next);
if (next && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true });
}
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
useEffect(() => {
if (!tenantMenuOpen) {
return undefined;
}
const handlePointer = (event) => {
const menuNode = tenantMenuRef.current;
const buttonNode = tenantButtonRef.current;
if (!menuNode) return;
if (menuNode.contains(event.target)) return;
if (buttonNode && buttonNode.contains(event.target)) return;
setTenantMenuOpen(false);
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setTenantMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKeyDown);
};
}, [tenantMenuOpen]);
const handleTenantSelect = useCallback(
(tenant) => {
const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
if (!targetId) {
return;
}
setTenantMenuOpen(false);
onSelectTenant?.(tenant);
},
[onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onLogout?.();
}, [onLogout]);
@@ -237,7 +301,61 @@ const Sidebar = ({
<aside className="sidebar">
<div className="panel-header sidebar__header">
<div className="panel-actions">
<h1 className="sidebar__title">Papercrate</h1>
<button
type="button"
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
onClick={toggleTenantMenu}
aria-haspopup="true"
aria-expanded={tenantMenuOpen}
ref={tenantButtonRef}
>
<span className="sidebar__title">
Papercrate
{tenantSlug ? <span className="sidebar__tenant"> / {tenantSlug}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuOpen ? (
<div className="menu" ref={tenantMenuRef} role="menu">
<div className="menu__list">
{tenants.length === 0 ? (
<span className="menu__empty">No tenants available.</span>
) : (
tenants.map((tenant) => {
const tenantId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
const isActive = tenantId === activeTenantId;
return (
<button
key={tenantId || tenant?.slug || tenant?.name}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__label">
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
</span>
{isActive ? <span className="menu__active-indicator">Active</span> : null}
</button>
);
})
)}
</div>
<div className="menu__footer">
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
<div className="spacer" />
{onCollapse ? (
<button
@@ -376,11 +494,6 @@ const Sidebar = ({
})}
</ul>
</div>
<div className="sidebar__footer">
<button className="secondary" type="button" onClick={handleLogoutClick}>
Log out
</button>
</div>
</div>
</aside>
);
+140 -4
View File
@@ -203,8 +203,8 @@ button.danger:hover:not([disabled]) {
.panel-actions button,
.panel-actions a.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
align-items: flex-start;
justify-content: flex-start;
border: none;
background: transparent;
color: var(--muted);
@@ -213,6 +213,7 @@ button.danger:hover:not([disabled]) {
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
text-decoration: none;
text-align: left;
}
.panel-actions .icon-button:hover:not([disabled]),
@@ -1051,6 +1052,142 @@ button.danger:hover:not([disabled]) {
color: var(--fg);
}
.sidebar__tenant {
color: var(--muted);
font-weight: 500;
}
.sidebar__header {
position: relative;
}
.sidebar__title-button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
}
.sidebar__title-button:focus-visible {
outline: 2px solid var(--accent, #2563eb);
outline-offset: 2px;
}
.sidebar__title-chevron {
transition: transform 0.2s ease;
}
.sidebar__title-chevron.is-open {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: 100%;
left: 1em;
right: auto;
background: var(--surface);
border: 1px solid var(--border-muted, var(--border));
border-radius: 0.5rem;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
min-width: 220px;
z-index: 20;
overflow: hidden;
}
.menu__list {
max-height: 260px;
overflow-y: auto;
padding: 0.35rem 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.menu button.menu__item,
.menu .menu__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
padding: 0.55rem 1rem;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.menu button.menu__item:hover,
.menu button.menu__item:focus-visible,
.menu .menu__item:hover,
.menu .menu__item:focus-visible {
background: var(--sidebar-hover-bg);
color: var(--fg);
}
.menu button.menu__item.active,
.menu .menu__item.active {
font-weight: 600;
color: var(--accent, #2563eb);
}
.menu button.menu__item:focus-visible,
.menu .menu__item:focus-visible {
outline: 2px solid var(--accent, #2563eb);
outline-offset: -2px;
}
.menu__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.menu__active-indicator {
font-size: 0.75rem;
color: var(--muted);
}
.menu__empty {
display: block;
padding: 0.6rem 0.75rem;
color: var(--muted);
font-size: 0.85rem;
}
.menu__footer {
border-top: 1px solid var(--border-muted, var(--border));
padding: 0.35rem 0.5rem;
}
.menu__logout {
width: 100%;
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: none;
background: none;
color: var(--danger, #d14343);
font: inherit;
cursor: pointer;
padding: 0.4rem 0.5rem;
border-radius: 0.35rem;
}
.menu__logout:hover,
.menu__logout:focus-visible {
background: rgba(209, 67, 67, 0.12);
}
.sidebar__hint {
color: var(--muted);
font-size: 0.9rem;
@@ -1108,6 +1245,7 @@ button.danger:hover:not([disabled]) {
.sidebar-section__header {
display: flex;
align-items: center;
font-size: 0.75rem;
justify-content: space-between;
color: var(--muted);
}
@@ -1142,9 +1280,7 @@ button.danger:hover:not([disabled]) {
.sidebar-section__header h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: inherit;
}
.sidebar-item {
+22
View File
@@ -18,6 +18,8 @@ import {
IconFolderPlus,
IconRefresh,
IconMinusVertical,
IconLogout,
IconChevronDown,
} from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
@@ -201,6 +203,24 @@ export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...r
/>
);
export const LogoutIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLogout
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ChevronDownIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronDown
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default {
ChevronIcon,
TrashIcon,
@@ -219,6 +239,8 @@ export default {
RefreshIcon,
ArrowUpIcon,
MinusVerticalIcon,
LogoutIcon,
ChevronDownIcon,
};
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (