Compare commits
3
Commits
b260065245
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5091cd005e | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 |
@@ -539,6 +539,7 @@ pub mod schemas {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -547,9 +548,15 @@ pub mod schemas {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -51,12 +52,23 @@ pub struct TenantSummary {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
@@ -121,7 +133,7 @@ pub async fn login(
|
||||
.collect();
|
||||
|
||||
let response = Json(TenantSelectionResponse {
|
||||
selection_token,
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response();
|
||||
@@ -250,6 +262,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
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(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
@@ -262,6 +306,12 @@ fn issue_session(
|
||||
.generate_token(user.id, tenant_id, &user.username)
|
||||
.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_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
@@ -283,6 +333,10 @@ fn issue_session(
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
slug: tenant_slug,
|
||||
},
|
||||
})
|
||||
.into_response();
|
||||
|
||||
|
||||
@@ -411,10 +411,7 @@ pub async fn list_documents(
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let mut include_descendants = include_descendants.unwrap_or_else(|| folder_id.is_some());
|
||||
if search_text.is_some() || tags_param.is_some() || correspondents_param.is_some() {
|
||||
include_descendants = true;
|
||||
}
|
||||
let include_descendants = include_descendants.unwrap_or(true);
|
||||
|
||||
match (folder_id, include_descendants) {
|
||||
(Some(folder_id), true) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route("/tenants", get(auth::list_tenants))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
|
||||
@@ -328,7 +328,7 @@ impl TestApp {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
selection_token: String,
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ impl TestApp {
|
||||
&SelectTenantPayload {
|
||||
tenant_id: target_tenant,
|
||||
},
|
||||
Some(&selection.selection_token),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
||||
- 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). Response also includes the current tenant `{ tenant: { id, slug } }`.
|
||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||
- GET /api/auth/me - Return the authenticated principal payload.
|
||||
|
||||
@@ -16,7 +16,7 @@ Health
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
@@ -317,6 +318,7 @@ const DetailPanel = ({
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssuedAt = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
correspondents = [],
|
||||
@@ -324,6 +326,8 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
ollamaUrl = null,
|
||||
ollamaModel = 'llama3',
|
||||
resolveFolderPath = null,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
@@ -353,6 +357,12 @@ const DetailPanel = ({
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrError, setOcrError] = 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(() => {
|
||||
if (!singleDoc) {
|
||||
@@ -386,6 +396,15 @@ const DetailPanel = ({
|
||||
setZoomedPreview(null);
|
||||
}, [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(() => {
|
||||
if (!singleDoc) return;
|
||||
setTitleEditDocId(singleDoc.id);
|
||||
@@ -439,6 +458,40 @@ const DetailPanel = ({
|
||||
[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 () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -481,6 +534,138 @@ const DetailPanel = ({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -493,6 +678,154 @@ const DetailPanel = ({
|
||||
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({
|
||||
document: singleDoc,
|
||||
assetType: 'preview',
|
||||
@@ -1055,10 +1388,87 @@ const DetailPanel = ({
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</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>
|
||||
{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>
|
||||
<strong>Uploaded:</strong>{' '}
|
||||
|
||||
+164
-11
@@ -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 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({
|
||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||||
@@ -63,6 +82,16 @@ const api = axios.create({
|
||||
});
|
||||
|
||||
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) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||
}
|
||||
@@ -73,6 +102,8 @@ const initialAppState = {
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: STORED_TENANT,
|
||||
tenants: [],
|
||||
};
|
||||
|
||||
const AppStateContext = React.createContext(null);
|
||||
@@ -81,7 +112,14 @@ const AppDispatchContext = React.createContext(null);
|
||||
const appStateReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
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':
|
||||
return {
|
||||
...state,
|
||||
@@ -89,6 +127,8 @@ const appStateReducer = (state, action) => {
|
||||
token: action.token,
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return {
|
||||
@@ -97,6 +137,8 @@ const appStateReducer = (state, action) => {
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'TENANT_SELECTION_REQUIRED':
|
||||
return {
|
||||
@@ -108,6 +150,8 @@ const appStateReducer = (state, action) => {
|
||||
selectionToken: action.selectionToken,
|
||||
tenants: action.tenants,
|
||||
},
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'CLEAR_TENANT_SELECTION':
|
||||
return {
|
||||
@@ -116,6 +160,8 @@ const appStateReducer = (state, action) => {
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'BOOTSTRAP_START':
|
||||
return { ...state, status: 'bootstrapping', error: null };
|
||||
@@ -132,6 +178,8 @@ const appStateReducer = (state, action) => {
|
||||
isRefreshing: false,
|
||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || state.tenant || null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'TOKEN_REFRESH_FAILURE':
|
||||
return {
|
||||
@@ -140,11 +188,26 @@ const appStateReducer = (state, action) => {
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
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':
|
||||
return { ...state, error: null };
|
||||
case 'SET_TENANTS':
|
||||
return {
|
||||
...state,
|
||||
tenants: Array.isArray(action.tenants) ? action.tenants : [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -164,6 +227,45 @@ const AppStateProvider = ({ children }) => {
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
return (
|
||||
@@ -416,7 +518,11 @@ const AppLayout = () => {
|
||||
try {
|
||||
const { data } = await api.post('/auth/refresh');
|
||||
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());
|
||||
return data.access_token;
|
||||
}
|
||||
@@ -3464,6 +3570,38 @@ const AppLayout = () => {
|
||||
[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(
|
||||
(documentId, tagId) => {
|
||||
if (!documentId || !tagId) {
|
||||
@@ -4196,10 +4334,10 @@ const AppLayout = () => {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
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({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
@@ -4210,7 +4348,11 @@ const AppLayout = () => {
|
||||
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');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
@@ -4798,6 +4940,7 @@ const AppLayout = () => {
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
activePreviewId,
|
||||
onUpdateTitle: handleDocumentTitleUpdate,
|
||||
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
correspondents,
|
||||
@@ -4807,6 +4950,8 @@ const AppLayout = () => {
|
||||
onFolderNavigate: selectFolder,
|
||||
onClose: clearDocumentSelection,
|
||||
resolveFolderPath,
|
||||
ollamaUrl: OLLAMA_BASE_URL,
|
||||
ollamaModel: OLLAMA_MODEL,
|
||||
};
|
||||
|
||||
const skeuoWorkspaceProps = useMemo(
|
||||
@@ -5322,10 +5467,10 @@ const LoginRoute = () => {
|
||||
try {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
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({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
@@ -5336,7 +5481,11 @@ const LoginRoute = () => {
|
||||
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');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
@@ -5369,7 +5518,11 @@ const LoginRoute = () => {
|
||||
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');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: width 0.28s ease, height 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
|
||||
@@ -60,9 +60,12 @@ const SkeuoPreviewCard = ({
|
||||
prefetch,
|
||||
});
|
||||
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||
const docId = doc?.id ?? null;
|
||||
|
||||
const metadataWidth = Number(currentMetadata?.width);
|
||||
const metadataHeight = Number(currentMetadata?.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onNavigatorSnapshot || !docId) {
|
||||
return undefined;
|
||||
@@ -74,6 +77,9 @@ const SkeuoPreviewCard = ({
|
||||
canGoNext,
|
||||
goPrev: navigator.goPrev,
|
||||
goNext: navigator.goNext,
|
||||
ordinal,
|
||||
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||
};
|
||||
onNavigatorSnapshot(docId, snapshot);
|
||||
return () => onNavigatorSnapshot(docId, null);
|
||||
@@ -83,6 +89,9 @@ const SkeuoPreviewCard = ({
|
||||
title,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
ordinal,
|
||||
metadataWidth,
|
||||
metadataHeight,
|
||||
navigator.goPrev,
|
||||
navigator.goNext,
|
||||
onNavigatorSnapshot,
|
||||
@@ -555,6 +564,37 @@ const clamp = (value, min, max) => {
|
||||
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) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
|
||||
@@ -602,43 +642,90 @@ const SkeuomorphicWorkspace = ({
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||
const draggingTagRef = useRef(null);
|
||||
const pendingDocTagDragRef = useRef(null);
|
||||
const docSizeMapRef = useRef(new Map());
|
||||
const documentLookupRef = useRef(new Map());
|
||||
const removalCursorActiveRef = useRef(false);
|
||||
const handleNavigatorSnapshot = useCallback((docId, snapshot) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
setPreviewSnapshots((previous) => {
|
||||
const prevSnapshot = previous.get(docId);
|
||||
if (!snapshot) {
|
||||
if (!previous.has(docId)) {
|
||||
const applySnapshotDimensions = useCallback(
|
||||
(docId, snapshot) => {
|
||||
if (!docId || !snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = documentLookupRef.current.get(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;
|
||||
}
|
||||
const next = new Map(previous);
|
||||
next.delete(docId);
|
||||
next.set(docId, snapshot);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (snapshot) {
|
||||
applySnapshotDimensions(docId, snapshot);
|
||||
}
|
||||
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;
|
||||
if (sameSnapshot) {
|
||||
return previous;
|
||||
}
|
||||
next.set(docId, snapshot);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[applySnapshotDimensions],
|
||||
);
|
||||
const activeTagSet = useMemo(() => {
|
||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||
return new Set();
|
||||
@@ -663,6 +750,15 @@ const SkeuomorphicWorkspace = ({
|
||||
const resolvePreviewDimensions = useCallback(
|
||||
(doc) => {
|
||||
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 view = createAssetView(asset);
|
||||
const metadata = view.getPrimaryMetadata() || {};
|
||||
@@ -673,7 +769,7 @@ const SkeuomorphicWorkspace = ({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[resolvePreviewAsset],
|
||||
[previewSnapshots, resolvePreviewAsset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -780,48 +876,26 @@ const SkeuomorphicWorkspace = ({
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
let width;
|
||||
let height;
|
||||
|
||||
const intrinsic = resolvePreviewDimensions(doc);
|
||||
let normalized = null;
|
||||
if (intrinsic?.width && intrinsic?.height) {
|
||||
width = intrinsic.width;
|
||||
height = intrinsic.height;
|
||||
} else {
|
||||
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
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));
|
||||
height = width * EMPTY_CARD_ASPECT;
|
||||
const height = width * EMPTY_CARD_ASPECT;
|
||||
normalized = clampCardDimensions(width, height);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = CARD_MIN;
|
||||
}
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
height = CARD_MIN;
|
||||
if (!normalized) {
|
||||
normalized = { width: CARD_MIN, height: CARD_MIN };
|
||||
}
|
||||
|
||||
const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height);
|
||||
width *= scaleDown;
|
||||
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;
|
||||
docSizeMapRef.current.set(key, normalized);
|
||||
return normalized;
|
||||
}, [resolvePreviewDimensions]);
|
||||
|
||||
const documentLookup = useMemo(() => {
|
||||
@@ -834,8 +908,13 @@ const SkeuomorphicWorkspace = ({
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
documentLookupRef.current = documentLookup;
|
||||
}, [documentLookup]);
|
||||
|
||||
useEffect(() => {
|
||||
docSizeMapRef.current = new Map();
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
}, [items]);
|
||||
|
||||
const overlayDisplay = useMemo(() => {
|
||||
@@ -904,7 +983,8 @@ const SkeuomorphicWorkspace = ({
|
||||
const container = containerRef.current;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1020,6 +1100,7 @@ const SkeuomorphicWorkspace = ({
|
||||
items,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
docSizeVersion,
|
||||
ensureDocumentSize,
|
||||
syncLayoutSnapshot,
|
||||
]);
|
||||
|
||||
@@ -1840,6 +1840,60 @@ button.danger:hover:not([disabled]) {
|
||||
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;
|
||||
|
||||
@@ -48,6 +48,8 @@ module.exports = {
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'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: {
|
||||
|
||||
Reference in New Issue
Block a user