diff --git a/backend/src/db.ts b/backend/src/db.ts index 0f0224c..25eabe8 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -43,6 +43,11 @@ db.exec(` name TEXT UNIQUE NOT NULL COLLATE NOCASE ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS meme_tags ( meme_id TEXT NOT NULL REFERENCES memes(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, diff --git a/backend/src/index.ts b/backend/src/index.ts index eb3689a..846d496 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import { authRoutes } from './routes/auth.js'; import { collectionsRoutes } from './routes/collections.js'; import { adminRoutes } from './routes/admin.js'; import { shareRoutes } from './routes/share.js'; +import { settingsRoutes } from './routes/settings.js'; // Ensure data dirs exist ensureImagesDir(); @@ -46,6 +47,7 @@ await app.register(tagsRoutes); await app.register(adminRoutes); await app.register(shareRoutes); +await app.register(settingsRoutes); // SPA fallback — serve index.html for all non-API, non-image routes app.setNotFoundHandler(async (req, reply) => { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..1e8c1ae --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from 'fastify'; +import db from '../db.js'; +import { requireAuth } from '../auth.js'; + +type SettingsRow = { key: string; value: string }; + +function getAllSettings(): Record { + const rows = db.prepare('SELECT key, value FROM settings').all() as SettingsRow[]; + return Object.fromEntries(rows.map((r) => [r.key, r.value])); +} + +export async function settingsRoutes(app: FastifyInstance) { + // Public — anyone can read settings (needed to render logo for guests) + app.get('/api/settings', async () => { + return getAllSettings(); + }); + + // Admin — update one or more settings keys + app.put<{ Body: Record }>( + '/api/settings', + { preHandler: requireAuth }, + async (req) => { + const allowed = new Set(['logo_url']); + const stmt = db.prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ); + for (const [key, value] of Object.entries(req.body)) { + if (!allowed.has(key)) continue; + if (value === '' || value == null) { + db.prepare('DELETE FROM settings WHERE key = ?').run(key); + } else { + stmt.run(key, String(value)); + } + } + return getAllSettings(); + } + ); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40025f1..4719a29 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,35 @@ +import { useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { Gallery } from './pages/Gallery'; +import { useSettings } from './hooks/useSettings'; + +function FaviconUpdater() { + const { data: settings } = useSettings(); + + useEffect(() => { + const logoUrl = settings?.logo_url; + if (!logoUrl) return; + + let link = document.querySelector('link[rel~="icon"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = logoUrl; + }, [settings?.logo_url]); + + return null; +} export default function App() { return ( - - } /> - } /> - + <> + + + } /> + } /> + + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6d7bb42..3a399ab 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -147,6 +147,19 @@ export const api = { }, }, + settings: { + get(): Promise> { + return apiFetch('/api/settings'); + }, + update(body: Record): Promise> { + return apiFetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + }, + admin: { reindexStatus(): Promise<{ pending: number; indexed: number }> { return apiFetch('/api/admin/reindex/status'); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index adca9c5..937a8e4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { X, ScanText, Database, RefreshCw, CheckCircle2, AlertCircle, Loader2, Share2, MousePointerClick } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { X, ScanText, Database, RefreshCw, CheckCircle2, AlertCircle, Loader2, Share2, MousePointerClick, ImageIcon, Save } from 'lucide-react'; import { useReindexStatus, useReindex, useCollections, useTags, useMemes, useAdminStats } from '../hooks/useMemes'; +import { useSettings, useUpdateSettings } from '../hooks/useSettings'; interface Props { onClose: () => void; @@ -13,14 +14,32 @@ export function SettingsModal({ onClose }: Props) { const { data: tags } = useTags(); const { data: allMemes } = useMemes({ parent_only: false, limit: 1 }); const { data: adminStats } = useAdminStats(); + const { data: settings } = useSettings(); + const updateSettings = useUpdateSettings(); + + const [logoInput, setLogoInput] = useState(''); + const [logoPreviewError, setLogoPreviewError] = useState(false); + + // Populate field when settings load + useEffect(() => { + if (settings?.logo_url !== undefined) { + setLogoInput(settings.logo_url ?? ''); + } + }, [settings?.logo_url]); async function handleReindex() { await reindex.mutateAsync(); refetchStatus(); } + async function saveLogo() { + await updateSettings.mutateAsync({ logo_url: logoInput.trim() }); + } + + const logoChanged = logoInput.trim() !== (settings?.logo_url ?? ''); const hasPending = (status?.pending ?? 0) > 0; const reindexResult = reindex.data; + const previewUrl = logoInput.trim(); return ( <> @@ -37,6 +56,54 @@ export function SettingsModal({ onClose }: Props) {
+ + {/* Branding */} +
+

+ Branding +

+ + +
+ { setLogoInput(e.target.value); setLogoPreviewError(false); }} + placeholder="https://example.com/logo.png" + className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent placeholder-zinc-600" + /> + +
+ + {/* Live preview */} + {previewUrl && !logoPreviewError && ( +
+ Logo preview setLogoPreviewError(true)} + /> + Header & favicon preview +
+ )} + {previewUrl && logoPreviewError && ( +

Could not load image — check the URL.

+ )} + {!previewUrl && settings?.logo_url == null && ( +

Leave blank to use the default 🎭 emoji logo.

+ )} +
+ +
+ {/* Library Stats */}

@@ -69,7 +136,6 @@ export function SettingsModal({ onClose }: Props) { OCR Index

- {/* Status row */}
{statusLoading ? ( Checking… @@ -96,7 +162,6 @@ export function SettingsModal({ onClose }: Props) {
- {/* Result banner */} {reindexResult && !reindex.isPending && (
0 diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..09a758e --- /dev/null +++ b/frontend/src/hooks/useSettings.ts @@ -0,0 +1,20 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../api/client'; + +export function useSettings() { + return useQuery({ + queryKey: ['settings'], + queryFn: () => api.settings.get(), + staleTime: 60_000, + }); +} + +export function useUpdateSettings() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: Record) => api.settings.update(body), + onSuccess: (data) => { + qc.setQueryData(['settings'], data); + }, + }); +} diff --git a/frontend/src/pages/Gallery.tsx b/frontend/src/pages/Gallery.tsx index 45a80d2..912724a 100644 --- a/frontend/src/pages/Gallery.tsx +++ b/frontend/src/pages/Gallery.tsx @@ -9,6 +9,7 @@ import { LoginModal } from '../components/LoginModal'; import { SharePanel } from '../components/SharePanel'; import { CollectionBar } from '../components/CollectionBar'; import { SettingsModal } from '../components/SettingsModal'; +import { useSettings } from '../hooks/useSettings'; import type { Meme } from '../api/client'; const PAGE_SIZE = 100; @@ -30,6 +31,8 @@ export function Gallery() { const { data: auth } = useAuth(); const logout = useLogout(); const isAdmin = auth?.authenticated === true; + const { data: settings } = useSettings(); + const logoUrl = settings?.logo_url; const { data: collections } = useCollections(); @@ -99,8 +102,14 @@ export function Gallery() {
{/* Logo */}
- 🎭 - Memer + {logoUrl ? ( + Logo + ) : ( + <> + 🎭 + Memer + + )}
{/* Search */}