feat : update
This commit is contained in:
@@ -111,6 +111,8 @@ export default async function StructuredEditorPage({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: group.note,
|
||||
iconMediaId: group.iconMediaId,
|
||||
iconUrl: group.iconUrl,
|
||||
tiers: group.tiers.map((tier) => ({
|
||||
id: tier.id,
|
||||
name: tier.name,
|
||||
@@ -127,6 +129,8 @@ export default async function StructuredEditorPage({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: group.note,
|
||||
iconMediaId: group.iconMediaId,
|
||||
iconUrl: group.iconUrl,
|
||||
rows: group.rows,
|
||||
})),
|
||||
teams: content.teams.map((team) => ({
|
||||
@@ -148,6 +152,8 @@ export default async function StructuredEditorPage({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: group.note,
|
||||
iconMediaId: group.iconMediaId,
|
||||
iconUrl: group.iconUrl,
|
||||
teams: group.teams.map((team) => ({
|
||||
id: team.id,
|
||||
title: team.title,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GripVerticalIcon,
|
||||
HeartPulseIcon,
|
||||
MousePointerClickIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RefreshCcwIcon,
|
||||
SearchIcon,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
} from "@/components/admin/ui/catalog-select";
|
||||
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { ConditionTabIcon } from "@/components/public/condition-switcher";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { LunarisText } from "@/components/public/lunaris-text";
|
||||
import { TeamArtifactIcon } from "@/components/public/team-search";
|
||||
@@ -258,6 +260,8 @@ type WeaponGroupValue = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
rows: WeaponValue[];
|
||||
};
|
||||
type ArtifactTierValue = {
|
||||
@@ -274,6 +278,8 @@ type ArtifactValue = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
tiers: ArtifactTierValue[];
|
||||
};
|
||||
type ArtifactProfile = {
|
||||
@@ -284,6 +290,8 @@ type ArtifactProfile = {
|
||||
recommendedConditions: Array<{
|
||||
name?: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
sands: string[];
|
||||
goblet: string[];
|
||||
circlet: string[];
|
||||
@@ -293,6 +301,8 @@ type ArtifactProfile = {
|
||||
targetConditions: Array<{
|
||||
name?: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
rows: Array<{ stat: string; value: string }>;
|
||||
}>;
|
||||
talents: TalentPriority[];
|
||||
@@ -308,6 +318,8 @@ type ConstellationGroupValue = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
rows: ConstellationValue[];
|
||||
};
|
||||
type CatalogConstellation = {
|
||||
@@ -341,6 +353,8 @@ type TeamGroupValue = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
note: string | null;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
teams: TeamValue[];
|
||||
};
|
||||
|
||||
@@ -373,20 +387,25 @@ function ConditionRenameDialog({
|
||||
open,
|
||||
fallback,
|
||||
value,
|
||||
iconUrl,
|
||||
onOpenChange,
|
||||
onChange,
|
||||
onIconChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
fallback: string;
|
||||
value: string;
|
||||
iconUrl: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChange: (value: string) => void;
|
||||
onIconChange: (icon: { iconMediaId: string | null; iconUrl: string | null }) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!uploading) onOpenChange(next); }}>
|
||||
<DialogContent showCloseButton={!uploading}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename condition</DialogTitle>
|
||||
<DialogTitle>Edit condition</DialogTitle>
|
||||
<DialogDescription>
|
||||
Leave blank to use {fallback}.
|
||||
</DialogDescription>
|
||||
@@ -402,14 +421,29 @@ function ConditionRenameDialog({
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<ConditionIconField iconUrl={iconUrl} onChange={onIconChange} onUploadingChange={setUploading} />
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>Done</Button>
|
||||
<Button type="button" disabled={uploading} onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionIconField({ iconUrl, onChange, onUploadingChange }: {
|
||||
iconUrl: string | null;
|
||||
onChange: (icon: { iconMediaId: string | null; iconUrl: string | null }) => void;
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-3">
|
||||
{iconUrl ? <div className="flex items-center gap-3">
|
||||
<Image src={iconUrl} alt="" width={40} height={40} className="size-10 object-contain" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onChange({ iconMediaId: null, iconUrl: null })}>Remove icon</Button>
|
||||
</div> : null}
|
||||
<MediaUploader onReady={(asset) => onChange({ iconMediaId: asset.id, iconUrl: asset.url })} onUploadingChange={onUploadingChange} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function parseEditorDraft<Draft>(
|
||||
value: unknown,
|
||||
arrayKeys: string[],
|
||||
@@ -891,6 +925,8 @@ function SortableExtraItem({
|
||||
type ConditionNavItem = {
|
||||
clientId: string;
|
||||
name: string;
|
||||
iconMediaId: string | null;
|
||||
iconUrl: string | null;
|
||||
};
|
||||
|
||||
function SortableConditionTab({
|
||||
@@ -922,13 +958,14 @@ function SortableConditionTab({
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn("flex shrink-0 items-center rounded-md", isDragging && "z-10 opacity-60")}
|
||||
>
|
||||
{index > 0 ? <Separator orientation="vertical" aria-hidden="true" className="mx-1 h-6" /> : null}
|
||||
<TabsTrigger
|
||||
value={item.clientId}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onRename}
|
||||
>
|
||||
<MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />
|
||||
{label}
|
||||
<ConditionTabIcon iconUrl={item.iconUrl} />
|
||||
<span className="text-base">{label}</span>
|
||||
{index === 0 ? <Badge variant={active ? "secondary" : "outline"}>Default</Badge> : null}
|
||||
</TabsTrigger>
|
||||
<SimpleTooltip text={`ลากเพื่อจัดลำดับ ${label}`}>
|
||||
@@ -1011,21 +1048,56 @@ function ConditionControls({
|
||||
onDelete,
|
||||
...switcherProps
|
||||
}: ComponentProps<typeof SortableConditionSwitcher> & {
|
||||
onAdd: () => void;
|
||||
onAdd: (details: { name: string; iconMediaId: string | null; iconUrl: string | null }) => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { items, activeIndex } = switcherProps;
|
||||
const atLimit = items.length >= 20;
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newIcon, setNewIcon] = useState<{ iconMediaId: string | null; iconUrl: string | null }>({ iconMediaId: null, iconUrl: null });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const closeCreate = () => {
|
||||
if (uploading) return;
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
setNewIcon({ iconMediaId: null, iconUrl: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SortableConditionSwitcher {...switcherProps} />
|
||||
<SimpleTooltip text="เพิ่ม Condition">
|
||||
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={atLimit} onClick={onAdd}>
|
||||
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={atLimit} onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
<Dialog open={createOpen} onOpenChange={(open) => { if (open) setCreateOpen(true); else closeCreate(); }}>
|
||||
<DialogContent showCloseButton={!uploading}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create condition</DialogTitle>
|
||||
<DialogDescription>Enter a name and optionally upload an icon.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-condition-name">Condition name</FieldLabel>
|
||||
<Input id="new-condition-name" autoFocus maxLength={120} value={newName} onChange={(event) => setNewName(event.target.value)} />
|
||||
</Field>
|
||||
<ConditionIconField iconUrl={newIcon.iconUrl} onChange={setNewIcon} onUploadingChange={setUploading} />
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={uploading} onClick={closeCreate}>Cancel</Button>
|
||||
<Button type="button" disabled={!newName.trim() || uploading} onClick={() => {
|
||||
onAdd({ name: newName.trim(), ...newIcon });
|
||||
closeCreate();
|
||||
}}>Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SimpleTooltip text="Edit Condition">
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Edit Condition" disabled={!items[activeIndex]} onClick={() => switcherProps.onRename(activeIndex)}>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{onDuplicate ? (
|
||||
<SimpleTooltip text="Duplicate Condition">
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={atLimit || !items[activeIndex]} onClick={onDuplicate}>
|
||||
@@ -1096,11 +1168,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
const [groups, setGroups] = useState(() => {
|
||||
const source = initial.weaponGroups.length
|
||||
? initial.weaponGroups
|
||||
: [{ id: "weapon-condition-default", name: null, note: null, rows: [] }];
|
||||
: [{ id: "weapon-condition-default", name: null, note: null, iconMediaId: null, iconUrl: null, rows: [] }];
|
||||
return source.map((group) => ({
|
||||
clientId: group.id,
|
||||
name: group.name ?? "",
|
||||
note: group.note ?? "",
|
||||
iconMediaId: group.iconMediaId ?? null,
|
||||
iconUrl: group.iconUrl ?? null,
|
||||
rows: group.rows.map((row) => ({
|
||||
clientId: row.id,
|
||||
weaponKey: row.weaponKey,
|
||||
@@ -1123,6 +1197,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
groups: groups.map((group) => ({
|
||||
name: group.name.trim() || null,
|
||||
note: group.note.trim() || null,
|
||||
iconMediaId: group.iconMediaId,
|
||||
rows: group.rows.map((row) => ({
|
||||
weaponKey: row.weaponKey,
|
||||
refinement: row.refinement,
|
||||
@@ -1196,8 +1271,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
setGroups(reordered.items);
|
||||
setActiveGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
setGroups([...groups, { clientId: createClientId("weapon-condition"), name: "", note: "", rows: [] }]);
|
||||
onAdd={(details) => {
|
||||
setGroups([...groups, { clientId: createClientId("weapon-condition"), ...details, note: "", rows: [] }]);
|
||||
setActiveGroupIndex(groups.length);
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
@@ -1209,6 +1284,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
clientId: createClientId("weapon-condition"),
|
||||
name: source.name,
|
||||
note: source.note,
|
||||
iconMediaId: source.iconMediaId,
|
||||
iconUrl: source.iconUrl,
|
||||
rows: source.rows.map((row) => ({
|
||||
...row,
|
||||
clientId: createClientId("weapon"),
|
||||
@@ -1227,8 +1304,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
open={renamingWeaponGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingWeaponGroupIndex ?? 0) + 1}`}
|
||||
value={renamingWeaponGroupIndex === null ? "" : groups[renamingWeaponGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingWeaponGroupIndex === null ? null : groups[renamingWeaponGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingWeaponGroupIndex(null); }}
|
||||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
|
||||
@@ -1539,11 +1618,13 @@ function ArtifactEditor({
|
||||
}),
|
||||
);
|
||||
const [groups, setGroups] = useState(() => {
|
||||
const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, tiers: [] }];
|
||||
const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, iconMediaId: null, iconUrl: null, tiers: [] }];
|
||||
return source.map((condition, conditionIndex) => ({
|
||||
clientId: condition.id,
|
||||
name: condition.name ?? "",
|
||||
note: condition.note ?? "",
|
||||
iconMediaId: condition.iconMediaId ?? null,
|
||||
iconUrl: condition.iconUrl ?? null,
|
||||
tiers: (["Late Game", "Early Game"] as const).map((name, tierIndex) => ({
|
||||
name,
|
||||
rows: condition.tiers[tierIndex]?.rows.map((row, rowIndex) => ({ ...row, clientId: `${conditionIndex}-${name}-${rowIndex}` })) ?? [],
|
||||
@@ -1562,8 +1643,8 @@ function ArtifactEditor({
|
||||
const [recommendedGroups, setRecommendedGroups] = useState(() => {
|
||||
const source = profile?.recommendedConditions.length
|
||||
? profile.recommendedConditions
|
||||
: [{ name: null, note: null, sands: profile?.sands ?? [], goblet: profile?.goblet ?? [], circlet: profile?.circlet ?? [], substats: profile?.substats ?? [] }];
|
||||
return source.map((group, index) => ({ ...group, clientId: `recommended-condition-${index}`, name: group.name ?? "", note: group.note ?? "" }));
|
||||
: [{ name: null, note: null, iconMediaId: null, iconUrl: null, sands: profile?.sands ?? [], goblet: profile?.goblet ?? [], circlet: profile?.circlet ?? [], substats: profile?.substats ?? [] }];
|
||||
return source.map((group, index) => ({ ...group, clientId: `recommended-condition-${index}`, name: group.name ?? "", note: group.note ?? "", iconMediaId: group.iconMediaId ?? null, iconUrl: group.iconUrl ?? null }));
|
||||
});
|
||||
const [activeRecommendedGroupIndex, setActiveRecommendedGroupIndex] = useState(0);
|
||||
const [renamingRecommendedGroupIndex, setRenamingRecommendedGroupIndex] = useState<number | null>(null);
|
||||
@@ -1574,7 +1655,7 @@ function ArtifactEditor({
|
||||
const [targetGroups, setTargetGroups] = useState(() => {
|
||||
const source = profile?.targetConditions.length
|
||||
? profile.targetConditions
|
||||
: [{ name: null, note: null, rows: profile?.targets ?? [] }];
|
||||
: [{ name: null, note: null, iconMediaId: null, iconUrl: null, rows: profile?.targets ?? [] }];
|
||||
return source.map((group, index) => {
|
||||
const existing = new Map(group.rows.flatMap((target) => {
|
||||
const stat = normalizeTargetStat(target.stat);
|
||||
@@ -1584,6 +1665,8 @@ function ArtifactEditor({
|
||||
clientId: `target-condition-${index}`,
|
||||
name: group.name ?? "",
|
||||
note: group.note ?? "",
|
||||
iconMediaId: group.iconMediaId ?? null,
|
||||
iconUrl: group.iconUrl ?? null,
|
||||
rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })),
|
||||
};
|
||||
});
|
||||
@@ -1617,6 +1700,7 @@ function ArtifactEditor({
|
||||
groups: groups.map((group) => ({
|
||||
name: group.name.trim() || null,
|
||||
note: group.note.trim() || null,
|
||||
iconMediaId: group.iconMediaId,
|
||||
tiers: group.tiers.map((tier) => ({
|
||||
name: tier.name,
|
||||
rows: tier.rows.map((row) => ({
|
||||
@@ -1631,10 +1715,11 @@ function ArtifactEditor({
|
||||
goblet: recommendedGroups[0].goblet,
|
||||
circlet: recommendedGroups[0].circlet,
|
||||
substats: recommendedGroups[0].substats,
|
||||
recommendedGroups: recommendedGroups.map(({ name, note, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: note.trim() || null, sands, goblet, circlet, substats })),
|
||||
recommendedGroups: recommendedGroups.map(({ name, note, iconMediaId, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: note.trim() || null, iconMediaId, sands, goblet, circlet, substats })),
|
||||
targetGroups: targetGroups.map((group) => ({
|
||||
name: group.name.trim() || null,
|
||||
note: group.note.trim() || null,
|
||||
iconMediaId: group.iconMediaId,
|
||||
rows: group.rows.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })),
|
||||
})),
|
||||
talents,
|
||||
@@ -1687,7 +1772,7 @@ function ArtifactEditor({
|
||||
restoreLocalDraft: (draft) => {
|
||||
setGroups(draft.groups);
|
||||
setActiveArtifactGroupIndex(draft.activeArtifactGroupIndex ?? 0);
|
||||
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
|
||||
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", iconMediaId: null, iconUrl: null, sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
|
||||
setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1)));
|
||||
setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" })));
|
||||
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
|
||||
@@ -1760,8 +1845,8 @@ function ArtifactEditor({
|
||||
setGroups(reordered.items);
|
||||
setActiveArtifactGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
setGroups([...groups, { clientId: createClientId("artifact-condition"), name: "", note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]);
|
||||
onAdd={(details) => {
|
||||
setGroups([...groups, { clientId: createClientId("artifact-condition"), ...details, note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]);
|
||||
setActiveArtifactGroupIndex(groups.length);
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
@@ -1780,8 +1865,10 @@ function ArtifactEditor({
|
||||
open={renamingArtifactGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingArtifactGroupIndex ?? 0) + 1}`}
|
||||
value={renamingArtifactGroupIndex === null ? "" : groups[renamingArtifactGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingArtifactGroupIndex === null ? null : groups[renamingArtifactGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingArtifactGroupIndex(null); }}
|
||||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||||
@@ -1927,14 +2014,14 @@ function ArtifactEditor({
|
||||
setRecommendedGroups(reordered.items);
|
||||
setActiveRecommendedGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: "", note: "", sands: [], goblet: [], circlet: [], substats: [] }]);
|
||||
onAdd={(details) => {
|
||||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), ...details, note: "", sands: [], goblet: [], circlet: [], substats: [] }]);
|
||||
setActiveRecommendedGroupIndex(recommendedGroups.length);
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
const source = recommendedGroups[activeRecommendedGroupIndex];
|
||||
if (!source) return;
|
||||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: source.name, note: source.note, sands: [...source.sands], goblet: [...source.goblet], circlet: [...source.circlet], substats: [...source.substats] }]);
|
||||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: source.name, note: source.note, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, sands: [...source.sands], goblet: [...source.goblet], circlet: [...source.circlet], substats: [...source.substats] }]);
|
||||
setActiveRecommendedGroupIndex(recommendedGroups.length);
|
||||
}}
|
||||
onDelete={() => {
|
||||
@@ -1946,8 +2033,10 @@ function ArtifactEditor({
|
||||
open={renamingRecommendedGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingRecommendedGroupIndex ?? 0) + 1}`}
|
||||
value={renamingRecommendedGroupIndex === null ? "" : recommendedGroups[renamingRecommendedGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingRecommendedGroupIndex === null ? null : recommendedGroups[renamingRecommendedGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingRecommendedGroupIndex(null); }}
|
||||
onChange={(name) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||||
@@ -2013,14 +2102,14 @@ function ArtifactEditor({
|
||||
setTargetGroups(reordered.items);
|
||||
setActiveTargetGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
|
||||
onAdd={(details) => {
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), ...details, note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
const source = targetGroups[activeTargetGroupIndex];
|
||||
if (!source) return;
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, rows: source.rows.map((row) => ({ ...row })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}
|
||||
onDelete={() => {
|
||||
@@ -2032,8 +2121,10 @@ function ArtifactEditor({
|
||||
open={renamingTargetGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingTargetGroupIndex ?? 0) + 1}`}
|
||||
value={renamingTargetGroupIndex === null ? "" : targetGroups[renamingTargetGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingTargetGroupIndex === null ? null : targetGroups[renamingTargetGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingTargetGroupIndex(null); }}
|
||||
onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||||
@@ -2242,11 +2333,13 @@ function ConstellationEditor({
|
||||
const [groups, setGroups] = useState(() => {
|
||||
const source = initial.constellationGroups.length
|
||||
? initial.constellationGroups
|
||||
: [{ id: "constellation-condition-default", name: null, note: DEFAULT_CONSTELLATION_CONDITION_NOTE, rows: [] }];
|
||||
: [{ id: "constellation-condition-default", name: null, note: DEFAULT_CONSTELLATION_CONDITION_NOTE, iconMediaId: null, iconUrl: null, rows: [] }];
|
||||
return source.map((group) => ({
|
||||
clientId: group.id,
|
||||
name: group.name ?? "",
|
||||
note: group.note ?? "",
|
||||
iconMediaId: group.iconMediaId ?? null,
|
||||
iconUrl: group.iconUrl ?? null,
|
||||
rows: makeRows(group.rows),
|
||||
}));
|
||||
});
|
||||
@@ -2257,6 +2350,7 @@ function ConstellationEditor({
|
||||
groups: groups.map((group) => ({
|
||||
name: group.name.trim() || null,
|
||||
note: group.note.trim() || null,
|
||||
iconMediaId: group.iconMediaId,
|
||||
rows: group.rows
|
||||
.filter((row) => row.description.trim() && row.cumulativePercent !== "")
|
||||
.map((row) => ({
|
||||
@@ -2322,12 +2416,12 @@ function ConstellationEditor({
|
||||
setGroups(reordered.items);
|
||||
setActiveConstellationGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
onAdd={(details) => {
|
||||
setGroups([
|
||||
...groups,
|
||||
{
|
||||
clientId: createClientId("constellation-condition"),
|
||||
name: "",
|
||||
...details,
|
||||
note: DEFAULT_CONSTELLATION_CONDITION_NOTE,
|
||||
rows: makeRows(),
|
||||
},
|
||||
@@ -2343,8 +2437,10 @@ function ConstellationEditor({
|
||||
open={renamingConstellationGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingConstellationGroupIndex ?? 0) + 1}`}
|
||||
value={renamingConstellationGroupIndex === null ? "" : groups[renamingConstellationGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingConstellationGroupIndex === null ? null : groups[renamingConstellationGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingConstellationGroupIndex(null); }}
|
||||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
{groups[activeConstellationGroupIndex] ? [groups[activeConstellationGroupIndex]].map((group) => {
|
||||
const groupIndex = activeConstellationGroupIndex;
|
||||
@@ -2563,8 +2659,8 @@ function TeamEditor({
|
||||
return id;
|
||||
};
|
||||
const [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
|
||||
? initial.teamGroups.map((group) => ({ clientId: group.id, name: group.name ?? "", note: group.note ?? "", teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
|
||||
: [{ clientId: "team-condition-default", name: "", note: "", teams: [] as TeamToolValue[] }]);
|
||||
? initial.teamGroups.map((group) => ({ clientId: group.id, name: group.name ?? "", note: group.note ?? "", iconMediaId: group.iconMediaId ?? null, iconUrl: group.iconUrl ?? null, teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
|
||||
: [{ clientId: "team-condition-default", name: "", note: "", iconMediaId: null, iconUrl: null, teams: [] as TeamToolValue[] }]);
|
||||
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
|
||||
const [renamingTeamGroupIndex, setRenamingTeamGroupIndex] = useState<number | null>(null);
|
||||
const teams = useMemo(() => teamGroups[activeTeamGroupIndex]?.teams ?? [], [activeTeamGroupIndex, teamGroups]);
|
||||
@@ -2642,7 +2738,7 @@ function TeamEditor({
|
||||
const filteredImportTeams = importTeamSearch.items;
|
||||
const filteredImportGuides = importGuideSearch.items;
|
||||
const value = {
|
||||
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.teams.map((team) => ({
|
||||
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, iconMediaId: group.iconMediaId, teams: group.teams.map((team) => ({
|
||||
title: team.title,
|
||||
rotationSeconds: Number(team.rotationSeconds),
|
||||
combo: team.combo,
|
||||
@@ -3006,8 +3102,8 @@ function TeamEditor({
|
||||
setTeamGroups(reordered.items);
|
||||
setActiveTeamGroupIndex(reordered.activeIndex);
|
||||
}}
|
||||
onAdd={() => {
|
||||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: "", note: "", teams: [] }]);
|
||||
onAdd={(details) => {
|
||||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), ...details, note: "", teams: [] }]);
|
||||
setActiveTeamGroupIndex(teamGroups.length);
|
||||
setSelectedTeamIds(new Set());
|
||||
setSourceTeamId("");
|
||||
@@ -3016,7 +3112,7 @@ function TeamEditor({
|
||||
const source = teamGroups[activeTeamGroupIndex];
|
||||
if (!source) return;
|
||||
const copiedTeams = source.teams.map((team) => ({ ...team, clientId: createClientId("team"), members: team.members.map((member) => ({ ...member })) }));
|
||||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: source.name, note: source.note, teams: copiedTeams }]);
|
||||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: source.name, note: source.note, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, teams: copiedTeams }]);
|
||||
setActiveTeamGroupIndex(teamGroups.length);
|
||||
setSelectedTeamIds(new Set());
|
||||
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
|
||||
@@ -3031,8 +3127,10 @@ function TeamEditor({
|
||||
open={renamingTeamGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingTeamGroupIndex ?? 0) + 1}`}
|
||||
value={renamingTeamGroupIndex === null ? "" : teamGroups[renamingTeamGroupIndex]?.name ?? ""}
|
||||
iconUrl={renamingTeamGroupIndex === null ? null : teamGroups[renamingTeamGroupIndex]?.iconUrl ?? null}
|
||||
onOpenChange={(open) => { if (!open) setRenamingTeamGroupIndex(null); }}
|
||||
onChange={(name) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, name } : group))}
|
||||
onIconChange={(icon) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, ...icon } : group))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||||
|
||||
@@ -20,9 +20,11 @@ export interface UploadedMedia {
|
||||
export function MediaUploader({
|
||||
onReady,
|
||||
onFileSelected,
|
||||
onUploadingChange,
|
||||
}: {
|
||||
onReady: (media: UploadedMedia) => void;
|
||||
onFileSelected?: (file: File) => void;
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const [state, setState] = useState<"idle" | "uploading">("idle");
|
||||
@@ -40,6 +42,7 @@ export function MediaUploader({
|
||||
|
||||
try {
|
||||
setState("uploading");
|
||||
onUploadingChange?.(true);
|
||||
const body = new FormData();
|
||||
body.set("file", file);
|
||||
const response = await fetch("/api/admin/media", {
|
||||
@@ -54,6 +57,7 @@ export function MediaUploader({
|
||||
toast.error(cause instanceof Error ? cause.message : "อัปโหลดไม่สำเร็จ");
|
||||
} finally {
|
||||
setState("idle");
|
||||
onUploadingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { MousePointerClickIcon } from "lucide-react";
|
||||
import { Children, type ReactNode, useState } from "react";
|
||||
|
||||
import { Children, Fragment, type ReactNode, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export function ConditionTabIcon({ iconUrl }: { iconUrl?: string | null }) {
|
||||
return iconUrl
|
||||
? <Image src={iconUrl} alt="" width={32} height={32} className="size-8 shrink-0 object-contain" aria-hidden="true" data-icon="inline-start" />
|
||||
: <MousePointerClickIcon className="size-5 text-primary group-aria-pressed/tabs:text-primary-foreground" aria-hidden="true" data-icon="inline-start" />;
|
||||
}
|
||||
|
||||
export function ConditionSwitcher({
|
||||
children,
|
||||
label = "เลือก Condition",
|
||||
labels,
|
||||
iconUrls,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
labels?: readonly string[];
|
||||
iconUrls?: readonly (string | null | undefined)[];
|
||||
}) {
|
||||
const items = Children.toArray(children);
|
||||
const [active, setActive] = useState("0");
|
||||
@@ -25,13 +34,13 @@ export function ConditionSwitcher({
|
||||
<ScrollableNav ariaLabel={label} className="w-full min-w-0">
|
||||
<TabsList aria-label={label} className="mx-auto shrink-0">
|
||||
{items.map((_, index) => (
|
||||
<TabsTrigger
|
||||
key={index}
|
||||
value={String(index)}
|
||||
>
|
||||
<MousePointerClickIcon className="text-primary group-aria-pressed/tabs:text-primary-foreground" aria-hidden="true" data-icon="inline-start" />
|
||||
{labels?.[index] || `Condition ${index + 1}`}
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <Separator orientation="vertical" aria-hidden="true" className="mx-1 h-4" /> : null}
|
||||
<TabsTrigger value={String(index)} className="outline-primary m-2">
|
||||
<ConditionTabIcon iconUrl={iconUrls?.[index]} />
|
||||
<span className="font-medium text-lg">{labels?.[index] || `Condition ${index + 1}`}</span>
|
||||
</TabsTrigger>
|
||||
</Fragment>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollableNav>
|
||||
|
||||
@@ -453,6 +453,7 @@ function WeaponSectionContent({
|
||||
<ConditionSwitcher
|
||||
label="เลือกเงื่อนไขอาวุธ"
|
||||
labels={weaponGroups.map((group, index) => group.name || (exporting ? `Condition ${index + 1}` : `เงื่อนไข ${index + 1}`))}
|
||||
iconUrls={weaponGroups.map((group) => group.iconUrl)}
|
||||
>
|
||||
{weaponGroups.map((group, groupIndex, groups) => (
|
||||
<div key={group.id} className="relative z-10 flex flex-col gap-3">
|
||||
@@ -756,7 +757,7 @@ function ArtifactSectionContent({
|
||||
{" "}
|
||||
<div className="artifact-export-left flex flex-col gap-5">
|
||||
{exporting ? recommendedCards : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={recommendedConditions.map((condition) => condition.iconUrl)}>
|
||||
{recommendedCards}
|
||||
</ConditionSwitcher>
|
||||
)}
|
||||
@@ -814,7 +815,7 @@ function ArtifactSectionContent({
|
||||
</Card>)}
|
||||
</div>
|
||||
));
|
||||
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>{tierCards}</ConditionSwitcher>;
|
||||
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.artifactGroups.map((condition) => condition.iconUrl)}>{tierCards}</ConditionSwitcher>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -863,7 +864,7 @@ function ArtifactSectionContent({
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} iconUrls={profile.targetConditions.filter((group) => group.rows.length).map((group) => group.iconUrl)}>
|
||||
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
||||
<div key={groupIndex}>
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
||||
@@ -1187,7 +1188,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group) => group.iconUrl)}>
|
||||
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
|
||||
<div key={group.id} className="flex flex-col gap-4">
|
||||
<div>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRightIcon, MousePointerClickIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { ConditionTabIcon } from "@/components/public/condition-switcher";
|
||||
import { ExportText } from "@/components/public/export-language";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -451,7 +452,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
|
||||
<Tabs className="mx-auto" value={activeCondition} onValueChange={setActiveCondition}>
|
||||
<TabsList aria-label="เลือกเงื่อนไขทีม">
|
||||
{groups.map((group, index) => <TabsTrigger key={group.id} value={String(index)}><MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />{group.name || `เงื่อนไข ${index + 1}`}</TabsTrigger>)}
|
||||
{groups.map((group, index) => <Fragment key={group.id}>
|
||||
{index > 0 ? <Separator orientation="vertical" aria-hidden="true" className="mx-1 h-6" /> : null}
|
||||
<TabsTrigger value={String(index)}><ConditionTabIcon iconUrl={group.iconUrl} /><span className="text-base">{group.name || `เงื่อนไข ${index + 1}`}</span></TabsTrigger>
|
||||
</Fragment>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</ScrollableNav>
|
||||
|
||||
+6
-2
@@ -524,6 +524,7 @@ export const weaponConditions = guidesSchema.table(
|
||||
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 120 }),
|
||||
note: text("note"),
|
||||
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
},
|
||||
(table) => [index("weapon_condition_order_idx").on(table.guideId, table.sortOrder)],
|
||||
@@ -551,6 +552,7 @@ export const artifactConditions = guidesSchema.table(
|
||||
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 120 }),
|
||||
note: text("note"),
|
||||
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
},
|
||||
(table) => [index("artifact_condition_order_idx").on(table.guideId, table.sortOrder)],
|
||||
@@ -588,12 +590,12 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
|
||||
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
|
||||
substats: jsonb("substats").$type<string[]>().default([]).notNull(),
|
||||
recommendedConditions: jsonb("recommended_conditions")
|
||||
.$type<Array<{ name?: string | null; note: string | null; sands: string[]; goblet: string[]; circlet: string[]; substats: string[] }>>()
|
||||
.$type<Array<{ name?: string | null; note: string | null; iconMediaId?: string | null; sands: string[]; goblet: string[]; circlet: string[]; substats: string[] }>>()
|
||||
.default([])
|
||||
.notNull(),
|
||||
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
|
||||
targetConditions: jsonb("target_conditions")
|
||||
.$type<Array<{ name?: string | null; note: string | null; rows: Array<{ stat: string; value: string }> }>>()
|
||||
.$type<Array<{ name?: string | null; note: string | null; iconMediaId?: string | null; rows: Array<{ stat: string; value: string }> }>>()
|
||||
.default([])
|
||||
.notNull(),
|
||||
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" | "eq" }>>().default([]).notNull(),
|
||||
@@ -608,6 +610,7 @@ export const teamConditions = guidesSchema.table(
|
||||
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 120 }),
|
||||
note: text("note"),
|
||||
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
},
|
||||
(table) => [index("team_condition_order_idx").on(table.guideId, table.sortOrder)],
|
||||
@@ -620,6 +623,7 @@ export const constellationConditions = guidesSchema.table(
|
||||
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 120 }),
|
||||
note: text("note"),
|
||||
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
},
|
||||
(table) => [index("constellation_condition_order_idx").on(table.guideId, table.sortOrder)],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "guides"."artifact_condition" ADD COLUMN "icon_media_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."constellation_condition" ADD COLUMN "icon_media_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."team_condition" ADD COLUMN "icon_media_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."weapon_condition" ADD COLUMN "icon_media_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."artifact_condition" ADD CONSTRAINT "artifact_condition_icon_media_id_media_id_fk" FOREIGN KEY ("icon_media_id") REFERENCES "public"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."constellation_condition" ADD CONSTRAINT "constellation_condition_icon_media_id_media_id_fk" FOREIGN KEY ("icon_media_id") REFERENCES "public"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."team_condition" ADD CONSTRAINT "team_condition_icon_media_id_media_id_fk" FOREIGN KEY ("icon_media_id") REFERENCES "public"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "guides"."weapon_condition" ADD CONSTRAINT "weapon_condition_icon_media_id_media_id_fk" FOREIGN KEY ("icon_media_id") REFERENCES "public"."media"("id") ON DELETE restrict ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -505,6 +505,13 @@
|
||||
"when": 1791100706838,
|
||||
"tag": "0071_first_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1791206244855,
|
||||
"tag": "0072_sweet_joseph",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
-4
@@ -161,16 +161,36 @@ async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof g
|
||||
return guide;
|
||||
}
|
||||
|
||||
async function syncConditionIconReferences(
|
||||
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
|
||||
previous: Array<string | null | undefined>,
|
||||
next: Array<string | null | undefined>,
|
||||
) {
|
||||
const nextIds = [...new Set(next.filter((id): id is string => Boolean(id)))];
|
||||
if (nextIds.length) {
|
||||
const ready = await tx.select({ id: media.id }).from(media).where(and(inArray(media.id, nextIds), eq(media.status, "ready")));
|
||||
if (ready.length !== nextIds.length) throw new Error("ไม่พบภาพ Condition ที่อัปโหลดแล้ว");
|
||||
}
|
||||
const changes = new Map<string, number>();
|
||||
for (const id of previous) if (id) changes.set(id, (changes.get(id) ?? 0) - 1);
|
||||
for (const id of next) if (id) changes.set(id, (changes.get(id) ?? 0) + 1);
|
||||
for (const [id, change] of changes) {
|
||||
if (change) await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} + ${change}, 0)` }).where(eq(media.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWeapons(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||
const data = weaponSectionSchema.parse(input);
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "weapon", invalidationDetails(context));
|
||||
const previous = await tx.select({ iconMediaId: weaponConditions.iconMediaId }).from(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
|
||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
|
||||
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
||||
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||
}
|
||||
await auditGuide(tx, context, "guide.weapon.saved", updated, "weapon");
|
||||
@@ -259,9 +279,20 @@ export async function saveArtifacts(guideId: string, input: unknown, context: Gu
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", invalidationDetails(context));
|
||||
const previousConditions = await tx.select({ iconMediaId: artifactConditions.iconMediaId }).from(artifactConditions).where(eq(artifactConditions.guideId, guideId));
|
||||
const [previousProfile] = await tx.select({ recommendedConditions: artifactProfiles.recommendedConditions, targetConditions: artifactProfiles.targetConditions }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1);
|
||||
await syncConditionIconReferences(tx, [
|
||||
...previousConditions.map((group) => group.iconMediaId),
|
||||
...(previousProfile?.recommendedConditions ?? []).map((group) => group.iconMediaId),
|
||||
...(previousProfile?.targetConditions ?? []).map((group) => group.iconMediaId),
|
||||
], [
|
||||
...data.groups.map((group) => group.iconMediaId),
|
||||
...(data.recommendedGroups ?? []).map((group) => group.iconMediaId),
|
||||
...data.targetGroups.map((group) => group.iconMediaId),
|
||||
]);
|
||||
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
|
||||
for (const [conditionOrder, condition] of data.groups.entries()) {
|
||||
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
|
||||
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, iconMediaId: condition.iconMediaId, sortOrder: conditionOrder }).returning();
|
||||
for (const [sortOrder, group] of condition.tiers.entries()) {
|
||||
const [created] = await tx.insert(artifactGroups).values({ conditionId: createdCondition.id, name: group.name, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
|
||||
@@ -277,10 +308,12 @@ export async function saveConstellations(guideId: string, input: unknown, contex
|
||||
const data = constellationSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations", invalidationDetails(context));
|
||||
const previous = await tx.select({ iconMediaId: constellationConditions.iconMediaId }).from(constellationConditions).where(eq(constellationConditions.guideId, guideId));
|
||||
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
|
||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
|
||||
await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(constellationConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
||||
const [created] = await tx.insert(constellationConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
|
||||
}
|
||||
await auditGuide(tx, context, "guide.constellations.saved", updated, "constellations");
|
||||
@@ -313,9 +346,11 @@ export async function saveTeams(guideId: string, input: unknown, context: GuideM
|
||||
}
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team", invalidationDetails(context));
|
||||
const previous = await tx.select({ iconMediaId: teamConditions.iconMediaId }).from(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
|
||||
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||
for (const [groupOrder, group] of data.groups.entries()) {
|
||||
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, sortOrder: groupOrder }).returning();
|
||||
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder: groupOrder }).returning();
|
||||
for (const [sortOrder, team] of group.teams.entries()) {
|
||||
const [created] = await tx.insert(teamCompositions).values({ guideId, conditionId: condition.id, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
|
||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
|
||||
@@ -583,7 +618,18 @@ export async function permanentlyDeleteGuide(
|
||||
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||
const bleeding = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
|
||||
const weapons = await tx.select({ iconMediaId: weaponConditions.iconMediaId }).from(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||
const artifacts = await tx.select({ iconMediaId: artifactConditions.iconMediaId }).from(artifactConditions).where(eq(artifactConditions.guideId, guideId));
|
||||
const constellations = await tx.select({ iconMediaId: constellationConditions.iconMediaId }).from(constellationConditions).where(eq(constellationConditions.guideId, guideId));
|
||||
const teams = await tx.select({ iconMediaId: teamConditions.iconMediaId }).from(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||
const profiles = await tx.select({ recommendedConditions: artifactProfiles.recommendedConditions, targetConditions: artifactProfiles.targetConditions }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId));
|
||||
const conditionIconIds = [
|
||||
...weapons, ...artifacts, ...constellations, ...teams,
|
||||
...(profiles[0]?.recommendedConditions ?? []),
|
||||
...(profiles[0]?.targetConditions ?? []),
|
||||
].map((condition) => condition.iconMediaId);
|
||||
await tx.delete(guides).where(eq(guides.id, guideId));
|
||||
await syncConditionIconReferences(tx, conditionIconIds, []);
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
|
||||
if (guide.posterArtMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
|
||||
|
||||
@@ -25,9 +25,9 @@ vi.mock("@/db", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
artifactConditions, constellationConditions, extraSectionRows,
|
||||
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
|
||||
guides, guideSections, teamConditions, weaponConditions,
|
||||
catalogMaterials, guidePosterBleeding,
|
||||
catalogMaterials, guidePosterBleeding, media,
|
||||
} from "@/db/schema";
|
||||
import { getAdminGuideContent } from "./queries";
|
||||
|
||||
@@ -78,4 +78,27 @@ describe("admin section reads", () => {
|
||||
}
|
||||
expect(result?.extras).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resolves condition icons from one batched media read", async () => {
|
||||
const iconMediaId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
mocks.rows.set(weaponConditions, [{ id: "weapon-condition", iconMediaId }]);
|
||||
mocks.rows.set(artifactConditions, [{ id: "artifact-condition", iconMediaId }]);
|
||||
mocks.rows.set(constellationConditions, [{ id: "constellation-condition", iconMediaId }]);
|
||||
mocks.rows.set(teamConditions, [{ id: "team-condition", iconMediaId }]);
|
||||
mocks.rows.set(artifactProfiles, [{
|
||||
sands: [], goblet: [], circlet: [], substats: [], targets: [],
|
||||
recommendedConditions: [{ name: "Recommended", note: null, iconMediaId, sands: [], goblet: [], circlet: [], substats: [] }],
|
||||
targetConditions: [{ name: "Target", note: null, iconMediaId, rows: [] }],
|
||||
}]);
|
||||
mocks.rows.set(media, [{ id: iconMediaId, objectKey: "condition-icon.png" }]);
|
||||
|
||||
const result = await getAdminGuideContent("test");
|
||||
expect(result?.weaponGroups[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(result?.artifactGroups[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(result?.constellationGroups[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(result?.teamGroups[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(result?.artifactProfile?.recommendedConditions[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(result?.artifactProfile?.targetConditions[0].iconUrl).toBe("/media/condition-icon.png");
|
||||
expect(mocks.reads.filter((table) => table === media)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-4
@@ -425,7 +425,15 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
|
||||
: Promise.resolve([]),
|
||||
() => wants("overview") ? getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)) : Promise.resolve([]),
|
||||
]);
|
||||
const iconIds = [...new Set([
|
||||
...weaponConditionsRows, ...artifactConditionRows, ...constellationGroups, ...teamConditionRows,
|
||||
...(profile[0]?.recommendedConditions ?? []), ...(profile[0]?.targetConditions ?? []),
|
||||
].map((condition) => condition.iconMediaId).filter((id): id is string => Boolean(id)))];
|
||||
const iconAssetsPromise = iconIds.length ? getDb().select({ id: media.id, objectKey: media.objectKey }).from(media).where(and(inArray(media.id, iconIds), eq(media.status, "ready"))) : Promise.resolve([]);
|
||||
const groups = artifactConditionRows.length ? await getDb().select().from(artifactGroups).where(inArray(artifactGroups.conditionId, artifactConditionRows.map((condition) => condition.id))).orderBy(asc(artifactGroups.sortOrder)) : [];
|
||||
const iconAssets = await iconAssetsPromise;
|
||||
const iconUrls = new Map(iconAssets.map((asset) => [asset.id, publicMediaUrl(asset.objectKey)]));
|
||||
const iconUrl = (id: string | null | undefined) => id ? iconUrls.get(id) ?? null : null;
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
|
||||
@@ -485,11 +493,12 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
|
||||
sections,
|
||||
availableSections,
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
artifactGroups: artifactConditionRows.map((condition) => ({ ...condition, tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifactByKey.get(recommendation.secondArtifactKey ?? ""); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })) })),
|
||||
artifactProfile: profile[0] ? { ...profile[0], recommendedConditions: profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, rows: profile[0].targets }] } : null,
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
artifactGroups: artifactConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifactByKey.get(recommendation.secondArtifactKey ?? ""); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })) })),
|
||||
artifactProfile: profile[0] ? { ...profile[0], recommendedConditions: (profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, iconMediaId: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })), targetConditions: (profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, iconMediaId: null, rows: profile[0].targets }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })) } : null,
|
||||
constellationGroups: constellationGroups.map((group) => ({
|
||||
...group,
|
||||
iconUrl: iconUrl(group.iconMediaId),
|
||||
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
|
||||
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
|
||||
return {
|
||||
@@ -502,7 +511,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
|
||||
}),
|
||||
})),
|
||||
teams: hydratedTeams,
|
||||
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, name: null, note: null, sortOrder: 0, teams: hydratedTeams }],
|
||||
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, name: null, note: null, iconMediaId: null, iconUrl: null, sortOrder: 0, teams: hydratedTeams }],
|
||||
extras: sections.filter((section) => section.kind === "extra" && wants(section.slug)).map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
|
||||
...extraRow,
|
||||
catalogItems: extraRow.catalogItems.map((reference) => {
|
||||
|
||||
@@ -78,6 +78,16 @@ describe("structured guide validation", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ name: "x".repeat(121), note: null, rows: [row] }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts optional condition icons and rejects invalid media IDs", () => {
|
||||
const iconMediaId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const weapon = { expectedVersion: 1, note: null, groups: [{ name: "DPS", note: null, iconMediaId, rows: [] }] };
|
||||
expect(weaponSectionSchema.parse(weapon).groups[0].iconMediaId).toBe(iconMediaId);
|
||||
expect(weaponSectionSchema.parse({ ...weapon, groups: [{ name: null, note: null, rows: [] }] }).groups[0].iconMediaId).toBeNull();
|
||||
expect(weaponSectionSchema.safeParse({ ...weapon, groups: [{ ...weapon.groups[0], iconMediaId: "bad" }] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: [{ name: "C1", note: null, iconMediaId, rows: [] }] }).groups[0].iconMediaId).toBe(iconMediaId);
|
||||
expect(teamSectionSchema.parse({ expectedVersion: 1, groups: [{ name: "Team", note: null, iconMediaId, teams: [] }] }).groups[0].iconMediaId).toBe(iconMediaId);
|
||||
});
|
||||
|
||||
it("limits weapon and constellation header notes", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
|
||||
const percent = z.coerce.number().finite().min(-9999).max(9999);
|
||||
const conditionName = z.string().trim().max(120).nullable().optional().transform((value) => value || null);
|
||||
const conditionIconMediaId = z.string().uuid().nullable().optional().transform((value) => value ?? null);
|
||||
const catalogKey = z.string().trim().min(1).max(64);
|
||||
export const weaponRefinementSchema = z.union([
|
||||
z.number().int().min(1).max(5).transform(String),
|
||||
@@ -56,6 +57,7 @@ export const weaponSectionSchema = z.strictObject({
|
||||
groups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
rows: z.array(z.strictObject({
|
||||
weaponKey: catalogKey,
|
||||
refinement: weaponRefinementSchema,
|
||||
@@ -93,6 +95,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
groups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
tiers: z.tuple([
|
||||
artifactGroupSchema("Late Game"),
|
||||
artifactGroupSchema("Early Game"),
|
||||
@@ -105,6 +108,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
recommendedGroups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
sands: z.array(z.enum(mainStatOptions.sands)),
|
||||
goblet: z.array(z.enum(mainStatOptions.goblet)),
|
||||
circlet: z.array(z.enum(mainStatOptions.circlet)),
|
||||
@@ -113,6 +117,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
targetGroups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
rows: z.array(z.strictObject({
|
||||
stat: z.enum(targetStatOptions),
|
||||
value: z.string().trim().min(1).max(80),
|
||||
@@ -142,6 +147,7 @@ export const constellationSectionSchema = z.strictObject({
|
||||
groups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
rows: constellationRowsSchema,
|
||||
})).max(20),
|
||||
});
|
||||
@@ -151,6 +157,7 @@ export const teamSectionSchema = z.strictObject({
|
||||
groups: z.array(z.strictObject({
|
||||
name: conditionName,
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
iconMediaId: conditionIconMediaId,
|
||||
teams: z.array(z.strictObject({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
rotationSeconds: z.coerce.number().finite().positive().max(9999),
|
||||
|
||||
Reference in New Issue
Block a user