build ocr

This commit is contained in:
2026-03-28 01:59:13 -05:00
parent e1145b9448
commit 0e03cec842
14 changed files with 379 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ export interface Meme {
height: number;
parent_id: string | null;
collection_id: number | null;
ocr_text: string | null;
created_at: string;
tags: string[];
children?: Meme[];
@@ -140,6 +141,16 @@ export const api = {
},
},
admin: {
reindexStatus(): Promise<{ pending: number; indexed: number }> {
return apiFetch('/api/admin/reindex/status');
},
reindex(): Promise<{ total: number; indexed: number; no_text_found: number }> {
return apiFetch('/api/admin/reindex', { method: 'POST' });
},
},
imageUrl(filePath: string): string {
return `/images/${filePath}`;
},

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox } from 'lucide-react';
import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox, ScanText, ChevronDown, ChevronUp } from 'lucide-react';
import { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes';
import { useAuth } from '../hooks/useAuth';
import { SharePanel } from './SharePanel';
@@ -33,6 +33,7 @@ export function MemeDetail({ memeId, onClose }: Props) {
const { data: collections } = useCollections();
const [editing, setEditing] = useState(false);
const [ocrExpanded, setOcrExpanded] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editTags, setEditTags] = useState('');
@@ -244,6 +245,24 @@ export function MemeDetail({ memeId, onClose }: Props) {
</section>
)}
{/* OCR Text */}
{meme.ocr_text && (
<section>
<button
onClick={() => setOcrExpanded((v) => !v)}
className="flex items-center justify-between w-full text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2 hover:text-zinc-400 transition-colors"
>
<span className="flex items-center gap-1"><ScanText size={12} /> Detected Text</span>
{ocrExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{ocrExpanded && (
<p className="text-xs text-zinc-400 bg-zinc-800/60 rounded-lg px-3 py-2 leading-relaxed whitespace-pre-wrap break-words">
{meme.ocr_text}
</p>
)}
</section>
)}
{/* Metadata */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { X, Minimize2 } from 'lucide-react';
import { X, Minimize2, Link } from 'lucide-react';
import type { Meme } from '../api/client';
import { useRescaleMeme } from '../hooks/useMemes';
@@ -10,20 +10,40 @@ interface Props {
}
export function RescaleModal({ meme, onClose, onDone }: Props) {
const ratio = meme.width / meme.height;
const [width, setWidth] = useState('');
const [height, setHeight] = useState('');
const [quality, setQuality] = useState('85');
const [label, setLabel] = useState('');
const rescale = useRescaleMeme();
function handleWidthChange(val: string) {
setWidth(val);
if (val && Number(val) > 0) {
setHeight(String(Math.round(Number(val) / ratio)));
} else {
setHeight('');
}
}
function handleHeightChange(val: string) {
setHeight(val);
if (val && Number(val) > 0) {
setWidth(String(Math.round(Number(val) * ratio)));
} else {
setWidth('');
}
}
// Send only width to the backend — Sharp derives height from aspect ratio
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!width && !height) return;
if (!width) return;
await rescale.mutateAsync({
id: meme.id,
width: width ? Number(width) : undefined,
height: height ? Number(height) : undefined,
width: Number(width),
quality: Number(quality),
label: label || undefined,
});
@@ -31,6 +51,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
}
const isGif = meme.mime_type === 'image/gif';
const previewLabel = width ? `${width}w` : '';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 animate-fade-in">
@@ -50,33 +71,43 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
Creates a new derived image. Original ({meme.width}×{meme.height}) is never modified.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
{/* Linked dimension inputs */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs text-zinc-500 mb-1">Width (px)</label>
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
onChange={(e) => handleWidthChange(e.target.value)}
placeholder={String(meme.width)}
min={1}
max={meme.width}
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
<div>
{/* Lock icon between fields */}
<div className="flex flex-col items-center pb-2 text-accent/70">
<Link size={14} />
</div>
<div className="flex-1">
<label className="block text-xs text-zinc-500 mb-1">Height (px)</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
onChange={(e) => handleHeightChange(e.target.value)}
placeholder={String(meme.height)}
min={1}
max={meme.height}
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
</div>
<p className="text-xs text-zinc-500 -mt-1">
Aspect ratio is preserved automatically (fit: inside).
<p className="text-xs text-zinc-600 -mt-1 flex items-center gap-1">
<Link size={10} className="text-accent/50" />
Aspect ratio locked editing either dimension updates the other.
</p>
{!isGif && (
@@ -99,7 +130,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={`e.g. "thumb" or "${width || meme.width}w"`}
placeholder={previewLabel || `e.g. "thumb"`}
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
@@ -118,7 +149,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
</button>
<button
type="submit"
disabled={(!width && !height) || rescale.isPending}
disabled={!width || rescale.isPending}
className="flex-1 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{rescale.isPending ? 'Rescaling…' : 'Create Rescaled Copy'}

View File

@@ -0,0 +1,140 @@
import { X, ScanText, Database, RefreshCw, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { useReindexStatus, useReindex, useCollections, useTags, useMemes } from '../hooks/useMemes';
interface Props {
onClose: () => void;
}
export function SettingsModal({ onClose }: Props) {
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useReindexStatus();
const reindex = useReindex();
const { data: collections } = useCollections();
const { data: tags } = useTags();
const { data: allMemes } = useMemes({ parent_only: false, limit: 1 });
async function handleReindex() {
await reindex.mutateAsync();
refetchStatus();
}
const hasPending = (status?.pending ?? 0) > 0;
const reindexResult = reindex.data;
return (
<>
<div className="fixed inset-0 z-40 bg-black/80 animate-fade-in" onClick={onClose} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-zinc-900 rounded-2xl border border-zinc-800 shadow-2xl animate-scale-in flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800 flex-shrink-0">
<h2 className="font-semibold text-base">Settings</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors p-1">
<X size={18} />
</button>
</div>
<div className="overflow-y-auto flex-1 p-5 space-y-6">
{/* Library Stats */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Database size={12} /> Library
</h3>
<div className="grid grid-cols-3 gap-2">
<StatCard label="Memes" value={allMemes?.total ?? '—'} />
<StatCard label="Collections" value={collections?.length ?? '—'} />
<StatCard label="Tags" value={tags?.length ?? '—'} />
</div>
</section>
<div className="border-t border-zinc-800" />
{/* OCR Re-index */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<ScanText size={12} /> OCR Index
</h3>
{/* Status row */}
<div className="flex items-center justify-between bg-zinc-800/60 rounded-lg px-4 py-3 mb-3">
{statusLoading ? (
<span className="text-sm text-zinc-500">Checking</span>
) : (
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-400">Indexed</span>
<span className="font-medium text-zinc-200">{status?.indexed ?? 0}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-400">Pending</span>
<span className={`font-medium ${hasPending ? 'text-amber-400' : 'text-zinc-400'}`}>
{status?.pending ?? 0}
</span>
</div>
</div>
)}
<button
onClick={() => refetchStatus()}
className="text-zinc-600 hover:text-zinc-400 transition-colors p-1"
title="Refresh status"
>
<RefreshCw size={14} />
</button>
</div>
{/* Result banner */}
{reindexResult && !reindex.isPending && (
<div className={`flex items-start gap-2.5 rounded-lg px-4 py-3 mb-3 text-sm ${
reindexResult.no_text_found > 0
? 'bg-amber-900/30 border border-amber-800/50 text-amber-300'
: 'bg-emerald-900/30 border border-emerald-800/50 text-emerald-300'
}`}>
{reindexResult.no_text_found > 0
? <AlertCircle size={15} className="flex-shrink-0 mt-0.5" />
: <CheckCircle2 size={15} className="flex-shrink-0 mt-0.5" />
}
<span>
Processed <strong>{reindexResult.total}</strong> image{reindexResult.total !== 1 ? 's' : ''}.{' '}
<strong>{reindexResult.indexed}</strong> indexed
{reindexResult.no_text_found > 0 && <>, <strong>{reindexResult.no_text_found}</strong> had no readable text</>}.
</span>
</div>
)}
<button
onClick={handleReindex}
disabled={reindex.isPending || (!hasPending && !statusLoading)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{reindex.isPending ? (
<>
<Loader2 size={15} className="animate-spin" />
Indexing this may take a while
</>
) : (
<>
<ScanText size={15} />
{hasPending ? `Re-index ${status!.pending} image${status!.pending !== 1 ? 's' : ''}` : 'All images indexed'}
</>
)}
</button>
<p className="text-xs text-zinc-600 mt-2">
Runs OCR on images that haven't been scanned yet. Already-indexed images are skipped.
New uploads are scanned automatically in the background.
</p>
</section>
</div>
</div>
</div>
</>
);
}
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="bg-zinc-800/60 rounded-lg px-3 py-3 text-center">
<div className="text-lg font-semibold text-zinc-200">{value}</div>
<div className="text-xs text-zinc-500 mt-0.5">{label}</div>
</div>
);
}

View File

@@ -106,6 +106,25 @@ export function useDeleteMeme() {
});
}
export function useReindexStatus() {
return useQuery({
queryKey: ['admin', 'reindex-status'],
queryFn: () => api.admin.reindexStatus(),
staleTime: 0,
});
}
export function useReindex() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.admin.reindex(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'reindex-status'] });
qc.invalidateQueries({ queryKey: ['memes'] });
},
});
}
export function useRescaleMeme() {
const qc = useQueryClient();
return useMutation({

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { Search, Upload as UploadIcon, X, Share2, Lock, LogOut } from 'lucide-react';
import { Search, Upload as UploadIcon, X, Share2, Lock, LogOut, Settings } from 'lucide-react';
import { useMemes, useTags, useCollections } from '../hooks/useMemes';
import { useAuth, useLogout } from '../hooks/useAuth';
import { GalleryGrid } from '../components/GalleryGrid';
@@ -8,6 +8,7 @@ import { UploadModal } from '../components/UploadModal';
import { LoginModal } from '../components/LoginModal';
import { SharePanel } from '../components/SharePanel';
import { CollectionBar } from '../components/CollectionBar';
import { SettingsModal } from '../components/SettingsModal';
import type { Meme } from '../api/client';
export function Gallery() {
@@ -19,6 +20,7 @@ export function Gallery() {
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const { data: auth } = useAuth();
const logout = useLogout();
@@ -116,6 +118,16 @@ export function Gallery() {
<span className="hidden sm:inline">Upload</span>
</button>
{isAdmin && (
<button
onClick={() => setShowSettings(true)}
title="Settings"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 text-sm transition-colors"
>
<Settings size={15} />
</button>
)}
{isAdmin ? (
<button
onClick={() => logout.mutate()}
@@ -271,6 +283,11 @@ export function Gallery() {
onSuccess={() => setShowUpload(true)}
/>
)}
{/* Settings modal */}
{showSettings && isAdmin && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
</div>
);
}