diff --git a/src/app/api/ai-jobs/route.ts b/src/app/api/ai-jobs/route.ts new file mode 100644 index 0000000..79c2810 --- /dev/null +++ b/src/app/api/ai-jobs/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const queue = getJobQueue() + const history = getJobHistory(50) + return NextResponse.json({ queue, history }) +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + let body: { action?: string; jobId?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { action, jobId } = body + + switch (action) { + case 'retry': { + if (!jobId || typeof jobId !== 'string') { + return NextResponse.json({ error: 'jobId is required' }, { status: 400 }) + } + const ok = retryJob(jobId) + if (!ok) { + return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 }) + } + return NextResponse.json({ ok: true }) + } + + case 'cancel': { + if (!jobId || typeof jobId !== 'string') { + return NextResponse.json({ error: 'jobId is required' }, { status: 400 }) + } + const ok = cancelJob(jobId) + if (!ok) { + return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 }) + } + return NextResponse.json({ ok: true }) + } + + case 'cancel-all': { + const cancelled = cancelAllQueued() + return NextResponse.json({ cancelled }) + } + + case 'clear-history': { + const cleared = clearJobHistory() + return NextResponse.json({ cleared }) + } + + default: + return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) + } +} diff --git a/src/app/api/ai-settings/route.ts b/src/app/api/ai-settings/route.ts index 414ddbb..219e559 100644 --- a/src/app/api/ai-settings/route.ts +++ b/src/app/api/ai-settings/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireAdmin } from '@/lib/auth' -import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage } from '@/lib/app-settings' +import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } from '@/lib/app-settings' export async function GET(request: NextRequest) { const auth = await requireAdmin(request) @@ -8,7 +8,8 @@ export async function GET(request: NextRequest) { const config = getAiConfig() const preferredLanguage = getPreferredLanguage() - return NextResponse.json({ ...config, preferredLanguage }) + const maxRetries = getAiMaxRetries() + return NextResponse.json({ ...config, preferredLanguage, maxRetries }) } export async function PUT(request: NextRequest) { @@ -28,6 +29,7 @@ export async function PUT(request: NextRequest) { promptTagger?: string promptExtract?: string promptTranslate?: string + maxRetries?: number } try { body = await request.json() @@ -39,6 +41,7 @@ export async function PUT(request: NextRequest) { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate, promptDescribe, promptTagger, promptExtract, promptTranslate, + maxRetries, } = body if (typeof endpoint !== 'string') { @@ -69,6 +72,10 @@ export async function PUT(request: NextRequest) { setPreferredLanguage(preferredLanguage.trim()) } + if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) { + setAiMaxRetries(maxRetries) + } + const config = getAiConfig() - return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage() }) + return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() }) } diff --git a/src/app/api/ai-tagging/describe-bulk/route.ts b/src/app/api/ai-tagging/describe-bulk/route.ts index fb9d05e..35fc2ba 100644 --- a/src/app/api/ai-tagging/describe-bulk/route.ts +++ b/src/app/api/ai-tagging/describe-bulk/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { describeDirectoryItems } from '@/lib/ai-tagger' +import { enqueueBulkJobs } from '@/lib/ai-jobs' + +const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg']) +const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]) export async function POST(request: NextRequest) { let body: { libraryId?: string; path?: string } @@ -18,21 +22,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const processed = await describeDirectoryItems(libraryId, dirPath ?? '') - return NextResponse.json({ processed }) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.code === 'INVALID_TYPE') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - console.error('[ai-tagging/describe-bulk] Error:', error) - return NextResponse.json({ error: 'Failed to generate descriptions' }, { status: 502 }) - } + const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS) + return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/describe/route.ts b/src/app/api/ai-tagging/describe/route.ts index 5121c65..ba6e508 100644 --- a/src/app/api/ai-tagging/describe/route.ts +++ b/src/app/api/ai-tagging/describe/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { generateItemDescription } from '@/lib/ai-tagger' +import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { let body: { itemKey?: string } @@ -19,21 +19,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const description = await generateItemDescription(itemKey) - return NextResponse.json({ description }) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.code === 'NO_IMAGE') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - console.error('[ai-tagging/describe] Error:', error) - return NextResponse.json({ error: 'Failed to generate description' }, { status: 502 }) - } + const jobId = enqueueJob(itemKey, 'describe', libraryId) + return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/extract-text-bulk/route.ts b/src/app/api/ai-tagging/extract-text-bulk/route.ts index 196ca19..3004abd 100644 --- a/src/app/api/ai-tagging/extract-text-bulk/route.ts +++ b/src/app/api/ai-tagging/extract-text-bulk/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { extractDirectoryText } from '@/lib/ai-tagger' +import { enqueueBulkJobs } from '@/lib/ai-jobs' + +const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) export async function POST(request: NextRequest) { let body: { libraryId?: string; path?: string } @@ -18,21 +20,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const processed = await extractDirectoryText(libraryId, dirPath ?? '') - return NextResponse.json({ processed }) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.code === 'INVALID_TYPE') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - console.error('[ai-tagging/extract-text-bulk] Error:', error) - return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 }) - } + const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS) + return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts index 58de630..5b6ad22 100644 --- a/src/app/api/ai-tagging/extract-text/route.ts +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { extractItemText } from '@/lib/ai-tagger' +import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { let body: { itemKey?: string } @@ -19,21 +19,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const result = await extractItemText(itemKey) - return NextResponse.json(result) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.code === 'NO_IMAGE' || error.code === 'INVALID_TYPE') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - console.error('[ai-tagging/extract-text] Error:', error) - return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 }) - } + const jobId = enqueueJob(itemKey, 'extract', libraryId) + return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/fields/route.ts b/src/app/api/ai-tagging/fields/route.ts index ee647aa..c4155b1 100644 --- a/src/app/api/ai-tagging/fields/route.ts +++ b/src/app/api/ai-tagging/fields/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { getAiFields } from '@/lib/ai-tagger' +import { getAiFields, updateExtractedText } from '@/lib/ai-tagger' export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl @@ -17,3 +17,27 @@ export async function GET(request: NextRequest) { const fields = getAiFields(itemKey) return NextResponse.json(fields) } + +export async function PATCH(request: NextRequest) { + let body: { itemKey?: string; extractedText?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { itemKey, extractedText } = body + if (!itemKey || typeof itemKey !== 'string') { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + if (typeof extractedText !== 'string') { + return NextResponse.json({ error: 'extractedText is required' }, { status: 400 }) + } + + const libraryId = itemKey.split(':')[0] + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + updateExtractedText(itemKey, extractedText) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/ai-tagging/route.ts b/src/app/api/ai-tagging/route.ts index f248a14..428b701 100644 --- a/src/app/api/ai-tagging/route.ts +++ b/src/app/api/ai-tagging/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { tagSingleItem } from '@/lib/ai-tagger' +import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { let body: { itemKey?: string } @@ -19,21 +19,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const tagIds = await tagSingleItem(itemKey) - return NextResponse.json({ tagIds }) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.code === 'NO_IMAGE') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - console.error('[ai-tagging] Error tagging item:', error) - return NextResponse.json({ error: 'AI tagging failed' }, { status: 502 }) - } + const jobId = enqueueJob(itemKey, 'tag', libraryId) + return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/translate/route.ts b/src/app/api/ai-tagging/translate/route.ts index 740d9fb..27ee8dd 100644 --- a/src/app/api/ai-tagging/translate/route.ts +++ b/src/app/api/ai-tagging/translate/route.ts @@ -1,16 +1,16 @@ import { NextRequest, NextResponse } from 'next/server' import { requireLibraryAccess } from '@/lib/auth' -import { translateItemText } from '@/lib/ai-tagger' +import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { - let body: { itemKey?: string } + let body: { itemKey?: string; sourceLanguage?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { itemKey } = body + const { itemKey, sourceLanguage } = body if (!itemKey || typeof itemKey !== 'string') { return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) } @@ -19,18 +19,6 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - try { - const translatedText = await translateItemText(itemKey) - return NextResponse.json({ translatedText }) - } catch (err) { - const error = err as Error & { code?: string } - if (error.code === 'NOT_CONFIGURED') { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.code === 'NOT_FOUND') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - console.error('[ai-tagging/translate] Error:', error) - return NextResponse.json({ error: 'Failed to translate text' }, { status: 502 }) - } + const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined) + return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/app/manage/ai-tagging/page.tsx b/src/app/manage/ai-tagging/page.tsx index 5807cd7..e45b65c 100644 --- a/src/app/manage/ai-tagging/page.tsx +++ b/src/app/manage/ai-tagging/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' interface AiSettings { endpoint: string @@ -15,6 +15,22 @@ interface AiSettings { promptTagger: string promptExtract: string promptTranslate: string + maxRetries: number +} + +interface AiJob { + id: string + itemKey: string + libraryId: string + jobType: string + status: string + error: string | null + attempt: number + maxRetries: number + createdAt: number + startedAt: number | null + completedAt: number | null + itemTitle: string | null } interface Library { @@ -33,11 +49,24 @@ interface LibraryOverride { promptTranslate: string } +function formatElapsed(startedAt: number): string { + const seconds = Math.floor((Date.now() - startedAt) / 1000) + if (seconds < 60) return `${seconds}s` + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}m ${s}s` +} + +function formatDate(ts: number): string { + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + export default function AiTaggingPage() { const [settings, setSettings] = useState({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English', promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '', + maxRetries: 3, }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -54,6 +83,11 @@ export default function AiTaggingPage() { const [librarySaving, setLibrarySaving] = useState>({}) const [librarySaveResult, setLibrarySaveResult] = useState>({}) + // Job queue state + const [jobQueue, setJobQueue] = useState([]) + const [jobHistory, setJobHistory] = useState([]) + const [historyExpanded, setHistoryExpanded] = useState(false) + const jobPollRef = useRef | null>(null) const fetchSettings = useCallback(async () => { try { const [settingsRes, librariesRes] = await Promise.all([ @@ -79,6 +113,77 @@ export default function AiTaggingPage() { fetchSettings() }, [fetchSettings]) + // ─── Job queue polling ─────────────────────────────────────────────────────── + + const fetchJobs = useCallback(async () => { + try { + const res = await fetch('/api/ai-jobs') + if (res.ok) { + const data: { queue: AiJob[]; history: AiJob[] } = await res.json() + setJobQueue(data.queue) + setJobHistory(data.history) + } + } catch { + // ignore + } + }, []) + + useEffect(() => { + fetchJobs() + }, [fetchJobs]) + + // Poll every 2s while there are active jobs + useEffect(() => { + const hasActive = jobQueue.length > 0 + if (hasActive) { + jobPollRef.current = setInterval(fetchJobs, 2000) + } else { + if (jobPollRef.current) { + clearInterval(jobPollRef.current) + jobPollRef.current = null + } + } + return () => { + if (jobPollRef.current) clearInterval(jobPollRef.current) + } + }, [jobQueue.length, fetchJobs]) + + const handleRetryJob = async (jobId: string) => { + await fetch('/api/ai-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'retry', jobId }), + }) + fetchJobs() + } + + const handleCancelJob = async (jobId: string) => { + await fetch('/api/ai-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel', jobId }), + }) + fetchJobs() + } + + const handleCancelAll = async () => { + await fetch('/api/ai-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel-all' }), + }) + fetchJobs() + } + + const handleClearHistory = async () => { + await fetch('/api/ai-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'clear-history' }), + }) + fetchJobs() + } + const fetchLibraryOverrides = useCallback(async (libraryId: string) => { try { const res = await fetch(`/api/ai-settings/library/${libraryId}`) @@ -201,12 +306,182 @@ export default function AiTaggingPage() { return (

- AI Tagging + AI Integrations

- Automatically tag media using a vision-capable LLM on your network. + Manage AI-powered tagging, descriptions, and text extraction.

+ {/* ─── Job Queue ─────────────────────────────────────────────────────── */} +
+ {(() => { + const running = jobQueue.filter((j) => j.status === 'running') + const queued = jobQueue.filter((j) => j.status === 'queued') + if (running.length === 0 && queued.length === 0) { + return ( +

+ No active jobs. +

+ ) + } + return ( +
+
+

+ {running.length > 0 && {running.length} running} + {running.length > 0 && queued.length > 0 && ', '} + {queued.length > 0 && {queued.length} queued} +

+ {queued.length > 0 && ( + + )} +
+
+ {running.map((job) => ( +
+ + Running + + + {job.itemTitle || job.itemKey} + + + {job.jobType} + + {job.startedAt && ( + + {formatElapsed(job.startedAt)} + + )} +
+ ))} + {queued.map((job) => ( +
+ + Queued + + + {job.itemTitle || job.itemKey} + + + {job.jobType} + + +
+ ))} +
+
+ ) + })()} +
+ + {/* ─── Job History ───────────────────────────────────────────────────── */} +
+ {jobHistory.length === 0 ? ( +

+ No recent jobs. +

+ ) : ( +
+ + {historyExpanded && ( + <> +
+ {jobHistory.map((job) => ( +
+ + {job.status === 'completed' ? 'Done' : 'Failed'} + +
+ + {job.itemTitle || job.itemKey} + + {job.status === 'failed' && job.error && ( + + {job.error} + + )} +
+ + {job.jobType} + + + {job.completedAt ? formatDate(job.completedAt) : ''} + + {job.status === 'failed' && ( + + )} +
+ ))} +
+
+ +
+ + )} +
+ )} +
+
{loading ? ( @@ -365,6 +640,29 @@ export default function AiTaggingPage() {

+ + + setSettings((s) => ({ ...s, maxRetries: Math.max(0, Math.min(10, parseInt(e.target.value) || 0)) })) + } + className="w-24 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')} + onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')} + /> +

+ Number of times to automatically retry a failed AI job before marking it as failed (0–10). +

+
+ {saveError && (

({})) throw new Error((data as { error?: string }).error ?? 'Extraction failed') } + if (res.status === 202) { + setExtractError('Queued — check AI Integrations for progress') + setTimeout(() => setExtractError(null), 4000) + return + } const result = await res.json() setExtractedText(result.extractedText || null) setTranslatedText(result.translatedText || null) diff --git a/src/components/ManageSubNav.tsx b/src/components/ManageSubNav.tsx index a57531f..0195c31 100644 --- a/src/components/ManageSubNav.tsx +++ b/src/components/ManageSubNav.tsx @@ -8,7 +8,7 @@ const TABS = [ { href: '/manage/tags', label: 'Tags' }, { href: '/manage/users', label: 'Users' }, { href: '/manage/scanning', label: 'Scanning' }, - { href: '/manage/ai-tagging', label: 'AI Tagging' }, + { href: '/manage/ai-tagging', label: 'AI Integrations' }, ] export default function ManageSubNav() { diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index c49c52e..5c60986 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -27,6 +27,9 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item const [extracting, setExtracting] = useState(false) const [extractError, setExtractError] = useState(null) const [retranslating, setRetranslating] = useState(false) + const [editedExtractedText, setEditedExtractedText] = useState('') + const [savingText, setSavingText] = useState(false) + const [sourceLanguage, setSourceLanguage] = useState('') // Text overlay state const [showTextOverlay, setShowTextOverlay] = useState(false) @@ -45,6 +48,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item .then((r) => r.json()) .then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { setExtractedText(data.extractedText) + setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) }) .catch(() => {}) @@ -179,14 +183,14 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item

{showTags ? ( -
+
{/* Image */} -
+
{/* eslint-disable-next-line @next/next/no-img-element */} {name} e.stopPropagation()} /> {onPrev && ( @@ -265,8 +269,14 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item const data = await res.json().catch(() => ({})) throw new Error((data as { error?: string }).error ?? 'Failed to extract text') } + if (res.status === 202) { + setExtractError('Queued — check AI Integrations for progress') + setTimeout(() => setExtractError(null), 4000) + return + } const result = await res.json() setExtractedText(result.extractedText || null) + setEditedExtractedText(result.extractedText || '') setTranslatedText(result.translatedText || null) } catch (err) { setExtractError(err instanceof Error ? err.message : 'Failed to extract text') @@ -302,12 +312,41 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item

Extracted Text

-
-                        {extractedText}
-                      
+