feat: pretty gallery info

This commit is contained in:
monoid 2025-05-10 23:18:03 +09:00
parent c26c3f7235
commit f8e2b43e79
2 changed files with 172 additions and 81 deletions

View file

@ -2,30 +2,48 @@ import StyledLink from "@/components/gallery/StyledLink";
import { cn } from "@/lib/utils";
import { Fragment } from "react/jsx-runtime";
export function DescItem({ name, children, className }: {
export function DescItem({
name,
children,
className,
icon
}: {
name: string;
className?: string;
children?: React.ReactNode;
icon?: React.ReactNode;
}) {
return <div className={cn("grid content-start", className)}>
<span className="text-muted-foreground text-sm">{name}</span>
<span className="text-primary leading-4 font-medium">{children}</span>
<div className="flex items-center gap-1.5 mb-1">
{icon && <span className="text-muted-foreground">{icon}</span>}
<span className="text-muted-foreground text-sm font-medium">{name}</span>
</div>
<div className="text-primary leading-relaxed font-medium pl-0.5">{children}</div>
</div>;
}
export function DescTagItem({
items, name, className,
items, name, className, icon
}: {
name: string;
items: string[];
className?: string;
icon?: React.ReactNode;
}) {
return <DescItem name={name} className={className}>
{items.length === 0 ? "N/A" : items.map(
return <DescItem name={name} className={className} icon={icon}>
{items.length === 0 ? (
<span className="text-muted-foreground italic text-sm"> </span>
) : items.map(
(x, i) =>
<Fragment key={x}>
<StyledLink to={`/search?allow_tag=${name.toLowerCase()}:${x}`}>{x}</StyledLink>
{i + 1 < items.length && <span className="">, </span>}
<StyledLink
to={`/search?allow_tag=${name.toLowerCase()}:${x}`}
className="inline-flex items-center hover:bg-secondary/40 px-1.5 py-0.5 rounded-md transition-colors">
{x}
</StyledLink>
{i + 1 < items.length && <span className="mx-0.5">, </span>}
</Fragment>
)}
</DescItem>;
}
}

View file

@ -10,7 +10,12 @@ import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
import { useEffect, useRef } from "react";
import { useLogin } from "@/state/user.ts";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu.tsx";
import { DotsVerticalIcon } from "@radix-ui/react-icons";
import { EllipsisVerticalIcon } from "lucide-react";
import {
RefreshCw, ScanSearch, Trash2,
Paintbrush
, Users, BookOpen, User, Clock, FileCheck, FileText, Layers, Tag, Loader2, AlertCircle, FileQuestion
} from "lucide-react";
export interface ContentInfoPageProps {
params: {
@ -45,19 +50,30 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
const isAdmin = username === "admin";
if (isLoading) {
return <div className="p-4 flex items-center justify-center h-full">
<span className="animate-pulse text-4xl">
Loading...
return <div className="p-8 flex flex-col items-center justify-center h-full">
<Loader2 className="w-16 h-16 animate-spin text-primary mb-4" />
<span className="text-xl font-medium text-muted-foreground">
...
</span>
</div>
}
if (error) {
return <div className="p-4">Error: {String(error)}</div>
return <div className="p-8 flex flex-col items-center justify-center h-full text-destructive">
<AlertCircle className="w-16 h-16 mb-4" />
<div className="text-xl font-medium mb-2"> </div>
<div className="text-destructive/80 bg-destructive/10 p-3 rounded-md">{String(error)}</div>
</div>
}
if (!data) {
return <div className="p-4">Not found</div>
return <div className="p-8 flex flex-col items-center justify-center h-full text-muted-foreground">
<FileQuestion className="w-16 h-16 mb-4" />
<div className="text-xl font-medium"> </div>
<Button variant="outline" className="mt-4">
<Link href="/search"> </Link>
</Button>
</div>
}
const tags = data?.tags ?? [];
@ -68,77 +84,117 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
return (
<Wrapper>
<Link to={contentLocation}>
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
rounded-xl shadow-lg overflow-hidden">
<div className="m-auto h-[400px] mb-6 flex justify-center items-center flex-none bg-gradient-to-b from-[#1d1d25] to-[#272733]
rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-300 group">
<img
className="max-w-full max-h-full object-cover object-center"
className="max-w-full max-h-full object-cover object-center group-hover:scale-[1.02] transition-transform duration-300"
src={`/api/doc/${data.id}/comic/thumbnail`}
alt={data.title} />
</div>
</Link>
<Card className="flex-1 relative">
{isAdmin &&
<div className="absolute top-0 right-0 p-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost"> <DotsVerticalIcon /> Actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" >
<DropdownMenuItem
onClick={async () => {
await rehashDoc(params.id);
}}
<div className="flex justify-between items-start p-6">
<CardHeader className="p-0">
<CardTitle className="font-bold bg-clip-text">
<StyledLink to={contentLocation} className="hover:no-underline">
{data.title}
</StyledLink>
</CardTitle>
<CardDescription className="mt-1">
<StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`}
className="text-sm px-2 py-0.5 rounded-md inline-flex items-center gap-1">
<Tag className="w-3.5 h-3.5" />
{classifiedTags.type[0] ?? "N/A"}
</StyledLink>
</CardDescription>
</CardHeader>
{isAdmin &&
<div className="z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="hover:bg-secondary/50 flex items-center gap-1.5 rounded-full px-3">
<EllipsisVerticalIcon className="w-4 h-4" />
<span className="font-medium">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="animate-in fade-in-50 zoom-in-95 duration-100">
<DropdownMenuItem
onClick={async () => {
await rehashDoc(params.id);
}}
className="flex items-center gap-2 cursor-pointer"
>
Rehash
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!window.confirm("Are you sure?")) {
return;
}
// Rescan
await rescanDoc(params.id);
}}
<RefreshCw className="w-4 h-4" />
Rehash
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!window.confirm("Are you sure?")) {
return;
}
await rescanDoc(params.id);
}}
className="flex items-center gap-2 cursor-pointer"
>
Rescan
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!window.confirm("Are you sure?")) {
return;
}
// Delete
await deleteDoc(params.id);
}}
<ScanSearch className="w-4 h-4" />
Rescan
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!window.confirm("Are you sure?")) {
return;
}
await deleteDoc(params.id);
}}
className="flex items-center gap-2 cursor-pointer text-destructive hover:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
<CardHeader>
<CardTitle>
<StyledLink to={contentLocation}>
{data.title}
</StyledLink>
</CardTitle>
<CardDescription>
<StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`} className="text-sm">
{classifiedTags.type[0] ?? "N/A"}
</StyledLink>
</CardDescription>
</CardHeader>
<Trash2 className="w-4 h-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
</div>
<CardContent>
<div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]">
<DescTagItem name="Artist" items={classifiedTags.artist} />
<DescTagItem name="Group" items={classifiedTags.group} />
<DescTagItem name="Series" items={classifiedTags.series} />
<DescTagItem name="Character" items={classifiedTags.character} />
<DescItem name="Created At / Modified At">{new Date(data.created_at).toLocaleString()} / {new Date(data.modified_at).toLocaleString()}</DescItem>
<DescItem name="Filehash">{data.content_hash}</DescItem>
<DescItem name="Path">{`${data.basepath}/${data.filename}`}</DescItem>
<DescItem name="Page Count">{data.pagenum}</DescItem>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<DescTagItem name="Artist" items={classifiedTags.artist}
icon={<Paintbrush className="w-4 h-4" />} />
<DescTagItem name="Group" items={classifiedTags.group}
icon={<Users className="w-4 h-4" />} />
<DescTagItem name="Series" items={classifiedTags.series}
icon={<BookOpen className="w-4 h-4" />} />
<DescTagItem name="Character" items={classifiedTags.character}
icon={<User className="w-4 h-4" />} />
<DescItem name="Created At / Modified At"
icon={<Clock className="w-4 h-4" />}
className="md:col-span-2">
<div className="flex flex-wrap gap-x-2">
<span className="bg-muted/50 px-1.5 py-0.5 rounded text-sm">{new Date(data.created_at).toLocaleString()}</span> /
<span className="bg-muted/50 px-1.5 py-0.5 rounded text-sm">{new Date(data.modified_at).toLocaleString()}</span>
</div>
</DescItem>
<DescItem name="Filehash"
icon={<FileCheck className="w-4 h-4" />}>
<span className="font-mono text-sm bg-muted/50 px-1.5 py-0.5 rounded overflow-x-auto block whitespace-nowrap max-w-full">{data.content_hash}</span>
</DescItem>
<DescItem name="Page Count"
icon={<Layers className="w-4 h-4" />}>
<span className="text-sm bg-primary/20 px-2 py-0.5 rounded-full font-medium">{data.pagenum} </span>
</DescItem>
<DescItem name="Path"
className="md:col-span-2"
icon={<FileText className="w-4 h-4" />}>
<span className="font-mono text-sm bg-muted/50 px-1.5 py-0.5 rounded overflow-x-auto text-wrap block whitespace-nowrap max-w-full">{`${data.basepath}/${data.filename}`}</span>
</DescItem>
</div>
<div className="grid mt-4">
<span className="text-muted-foreground text-sm">Tags</span>
@ -161,20 +217,37 @@ function SimilarContentCard({
const { data, error, isLoading } = useGalleryDocSimilar(id);
if (isLoading) {
return <div className="p-4">Loading...</div>
return <div className="p-4 mt-6 text-center animate-pulse bg-muted/20 rounded-lg">
<Loader2 className="w-6 h-6 mx-auto mb-2 animate-spin opacity-50" />
<span className="text-muted-foreground"> ...</span>
</div>
}
if (error) {
return <div className="p-4">Error: {String(error)}</div>
return <div className="p-4 mt-6 text-destructive/70 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
: {String(error)}
</div>
</div>
}
if (!data) {
return <div className="p-4">Not found</div>
if (!data || data.length === 0) {
return <div className="p-6 mt-6 text-center bg-muted/10 rounded-lg border border-border/50">
<FileQuestion className="w-9 h-9 mx-auto mb-3 opacity-30" />
<span className="text-muted-foreground"> .</span>
</div>
}
return (
<div className="space-y-4 mt-4 mx-2">
<h2 className="text-2xl font-bold">Contents with Similar Tags</h2>
<div className="flex items-center mb-4 gap-2">
<h2 className="text-2xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent"> </h2>
<div className="h-[2px] flex-1 bg-gradient-to-r from-border to-transparent"></div>
<span className="text-sm text-muted-foreground bg-secondary/20 px-2 py-0.5 rounded-full">
{data.length}
</span>
</div>
<div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]">
{data.map((doc) => (
<GalleryCard key={doc.id} doc={doc} />