Compare commits

...

10 Commits

Author SHA1 Message Date
da78075620 fix path 2026-05-16 21:37:24 -04:00
9e0eabbdbf update docker compose
Co-authored-by: Copilot <copilot@github.com>
2026-05-16 21:32:53 -04:00
ff388cba19 Merge pull request 'doom-scroll' (#2) from doom-scroll into main
Reviewed-on: http://gitea.lan/gpatti/MediaLore-Web-App/pulls/2
2026-05-17 01:03:19 +00:00
7187232236 avoid incorrect thumbnails 2026-05-16 19:41:06 -04:00
7bca2ade5a move path 2026-05-16 19:31:25 -04:00
8daf4e013b add doom scroll 2026-05-16 16:51:55 -04:00
8ada8af62f Merge pull request 'ui-cleanup' (#1) from ui-cleanup into main
Reviewed-on: http://gitea.lan/gpatti/MediaLore-Web-App/pulls/1
2026-05-16 19:46:16 +00:00
b8eab67a93 media viewer visual update 2026-05-16 15:32:29 -04:00
5c766f042c search navigation 2026-05-16 15:13:56 -04:00
b243266ad3 add rescan button 2026-05-16 14:39:00 -04:00
11 changed files with 446 additions and 107 deletions

View File

@@ -2,10 +2,11 @@ from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, 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
from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import Library, MediaItem from app.models import Library, MediaItem
from app.schemas import LibraryCreate, LibraryOut, BrowseResult, BrowseEntry from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service from app.services import scanner, watcher as watcher_service
router = APIRouter(prefix="/libraries", tags=["libraries"]) router = APIRouter(prefix="/libraries", tags=["libraries"])
@@ -47,6 +48,44 @@ async def get_scan_status(library_id: int):
return {"scanning": scanner.is_scanning(library_id)} return {"scanning": scanner.is_scanning(library_id)}
@router.post("/{library_id}/rescan")
async def rescan_library(
library_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:
raise HTTPException(404, "Library not found")
if scanner.is_scanning(library_id):
raise HTTPException(409, "Scan already in progress")
background_tasks.add_task(scanner.scan_library_background, lib.id, lib.path)
return {"scanning": True}
@router.get("/{library_id}/doom-scroll", response_model=list[MediaItemOut])
async def doom_scroll(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first():
raise HTTPException(404, "Library not found")
stmt = (
select(MediaItem)
.options(selectinload(MediaItem.tags))
.where(MediaItem.library_id == library_id, MediaItem.missing == False) # noqa: E712
)
if path:
stmt = stmt.where(MediaItem.rel_path.like(path + "/%"))
result = await db.execute(stmt)
return result.scalars().all()
@router.delete("/{library_id}", status_code=204) @router.delete("/{library_id}", status_code=204)
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)): async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Library).where(Library.id == library_id)) result = await db.execute(select(Library).where(Library.id == library_id))
@@ -88,7 +127,7 @@ async def browse_library(library_id: int, path: str = "", db: AsyncSession = Dep
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((e for e in target.iterdir() if not e.name.startswith(".")), 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))

View File

