diff --git a/backend/app/routers/libraries.py b/backend/app/routers/libraries.py index 2321371..4d4bda8 100644 --- a/backend/app/routers/libraries.py +++ b/backend/app/routers/libraries.py @@ -2,10 +2,11 @@ from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.database import get_db 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 router = APIRouter(prefix="/libraries", tags=["libraries"]) @@ -63,6 +64,28 @@ async def rescan_library( 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) async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)): result = await db.execute(select(Library).where(Library.id == library_id)) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0ef358c..7250a84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -71,6 +71,8 @@ export const api = { request<{ scanning: boolean }>(`/libraries/${id}/scan-status`), rescan: (id: number) => request<{ scanning: boolean }>(`/libraries/${id}/rescan`, { method: "POST" }), + doomScroll: (id: number, path = "") => + request(`/libraries/${id}/doom-scroll?path=${encodeURIComponent(path)}`), }, media: { diff --git a/frontend/src/components/DoomScrollViewer/DoomScrollViewer.tsx b/frontend/src/components/DoomScrollViewer/DoomScrollViewer.tsx new file mode 100644 index 0000000..ab8e5c8 --- /dev/null +++ b/frontend/src/components/DoomScrollViewer/DoomScrollViewer.tsx @@ -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 */} +
+ + {/* Media area */} +
+
{item?.filename}
+ {item?.media_type === "image" && ( + {item.filename} + )} + {item?.media_type === "video" && ( +
+ + {/* Bottom bar */} +
+ + + {index + 1} / {items.length} + + +
+ + ); +} diff --git a/frontend/src/components/FileBrowser/FileBrowser.tsx b/frontend/src/components/FileBrowser/FileBrowser.tsx index 6292ff3..6c1a010 100644 --- a/frontend/src/components/FileBrowser/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser/FileBrowser.tsx @@ -1,15 +1,21 @@ import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; 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 DoomScrollViewer from "../DoomScrollViewer/DoomScrollViewer"; interface Props { libraryId: number; } export default function FileBrowser({ libraryId }: Props) { - const [libraryPaths, setLibraryPaths] = useState>({}); + const [searchParams] = useSearchParams(); + const [libraryPaths, setLibraryPaths] = useState>({ + [libraryId]: searchParams.get("path") ?? "", + }); const [viewingId, setViewingId] = useState(null); + const [doomScrollItems, setDoomScrollItems] = useState(null); const currentPath = libraryPaths[libraryId] ?? ""; @@ -25,6 +31,17 @@ export default function FileBrowser({ libraryId }: Props) { 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 (
{/* Breadcrumb */} @@ -32,6 +49,9 @@ export default function FileBrowser({ libraryId }: Props) { + {pathParts.map((part, i) => { const partPath = pathParts.slice(0, i + 1).join("/"); return ( @@ -94,6 +114,14 @@ export default function FileBrowser({ libraryId }: Props) { onNavigate={setViewingId} /> )} + + {doomScrollItems && ( + setDoomScrollItems(null)} + onViewInLibrary={handleViewInLibrary} + /> + )}
); } diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index d0a339e..573606b 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,13 +1,22 @@ import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; 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() { + const routerNavigate = useNavigate(); const [q, setQ] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [submitted, setSubmitted] = useState(false); const [viewingId, setViewingId] = useState(null); + const [doomScrollItems, setDoomScrollItems] = useState(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({ queryKey: ["tags"], @@ -78,6 +87,15 @@ export default function SearchPage() { {isFetching &&

Searching…

} + {results.length > 0 && ( + + )} +
{results.map((item) => (
setViewingId(item.id)} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)", cursor: "pointer" }}> @@ -107,6 +125,14 @@ export default function SearchPage() {

No results found.

)} + {doomScrollItems && ( + setDoomScrollItems(null)} + onViewInLibrary={handleViewInLibrary} + /> + )} + {viewingId !== null && (() => { const siblings: BrowseEntry[] = results.map((item) => ({ name: item.filename,