This commit is contained in:
2026-05-09 14:51:25 -04:00
parent 97fabc2c17
commit f23a8a2be6
20 changed files with 382 additions and 185 deletions

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM docker.io/library/python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
@@ -13,4 +13,4 @@ COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

View File

@@ -1,14 +1,11 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import create_engine, pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import models so Alembic can detect them
from app.database import Base # noqa: F401
import app.models # noqa: F401
@@ -22,24 +19,17 @@ def run_migrations_offline() -> None:
context.run_migrations()
def do_run_migrations(connection):
def run_migrations_online() -> None:
# Alembic requires a sync engine; strip the async driver prefix
url = config.get_main_option("sqlalchemy.url").replace("+aiosqlite", "")
connectable = create_engine(url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
run_migrations_online()

View File

@@ -1,3 +1,4 @@
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
@@ -6,6 +7,17 @@ engine = create_async_engine(settings.database_url, echo=False)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
# Enable WAL mode so concurrent reads don't block on an open write transaction.
# Also set a generous busy timeout so transient lock contention retries instead
# of immediately raising OperationalError.
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_conn, _record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=10000") # 10 s
cursor.close()
class Base(DeclarativeBase):
pass

View File

@@ -1,29 +1,52 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from alembic.config import Config
from alembic import command
from fastapi.responses import JSONResponse
from app.database import engine
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
# ends up with no handler, so app.* records are silently discarded.
# Give the app namespace its own StreamHandler to guarantee output.
_app_logger = logging.getLogger("app")
_app_logger.setLevel(logging.INFO)
if not _app_logger.handlers:
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("%(levelname)-8s [%(name)s] %(message)s"))
_app_logger.addHandler(_h)
_app_logger.propagate = False
log = logging.getLogger(__name__)
from app.database import engine, Base
from app.routers import libraries, media, tags, search
from app.services import watcher as watcher_service
def run_migrations():
alembic_cfg = Config("/backend/alembic.ini")
command.upgrade(alembic_cfg, "head")
import app.models # noqa: F401 — registers models with Base.metadata
@asynccontextmanager
async def lifespan(app: FastAPI):
run_migrations()
log.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
log.info("Starting library watchers...")
await watcher_service.start_all()
log.info("Startup complete.")
yield
await watcher_service.stop_all()
app = FastAPI(title="MediaLore", lifespan=lifespan)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
log.exception("Unhandled error on %s %s", request.method, request.url)
return JSONResponse(status_code=500, content={"detail": str(exc)})
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],

View File

