feat(guides): add team loadouts and element icons
This commit is contained in:
@@ -49,7 +49,7 @@ type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey:
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null;
|
||||
type ConstellationValue = { level: number; detail: string; cumulativePercent: string };
|
||||
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
|
||||
type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; damage: string }> };
|
||||
type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
|
||||
|
||||
export interface GuideSectionEditorProps {
|
||||
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
|
||||
@@ -78,7 +78,7 @@ function CatalogPicker({ items, value, onChange, placeholder, weaponBackground =
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{items.map((item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", weaponBackground && item.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", weaponBackground && item.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>)}
|
||||
{items.map((item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", weaponBackground && item.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", weaponBackground && item.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black", weaponBackground && item.rarity === 3 && "bg-[linear-gradient(90deg,rgba(0,160,181,1)_0%,rgba(0,0,0,1)_100%)]")}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
@@ -133,7 +133,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>เรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", selectedWeapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", selectedWeapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" weaponBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
|
||||
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", selectedWeapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", selectedWeapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black", selectedWeapon.rarity === 3 && "bg-[linear-gradient(90deg,rgba(0,160,181,1)_0%,rgba(0,0,0,1)_100%)]")}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" weaponBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
|
||||
</Card>
|
||||
@@ -193,8 +193,8 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
|
||||
);
|
||||
}
|
||||
|
||||
function TeamEditor({ guide, characters, initial }: EditorBase) {
|
||||
const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, damage: "0" }));
|
||||
function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBase) {
|
||||
const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: "0" }));
|
||||
const [teams, setTeams] = useState(initial.teams);
|
||||
const value = { teams: teams.map((team) => ({ ...team, rotationSeconds: Number(team.rotationSeconds), members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
|
||||
const autosave = useAutosave({ value, valid: teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) });
|
||||
@@ -202,7 +202,7 @@ function TeamEditor({ guide, characters, initial }: EditorBase) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{teams.map((team, teamIndex) => { const total = team.members.reduce((sum, member) => sum + (Number(member.damage) || 0), 0); const dps = total / (Number(team.rotationSeconds) || 1); return <Card key={teamIndex} size="sm"><CardHeader><CardTitle>Team {teamIndex + 1}</CardTitle><CardDescription>Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</CardDescription><CardAction><RowActions index={teamIndex} length={teams.length} move={(from, to) => setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field><div className="grid gap-3 lg:grid-cols-4">{team.members.map((member, memberIndex) => <Field key={memberIndex}><FieldLabel>ตัวละคร {memberIndex + 1}</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey } : candidate) } : item))} /><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /><FieldDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}%` : "0%"}</FieldDescription></Field>)}</div><Field><FieldLabel>Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field><Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>; })}
|
||||
{teams.map((team, teamIndex) => { const total = team.members.reduce((sum, member) => sum + (Number(member.damage) || 0), 0); const dps = total / (Number(team.rotationSeconds) || 1); return <Card key={teamIndex} size="sm"><CardHeader><CardTitle>Team {teamIndex + 1}</CardTitle><CardDescription>Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</CardDescription><CardAction><RowActions index={teamIndex} length={teams.length} move={(from, to) => setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field><div className="grid gap-4 md:grid-cols-2">{team.members.map((member, memberIndex) => { const character = characters.find((item) => item.key === member.characterKey); const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType)); return <Card key={memberIndex} size="sm"><CardHeader><CardTitle>ตัวละคร {memberIndex + 1}</CardTitle><CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field><Field><FieldLabel>Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" weaponBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><Field><FieldLabel>Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field></FieldGroup></CardContent></Card>; })}</div><Field><FieldLabel>Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field><Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>; })}
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} /></CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeftIcon, DownloadIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
@@ -25,7 +26,7 @@ export function publicSections(guide: PublicGuide) {
|
||||
|
||||
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>;
|
||||
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.elementIconUrl ? <Image src={guide.character.elementIconUrl} alt="" width={16} height={16} className="brightness-0 invert" /> : null}{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> }) {
|
||||
@@ -43,14 +44,14 @@ function GuideSection({ guide, section }: { guide: PublicGuide; section: string
|
||||
|
||||
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={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", row.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", row.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-contain" /></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">ดาเมทโดยรวม</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">ดาเมทส่วนตัว</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</div></section>; }
|
||||
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" className={cn(row.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", row.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black", row.weapon.rarity === 3 && "bg-[linear-gradient(90deg,rgba(0,160,181,1)_0%,rgba(0,0,0,1)_100%)]")}><CardContent className="flex items-center gap-4"><div className="relative size-16 shrink-0 overflow-hidden rounded-lg"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-contain" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">ดาเมทโดยรวม</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">ดาเมทส่วนตัว</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>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
|
||||
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 className="text-3xl text-white">{metric(row.cumulativePercent)}</CardTitle><CardDescription className="text-base font-medium text-white">C{row.level} · {row.name}</CardDescription>{row.iconUrl ? <CardAction><Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /></CardAction> : null}</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><CardAction><a href={`/api/teams/${team.id}/image`} download className={buttonVariants({ variant: "outline", size: "sm" })}><DownloadIcon data-icon="inline-start" />Export image</a></CardAction></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-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</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">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</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><CardAction><a href={`/api/teams/${team.id}/image`} download className={buttonVariants({ variant: "outline", size: "sm" })}><DownloadIcon data-icon="inline-start" />Export image</a></CardAction></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{team.members.map((member) => <div key={member.position} className="min-w-0 text-center"><div className="grid grid-cols-[minmax(0,1fr)_2.75rem] gap-2"><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><div className="flex flex-col gap-2">{member.weapon ? <div className={cn("relative aspect-square overflow-hidden rounded-md bg-muted", member.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", member.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black", member.weapon.rarity === 3 && "bg-[linear-gradient(90deg,rgba(0,160,181,1)_0%,rgba(0,0,0,1)_100%)]")}><Image src={member.weapon.imageUrl} alt={member.weapon.name} fill className="object-contain" /><Kbd className="absolute top-0 right-0">R{member.refinement}</Kbd></div> : <div className="aspect-square rounded-md border bg-muted" />}{member.artifact ? <div className="relative aspect-square overflow-hidden rounded-md bg-muted"><Image src={member.artifact.imageUrl} alt={member.artifact.name} fill className="object-contain" /></div> : <div className="aspect-square rounded-md border bg-muted" />}</div></div><p className="mt-2 truncate text-sm font-medium">C{member.constellation} {member.character.name}</p><p className="text-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</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">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "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,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
Reference in New Issue
Block a user