fixes
This commit is contained in:
@@ -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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
@@ -13,4 +13,4 @@ COPY alembic.ini .
|
|||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
COPY app/ app/
|
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"]
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import create_engine, pool
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# Import models so Alembic can detect them
|
|
||||||
from app.database import Base # noqa: F401
|
from app.database import Base # noqa: F401
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
|
||||||
@@ -22,24 +19,17 @@ def run_migrations_offline() -> None:
|
|||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
def run_migrations_online() -> None:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
# Alembic requires a sync engine; strip the async driver prefix
|
||||||
with context.begin_transaction():
|
url = config.get_main_option("sqlalchemy.url").replace("+aiosqlite", "")
|
||||||
context.run_migrations()
|
connectable = create_engine(url, poolclass=pool.NullPool)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
async def run_migrations_online() -> None:
|
with context.begin_transaction():
|
||||||
connectable = async_engine_from_config(
|
context.run_migrations()
|
||||||
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():
|
if context.is_offline_mode():
|
||||||
run_migrations_offline()
|
run_migrations_offline()
|
||||||
else:
|
else:
|
||||||
asyncio.run(run_migrations_online())
|
run_migrations_online()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from app.config import settings
|
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)
|
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):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,52 @@
|
|||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from alembic.config import Config
|
from fastapi.responses import JSONResponse
|
||||||
from alembic import command
|
|
||||||
|
|
||||||
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.routers import libraries, media, tags, search
|
||||||
from app.services import watcher as watcher_service
|
from app.services import watcher as watcher_service
|
||||||
|
import app.models # noqa: F401 — registers models with Base.metadata
|
||||||
|
|
||||||
def run_migrations():
|
|
||||||
alembic_cfg = Config("/backend/alembic.ini")
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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()
|
await watcher_service.start_all()
|
||||||
|
|
||||||
|
log.info("Startup complete.")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
await watcher_service.stop_all()
|
await watcher_service.stop_all()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MediaLore", lifespan=lifespan)
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
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)
|
@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)
|
path = Path(body.path)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}")
|
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))
|
lib = Library(name=body.name, path=str(path))
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
await db.flush()
|
|
||||||
await db.refresh(lib)
|
|
||||||
lib_id = lib.id
|
|
||||||
lib_path = lib.path
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await db.refresh(lib)
|
||||||
|
|
||||||
await scanner.scan_library(lib_id, lib_path, db)
|
# Scan runs in the background so the HTTP response returns immediately
|
||||||
watcher_service.start_watcher(lib_id, lib_path)
|
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
|
@router.get("/{library_id}/scan-status")
|
||||||
result = await db.execute(select(Library).where(Library.id == lib_id))
|
async def get_scan_status(library_id: int):
|
||||||
return result.scalars().first()
|
return {"scanning": scanner.is_scanning(library_id)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{library_id}", status_code=204)
|
@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():
|
if not target.is_dir():
|
||||||
raise HTTPException(404, "Directory not found")
|
raise HTTPException(404, "Directory not found")
|
||||||
|
|
||||||
# Load all media items in this directory (non-recursive)
|
|
||||||
rel_prefix = path.strip("/")
|
|
||||||
items_result = await db.execute(
|
items_result = await db.execute(
|
||||||
select(MediaItem).where(
|
select(MediaItem).where(
|
||||||
MediaItem.library_id == library_id,
|
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():
|
for item in items_result.scalars().all():
|
||||||
db_items[item.rel_path] = item
|
db_items[item.rel_path] = item
|
||||||
|
|
||||||
|
from app.services.scanner import classify
|
||||||
|
|
||||||
entries: list[BrowseEntry] = []
|
entries: list[BrowseEntry] = []
|
||||||
|
|
||||||
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
|
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
|
||||||
rel_entry = str(entry.relative_to(root))
|
rel_entry = str(entry.relative_to(root))
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry))
|
entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry))
|
||||||
elif entry.is_file() and rel_entry in db_items:
|
elif entry.is_file():
|
||||||
item = db_items[rel_entry]
|
db_item = db_items.get(rel_entry)
|
||||||
entries.append(BrowseEntry(
|
if db_item:
|
||||||
name=entry.name,
|
entries.append(BrowseEntry(
|
||||||
type=item.media_type,
|
name=entry.name,
|
||||||
rel_path=rel_entry,
|
type=db_item.media_type,
|
||||||
media_item_id=item.id,
|
rel_path=rel_entry,
|
||||||
))
|
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)
|
return BrowseResult(path=path, entries=entries)
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.models import MediaItem
|
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"}
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif", ".heic"}
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"}
|
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"}
|
||||||
@@ -26,58 +39,88 @@ def hash_file(path: Path) -> str:
|
|||||||
return h.hexdigest()
|
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:
|
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)
|
root = Path(library_path)
|
||||||
|
log.info("Starting scan for library %d at %s", library_id, library_path)
|
||||||
|
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(MediaItem).where(MediaItem.library_id == library_id)
|
select(MediaItem).where(MediaItem.library_id == library_id)
|
||||||
)
|
)
|
||||||
db_items = {item.rel_path: item for item in existing.scalars().all()}
|
db_items = {item.rel_path: item for item in existing.scalars().all()}
|
||||||
|
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
total_dirs = 0
|
||||||
|
|
||||||
for file_path in root.rglob("*"):
|
for dirpath, dirnames, filenames in os.walk(library_path):
|
||||||
if not file_path.is_file():
|
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
|
||||||
continue
|
dir = Path(dirpath)
|
||||||
media_type = classify(file_path)
|
rel_dir = str(dir.relative_to(root)) if dir != root else "."
|
||||||
if not media_type:
|
found_in_dir = 0
|
||||||
continue
|
|
||||||
|
|
||||||
rel = str(file_path.relative_to(root))
|
for filename in sorted(f for f in filenames if not f.startswith(".")):
|
||||||
seen_paths.add(rel)
|
file_path = dir / filename
|
||||||
|
media_type = classify(file_path)
|
||||||
|
if not media_type:
|
||||||
|
continue
|
||||||
|
|
||||||
if rel in db_items:
|
rel = str(file_path.relative_to(root))
|
||||||
item = db_items[rel]
|
seen_paths.add(rel)
|
||||||
if item.missing:
|
found_in_dir += 1
|
||||||
item.missing = False
|
|
||||||
item.updated_at = datetime.utcnow()
|
if rel in db_items:
|
||||||
else:
|
item = db_items[rel]
|
||||||
file_hash = hash_file(file_path)
|
if item.missing:
|
||||||
# Check if this hash matches an orphaned (missing) item — file was moved while offline
|
item.missing = False
|
||||||
moved = await _find_by_hash(library_id, file_hash, db)
|
item.updated_at = datetime.utcnow()
|
||||||
if moved:
|
|
||||||
moved.rel_path = rel
|
|
||||||
moved.filename = file_path.name
|
|
||||||
moved.missing = False
|
|
||||||
moved.updated_at = datetime.utcnow()
|
|
||||||
else:
|
else:
|
||||||
item = MediaItem(
|
file_hash = await loop.run_in_executor(None, hash_file, file_path)
|
||||||
library_id=library_id,
|
moved = await _find_by_hash(library_id, file_hash, db)
|
||||||
rel_path=rel,
|
if moved:
|
||||||
filename=file_path.name,
|
moved.rel_path = rel
|
||||||
file_hash=file_hash,
|
moved.filename = file_path.name
|
||||||
media_type=media_type,
|
moved.missing = False
|
||||||
size_bytes=file_path.stat().st_size,
|
moved.updated_at = datetime.utcnow()
|
||||||
missing=False,
|
else:
|
||||||
)
|
db.add(MediaItem(
|
||||||
db.add(item)
|
library_id=library_id,
|
||||||
|
rel_path=rel,
|
||||||
|
filename=file_path.name,
|
||||||
|
file_hash=file_hash,
|
||||||
|
media_type=media_type,
|
||||||
|
size_bytes=file_path.stat().st_size,
|
||||||
|
missing=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
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():
|
for rel_path, item in db_items.items():
|
||||||
if rel_path not in seen_paths and not item.missing:
|
if rel_path not in seen_paths and not item.missing:
|
||||||
item.missing = True
|
item.missing = True
|
||||||
item.updated_at = datetime.utcnow()
|
item.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
await db.commit()
|
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:
|
async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> MediaItem | None:
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ class LibraryEventHandler(FileSystemEventHandler):
|
|||||||
def start_watcher(library_id: int, library_path: str):
|
def start_watcher(library_id: int, library_path: str):
|
||||||
if library_id in _observers:
|
if library_id in _observers:
|
||||||
return
|
return
|
||||||
handler = LibraryEventHandler(library_id, library_path)
|
try:
|
||||||
observer = Observer()
|
handler = LibraryEventHandler(library_id, library_path)
|
||||||
observer.schedule(handler, library_path, recursive=True)
|
observer = Observer()
|
||||||
observer.start()
|
observer.schedule(handler, library_path, recursive=True)
|
||||||
_observers[library_id] = observer
|
observer.start()
|
||||||
log.info("Started watcher for library %d at %s", library_id, library_path)
|
_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):
|
def stop_watcher(library_id: int):
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ services:
|
|||||||
- ${MEDIA_ROOT:-/media}:/media:ro
|
- ${MEDIA_ROOT:-/media}:/media:ro
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
|
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
|
||||||
- MEDIA_ROOT=/media
|
|
||||||
- THUMBNAIL_DIR=/data/thumbnails
|
- THUMBNAIL_DIR=/data/thumbnails
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8080:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM docker.io/library/node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@@ -6,7 +6,7 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM docker.io/library/nginx:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
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 BrowserPage from "./pages/BrowserPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import SearchPage from "./pages/SearchPage";
|
import SearchPage from "./pages/SearchPage";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
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[]>({
|
const { data: libraries = [] } = useQuery<Library[]>({
|
||||||
queryKey: ["libraries"],
|
queryKey: ["libraries"],
|
||||||
queryFn: api.libraries.list,
|
queryFn: api.libraries.list,
|
||||||
@@ -18,20 +32,31 @@ function Sidebar() {
|
|||||||
padding: "6px 12px",
|
padding: "6px 12px",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: isActive ? "#3b82f6" : "inherit",
|
color: isActive ? "var(--accent)" : "var(--text)",
|
||||||
background: isActive ? "#eff6ff" : "transparent",
|
background: isActive ? "var(--accent-bg)" : "transparent",
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{ width: 220, borderRight: "1px solid #e5e7eb", padding: "1rem", display: "flex", flexDirection: "column", gap: 4 }}>
|
<nav style={{
|
||||||
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12 }}>MediaLore</div>
|
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>
|
<NavLink to="/search" style={linkStyle}>Search</NavLink>
|
||||||
|
|
||||||
{libraries.length > 0 && (
|
{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
|
Libraries
|
||||||
</div>
|
</div>
|
||||||
{libraries.map((lib) => (
|
{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>
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
|
const { dark, toggle } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
|
||||||
<Sidebar />
|
<Sidebar onToggleTheme={toggle} dark={dark} />
|
||||||
<main style={{ flex: 1, overflow: "auto" }}>
|
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const api = {
|
|||||||
request<void>(`/libraries/${id}`, { method: "DELETE" }),
|
request<void>(`/libraries/${id}`, { method: "DELETE" }),
|
||||||
browse: (id: number, path = "") =>
|
browse: (id: number, path = "") =>
|
||||||
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
|
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
|
||||||
|
scanStatus: (id: number) =>
|
||||||
|
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
|
||||||
},
|
},
|
||||||
|
|
||||||
media: {
|
media: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, BrowseResult } from "../../api/client";
|
import { api, type BrowseResult } from "../../api/client";
|
||||||
import MediaViewer from "../MediaViewer/MediaViewer";
|
import MediaViewer from "../MediaViewer/MediaViewer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,15 +27,15 @@ export default function FileBrowser({ libraryId }: Props) {
|
|||||||
<div style={{ padding: "1rem" }}>
|
<div style={{ padding: "1rem" }}>
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
|
<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
|
Root
|
||||||
</button>
|
</button>
|
||||||
{pathParts.map((part, i) => {
|
{pathParts.map((part, i) => {
|
||||||
const partPath = pathParts.slice(0, i + 1).join("/");
|
const partPath = pathParts.slice(0, i + 1).join("/");
|
||||||
return (
|
return (
|
||||||
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<span style={{ color: "#888" }}>/</span>
|
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||||
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer" }}>
|
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "2px 4px" }}>
|
||||||
{part}
|
{part}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -43,7 +43,7 @@ export default function FileBrowser({ libraryId }: Props) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{isLoading && <p>Loading…</p>}
|
{isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading…</p>}
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
|
<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);
|
else if (entry.media_item_id) setViewingId(entry.media_item_id);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: entry.type === "dir" || entry.media_item_id ? "pointer" : "default",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: "#fafafa",
|
background: "var(--bg-card)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
opacity: entry.type !== "dir" && !entry.media_item_id ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entry.type === "dir" ? (
|
{entry.type === "dir" ? (
|
||||||
@@ -76,7 +77,7 @@ export default function FileBrowser({ libraryId }: Props) {
|
|||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
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}
|
{entry.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import TagPanel from "../TagPanel/TagPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface Props {
|
||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
@@ -13,7 +13,6 @@ export default function TagPanel({ item }: Props) {
|
|||||||
queryFn: api.tags.list,
|
queryFn: api.tags.list,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTags = grouped.flatMap((g) => g.tags);
|
|
||||||
const assignedIds = new Set(item.tags.map((t) => t.id));
|
const assignedIds = new Set(item.tags.map((t) => t.id));
|
||||||
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -28,8 +27,7 @@ export default function TagPanel({ item }: Props) {
|
|||||||
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
|
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
|
||||||
onSuccess: (tag: Tag) => {
|
onSuccess: (tag: Tag) => {
|
||||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
const newIds = [...assignedIds, tag.id];
|
setTagsMutation.mutate([...assignedIds, tag.id]);
|
||||||
setTagsMutation.mutate(newIds);
|
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setNewCategory("");
|
setNewCategory("");
|
||||||
},
|
},
|
||||||
@@ -44,11 +42,11 @@ export default function TagPanel({ item }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 220 }}>
|
<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) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.category} style={{ marginBottom: 12 }}>
|
<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}
|
{group.category}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
@@ -61,9 +59,9 @@ export default function TagPanel({ item }: Props) {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: assignedIds.has(tag.id) ? "#3b82f6" : "transparent",
|
background: assignedIds.has(tag.id) ? "var(--accent)" : "transparent",
|
||||||
color: assignedIds.has(tag.id) ? "#fff" : "inherit",
|
color: assignedIds.has(tag.id) ? "#fff" : "var(--text)",
|
||||||
borderColor: assignedIds.has(tag.id) ? "#3b82f6" : "#ccc",
|
borderColor: assignedIds.has(tag.id) ? "var(--accent)" : "var(--border)",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -75,19 +73,17 @@ export default function TagPanel({ item }: Props) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<details style={{ marginTop: 16 }}>
|
<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 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
|
||||||
<input
|
<input
|
||||||
placeholder="Tag name"
|
placeholder="Tag name"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
style={{ padding: "4px 8px" }}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="Category"
|
placeholder="Category"
|
||||||
value={newCategory}
|
value={newCategory}
|
||||||
onChange={(e) => setNewCategory(e.target.value)}
|
onChange={(e) => setNewCategory(e.target.value)}
|
||||||
style={{ padding: "4px 8px" }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => createTagMutation.mutate()}
|
onClick={() => createTagMutation.mutate()}
|
||||||
|
|||||||
66
frontend/src/index.css
Normal file
66
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import FileBrowser from "../components/FileBrowser/FileBrowser";
|
||||||
|
|
||||||
export default function BrowserPage() {
|
export default function BrowserPage() {
|
||||||
@@ -14,13 +14,13 @@ export default function BrowserPage() {
|
|||||||
|
|
||||||
const library = libraries.find((l) => l.id === id);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid #eee" }}>
|
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid var(--border-subtle)" }}>
|
||||||
<h2 style={{ margin: 0 }}>{library.name}</h2>
|
<h2 style={{ margin: 0, color: "var(--text)" }}>{library.name}</h2>
|
||||||
<div style={{ fontSize: 12, color: "#888" }}>{library.path}</div>
|
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{library.path}</div>
|
||||||
</div>
|
</div>
|
||||||
<FileBrowser libraryId={id} />
|
<FileBrowser libraryId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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() {
|
export default function SearchPage() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
@@ -31,24 +31,23 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "2rem", maxWidth: 900 }}>
|
<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" }}>
|
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||||
<input
|
<input
|
||||||
placeholder="Search by filename…"
|
placeholder="Search by filename…"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
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>
|
</form>
|
||||||
|
|
||||||
{/* Tag filter */}
|
|
||||||
{grouped.length > 0 && (
|
{grouped.length > 0 && (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<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) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.category} style={{ marginBottom: 8 }}>
|
<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}
|
{group.category}
|
||||||
</span>
|
</span>
|
||||||
{group.tags.map((tag) => (
|
{group.tags.map((tag) => (
|
||||||
@@ -61,9 +60,9 @@ export default function SearchPage() {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: selectedTags.includes(tag.id) ? "#3b82f6" : "transparent",
|
background: selectedTags.includes(tag.id) ? "var(--accent)" : "transparent",
|
||||||
color: selectedTags.includes(tag.id) ? "#fff" : "inherit",
|
color: selectedTags.includes(tag.id) ? "#fff" : "var(--text)",
|
||||||
borderColor: selectedTags.includes(tag.id) ? "#3b82f6" : "#ccc",
|
borderColor: selectedTags.includes(tag.id) ? "var(--accent)" : "var(--border)",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -75,24 +74,24 @@ export default function SearchPage() {
|
|||||||
</div>
|
</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 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
|
||||||
{results.map((item) => (
|
{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
|
<img
|
||||||
src={api.media.thumbnailUrl(item.id)}
|
src={api.media.thumbnailUrl(item.id)}
|
||||||
alt={item.filename}
|
alt={item.filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
style={{ width: "100%", height: 110, objectFit: "cover" }}
|
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}
|
{item.filename}
|
||||||
</div>
|
</div>
|
||||||
{item.tags.length > 0 && (
|
{item.tags.length > 0 && (
|
||||||
<div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}>
|
<div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||||
{item.tags.map((t) => (
|
{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}
|
{t.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -103,7 +102,7 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitted && !isFetching && results.length === 0 && (
|
{submitted && !isFetching && results.length === 0 && (
|
||||||
<p style={{ color: "#888" }}>No results found.</p>
|
<p style={{ color: "var(--text-secondary)" }}>No results found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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() {
|
export default function SettingsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -31,49 +62,24 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "2rem", maxWidth: 600 }}>
|
<div style={{ padding: "2rem", maxWidth: 600 }}>
|
||||||
<h1>Settings</h1>
|
<h1 style={{ color: "var(--text)" }}>Settings</h1>
|
||||||
<h2>Libraries</h2>
|
<h2 style={{ color: "var(--text)" }}>Libraries</h2>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
|
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
|
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
|
||||||
>
|
>
|
||||||
<input
|
<input placeholder="Library name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
placeholder="Library name"
|
<input placeholder="/path/to/directory" value={path} onChange={(e) => setPath(e.target.value)} required />
|
||||||
value={name}
|
{error && <p style={{ color: "var(--danger)", margin: 0 }}>{error}</p>}
|
||||||
onChange={(e) => setName(e.target.value)}
|
<button type="submit" disabled={addMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
|
||||||
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}>
|
|
||||||
{addMutation.isPending ? "Adding…" : "Add Library"}
|
{addMutation.isPending ? "Adding…" : "Add Library"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<li
|
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user