frontend
This commit is contained in:
+35
-6
@@ -57,7 +57,8 @@ use uuid::Uuid;
|
|||||||
schemas::LoginResponseVariants,
|
schemas::LoginResponseVariants,
|
||||||
schemas::DocumentResponse,
|
schemas::DocumentResponse,
|
||||||
schemas::DocumentDetailResponse,
|
schemas::DocumentDetailResponse,
|
||||||
schemas::DocumentVersion,
|
schemas::DocumentVersionResponse,
|
||||||
|
schemas::DocumentVersionDetailResponse,
|
||||||
schemas::DocumentAssetSummary,
|
schemas::DocumentAssetSummary,
|
||||||
schemas::DocumentAssetDetail,
|
schemas::DocumentAssetDetail,
|
||||||
schemas::DocumentAssetObject,
|
schemas::DocumentAssetObject,
|
||||||
@@ -238,6 +239,27 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn delete_document() {}
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/documents/{id}/restore",
|
path = "/api/documents/{id}/restore",
|
||||||
@@ -650,15 +672,22 @@ pub mod schemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct DocumentVersion {
|
pub struct DocumentVersionResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub version_number: i32,
|
pub version_number: i32,
|
||||||
pub checksum: String,
|
pub s3_key: String,
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub metadata: Value,
|
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,
|
pub download_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +721,7 @@ pub mod schemas {
|
|||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub current_version: Option<DocumentVersion>,
|
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ impl From<Tag> for TagResponse {
|
|||||||
pub struct DocumentVersionResponse {
|
pub struct DocumentVersionResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub version_number: i32,
|
pub version_number: i32,
|
||||||
pub s3_key: String,
|
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -175,7 +174,7 @@ pub struct DocumentAssetDetailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
pub struct DocumentCurrentVersionResponse {
|
pub struct DocumentVersionDetailResponse {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub version: DocumentVersionResponse,
|
pub version: DocumentVersionResponse,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -209,7 +208,7 @@ pub struct DocumentResponse {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub current_version: Option<DocumentCurrentVersionResponse>,
|
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||||
}
|
}
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct DocumentDetailResponse {
|
pub struct DocumentDetailResponse {
|
||||||
@@ -1145,6 +1144,73 @@ pub async fn get_document_asset(
|
|||||||
Ok(Json(to_asset_detail_response(asset, object_responses)))
|
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(
|
pub async fn download_document(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
@@ -2574,7 +2640,7 @@ pub(crate) fn to_document_response(
|
|||||||
) -> AppResult<DocumentResponse> {
|
) -> AppResult<DocumentResponse> {
|
||||||
let current_version = if let Some((version, assets)) = current_version {
|
let current_version = if let Some((version, assets)) = current_version {
|
||||||
let download_path = build_download_path(state, &doc, user_id)?;
|
let download_path = build_download_path(state, &doc, user_id)?;
|
||||||
Some(DocumentCurrentVersionResponse {
|
Some(DocumentVersionDetailResponse {
|
||||||
version,
|
version,
|
||||||
assets,
|
assets,
|
||||||
download_path,
|
download_path,
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse
|
|||||||
DocumentVersionResponse {
|
DocumentVersionResponse {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
version_number: version.version_number,
|
version_number: version.version_number,
|
||||||
s3_key: version.s3_key,
|
|
||||||
size_bytes: version.size_bytes,
|
size_bytes: version.size_bytes,
|
||||||
checksum: version.checksum,
|
checksum: version.checksum,
|
||||||
created_at: to_iso(version.created_at),
|
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),
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
)
|
)
|
||||||
.route("/:id/folder", patch(documents::move_document))
|
.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/restore", post(documents::restore_document))
|
||||||
.route("/:id/tags", post(documents::assign_tags))
|
.route("/:id/tags", post(documents::assign_tags))
|
||||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||||
|
|||||||
+309
-115
@@ -313,6 +313,7 @@ const createRootNode = () => ({
|
|||||||
children: [],
|
children: [],
|
||||||
expanded: true,
|
expanded: true,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const StatusBanner = ({ status }) => {
|
const StatusBanner = ({ status }) => {
|
||||||
@@ -421,7 +422,9 @@ const AppLayout = () => {
|
|||||||
const routeFolderId = folderMatch?.params?.folderId || null;
|
const routeFolderId = folderMatch?.params?.folderId || null;
|
||||||
const routeDocumentId = docMatch?.params?.documentId || null;
|
const routeDocumentId = docMatch?.params?.documentId || null;
|
||||||
const previewDocumentId = routeDocumentId;
|
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 [status, setStatus] = useState(null);
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -475,6 +478,7 @@ const AppLayout = () => {
|
|||||||
const refreshPromiseRef = useRef(null);
|
const refreshPromiseRef = useRef(null);
|
||||||
const breadcrumbFetchRef = useRef(new Set());
|
const breadcrumbFetchRef = useRef(new Set());
|
||||||
const tagRemovalCursorActiveRef = useRef(false);
|
const tagRemovalCursorActiveRef = useRef(false);
|
||||||
|
const tenantIdRef = useRef(currentTenantId);
|
||||||
const setTagRemovalCursor = useCallback((active) => {
|
const setTagRemovalCursor = useCallback((active) => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -584,6 +588,7 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
const tagManager = tagManagerRef.current;
|
const tagManager = tagManagerRef.current;
|
||||||
|
|
||||||
|
|
||||||
const buildTagPayload = useCallback(
|
const buildTagPayload = useCallback(
|
||||||
({ label, color } = {}) => tagManager.buildPayload({ label, color }),
|
({ label, color } = {}) => tagManager.buildPayload({ label, color }),
|
||||||
[tagManager],
|
[tagManager],
|
||||||
@@ -680,7 +685,6 @@ const AppLayout = () => {
|
|||||||
const bootstrapInitializedRef = useRef(false);
|
const bootstrapInitializedRef = useRef(false);
|
||||||
const selectionInitializedRef = useRef(false);
|
const selectionInitializedRef = useRef(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const prefetchedFoldersRef = useRef(new Set(['root']));
|
|
||||||
const detailFolderFetchRef = useRef(new Set());
|
const detailFolderFetchRef = useRef(new Set());
|
||||||
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
|
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
|
||||||
const selectionOrderRef = useRef(initialRowSelection);
|
const selectionOrderRef = useRef(initialRowSelection);
|
||||||
@@ -729,11 +733,11 @@ const AppLayout = () => {
|
|||||||
setPreviewEntries(() => new Map());
|
setPreviewEntries(() => new Map());
|
||||||
previewInflightRef.current = new Map();
|
previewInflightRef.current = new Map();
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
prefetchedFoldersRef.current = new Set(['root']);
|
|
||||||
breadcrumbFetchRef.current = new Set();
|
breadcrumbFetchRef.current = new Set();
|
||||||
detailFolderFetchRef.current = new Set();
|
detailFolderFetchRef.current = new Set();
|
||||||
bootstrapInitializedRef.current = false;
|
bootstrapInitializedRef.current = false;
|
||||||
selectionInitializedRef.current = false;
|
selectionInitializedRef.current = false;
|
||||||
|
tenantIdRef.current = null;
|
||||||
}, [assetManager]);
|
}, [assetManager]);
|
||||||
|
|
||||||
const tagLookupById = useMemo(() => {
|
const tagLookupById = useMemo(() => {
|
||||||
@@ -797,6 +801,10 @@ const AppLayout = () => {
|
|||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tenantIdRef.current = currentTenantId;
|
||||||
|
}, [currentTenantId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestInterceptor = api.interceptors.request.use((config) => {
|
const requestInterceptor = api.interceptors.request.use((config) => {
|
||||||
const currentToken = tokenRef.current;
|
const currentToken = tokenRef.current;
|
||||||
@@ -1401,9 +1409,29 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
|
|
||||||
const ensureFolderData = useCallback(
|
const ensureFolderData = useCallback(
|
||||||
async (folderId, { force = false, includeDocuments = true } = {}) => {
|
async (
|
||||||
if (!force && folderContents.has(folderId)) {
|
folderId,
|
||||||
return folderContents.get(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;
|
const path = folderId === 'root' ? 'root' : folderId;
|
||||||
@@ -1414,6 +1442,12 @@ const AppLayout = () => {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
const hydrated = assetManager.hydrateFolderContents(data);
|
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) => {
|
setFolderNodes((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -1424,9 +1458,9 @@ const AppLayout = () => {
|
|||||||
children: [],
|
children: [],
|
||||||
expanded: folderId === 'root',
|
expanded: folderId === 'root',
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const childIds = (data.subfolders || []).map((child) => child.id);
|
|
||||||
next.set(folderId, {
|
next.set(folderId, {
|
||||||
...existingNode,
|
...existingNode,
|
||||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
||||||
@@ -1434,62 +1468,96 @@ const AppLayout = () => {
|
|||||||
children: childIds,
|
children: childIds,
|
||||||
expanded: folderId === 'root' ? true : existingNode.expanded,
|
expanded: folderId === 'root' ? true : existingNode.expanded,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
hasChildren: childIds.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
(data.subfolders || []).forEach((child) => {
|
childFolders.forEach((child) => {
|
||||||
const childNode = next.get(child.id);
|
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, {
|
next.set(child.id, {
|
||||||
id: child.id,
|
id: child.id,
|
||||||
name: child.name,
|
name: child.name,
|
||||||
parentId: child.parent_id ?? 'root',
|
parentId: child.parent_id ?? 'root',
|
||||||
children: childNode?.children ?? [],
|
children: previousChildren,
|
||||||
expanded: childNode?.expanded ?? false,
|
expanded: childNode?.expanded ?? false,
|
||||||
loaded: childNode?.loaded ?? false,
|
loaded: childNode?.loaded ?? false,
|
||||||
|
hasChildren: childHasChildren,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setFolderContents((prev) => {
|
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
|
||||||
const next = new Map(prev);
|
await Promise.allSettled(
|
||||||
next.set(folderId, hydrated);
|
childIds.map((childId) =>
|
||||||
return next;
|
ensureFolderData(childId, {
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
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;
|
||||||
});
|
});
|
||||||
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
|
} else {
|
||||||
subfolders.forEach((entry) => {
|
setFolderContents((prev) => {
|
||||||
if (entry?.id && !visited.has(entry.id)) {
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
queue.push(entry.id);
|
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,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} else {
|
||||||
console.warn('[Folders] Failed to prefetch folder tree for', nextId, error);
|
next.set(folderId, enriched);
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return enriched;
|
||||||
},
|
},
|
||||||
[ensureFolderData],
|
[api, assetManager, folderContents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInvalidFolderDrop = useCallback(
|
const isInvalidFolderDrop = useCallback(
|
||||||
@@ -1550,9 +1618,13 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const previousParent = next.get(previousParentKey);
|
const previousParent = next.get(previousParentKey);
|
||||||
if (previousParent) {
|
if (previousParent) {
|
||||||
|
const remainingChildren = (previousParent.children || []).filter(
|
||||||
|
(childId) => childId !== folderId,
|
||||||
|
);
|
||||||
next.set(previousParentKey, {
|
next.set(previousParentKey, {
|
||||||
...previousParent,
|
...previousParent,
|
||||||
children: (previousParent.children || []).filter((childId) => childId !== folderId),
|
children: remainingChildren,
|
||||||
|
hasChildren: remainingChildren.length > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,6 +1636,7 @@ const AppLayout = () => {
|
|||||||
children: [],
|
children: [],
|
||||||
expanded: targetKey === 'root',
|
expanded: targetKey === 'root',
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,6 +1645,7 @@ const AppLayout = () => {
|
|||||||
next.set(targetKey, {
|
next.set(targetKey, {
|
||||||
...targetNode,
|
...targetNode,
|
||||||
children: [...targetNode.children, folderId],
|
children: [...targetNode.children, folderId],
|
||||||
|
hasChildren: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,14 +1655,14 @@ const AppLayout = () => {
|
|||||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
for (const key of refreshTargets) {
|
for (const key of refreshTargets) {
|
||||||
if (key === 'root') {
|
if (key === 'root') {
|
||||||
await ensureFolderData('root', { force: true });
|
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
|
||||||
} else {
|
} else {
|
||||||
await ensureFolderData(key, { force: true });
|
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFolder === folderId) {
|
if (selectedFolder === folderId) {
|
||||||
await ensureFolderData(folderId, { force: true });
|
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||||
setSelectedFolder(folderId);
|
setSelectedFolder(folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1600,9 +1674,9 @@ const AppLayout = () => {
|
|||||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
for (const key of refreshTargets) {
|
for (const key of refreshTargets) {
|
||||||
if (key === 'root') {
|
if (key === 'root') {
|
||||||
await ensureFolderData('root', { force: true });
|
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
|
||||||
} else {
|
} else {
|
||||||
await ensureFolderData(key, { force: true });
|
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1611,19 +1685,33 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const refreshTags = useCallback(async () => {
|
const refreshTags = useCallback(async () => {
|
||||||
|
const requestTenantId = tenantIdRef.current;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/tags');
|
const { data } = await api.get('/tags');
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTags(data || []);
|
setTags(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
notifyApiError(error, 'Unable to load tags.');
|
notifyApiError(error, 'Unable to load tags.');
|
||||||
}
|
}
|
||||||
}, [api, notifyApiError]);
|
}, [api, notifyApiError]);
|
||||||
|
|
||||||
const refreshCorrespondents = useCallback(async () => {
|
const refreshCorrespondents = useCallback(async () => {
|
||||||
|
const requestTenantId = tenantIdRef.current;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/correspondents');
|
const { data } = await api.get('/correspondents');
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCorrespondents(data || []);
|
setCorrespondents(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
notifyApiError(error, 'Unable to load correspondents.');
|
notifyApiError(error, 'Unable to load correspondents.');
|
||||||
}
|
}
|
||||||
}, [api, notifyApiError]);
|
}, [api, notifyApiError]);
|
||||||
@@ -1914,10 +2002,14 @@ const AppLayout = () => {
|
|||||||
setSelectedFolder(targetId);
|
setSelectedFolder(targetId);
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const contents = await ensureFolderData(targetId, { force: true });
|
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
||||||
if (targetId !== 'root') {
|
if (targetId !== 'root') {
|
||||||
try {
|
try {
|
||||||
await ensureFolderData('root', { force: false });
|
await ensureFolderData('root', {
|
||||||
|
force: false,
|
||||||
|
includeDocuments: false,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to refresh root folder tree', error);
|
console.warn('Failed to refresh root folder tree', error);
|
||||||
}
|
}
|
||||||
@@ -1935,27 +2027,6 @@ const AppLayout = () => {
|
|||||||
[ensureFolderData, applySelectedFolder, notifyApiError],
|
[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(
|
const expandFolderAncestors = useCallback(
|
||||||
(targetId) => {
|
(targetId) => {
|
||||||
@@ -2051,7 +2122,11 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
if (!folderNodes.has(ancestorId)) {
|
if (!folderNodes.has(ancestorId)) {
|
||||||
try {
|
try {
|
||||||
await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
|
await ensureFolderData(ancestorId, {
|
||||||
|
includeDocuments: false,
|
||||||
|
force: false,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
|
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
|
||||||
}
|
}
|
||||||
@@ -2136,7 +2211,10 @@ const AppLayout = () => {
|
|||||||
const refreshCurrentFolder = useCallback(async () => {
|
const refreshCurrentFolder = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const contents = await ensureFolderData(selectedFolder, { force: true });
|
const contents = await ensureFolderData(selectedFolder, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
applySelectedFolder(selectedFolder, contents);
|
applySelectedFolder(selectedFolder, contents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Failed to refresh folder.');
|
notifyApiError(error, 'Failed to refresh folder.');
|
||||||
@@ -2311,7 +2389,9 @@ const AppLayout = () => {
|
|||||||
const target = form.get('target')?.toString() || 'root';
|
const target = form.get('target')?.toString() || 'root';
|
||||||
const folderId = target === 'root' ? null : target;
|
const folderId = target === 'root' ? null : target;
|
||||||
|
|
||||||
|
if (!refreshOnly) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.post('/documents/bulk/move', {
|
await api.post('/documents/bulk/move', {
|
||||||
document_ids: selectedDocumentIds,
|
document_ids: selectedDocumentIds,
|
||||||
@@ -2333,7 +2413,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
if (folderId && folderId !== selectedFolder) {
|
if (folderId && folderId !== selectedFolder) {
|
||||||
await ensureFolderData(folderId, { force: true });
|
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
event.currentTarget.reset();
|
event.currentTarget.reset();
|
||||||
@@ -2433,8 +2513,10 @@ const AppLayout = () => {
|
|||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return { ok: false, reason: 'request-failed' };
|
return { ok: false, reason: 'request-failed' };
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!refreshOnly) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
@@ -3224,7 +3306,7 @@ const AppLayout = () => {
|
|||||||
targetFolderId !== 'root' &&
|
targetFolderId !== 'root' &&
|
||||||
targetFolderId !== selectedFolder
|
targetFolderId !== selectedFolder
|
||||||
) {
|
) {
|
||||||
await ensureFolderData(targetFolderId, { force: true });
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Failed to upload files.');
|
notifyApiError(error, 'Failed to upload files.');
|
||||||
@@ -3285,7 +3367,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
if (targetFolderId && targetFolderId !== selectedFolder) {
|
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||||||
await ensureFolderData(targetFolderId, { force: true });
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to move documents.';
|
const message = error.response?.data?.error || 'Failed to move documents.';
|
||||||
@@ -3728,7 +3810,10 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const contents = await ensureFolderData(folderId, { force: true });
|
const contents = await ensureFolderData(folderId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
const hasChildren = (contents.subfolders || []).length > 0;
|
const hasChildren = (contents.subfolders || []).length > 0;
|
||||||
const hasDocs = (contents.documents || []).length > 0;
|
const hasDocs = (contents.documents || []).length > 0;
|
||||||
if (hasChildren || hasDocs) {
|
if (hasChildren || hasDocs) {
|
||||||
@@ -3744,9 +3829,11 @@ const AppLayout = () => {
|
|||||||
const parentId = node.parentId || 'root';
|
const parentId = node.parentId || 'root';
|
||||||
const parentNode = next.get(parentId);
|
const parentNode = next.get(parentId);
|
||||||
if (parentNode) {
|
if (parentNode) {
|
||||||
|
const remaining = parentNode.children.filter((id) => id !== folderId);
|
||||||
next.set(parentId, {
|
next.set(parentId, {
|
||||||
...parentNode,
|
...parentNode,
|
||||||
children: parentNode.children.filter((id) => id !== folderId),
|
children: remaining,
|
||||||
|
hasChildren: remaining.length > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3761,10 +3848,13 @@ const AppLayout = () => {
|
|||||||
const node = folderNodes.get(folderId);
|
const node = folderNodes.get(folderId);
|
||||||
const parentId = node?.parentId || 'root';
|
const parentId = node?.parentId || 'root';
|
||||||
setSelectedFolder(parentId);
|
setSelectedFolder(parentId);
|
||||||
const parentContents = await ensureFolderData(parentId, { force: true });
|
const parentContents = await ensureFolderData(parentId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
applySelectedFolder(parentId, parentContents);
|
applySelectedFolder(parentId, parentContents);
|
||||||
} else if (selectedFolder !== 'root') {
|
} else if (selectedFolder !== 'root') {
|
||||||
await ensureFolderData(selectedFolder, { force: true });
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
setStatusMessage('Folder deleted.', 'success');
|
setStatusMessage('Folder deleted.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3871,6 +3961,7 @@ const AppLayout = () => {
|
|||||||
...parentNode,
|
...parentNode,
|
||||||
children: parentNode.children.concat([data.folder.id]),
|
children: parentNode.children.concat([data.folder.id]),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
hasChildren: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next.set(data.folder.id, {
|
next.set(data.folder.id, {
|
||||||
@@ -3880,10 +3971,11 @@ const AppLayout = () => {
|
|||||||
children: [],
|
children: [],
|
||||||
expanded: false,
|
expanded: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
await ensureFolderData(selectedFolder, { force: true });
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||||
succeeded = true;
|
succeeded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to create folder.';
|
const message = error.response?.data?.error || 'Failed to create folder.';
|
||||||
@@ -4350,9 +4442,22 @@ const AppLayout = () => {
|
|||||||
const folderClickHandlers = {
|
const folderClickHandlers = {
|
||||||
onToggle: async (folderId) => {
|
onToggle: async (folderId) => {
|
||||||
const node = folderNodes.get(folderId);
|
const node = folderNodes.get(folderId);
|
||||||
if (node && !node.loaded) {
|
const nextExpanded = !(node?.expanded ?? false);
|
||||||
|
if (nextExpanded) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Failed to load folder.');
|
notifyApiError(error, 'Failed to load folder.');
|
||||||
}
|
}
|
||||||
@@ -4361,7 +4466,7 @@ const AppLayout = () => {
|
|||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
const current = next.get(folderId);
|
const current = next.get(folderId);
|
||||||
if (!current) return prev;
|
if (!current) return prev;
|
||||||
next.set(folderId, { ...current, expanded: !current.expanded });
|
next.set(folderId, { ...current, expanded: nextExpanded });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -4789,38 +4894,6 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
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(
|
const resolveThumbnailUrlForDoc = useCallback(
|
||||||
(doc) =>
|
(doc) =>
|
||||||
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
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 = {
|
const documentsTableProps = {
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
@@ -4894,6 +5042,52 @@ const AppLayout = () => {
|
|||||||
onClearSelection: clearDocumentSelection,
|
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 = {
|
const detailPanelProps = {
|
||||||
selectedDocuments: orderedSelectedDocuments,
|
selectedDocuments: orderedSelectedDocuments,
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
|
FolderIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
|
||||||
@@ -20,9 +28,9 @@ const FolderNode = ({
|
|||||||
draggingFolderId,
|
draggingFolderId,
|
||||||
}) => {
|
}) => {
|
||||||
const isRoot = node.id === 'root';
|
const isRoot = node.id === 'root';
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = Boolean(node.hasChildren);
|
||||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
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 icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||||
const canDrag = !isRoot;
|
const canDrag = !isRoot;
|
||||||
const isDragging = draggingFolderId === node.id;
|
const isDragging = draggingFolderId === node.id;
|
||||||
@@ -149,6 +157,10 @@ const Sidebar = ({
|
|||||||
onLogout,
|
onLogout,
|
||||||
status,
|
status,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
|
tenantSlug,
|
||||||
|
tenants = [],
|
||||||
|
activeTenantId = null,
|
||||||
|
onSelectTenant,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -181,7 +193,59 @@ const Sidebar = ({
|
|||||||
const handleSearchClear = useCallback(() => {
|
const handleSearchClear = useCallback(() => {
|
||||||
onSearchClear?.();
|
onSearchClear?.();
|
||||||
}, [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?.();
|
||||||
}, [onLogout]);
|
}, [onLogout]);
|
||||||
|
|
||||||
@@ -237,7 +301,61 @@ const Sidebar = ({
|
|||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="panel-header sidebar__header">
|
<div className="panel-header sidebar__header">
|
||||||
<div className="panel-actions">
|
<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" />
|
<div className="spacer" />
|
||||||
{onCollapse ? (
|
{onCollapse ? (
|
||||||
<button
|
<button
|
||||||
@@ -376,11 +494,6 @@ const Sidebar = ({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar__footer">
|
|
||||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+140
-4
@@ -203,8 +203,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
.panel-actions button,
|
.panel-actions button,
|
||||||
.panel-actions a.icon-button {
|
.panel-actions a.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -213,6 +213,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-actions .icon-button:hover:not([disabled]),
|
.panel-actions .icon-button:hover:not([disabled]),
|
||||||
@@ -1051,6 +1052,142 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--fg);
|
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 {
|
.sidebar__hint {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -1108,6 +1245,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.sidebar-section__header {
|
.sidebar-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1142,9 +1280,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.sidebar-section__header h3 {
|
.sidebar-section__header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
|
IconLogout,
|
||||||
|
IconChevronDown,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
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 {
|
export default {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -219,6 +239,8 @@ export default {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user