Shell shortcuts: /, D, [ ], Ctrl+K palette + neighbors endpoint
Backend:
- GET /api/files/{md5}/neighbors/ returns previous/next items in the
current ordering (name, size, created_at) for keyboard navigation
Frontend:
- / focuses the library search input
- D downloads the file on the detail page
- [ and ] navigate to the previous/next item; hint shown on the page
- Ctrl/Cmd+K opens a command palette (navigation, toggle filters,
focus search, log in/out); Escape closes it
- Sort order now persists alongside the other library filters so
prev/next stays consistent
This commit is contained in:
@@ -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<MediaItem>(`/api/files/${md5}/`),
|
||||
enabled: md5.length > 0,
|
||||
});
|
||||
|
||||
const neighborsQuery = useQuery({
|
||||
queryKey: ["neighbors", md5, ordering],
|
||||
queryFn: () =>
|
||||
api<FileNeighbors>(
|
||||
`/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 (
|
||||
<div className="flex justify-center py-24">
|
||||
@@ -142,7 +177,6 @@ export default function FileDetailPage() {
|
||||
}
|
||||
|
||||
const item = query.data;
|
||||
const rawUrl = `/api/files/${item.md5}/raw/`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -211,6 +245,9 @@ export default function FileDetailPage() {
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</a>
|
||||
<p className="mt-3 font-mono text-[11px] text-ctp-overlay0">
|
||||
[ ] navigate · D download
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CustomDataCard key={item.updated_at} item={item} />
|
||||
|
||||
@@ -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() {
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
|
||||
<input
|
||||
id="library-search"
|
||||
className={cn(inputClass, "pl-8")}
|
||||
placeholder="Filename…"
|
||||
value={searchInput}
|
||||
|
||||
Reference in New Issue
Block a user