feat : add import from buzz guide
CI / verify (push) Successful in 59s
CI / build-and-deploy (push) Successful in 1m38s

This commit is contained in:
2026-09-20 00:41:02 +07:00 Unverified
parent 3fe17a2ff7
commit b3bc4c3bb4
7 changed files with 1406 additions and 81 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
import { Translator } from "@/components/translator";
import { getCatalog } from "@/lib/catalog";
import { getTeamGuides } from "@/lib/team-guide-data";
export default async function Home() {
return <Translator catalog={await getCatalog()} />;
const [catalog, teamGuides] = await Promise.all([getCatalog(), getTeamGuides()]);
return <Translator catalog={catalog} teamGuides={teamGuides} />;
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useMemo, useState } from "react";
import { BookOpenIcon, CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { importGuideTeam, type TeamGuide, type TeamGuideTeam } from "@/lib/team-guides";
import { rarityGradientClass } from "@/lib/catalog-rarity";
import { cn } from "@/lib/utils";
import type { Catalog, Team } from "@/lib/translator/types";
function GuideCharacter({ guide, catalog }: { guide: TeamGuide; catalog: Catalog }) {
const character = catalog.characters.find((item) => item.id === guide.characterId);
return <Avatar size="sm" className={rarityGradientClass(character?.rarity ?? null)}><AvatarImage src={character?.imageUrl || undefined} alt={character?.name ?? guide.name} /><AvatarFallback>{character?.name.slice(0, 1) ?? guide.name.slice(0, 1)}</AvatarFallback></Avatar>;
}
function GuidePicker({ guides, catalog, value, onChange }: { guides: TeamGuide[]; catalog: Catalog; value: string; onChange: (id: string) => void }) {
const [open, setOpen] = useState(false);
const selected = guides.find((guide) => guide.id === value);
return <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline" className="w-full justify-between normal-case tracking-normal" />}>
{selected && <GuideCharacter guide={selected} catalog={catalog} />}<span className="min-w-0 flex-1 truncate text-left">{selected?.name ?? "Choose guide"}</span><ChevronsUpDownIcon data-icon="inline-end" />
</PopoverTrigger>
<PopoverContent className="w-(--anchor-width) p-0" align="start"><Command><CommandInput placeholder="Search guides..." /><CommandList><CommandEmpty>No matching guide.</CommandEmpty><CommandGroup>{guides.map((guide) => <CommandItem key={guide.id} value={guide.name} data-checked={guide.id === value} onSelect={() => { onChange(guide.id); setOpen(false); }}><CheckIcon className={cn(guide.id === value ? "opacity-100" : "opacity-0")} /><GuideCharacter guide={guide} catalog={catalog} /><span className="min-w-0 flex-1 truncate">{guide.name}</span><Badge variant="secondary">{guide.teams.length}</Badge></CommandItem>)}</CommandGroup></CommandList></Command></PopoverContent>
</Popover>;
}
function TeamCharacters({ team, catalog }: { team: TeamGuideTeam; catalog: Catalog }) {
return <div className="flex shrink-0 gap-1">{team.members.map((member) => {
const character = catalog.characters.find((item) => item.id === member.characterId);
return <Avatar key={`${team.id}-${member.position}`} size="sm" className={rarityGradientClass(character?.rarity ?? null)}><AvatarImage src={character?.imageUrl || undefined} alt={character?.name ?? ""} /><AvatarFallback>{character?.name.slice(0, 1) ?? "?"}</AvatarFallback></Avatar>;
})}</div>;
}
function TeamPicker({ groups, team, catalog, onChange }: { groups: [string, TeamGuideTeam[]][]; team: TeamGuideTeam | undefined; catalog: Catalog; onChange: (id: string) => void }) {
const [open, setOpen] = useState(false);
return <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline" className="w-full justify-between normal-case tracking-normal" />}>
{team ? <><TeamCharacters team={team} catalog={catalog} /><span className="min-w-0 flex-1 truncate text-left">{team.title}</span></> : <span className="min-w-0 flex-1 text-left text-muted-foreground">Choose team</span>}<ChevronsUpDownIcon data-icon="inline-end" />
</PopoverTrigger>
<PopoverContent className="w-(--anchor-width) p-0" align="start"><Command><CommandInput placeholder="Search teams or characters..." /><CommandList><CommandEmpty>No matching team.</CommandEmpty>{groups.map(([label, teams]) => <CommandGroup key={label} heading={label}>{teams.map((item) => {
const characterNames = item.members.map((member) => catalog.characters.find((character) => character.id === member.characterId)?.name).filter(Boolean).join(" ");
return <CommandItem key={item.id} value={`${item.title} ${characterNames}`} data-checked={item.id === team?.id} onSelect={() => { onChange(item.id); setOpen(false); }}><CheckIcon className={cn(item.id === team?.id ? "opacity-100" : "opacity-0")} /><TeamCharacters team={item} catalog={catalog} /><span className="min-w-0 flex-1 truncate">{item.title}</span></CommandItem>;
})}</CommandGroup>)}</CommandList></Command></PopoverContent>
</Popover>;
}
export function ImportTeamGuideDialog({ catalog, guides, disabled, onImport }: { catalog: Catalog; guides: TeamGuide[]; disabled: boolean; onImport: (team: Team) => void }) {
const [open, setOpen] = useState(false);
const [guideId, setGuideId] = useState(guides[0]?.id ?? "");
const guide = guides.find((item) => item.id === guideId) ?? guides[0];
const [teamId, setTeamId] = useState(guide?.teams[0]?.id ?? "");
const team = guide?.teams.find((item) => item.id === teamId) ?? guide?.teams[0];
const preview = useMemo(() => team ? importGuideTeam(team, catalog) : undefined, [team, catalog]);
const groups = useMemo(() => {
const result = new Map<string, NonNullable<typeof guide>["teams"]>();
for (const item of guide?.teams ?? []) {
const label = item.conditionName?.trim() || "Teams";
result.set(label, [...(result.get(label) ?? []), item]);
}
return [...result.entries()];
}, [guide]);
const chooseGuide = (id: string) => {
const next = guides.find((item) => item.id === id);
setGuideId(id);
setTeamId(next?.teams[0]?.id ?? "");
};
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" disabled={disabled || !guides.length} />}><BookOpenIcon data-icon="inline-start" />IMPORT FROM BUZZ GUIDE</DialogTrigger>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader><DialogTitle>Import from Buzz Guide</DialogTitle><DialogDescription>Choose a public guide team to import its characters, weapons, artifacts, constellations, refinements, and combo.</DialogDescription></DialogHeader>
<FieldGroup>
<Field><FieldLabel>Guide</FieldLabel><GuidePicker guides={guides} catalog={catalog} value={guide?.id ?? ""} onChange={chooseGuide} /><FieldDescription>{guide?.teams.length ?? 0} teams available.</FieldDescription></Field>
<Field><FieldLabel>Team</FieldLabel><TeamPicker groups={groups} team={team} catalog={catalog} onChange={setTeamId} /></Field>
{preview?.error && <Alert variant="destructive"><AlertTitle>Cannot import this team</AlertTitle><AlertDescription>{preview.error}</AlertDescription></Alert>}
{preview?.team && <>
<Field><FieldLabel>Loadout preview</FieldLabel><div className="grid gap-3 sm:grid-cols-2">{preview.team.builds.map((build) => <div key={build.id} className="flex items-center gap-3 border p-3"><Avatar><AvatarImage src={build.characterImageUrl || undefined} alt="" /><AvatarFallback>{build.characterName.slice(0, 1)}</AvatarFallback></Avatar><div className="min-w-0 flex-1"><p className="truncate font-medium">{build.characterName} <span className="text-muted-foreground">C{build.constellation}</span></p><p className="truncate text-sm text-muted-foreground">{build.weaponName} R{build.refinement}</p><p className="truncate text-sm text-muted-foreground">{build.artifactOneName}{build.artifactTwoName ? ` + ${build.artifactTwoName}` : " · 4pc"}</p></div></div>)}</div></Field>
{team?.combo.trim() && team.combo.trim() !== "-" && <Alert><AlertTitle>{preview.team.rotation?.enabled ? "Combo will be included" : "Combo saved as reference"}</AlertTitle><AlertDescription>{preview.team.rotation?.enabled ? "The guide combo is valid shorthand and will be enabled." : "The combo uses unsupported guide notation, so it will be preserved with rotation disabled."}</AlertDescription></Alert>}
</>}
</FieldGroup>
<DialogFooter><DialogClose render={<Button variant="outline" />}>Cancel</DialogClose><Button disabled={disabled || !preview?.team} onClick={() => { if (!preview?.team || disabled) return; onImport(preview.team); setOpen(false); }}>Import team</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
+1090 -79
View File
File diff suppressed because it is too large Load Diff
+39 -1
View File
@@ -1,7 +1,8 @@
import { integer, jsonb, pgSchema, text, varchar } from "drizzle-orm/pg-core";
import { boolean, integer, jsonb, numeric, pgSchema, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import type { CatalogBaseStat } from "@/lib/translator/types";
export const catalogSchema = pgSchema("catalog");
export const guidesSchema = pgSchema("guides");
export const catalogCharacters = catalogSchema.table("character", {
key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), baseStats: jsonb("base_stats").$type<CatalogBaseStat[]>().default([]).notNull(),
@@ -12,3 +13,40 @@ export const catalogWeapons = catalogSchema.table("weapon", {
export const catalogArtifacts = catalogSchema.table("artifact", {
key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), rarity: integer("rarity"), imageKey: text("image_key").notNull(),
});
export const guides = guidesSchema.table("guide", {
id: uuid("id").primaryKey(),
characterKey: varchar("character_key", { length: 64 }).notNull(),
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull(),
isPublic: boolean("is_public").notNull(),
trashedAt: timestamp("trashed_at", { withTimezone: true }),
});
export const teamConditions = guidesSchema.table("team_condition", {
id: uuid("id").primaryKey(),
guideId: uuid("guide_id").notNull(),
name: varchar("name", { length: 255 }),
sortOrder: integer("sort_order").notNull(),
});
export const teamCompositions = guidesSchema.table("team_composition", {
id: uuid("id").primaryKey(),
guideId: uuid("guide_id").notNull(),
title: text("title").notNull(),
combo: text("combo").notNull(),
sortOrder: integer("sort_order").notNull(),
conditionId: uuid("condition_id"),
rotationSeconds: numeric("rotation_seconds").notNull(),
});
export const teamMembers = guidesSchema.table("team_member", {
teamId: uuid("team_id").notNull(),
position: integer("position").notNull(),
characterKey: varchar("character_key", { length: 64 }).notNull(),
weaponKey: varchar("weapon_key", { length: 64 }).notNull(),
artifactKey: varchar("artifact_key", { length: 64 }).notNull(),
secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
constellation: integer("constellation").notNull(),
refinement: integer("refinement").notNull(),
});
+61
View File
@@ -0,0 +1,61 @@
import "server-only";
import { and, asc, eq, isNull } from "drizzle-orm";
import { connection } from "next/server";
import { getDb } from "@/db/client";
import { guides, teamCompositions, teamConditions, teamMembers } from "@/db/schema";
import type { TeamGuide } from "@/lib/team-guides";
export async function getTeamGuides(): Promise<TeamGuide[]> {
await connection();
if (!process.env.DATABASE_URL) return [];
try {
const rows = await getDb().select({
guideId: guides.id,
guideCharacterId: guides.characterKey,
guideName: guides.name,
teamId: teamCompositions.id,
teamTitle: teamCompositions.title,
combo: teamCompositions.combo,
conditionName: teamConditions.name,
position: teamMembers.position,
characterId: teamMembers.characterKey,
weaponId: teamMembers.weaponKey,
artifactOneId: teamMembers.artifactKey,
artifactTwoId: teamMembers.secondArtifactKey,
constellation: teamMembers.constellation,
refinement: teamMembers.refinement,
}).from(guides)
.innerJoin(teamCompositions, eq(teamCompositions.guideId, guides.id))
.innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id))
.leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
.orderBy(asc(guides.sortOrder), asc(guides.name), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamMembers.position));
const result: TeamGuide[] = [];
for (const row of rows) {
let guide = result.at(-1);
if (!guide || guide.id !== row.guideId) {
guide = { id: row.guideId, characterId: row.guideCharacterId, name: row.guideName, teams: [] };
result.push(guide);
}
let team = guide.teams.at(-1);
if (!team || team.id !== row.teamId) {
team = { id: row.teamId, title: row.teamTitle, conditionName: row.conditionName, combo: row.combo, members: [] };
guide.teams.push(team);
}
team.members.push({
position: row.position,
characterId: row.characterId,
weaponId: row.weaponId,
artifactOneId: row.artifactOneId,
artifactTwoId: row.artifactTwoId,
constellation: row.constellation,
refinement: row.refinement,
});
}
return result;
} catch (error) {
console.error("Team guides unavailable", error);
return [];
}
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { generateTeam } from "./translator/generate";
import type { Catalog, CatalogItem } from "./translator/types";
import { importGuideTeam, type TeamGuideTeam } from "./team-guides";
const item = (id: string, key: string, name: string, weaponType?: string): CatalogItem => ({ id, key, name, imageUrl: "", rarity: 5, weaponType, supported: true, baseStats: [] });
const catalog: Catalog = {
version: "test",
characters: [item("char-1", "mavuika", "Mavuika", "claymore"), item("char-2", "xilonen", "Xilonen", "sword")],
weapons: [item("weapon-1", "favoniusgreatsword", "Favonius Greatsword", "claymore"), item("weapon-2", "favoniussword", "Favonius Sword", "sword")],
artifacts: [item("set-1", "obsidiancodex", "Obsidian Codex"), item("set-2", "scrolloftheheroofcindercity", "Scroll")],
};
const guideTeam = (combo = "Mavuika E > Xilonen E N2"): TeamGuideTeam => ({
id: "team-1",
title: "Guide team",
conditionName: "Main Sheet",
combo,
members: [
{ position: 0, characterId: "char-1", weaponId: "weapon-1", artifactOneId: "set-1", artifactTwoId: null, constellation: 2, refinement: 1 },
{ position: 1, characterId: "char-2", weaponId: "weapon-2", artifactOneId: "set-1", artifactTwoId: "set-2", constellation: 0, refinement: 5 },
],
});
describe("team guide import", () => {
it("maps guide loadouts and defaults into a generated team", () => {
const result = importGuideTeam(guideTeam(), catalog);
expect(result.error).toBeUndefined();
expect(result.team!.builds[0]).toMatchObject({ characterKey: "mavuika", weaponKey: "favoniusgreatsword", constellation: 2, refinement: 1, artifactMode: "4pc", artifactOneKey: "obsidiancodex", level: 90, talents: [9, 9, 9], sands: "atk%", goblet: "atk%", circlet: "cr" });
expect(result.team!.builds[1]).toMatchObject({ artifactMode: "2+2", artifactOneKey: "obsidiancodex", artifactTwoKey: "scrolloftheheroofcindercity", refinement: 5 });
expect(result.team!.rotation).toMatchObject({ enabled: true, text: "Mavuika E > Xilonen E N2", repeat: false });
expect(generateTeam(result.team!)).toContain('xilonen add set="scrolloftheheroofcindercity" count=2;');
});
it("preserves unsupported combo text with rotation disabled", () => {
const result = importGuideTeam(guideTeam("Mavuika 3[C ED] Q"), catalog);
expect(result.team!.rotation).toEqual({ enabled: false, text: "Mavuika 3[C ED] Q", repeat: false });
expect(generateTeam(result.team!)).not.toContain("# Rotation");
});
it.each(["", " - "])("omits an empty guide combo %#", (combo) => {
expect(importGuideTeam(guideTeam(combo), catalog).team!.rotation).toBeUndefined();
});
it("reports missing catalog entries before importing", () => {
const source = guideTeam();
source.members[0].weaponId = "missing";
expect(importGuideTeam(source, catalog).error).toBe("Weapon missing is missing from the catalog.");
});
});
+71
View File
@@ -0,0 +1,71 @@
import { createBuild } from "./translator/build";
import { translateRotation } from "./translator/rotation";
import type { Catalog, Team } from "./translator/types";
export interface TeamGuideMember {
position: number;
characterId: string;
weaponId: string;
artifactOneId: string;
artifactTwoId: string | null;
constellation: number;
refinement: number;
}
export interface TeamGuideTeam {
id: string;
title: string;
conditionName: string | null;
combo: string;
members: TeamGuideMember[];
}
export interface TeamGuide {
id: string;
characterId: string;
name: string;
teams: TeamGuideTeam[];
}
export type GuideImportResult = { team: Team; error?: never } | { team?: never; error: string };
export function importGuideTeam(source: TeamGuideTeam, catalog: Catalog): GuideImportResult {
const builds = [] as Team["builds"];
for (const member of source.members) {
const character = catalog.characters.find((item) => item.id === member.characterId);
const weapon = catalog.weapons.find((item) => item.id === member.weaponId);
const artifactOne = catalog.artifacts.find((item) => item.id === member.artifactOneId);
const artifactTwo = member.artifactTwoId ? catalog.artifacts.find((item) => item.id === member.artifactTwoId) : undefined;
if (!character) return { error: `Character ${member.characterId} is missing from the catalog.` };
if (!weapon) return { error: `Weapon ${member.weaponId} is missing from the catalog.` };
if (!artifactOne) return { error: `Artifact ${member.artifactOneId} is missing from the catalog.` };
if (member.artifactTwoId && !artifactTwo) return { error: `Artifact ${member.artifactTwoId} is missing from the catalog.` };
if (character.weaponType && weapon.weaponType !== character.weaponType) return { error: `${weapon.name} is not compatible with ${character.name}.` };
const build = createBuild(catalog, character, `guide-build-${source.id}-${member.position}`);
builds.push({
...build,
weaponId: weapon.id,
weaponKey: weapon.key,
weaponName: weapon.name,
refinement: member.refinement,
constellation: member.constellation,
artifactMode: artifactTwo ? "2+2" : "4pc",
artifactOneId: artifactOne.id,
artifactOneKey: artifactOne.key,
artifactOneName: artifactOne.name,
artifactTwoId: artifactTwo?.id ?? "",
artifactTwoKey: artifactTwo?.key ?? "",
artifactTwoName: artifactTwo?.name ?? "",
});
}
if (!builds.length) return { error: "This guide team has no characters." };
const team: Team = { id: `guide-team-${source.id}`, name: source.title, builds };
const combo = source.combo.trim();
if (combo && combo !== "-") {
team.rotation = { enabled: true, text: combo, repeat: false };
if (translateRotation(team).error) team.rotation.enabled = false;
}
return { team };
}