Compare commits
4
Commits
264316eb29
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5091cd005e | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 |
@@ -539,6 +539,7 @@ pub mod schemas {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
pub expires_in: i64,
|
||||||
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -547,9 +548,15 @@ pub mod schemas {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TenantSnippet {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub selection_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub struct LoginResponse {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
pub expires_in: i64,
|
||||||
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -51,12 +52,23 @@ pub struct TenantSummary {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantSnippet {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub selection_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantListResponse {
|
||||||
|
pub tenants: Vec<TenantSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct TenantSelectionRequest {
|
pub struct TenantSelectionRequest {
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -121,7 +133,7 @@ pub async fn login(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let response = Json(TenantSelectionResponse {
|
let response = Json(TenantSelectionResponse {
|
||||||
selection_token,
|
access_token: selection_token,
|
||||||
tenants,
|
tenants,
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -250,6 +262,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
|||||||
Json(user)
|
Json(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_tenants(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||||
|
) -> AppResult<Json<TenantListResponse>> {
|
||||||
|
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||||
|
let token = bearer.token();
|
||||||
|
|
||||||
|
let user_id = match state.jwt.verify_token(token) {
|
||||||
|
Ok(claims) => claims.sub,
|
||||||
|
Err(_) => {
|
||||||
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_tenant_selector_token(token)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
claims.sub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let tenants = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
|
.load::<(Uuid, String)>(&mut conn)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, slug)| TenantSnippet { id, slug })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TenantListResponse { tenants }))
|
||||||
|
}
|
||||||
|
|
||||||
fn issue_session(
|
fn issue_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -262,6 +306,12 @@ fn issue_session(
|
|||||||
.generate_token(user.id, tenant_id, &user.username)
|
.generate_token(user.id, tenant_id, &user.username)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenant_slug: String = tenant_dsl::tenants
|
||||||
|
.find(tenant_id)
|
||||||
|
.select(tenant_dsl::slug)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let refresh_value = generate_refresh_token();
|
let refresh_value = generate_refresh_token();
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
@@ -283,6 +333,10 @@ fn issue_session(
|
|||||||
access_token,
|
access_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
tenant: TenantSnippet {
|
||||||
|
id: tenant_id,
|
||||||
|
slug: tenant_slug,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|
||||||
|
|||||||
@@ -411,10 +411,7 @@ pub async fn list_documents(
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_owned());
|
.map(|s| s.to_owned());
|
||||||
|
|
||||||
let mut include_descendants = include_descendants.unwrap_or_else(|| folder_id.is_some());
|
let include_descendants = include_descendants.unwrap_or(true);
|
||||||
if search_text.is_some() || tags_param.is_some() || correspondents_param.is_some() {
|
|
||||||
include_descendants = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
match (folder_id, include_descendants) {
|
match (folder_id, include_descendants) {
|
||||||
(Some(folder_id), true) => {
|
(Some(folder_id), true) => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
|
.route("/tenants", get(auth::list_tenants))
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ impl TestApp {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSelectionResponse {
|
struct TenantSelectionResponse {
|
||||||
selection_token: String,
|
access_token: String,
|
||||||
tenants: Vec<TenantSummary>,
|
tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ impl TestApp {
|
|||||||
&SelectTenantPayload {
|
&SelectTenantPayload {
|
||||||
tenant_id: target_tenant,
|
tenant_id: target_tenant,
|
||||||
},
|
},
|
||||||
Some(&selection.selection_token),
|
Some(&selection.access_token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
|||||||
|
|
||||||
Authentication
|
Authentication
|
||||||
--------------
|
--------------
|
||||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
||||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
|
||||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||||
- GET /api/auth/me - Return the authenticated principal payload.
|
- GET /api/auth/me - Return the authenticated principal payload.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Health
|
|||||||
|
|
||||||
Documents
|
Documents
|
||||||
---------
|
---------
|
||||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||||
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
||||||
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
||||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
@@ -317,12 +318,17 @@ const DetailPanel = ({
|
|||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
activePreviewId = null,
|
activePreviewId = null,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
|
onUpdateIssuedAt = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
|
onFolderNavigate = null,
|
||||||
|
ollamaUrl = null,
|
||||||
|
ollamaModel = 'llama3',
|
||||||
|
resolveFolderPath = null,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
@@ -351,6 +357,12 @@ const DetailPanel = ({
|
|||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
const [ocrError, setOcrError] = useState(null);
|
const [ocrError, setOcrError] = useState(null);
|
||||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
const ocrTextCacheRef = useRef({ docId: null, text: null });
|
||||||
|
const [aiSuggestion, setAiSuggestion] = useState(null);
|
||||||
|
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
|
||||||
|
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
|
||||||
|
const [aiSuggestionError, setAiSuggestionError] = useState(null);
|
||||||
|
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
@@ -384,6 +396,15 @@ const DetailPanel = ({
|
|||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [selectionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ocrTextCacheRef.current = { docId: null, text: null };
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
setAiSuggestionLoading(false);
|
||||||
|
setAiSuggestionApplying(false);
|
||||||
|
}, [singleDoc?.id]);
|
||||||
|
|
||||||
const startTitleEdit = useCallback(() => {
|
const startTitleEdit = useCallback(() => {
|
||||||
if (!singleDoc) return;
|
if (!singleDoc) return;
|
||||||
setTitleEditDocId(singleDoc.id);
|
setTitleEditDocId(singleDoc.id);
|
||||||
@@ -437,6 +458,40 @@ const DetailPanel = ({
|
|||||||
[singleDoc, getDocumentAsset],
|
[singleDoc, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveOllamaUrl = useMemo(
|
||||||
|
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
|
||||||
|
[ollamaUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveOllamaModel = useMemo(
|
||||||
|
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
|
||||||
|
[ollamaModel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSuggestTitle = Boolean(singleDoc);
|
||||||
|
const suggestionDisabledReason = useMemo(() => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
return 'Select a single document to generate suggestions.';
|
||||||
|
}
|
||||||
|
if (!hasOcrAsset) {
|
||||||
|
return 'OCR text is required to generate suggestions.';
|
||||||
|
}
|
||||||
|
if (!effectiveOllamaUrl) {
|
||||||
|
return 'Set an Ollama URL to enable AI suggestions.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
|
||||||
|
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
|
||||||
|
|
||||||
|
const canApplyAiSuggestion = useMemo(() => {
|
||||||
|
if (!aiSuggestion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
|
||||||
|
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
|
||||||
|
return applyTitle || applyIssuedAt;
|
||||||
|
}, [aiSuggestion, aiSuggestionSelection]);
|
||||||
|
|
||||||
const loadOcrUrl = useCallback(async () => {
|
const loadOcrUrl = useCallback(async () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return;
|
return;
|
||||||
@@ -479,6 +534,138 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
const fetchOcrText = useCallback(async () => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
throw new Error('No document selected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = ocrTextCacheRef.current;
|
||||||
|
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
|
||||||
|
return cached.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('No OCR text available for this document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = asset;
|
||||||
|
let url = entry?.url ||
|
||||||
|
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url && typeof ensureAssetUrl === 'function') {
|
||||||
|
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
||||||
|
if (ensured) {
|
||||||
|
entry = ensured;
|
||||||
|
}
|
||||||
|
url = entry?.url ||
|
||||||
|
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('OCR text URL is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('OCR text is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
|
||||||
|
return trimmed;
|
||||||
|
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
const extractSuggestionPayload = useCallback((raw) => {
|
||||||
|
const tryParse = (input) => {
|
||||||
|
if (!input) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(input);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimmed = (raw || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = tryParse(trimmed);
|
||||||
|
if (!payload) {
|
||||||
|
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
|
||||||
|
if (fenced) {
|
||||||
|
payload = tryParse(fenced[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
const firstObject = trimmed.match(/\{[\s\S]*\}/);
|
||||||
|
if (firstObject) {
|
||||||
|
payload = tryParse(firstObject[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof payload.title === 'string' ? payload.title : null;
|
||||||
|
const issuedAt =
|
||||||
|
typeof payload.issued_at === 'string'
|
||||||
|
? payload.issued_at
|
||||||
|
: typeof payload.issuedAt === 'string'
|
||||||
|
? payload.issuedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { title, issuedAt };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizeIssuedAtSuggestion = useCallback((input) => {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = String(input).trim();
|
||||||
|
if (!raw || raw.toLowerCase() === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
|
||||||
|
let year;
|
||||||
|
let month;
|
||||||
|
let day;
|
||||||
|
if (isoMatch) {
|
||||||
|
year = isoMatch[1];
|
||||||
|
month = isoMatch[2];
|
||||||
|
day = isoMatch[3];
|
||||||
|
} else {
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(parsed);
|
||||||
|
year = String(date.getUTCFullYear());
|
||||||
|
month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T00:00:00Z`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openOcrModal = useCallback(() => {
|
const openOcrModal = useCallback(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return;
|
return;
|
||||||
@@ -491,6 +678,154 @@ const DetailPanel = ({
|
|||||||
setOcrOpen(false);
|
setOcrOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSuggestTitle = useCallback(async () => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestionDisabledReason) {
|
||||||
|
setAiSuggestionError(suggestionDisabledReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiSuggestionLoading(true);
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
try {
|
||||||
|
const ocrText = await fetchOcrText();
|
||||||
|
const snippetLimit = 6000;
|
||||||
|
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}…` : ocrText;
|
||||||
|
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
|
||||||
|
{
|
||||||
|
"title": "<short descriptive title in the document's original language or null>",
|
||||||
|
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
|
||||||
|
}
|
||||||
|
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
|
||||||
|
|
||||||
|
OCR TEXT:
|
||||||
|
${trimmedOcr}`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: effectiveOllamaModel,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!suggestionResponse.ok) {
|
||||||
|
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await suggestionResponse.json();
|
||||||
|
const raw = (data?.response || data?.text || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('Model returned an empty response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = extractSuggestionPayload(raw);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Unable to parse suggestion response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedTitle = payload.title
|
||||||
|
? payload.title
|
||||||
|
.replace(/^['"\s]+/, '')
|
||||||
|
.replace(/['"\s]+$/, '')
|
||||||
|
.replace(/[.!?]+$/, '')
|
||||||
|
.trim()
|
||||||
|
: null;
|
||||||
|
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
|
||||||
|
|
||||||
|
if (!cleanedTitle && !normalizedIssuedAt) {
|
||||||
|
throw new Error('No usable title or date found in the response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSuggestion({
|
||||||
|
title: cleanedTitle || null,
|
||||||
|
issuedAt: normalizedIssuedAt,
|
||||||
|
raw,
|
||||||
|
model: effectiveOllamaModel,
|
||||||
|
});
|
||||||
|
setAiSuggestionSelection({
|
||||||
|
title: Boolean(cleanedTitle),
|
||||||
|
issuedAt: Boolean(normalizedIssuedAt),
|
||||||
|
});
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
|
||||||
|
} finally {
|
||||||
|
setAiSuggestionLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
singleDoc,
|
||||||
|
effectiveOllamaUrl,
|
||||||
|
effectiveOllamaModel,
|
||||||
|
fetchOcrText,
|
||||||
|
extractSuggestionPayload,
|
||||||
|
normalizeIssuedAtSuggestion,
|
||||||
|
suggestionDisabledReason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleApplySuggestion = useCallback(async () => {
|
||||||
|
if (!singleDoc || !aiSuggestion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
|
||||||
|
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
|
||||||
|
|
||||||
|
if (!applyTitle && !applyIssuedAt) {
|
||||||
|
setAiSuggestionError('Select at least one suggestion to apply.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSuggestionApplying(true);
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
try {
|
||||||
|
let titleOk = true;
|
||||||
|
let issuedOk = true;
|
||||||
|
|
||||||
|
if (applyTitle) {
|
||||||
|
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyIssuedAt) {
|
||||||
|
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleOk && issuedOk) {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
} else {
|
||||||
|
const failures = [];
|
||||||
|
if (!titleOk && applyTitle) failures.push('title');
|
||||||
|
if (!issuedOk && applyIssuedAt) failures.push('date');
|
||||||
|
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAiSuggestionApplying(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
singleDoc,
|
||||||
|
aiSuggestion,
|
||||||
|
aiSuggestionSelection,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssuedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const singlePreviewNavigator = useAssetNavigator({
|
const singlePreviewNavigator = useAssetNavigator({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
assetType: 'preview',
|
assetType: 'preview',
|
||||||
@@ -670,6 +1005,20 @@ const DetailPanel = ({
|
|||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [singleDoc]);
|
||||||
|
|
||||||
|
const singleFolderPath = useMemo(() => {
|
||||||
|
if (!singleDoc?.folder_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof resolveFolderPath !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segments = resolveFolderPath(singleDoc.folder_id);
|
||||||
|
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}, [singleDoc?.folder_id, resolveFolderPath]);
|
||||||
|
|
||||||
const bulkCorrespondents = useMemo(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -1039,10 +1388,87 @@ const DetailPanel = ({
|
|||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
|
{canSuggestTitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary detail-title-suggest-button"
|
||||||
|
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
|
||||||
|
disabled={suggestButtonDisabled}
|
||||||
|
title={
|
||||||
|
aiSuggestionLoading
|
||||||
|
? 'Generating suggestion…'
|
||||||
|
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
{aiSuggestionError ? (
|
||||||
|
<div className="status-inline error">{aiSuggestionError}</div>
|
||||||
|
) : null}
|
||||||
|
{aiSuggestionLoading && !aiSuggestion ? (
|
||||||
|
<div className="status-inline">Generating title suggestion…</div>
|
||||||
|
) : null}
|
||||||
|
{aiSuggestion ? (
|
||||||
|
<div className="detail-title-suggestion" role="status" aria-live="polite">
|
||||||
|
<div className="detail-title-suggestion__label">AI suggestions</div>
|
||||||
|
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
|
||||||
|
<div className="detail-title-suggestion__options">
|
||||||
|
<label className="detail-title-suggestion__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
|
||||||
|
disabled={!aiSuggestion.title || aiSuggestionApplying}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAiSuggestionSelection((previous) => ({
|
||||||
|
...previous,
|
||||||
|
title: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Title:</strong>{' '}
|
||||||
|
{aiSuggestion.title || <em>Not available</em>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="detail-title-suggestion__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
|
||||||
|
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAiSuggestionSelection((previous) => ({
|
||||||
|
...previous,
|
||||||
|
issuedAt: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Issued date:</strong>{' '}
|
||||||
|
{aiSuggestion.issuedAt
|
||||||
|
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
|
||||||
|
: <em>Not available</em>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="detail-title-suggestion__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplySuggestion}
|
||||||
|
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
|
||||||
|
>
|
||||||
|
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>Uploaded:</strong>{' '}
|
<strong>Uploaded:</strong>{' '}
|
||||||
@@ -1063,6 +1489,53 @@ const DetailPanel = ({
|
|||||||
<strong>Pages:</strong> {pageCountValue}
|
<strong>Pages:</strong> {pageCountValue}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{singleFolderPath?.length ? (
|
||||||
|
<div>
|
||||||
|
<strong>Folder:</strong>{' '}
|
||||||
|
<span className="detail-folder-path">
|
||||||
|
{singleFolderPath.map((segment, index) => {
|
||||||
|
const label = segment?.name || '…';
|
||||||
|
const targetId = segment?.id || null;
|
||||||
|
const key = `${targetId || label}-${index}`;
|
||||||
|
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
||||||
|
const href = !isClickable
|
||||||
|
? null
|
||||||
|
: targetId === 'root'
|
||||||
|
? '/documents'
|
||||||
|
: `/documents/folder/${targetId}`;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
||||||
|
{isClickable ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="detail-folder-path__link"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.altKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderNavigate(targetId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="detail-folder-path__segment">{label}</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<strong>Original filename:</strong>{' '}
|
<strong>Original filename:</strong>{' '}
|
||||||
{singleDoc.original_name}
|
{singleDoc.original_name}
|
||||||
|
|||||||
+373
-12
@@ -55,7 +55,26 @@ const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
|||||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
|
||||||
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
const ENV = typeof process !== 'undefined' && process?.env ? process.env : {};
|
||||||
|
|
||||||
|
const API_ROOT = (runtimeApiBase || ENV.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
|
||||||
|
const runtimeOllamaUrl =
|
||||||
|
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_URL
|
||||||
|
? String(window.__PAPERCRATE_OLLAMA_URL)
|
||||||
|
: '';
|
||||||
|
const envOllamaUrl = (ENV.OLLAMA_BASE_URL || '').trim();
|
||||||
|
const rawOllamaUrl = (runtimeOllamaUrl || envOllamaUrl || DEFAULT_OLLAMA_URL).trim();
|
||||||
|
const OLLAMA_BASE_URL = rawOllamaUrl ? rawOllamaUrl.replace(/\/$/, '') : null;
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_MODEL = 'gpt-oss';
|
||||||
|
const runtimeOllamaModel =
|
||||||
|
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_MODEL
|
||||||
|
? String(window.__PAPERCRATE_OLLAMA_MODEL)
|
||||||
|
: '';
|
||||||
|
const envOllamaModel = (ENV.OLLAMA_MODEL || '').trim();
|
||||||
|
const OLLAMA_MODEL = (runtimeOllamaModel || envOllamaModel || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||||||
@@ -63,6 +82,16 @@ const api = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || '';
|
const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || '';
|
||||||
|
let STORED_TENANT = null;
|
||||||
|
try {
|
||||||
|
const rawTenant = window.localStorage.getItem('papercrate_tenant');
|
||||||
|
if (rawTenant) {
|
||||||
|
STORED_TENANT = JSON.parse(rawTenant);
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
if (STORED_TOKEN) {
|
if (STORED_TOKEN) {
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||||
}
|
}
|
||||||
@@ -73,6 +102,8 @@ const initialAppState = {
|
|||||||
error: null,
|
error: null,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: STORED_TENANT,
|
||||||
|
tenants: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppStateContext = React.createContext(null);
|
const AppStateContext = React.createContext(null);
|
||||||
@@ -81,7 +112,14 @@ const AppDispatchContext = React.createContext(null);
|
|||||||
const appStateReducer = (state, action) => {
|
const appStateReducer = (state, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'LOGIN_REQUEST':
|
case 'LOGIN_REQUEST':
|
||||||
return { ...state, status: 'authenticating', error: null, tenantSelection: null };
|
return {
|
||||||
|
...state,
|
||||||
|
status: 'authenticating',
|
||||||
|
error: null,
|
||||||
|
tenantSelection: null,
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
|
};
|
||||||
case 'LOGIN_SUCCESS':
|
case 'LOGIN_SUCCESS':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -89,6 +127,8 @@ const appStateReducer = (state, action) => {
|
|||||||
token: action.token,
|
token: action.token,
|
||||||
error: null,
|
error: null,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: action.tenant || null,
|
||||||
|
tenants: state.tenants,
|
||||||
};
|
};
|
||||||
case 'LOGIN_FAILURE':
|
case 'LOGIN_FAILURE':
|
||||||
return {
|
return {
|
||||||
@@ -97,6 +137,8 @@ const appStateReducer = (state, action) => {
|
|||||||
error: action.error || null,
|
error: action.error || null,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
};
|
};
|
||||||
case 'TENANT_SELECTION_REQUIRED':
|
case 'TENANT_SELECTION_REQUIRED':
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +150,8 @@ const appStateReducer = (state, action) => {
|
|||||||
selectionToken: action.selectionToken,
|
selectionToken: action.selectionToken,
|
||||||
tenants: action.tenants,
|
tenants: action.tenants,
|
||||||
},
|
},
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
};
|
};
|
||||||
case 'CLEAR_TENANT_SELECTION':
|
case 'CLEAR_TENANT_SELECTION':
|
||||||
return {
|
return {
|
||||||
@@ -116,6 +160,8 @@ const appStateReducer = (state, action) => {
|
|||||||
error: null,
|
error: null,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
};
|
};
|
||||||
case 'BOOTSTRAP_START':
|
case 'BOOTSTRAP_START':
|
||||||
return { ...state, status: 'bootstrapping', error: null };
|
return { ...state, status: 'bootstrapping', error: null };
|
||||||
@@ -132,6 +178,8 @@ const appStateReducer = (state, action) => {
|
|||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: action.tenant || state.tenant || null,
|
||||||
|
tenants: state.tenants,
|
||||||
};
|
};
|
||||||
case 'TOKEN_REFRESH_FAILURE':
|
case 'TOKEN_REFRESH_FAILURE':
|
||||||
return {
|
return {
|
||||||
@@ -140,11 +188,26 @@ const appStateReducer = (state, action) => {
|
|||||||
error: action.error || null,
|
error: action.error || null,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
};
|
};
|
||||||
case 'LOGOUT':
|
case 'LOGOUT':
|
||||||
return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null };
|
return {
|
||||||
|
status: 'logged-out',
|
||||||
|
token: '',
|
||||||
|
error: null,
|
||||||
|
isRefreshing: false,
|
||||||
|
tenantSelection: null,
|
||||||
|
tenant: null,
|
||||||
|
tenants: [],
|
||||||
|
};
|
||||||
case 'RESET_ERROR':
|
case 'RESET_ERROR':
|
||||||
return { ...state, error: null };
|
return { ...state, error: null };
|
||||||
|
case 'SET_TENANTS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
tenants: Array.isArray(action.tenants) ? action.tenants : [],
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -164,6 +227,45 @@ const AppStateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [state.token]);
|
}, [state.token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.tenant) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('papercrate_tenant', JSON.stringify(state.tenant));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to persist tenant info', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem('papercrate_tenant');
|
||||||
|
}
|
||||||
|
}, [state.tenant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let abort = false;
|
||||||
|
const loadTenants = async () => {
|
||||||
|
if (state.status !== 'authenticated' || !state.token) {
|
||||||
|
dispatch({ type: 'SET_TENANTS', tenants: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/auth/tenants');
|
||||||
|
if (!abort) {
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_TENANTS',
|
||||||
|
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!abort) {
|
||||||
|
console.warn('Failed to load tenant list', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTenants();
|
||||||
|
return () => {
|
||||||
|
abort = true;
|
||||||
|
};
|
||||||
|
}, [state.status, state.token, dispatch]);
|
||||||
|
|
||||||
const stateValue = useMemo(() => state, [state]);
|
const stateValue = useMemo(() => state, [state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -416,7 +518,11 @@ const AppLayout = () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/refresh');
|
const { data } = await api.post('/auth/refresh');
|
||||||
if (data?.access_token) {
|
if (data?.access_token) {
|
||||||
appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token });
|
appDispatch({
|
||||||
|
type: 'TOKEN_REFRESH_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
@@ -594,6 +700,7 @@ const AppLayout = () => {
|
|||||||
const selectionInitializedRef = useRef(false);
|
const selectionInitializedRef = useRef(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const prefetchedFoldersRef = useRef(new Set(['root']));
|
const prefetchedFoldersRef = useRef(new Set(['root']));
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -643,6 +750,7 @@ const AppLayout = () => {
|
|||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
prefetchedFoldersRef.current = new Set(['root']);
|
prefetchedFoldersRef.current = new Set(['root']);
|
||||||
breadcrumbFetchRef.current = new Set();
|
breadcrumbFetchRef.current = new Set();
|
||||||
|
detailFolderFetchRef.current = new Set();
|
||||||
bootstrapInitializedRef.current = false;
|
bootstrapInitializedRef.current = false;
|
||||||
selectionInitializedRef.current = false;
|
selectionInitializedRef.current = false;
|
||||||
}, [assetManager]);
|
}, [assetManager]);
|
||||||
@@ -1868,19 +1976,134 @@ const AppLayout = () => {
|
|||||||
greedyPrefetchFolders(pendingIds).catch(() => {});
|
greedyPrefetchFolders(pendingIds).catch(() => {});
|
||||||
}, [folderContents, greedyPrefetchFolders]);
|
}, [folderContents, greedyPrefetchFolders]);
|
||||||
|
|
||||||
|
const expandFolderAncestors = useCallback(
|
||||||
|
(targetId) => {
|
||||||
|
if (!targetId || targetId === 'root') {
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
if (prev.get('root')?.expanded) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
const rootNode = next.get('root');
|
||||||
|
if (rootNode) {
|
||||||
|
next.set('root', { ...rootNode, expanded: true });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
let currentId = targetId;
|
||||||
|
let guard = 0;
|
||||||
|
while (currentId && !next.has(currentId) && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
const node = prev.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentId = node.parentId ?? 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentId = targetId;
|
||||||
|
guard = 0;
|
||||||
|
while (currentId && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
const node = next.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!node.expanded && currentId !== targetId) {
|
||||||
|
next.set(currentId, { ...node, expanded: true });
|
||||||
|
}
|
||||||
|
currentId = node.parentId ?? 'root';
|
||||||
|
if (!currentId || currentId === 'root') {
|
||||||
|
const rootNode = next.get('root');
|
||||||
|
if (rootNode && !rootNode.expanded) {
|
||||||
|
next.set('root', { ...rootNode, expanded: true });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]);
|
||||||
|
|
||||||
|
const ensureFolderAncestorsLoaded = useCallback(
|
||||||
|
async (targetId) => {
|
||||||
|
if (!targetId || targetId === 'root') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const stack = [];
|
||||||
|
|
||||||
|
let currentId = targetId;
|
||||||
|
let guard = 0;
|
||||||
|
while (currentId && currentId !== 'root' && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
const node = folderNodes.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
stack.push(currentId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = node.parentId ?? 'root';
|
||||||
|
if (!parentId || parentId === 'root') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
stack.push(parentId);
|
||||||
|
currentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const ancestorId = stack.pop();
|
||||||
|
if (!ancestorId || ancestorId === 'root') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!folderNodes.has(ancestorId)) {
|
||||||
|
try {
|
||||||
|
await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[folderNodes, ensureFolderData],
|
||||||
|
);
|
||||||
|
|
||||||
const selectFolder = useCallback(
|
const selectFolder = useCallback(
|
||||||
async (folderId, { replace = false, immediate = false } = {}) => {
|
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||||
|
|
||||||
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
|
expandFolderAncestors(targetId);
|
||||||
|
|
||||||
if (!navigate || immediate) {
|
if (!navigate || immediate) {
|
||||||
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
||||||
|
setSelectedFolder(targetId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||||
navigate(path, { replace });
|
navigate(path, { replace });
|
||||||
},
|
},
|
||||||
[loadFolder, navigate, isFilterActive],
|
[
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
navigate,
|
||||||
|
loadFolder,
|
||||||
|
isFilterActive,
|
||||||
|
setSelectedFolder,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initializeAfterLogin = useCallback(async () => {
|
const initializeAfterLogin = useCallback(async () => {
|
||||||
@@ -3347,6 +3570,38 @@ const AppLayout = () => {
|
|||||||
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDocumentIssuedAtUpdate = useCallback(
|
||||||
|
async (documentId, nextIssuedAt) => {
|
||||||
|
const payload = {
|
||||||
|
issued_at: nextIssuedAt ? new Date(nextIssuedAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||||
|
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||||
|
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
||||||
|
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (hydratedDocument) {
|
||||||
|
return { ...doc, ...hydratedDocument };
|
||||||
|
}
|
||||||
|
return { ...doc, issued_at: payload.issued_at };
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMessage('Document issued date updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[assetManager, api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
const applyTagRemovalToCaches = useCallback(
|
const applyTagRemovalToCaches = useCallback(
|
||||||
(documentId, tagId) => {
|
(documentId, tagId) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
@@ -4079,10 +4334,10 @@ const AppLayout = () => {
|
|||||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||||
const { data } = await api.post('/auth/login', payload);
|
const { data } = await api.post('/auth/login', payload);
|
||||||
|
|
||||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||||
appDispatch({
|
appDispatch({
|
||||||
type: 'TENANT_SELECTION_REQUIRED',
|
type: 'TENANT_SELECTION_REQUIRED',
|
||||||
selectionToken: data.selection_token,
|
selectionToken: data.access_token,
|
||||||
tenants: data.tenants,
|
tenants: data.tenants,
|
||||||
});
|
});
|
||||||
setStatusMessage('Select a tenant to continue.', 'info');
|
setStatusMessage('Select a tenant to continue.', 'info');
|
||||||
@@ -4093,7 +4348,11 @@ const AppLayout = () => {
|
|||||||
throw new Error('Invalid login response.');
|
throw new Error('Invalid login response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
appDispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
setStatusMessage('Login successful.', 'success');
|
setStatusMessage('Login successful.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||||
@@ -4450,6 +4709,95 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderedSelectedDocuments.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
orderedSelectedDocuments.forEach((doc) => {
|
||||||
|
const folderId = doc?.folder_id;
|
||||||
|
if (!folderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currentId = folderId;
|
||||||
|
let guard = 0;
|
||||||
|
while (currentId && currentId !== 'root' && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
const node = folderNodes.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
if (!detailFolderFetchRef.current.has(currentId)) {
|
||||||
|
detailFolderFetchRef.current.add(currentId);
|
||||||
|
ensureFolderData(currentId, { force: false, includeDocuments: false })
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Failed to preload folder metadata for detail path', currentId, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailFolderFetchRef.current.delete(currentId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = node.parentId ?? 'root';
|
||||||
|
if (!parentId || parentId === 'root') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentId = parentId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
|
||||||
|
|
||||||
|
const resolveFolderPath = useCallback(
|
||||||
|
(folderId) => {
|
||||||
|
if (!folderId || folderId === 'root') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
const visited = new Set();
|
||||||
|
let currentId = folderId;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (currentId && guard < 32 && !visited.has(currentId)) {
|
||||||
|
guard += 1;
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
if (currentId === 'root') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = folderNodes.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
segments.push({ id: currentId, name: '…' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({ id: node.id, name: node.name || 'Folder' });
|
||||||
|
|
||||||
|
const parentId = node.parentId ?? 'root';
|
||||||
|
if (!parentId || parentId === 'root') {
|
||||||
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segments.some((segment) => segment.id === 'root')) {
|
||||||
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.reverse();
|
||||||
|
},
|
||||||
|
[folderNodes],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedPreviewEntry = useMemo(() => {
|
const selectedPreviewEntry = useMemo(() => {
|
||||||
if (!selectedDocument) {
|
if (!selectedDocument) {
|
||||||
return null;
|
return null;
|
||||||
@@ -4592,13 +4940,18 @@ const AppLayout = () => {
|
|||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
|
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
|
onFolderNavigate: selectFolder,
|
||||||
onClose: clearDocumentSelection,
|
onClose: clearDocumentSelection,
|
||||||
|
resolveFolderPath,
|
||||||
|
ollamaUrl: OLLAMA_BASE_URL,
|
||||||
|
ollamaModel: OLLAMA_MODEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
const skeuoWorkspaceProps = useMemo(
|
const skeuoWorkspaceProps = useMemo(
|
||||||
@@ -5114,10 +5467,10 @@ const LoginRoute = () => {
|
|||||||
try {
|
try {
|
||||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||||
const { data } = await api.post('/auth/login', payload);
|
const { data } = await api.post('/auth/login', payload);
|
||||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||||
appDispatch({
|
appDispatch({
|
||||||
type: 'TENANT_SELECTION_REQUIRED',
|
type: 'TENANT_SELECTION_REQUIRED',
|
||||||
selectionToken: data.selection_token,
|
selectionToken: data.access_token,
|
||||||
tenants: data.tenants,
|
tenants: data.tenants,
|
||||||
});
|
});
|
||||||
setStatusMessage('Select a tenant to continue.', 'info');
|
setStatusMessage('Select a tenant to continue.', 'info');
|
||||||
@@ -5128,7 +5481,11 @@ const LoginRoute = () => {
|
|||||||
throw new Error('Invalid login response.');
|
throw new Error('Invalid login response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
appDispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
setStatusMessage('Login successful.', 'success');
|
setStatusMessage('Login successful.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||||
@@ -5161,7 +5518,11 @@ const LoginRoute = () => {
|
|||||||
throw new Error('Invalid tenant selection response.');
|
throw new Error('Invalid tenant selection response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
appDispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
setStatusMessage('Login successful.', 'success');
|
setStatusMessage('Login successful.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: width 0.28s ease, height 0.28s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item:focus-visible {
|
.skeuo-item:focus-visible {
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ const SkeuoPreviewCard = ({
|
|||||||
prefetch,
|
prefetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
|
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||||
const docId = doc?.id ?? null;
|
const docId = doc?.id ?? null;
|
||||||
|
|
||||||
|
const metadataWidth = Number(currentMetadata?.width);
|
||||||
|
const metadataHeight = Number(currentMetadata?.height);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onNavigatorSnapshot || !docId) {
|
if (!onNavigatorSnapshot || !docId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -74,6 +77,9 @@ const SkeuoPreviewCard = ({
|
|||||||
canGoNext,
|
canGoNext,
|
||||||
goPrev: navigator.goPrev,
|
goPrev: navigator.goPrev,
|
||||||
goNext: navigator.goNext,
|
goNext: navigator.goNext,
|
||||||
|
ordinal,
|
||||||
|
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||||
|
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||||
};
|
};
|
||||||
onNavigatorSnapshot(docId, snapshot);
|
onNavigatorSnapshot(docId, snapshot);
|
||||||
return () => onNavigatorSnapshot(docId, null);
|
return () => onNavigatorSnapshot(docId, null);
|
||||||
@@ -83,6 +89,9 @@ const SkeuoPreviewCard = ({
|
|||||||
title,
|
title,
|
||||||
canGoPrev,
|
canGoPrev,
|
||||||
canGoNext,
|
canGoNext,
|
||||||
|
ordinal,
|
||||||
|
metadataWidth,
|
||||||
|
metadataHeight,
|
||||||
navigator.goPrev,
|
navigator.goPrev,
|
||||||
navigator.goNext,
|
navigator.goNext,
|
||||||
onNavigatorSnapshot,
|
onNavigatorSnapshot,
|
||||||
@@ -555,6 +564,37 @@ const clamp = (value, min, max) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clampCardDimensions = (width, height) => {
|
||||||
|
let nextWidth = Number(width);
|
||||||
|
let nextHeight = Number(height);
|
||||||
|
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleDown = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
||||||
|
nextWidth *= scaleDown;
|
||||||
|
nextHeight *= scaleDown;
|
||||||
|
|
||||||
|
const minDim = Math.min(nextWidth, nextHeight);
|
||||||
|
if (minDim > 0 && minDim < CARD_MIN) {
|
||||||
|
const scaleUp = CARD_MIN / minDim;
|
||||||
|
nextWidth *= scaleUp;
|
||||||
|
nextHeight *= scaleUp;
|
||||||
|
|
||||||
|
const adjust = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
||||||
|
nextWidth *= adjust;
|
||||||
|
nextHeight *= adjust;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = clamp(nextWidth, CARD_MIN, CARD_MAX);
|
||||||
|
nextHeight = clamp(nextHeight, CARD_MIN, CARD_MAX);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||||
|
|
||||||
@@ -602,43 +642,90 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
||||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
||||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
||||||
|
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||||
const draggingTagRef = useRef(null);
|
const draggingTagRef = useRef(null);
|
||||||
const pendingDocTagDragRef = useRef(null);
|
const pendingDocTagDragRef = useRef(null);
|
||||||
const docSizeMapRef = useRef(new Map());
|
const docSizeMapRef = useRef(new Map());
|
||||||
|
const documentLookupRef = useRef(new Map());
|
||||||
const removalCursorActiveRef = useRef(false);
|
const removalCursorActiveRef = useRef(false);
|
||||||
const handleNavigatorSnapshot = useCallback((docId, snapshot) => {
|
const applySnapshotDimensions = useCallback(
|
||||||
if (!docId) {
|
(docId, snapshot) => {
|
||||||
return;
|
if (!docId || !snapshot) {
|
||||||
}
|
return;
|
||||||
setPreviewSnapshots((previous) => {
|
}
|
||||||
const prevSnapshot = previous.get(docId);
|
|
||||||
if (!snapshot) {
|
const doc = documentLookupRef.current.get(docId);
|
||||||
if (!previous.has(docId)) {
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Number(snapshot.width);
|
||||||
|
const height = Number(snapshot.height);
|
||||||
|
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = clampCardDimensions(width, height);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = resolveSizeKey(doc);
|
||||||
|
const existing = docSizeMapRef.current.get(key);
|
||||||
|
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
docSizeMapRef.current.set(key, normalized);
|
||||||
|
setDocSizeVersion((value) => value + 1);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleNavigatorSnapshot = useCallback(
|
||||||
|
(docId, snapshot) => {
|
||||||
|
if (!docId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewSnapshots((previous) => {
|
||||||
|
const prevSnapshot = previous.get(docId);
|
||||||
|
if (!snapshot) {
|
||||||
|
if (!previous.has(docId)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.delete(docId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map(previous);
|
||||||
|
const sameSnapshot =
|
||||||
|
prevSnapshot &&
|
||||||
|
prevSnapshot.url === snapshot.url &&
|
||||||
|
prevSnapshot.alt === snapshot.alt &&
|
||||||
|
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
|
||||||
|
prevSnapshot.canGoNext === snapshot.canGoNext &&
|
||||||
|
prevSnapshot.goPrev === snapshot.goPrev &&
|
||||||
|
prevSnapshot.goNext === snapshot.goNext &&
|
||||||
|
prevSnapshot.ordinal === snapshot.ordinal &&
|
||||||
|
prevSnapshot.width === snapshot.width &&
|
||||||
|
prevSnapshot.height === snapshot.height;
|
||||||
|
if (sameSnapshot) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
const next = new Map(previous);
|
next.set(docId, snapshot);
|
||||||
next.delete(docId);
|
|
||||||
return next;
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
applySnapshotDimensions(docId, snapshot);
|
||||||
}
|
}
|
||||||
const next = new Map(previous);
|
},
|
||||||
const sameSnapshot =
|
[applySnapshotDimensions],
|
||||||
prevSnapshot &&
|
);
|
||||||
prevSnapshot.url === snapshot.url &&
|
|
||||||
prevSnapshot.alt === snapshot.alt &&
|
|
||||||
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
|
|
||||||
prevSnapshot.canGoNext === snapshot.canGoNext &&
|
|
||||||
prevSnapshot.goPrev === snapshot.goPrev &&
|
|
||||||
prevSnapshot.goNext === snapshot.goNext;
|
|
||||||
if (sameSnapshot) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
next.set(docId, snapshot);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
const activeTagSet = useMemo(() => {
|
const activeTagSet = useMemo(() => {
|
||||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||||
return new Set();
|
return new Set();
|
||||||
@@ -663,6 +750,15 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const resolvePreviewDimensions = useCallback(
|
const resolvePreviewDimensions = useCallback(
|
||||||
(doc) => {
|
(doc) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const snapshot = previewSnapshots.get(doc.id);
|
||||||
|
if (snapshot && snapshot.width && snapshot.height) {
|
||||||
|
return {
|
||||||
|
width: snapshot.width,
|
||||||
|
height: snapshot.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const asset = resolvePreviewAsset(doc);
|
const asset = resolvePreviewAsset(doc);
|
||||||
const view = createAssetView(asset);
|
const view = createAssetView(asset);
|
||||||
const metadata = view.getPrimaryMetadata() || {};
|
const metadata = view.getPrimaryMetadata() || {};
|
||||||
@@ -673,7 +769,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[resolvePreviewAsset],
|
[previewSnapshots, resolvePreviewAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -780,48 +876,26 @@ const SkeuomorphicWorkspace = ({
|
|||||||
if (cache) {
|
if (cache) {
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
let width;
|
|
||||||
let height;
|
|
||||||
|
|
||||||
const intrinsic = resolvePreviewDimensions(doc);
|
const intrinsic = resolvePreviewDimensions(doc);
|
||||||
|
let normalized = null;
|
||||||
if (intrinsic?.width && intrinsic?.height) {
|
if (intrinsic?.width && intrinsic?.height) {
|
||||||
width = intrinsic.width;
|
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
|
||||||
height = intrinsic.height;
|
}
|
||||||
} else {
|
|
||||||
|
if (!normalized) {
|
||||||
const seed = seededRandom(`${key}:size`);
|
const seed = seededRandom(`${key}:size`);
|
||||||
width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
|
let width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
|
||||||
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
|
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
|
||||||
height = width * EMPTY_CARD_ASPECT;
|
const height = width * EMPTY_CARD_ASPECT;
|
||||||
|
normalized = clampCardDimensions(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(width) || width <= 0) {
|
if (!normalized) {
|
||||||
width = CARD_MIN;
|
normalized = { width: CARD_MIN, height: CARD_MIN };
|
||||||
}
|
|
||||||
if (!Number.isFinite(height) || height <= 0) {
|
|
||||||
height = CARD_MIN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height);
|
docSizeMapRef.current.set(key, normalized);
|
||||||
width *= scaleDown;
|
return normalized;
|
||||||
height *= scaleDown;
|
|
||||||
|
|
||||||
const minDim = Math.min(width, height);
|
|
||||||
if (minDim < CARD_MIN) {
|
|
||||||
const scaleUp = CARD_MIN / minDim;
|
|
||||||
width *= scaleUp;
|
|
||||||
height *= scaleUp;
|
|
||||||
|
|
||||||
const adjust = Math.min(1, CARD_MAX / width, CARD_MAX / height);
|
|
||||||
width *= adjust;
|
|
||||||
height *= adjust;
|
|
||||||
}
|
|
||||||
|
|
||||||
width = clamp(width, CARD_MIN, CARD_MAX);
|
|
||||||
height = clamp(height, CARD_MIN, CARD_MAX);
|
|
||||||
|
|
||||||
const size = { width, height };
|
|
||||||
docSizeMapRef.current.set(key, size);
|
|
||||||
return size;
|
|
||||||
}, [resolvePreviewDimensions]);
|
}, [resolvePreviewDimensions]);
|
||||||
|
|
||||||
const documentLookup = useMemo(() => {
|
const documentLookup = useMemo(() => {
|
||||||
@@ -834,8 +908,13 @@ const SkeuomorphicWorkspace = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
documentLookupRef.current = documentLookup;
|
||||||
|
}, [documentLookup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
docSizeMapRef.current = new Map();
|
docSizeMapRef.current = new Map();
|
||||||
|
setDocSizeVersion((value) => value + 1);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const overlayDisplay = useMemo(() => {
|
const overlayDisplay = useMemo(() => {
|
||||||
@@ -904,7 +983,8 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return () => {};
|
if (!container) return () => {};
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
const isDevEnv = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
|
||||||
|
if (isDevEnv) {
|
||||||
console.log('[skeuo] canvas element', container);
|
console.log('[skeuo] canvas element', container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,6 +1100,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
items,
|
items,
|
||||||
canvasSize.width,
|
canvasSize.width,
|
||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
|
docSizeVersion,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
syncLayoutSnapshot,
|
syncLayoutSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
transition: color 0.15s ease, text-decoration-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a:focus-visible {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
outline: none;
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -1821,6 +1840,86 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggest-button {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__option input[type='checkbox'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path__link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path__link:hover,
|
||||||
|
.detail-panel .detail-folder-path__link:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path__separator {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path__segment {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-row {
|
.detail-panel .doc-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
||||||
|
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
|
||||||
|
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
|
|||||||
Reference in New Issue
Block a user