3 Commits
Author SHA1 Message Date
nils 5091cd005e ollama experiment 2025-10-27 13:24:47 +01:00
nils 82aa8948cf tenants 2025-10-27 11:32:22 +01:00
nils 84a3a9a5b5 skeuo aspect ratio 2025-10-27 01:55:37 +01:00
12 changed files with 845 additions and 87 deletions
+8 -1
View File
@@ -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>,
} }
+56 -2
View File
@@ -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();
+1 -4
View File
@@ -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) => {
+1
View File
@@ -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()
+2 -2
View File
@@ -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
View File
@@ -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.
+410
View File
@@ -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,6 +318,7 @@ 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 = [],
@@ -324,6 +326,8 @@ const DetailPanel = ({
onCorrespondentRemove, onCorrespondentRemove,
resolveApiPath, resolveApiPath,
onFolderNavigate = null, onFolderNavigate = null,
ollamaUrl = null,
ollamaModel = 'llama3',
resolveFolderPath = null, resolveFolderPath = null,
onClose = () => {}, onClose = () => {},
}) => { }) => {
@@ -353,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) {
@@ -386,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);
@@ -439,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;
@@ -481,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;
@@ -493,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',
@@ -1055,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>{' '}
+164 -11
View File
@@ -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;
} }
@@ -3464,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) {
@@ -4196,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');
@@ -4210,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.';
@@ -4798,6 +4940,7 @@ const AppLayout = () => {
onPromoteSelection: promoteSelectionOrder, onPromoteSelection: promoteSelectionOrder,
activePreviewId, activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate, onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
correspondents, correspondents,
@@ -4807,6 +4950,8 @@ const AppLayout = () => {
onFolderNavigate: selectFolder, onFolderNavigate: selectFolder,
onClose: clearDocumentSelection, onClose: clearDocumentSelection,
resolveFolderPath, resolveFolderPath,
ollamaUrl: OLLAMA_BASE_URL,
ollamaModel: OLLAMA_MODEL,
}; };
const skeuoWorkspaceProps = useMemo( const skeuoWorkspaceProps = useMemo(
@@ -5322,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');
@@ -5336,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.';
@@ -5369,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.';
-1
View File
@@ -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 {
+144 -63
View File
@@ -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,
]); ]);
+54
View File
@@ -1840,6 +1840,60 @@ 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 { .detail-panel .detail-folder-path {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
+2
View File
@@ -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: {