diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index e9922f6..b486ea3 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -13,6 +13,8 @@ from . import services from .models import MediaItem from .serializers import MediaItemSerializer +LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"} + class MediaItemViewSet( mixins.ListModelMixin, @@ -39,8 +41,9 @@ class MediaItemViewSet( if ratings: queryset = queryset.filter(rating__in=ratings) ordering = self.request.query_params.get("ordering", "").strip() - allowed = {"name", "-name", "size", "-size", "created_at", "-created_at"} - queryset = queryset.order_by(ordering if ordering in allowed else "-created_at") + queryset = queryset.order_by( + ordering if ordering in LIST_ORDERINGS else "-created_at" + ) return queryset.distinct() @action(detail=True, methods=["get"]) @@ -76,6 +79,47 @@ class MediaItemViewSet( return services.serve_file(request, thumbnail) return services.serve_file(request, path) + @action(detail=True, methods=["get"]) + def neighbors(self, request, md5=None): + """Previous/next items in the ordered list, for keyboard navigation.""" + item = self.get_object() + ordering = request.query_params.get("ordering", "-created_at").strip() + if ordering not in LIST_ORDERINGS: + ordering = "-created_at" + field = ordering.lstrip("-") + descending = ordering.startswith("-") + value = { + "size": item.size, + "created_at": item.created_at, + "name": item.name, + }[field] + + queryset = self.get_queryset().exclude(pk=item.pk) + if descending: + previous_query = queryset.filter(**{f"{field}__gt": value}).order_by(field) + next_query = queryset.filter(**{f"{field}__lt": value}).order_by(f"-{field}") + else: + previous_query = queryset.filter(**{f"{field}__lt": value}).order_by( + f"-{field}" + ) + next_query = queryset.filter(**{f"{field}__gt": value}).order_by(field) + + def brief(obj): + location = obj.locations.first() + return { + "md5": obj.md5, + "filename": Path(location.rel_path).name if location else obj.md5, + } + + previous = previous_query.first() + next_item = next_query.first() + return Response( + { + "previous": brief(previous) if previous else None, + "next": brief(next_item) if next_item else None, + } + ) + class UploadView(APIView): permission_classes = [IsAuthenticated] diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1db3aba..d523c60 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { LogOut, Menu } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; +import { CommandPalette } from "@/components/CommandPalette"; import { StatusFooter } from "@/components/StatusFooter"; import { StatusPill } from "@/components/StatusPill"; import { api } from "@/lib/api"; @@ -21,6 +22,7 @@ export function AppShell() { const logout = useAuth((state) => state.logout); const toggleSidebar = useUi((state) => state.toggleSidebar); const location = useLocation(); + const [paletteOpen, setPaletteOpen] = useState(false); const statusQuery = useQuery({ queryKey: ["status"], @@ -32,9 +34,19 @@ export function AppShell() { useEffect(() => { function handleKeyDown(event: KeyboardEvent) { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") { + const modifier = event.ctrlKey || event.metaKey; + if (modifier && event.key.toLowerCase() === "b") { event.preventDefault(); toggleSidebar(); + return; + } + if (modifier && event.key.toLowerCase() === "k") { + event.preventDefault(); + setPaletteOpen((open) => !open); + return; + } + if (event.key === "Escape") { + setPaletteOpen(false); } } window.addEventListener("keydown", handleKeyDown); @@ -122,6 +134,7 @@ export function AppShell() { + setPaletteOpen(false)} /> ); } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..a6e2da9 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,180 @@ +import { + FolderOpen, + LogIn, + LogOut, + Menu, + Search, + Upload, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { cn } from "@/lib/cn"; +import { useAuth } from "@/store/auth"; +import { useUi } from "@/store/ui"; + +interface Command { + id: string; + label: string; + hint?: string; + icon: LucideIcon; + run: () => void; +} + +export function CommandPalette({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + if (!open) return null; + return ; +} + +function CommandPaletteDialog({ onClose }: { onClose: () => void }) { + const navigate = useNavigate(); + const user = useAuth((state) => state.user); + const logout = useAuth((state) => state.logout); + const toggleSidebar = useUi((state) => state.toggleSidebar); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const commands = useMemo(() => { + const list: Command[] = [ + { + id: "library", + label: "Go to Library", + icon: FolderOpen, + run: () => navigate("/"), + }, + { + id: "upload", + label: "Go to Upload", + icon: Upload, + run: () => navigate("/upload"), + }, + { + id: "filters", + label: "Toggle filters", + hint: "Ctrl+B", + icon: Menu, + run: toggleSidebar, + }, + { + id: "search", + label: "Focus library search", + hint: "/", + icon: Search, + run: () => { + navigate("/"); + window.requestAnimationFrame(() => { + document.getElementById("library-search")?.focus(); + }); + }, + }, + ]; + if (user) { + list.push({ + id: "logout", + label: `Log out (${user.username})`, + icon: LogOut, + run: () => void logout(), + }); + } else { + list.push({ + id: "login", + label: "Log in", + icon: LogIn, + run: () => navigate("/login"), + }); + } + return list; + }, [navigate, toggleSidebar, user, logout]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + return needle + ? commands.filter((command) => + command.label.toLowerCase().includes(needle), + ) + : commands; + }, [commands, query]); + + function runCommand(command: Command | undefined) { + if (!command) return; + onClose(); + command.run(); + } + + return ( +
+
event.stopPropagation()} + > + { + setQuery(event.target.value); + setActiveIndex(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => + Math.min(index + 1, filtered.length - 1), + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + runCommand(filtered[activeIndex]); + } else if (event.key === "Escape") { + onClose(); + } + }} + placeholder="Type a command…" + className="w-full border-b border-ctp-surface1 bg-transparent px-4 py-3 font-mono text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:outline-none" + /> +
    + {filtered.length === 0 ? ( +
  • + No matching commands +
  • + ) : ( + filtered.map((command, index) => ( +
  • + +
  • + )) + )} +
+
+
+ ); +} diff --git a/frontend/src/features/library/FileDetailPage.tsx b/frontend/src/features/library/FileDetailPage.tsx index e4c0d87..cc37125 100644 --- a/frontend/src/features/library/FileDetailPage.tsx +++ b/frontend/src/features/library/FileDetailPage.tsx @@ -1,13 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Download } from "lucide-react"; -import { useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { api, errorMessage } from "@/lib/api"; import { cn } from "@/lib/cn"; +import { isTypingTarget } from "@/lib/dom"; import { formatBytes, formatDate } from "@/lib/format"; -import type { MediaItem, Rating } from "@/lib/types"; +import { useUi } from "@/store/ui"; +import type { FileNeighbors, MediaItem, Rating } from "@/lib/types"; const ratingChoices: { value: Rating; label: string; active: string }[] = [ { @@ -114,12 +116,45 @@ function CustomDataCard({ item }: { item: MediaItem }) { export default function FileDetailPage() { const { md5 = "" } = useParams(); + const navigate = useNavigate(); + const ordering = useUi((state) => state.ordering); + const rawUrl = `/api/files/${md5}/raw/`; + const query = useQuery({ queryKey: ["file", md5], queryFn: () => api(`/api/files/${md5}/`), enabled: md5.length > 0, }); + const neighborsQuery = useQuery({ + queryKey: ["neighbors", md5, ordering], + queryFn: () => + api( + `/api/files/${md5}/neighbors/?ordering=${encodeURIComponent(ordering)}`, + ), + enabled: md5.length > 0, + }); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + if (event.key === "d" || event.key === "D") { + event.preventDefault(); + window.location.href = `${rawUrl}?download=1`; + return; + } + const neighbors = neighborsQuery.data; + if (event.key === "[" && neighbors?.previous) { + navigate(`/view/${neighbors.previous.md5}`); + } else if (event.key === "]" && neighbors?.next) { + navigate(`/view/${neighbors.next.md5}`); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [neighborsQuery.data, navigate, rawUrl]); + if (query.isPending) { return (
@@ -142,7 +177,6 @@ export default function FileDetailPage() { } const item = query.data; - const rawUrl = `/api/files/${item.md5}/raw/`; return (
@@ -211,6 +245,9 @@ export default function FileDetailPage() { > Download +

+ [ ] navigate · D download +

diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx index c99c60a..b238a0f 100644 --- a/frontend/src/features/library/LibraryPage.tsx +++ b/frontend/src/features/library/LibraryPage.tsx @@ -1,11 +1,12 @@ import { useQuery } from "@tanstack/react-query"; import { Search, X } from "lucide-react"; -import { useState, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import { MediaCard } from "@/components/MediaCard"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { api } from "@/lib/api"; import { cn } from "@/lib/cn"; +import { isTypingTarget } from "@/lib/dom"; import { useUi } from "@/store/ui"; import type { MediaItem, Paginated } from "@/lib/types"; @@ -41,7 +42,6 @@ const ratingFilters = [ export default function LibraryPage() { const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); - const [ordering, setOrdering] = useState("-created_at"); const [page, setPage] = useState(1); const sidebarOpen = useUi((state) => state.sidebarOpen); @@ -50,6 +50,21 @@ export default function LibraryPage() { const setRatings = useUi((state) => state.setRatings); const perPage = useUi((state) => state.perPage); const setPerPage = useUi((state) => state.setPerPage); + const ordering = useUi((state) => state.ordering); + const setOrdering = useUi((state) => state.setOrdering); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== "/" || event.ctrlKey || event.metaKey || event.altKey) { + return; + } + if (isTypingTarget(event.target)) return; + event.preventDefault(); + document.getElementById("library-search")?.focus(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); const query = useQuery({ queryKey: ["files", { search, ratings, ordering, perPage, page }], @@ -100,6 +115,7 @@ export default function LibraryPage() {
void; perPage: number; setPerPage: (perPage: number) => void; + ordering: string; + setOrdering: (ordering: string) => void; } export const useUi = create()( @@ -23,6 +25,9 @@ export const useUi = create()( perPage: 48, setPerPage: (perPage) => set({ perPage }), + + ordering: "-created_at", + setOrdering: (ordering) => set({ ordering }), }), { name: "j621.ui" }, ),