refactor(guides) : consolidate duplicate guide and Stygian logic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user