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

@@ -25,6 +25,9 @@ RUN npm run build
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
# Tesseract OCR — English language data only (add more langs as needed)
RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng
# Install production deps only # Install production deps only
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev

View File

@@ -13,6 +13,7 @@
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"fastify": "^4.27.0", "fastify": "^4.27.0",
"node-tesseract-ocr": "^2.2.1",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },

View File

@@ -34,6 +34,7 @@ db.exec(`
height INTEGER NOT NULL, height INTEGER NOT NULL,
parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE, parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL, collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
ocr_text TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -54,15 +55,22 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id); CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id);
`); `);
// Migration: add collection_id column if upgrading from earlier schema // Migrations — run after CREATE TABLE IF NOT EXISTS so they only apply to existing DBs
// Must run BEFORE creating the index on that column
const memesCols = db.prepare('PRAGMA table_info(memes)').all() as { name: string }[]; const memesCols = db.prepare('PRAGMA table_info(memes)').all() as { name: string }[];
if (!memesCols.find((c) => c.name === 'collection_id')) { if (!memesCols.find((c) => c.name === 'collection_id')) {
db.exec('ALTER TABLE memes ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL'); db.exec('ALTER TABLE memes ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL');
} }
// Create index after the column is guaranteed to exist (handles both fresh and migrated DBs) if (!memesCols.find((c) => c.name === 'ocr_text')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id)'); db.exec('ALTER TABLE memes ADD COLUMN ocr_text TEXT');
}
// Indexes that depend on migrated columns — created after columns are guaranteed to exist
db.exec(`
CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id);
CREATE INDEX IF NOT EXISTS idx_memes_ocr ON memes(ocr_text) WHERE ocr_text IS NOT NULL;
`);
// Seed the default UNSORTED collection // Seed the default UNSORTED collection
const defaultCollection = db const defaultCollection = db

View File

@@ -8,6 +8,7 @@ import { memesRoutes } from './routes/memes.js';
import { tagsRoutes } from './routes/tags.js'; import { tagsRoutes } from './routes/tags.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { collectionsRoutes } from './routes/collections.js'; import { collectionsRoutes } from './routes/collections.js';
import { adminRoutes } from './routes/admin.js';
// Ensure data dirs exist // Ensure data dirs exist
ensureImagesDir(); ensureImagesDir();
@@ -41,6 +42,7 @@ await app.register(authRoutes);
await app.register(collectionsRoutes); await app.register(collectionsRoutes);
await app.register(memesRoutes); await app.register(memesRoutes);
await app.register(tagsRoutes); await app.register(tagsRoutes);
await app.register(adminRoutes);
// SPA fallback — serve index.html for all non-API, non-image routes // SPA fallback — serve index.html for all non-API, non-image routes
app.setNotFoundHandler(async (req, reply) => { app.setNotFoundHandler(async (req, reply) => {

View File

@@ -0,0 +1,52 @@
import type { FastifyInstance } from 'fastify';
import db from '../db.js';
import { requireAuth } from '../auth.js';
import { extractText } from '../services/ocr.js';
import type { Meme } from '../types.js';
export async function adminRoutes(app: FastifyInstance) {
/**
* POST /api/admin/reindex
* Re-runs OCR on every meme that has no ocr_text yet.
* Processes sequentially to avoid hammering the CPU.
* Returns counts so the caller knows progress.
*/
app.post('/api/admin/reindex', { preHandler: requireAuth }, async (_req, reply) => {
const pending = db
.prepare('SELECT id, file_path, mime_type FROM memes WHERE ocr_text IS NULL')
.all() as Pick<Meme, 'id' | 'file_path' | 'mime_type'>[];
reply.raw.setHeader('Content-Type', 'application/json');
let done = 0;
let failed = 0;
for (const meme of pending) {
const text = await extractText(meme.file_path, meme.mime_type);
if (text) {
db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, meme.id);
done++;
} else {
// Store empty string so it won't be retried on subsequent runs
db.prepare("UPDATE memes SET ocr_text = '' WHERE id = ?").run(meme.id);
failed++;
}
}
return { total: pending.length, indexed: done, no_text_found: failed };
});
/**
* GET /api/admin/reindex/status
* Returns how many memes still need OCR indexing.
*/
app.get('/api/admin/reindex/status', { preHandler: requireAuth }, async () => {
const { pending } = db
.prepare('SELECT COUNT(*) as pending FROM memes WHERE ocr_text IS NULL')
.get() as { pending: number };
const { indexed } = db
.prepare("SELECT COUNT(*) as indexed FROM memes WHERE ocr_text IS NOT NULL AND ocr_text != ''")
.get() as { indexed: number };
return { pending, indexed };
});
}

View File

@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import db, { UNSORTED_ID } from '../db.js'; import db, { UNSORTED_ID } from '../db.js';
import { buildFilePath, deleteFile, getExtension } from '../services/storage.js'; import { buildFilePath, deleteFile, getExtension } from '../services/storage.js';
import { extractMeta, resizeImage, saveBuffer } from '../services/image.js'; import { extractMeta, resizeImage, saveBuffer } from '../services/image.js';
import { extractText } from '../services/ocr.js';
import { requireAuth } from '../auth.js'; import { requireAuth } from '../auth.js';
import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js'; import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js';
@@ -72,8 +73,8 @@ export async function memesRoutes(app: FastifyInstance) {
} }
if (q) { if (q) {
conditions.push('(m.title LIKE ? OR m.description LIKE ?)'); conditions.push('(m.title LIKE ? OR m.description LIKE ? OR m.ocr_text LIKE ?)');
params.push(`%${q}%`, `%${q}%`); params.push(`%${q}%`, `%${q}%`, `%${q}%`);
} }
if (conditions.length) { if (conditions.length) {
@@ -98,7 +99,7 @@ export async function memesRoutes(app: FastifyInstance) {
countParams.push(tag.toLowerCase()); countParams.push(tag.toLowerCase());
} }
if (collection_id !== undefined) countParams.push(Number(collection_id)); if (collection_id !== undefined) countParams.push(Number(collection_id));
if (q) countParams.push(`%${q}%`, `%${q}%`); if (q) countParams.push(`%${q}%`, `%${q}%`, `%${q}%`);
if (countConditions.length) countSql += ' WHERE ' + countConditions.join(' AND '); if (countConditions.length) countSql += ' WHERE ' + countConditions.join(' AND ');
@@ -159,6 +160,11 @@ export async function memesRoutes(app: FastifyInstance) {
if (tagsRaw) setMemeTags(id, tagsRaw.split(',')); if (tagsRaw) setMemeTags(id, tagsRaw.split(','));
// Fire OCR in the background — doesn't block the upload response
extractText(filePath, mimeType).then((text) => {
if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id);
});
return reply.status(201).send(getMemeById(id)); return reply.status(201).send(getMemeById(id));
}); });

View File

@@ -0,0 +1,47 @@
import tesseract from 'node-tesseract-ocr';
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import { absolutePath } from './storage.js';
const OCR_CONFIG = {
lang: 'eng',
oem: 1, // LSTM neural net mode — best accuracy
psm: 3, // Fully automatic page segmentation (good for varied meme layouts)
};
export async function extractText(relPath: string, mimeType: string): Promise<string> {
const srcAbs = absolutePath(relPath);
let inputPath = srcAbs;
let tempPath: string | null = null;
try {
// Animated GIFs: extract first frame as PNG for Tesseract (it can't read GIF directly)
if (mimeType === 'image/gif') {
tempPath = `${srcAbs}.ocr_tmp.png`;
await sharp(srcAbs, { animated: false }).png().toFile(tempPath);
inputPath = tempPath;
}
const raw = await tesseract.recognize(inputPath, OCR_CONFIG);
// Clean up: collapse whitespace, strip lines that are pure noise (< 2 chars)
const cleaned = raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length >= 2)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
return cleaned;
} catch (err) {
// OCR failure is non-fatal — image still gets saved, just won't be text-searchable
console.warn(`OCR failed for ${relPath}:`, (err as Error).message);
return '';
} finally {
if (tempPath && fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}

View File

@@ -10,6 +10,7 @@ export interface Meme {
height: number; height: number;
parent_id: string | null; parent_id: string | null;
collection_id: number | null; collection_id: number | null;
ocr_text: string | null;
created_at: string; created_at: string;
tags: string[]; tags: string[];
} }

View File

@@ -10,6 +10,7 @@ export interface Meme {
height: number; height: number;
parent_id: string | null; parent_id: string | null;
collection_id: number | null; collection_id: number | null;
ocr_text: string | null;
created_at: string; created_at: string;
tags: string[]; tags: string[];
children?: Meme[]; 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 { imageUrl(filePath: string): string {
return `/images/${filePath}`; return `/images/${filePath}`;
}, },

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; 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 { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { SharePanel } from './SharePanel'; import { SharePanel } from './SharePanel';
@@ -33,6 +33,7 @@ export function MemeDetail({ memeId, onClose }: Props) {
const { data: collections } = useCollections(); const { data: collections } = useCollections();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [ocrExpanded, setOcrExpanded] = useState(false);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editDesc, setEditDesc] = useState(''); const [editDesc, setEditDesc] = useState('');
const [editTags, setEditTags] = useState(''); const [editTags, setEditTags] = useState('');
@@ -244,6 +245,24 @@ export function MemeDetail({ memeId, onClose }: Props) {
</section> </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 */} {/* Metadata */}
<section> <section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3> <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 { useState } from 'react';
import { X, Minimize2 } from 'lucide-react'; import { X, Minimize2, Link } from 'lucide-react';
import type { Meme } from '../api/client'; import type { Meme } from '../api/client';
import { useRescaleMeme } from '../hooks/useMemes'; import { useRescaleMeme } from '../hooks/useMemes';
@@ -10,20 +10,40 @@ interface Props {
} }
export function RescaleModal({ meme, onClose, onDone }: Props) { export function RescaleModal({ meme, onClose, onDone }: Props) {
const ratio = meme.width / meme.height;
const [width, setWidth] = useState(''); const [width, setWidth] = useState('');
const [height, setHeight] = useState(''); const [height, setHeight] = useState('');
const [quality, setQuality] = useState('85'); const [quality, setQuality] = useState('85');
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const rescale = useRescaleMeme(); 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!width && !height) return; if (!width) return;
await rescale.mutateAsync({ await rescale.mutateAsync({
id: meme.id, id: meme.id,
width: width ? Number(width) : undefined, width: Number(width),
height: height ? Number(height) : undefined,
quality: Number(quality), quality: Number(quality),
label: label || undefined, label: label || undefined,
}); });
@@ -31,6 +51,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
} }
const isGif = meme.mime_type === 'image/gif'; const isGif = meme.mime_type === 'image/gif';
const previewLabel = width ? `${width}w` : '';
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 animate-fade-in"> <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. Creates a new derived image. Original ({meme.width}×{meme.height}) is never modified.
</p> </p>
<div className="grid grid-cols-2 gap-3"> {/* Linked dimension inputs */}
<div> <div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs text-zinc-500 mb-1">Width (px)</label> <label className="block text-xs text-zinc-500 mb-1">Width (px)</label>
<input <input
type="number" type="number"
value={width} value={width}
onChange={(e) => setWidth(e.target.value)} onChange={(e) => handleWidthChange(e.target.value)}
placeholder={String(meme.width)} placeholder={String(meme.width)}
min={1} 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" 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>
<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> <label className="block text-xs text-zinc-500 mb-1">Height (px)</label>
<input <input
type="number" type="number"
value={height} value={height}
onChange={(e) => setHeight(e.target.value)} onChange={(e) => handleHeightChange(e.target.value)}
placeholder={String(meme.height)} placeholder={String(meme.height)}
min={1} 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" 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>
</div> </div>
<p className="text-xs text-zinc-500 -mt-1"> <p className="text-xs text-zinc-600 -mt-1 flex items-center gap-1">
Aspect ratio is preserved automatically (fit: inside). <Link size={10} className="text-accent/50" />
Aspect ratio locked editing either dimension updates the other.
</p> </p>
{!isGif && ( {!isGif && (
@@ -99,7 +130,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
type="text" type="text"
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} 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" 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>
@@ -118,7 +149,7 @@ export function RescaleModal({ meme, onClose, onDone }: Props) {
</button> </button>
<button <button
type="submit" 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" 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'} {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() { export function useRescaleMeme() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'; 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 { useMemes, useTags, useCollections } from '../hooks/useMemes';
import { useAuth, useLogout } from '../hooks/useAuth'; import { useAuth, useLogout } from '../hooks/useAuth';
import { GalleryGrid } from '../components/GalleryGrid'; import { GalleryGrid } from '../components/GalleryGrid';
@@ -8,6 +8,7 @@ import { UploadModal } from '../components/UploadModal';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { SharePanel } from '../components/SharePanel'; import { SharePanel } from '../components/SharePanel';
import { CollectionBar } from '../components/CollectionBar'; import { CollectionBar } from '../components/CollectionBar';
import { SettingsModal } from '../components/SettingsModal';
import type { Meme } from '../api/client'; import type { Meme } from '../api/client';
export function Gallery() { export function Gallery() {
@@ -19,6 +20,7 @@ export function Gallery() {
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null); const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const { data: auth } = useAuth(); const { data: auth } = useAuth();
const logout = useLogout(); const logout = useLogout();
@@ -116,6 +118,16 @@ export function Gallery() {
<span className="hidden sm:inline">Upload</span> <span className="hidden sm:inline">Upload</span>
</button> </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 ? ( {isAdmin ? (
<button <button
onClick={() => logout.mutate()} onClick={() => logout.mutate()}
@@ -271,6 +283,11 @@ export function Gallery() {
onSuccess={() => setShowUpload(true)} onSuccess={() => setShowUpload(true)}
/> />
)} )}
{/* Settings modal */}
{showSettings && isAdmin && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
</div> </div>
); );
} }