Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download
Backend: - User.role (user/uploader/staff) with can_upload; uploads and downloads gated to uploader+; owners and staff can edit their items - MediaItem.uploaded_by plus J-<id> identity (serializer, admin, scan_files --user, first superuser as default owner) - API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup return j_ids - Guest safety: mirror e621's anonymous default blacklist into Redis (parses comments, negations and wildcards), flag hidden_from_guests and filter lists, details and lookups for anonymous users - POST /api/online/downloads/ writes an e621 file into the watched folder and indexes it for the uploader - MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL driver shim, Redis cache replacing the file cache; SQLite data dumped and loaded into MariaDB Frontend: - Single /detail/:itemId route with an adaptive shell: J-<id> renders the library item, bare numbers render the e621 post - Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs - Cards expose J-IDs; library custom-data editor is read-only for non-owners - Role gating: Upload hidden/blocked for regular users, account shows the role, guest hint on the library
This commit is contained in:
+17
-5
@@ -1,14 +1,20 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { FullPageSpinner } from "@/components/ui";
|
||||
import AccountPage from "@/features/account/AccountPage";
|
||||
import LoginPage from "@/features/auth/LoginPage";
|
||||
import RegisterPage from "@/features/auth/RegisterPage";
|
||||
import FileDetailPage from "@/features/library/FileDetailPage";
|
||||
import DetailPage from "@/features/detail/DetailPage";
|
||||
import LibraryPage from "@/features/library/LibraryPage";
|
||||
import OnlineDetailPage from "@/features/online/OnlineDetailPage";
|
||||
import Md5Redirect from "@/features/library/Md5Redirect";
|
||||
import OnlinePage from "@/features/online/OnlinePage";
|
||||
import UploadPage from "@/features/upload/UploadPage";
|
||||
import { useAuth } from "@/store/auth";
|
||||
@@ -22,6 +28,11 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function PostRedirect() {
|
||||
const { postId = "" } = useParams();
|
||||
return <Navigate to={`/detail/${postId}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const ready = useAuth((state) => state.ready);
|
||||
const loadUser = useAuth((state) => state.loadUser);
|
||||
@@ -38,9 +49,10 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<LibraryPage />} />
|
||||
<Route path="/view/:md5" element={<FileDetailPage />} />
|
||||
<Route path="/detail/:itemId" element={<DetailPage />} />
|
||||
<Route path="/view/:md5" element={<Md5Redirect />} />
|
||||
<Route path="/online" element={<OnlinePage />} />
|
||||
<Route path="/online/view/:id" element={<OnlineDetailPage />} />
|
||||
<Route path="/online/view/:postId" element={<PostRedirect />} />
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
|
||||
@@ -13,12 +13,6 @@ import { useAuth } from "@/store/auth";
|
||||
import { useE621 } from "@/store/e621";
|
||||
import { useUi } from "@/store/ui";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Library" },
|
||||
{ to: "/online", label: "Online" },
|
||||
{ to: "/upload", label: "Upload" },
|
||||
];
|
||||
|
||||
export function AppShell() {
|
||||
const user = useAuth((state) => state.user);
|
||||
const logout = useAuth((state) => state.logout);
|
||||
@@ -63,6 +57,12 @@ export function AppShell() {
|
||||
const showSidebarToggle =
|
||||
location.pathname === "/" || location.pathname === "/online";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Library" },
|
||||
{ to: "/online", label: "Online" },
|
||||
...(user?.can_upload ? [{ to: "/upload", label: "Upload" }] : []),
|
||||
];
|
||||
|
||||
const navigationType = useNavigationType();
|
||||
useEffect(() => {
|
||||
// New navigations start at the top; history back/forward keeps position.
|
||||
|
||||
@@ -51,12 +51,16 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
icon: FolderOpen,
|
||||
run: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
label: "Go to Upload",
|
||||
icon: Upload,
|
||||
run: () => navigate("/upload"),
|
||||
},
|
||||
...(user?.can_upload
|
||||
? [
|
||||
{
|
||||
id: "upload",
|
||||
label: "Go to Upload",
|
||||
icon: Upload,
|
||||
run: () => navigate("/upload"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "online",
|
||||
label: "Go to Online browser",
|
||||
|
||||
@@ -19,12 +19,12 @@ const ratingLabels: Record<string, string> = {
|
||||
export function MediaCard({ item }: { item: MediaItem }) {
|
||||
const preview =
|
||||
item.kind === "video"
|
||||
? `/api/files/${item.md5}/thumbnail/`
|
||||
: `/api/files/${item.md5}/raw/`;
|
||||
? `/api/files/${item.j_id}/thumbnail/`
|
||||
: `/api/files/${item.j_id}/raw/`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/view/${item.md5}`}
|
||||
to={`/detail/${item.j_id}`}
|
||||
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||
@@ -54,7 +54,11 @@ export function MediaCard({ item }: { item: MediaItem }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2.5 py-2">
|
||||
<p className="truncate font-mono text-xs text-ctp-subtext1" title={item.filename}>
|
||||
<p className="font-mono text-[10px] text-ctp-overlay0">{item.j_id}</p>
|
||||
<p
|
||||
className="truncate font-mono text-xs text-ctp-subtext1"
|
||||
title={item.filename}
|
||||
>
|
||||
{item.filename}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,9 @@ export function Field({
|
||||
export const inputClass =
|
||||
"w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-mauve focus:outline-none";
|
||||
|
||||
export const linkButtonClass =
|
||||
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -230,8 +230,11 @@ export default function AccountPage() {
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Account</h1>
|
||||
{user ? (
|
||||
<p className="mt-1 text-sm text-ctp-overlay0">
|
||||
Signed in as {user.username}.
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-ctp-overlay0">
|
||||
<span>Signed in as {user.username}.</span>
|
||||
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
|
||||
{user.role}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { EmptyState } from "@/components/ui";
|
||||
import { LibraryDetail } from "@/features/library/LibraryDetail";
|
||||
import { OnlineDetail } from "@/features/online/OnlineDetail";
|
||||
|
||||
const J_ID_RE = /^J-\d+$/i;
|
||||
|
||||
export default function DetailPage() {
|
||||
const { itemId = "" } = useParams();
|
||||
|
||||
if (J_ID_RE.test(itemId)) {
|
||||
const normalized = `J-${itemId.slice(2)}`;
|
||||
return <LibraryDetail jId={normalized} />;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(itemId)) {
|
||||
return <OnlineDetail postId={Number(itemId)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="Unknown item"
|
||||
description={
|
||||
<span className="font-mono text-xs">
|
||||
Use /detail/J-<id> for library items or /detail/<id> for
|
||||
e621 posts.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function DetailShell({
|
||||
breadcrumb,
|
||||
actions,
|
||||
media,
|
||||
aside,
|
||||
footer,
|
||||
}: {
|
||||
breadcrumb: ReactNode;
|
||||
actions?: ReactNode;
|
||||
media: ReactNode;
|
||||
aside: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{breadcrumb}
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="order-2 flex flex-col gap-4 lg:order-1">{aside}</aside>
|
||||
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
|
||||
{actions}
|
||||
{media}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+101
-47
@@ -1,15 +1,17 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
|
||||
import { DetailShell } from "@/features/detail/DetailShell";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isTypingTarget } from "@/lib/dom";
|
||||
import { formatBytes, formatDate } from "@/lib/format";
|
||||
import { useUi } from "@/store/ui";
|
||||
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useUi } from "@/store/ui";
|
||||
|
||||
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||
{
|
||||
@@ -34,7 +36,13 @@ const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function CustomDataCard({ item }: { item: MediaItem }) {
|
||||
const ratingLabel: Record<string, string> = {
|
||||
s: "Safe",
|
||||
q: "Questionable",
|
||||
e: "Explicit",
|
||||
};
|
||||
|
||||
function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rating, setRating] = useState<Rating>(item.rating);
|
||||
const [tags, setTags] = useState(item.tags.join(", "));
|
||||
@@ -42,16 +50,49 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
||||
api<MediaItem>(`/api/files/${item.md5}/`, {
|
||||
api<MediaItem>(`/api/files/${item.j_id}/`, {
|
||||
method: "PATCH",
|
||||
json: payload,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["file", item.md5] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Custom data</h2>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-ctp-overlay0">Rating</dt>
|
||||
<dd className="text-ctp-subtext0">
|
||||
{item.rating ? ratingLabel[item.rating] : "None"}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Tags</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{item.tags.length > 0 ? (
|
||||
item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-ctp-subtext0">—</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Notes</dt>
|
||||
<dd className="whitespace-pre-line text-ctp-subtext0">
|
||||
{item.notes || "—"}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function save() {
|
||||
mutation.mutate({
|
||||
rating,
|
||||
@@ -114,25 +155,23 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDetailPage() {
|
||||
const { md5 = "" } = useParams();
|
||||
export function LibraryDetail({ jId }: { jId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const ordering = useUi((state) => state.ordering);
|
||||
const rawUrl = `/api/files/${md5}/raw/`;
|
||||
const user = useAuth((state) => state.user);
|
||||
const rawUrl = `/api/files/${jId}/raw/`;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["file", md5],
|
||||
queryFn: () => api<MediaItem>(`/api/files/${md5}/`),
|
||||
enabled: md5.length > 0,
|
||||
queryKey: ["file", jId],
|
||||
queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
|
||||
});
|
||||
|
||||
const neighborsQuery = useQuery({
|
||||
queryKey: ["neighbors", md5, ordering],
|
||||
queryKey: ["neighbors", jId, ordering],
|
||||
queryFn: () =>
|
||||
api<FileNeighbors>(
|
||||
`/api/files/${md5}/neighbors/?ordering=${encodeURIComponent(ordering)}`,
|
||||
`/api/files/${jId}/neighbors/?ordering=${encodeURIComponent(ordering)}`,
|
||||
),
|
||||
enabled: md5.length > 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,9 +185,9 @@ export default function FileDetailPage() {
|
||||
}
|
||||
const neighbors = neighborsQuery.data;
|
||||
if (event.key === "[" && neighbors?.previous) {
|
||||
navigate(`/view/${neighbors.previous.md5}`);
|
||||
navigate(`/detail/${neighbors.previous.j_id}`);
|
||||
} else if (event.key === "]" && neighbors?.next) {
|
||||
navigate(`/view/${neighbors.next.md5}`);
|
||||
navigate(`/detail/${neighbors.next.j_id}`);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
@@ -177,27 +216,39 @@ export default function FileDetailPage() {
|
||||
}
|
||||
|
||||
const item = query.data;
|
||||
const canEdit = Boolean(
|
||||
user &&
|
||||
(user.id === item.uploaded_by_id ||
|
||||
user.is_staff ||
|
||||
user.role === "staff"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Library
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="truncate font-mono text-xs text-ctp-overlay0">
|
||||
{item.filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<DetailShell
|
||||
breadcrumb={
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Library
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="font-mono text-xs text-ctp-overlay0">{item.j_id}</span>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href={`${rawUrl}?download=1`} className={linkButtonClass}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
media={
|
||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||
{item.kind === "video" ? (
|
||||
<video
|
||||
key={item.md5}
|
||||
key={item.j_id}
|
||||
src={rawUrl}
|
||||
controls
|
||||
autoPlay
|
||||
@@ -207,15 +258,16 @@ export default function FileDetailPage() {
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={item.md5}
|
||||
key={item.j_id}
|
||||
src={rawUrl}
|
||||
alt={item.filename}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
}
|
||||
aside={
|
||||
<>
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h1
|
||||
className="truncate font-mono text-sm text-ctp-subtext1"
|
||||
@@ -224,6 +276,8 @@ export default function FileDetailPage() {
|
||||
{item.filename}
|
||||
</h1>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
||||
<dt className="text-ctp-overlay0">ID</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">{item.j_id}</dd>
|
||||
<dt className="text-ctp-overlay0">MD5</dt>
|
||||
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
|
||||
{item.md5}
|
||||
@@ -238,20 +292,14 @@ export default function FileDetailPage() {
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Added</dt>
|
||||
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
|
||||
<dt className="text-ctp-overlay0">Uploaded by</dt>
|
||||
<dd className="text-ctp-subtext0">{item.uploaded_by ?? "—"}</dd>
|
||||
</dl>
|
||||
<a
|
||||
href={`${rawUrl}?download=1`}
|
||||
className="mt-4 inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"
|
||||
>
|
||||
<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} />
|
||||
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Locations ({item.locations.length})
|
||||
@@ -268,8 +316,14 @@ export default function FileDetailPage() {
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomDataCard
|
||||
key={`${item.j_id}-${item.updated_at}`}
|
||||
item={item}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isTypingTarget } from "@/lib/dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useUi } from "@/store/ui";
|
||||
import type { MediaItem, Paginated } from "@/lib/types";
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function LibraryPage() {
|
||||
const setPerPage = useUi((state) => state.setPerPage);
|
||||
const ordering = useUi((state) => state.ordering);
|
||||
const setOrdering = useUi((state) => state.setOrdering);
|
||||
const user = useAuth((state) => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -214,6 +216,12 @@ export default function LibraryPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!user ? (
|
||||
<p className="mb-4 rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-overlay0">
|
||||
Browsing as a guest — some items may be hidden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{query.isPending ? (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
|
||||
import { EmptyState, Spinner } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
|
||||
const MD5_RE = /^[0-9a-fA-F]{32}$/;
|
||||
|
||||
export default function Md5Redirect() {
|
||||
const { md5 = "" } = useParams();
|
||||
const valid = MD5_RE.test(md5);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["resolve-md5", md5.toLowerCase()],
|
||||
queryFn: () => api<MediaItem>(`/api/files/${md5.toLowerCase()}/`),
|
||||
enabled: valid,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (valid && query.isPending) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || !query.data) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="File not found"
|
||||
description="This link may be outdated, or the file is not available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Navigate to={`/detail/${query.data.j_id}`} replace />;
|
||||
}
|
||||
+150
-102
@@ -8,10 +8,11 @@ import {
|
||||
HeartOff,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner } from "@/components/ui";
|
||||
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
|
||||
import { TagChip } from "@/components/TagChip";
|
||||
import { DetailShell } from "@/features/detail/DetailShell";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { renderDText } from "@/lib/dtext";
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
TAG_CATEGORY_ORDER,
|
||||
} from "@/lib/e621";
|
||||
import { formatBytes, formatDate } from "@/lib/format";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useE621 } from "@/store/e621";
|
||||
|
||||
const ratingText: Record<string, string> = {
|
||||
@@ -40,17 +43,18 @@ const relatedBorder: Record<string, string> = {
|
||||
e: "border-ctp-red/25 hover:border-ctp-red/60",
|
||||
};
|
||||
|
||||
const linkButtonClass =
|
||||
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
|
||||
interface LibraryLookup {
|
||||
found: string[];
|
||||
j_ids: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function OnlineDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const postId = Number(id);
|
||||
export function OnlineDetail({ postId }: { postId: number }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = (location.state as { from?: string } | null)?.from ?? "/online";
|
||||
const queryClient = useQueryClient();
|
||||
const credentials = useE621((state) => state.credentials);
|
||||
const user = useAuth((state) => state.user);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
|
||||
@@ -91,14 +95,16 @@ export default function OnlineDetailPage() {
|
||||
const lookupQuery = useQuery({
|
||||
queryKey: ["library-lookup", post?.file.md5 ?? ""],
|
||||
queryFn: () =>
|
||||
api<{ found: string[] }>("/api/files/lookup/", {
|
||||
api<LibraryLookup>("/api/files/lookup/", {
|
||||
method: "POST",
|
||||
json: { md5s: [post?.file.md5] },
|
||||
}),
|
||||
enabled: Boolean(post?.file.md5),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0;
|
||||
const libraryJId = post
|
||||
? lookupQuery.data?.j_ids?.[post.file.md5]
|
||||
: undefined;
|
||||
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: (favorite: boolean) =>
|
||||
@@ -110,6 +116,23 @@ export default function OnlineDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (target: NonNullable<typeof post>) =>
|
||||
api<MediaItem>("/api/online/downloads/", {
|
||||
method: "POST",
|
||||
json: {
|
||||
post_id: target.id,
|
||||
url: target.file.url,
|
||||
filename: `${target.file.md5}.${target.file.ext}`,
|
||||
},
|
||||
}),
|
||||
onSuccess: (item) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
navigate(`/detail/${item.j_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
@@ -138,24 +161,119 @@ export default function OnlineDetailPage() {
|
||||
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
|
||||
const imageSrc =
|
||||
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
||||
const canUpload = Boolean(user?.can_upload);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to={from}
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Online
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="font-mono text-xs text-ctp-overlay0">
|
||||
Post #{post.id}
|
||||
</span>
|
||||
</div>
|
||||
<DetailShell
|
||||
breadcrumb={
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to={from}
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Online
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="font-mono text-xs text-ctp-overlay0">
|
||||
Post #{post.id}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={post.is_favorited ? "secondary" : "primary"}
|
||||
disabled={!credentials?.configured || favoriteMutation.isPending}
|
||||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||||
title={
|
||||
credentials?.configured
|
||||
? undefined
|
||||
: "Add e621 credentials in Account to favorite posts"
|
||||
}
|
||||
>
|
||||
{post.is_favorited ? (
|
||||
<HeartOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Heart className="h-4 w-4" />
|
||||
)}
|
||||
{favoriteMutation.isPending
|
||||
? "Saving…"
|
||||
: post.is_favorited
|
||||
? "Unfavorite"
|
||||
: "Favorite"}
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="order-2 flex flex-col gap-4 lg:order-1">
|
||||
{libraryJId ? (
|
||||
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
|
||||
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!canUpload || downloadMutation.isPending || !post.file.url
|
||||
}
|
||||
onClick={() => downloadMutation.mutate(post)}
|
||||
title={
|
||||
canUpload
|
||||
? undefined
|
||||
: "Your account is not allowed to add to the library"
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{downloadMutation.isPending
|
||||
? "Downloading…"
|
||||
: "Download to Library"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`https://e621.net/posts/${post.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkButtonClass}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> View on e621
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{favoriteMutation.isError ? (
|
||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||
{e621ErrorMessage(favoriteMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{downloadMutation.isError ? (
|
||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||
{e621ErrorMessage(downloadMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
media={
|
||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||
{isVideo ? (
|
||||
<video
|
||||
key={post.id}
|
||||
src={post.file.url ?? undefined}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={post.id}
|
||||
src={imageSrc ?? undefined}
|
||||
alt={`Post #${post.id}`}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
aside={
|
||||
<>
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -226,81 +344,11 @@ export default function OnlineDetailPage() {
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{inLibrary ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-ctp-green">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> In your library
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={post.is_favorited ? "secondary" : "primary"}
|
||||
disabled={!credentials?.configured || favoriteMutation.isPending}
|
||||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||||
title={
|
||||
credentials?.configured
|
||||
? undefined
|
||||
: "Add e621 credentials in Account to favorite posts"
|
||||
}
|
||||
>
|
||||
{post.is_favorited ? (
|
||||
<HeartOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Heart className="h-4 w-4" />
|
||||
)}
|
||||
{favoriteMutation.isPending
|
||||
? "Saving…"
|
||||
: post.is_favorited
|
||||
? "Unfavorite"
|
||||
: "Favorite"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled
|
||||
title="Coming with the Download to Library update"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download to Library
|
||||
</Button>
|
||||
<a
|
||||
href={`https://e621.net/posts/${post.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkButtonClass}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> View on e621
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{favoriteMutation.isError ? (
|
||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||
{e621ErrorMessage(favoriteMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||
{isVideo ? (
|
||||
<video
|
||||
key={post.id}
|
||||
src={post.file.url ?? undefined}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={post.id}
|
||||
src={imageSrc ?? undefined}
|
||||
alt={`Post #${post.id}`}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
{post.description.trim() ? (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<details>
|
||||
@@ -368,7 +416,7 @@ export default function OnlineDetailPage() {
|
||||
{(relatedQuery.data ?? []).map((related) => (
|
||||
<Link
|
||||
key={related.id}
|
||||
to={`/online/view/${related.id}`}
|
||||
to={`/detail/${related.id}`}
|
||||
state={{ from }}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
||||
@@ -394,8 +442,8 @@ export default function OnlineDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function PostCard({
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/online/view/${post.id}`}
|
||||
to={`/detail/${post.id}`}
|
||||
state={from ? { from } : undefined}
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
||||
|
||||
@@ -22,7 +22,7 @@ export function PostThumb({ postId }: { postId: number }) {
|
||||
return (
|
||||
<span className="my-1 inline-block align-top">
|
||||
<Link
|
||||
to={`/online/view/${postId}`}
|
||||
to={`/detail/${postId}`}
|
||||
title={`Post #${postId}`}
|
||||
className="group inline-block"
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button, EmptyState } from "@/components/ui";
|
||||
import { errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||
|
||||
@@ -19,6 +20,7 @@ interface UploadEntry {
|
||||
|
||||
export default function UploadPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuth((state) => state.user);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
||||
@@ -81,6 +83,20 @@ export default function UploadPage() {
|
||||
setEntries((current) => current.filter((entry) => entry.status !== "done"));
|
||||
}
|
||||
|
||||
if (user && !user.can_upload) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Upload</h1>
|
||||
</header>
|
||||
<EmptyState
|
||||
title="Uploads are restricted"
|
||||
description="Your account doesn't have the uploader role yet. Ask an admin to grant it."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<header>
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface User {
|
||||
email: string;
|
||||
is_staff: boolean;
|
||||
date_joined: string;
|
||||
role: "user" | "uploader" | "staff";
|
||||
can_upload: boolean;
|
||||
e621_username: string;
|
||||
e621_configured: boolean;
|
||||
}
|
||||
|
||||
export interface MediaLocation {
|
||||
@@ -16,6 +20,8 @@ export interface MediaLocation {
|
||||
export type Rating = "" | "s" | "q" | "e";
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
j_id: string;
|
||||
md5: string;
|
||||
size: number;
|
||||
rating: Rating;
|
||||
@@ -24,6 +30,8 @@ export interface MediaItem {
|
||||
filename: string;
|
||||
extension: string;
|
||||
kind: "image" | "video";
|
||||
uploaded_by: string | null;
|
||||
uploaded_by_id: number | null;
|
||||
locations: MediaLocation[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -56,6 +64,7 @@ export interface SystemStatus {
|
||||
}
|
||||
|
||||
export interface FileNeighbor {
|
||||
j_id: string;
|
||||
md5: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user