frontend
This commit is contained in:
+35
-6
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
Reference in New Issue
Block a user