@@ -8,6 +8,7 @@ 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 from app.database import SessionLocal
from app.services.thumbnails import thumbnail_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -86,6 +87,11 @@ async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None
if rel in db_items: if rel in db_items:
item = db_items[rel] item = db_items[rel]
new_hash = await loop.run_in_executor(None, hash_file, file_path)
if item.file_hash != new_hash:
item.file_hash = new_hash
item.updated_at = datetime.utcnow()
thumbnail_path(item.id).unlink(missing_ok=True)
if item.missing: if item.missing:
item.missing = False item.missing = False
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()
@@ -93,6 +99,7 @@ async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None
file_hash = await loop.run_in_executor(None, hash_file, file_path) file_hash = await loop.run_in_executor(None, hash_file, file_path)
moved = await _find_by_hash(library_id, file_hash, db) moved = await _find_by_hash(library_id, file_hash, db)
if moved: if moved:
thumbnail_path(moved.id).unlink(missing_ok=True)
moved.rel_path = rel moved.rel_path = rel
moved.filename = file_path.name moved.filename = file_path.name
moved.missing = False moved.missing = False
@@ -131,4 +138,5 @@ async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> Me
MediaItem.missing == True, # noqa: E712 MediaItem.missing == True, # noqa: E712
) )
) )
return result.scalars().first() rows = result.scalars().all()
return rows[0] if len(rows) == 1 else None

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from app.database import SessionLocal from app.database import SessionLocal
from app.models import Library, MediaItem from app.models import Library, MediaItem
from app.services.scanner import classify, hash_file from app.services.scanner import classify, hash_file
from app.services.thumbnails import thumbnail_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -59,6 +60,7 @@ class LibraryEventHandler(FileSystemEventHandler):
) )
item = result.scalars().first() item = result.scalars().first()
if item: if item:
thumbnail_path(item.id).unlink(missing_ok=True)
item.rel_path = dest_rel item.rel_path = dest_rel
item.filename = Path(event.dest_path).name item.filename = Path(event.dest_path).name
item.missing = False item.missing = False

View File

@@ -4,13 +4,11 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./data:/data - ./data:/data
- ${MEDIA_ROOT:-/media}:/media:ro - /data/smb/adult/Images:/media/Images
- /data/smb/adult/Video Clips:/media/Video Clips
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db - DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- THUMBNAIL_DIR=/data/thumbnails - THUMBNAIL_DIR=/data/thumbnails
env_file:
- .env
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped

View File

