feat : add import from buzz guide
This commit is contained in:
+3
-1
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+39
-1
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user