refactor(guides) : consolidate duplicate guide and Stygian logic

This commit is contained in:
2026-10-07 01:41:13 +07:00 Unverified
parent 3c8f60433a
commit b35c1a4471
17 changed files with 268 additions and 171 deletions
@@ -36,6 +36,7 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { isValidPercent } from "@/lib/guides/percent-input";
const rowSchema = z.object({
level: z.number().int().min(1).max(6),
@@ -69,11 +70,6 @@ const parseConstellationDraft = (value: unknown): ConstellationDraft | null => {
return parsed.success ? parsed.data : null;
};
function validPercent(value: string): boolean {
const number = Number(value);
return value.trim().length > 0 && Number.isFinite(number) && number >= -9999 && number <= 9999;
}
export function ConstellationImageGenerator({
characters,
}: {
@@ -85,7 +81,7 @@ export function ConstellationImageGenerator({
parseConstellationDraft,
);
const character = characters.find((item) => item.key === draft.characterKey);
const valid = Boolean(character && draft.rows.every((row) => validPercent(row.cumulativePercent)));
const valid = Boolean(character && draft.rows.every((row) => isValidPercent(row.cumulativePercent)));
const guide = useMemo<ConstellationImageGuide>(() => ({
coverUrl: character?.imageUrl ?? "",
@@ -153,7 +149,7 @@ export function ConstellationImageGenerator({
<FieldGroup>
{draft.rows.map((row, index) => (
<Field key={row.level} data-invalid={!validPercent(row.cumulativePercent)}>
<Field key={row.level} data-invalid={!isValidPercent(row.cumulativePercent)}>
<div className="grid grid-cols-[3rem_minmax(0,1fr)_auto] items-center gap-3">
<FieldLabel htmlFor={`constellation-image-c${row.level}`}>
C{row.level}
@@ -163,7 +159,7 @@ export function ConstellationImageGenerator({
type="number"
step="0.01"
value={row.cumulativePercent}
aria-invalid={!validPercent(row.cumulativePercent)}
aria-invalid={!isValidPercent(row.cumulativePercent)}
onChange={(event) => setDraft((current) => ({
...current,
rows: current.rows.map((item, rowIndex) => rowIndex === index
+3 -28
View File
@@ -170,6 +170,7 @@ import { elementBackgroundColor, elementIconFilter } from "@/lib/catalog/element
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import { toTeamDraft } from "@/lib/guides/team-draft";
import {
applyPresetFields,
applyTeamFields,
@@ -3194,20 +3195,7 @@ function TeamEditor({
};
const importEverywhereTeams = (sourceTeams: TeamEverywhereImport[]) => {
if (!sourceTeams.length) return;
const importedTeams = sourceTeams.map((team) => ({
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members.map((member) => ({
characterKey: member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
})),
}));
const importedTeams = sourceTeams.map(toTeamDraft);
if (!appendImportedTeams(importedTeams)) return;
setEverywhereImportOpen(false);
setEverywhereSelectedIds(new Set());
@@ -4243,20 +4231,7 @@ function TeamEditor({
checkboxId={`guide-team-${team.id}`}
onCheckedChange={(checked) => setSelectedImports((current) => {
if (checked) {
return { ...current, [team.id]: {
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members.map((member) => ({
characterKey: member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
})),
} };
return { ...current, [team.id]: toTeamDraft(team) };
}
const next = { ...current };
delete next[team.id];
+11 -38
View File
@@ -2,12 +2,10 @@
import Image from "@/components/ui/resilient-image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, useTransition, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
GripVerticalIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
import {
@@ -29,11 +27,9 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import {
saveStygianRecommendationsAction,
syncStygianArchiveAction,
} from "@/app/admin/stygian/actions";
import { saveStygianRecommendationsAction } from "@/app/admin/stygian/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { StygianSyncButton } from "@/components/admin/stygian-sync-button";
import {
ArtifactSelect,
CatalogDropdown,
@@ -70,10 +66,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Spinner } from "@/components/ui/spinner";
import { elementIconFilter } from "@/lib/catalog/element";
import { AutosaveCoordinator, type AutosaveStatus as CoordinatorStatus } from "@/lib/editor/autosave";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { stygianBossPositionLabels, stygianBossPositions, stygianLevels } from "@/lib/stygian/constants";
import {
stygianElements,
type StygianRecommendationItemInput,
@@ -1021,11 +1017,9 @@ export function StygianEditor({
weapons: CatalogItem[];
artifacts: CatalogItem[];
}) {
const router = useRouter();
const [version, setVersion] = useState(schedule.version);
const [level, setLevel] = useState<105 | 110>(110);
const [bossPosition, setBossPosition] = useState(0);
const [pending, startTransition] = useTransition();
return (
<div className="flex flex-col gap-6">
<nav className="flex flex-wrap gap-2" aria-label="Stygian versions">
@@ -1048,29 +1042,7 @@ export function StygianEditor({
</Button>
),
)}
<Button
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await syncStygianArchiveAction();
if (result.status === "error") toast.error(result.message);
else {
toast.success(
`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`,
);
router.refresh();
}
})
}
>
{pending ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCwIcon data-icon="inline-start" />
)}
Sync archive
</Button>
<StygianSyncButton size="sm" />
</nav>
<Tabs
@@ -1078,24 +1050,25 @@ export function StygianEditor({
onValueChange={(next) => setLevel(Number(next) as 105 | 110)}
>
<TabsList>
<TabsTrigger value="105">Level 105</TabsTrigger>
<TabsTrigger value="110">Level 110</TabsTrigger>
{stygianLevels.map((entry) => (
<TabsTrigger key={entry} value={String(entry)}>Level {entry}</TabsTrigger>
))}
</TabsList>
{([105, 110] as const).map((entry) => (
{stygianLevels.map((entry) => (
<TabsContent key={entry} value={String(entry)}>
<Tabs
value={String(bossPosition)}
onValueChange={(next) => setBossPosition(Number(next))}
>
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto group-data-horizontal/tabs:min-h-96" aria-label={`Level ${entry} bosses`}>
{[0, 1, 2].map((position) => {
{stygianBossPositions.map((position) => {
const boss = bosses.find(
(candidate) =>
candidate.level === entry &&
candidate.position === position,
);
const label = boss?.name ?? `Boss ${position + 1}`;
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
const positionLabel = stygianBossPositionLabels[position];
return (
<TabsTrigger
key={position}
+3 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useTransition, type ComponentProps } from "react";
import { RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
@@ -9,11 +9,11 @@ import { syncStygianArchiveAction } from "@/app/admin/stygian/actions";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function StygianSyncButton() {
export function StygianSyncButton({ size }: Pick<ComponentProps<typeof Button>, "size"> = {}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<Button disabled={pending} onClick={() => startTransition(async () => {
<Button size={size} disabled={pending} onClick={() => startTransition(async () => {
const result = await syncStygianArchiveAction();
if (result.status === "error") toast.error(result.message);
else {
+9 -14
View File
@@ -48,6 +48,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { isValidPercent } from "@/lib/guides/percent-input";
const rowSchema = z.object({
id: z.string(),
@@ -92,12 +93,6 @@ const parseWeaponDraft = (value: unknown): WeaponDraft | null => {
return parsed.success ? parsed.data : null;
};
function validPercent(value: string): boolean {
if (!value.trim()) return false;
const number = Number(value);
return Number.isFinite(number) && number >= -9999 && number <= 9999;
}
export function WeaponImageGenerator({
characters,
weapons,
@@ -118,9 +113,9 @@ export function WeaponImageGenerator({
character &&
draft.rows.every((row) =>
compatibleWeapons.some((weapon) => weapon.key === row.weaponKey) &&
(validPercent(row.overallPercent) || validPercent(row.personalPercent)),
(isValidPercent(row.overallPercent) || isValidPercent(row.personalPercent)),
) &&
draft.rows.some((row) => validPercent(row[draft.metric])),
draft.rows.some((row) => isValidPercent(row[draft.metric])),
);
const guide = useMemo<WeaponExportGuide>(() => ({
@@ -141,8 +136,8 @@ export function WeaponImageGenerator({
return [{
id: row.id,
refinement: row.refinement,
overallPercent: validPercent(row.overallPercent) ? row.overallPercent : null,
personalPercent: validPercent(row.personalPercent) ? row.personalPercent : null,
overallPercent: isValidPercent(row.overallPercent) ? row.overallPercent : null,
personalPercent: isValidPercent(row.personalPercent) ? row.personalPercent : null,
note: row.note.trim() || null,
weapon,
}];
@@ -261,13 +256,13 @@ export function WeaponImageGenerator({
</SelectGroup></SelectContent>
</Select>
</Field>
<Field data-invalid={Boolean(row.overallPercent) && !validPercent(row.overallPercent)}>
<Field data-invalid={Boolean(row.overallPercent) && !isValidPercent(row.overallPercent)}>
<FieldLabel htmlFor={`${row.id}-overall`}>Overall %</FieldLabel>
<Input id={`${row.id}-overall`} type="number" value={row.overallPercent} aria-invalid={Boolean(row.overallPercent) && !validPercent(row.overallPercent)} onChange={(event) => updateRow(row.id, { overallPercent: event.target.value })} />
<Input id={`${row.id}-overall`} type="number" value={row.overallPercent} aria-invalid={Boolean(row.overallPercent) && !isValidPercent(row.overallPercent)} onChange={(event) => updateRow(row.id, { overallPercent: event.target.value })} />
</Field>
<Field data-invalid={Boolean(row.personalPercent) && !validPercent(row.personalPercent)}>
<Field data-invalid={Boolean(row.personalPercent) && !isValidPercent(row.personalPercent)}>
<FieldLabel htmlFor={`${row.id}-personal`}>Personal %</FieldLabel>
<Input id={`${row.id}-personal`} type="number" value={row.personalPercent} aria-invalid={Boolean(row.personalPercent) && !validPercent(row.personalPercent)} onChange={(event) => updateRow(row.id, { personalPercent: event.target.value })} />
<Input id={`${row.id}-personal`} type="number" value={row.personalPercent} aria-invalid={Boolean(row.personalPercent) && !isValidPercent(row.personalPercent)} onChange={(event) => updateRow(row.id, { personalPercent: event.target.value })} />
</Field>
</div>
<Field>
+8 -6
View File
@@ -9,6 +9,7 @@ import {
import { elementIconFilter } from "@/lib/catalog/element";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import type { getActiveCatalog } from "@/lib/guides/queries";
import { stygianBossPositionLabels, stygianBossPositions, stygianLevels } from "@/lib/stygian/constants";
import type { getStygianSchedule } from "@/lib/stygian/repository";
import { cn } from "@/lib/utils";
@@ -103,23 +104,24 @@ export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalo
return (
<Tabs defaultValue="110">
<TabsList aria-label="Stygian difficulty">
<TabsTrigger value="105">Level 105</TabsTrigger>
<TabsTrigger value="110">Level 110</TabsTrigger>
{stygianLevels.map((level) => (
<TabsTrigger key={level} value={String(level)}>Level {level}</TabsTrigger>
))}
</TabsList>
{([105, 110] as const).map((level) => (
{stygianLevels.map((level) => (
<TabsContent key={level} value={String(level)}>
<Tabs defaultValue="0">
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto" aria-label={`Level ${level} bosses`}>
{[0, 1, 2].map((position) => {
{stygianBossPositions.map((position) => {
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
const label = boss?.name ?? `Boss ${position + 1}`;
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
const positionLabel = stygianBossPositionLabels[position];
return <TabsTrigger key={position} value={String(position)} aria-label={`${positionLabel}: ${label}`} title={label} className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-1 py-2 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset sm:px-2 sm:py-3">
<StygianBossTab boss={boss} position={position} priority={level === 110} />
</TabsTrigger>;
})}
</TabsList>
{[0, 1, 2].map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
{stygianBossPositions.map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
</Tabs>
</TabsContent>
))}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
import { Card, CardContent } from "@/components/ui/card";
import { LunarisText } from "@/components/public/lunaris-text";
import { parseLunarisText } from "@/lib/catalog/lunaris";
import { stygianBossPositionLabels } from "@/lib/stygian/constants";
export type StygianBossDisplay = {
name: string;
@@ -71,7 +72,7 @@ export function StygianBossTab({
position: number;
priority?: boolean;
}) {
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
const positionLabel = stygianBossPositionLabels[position];
return (
<>
{boss ? (