feat(guides): add structured image-first editor
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeftIcon, CheckIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createStructuredGuide } from "@/app/admin/actions";
|
||||
import { MediaUploader, type UploadedMedia } from "@/components/admin/media-uploader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Character = { key: string; name: string; element: string | null; rarity: number | null; imageUrl: string };
|
||||
|
||||
export function CreateGuideFlow({ characters, existingKeys }: { characters: Character[]; existingKeys: string[] }) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Character | null>(null);
|
||||
const [cover, setCover] = useState<UploadedMedia | null>(null);
|
||||
const [overview, setOverview] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const existing = useMemo(() => new Set(existingKeys), [existingKeys]);
|
||||
const filtered = characters.filter((character) => character.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<Card className="mx-auto max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>ตั้งค่า Guide ของ {selected.name}</CardTitle>
|
||||
<CardDescription>เพิ่มภาพหน้าปกและ Overview ก่อนสร้าง Guide สาธารณะ</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Guide Image</FieldLabel>
|
||||
{cover ? (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border">
|
||||
<Image src={cover.url} alt={`Guide cover ${selected.name}`} fill className="object-cover" />
|
||||
</div>
|
||||
) : null}
|
||||
<MediaUploader onReady={setCover} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="overview">Overview</FieldLabel>
|
||||
<Textarea id="overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} placeholder="สรุปแนวทางเล่น จุดเด่น และสิ่งที่ผู้ชมจะพบใน Guide นี้" />
|
||||
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between">
|
||||
<Button type="button" variant="outline" onClick={() => setSelected(null)}><ArrowLeftIcon data-icon="inline-start" />เลือกใหม่</Button>
|
||||
<Button disabled={pending || !cover || !overview.trim()} onClick={() => startTransition(async () => {
|
||||
const result = await createStructuredGuide({ characterKey: selected.key, coverMediaId: cover?.id, overview });
|
||||
if (result.status === "error") { toast.error(result.message); return; }
|
||||
toast.success("สร้าง Guide แล้ว");
|
||||
router.push(result.location!);
|
||||
})}>{pending ? <Spinner data-icon="inline-start" /> : <CheckIcon data-icon="inline-start" />}สร้าง Guide</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputGroup className="max-w-xl">
|
||||
<InputGroupAddon><SearchIcon /></InputGroupAddon>
|
||||
<InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" />
|
||||
</InputGroup>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{filtered.map((character) => {
|
||||
const disabled = existing.has(character.key);
|
||||
return (
|
||||
<Card key={character.key} className="overflow-hidden py-0">
|
||||
<div className="relative aspect-square bg-muted"><Image src={character.imageUrl} alt={character.name} fill className="object-cover" /></div>
|
||||
<CardHeader className="pt-4">
|
||||
<CardTitle>{character.name}</CardTitle>
|
||||
<CardDescription>{character.element ?? "Unknown element"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2">{character.rarity ? <Badge variant="secondary">{character.rarity}★</Badge> : null}{disabled ? <Badge variant="outline">มี Guide แล้ว</Badge> : null}</CardContent>
|
||||
<CardFooter className="pb-4"><Button className="w-full" disabled={disabled} onClick={() => setSelected(character)}>เลือกตัวละคร</Button></CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { saveGuideOverview } from "@/app/admin/actions";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
|
||||
const router = useRouter();
|
||||
const [overview, setOverview] = useState(guide.overview);
|
||||
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
|
||||
const [pending, startTransition] = useTransition();
|
||||
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><Button disabled={pending || !overview.trim()} onClick={() => startTransition(async () => { const result = await saveGuideOverview(guide.id, { expectedVersion: guide.version, coverMediaId: cover.id, overview }); if (result.status === "error") { toast.error(result.message); return; } toast.success("บันทึก Overview แล้ว"); router.refresh(); })}>{pending ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}Save</Button></CardFooter></Card>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -11,10 +11,8 @@ import { IMAGE_MIME_TYPES, MAX_MEDIA_BYTES } from "@/lib/media/validation";
|
||||
|
||||
export interface UploadedMedia {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
url: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function MediaUploader({
|
||||
@@ -23,7 +21,7 @@ export function MediaUploader({
|
||||
onReady: (media: UploadedMedia) => void;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const [state, setState] = useState<"idle" | "preparing" | "uploading" | "checking">("idle");
|
||||
const [state, setState] = useState<"idle" | "uploading">("idle");
|
||||
const busy = state !== "idle";
|
||||
|
||||
async function upload(file: File) {
|
||||
@@ -36,41 +34,20 @@ export function MediaUploader({
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaId: string | undefined;
|
||||
try {
|
||||
setState("preparing");
|
||||
const presignResponse = await fetch("/api/admin/media/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileName: file.name, mimeType: file.type, byteSize: file.size }),
|
||||
});
|
||||
if (!presignResponse.ok) throw new Error("presign");
|
||||
const upload = await presignResponse.json() as {
|
||||
mediaId: string;
|
||||
uploadUrl: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
mediaId = upload.mediaId;
|
||||
|
||||
setState("uploading");
|
||||
const putResponse = await fetch(upload.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: upload.headers,
|
||||
body: file,
|
||||
const body = new FormData();
|
||||
body.set("file", file);
|
||||
const response = await fetch("/api/admin/media", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!putResponse.ok) throw new Error("upload");
|
||||
|
||||
setState("checking");
|
||||
const completeResponse = await fetch(`/api/admin/media/${mediaId}/complete`, { method: "POST" });
|
||||
if (!completeResponse.ok) throw new Error("complete");
|
||||
const result = await completeResponse.json() as { media: UploadedMedia };
|
||||
onReady(result.media);
|
||||
const result = await response.json() as UploadedMedia & { error?: string };
|
||||
if (!response.ok) throw new Error(result.error || "upload");
|
||||
onReady(result);
|
||||
toast.success("อัปโหลดรูปภาพแล้ว");
|
||||
} catch {
|
||||
if (mediaId) {
|
||||
await fetch(`/api/admin/media/${mediaId}`, { method: "DELETE" }).catch(() => undefined);
|
||||
}
|
||||
toast.error("อัปโหลดไม่สำเร็จ ไฟล์ชั่วคราวถูกเก็บกวาดแล้ว");
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : "อัปโหลดไม่สำเร็จ");
|
||||
} finally {
|
||||
setState("idle");
|
||||
}
|
||||
@@ -99,9 +76,7 @@ export function MediaUploader({
|
||||
render={<label htmlFor={inputId} />}
|
||||
>
|
||||
{busy ? <Spinner /> : <ImagePlusIcon />}
|
||||
{state === "preparing" && "กำลังเตรียม…"}
|
||||
{state === "uploading" && "กำลังอัปโหลด…"}
|
||||
{state === "checking" && "กำลังตรวจสอบไฟล์…"}
|
||||
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
|
||||
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PlusIcon, RefreshCwIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
type GuideCard = { id: string; characterKey: string; name: string; overview: string; coverUrl: string; version: number; updatedAt: Date; trashedAt: Date | null };
|
||||
type SyncJob = { id: string; status: string; version: string | null; progress: number; total: number; error: string | null } | null;
|
||||
|
||||
export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }: { guides: GuideCard[]; trash: GuideCard[]; syncJob: SyncJob; catalogVersion: string | null }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const syncing = syncJob?.status === "pending" || syncJob?.status === "processing";
|
||||
useEffect(() => {
|
||||
if (!syncing) return;
|
||||
const timer = window.setInterval(() => router.refresh(), 3_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [router, syncing]);
|
||||
|
||||
function run(action: () => Promise<{ status: string; message?: string }>, success: string) {
|
||||
startTransition(async () => {
|
||||
const result = await action();
|
||||
if (result.status === "error") toast.error(result.message);
|
||||
else { toast.success(success); router.refresh(); }
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2"><Badge variant="outline">Lunaris {catalogVersion ?? "ยังไม่ Sync"}</Badge>{syncJob ? <Badge variant={syncJob.status === "failed" ? "destructive" : "secondary"}>{syncJob.status}{syncing && syncJob.total ? ` ${syncJob.progress}/${syncJob.total}` : ""}</Badge> : null}</div>
|
||||
<div className="flex gap-2"><Button variant="outline" disabled={pending || syncing} onClick={() => run(startCatalogSync, "เริ่ม Sync แล้ว")}>{pending || syncing ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync Catalog</Button><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}><PlusIcon data-icon="inline-start" />สร้าง Guide</Button></div>
|
||||
</div>
|
||||
{syncJob?.status === "failed" ? <Alert variant="destructive"><AlertTitle>Sync ไม่สำเร็จ</AlertTitle><AlertDescription>{syncJob.error}</AlertDescription></Alert> : null}
|
||||
{guides.length ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => (
|
||||
<Card key={guide.id} className="overflow-hidden py-0">
|
||||
<div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div>
|
||||
<CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-2">{guide.overview}</CardDescription><CardAction><Badge variant="secondary">v{guide.version}</Badge></CardAction></CardHeader>
|
||||
<CardContent><p className="text-xs text-muted-foreground">แก้ไขล่าสุด {new Date(guide.updatedAt).toLocaleString("th-TH")}</p></CardContent>
|
||||
<CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter>
|
||||
</Card>
|
||||
))}</div>
|
||||
) : <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Guide</EmptyTitle><EmptyDescription>Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก</EmptyDescription></EmptyHeader><EmptyContent><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}>สร้าง Guide</Button></EmptyContent></Empty>}
|
||||
{trash.length ? <section className="flex flex-col gap-4"><div><h2 className="font-heading text-xl font-semibold">ถังขยะ</h2><p className="text-sm text-muted-foreground">Guide ที่ซ่อนไว้จากเว็บไซต์</p></div><div className="grid gap-3 md:grid-cols-2">{trash.map((guide) => <Card key={guide.id}><CardHeader><CardTitle>{guide.name}</CardTitle><CardDescription>{guide.overview}</CardDescription><CardAction><div className="flex gap-2"><Button variant="outline" size="sm" disabled={pending} onClick={() => run(() => restoreStructuredGuide(guide.id), "กู้คืน Guide แล้ว")}><RotateCcwIcon data-icon="inline-start" />กู้คืน</Button><AlertDialog><AlertDialogTrigger render={<Button variant="destructive" size="sm" />}><Trash2Icon data-icon="inline-start" />ลบถาวร</AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ {guide.name} ถาวร?</AlertDialogTitle><AlertDialogDescription>ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></CardAction></CardHeader></Card>)}</div></section> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
type GuideCard = { id: string; slug: string; name: string; overview: string; coverUrl: string; version: number };
|
||||
|
||||
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const filtered = guides.filter((guide) => `${guide.name} ${guide.overview}`.toLowerCase().includes(normalized));
|
||||
return <div className="flex flex-col gap-8"><InputGroup className="h-11 max-w-2xl"><InputGroupAddon><SearchIcon /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหา Guide…" aria-label="ค้นหา Guide" /></InputGroup>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">{filtered.map((guide) => <Link key={guide.id} href={`/${guide.slug}`} className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5"><div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-3">{guide.overview}</CardDescription></CardHeader><CardContent className="pb-5"><Badge variant="secondary">Build Guide</Badge></CardContent></Card></Link>)}</div> : <Empty className="border"><EmptyHeader><EmptyTitle>ไม่พบ Guide</EmptyTitle><EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription></EmptyHeader></Empty>}</div>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function hasSection(guide: PublicGuide, slug: string): boolean {
|
||||
if (slug === "weapon") return guide.weapons.length > 0;
|
||||
if (slug === "artifact") return guide.artifactGroups.some((group) => group.rows.length > 0) || Boolean(guide.artifactProfile && (guide.artifactProfile.sands.length || guide.artifactProfile.goblet.length || guide.artifactProfile.circlet.length));
|
||||
if (slug === "constellations") return guide.constellations.length > 0;
|
||||
if (slug === "team") return guide.teams.length > 0;
|
||||
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
|
||||
}
|
||||
|
||||
export function publicSections(guide: PublicGuide) {
|
||||
return guide.sections.filter((section) => hasSection(guide, section.slug));
|
||||
}
|
||||
|
||||
export function StructuredGuideShell({ guide, section }: { guide: PublicGuide; section?: string }) {
|
||||
const sections = publicSections(guide);
|
||||
return <div className="min-h-svh bg-background"><LiveRefresh endpoint={`/api/events/page/${guide.id}`} /><SiteHeader /><div className="sticky top-0 border-b bg-background/95 supports-backdrop-filter:backdrop-blur"><div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6"><Link href="/" aria-label="กลับหน้ารวม" className="grid size-10 place-items-center rounded-lg hover:bg-muted"><ChevronLeftIcon /></Link><div className="relative size-11 overflow-hidden rounded-lg border"><Image src={guide.character.imageUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="truncate font-heading font-semibold">{guide.name}</p><p className="truncate text-xs text-muted-foreground">Build Guide</p></div>{guide.character.element ? <Badge>{guide.character.element}</Badge> : null}</div><nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6"><Link href={`/${guide.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", !section && "bg-muted text-foreground")}>Overview</Link>{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", section === item.slug && "bg-muted text-foreground")}>{item.title}</Link>)}</nav></div><main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">{section ? <GuideSection guide={guide} section={section} /> : <GuideOverview guide={guide} sections={sections} />}</main></div>;
|
||||
}
|
||||
|
||||
function GuideOverview({ guide, sections }: { guide: PublicGuide; sections: ReturnType<typeof publicSections> }) {
|
||||
return <div className="flex flex-col gap-8"><div className="relative aspect-[16/7] overflow-hidden rounded-xl border bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill priority className="object-cover" /></div><div className="mx-auto flex max-w-3xl flex-col gap-3 text-center"><p className="text-sm font-medium text-primary">Build Overview</p><h1 className="font-heading text-4xl font-semibold">{guide.name}</h1><p className="text-lg leading-8 text-muted-foreground">{guide.overview}</p></div>{sections.length ? <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`}><Card className="h-full"><CardHeader><CardTitle>{item.title}</CardTitle><CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription></CardHeader></Card></Link>)}</div> : <Card><CardHeader><CardTitle>กำลังจัดทำเนื้อหา</CardTitle><CardDescription>กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล</CardDescription></CardHeader></Card>}</div>;
|
||||
}
|
||||
|
||||
function GuideSection({ guide, section }: { guide: PublicGuide; section: string }) {
|
||||
if (section === "weapon") return <WeaponSection guide={guide} />;
|
||||
if (section === "artifact") return <ArtifactSection guide={guide} />;
|
||||
if (section === "constellations") return <ConstellationSection guide={guide} />;
|
||||
if (section === "team") return <TeamSection guide={guide} />;
|
||||
const extra = guide.extras.find((item) => item.slug === section);
|
||||
return extra ? <ExtraSection extra={extra} /> : null;
|
||||
}
|
||||
|
||||
function metric(value: string | null) { return value === null ? null : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; }
|
||||
|
||||
function WeaponSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Weapons</h1></header><div className="grid gap-3 lg:grid-cols-2">{guide.weapons.map((row, index) => <Card key={row.id} size="sm"><CardContent className="flex items-center gap-4"><div className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p><p className="text-sm text-muted-foreground">{row.weapon.weaponType}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Overall</p><p className="text-2xl font-semibold">{metric(row.overallPercent)}</p></div> : null}{row.personalPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Personal</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</div></section>; }
|
||||
|
||||
function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = guide.artifactProfile; return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาร์ติแฟกต์และสเตตัสที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Artifacts & Stats</h1></header><div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="flex flex-col gap-5">{guide.artifactGroups.map((group) => <Card key={group.id}><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, index) => <div key={row.id} className="flex items-center gap-3"><div className="flex -space-x-3"><Image src={row.first.imageUrl} alt={row.first.name} width={56} height={56} className="rounded-lg border bg-muted" />{row.second ? <Image src={row.second.imageUrl} alt={row.second.name} width={56} height={56} className="rounded-lg border bg-muted" /> : null}</div><div className="min-w-0 flex-1"><p className="font-medium">{index + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}</p></div><p className="text-2xl font-semibold">{metric(row.percent)}</p></div>)}</CardContent></Card>)}</div>{profile ? <Card><CardHeader><CardTitle>Recommended Stats</CardTitle></CardHeader><CardContent className="flex flex-col gap-5"><StatLine label="Sands" values={profile.sands} /><StatLine label="Goblet" values={profile.goblet} /><StatLine label="Circlet" values={profile.circlet} /><Separator /><StatLine label="Substats" values={profile.substats} />{profile.targets.length ? <div><p className="mb-2 text-sm font-medium">Target Stats</p>{profile.targets.map((target) => <div key={`${target.stat}-${target.value}`} className="flex justify-between py-1 text-sm"><span>{target.stat}</span><strong>{target.value}</strong></div>)}</div> : null}{profile.talents.length ? <div><p className="mb-2 text-sm font-medium">Talent Priority</p><div className="flex gap-2">{[...profile.talents].sort((a,b) => a.priority-b.priority).map((talent) => <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}{talent.level ? ` ${talent.level}` : ""}</Badge>)}</div></div> : null}{profile.note ? <p className="text-sm leading-6 text-muted-foreground">{profile.note}</p> : null}</CardContent></Card> : null}</div></section>; }
|
||||
|
||||
function StatLine({ label, values }: { label: string; values: string[] }) { return <div><p className="text-sm text-muted-foreground">{label}</p><div className="mt-1 flex flex-wrap gap-2">{values.map((value) => <Badge key={value} variant="outline">{value}</Badge>)}</div></div>; }
|
||||
|
||||
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader><CardTitle>C{row.level}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
|
||||
|
||||
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-4 gap-2">{team.members.map((member) => <div key={member.position} className="text-center"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><p className="mt-2 truncate text-sm font-medium">{member.character.name}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{(total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 })}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
|
||||
|
||||
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">Extra Guide</p><h1 className="font-heading text-4xl font-semibold">{extra.title}</h1></header><div className="grid gap-5 md:grid-cols-2">{extra.rows.map((row) => <Card key={row.id} className="overflow-hidden py-0">{row.imageUrl ? <div className="relative aspect-video bg-muted"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<CardHeader className="pt-5"><CardTitle>{row.heading}</CardTitle><CardDescription>{row.body}</CardDescription>{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}</CardHeader></Card>)}</div></section>; }
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex items-center gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "outline" : "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
Reference in New Issue
Block a user