@@ -69,6 +69,10 @@ export const api = {
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`), request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
scanStatus: (id: number) => scanStatus: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`), request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
rescan: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/rescan`, { method: "POST" }),
doomScroll: (id: number, path = "") =>
request<MediaItem[]>(`/libraries/${id}/doom-scroll?path=${encodeURIComponent(path)}`),
}, },
media: { media: {

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from "react";
import { api, type MediaItem } from "../../api/client";
interface Props {
items: MediaItem[];
onClose: () => void;
onViewInLibrary: (item: MediaItem) => void;
}
export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Props) {
const [index, setIndex] = useState(0);
const [fading, setFading] = useState(false);
const wheelLock = useRef(false);
const item = items[index];
function go(delta: 1 | -1) {
if (wheelLock.current) return;
const next = index + delta;
if (next < 0 || next >= items.length) return;
wheelLock.current = true;
setFading(true);
setTimeout(() => {
setIndex(next);
setFading(false);
wheelLock.current = false;
}, 200);
}
useEffect(() => {
const onWheel = (e: WheelEvent) => { e.deltaY > 0 ? go(1) : go(-1); };
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === " ") { e.preventDefault(); go(1); }
if (e.key === "ArrowUp") { e.preventDefault(); go(-1); }
if (e.key === "Escape") onClose();
};
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("keydown", onKey);
};
}, [index, fading]);
return (
<>
{/* Backdrop */}
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.95)", zIndex: 200 }} />
{/* Media area */}
<div
style={{
position: "fixed", inset: 0, zIndex: 201,
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 12, paddingBottom: 56,
transition: "opacity 0.2s ease, transform 0.2s ease",
opacity: fading ? 0 : 1,
transform: fading ? "translateY(-12px)" : "translateY(0)",
}}
>
<div style={{ color: "#ccc", fontSize: 13 }}>{item?.filename}</div>
{item?.media_type === "image" && (
<img
key={item.id}
src={api.media.fileUrl(item.id)}
alt={item.filename}
style={{ maxWidth: "90vw", maxHeight: "82vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
key={item.id}
src={api.media.fileUrl(item.id)}
controls
autoPlay
style={{ maxWidth: "90vw", maxHeight: "82vh" }}
/>
)}
</div>
{/* Bottom bar */}
<div
style={{
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 202,
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 20px", background: "rgba(0,0,0,0.6)",
}}
>
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "#fff", fontSize: 15, cursor: "pointer" }}
>
Close
</button>
<span style={{ color: "#888", fontSize: 13 }}>
{index + 1} / {items.length}
</span>
<button
onClick={() => onViewInLibrary(item)}
style={{ background: "none", border: "none", color: "var(--accent, #60a5fa)", fontSize: 15, cursor: "pointer" }}
>
View in Library
</button>
</div>
</>
);
}

View File

@@ -1,15 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type BrowseResult } from "../../api/client"; import { api, type BrowseResult, type MediaItem } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer"; import MediaViewer from "../MediaViewer/MediaViewer";
import DoomScrollViewer from "../DoomScrollViewer/DoomScrollViewer";
interface Props { interface Props {
libraryId: number; libraryId: number;
} }
export default function FileBrowser({ libraryId }: Props) { export default function FileBrowser({ libraryId }: Props) {
const [currentPath, setCurrentPath] = useState(""); const [searchParams] = useSearchParams();
const [libraryPaths, setLibraryPaths] = useState<Record<number, string>>({
[libraryId]: searchParams.get("path") ?? "",
});
const [viewingId, setViewingId] = useState<number | null>(null); const [viewingId, setViewingId] = useState<number | null>(null);
const [doomScrollItems, setDoomScrollItems] = useState<MediaItem[] | null>(null);
const currentPath = libraryPaths[libraryId] ?? "";
const { data, isLoading } = useQuery<BrowseResult>({ const { data, isLoading } = useQuery<BrowseResult>({
queryKey: ["browse", libraryId, currentPath], queryKey: ["browse", libraryId, currentPath],
@@ -19,10 +27,21 @@ export default function FileBrowser({ libraryId }: Props) {
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : []; const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
function navigate(relPath: string) { function navigate(relPath: string) {
setCurrentPath(relPath); setLibraryPaths((prev) => ({ ...prev, [libraryId]: relPath }));
setViewingId(null); setViewingId(null);
} }
async function startDoomScroll() {
const items = await api.libraries.doomScroll(libraryId, currentPath);
setDoomScrollItems([...items].sort(() => Math.random() - 0.5));
}
function handleViewInLibrary(item: MediaItem) {
const dirPath = item.rel_path.split("/").slice(0, -1).join("/");
navigate(dirPath);
setDoomScrollItems(null);
}
return ( return (
<div style={{ padding: "1rem" }}> <div style={{ padding: "1rem" }}>
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -41,6 +60,9 @@ export default function FileBrowser({ libraryId }: Props) {
</span> </span>
); );
})} })}
<button onClick={startDoomScroll} style={{ marginLeft: "auto", background: "var(--accent)", color: "#fff", border: "none", borderRadius: 4, padding: "4px 10px", cursor: "pointer", fontSize: 13 }}>
Doom Scroll
</button>
</nav> </nav>
{isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>} {isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>}
@@ -92,6 +114,14 @@ export default function FileBrowser({ libraryId }: Props) {
onNavigate={setViewingId} onNavigate={setViewingId}
/> />
)} )}
{doomScrollItems && (
<DoomScrollViewer
items={doomScrollItems}
onClose={() => setDoomScrollItems(null)}
onViewInLibrary={handleViewInLibrary}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type BrowseEntry, type MediaItem } from "../../api/client"; import { api, type BrowseEntry, type MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel"; import TagPanel from "../TagPanel/TagPanel";
@@ -11,6 +11,8 @@ interface Props {
} }
export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) { export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) {
const [showTags, setShowTags] = useState(true);
const { data: item } = useQuery<MediaItem>({ const { data: item } = useQuery<MediaItem>({
queryKey: ["media", mediaId], queryKey: ["media", mediaId],
queryFn: () => api.media.get(mediaId), queryFn: () => api.media.get(mediaId),
@@ -32,74 +34,80 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
}, [prevId, nextId, onClose, onNavigate]); }, [prevId, nextId, onClose, onNavigate]);
return ( return (
<div <>
onClick={onClose} {/* Backdrop */}
style={{ <div
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", onClick={onClose}
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", zIndex: 100 }}
}} />
>
{/* Prev */}
<button
onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId}
style={{ position: "fixed", left: 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
>
</button>
{/* Next */}
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ position: "fixed", right: showTags ? 276 : 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Media card */}
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{ position: "fixed", top: "50%", left: "50%", transform: "translate(-50%, -50%)", zIndex: 101, background: "#1a1a1a", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 12, maxWidth: "80vw", maxHeight: "90vh", overflow: "auto" }}
display: "flex", gap: 16, background: "#1a1a1a", borderRadius: 8,
padding: 16, maxWidth: "95vw", maxHeight: "95vh", overflow: "auto",
}}
> >
{/* Prev */} {item?.filename && (
<button <div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
>
</button>
{/* Media */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
{item?.filename && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
)}
{item?.media_type === "image" && (
<img
src={api.media.fileUrl(mediaId)}
alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "80vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
src={api.media.fileUrl(mediaId)}
controls
style={{ maxWidth: "70vw", maxHeight: "80vh" }}
/>
)}
</div>
{/* Next */}
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Tag panel */}
{item && (
<div style={{ color: "#fff", borderLeft: "1px solid #333", paddingLeft: 16, minWidth: 200 }}>
<TagPanel item={item} />
</div>
)} )}
{item?.media_type === "image" && (
<img
src={api.media.fileUrl(mediaId)}
alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "82vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
src={api.media.fileUrl(mediaId)}
controls
style={{ maxWidth: "70vw", maxHeight: "82vh" }}
/>
)}
</div>
{/* Close */} {/* Top-right controls */}
<div style={{ position: "fixed", top: 16, right: 16, zIndex: 103, display: "flex", gap: 8 }}>
<button
onClick={() => setShowTags((v) => !v)}
style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
>
</button>
<button <button
onClick={onClose} onClick={onClose}
style={{ position: "absolute", top: 16, right: 16, background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }} style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
> >
</button> </button>
</div> </div>
</div>
{/* Tag panel */}
{showTags && item && (
<div
onClick={(e) => e.stopPropagation()}
style={{ position: "fixed", top: 0, right: 0, height: "100%", width: 260, background: "#1a1a1a", borderLeft: "1px solid #333", padding: "48px 16px 16px", zIndex: 101, overflowY: "auto" }}
>
<TagPanel item={item} />
</div>
)}
</>
); );
} }

View File

@@ -17,6 +17,9 @@ export default function TagPanel({ item }: Props) {
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newCategory, setNewCategory] = useState(""); const [newCategory, setNewCategory] = useState("");
const [quickAddCategory, setQuickAddCategory] = useState<string | null>(null);
const [quickAddName, setQuickAddName] = useState("");
const [categorySearch, setCategorySearch] = useState<Record<string, string>>({});
const setTagsMutation = useMutation({ const setTagsMutation = useMutation({
mutationFn: (ids: number[]) => api.media.setTags(item.id, ids), mutationFn: (ids: number[]) => api.media.setTags(item.id, ids),
@@ -24,12 +27,15 @@ export default function TagPanel({ item }: Props) {
}); });
const createTagMutation = useMutation({ const createTagMutation = useMutation({
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()), mutationFn: ({ name, category }: { name: string; category: string }) =>
api.tags.create(name.trim(), category.trim()),
onSuccess: (tag: Tag) => { onSuccess: (tag: Tag) => {
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["tags"] });
setTagsMutation.mutate([...assignedIds, tag.id]); setTagsMutation.mutate([...assignedIds, tag.id]);
setNewName(""); setNewName("");
setNewCategory(""); setNewCategory("");
setQuickAddCategory(null);
setQuickAddName("");
}, },
}); });
@@ -40,53 +46,130 @@ export default function TagPanel({ item }: Props) {
setTagsMutation.mutate(next); setTagsMutation.mutate(next);
} }
function toggleQuickAdd(category: string) {
if (quickAddCategory === category) {
setQuickAddCategory(null);
setQuickAddName("");
} else {
setQuickAddCategory(category);
setQuickAddName("");
}
}
return ( return (
<div style={{ minWidth: 220 }}> <div style={{ minWidth: 220 }}>
<h3 style={{ margin: "0 0 12px", color: "var(--text)" }}>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 }}> const search = categorySearch[group.category] ?? "";
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginBottom: 4 }}> const visibleTags = search
{group.category} ? group.tags.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
</div> : group.tags;
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{group.tags.map((tag) => ( return (
<div key={group.category} style={{ marginBottom: 16 }}>
{/* Category header row */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)" }}>
{group.category}
</span>
<button <button
key={tag.id} onClick={() => toggleQuickAdd(group.category)}
onClick={() => toggle(tag.id)} title={`Add tag to ${group.category}`}
style={{ style={{
padding: "3px 10px", background: quickAddCategory === group.category ? "var(--accent)" : "transparent",
borderRadius: 12, color: quickAddCategory === group.category ? "#fff" : "var(--text-muted)",
border: "1px solid", border: "1px solid var(--border)",
borderRadius: 4,
padding: "1px 7px",
fontSize: 16,
lineHeight: 1,
cursor: "pointer", cursor: "pointer",
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,
}} }}
> >
{tag.name} +
</button> </button>
))} </div>
</div>
</div>
))}
<details style={{ marginTop: 16 }}> {/* Per-category search */}
<input
placeholder={`Search ${group.category.toLowerCase()}`}
value={search}
onChange={(e) =>
setCategorySearch((prev) => ({ ...prev, [group.category]: e.target.value }))
}
style={{ marginBottom: 6, fontSize: 12, padding: "3px 8px" }}
/>
{/* Tag chips */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{visibleTags.map((tag) => (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
style={{
padding: "3px 10px",
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
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,
}}
>
{tag.name}
</button>
))}
{search && visibleTags.length === 0 && (
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>No matches</span>
)}
</div>
{/* Inline quick-add form */}
{quickAddCategory === group.category && (
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<input
placeholder="Tag name"
value={quickAddName}
onChange={(e) => setQuickAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && quickAddName.trim()) {
createTagMutation.mutate({ name: quickAddName, category: group.category });
}
}}
autoFocus
style={{ flex: 1, fontSize: 12, padding: "3px 8px" }}
/>
<button
onClick={() => createTagMutation.mutate({ name: quickAddName, category: group.category })}
disabled={!quickAddName.trim() || createTagMutation.isPending}
style={{ background: "var(--accent)", color: "#fff", border: "none", padding: "3px 10px", borderRadius: 4, fontSize: 12 }}
>
Add
</button>
</div>
)}
</div>
);
})}
{/* Global new-tag form */}
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: "pointer", fontSize: 13, color: "var(--accent-text)" }}>+ 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
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<input <input
placeholder="Category" placeholder="Category"
value={newCategory} value={newCategory}
onChange={(e) => setNewCategory(e.target.value)} onChange={(e) => setNewCategory(e.target.value)}
/> />
<input
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button <button
onClick={() => createTagMutation.mutate()} onClick={() => createTagMutation.mutate({ name: newName, category: newCategory })}
disabled={!newName.trim() || !newCategory.trim() || createTagMutation.isPending} disabled={!newName.trim() || !newCategory.trim() || createTagMutation.isPending}
> >
Create & assign Create & assign

View File

@@ -1,11 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type MediaItem, type TagsByCategory } from "../api/client"; import { api, type MediaItem, type TagsByCategory, type BrowseEntry } from "../api/client";
import MediaViewer from "../components/MediaViewer/MediaViewer";
import DoomScrollViewer from "../components/DoomScrollViewer/DoomScrollViewer";
export default function SearchPage() { export default function SearchPage() {
const routerNavigate = useNavigate();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [selectedTags, setSelectedTags] = useState<number[]>([]); const [selectedTags, setSelectedTags] = useState<number[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [viewingId, setViewingId] = useState<number | null>(null);
const [doomScrollItems, setDoomScrollItems] = useState<MediaItem[] | null>(null);
function handleViewInLibrary(item: MediaItem) {
const dirPath = item.rel_path.split("/").slice(0, -1).join("/");
routerNavigate(`/library/${item.library_id}?path=${encodeURIComponent(dirPath)}`);
}
const { data: grouped = [] } = useQuery<TagsByCategory[]>({ const { data: grouped = [] } = useQuery<TagsByCategory[]>({
queryKey: ["tags"], queryKey: ["tags"],
@@ -76,9 +87,18 @@ export default function SearchPage() {
{isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>} {isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>}
{results.length > 0 && (
<button
onClick={() => setDoomScrollItems([...results].sort(() => Math.random() - 0.5))}
style={{ background: "var(--accent)", color: "#fff", border: "none", borderRadius: 4, padding: "4px 10px", cursor: "pointer", fontSize: 13, marginBottom: 16 }}
>
Doom Scroll
</button>
)}
<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 var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)" }}> <div key={item.id} onClick={() => setViewingId(item.id)} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)", cursor: "pointer" }}>
<img <img
src={api.media.thumbnailUrl(item.id)} src={api.media.thumbnailUrl(item.id)}
alt={item.filename} alt={item.filename}
@@ -104,6 +124,31 @@ export default function SearchPage() {
{submitted && !isFetching && results.length === 0 && ( {submitted && !isFetching && results.length === 0 && (
<p style={{ color: "var(--text-secondary)" }}>No results found.</p> <p style={{ color: "var(--text-secondary)" }}>No results found.</p>
)} )}
{doomScrollItems && (
<DoomScrollViewer
items={doomScrollItems}
onClose={() => setDoomScrollItems(null)}
onViewInLibrary={handleViewInLibrary}
/>
)}
{viewingId !== null && (() => {
const siblings: BrowseEntry[] = results.map((item) => ({
name: item.filename,
type: item.media_type,
rel_path: item.rel_path,
media_item_id: item.id,
}));
return (
<MediaViewer
mediaId={viewingId}
siblings={siblings}
onClose={() => setViewingId(null)}
onNavigate={(id) => setViewingId(id)}
/>
);
})()}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Library } from "../api/client"; import { api, type Library } from "../api/client";
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) { function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const qc = useQueryClient();
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["scan-status", lib.id], queryKey: ["scan-status", lib.id],
queryFn: () => api.libraries.scanStatus(lib.id), queryFn: () => api.libraries.scanStatus(lib.id),
@@ -11,6 +12,11 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
const scanning = data?.scanning ?? false; const scanning = data?.scanning ?? false;
const rescanMutation = useMutation({
mutationFn: () => api.libraries.rescan(lib.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scan-status", lib.id] }),
});
return ( return (
<li style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}> <li style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
<div> <div>
@@ -22,13 +28,22 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
)} )}
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{lib.path}</div> <div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{lib.path}</div>
</div> </div>
<button <div style={{ display: "flex", gap: 8 }}>
onClick={() => onRemove(lib.id)} <button
disabled={scanning} onClick={() => rescanMutation.mutate()}
style={{ color: scanning ? "var(--text-muted)" : "var(--danger)", background: "transparent", border: "none" }} disabled={scanning}
> style={{ color: scanning ? "var(--text-muted)" : "var(--accent)", background: "transparent", border: "none" }}
Remove >
</button> Rescan
</button>
<button
onClick={() => onRemove(lib.id)}
disabled={scanning}
style={{ color: scanning ? "var(--text-muted)" : "var(--danger)", background: "transparent", border: "none" }}
>
Remove
</button>
</div>
</li> </li>
); );
} }