@@ -1,6 +1,5 @@
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -19,7 +18,11 @@ async def list_libraries(db: AsyncSession = Depends(get_db)):
@router.post("", response_model=LibraryOut, status_code=201)
async def create_library(body: LibraryCreate, db: AsyncSession = Depends(get_db)):
async def create_library(
body: LibraryCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
path = Path(body.path)
if not path.is_dir():
raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}")
@@ -30,19 +33,18 @@ async def create_library(body: LibraryCreate, db: AsyncSession = Depends(get_db)
lib = Library(name=body.name, path=str(path))
db.add(lib)
await db.flush()
await db.refresh(lib)
lib_id = lib.id
lib_path = lib.path
await db.commit()
await db.refresh(lib)
await scanner.scan_library(lib_id, lib_path, db)
watcher_service.start_watcher(lib_id, lib_path)
# Scan runs in the background so the HTTP response returns immediately
background_tasks.add_task(scanner.scan_library_background, lib.id, lib.path)
watcher_service.start_watcher(lib.id, lib.path)
return lib
async with db.begin():
pass
result = await db.execute(select(Library).where(Library.id == lib_id))
return result.scalars().first()
@router.get("/{library_id}/scan-status")
async def get_scan_status(library_id: int):
return {"scanning": scanner.is_scanning(library_id)}
@router.delete("/{library_id}", status_code=204)
@@ -72,8 +74,6 @@ async def browse_library(library_id: int, path: str = "", db: AsyncSession = Dep
if not target.is_dir():
raise HTTPException(404, "Directory not found")
# Load all media items in this directory (non-recursive)
rel_prefix = path.strip("/")
items_result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == library_id,
@@ -84,19 +84,32 @@ async def browse_library(library_id: int, path: str = "", db: AsyncSession = Dep
for item in items_result.scalars().all():
db_items[item.rel_path] = item
from app.services.scanner import classify
entries: list[BrowseEntry] = []
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
rel_entry = str(entry.relative_to(root))
if entry.is_dir():
entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry))
elif entry.is_file() and rel_entry in db_items:
item = db_items[rel_entry]
elif entry.is_file():
db_item = db_items.get(rel_entry)
if db_item:
entries.append(BrowseEntry(
name=entry.name,
type=item.media_type,
type=db_item.media_type,
rel_path=rel_entry,
media_item_id=item.id,
media_item_id=db_item.id,
))
else:
# File exists on disk but scan hasn't indexed it yet; show by extension
media_type = classify(entry)
if media_type:
entries.append(BrowseEntry(
name=entry.name,
type=media_type,
rel_path=rel_entry,
media_item_id=None,
))
return BrowseResult(path=path, entries=entries)

View File

@@ -1,9 +1,22 @@
import asyncio
import hashlib
import logging
import os
from pathlib import Path
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import MediaItem
from app.database import SessionLocal
log = logging.getLogger(__name__)
_scanning: set[int] = set()
def is_scanning(library_id: int) -> bool:
return library_id in _scanning
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif", ".heic"}
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"}
@@ -26,24 +39,50 @@ def hash_file(path: Path) -> str:
return h.hexdigest()
async def scan_library_background(library_id: int, library_path: str) -> None:
"""Run a full library scan in a fresh session. Safe to call as a background task."""
_scanning.add(library_id)
try:
async with SessionLocal() as db:
await _do_scan(library_id, library_path, db)
except Exception:
log.exception("Scan failed for library %d at %s", library_id, library_path)
finally:
_scanning.discard(library_id)
async def scan_library(library_id: int, library_path: str, db: AsyncSession) -> None:
await _do_scan(library_id, library_path, db)
async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None:
root = Path(library_path)
log.info("Starting scan for library %d at %s", library_id, library_path)
existing = await db.execute(
select(MediaItem).where(MediaItem.library_id == library_id)
)
db_items = {item.rel_path: item for item in existing.scalars().all()}
seen_paths: set[str] = set()
loop = asyncio.get_running_loop()
total_dirs = 0
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
for dirpath, dirnames, filenames in os.walk(library_path):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
dir = Path(dirpath)
rel_dir = str(dir.relative_to(root)) if dir != root else "."
found_in_dir = 0
for filename in sorted(f for f in filenames if not f.startswith(".")):
file_path = dir / filename
media_type = classify(file_path)
if not media_type:
continue
rel = str(file_path.relative_to(root))
seen_paths.add(rel)
found_in_dir += 1
if rel in db_items:
item = db_items[rel]
@@ -51,8 +90,7 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
item.missing = False
item.updated_at = datetime.utcnow()
else:
file_hash = hash_file(file_path)
# Check if this hash matches an orphaned (missing) item — file was moved while offline
file_hash = await loop.run_in_executor(None, hash_file, file_path)
moved = await _find_by_hash(library_id, file_hash, db)
if moved:
moved.rel_path = rel
@@ -60,7 +98,7 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
moved.missing = False
moved.updated_at = datetime.utcnow()
else:
item = MediaItem(
db.add(MediaItem(
library_id=library_id,
rel_path=rel,
filename=file_path.name,
@@ -68,16 +106,21 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
media_type=media_type,
size_bytes=file_path.stat().st_size,
missing=False,
)
db.add(item)
))
log.info("Scanned directory %s%d media file(s) found", rel_dir, found_in_dir)
total_dirs += 1
# Mark items no longer on disk as missing
for rel_path, item in db_items.items():
if rel_path not in seen_paths and not item.missing:
item.missing = True
item.updated_at = datetime.utcnow()
await db.commit()
log.info(
"Scan complete for library %d%d director%s, %d media file(s) indexed",
library_id, total_dirs, "y" if total_dirs == 1 else "ies", len(seen_paths),
)
async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> MediaItem | None:

View File

@@ -111,12 +111,15 @@ class LibraryEventHandler(FileSystemEventHandler):
def start_watcher(library_id: int, library_path: str):
if library_id in _observers:
return
try:
handler = LibraryEventHandler(library_id, library_path)
observer = Observer()
observer.schedule(handler, library_path, recursive=True)
observer.start()
_observers[library_id] = observer
log.info("Started watcher for library %d at %s", library_id, library_path)
except Exception:
log.exception("Failed to start watcher for library %d at %s", library_id, library_path)
def stop_watcher(library_id: int):

View File

@@ -7,13 +7,14 @@ services:
- ${MEDIA_ROOT:-/media}:/media:ro
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- MEDIA_ROOT=/media
- THUMBNAIL_DIR=/data/thumbnails
env_file:
- .env
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "80:80"
- "8080:80"
depends_on:
- backend

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS build
FROM docker.io/library/node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
@@ -6,7 +6,7 @@ RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
FROM docker.io/library/nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -4,7 +4,14 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>MediaLore</title>
<script>
// Apply saved theme before first paint to avoid flash
const t = localStorage.getItem("theme");
if (t === "dark" || (!t && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.setAttribute("data-theme", "dark");
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -1,13 +1,27 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, Library } from "./api/client";
import { api, type Library } from "./api/client";
import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage";
const queryClient = new QueryClient();
function Sidebar() {
function useTheme() {
const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark"
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
localStorage.setItem("theme", dark ? "dark" : "light");
}, [dark]);
return { dark, toggle: () => setDark((d) => !d) };
}
function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boolean }) {
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
@@ -18,20 +32,31 @@ function Sidebar() {
padding: "6px 12px",
textDecoration: "none",
borderRadius: 4,
color: isActive ? "#3b82f6" : "inherit",
background: isActive ? "#eff6ff" : "transparent",
color: isActive ? "var(--accent)" : "var(--text)",
background: isActive ? "var(--accent-bg)" : "transparent",
fontWeight: isActive ? 600 : 400,
});
return (
<nav style={{ width: 220, borderRight: "1px solid #e5e7eb", padding: "1rem", display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12 }}>MediaLore</div>
<nav style={{
width: 220,
borderRight: "1px solid var(--border)",
background: "var(--bg)",
padding: "1rem",
display: "flex",
flexDirection: "column",
gap: 4,
flexShrink: 0,
}}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12, color: "var(--text)" }}>
MediaLore
</div>
<NavLink to="/search" style={linkStyle}>Search</NavLink>
{libraries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#9ca3af", margin: "12px 0 4px", padding: "0 12px" }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", margin: "12px 0 4px", padding: "0 12px" }}>
Libraries
</div>
{libraries.map((lib) => (
@@ -42,18 +67,27 @@ function Sidebar() {
</>
)}
<div style={{ marginTop: "auto" }}>
<div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 4 }}>
<NavLink to="/settings" style={linkStyle}>Settings</NavLink>
<button
onClick={onToggleTheme}
title={dark ? "Switch to light mode" : "Switch to dark mode"}
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--text-secondary)", borderRadius: 4 }}
>
{dark ? "☀ Light mode" : "☾ Dark mode"}
</button>
</div>
</nav>
);
}
function AppShell() {
const { dark, toggle } = useTheme();
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
<Sidebar />
<main style={{ flex: 1, overflow: "auto" }}>
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
<Sidebar onToggleTheme={toggle} dark={dark} />
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />

View File

@@ -67,6 +67,8 @@ export const api = {
request<void>(`/libraries/${id}`, { method: "DELETE" }),
browse: (id: number, path = "") =>
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
scanStatus: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
},
media: {

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseResult } from "../../api/client";
import { api, type BrowseResult } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer";
interface Props {
@@ -27,15 +27,15 @@ export default function FileBrowser({ libraryId }: Props) {
<div style={{ padding: "1rem" }}>
{/* Breadcrumb */}
<nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}>
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600, color: "var(--text)", padding: "2px 4px" }}>
Root
</button>
{pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/");
return (
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span style={{ color: "#888" }}>/</span>
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer" }}>
<span style={{ color: "var(--text-muted)" }}>/</span>
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "2px 4px" }}>
{part}
</button>
</span>
@@ -43,7 +43,7 @@ export default function FileBrowser({ libraryId }: Props) {
})}
</nav>
{isLoading && <p>Loading</p>}
{isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>}
{/* Grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
@@ -55,14 +55,15 @@ export default function FileBrowser({ libraryId }: Props) {
else if (entry.media_item_id) setViewingId(entry.media_item_id);
}}
style={{
cursor: "pointer",
border: "1px solid #ddd",
cursor: entry.type === "dir" || entry.media_item_id ? "pointer" : "default",
border: "1px solid var(--border)",
borderRadius: 6,
overflow: "hidden",
background: "#fafafa",
background: "var(--bg-card)",
display: "flex",
flexDirection: "column",
alignItems: "center",
opacity: entry.type !== "dir" && !entry.media_item_id ? 0.5 : 1,
}}
>
{entry.type === "dir" ? (
@@ -76,7 +77,7 @@ export default function FileBrowser({ libraryId }: Props) {
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
{entry.name}
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseEntry, MediaItem } from "../../api/client";
import { api, type BrowseEntry, type MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel";
interface Props {

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Tag, TagsByCategory, MediaItem } from "../../api/client";
import { api, type Tag, type TagsByCategory, type MediaItem } from "../../api/client";
interface Props {
item: MediaItem;
@@ -13,7 +13,6 @@ export default function TagPanel({ item }: Props) {
queryFn: api.tags.list,
});
const allTags = grouped.flatMap((g) => g.tags);
const assignedIds = new Set(item.tags.map((t) => t.id));
const [newName, setNewName] = useState("");
@@ -28,8 +27,7 @@ export default function TagPanel({ item }: Props) {
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
onSuccess: (tag: Tag) => {
qc.invalidateQueries({ queryKey: ["tags"] });
const newIds = [...assignedIds, tag.id];
setTagsMutation.mutate(newIds);
setTagsMutation.mutate([...assignedIds, tag.id]);
setNewName("");
setNewCategory("");
},
@@ -44,11 +42,11 @@ export default function TagPanel({ item }: Props) {
return (
<div style={{ minWidth: 220 }}>
<h3 style={{ margin: "0 0 12px" }}>Tags</h3>
<h3 style={{ margin: "0 0 12px", color: "var(--text)" }}>Tags</h3>
{grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginBottom: 4 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginBottom: 4 }}>
{group.category}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
@@ -61,9 +59,9 @@ export default function TagPanel({ item }: Props) {
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
background: assignedIds.has(tag.id) ? "#3b82f6" : "transparent",
color: assignedIds.has(tag.id) ? "#fff" : "inherit",
borderColor: assignedIds.has(tag.id) ? "#3b82f6" : "#ccc",
background: assignedIds.has(tag.id) ? "var(--accent)" : "transparent",
color: assignedIds.has(tag.id) ? "#fff" : "var(--text)",
borderColor: assignedIds.has(tag.id) ? "var(--accent)" : "var(--border)",
fontSize: 13,
}}
>
@@ -75,19 +73,17 @@ export default function TagPanel({ item }: Props) {
))}
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13, color: "#3b82f6" }}>+ New tag</summary>
<summary style={{ cursor: "pointer", fontSize: 13, color: "var(--accent-text)" }}>+ New tag</summary>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
<input
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<input
placeholder="Category"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<button
onClick={() => createTagMutation.mutate()}

66
frontend/src/index.css Normal file
View File

@@ -0,0 +1,66 @@
:root {
--bg: #ffffff;
--bg-secondary: #fafafa;
--bg-card: #fafafa;
--border: #e5e7eb;
--border-subtle: #eeeeee;
--text: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-bg: #eff6ff;
--accent-text: #3b82f6;
--tag-bg: #dbeafe;
--danger: #ef4444;
}
[data-theme="dark"] {
--bg: #111827;
--bg-secondary: #1f2937;
--bg-card: #1f2937;
--border: #374151;
--border-subtle: #374151;
--text: #f9fafb;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--accent: #60a5fa;
--accent-bg: #1e3a5f;
--accent-text: #60a5fa;
--tag-bg: #1e3a5f;
--danger: #f87171;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
}
input, textarea, select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 10px;
font: inherit;
outline-color: var(--accent);
width: 100%;
}
button {
font: inherit;
cursor: pointer;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text);
padding: 6px 12px;
}
button:disabled {
opacity: 0.4;
cursor: default;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(

View File

@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api, Library } from "../api/client";
import { api, type Library } from "../api/client";
import FileBrowser from "../components/FileBrowser/FileBrowser";
export default function BrowserPage() {
@@ -14,13 +14,13 @@ export default function BrowserPage() {
const library = libraries.find((l) => l.id === id);
if (!library) return <p style={{ padding: "2rem" }}>Library not found.</p>;
if (!library) return <p style={{ padding: "2rem", color: "var(--text)" }}>Library not found.</p>;
return (
<div>
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid #eee" }}>
<h2 style={{ margin: 0 }}>{library.name}</h2>
<div style={{ fontSize: 12, color: "#888" }}>{library.path}</div>
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid var(--border-subtle)" }}>
<h2 style={{ margin: 0, color: "var(--text)" }}>{library.name}</h2>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{library.path}</div>
</div>
<FileBrowser libraryId={id} />
</div>

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, MediaItem, TagsByCategory } from "../api/client";
import { api, type MediaItem, type TagsByCategory } from "../api/client";
export default function SearchPage() {
const [q, setQ] = useState("");
@@ -31,24 +31,23 @@ export default function SearchPage() {
return (
<div style={{ padding: "2rem", maxWidth: 900 }}>
<h1>Search</h1>
<h1 style={{ color: "var(--text)" }}>Search</h1>
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
<input
placeholder="Search by filename…"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ flex: 1, minWidth: 200, padding: "6px 10px" }}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit">Search</button>
<button type="submit" style={{ background: "var(--accent)", color: "#fff", border: "none" }}>Search</button>
</form>
{/* Tag filter */}
{grouped.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, color: "#666", marginBottom: 8 }}>Filter by tag:</div>
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 8 }}>Filter by tag:</div>
{grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginRight: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginRight: 8 }}>
{group.category}
</span>
{group.tags.map((tag) => (
@@ -61,9 +60,9 @@ export default function SearchPage() {
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
background: selectedTags.includes(tag.id) ? "#3b82f6" : "transparent",
color: selectedTags.includes(tag.id) ? "#fff" : "inherit",
borderColor: selectedTags.includes(tag.id) ? "#3b82f6" : "#ccc",
background: selectedTags.includes(tag.id) ? "var(--accent)" : "transparent",
color: selectedTags.includes(tag.id) ? "#fff" : "var(--text)",
borderColor: selectedTags.includes(tag.id) ? "var(--accent)" : "var(--border)",
fontSize: 13,
}}
>
@@ -75,24 +74,24 @@ export default function SearchPage() {
</div>
)}
{isFetching && <p>Searching</p>}
{isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{results.map((item) => (
<div key={item.id} style={{ border: "1px solid #ddd", borderRadius: 6, overflow: "hidden" }}>
<div key={item.id} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)" }}>
<img
src={api.media.thumbnailUrl(item.id)}
alt={item.filename}
loading="lazy"
style={{ width: "100%", height: 110, objectFit: "cover" }}
/>
<div style={{ padding: "4px 6px", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<div style={{ padding: "4px 6px", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
{item.filename}
</div>
{item.tags.length > 0 && (
<div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}>
{item.tags.map((t) => (
<span key={t.id} style={{ background: "#e0edff", color: "#3b82f6", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}>
<span key={t.id} style={{ background: "var(--tag-bg)", color: "var(--accent-text)", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}>
{t.name}
</span>
))}
@@ -103,7 +102,7 @@ export default function SearchPage() {
</div>
{submitted && !isFetching && results.length === 0 && (
<p style={{ color: "#888" }}>No results found.</p>
<p style={{ color: "var(--text-secondary)" }}>No results found.</p>
)}
</div>
);

View File

@@ -1,6 +1,37 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Library } from "../api/client";
import { api, type Library } from "../api/client";
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const { data } = useQuery({
queryKey: ["scan-status", lib.id],
queryFn: () => api.libraries.scanStatus(lib.id),
refetchInterval: (query) => (query.state.data?.scanning ? 2000 : false),
});
const scanning = data?.scanning ?? false;
return (
<li style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
<div>
<strong style={{ color: "var(--text)" }}>{lib.name}</strong>
{scanning && (
<span style={{ marginLeft: 8, fontSize: 12, color: "var(--accent)" }}>
Scanning
</span>
)}
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{lib.path}</div>
</div>
<button
onClick={() => onRemove(lib.id)}
disabled={scanning}
style={{ color: scanning ? "var(--text-muted)" : "var(--danger)", background: "transparent", border: "none" }}
>
Remove
</button>
</li>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
@@ -31,49 +62,24 @@ export default function SettingsPage() {
return (
<div style={{ padding: "2rem", maxWidth: 600 }}>
<h1>Settings</h1>
<h2>Libraries</h2>
<h1 style={{ color: "var(--text)" }}>Settings</h1>
<h2 style={{ color: "var(--text)" }}>Libraries</h2>
<form
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
>
<input
placeholder="Library name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
placeholder="/path/to/directory"
value={path}
onChange={(e) => setPath(e.target.value)}
required
/>
{error && <p style={{ color: "red", margin: 0 }}>{error}</p>}
<button type="submit" disabled={addMutation.isPending}>
<input placeholder="Library name" value={name} onChange={(e) => setName(e.target.value)} required />
<input placeholder="/path/to/directory" value={path} onChange={(e) => setPath(e.target.value)} required />
{error && <p style={{ color: "var(--danger)", margin: 0 }}>{error}</p>}
<button type="submit" disabled={addMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
{addMutation.isPending ? "Adding…" : "Add Library"}
</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{libraries.map((lib) => (
<li
key={lib.id}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #eee" }}
>
<div>
<strong>{lib.name}</strong>
<div style={{ fontSize: 12, color: "#666" }}>{lib.path}</div>
</div>
<button
onClick={() => deleteMutation.mutate(lib.id)}
disabled={deleteMutation.isPending}
style={{ color: "red" }}
>
Remove
</button>
</li>
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
))}
</ul>
</div>