Files
J621/frontend/src/components/ui.tsx
T
JakeBreath e5cc63b0cc 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
2026-09-17 10:16:45 -05:00

94 lines
2.7 KiB
TypeScript

import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/cn";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-ctp-mauve text-ctp-crust hover:brightness-110",
secondary:
"border border-ctp-surface1 bg-ctp-surface0 text-ctp-text hover:bg-ctp-surface1",
ghost: "text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text",
danger: "border border-ctp-red/40 text-ctp-red hover:bg-ctp-red/15",
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
}
export function Button({ variant = "primary", className, ...props }: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ctp-mauve",
"active:scale-[.98] disabled:pointer-events-none disabled:opacity-50",
buttonVariants[variant],
className,
)}
{...props}
/>
);
}
export function Spinner({ className }: { className?: string }) {
return (
<div
className={cn(
"h-4 w-4 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-mauve",
className,
)}
role="status"
aria-label="Loading"
/>
);
}
export function FullPageSpinner() {
return (
<div className="flex min-h-screen items-center justify-center bg-ctp-crust">
<Spinner className="h-6 w-6" />
</div>
);
}
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
{label}
</span>
{children}
</label>
);
}
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,
}: {
title: string;
description?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-ctp-surface1 px-6 py-16 text-center">
<p className="text-sm font-medium text-ctp-subtext1">{title}</p>
{description ? (
<p className="text-sm text-ctp-overlay0">{description}</p>
) : null}
</div>
);
}