add doom scroll

This commit is contained in:
2026-05-16 16:51:55 -04:00
parent 8ada8af62f
commit 8daf4e013b
5 changed files with 189 additions and 3 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"])
@@ -63,6 +64,28 @@ async def rescan_library(
return {"scanning": True} 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))

View File

@@ -71,6 +71,8 @@ export const api = {
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`), request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
rescan: (id: number) => rescan: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/rescan`, { method: "POST" }), 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,21 @@
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 [libraryPaths, setLibraryPaths] = useState<Record<number, string>>({}); 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 currentPath = libraryPaths[libraryId] ?? "";
@@ -25,6 +31,17 @@ export default function FileBrowser({ libraryId }: Props) {
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 */}
@@ -32,6 +49,9 @@ export default function FileBrowser({ libraryId }: Props) {
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600, color: "var(--text)", padding: "2px 4px" }}> <button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600, color: "var(--text)", padding: "2px 4px" }}>
Root Root
</button> </button>
<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>
{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 (
@@ -94,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,13 +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, type BrowseEntry } from "../api/client"; import { api, type MediaItem, type TagsByCategory, type BrowseEntry } from "../api/client";
import MediaViewer from "../components/MediaViewer/MediaViewer"; 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 [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"],
@@ -78,6 +87,15 @@ 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} onClick={() => setViewingId(item.id)} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)", cursor: "pointer" }}> <div key={item.id} onClick={() => 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() {
<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 && (() => { {viewingId !== null && (() => {
const siblings: BrowseEntry[] = results.map((item) => ({ const siblings: BrowseEntry[] = results.map((item) => ({
name: item.filename, name: item.filename,