feat(ui) : improve guide navigation, signup, and commissions
This commit is contained in:
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import { BookOpenIcon, MenuIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SiteInstallButton } from "@/components/site-install-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -18,8 +19,8 @@ import {
|
||||
|
||||
const destinations = [
|
||||
{ href: "/", label: "หน้ารวม", icon: null },
|
||||
{ href: "/commission", label: "Commission", icon: "/icon/commission.webp" },
|
||||
{ href: "/stygian", label: "Stygian Guide", icon: "/icon/stygian.webp" },
|
||||
{ href: "/commission", label: "ขอคำนวณทีม", icon: "/icon/commission.webp" },
|
||||
{ href: "/stygian", label: "ไกด์ Stygian", icon: "/icon/stygian.webp" },
|
||||
] as const;
|
||||
|
||||
export function PublicNavigation() {
|
||||
@@ -34,6 +35,7 @@ export function PublicNavigation() {
|
||||
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : null}{label}
|
||||
</Button>)}
|
||||
</nav>
|
||||
<SiteInstallButton />
|
||||
<div className="lg:hidden">
|
||||
<Drawer>
|
||||
<DrawerTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปิดเมนูหลัก" />}>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
|
||||
export function RotationHelp() {
|
||||
return <Accordion>
|
||||
<AccordionItem value="rotation-help">
|
||||
<AccordionTrigger>อ่านโรเทชั่นและค่า DPS อย่างไร?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>DPS คือดาเมจรวมของทีม ÷ เวลาโรเทชั่น ผลคำนวณขึ้นอยู่กับทีม อาวุธ กลุ่มดาว และเงื่อนไขในไกด์ จึงอาจต่างจากดาเมจที่เล่นจริง</p>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
||||
<dt className="font-semibold text-foreground">E / Q</dt><dd>สกิลธาตุ / ท่าไม้ตาย</dd>
|
||||
<dt className="font-semibold text-foreground">N / C / P</dt><dd>โจมตีปกติ / ชาร์จโจมตี / โจมตีพุ่งลง</dd>
|
||||
<dt className="font-semibold text-foreground">D / J</dt><dd>แดช / กระโดดเพื่อยกเลิกแอนิเมชั่น</dd>
|
||||
<dt className="font-semibold text-foreground">hE / tE</dt><dd>กดสกิลธาตุค้าง / แตะสกิลธาตุ</dd>
|
||||
<dt className="font-semibold text-foreground">C0 / R1</dt><dd>กลุ่มดาวระดับ 0 / อาวุธขัดเกลาระดับ 1</dd>
|
||||
</dl>
|
||||
<p>ตัวอย่าง: <strong className="text-foreground">EQ > N2D</strong> = ใช้สกิลธาตุ ตามด้วยท่าไม้ตาย แล้วโจมตีปกติ 2 ครั้งและแดช เครื่องหมาย > คือทำขั้นตอนถัดไป ส่วน 2[N2C] คือทำชุดโจมตีปกติ 2 ครั้ง + ชาร์จโจมตี ซ้ำ 2 รอบ</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>;
|
||||
}
|
||||
@@ -10,10 +10,12 @@ export function ScrollableNav({
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
activeKey,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
activeKey?: string;
|
||||
}) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
@@ -29,17 +31,29 @@ export function ScrollableNav({
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
updateScrollState();
|
||||
nav.addEventListener("scroll", updateScrollState, { passive: true });
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
return () => {
|
||||
nav.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", updateScrollState);
|
||||
const revealActive = () => {
|
||||
const active = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (active) {
|
||||
nav.scrollLeft = Math.max(0, active.offsetLeft - nav.offsetLeft - (nav.clientWidth - active.offsetWidth) / 2);
|
||||
}
|
||||
updateScrollState();
|
||||
};
|
||||
}, [updateScrollState]);
|
||||
const frame = requestAnimationFrame(revealActive);
|
||||
const observer = new ResizeObserver(updateScrollState);
|
||||
observer.observe(nav);
|
||||
nav.addEventListener("scroll", updateScrollState, { passive: true });
|
||||
window.addEventListener("resize", revealActive);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
nav.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", revealActive);
|
||||
};
|
||||
}, [activeKey, updateScrollState]);
|
||||
|
||||
const scroll = (direction: -1 | 1) => {
|
||||
navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75, behavior: "smooth" });
|
||||
navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75,
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -58,7 +72,7 @@ export function ScrollableNav({
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={ariaLabel}
|
||||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto scrollbar-none"
|
||||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
|
||||
@@ -55,7 +55,9 @@ import {
|
||||
import {
|
||||
normalizeTalentPriorities,
|
||||
talentRelationSymbol,
|
||||
talentTypeLabel,
|
||||
} from "@/lib/guides/talents";
|
||||
import { comparisonDelta, comparisonReferenceNames } from "@/lib/guides/comparison";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import {
|
||||
@@ -94,8 +96,8 @@ export function StructuredGuideShell({
|
||||
const sections = publicSections(guide);
|
||||
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
|
||||
const currentPageTitle = section
|
||||
? (sections.find((item) => item.slug === section)?.title ?? section)
|
||||
: "Overview";
|
||||
? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section)
|
||||
: "ภาพรวม";
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
|
||||
@@ -109,16 +111,16 @@ export function StructuredGuideShell({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sticky top-0 z-40 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 lg:px-8">
|
||||
<div className="mx-auto flex min-h-12 w-full max-w-7xl items-center gap-3 px-4 sm:min-h-16 sm:px-6 lg:px-8">
|
||||
<Button variant="ghost" size="icon" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
<div className="relative size-16 overflow-hidden rounded-lg border">
|
||||
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg border sm:size-16">
|
||||
<Image
|
||||
src={guide.coverUrl}
|
||||
alt={guide.name}
|
||||
fill
|
||||
sizes="64px"
|
||||
sizes="(min-width: 640px) 64px, 40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export function StructuredGuideShell({
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{guide.name}
|
||||
</p>
|
||||
<p className="truncate font-heading text-2xl font-semibold">
|
||||
<p className="max-w-full truncate font-heading text-lg font-semibold sm:text-2xl">
|
||||
{currentPageTitle}
|
||||
</p>
|
||||
</div>
|
||||
@@ -134,14 +136,16 @@ export function StructuredGuideShell({
|
||||
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<ScrollableNav
|
||||
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
||||
className="min-h-11 w-full rounded-xl border bg-card p-2"
|
||||
activeKey={section ?? "overview"}
|
||||
className="min-h-11 w-full rounded-xl border bg-card p-1 sm:p-2"
|
||||
>
|
||||
<Link
|
||||
href={baseHref}
|
||||
aria-current={!section ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!section
|
||||
? "bg-muted text-foreground"
|
||||
? "bg-primary/15 font-semibold text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch={false}
|
||||
@@ -153,16 +157,17 @@ export function StructuredGuideShell({
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
Overview
|
||||
ภาพรวม
|
||||
</Link>
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`${baseHref}/${item.slug}`}
|
||||
aria-current={section === item.slug ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
section === item.slug
|
||||
? "bg-muted text-foreground"
|
||||
? "bg-primary/15 font-semibold text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch={false}
|
||||
@@ -176,7 +181,7 @@ export function StructuredGuideShell({
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
{item.title}
|
||||
{publicSectionTitle(item.slug, item.title)}
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableNav>
|
||||
@@ -186,7 +191,7 @@ export function StructuredGuideShell({
|
||||
{section ? (
|
||||
<GuideSection guide={guide} section={section} />
|
||||
) : (
|
||||
<GuideOverview guide={guide} sections={sections} />
|
||||
<GuideOverview guide={guide} sections={sections} baseHref={baseHref} />
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
@@ -197,9 +202,11 @@ export function StructuredGuideShell({
|
||||
function GuideOverview({
|
||||
guide,
|
||||
sections,
|
||||
baseHref,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
sections: ReturnType<typeof publicSections>;
|
||||
baseHref: string;
|
||||
}) {
|
||||
const youtubeVideos = guide.youtubeUrls.flatMap((url) => {
|
||||
const video = parseYouTubeUrl(url);
|
||||
@@ -217,6 +224,7 @@ function GuideOverview({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<GuideBuildSummary guide={guide} baseHref={baseHref} />
|
||||
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">
|
||||
<Image
|
||||
@@ -229,8 +237,8 @@ function GuideOverview({
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="order-3 lg:col-start-2 lg:row-start-1">
|
||||
<GuideNavigation guide={guide} sections={sections} />
|
||||
<div className="order-0 lg:col-start-2 lg:row-start-1">
|
||||
<GuideNavigation guide={guide} sections={sections} baseHref={baseHref} />
|
||||
</div>
|
||||
{guide.posterArtUrl || youtubeVideos.length ? (
|
||||
<>
|
||||
@@ -299,19 +307,90 @@ function GuideOverview({
|
||||
);
|
||||
}
|
||||
|
||||
function GuideBuildSummary({ guide, baseHref }: { guide: PublicGuide; baseHref: string }) {
|
||||
const weaponGroup = guide.weaponGroups[0];
|
||||
const summarizeWeapon = (row: PublicGuide["weapons"][number] | undefined) => row ? {
|
||||
name: row.weapon.name, refinement: row.refinement, note: row.note,
|
||||
conditionName: weaponGroup?.name, conditionNote: weaponGroup?.note,
|
||||
} : null;
|
||||
const artifactGroup = guide.artifactGroups[0];
|
||||
const artifact = artifactGroup?.tiers.flatMap((tier) => tier.rows)[0];
|
||||
// Complete admin previews already contain this data; keep their reads unchanged.
|
||||
const summary = guide.buildSummary ?? {
|
||||
weapon: summarizeWeapon(weaponGroup?.rows[0]),
|
||||
fourStarWeapon: summarizeWeapon(weaponGroup?.rows.find((row) => row.weapon.rarity === 4)),
|
||||
artifact: artifact ? {
|
||||
name: artifact.first.name, secondKey: artifact.secondArtifactKey, secondName: artifact.second?.name,
|
||||
note: artifact.note, conditionName: artifactGroup?.name, conditionNote: artifactGroup?.note,
|
||||
} : null,
|
||||
profile: guide.artifactProfile,
|
||||
};
|
||||
if (!summary || (!summary.weapon && !summary.artifact && !summary.profile)) return null;
|
||||
const profile = summary.profile;
|
||||
const recommended = profile?.recommendedConditions[0] ?? profile;
|
||||
const target = profile?.targetConditions[0];
|
||||
const targets = target?.rows ?? profile?.targets ?? [];
|
||||
const talents = normalizeTalentPriorities(profile?.talents ?? []);
|
||||
return <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>สรุปคำแนะนำในไกด์</CardTitle>
|
||||
<CardDescription>สรุปจากเงื่อนไขแรกของแต่ละหัวข้อ เปิดรายละเอียดเพื่อเลือกเงื่อนไขที่ตรงกับทีมของคุณ</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 sm:grid-cols-2">
|
||||
{summary.weapon && <div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold">อาวุธ</h2>
|
||||
<p>{summary.weapon.name} R{summary.weapon.refinement}</p>
|
||||
{summary.fourStarWeapon && <p className="text-sm text-muted-foreground">ตัวเลือก 4 ดาวในไกด์: {summary.fourStarWeapon.name} R{summary.fourStarWeapon.refinement}</p>}
|
||||
{summary.weapon.conditionName && <Badge variant="outline">{summary.weapon.conditionName}</Badge>}
|
||||
{summary.weapon.conditionNote && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{summary.weapon.conditionNote}</p>}
|
||||
{summary.weapon.note && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{summary.weapon.note}</p>}
|
||||
<Link href={`${baseHref}/weapon`} prefetch={false} className="text-sm text-primary underline underline-offset-4">เปรียบเทียบอาวุธทั้งหมด</Link>
|
||||
</div>}
|
||||
{summary.artifact && <div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold">อาร์ติแฟกต์</h2>
|
||||
<p>{summary.artifact.secondKey ? `2 ${summary.artifact.name} + 2 ${summary.artifact.secondName ?? "ชุดที่สองตามไกด์"}` : `4 ${summary.artifact.name}`}</p>
|
||||
{summary.artifact.conditionName && <Badge variant="outline">{summary.artifact.conditionName}</Badge>}
|
||||
{summary.artifact.conditionNote && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{summary.artifact.conditionNote}</p>}
|
||||
{summary.artifact.note && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{summary.artifact.note}</p>}
|
||||
</div>}
|
||||
{recommended && [recommended.sands, recommended.goblet, recommended.circlet].some((values) => values.length) && <div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold">ออฟหลัก</h2>
|
||||
{profile?.recommendedConditions[0]?.name && <Badge variant="outline">{profile.recommendedConditions[0].name}</Badge>}
|
||||
{profile?.recommendedConditions[0]?.note && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{profile.recommendedConditions[0].note}</p>}
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{([['นาฬิกา', recommended.sands], ['ถ้วย', recommended.goblet], ['หัว', recommended.circlet]] as const).map(([label, values]) =>
|
||||
values.length ? <div key={label} className="contents"><dt className="text-muted-foreground">{label}</dt><dd>{values.map((value) => statLabel(value)).join(" / ")}</dd></div> : null)}
|
||||
</dl>
|
||||
</div>}
|
||||
{(targets.length > 0 || talents.length > 0) && <div className="flex flex-col gap-2">
|
||||
{targets.length > 0 && <>
|
||||
<h2 className="font-semibold">ค่าสถานะเป้าหมาย</h2>
|
||||
{target?.name && <Badge variant="outline">{target.name}</Badge>}
|
||||
{target?.note && <p className="whitespace-pre-wrap text-sm text-muted-foreground">{target.note}</p>}
|
||||
<dl className="grid grid-cols-[1fr_auto] gap-x-3 gap-y-1">{targets.map((row, index) => <div key={index} className="contents"><dt className="text-muted-foreground">{statLabel(row.stat)}</dt><dd>{row.value}</dd></div>)}</dl>
|
||||
</>}
|
||||
{talents.length > 0 && <><h2 className="font-semibold">ลำดับพรสวรรค์</h2><p>{talents.map((talent, index) => <span key={talent.type}>{talentTypeLabel(talent.type)}{talent.level ? ` Lv.${talent.level}` : ""}{index < talents.length - 1 ? ` ${talentRelationSymbol(talent.relationToNext)} ` : ""}</span>)}</p></>}
|
||||
<Link href={`${baseHref}/artifact`} prefetch={false} className="text-sm text-primary underline underline-offset-4">ดูค่าสถานะและเงื่อนไขทั้งหมด</Link>
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function GuideNavigation({
|
||||
guide,
|
||||
sections,
|
||||
baseHref,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
sections: ReturnType<typeof publicSections>;
|
||||
baseHref: string;
|
||||
}) {
|
||||
return sections.length ? (
|
||||
<nav aria-label={`เมนูคู่มือ ${guide.name}`} className="flex flex-col gap-3">
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
href={`${baseHref}/${item.slug}`}
|
||||
className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
prefetch={false}
|
||||
>
|
||||
@@ -439,7 +518,7 @@ function WeaponSectionContent({
|
||||
)}
|
||||
/>
|
||||
<h1 className={cn("whitespace-nowrap font-heading text-4xl font-semibold", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
|
||||
Weapons
|
||||
{exportLabel(exporting, "อาวุธ", "Weapons")}
|
||||
</h1>
|
||||
</div>
|
||||
{note ? (
|
||||
@@ -472,11 +551,41 @@ function WeaponSectionContent({
|
||||
) : null}
|
||||
</div>
|
||||
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
|
||||
{!exporting && <Alert>
|
||||
<AlertTitle>เปอร์เซ็นต์นี้หมายถึงอะไร?</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>100% คือค่าฐานของเงื่อนไขนี้ เช่น 109% คือดาเมจมากกว่าค่าฐาน 9% เปรียบเทียบภายในทีมและเงื่อนไขเดียวกัน</p>
|
||||
{([['overallPercent', 'ดาเมจทั้งทีม'], ['personalPercent', 'ดาเมจส่วนตัว']] as const).map(([key, label]) => {
|
||||
if (!group.rows.some((row) => Number(row[key]) > 0)) return null;
|
||||
const references = comparisonReferenceNames(group.rows, key);
|
||||
return <p key={key}>{label} · แถวที่แสดง 100%: {references.length ? references.join(", ") : "ผู้เขียนยังไม่ได้ระบุแถว 100%"}</p>;
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>}
|
||||
<WeaponDisplayMode exporting={exporting} rows={group.rows}>
|
||||
<div
|
||||
className={cn("grid gap-3", useCompactExportGrid && "grid-cols-2")}
|
||||
>
|
||||
{group.rows.map((row, index) => (
|
||||
{group.rows.map((row, index) => (!exporting ? (
|
||||
<Card key={row.id} size="sm">
|
||||
<CardContent className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<a href={lunarisItemUrl("weapon", row.weapon.key)} target="_blank" rel="noreferrer"
|
||||
aria-label={`เปิดข้อมูลอาวุธ ${row.weapon.name} บน Lunaris`}
|
||||
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className={cn("relative size-10 shrink-0 rounded-md sm:size-14", rarityGradientClass(row.weapon.rarity))}>
|
||||
<Image src={row.weapon.imageUrl} alt="" fill sizes="(min-width: 640px) 56px, 40px" className="object-contain p-1" />
|
||||
</span>
|
||||
<span className="min-w-0 font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</span>
|
||||
</a>
|
||||
<div className="flex flex-col gap-2 text-right sm:flex-row sm:gap-5">
|
||||
{([['overallPercent', 'ทั้งทีม'], ['personalPercent', 'ส่วนตัว']] as const).map(([key, label]) => comparisonDelta(row[key]) !== null ?
|
||||
<div key={key}><p className="text-lg font-semibold text-primary sm:text-2xl">{metric(row[key])}</p><p className="text-xs text-muted-foreground">{label}</p><p className="text-xs text-muted-foreground">{comparisonDelta(row[key])}</p></div> : null)}
|
||||
{comparisonDelta(row.overallPercent) === null && comparisonDelta(row.personalPercent) === null && <span className="text-xs text-muted-foreground">ยังไม่มีผลคำนวณ</span>}
|
||||
</div>
|
||||
{row.note && <p className="col-span-2 whitespace-pre-wrap text-sm text-muted-foreground">{row.note}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card key={row.id} size="sm" className="bg-white py-0">
|
||||
<CardContent
|
||||
className={cn(
|
||||
@@ -610,7 +719,7 @@ function WeaponSectionContent({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)))}
|
||||
</div>
|
||||
</WeaponDisplayMode>
|
||||
</div>
|
||||
@@ -742,7 +851,7 @@ function ArtifactSectionContent({
|
||||
)}
|
||||
/>
|
||||
<h1 className={cn("whitespace-nowrap font-semibold", exporting ? "text-4xl" : "font-heading text-xl sm:text-4xl", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
|
||||
Artifacts & Stats
|
||||
{exportLabel(exporting, "อาร์ติแฟกต์และค่าสถานะ", "Artifacts & Stats")}
|
||||
</h1>
|
||||
</div>
|
||||
{action}
|
||||
@@ -761,7 +870,7 @@ function ArtifactSectionContent({
|
||||
{recommendedCards}
|
||||
</ConditionSwitcher>
|
||||
)}
|
||||
<div data-artifact-export-section="tierlist" className="order-first">
|
||||
<div data-artifact-export-section="tierlist" className={exporting ? "order-first" : undefined}>
|
||||
{(() => {
|
||||
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
|
||||
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
|
||||
@@ -1179,7 +1288,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
className="size-14 shrink-0 object-contain"
|
||||
/>
|
||||
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
||||
Constellations
|
||||
กลุ่มดาว
|
||||
</h1>
|
||||
</div>
|
||||
{note ? (
|
||||
@@ -1256,7 +1365,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
|
||||
<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 Calculation</h1>
|
||||
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">คำนวณทีม</h1>
|
||||
</header>
|
||||
<TeamSearch groups={guide.teamGroups} />
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import Image from "@/components/ui/resilient-image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
StygianBossCard,
|
||||
@@ -77,12 +80,22 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
||||
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
||||
if (!boss) return null;
|
||||
const groups = schedule.recommendations.filter((group) => group.level === level && group.bossPosition === position);
|
||||
const teams = groups.filter((group) => group.kind === "team");
|
||||
const teams = groups.filter((group) => group.kind === "team" && group.items.length > 0);
|
||||
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
||||
return (
|
||||
<StygianBossCard boss={boss} level={level}>
|
||||
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">Recommended team</h3>
|
||||
<StygianBossCard boss={boss} level={level} mechanicsId={`stygian-mechanics-${level}-${position}`}>
|
||||
{!teams.length && !units.length && <Alert>
|
||||
<AlertTitle>คำแนะนำสำหรับบอสนี้กำลังจัดทำ</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>ยังไม่มีทีมและตัวละครแนะนำสำหรับระดับนี้ อ่านกลไกของบอสด้านล่าง หรือดูไกด์ตัวละครเพื่อเตรียมทีมระหว่างรอ</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={`#stygian-mechanics-${level}-${position}`} className={buttonVariants({ variant: "outline", size: "sm" })}>อ่านกลไกบอส</a>
|
||||
<Link href="/" className={buttonVariants({ variant: "outline", size: "sm" })}>เลือกไกด์ตัวละคร</Link>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>}
|
||||
<section className="flex flex-col gap-3" aria-label={`ทีมแนะนำสำหรับ ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">ทีมแนะนำ</h3>
|
||||
{teams.length ? <div className="grid grid-cols-1 items-start gap-3 xl:grid-cols-2">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="grid w-full min-w-0 grid-cols-4 items-start gap-1 rounded-lg border bg-muted/20 p-2 sm:flex sm:w-fit sm:flex-wrap sm:gap-3 xl:grid xl:grid-cols-4 xl:gap-1">
|
||||
@@ -90,11 +103,11 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
||||
{Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => <div key={`empty-${index}`} className="aspect-square w-full rounded-lg border border-dashed bg-muted/30 sm:w-[116px] xl:w-full" aria-hidden="true" />)}
|
||||
</div>
|
||||
))}
|
||||
</div> : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
|
||||
</div> : <p className="text-sm text-muted-foreground">ยังไม่มีทีมแนะนำสำหรับบอสนี้ในระดับที่เลือก</p>}
|
||||
</section>
|
||||
<section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">Recommended unit</h3>
|
||||
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(5rem,6.25rem))] gap-2">{units.map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} />)}</div> : <p className="text-sm text-muted-foreground">No unit recommendations yet.</p>}
|
||||
<section className="flex flex-col gap-3" aria-label={`ตัวเลือกแนะนำสำหรับ ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">ตัวเลือกแนะนำ</h3>
|
||||
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(5rem,6.25rem))] gap-2">{units.map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} />)}</div> : <p className="text-sm text-muted-foreground">ยังไม่มีตัวเลือกแนะนำเพิ่มเติมในระดับที่เลือก</p>}
|
||||
</section>
|
||||
</StygianBossCard>
|
||||
);
|
||||
@@ -103,9 +116,9 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
||||
export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) {
|
||||
return (
|
||||
<Tabs defaultValue="110">
|
||||
<TabsList aria-label="Stygian difficulty">
|
||||
<TabsList aria-label="ระดับความยาก Stygian">
|
||||
{stygianLevels.map((level) => (
|
||||
<TabsTrigger key={level} value={String(level)}>Level {level}</TabsTrigger>
|
||||
<TabsTrigger key={level} value={String(level)}>เลเวล {level}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{stygianLevels.map((level) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { ConditionTabIcon } from "@/components/public/condition-switcher";
|
||||
import { ExportText } from "@/components/public/export-language";
|
||||
import { RotationHelp } from "@/components/public/rotation-help";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -503,8 +504,8 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
||||
if (order) setDpsOrder(order);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="best">Best DPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="worst">Worst DPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="best">DPS สูงสุด</ToggleGroupItem>
|
||||
<ToggleGroupItem value="worst">DPS ต่ำสุด</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -513,9 +514,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
||||
onClick={() => setDpsOrder(null)}
|
||||
>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Clear
|
||||
ล้างการเรียง
|
||||
</Button>
|
||||
</div>
|
||||
<RotationHelp />
|
||||
{filteredTeams.length ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{filteredTeams.map((team) => (
|
||||
|
||||
@@ -46,15 +46,15 @@ export function WeaponDisplayMode({
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<ToggleGroup
|
||||
aria-label="Weapon display mode"
|
||||
aria-label="รูปแบบการแสดงอาวุธ"
|
||||
variant="outline"
|
||||
value={[mode]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setMode(values[0]);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="normal">List</ToggleGroupItem>
|
||||
<ToggleGroupItem value="chart">Chart</ToggleGroupItem>
|
||||
<ToggleGroupItem value="normal">รายการ</ToggleGroupItem>
|
||||
<ToggleGroupItem value="chart">กราฟ</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{mode === "chart" ? <LazyWeaponChart rows={rows} /> : children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user