feat : add codition for stats build

This commit is contained in:
2026-09-12 15:01:22 +07:00 Unverified
parent 6cc0945e2c
commit 54ae6a4b02
15 changed files with 2951 additions and 98 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ export default async function AdminLoginPage() {
if (await getAdminSession()) redirect("/admin"); if (await getAdminSession()) redirect("/admin");
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY; const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
if (!turnstileSiteKey) { if (process.env.NODE_ENV !== "development" && !turnstileSiteKey) {
throw new Error("TURNSTILE_SITE_KEY is required for administrator login."); throw new Error("TURNSTILE_SITE_KEY is required for administrator login.");
} }
@@ -18,7 +18,7 @@ export default async function AdminLoginPage() {
<div className="min-h-svh"> <div className="min-h-svh">
<SiteHeader /> <SiteHeader />
<main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12"> <main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12">
<LoginCard turnstileSiteKey={turnstileSiteKey} /> <LoginCard turnstileSiteKey={turnstileSiteKey ?? ""} />
</main> </main>
</div> </div>
); );
+86 -20
View File
@@ -248,6 +248,14 @@ type ArtifactProfile = {
goblet: string[]; goblet: string[];
circlet: string[]; circlet: string[];
substats: string[]; substats: string[];
recommendedConditions: Array<{
name?: string | null;
note: string | null;
sands: string[];
goblet: string[];
circlet: string[];
substats: string[];
}>;
targets: Array<{ stat: string; value: string }>; targets: Array<{ stat: string; value: string }>;
targetConditions: Array<{ targetConditions: Array<{
name?: string | null; name?: string | null;
@@ -745,7 +753,7 @@ function StatSelect({
: undefined} : undefined}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-max min-w-(--anchor-width) max-w-(--available-width)">
<SelectGroup> <SelectGroup>
{options.map((option) => ( {options.map((option) => (
<SelectItem <SelectItem
@@ -1756,10 +1764,18 @@ function ArtifactEditor({
})) ?? [], })) ?? [],
})), })),
); );
const [sands, setSands] = useState(profile?.sands ?? []); const [recommendedGroups, setRecommendedGroups] = useState(() => {
const [goblet, setGoblet] = useState(profile?.goblet ?? []); const source = profile?.recommendedConditions.length
const [circlet, setCirclet] = useState(profile?.circlet ?? []); ? profile.recommendedConditions
const [substats, setSubstats] = useState(profile?.substats ?? []); : [{ 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 ?? "" }));
});
const [activeRecommendedGroupIndex, setActiveRecommendedGroupIndex] = useState(0);
const [renamingRecommendedGroupIndex, setRenamingRecommendedGroupIndex] = useState<number | null>(null);
const recommended = recommendedGroups[activeRecommendedGroupIndex];
const { sands, goblet, circlet, substats } = recommended;
const setRecommendedStat = (slot: "sands" | "goblet" | "circlet" | "substats", values: string[]) =>
setRecommendedGroups((current) => current.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, [slot]: values } : group));
const [targetGroups, setTargetGroups] = useState(() => { const [targetGroups, setTargetGroups] = useState(() => {
const source = profile?.targetConditions.length const source = profile?.targetConditions.length
? profile.targetConditions ? profile.targetConditions
@@ -1812,10 +1828,11 @@ function ArtifactEditor({
note: row.note?.trim() || null, note: row.note?.trim() || null,
})), })),
})), })),
sands, sands: recommendedGroups[0].sands,
goblet, goblet: recommendedGroups[0].goblet,
circlet, circlet: recommendedGroups[0].circlet,
substats, 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 })),
targetGroups: targetGroups.map((group) => ({ targetGroups: targetGroups.map((group) => ({
name: group.name.trim() || null, name: group.name.trim() || null,
note: group.note.trim() || null, note: group.note.trim() || null,
@@ -1836,6 +1853,8 @@ function ArtifactEditor({
goblet, goblet,
circlet, circlet,
substats, substats,
recommendedGroups,
activeRecommendedGroupIndex,
targetGroups, targetGroups,
activeTargetGroupIndex, activeTargetGroupIndex,
talents, talents,
@@ -1865,10 +1884,8 @@ function ArtifactEditor({
), ),
restoreLocalDraft: (draft) => { restoreLocalDraft: (draft) => {
setGroups(draft.groups); setGroups(draft.groups);
setSands(draft.sands); setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
setGoblet(draft.goblet); setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1)));
setCirclet(draft.circlet);
setSubstats(draft.substats);
setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" }))); setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" })));
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0); setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
setTalents(draft.talents); setTalents(draft.talents);
@@ -2177,36 +2194,79 @@ function ArtifactEditor({
))} ))}
</CardContent> </CardContent>
</Card> </Card>
<Card className="order-3"> <Card className="order-3 overflow-hidden pt-0">
<CardHeader className="bg-primary text-center text-primary-foreground"> <CardHeader className="bg-primary py-4 text-center text-primary-foreground">
<CardTitle>Recommended Stats</CardTitle> <CardTitle className="text-2xl">Recommended Stats</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FieldGroup> <FieldGroup>
<div className="flex flex-wrap gap-2">
<SortableConditionSwitcher
ariaLabel="เลือกเงื่อนไขค่าสถานะแนะนำ"
items={recommendedGroups}
activeIndex={activeRecommendedGroupIndex}
onActiveIndexChange={setActiveRecommendedGroupIndex}
onRename={setRenamingRecommendedGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(recommendedGroups, activeRecommendedGroupIndex, from, to);
setRecommendedGroups(reordered.items);
setActiveRecommendedGroupIndex(reordered.activeIndex);
}}
/>
<ConditionRenameDialog
open={renamingRecommendedGroupIndex !== null}
fallback={`Condition ${(renamingRecommendedGroupIndex ?? 0) + 1}`}
value={renamingRecommendedGroupIndex === null ? "" : recommendedGroups[renamingRecommendedGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingRecommendedGroupIndex(null); }}
onChange={(name) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, name } : group))}
/>
<SimpleTooltip text="เพิ่ม Condition">
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={recommendedGroups.length >= 20} onClick={() => {
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: "", note: "", sands: [], goblet: [], circlet: [], substats: [] }]);
setActiveRecommendedGroupIndex(recommendedGroups.length);
}}><PlusIcon /></Button>
</SimpleTooltip>
<SimpleTooltip text="Duplicate Condition">
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={recommendedGroups.length >= 20} onClick={() => {
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] }]);
setActiveRecommendedGroupIndex(recommendedGroups.length);
}}><CopyIcon /></Button>
</SimpleTooltip>
</div>
<Field>
<FieldLabel htmlFor={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`} value={recommendedGroups[activeRecommendedGroupIndex]?.note ?? ""} onChange={(event) => setRecommendedGroups(recommendedGroups.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<div className="grid overflow-hidden rounded-xl border sm:grid-cols-3"> <div className="grid overflow-hidden rounded-xl border sm:grid-cols-3">
<StatSelect <StatSelect
label="Sands / นาฬิกา" label="Sands / นาฬิกา"
options={mainStatOptions.sands} options={mainStatOptions.sands}
value={sands} value={sands}
max={3} max={3}
onChange={setSands} onChange={(values) => setRecommendedStat("sands", values)}
elementIconUrls={elementIconUrls} elementIconUrls={elementIconUrls}
priorityColors
/> />
<StatSelect <StatSelect
label="Goblet / ถ้วย" label="Goblet / ถ้วย"
options={mainStatOptions.goblet} options={mainStatOptions.goblet}
value={goblet} value={goblet}
max={3} max={3}
onChange={setGoblet} onChange={(values) => setRecommendedStat("goblet", values)}
elementIconUrls={elementIconUrls} elementIconUrls={elementIconUrls}
priorityColors
/> />
<StatSelect <StatSelect
label="Circlet / หมวก" label="Circlet / หมวก"
options={mainStatOptions.circlet} options={mainStatOptions.circlet}
value={circlet} value={circlet}
max={3} max={3}
onChange={setCirclet} onChange={(values) => setRecommendedStat("circlet", values)}
elementIconUrls={elementIconUrls} elementIconUrls={elementIconUrls}
priorityColors
/> />
</div> </div>
<StatSelect <StatSelect
@@ -2214,10 +2274,16 @@ function ArtifactEditor({
options={substatOptions} options={substatOptions}
value={substats} value={substats}
max={substatOptions.length} max={substatOptions.length}
onChange={setSubstats} onChange={(values) => setRecommendedStat("substats", values)}
elementIconUrls={elementIconUrls} elementIconUrls={elementIconUrls}
priorityColors priorityColors
/> />
{recommendedGroups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
setRecommendedGroups(recommendedGroups.filter((_, index) => index !== activeRecommendedGroupIndex));
setActiveRecommendedGroupIndex(Math.max(0, activeRecommendedGroupIndex - 1));
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button>
) : null}
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
</Card> </Card>
+23 -17
View File
@@ -50,6 +50,7 @@ interface LoginCardProps {
export function LoginCard({ turnstileSiteKey }: LoginCardProps) { export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
const router = useRouter(); const router = useRouter();
const turnstileEnabled = process.env.NODE_ENV !== "development";
const turnstileContainerRef = useRef<HTMLDivElement>(null); const turnstileContainerRef = useRef<HTMLDivElement>(null);
const turnstileWidgetIdRef = useRef<string | null>(null); const turnstileWidgetIdRef = useRef<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -58,6 +59,7 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
const renderTurnstile = useCallback(() => { const renderTurnstile = useCallback(() => {
if ( if (
!turnstileEnabled ||
!window.turnstile || !window.turnstile ||
!turnstileContainerRef.current || !turnstileContainerRef.current ||
turnstileWidgetIdRef.current turnstileWidgetIdRef.current
@@ -80,7 +82,7 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
}, },
}, },
); );
}, [turnstileSiteKey]); }, [turnstileEnabled, turnstileSiteKey]);
function resetTurnstile() { function resetTurnstile() {
setTurnstileToken(null); setTurnstileToken(null);
@@ -91,7 +93,7 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
async function signIn(event: FormEvent<HTMLFormElement>) { async function signIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!turnstileToken) return; if (turnstileEnabled && !turnstileToken) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -105,7 +107,9 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
password, password,
rememberMe: true, rememberMe: true,
fetchOptions: { fetchOptions: {
headers: { "x-captcha-response": turnstileToken }, headers: turnstileEnabled && turnstileToken
? { "x-captcha-response": turnstileToken }
: undefined,
}, },
}); });
if (result.error) { if (result.error) {
@@ -161,19 +165,21 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
required required
/> />
</Field> </Field>
<Field> {turnstileEnabled ? (
<div ref={turnstileContainerRef} className="min-h-[65px]" /> <Field>
<Script <div ref={turnstileContainerRef} className="min-h-[65px]" />
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" <Script
strategy="afterInteractive" src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
onLoad={renderTurnstile} strategy="afterInteractive"
onReady={renderTurnstile} onLoad={renderTurnstile}
onError={() => { onReady={renderTurnstile}
setTurnstileToken(null); onError={() => {
setError("โหลดระบบยืนยันตัวตนไม่สำเร็จ โปรดลองอีกครั้ง"); setTurnstileToken(null);
}} setError("โหลดระบบยืนยันตัวตนไม่สำเร็จ โปรดลองอีกครั้ง");
/> }}
</Field> />
</Field>
) : null}
{error ? ( {error ? (
<Alert variant="destructive"> <Alert variant="destructive">
<CircleAlertIcon aria-hidden="true" /> <CircleAlertIcon aria-hidden="true" />
@@ -185,7 +191,7 @@ export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
className="w-full" className="w-full"
size="lg" size="lg"
type="submit" type="submit"
disabled={loading || !turnstileToken} disabled={loading || (turnstileEnabled && !turnstileToken)}
> >
{loading ? ( {loading ? (
<Spinner data-icon="inline-start" /> <Spinner data-icon="inline-start" />
+27 -1
View File
@@ -31,16 +31,19 @@ export function ArtifactImageExport({
fileName, fileName,
hasNote, hasNote,
targetConditionLabels, targetConditionLabels,
recommendedConditionLabels,
}: { }: {
children: ReactNode; children: ReactNode;
fileName: string; fileName: string;
hasNote: boolean; hasNote: boolean;
targetConditionLabels: readonly string[]; targetConditionLabels: readonly string[];
recommendedConditionLabels: readonly string[];
}) { }) {
const [sections, setSections] = useState<Set<ArtifactExportSection>>( const [sections, setSections] = useState<Set<ArtifactExportSection>>(
() => new Set(sectionOptions.map(([value]) => value)), () => new Set(sectionOptions.map(([value]) => value)),
); );
const [includeNote, setIncludeNote] = useState(true); const [includeNote, setIncludeNote] = useState(true);
const [recommendedCondition, setRecommendedCondition] = useState("0");
const [targetCondition, setTargetCondition] = useState("0"); const [targetCondition, setTargetCondition] = useState("0");
const showNote = hasNote && includeNote; const showNote = hasNote && includeNote;
const entireSection = sections.size === sectionOptions.length; const entireSection = sections.size === sectionOptions.length;
@@ -49,6 +52,7 @@ export function ArtifactImageExport({
.map(([value]) => `[data-artifact-export-section="${value}"]{display:none!important}`) .map(([value]) => `[data-artifact-export-section="${value}"]{display:none!important}`)
.join(""); .join("");
const targetRule = `[data-target-condition]:not([data-target-condition="${targetCondition}"]){display:none!important}`; const targetRule = `[data-target-condition]:not([data-target-condition="${targetCondition}"]){display:none!important}`;
const recommendedRule = `[data-recommended-condition]:not([data-recommended-condition="${recommendedCondition}"]){display:none!important}`;
const noteRule = showNote const noteRule = showNote
? "" ? ""
: '[data-artifact-export-section="note"]{display:none!important}'; : '[data-artifact-export-section="note"]{display:none!important}';
@@ -122,6 +126,28 @@ export function ArtifactImageExport({
</Field> </Field>
) : null} ) : null}
</FieldGroup> </FieldGroup>
{sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Recommended Stats condition</p>
<ToggleGroup
variant="outline"
value={[recommendedCondition]}
onValueChange={(values) => {
if (values[0]) setRecommendedCondition(values[0]);
}}
>
{recommendedConditionLabels.map((label, index) => (
<ToggleGroupItem
key={index}
value={String(index)}
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
) : null}
{sections.has("target-stats") && targetConditionLabels.length > 1 ? ( {sections.has("target-stats") && targetConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium">Target Stats condition</p> <p className="text-sm font-medium">Target Stats condition</p>
@@ -151,7 +177,7 @@ export function ArtifactImageExport({
disabled={sections.size === 0} disabled={sections.size === 0}
captureClassName="p-0" captureClassName="p-0"
> >
<style>{hiddenSections + targetRule + noteRule + hideEmptyRightColumn + layoutRules}</style> <style>{hiddenSections + targetRule + recommendedRule + noteRule + hideEmptyRightColumn + layoutRules}</style>
{children} {children}
</GuideImageExport> </GuideImageExport>
</DialogContent> </DialogContent>
+65 -49
View File
@@ -63,7 +63,8 @@ function hasSection(guide: PublicGuide, slug: string): boolean {
guide.artifactGroups.some((group) => group.rows.length > 0) || guide.artifactGroups.some((group) => group.rows.length > 0) ||
Boolean( Boolean(
guide.artifactProfile && guide.artifactProfile &&
(guide.artifactProfile.sands.length || (guide.artifactProfile.recommendedConditions.some((condition) => condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length) ||
guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length || guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length || guide.artifactProfile.circlet.length ||
guide.artifactProfile.substats.length || guide.artifactProfile.substats.length ||
@@ -615,6 +616,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
<ArtifactImageExport <ArtifactImageExport
fileName={`${guide.name}-Artifact.png`} fileName={`${guide.name}-Artifact.png`}
hasNote={Boolean(guide.artifactProfile?.note)} hasNote={Boolean(guide.artifactProfile?.note)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `Condition ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `Condition ${index + 1}`)} targetConditionLabels={targetConditions.map((group, index) => group.name || `Condition ${index + 1}`)}
> >
<ArtifactSectionContent guide={guide} exporting /> <ArtifactSectionContent guide={guide} exporting />
@@ -634,6 +636,63 @@ function ArtifactSectionContent({
exporting?: boolean; exporting?: boolean;
}) { }) {
const profile = guide.artifactProfile; const profile = guide.artifactProfile;
const recommendedConditions = profile?.recommendedConditions ?? [];
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || `Condition ${conditionIndex + 1}`}</p> : null}
{condition.note ? <p className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
{profile ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
<CardTitle className="text-2xl">ออฟหลักที่ดีที่สุด</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
label="นาฬิกา"
values={condition.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label="ถ้วย"
values={condition.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label="หัว"
values={condition.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
</CardContent>
</Card>
) : null}
{condition.substats.length ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
<strong>ออฟรองที่ดีที่สุด</strong>
<div className="flex flex-col items-center gap-1">
{condition.substats.map((stat, index) => (
<ArtifactStatValue
key={`${stat}-${index}`}
value={stat}
index={index}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
));
return ( return (
<section <section
className={cn( className={cn(
@@ -680,54 +739,11 @@ function ArtifactSectionContent({
> >
{" "} {" "}
<div className="artifact-export-left flex flex-col gap-5"> <div className="artifact-export-left flex flex-col gap-5">
{profile ? ( {exporting ? recommendedCards : (
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0"> <ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `Condition ${index + 1}`)}>
<CardHeader className="bg-primary py-4 text-center text-primary-foreground"> {recommendedCards}
<CardTitle className="text-2xl">ออฟหลักที่ดีที่สุด</CardTitle> </ConditionSwitcher>
</CardHeader> )}
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
label="นาฬิกา"
values={profile.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label="ถ้วย"
values={profile.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label="หัว"
values={profile.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
</CardContent>
</Card>
) : null}
{profile?.substats.length ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
<strong>ออฟรองที่ดีที่สุด</strong>
<div className="flex flex-col items-center gap-1">
{profile.substats.map((stat, index) => (
<ArtifactStatValue
key={`${stat}-${index}`}
value={stat}
index={index}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
))}
</div>
</CardContent>
</Card>
) : null}
<div data-artifact-export-section="tierlist" className="artifact-export-tierlist order-first flex flex-col gap-5"> <div data-artifact-export-section="tierlist" className="artifact-export-tierlist order-first flex flex-col gap-5">
{guide.artifactGroups.map((group) => ( {guide.artifactGroups.map((group) => (
<Card key={group.id}> <Card key={group.id}>
+4
View File
@@ -396,6 +396,10 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
goblet: jsonb("goblet").$type<string[]>().default([]).notNull(), goblet: jsonb("goblet").$type<string[]>().default([]).notNull(),
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(), circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
substats: jsonb("substats").$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[] }>>()
.default([])
.notNull(),
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(), targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
targetConditions: jsonb("target_conditions") 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; rows: Array<{ stat: string; value: string }> }>>()
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."artifact_profile" ADD COLUMN "recommended_conditions" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -302,6 +302,13 @@
"when": 1789043585443, "when": 1789043585443,
"tag": "0042_burly_the_fallen", "tag": "0042_burly_the_fallen",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1789199401241,
"tag": "0043_eager_killmonger",
"breakpoints": true
} }
] ]
} }
+7 -5
View File
@@ -61,11 +61,13 @@ function createAuth() {
}, },
}, },
plugins: [ plugins: [
captcha({ ...(process.env.NODE_ENV === "development" ? [] : [
provider: "cloudflare-turnstile", captcha({
secretKey: required("TURNSTILE_SECRET_KEY"), provider: "cloudflare-turnstile",
endpoints: ["/sign-in/email"], secretKey: required("TURNSTILE_SECRET_KEY"),
}), endpoints: ["/sign-in/email"],
}),
]),
admin({ defaultRole: "admin" }), admin({ defaultRole: "admin" }),
nextCookies(), nextCookies(),
], ],
+1 -1
View File
@@ -212,7 +212,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning(); const [created] = await tx.insert(artifactGroups).values({ guideId, 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 }))); if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
} }
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } }); await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
return updated; return updated;
}); });
} }
+1 -1
View File
@@ -405,7 +405,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), 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) } })) })), 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: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === 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 }; }) })), artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === 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], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, rows: profile[0].targets }] } : 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,
constellationGroups: constellationGroups.map((group) => ({ constellationGroups: constellationGroups.map((group) => ({
...group, ...group,
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => { rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
+14
View File
@@ -89,6 +89,20 @@ describe("structured guide validation", () => {
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%"] }).success).toBe(false); expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%"] }).success).toBe(false);
}); });
it("validates recommended stat conditions independently and preserves their order", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
const first = { name: " DPS ", note: null, sands: ["ATK%"], goblet: ["Pyro DMG%"], circlet: ["CRIT Rate%/CRIT DMG%"], substats: ["ATK", "ER"] };
const second = { name: "", note: "Support", sands: ["ER"], goblet: ["HP%"], circlet: ["Healing Bonus%"], substats: ["HP", "ER"] };
const result = artifactSectionSchema.parse({ ...base, recommendedGroups: [first, second] });
expect(result.recommendedGroups?.map((group) => group.name)).toEqual(["DPS", null]);
expect(result.recommendedGroups?.[1].sands).toEqual(["ER"]);
expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, sands: ["EM", "ER", "ATK%", "HP%"] }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, goblet: ["invalid"] }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: Array(21).fill(first) }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, recommendedGroups: [{ ...first, note: "x".repeat(2_001) }] }).success).toBe(false);
});
it("accepts flat artifact substats with the same icons as their percentage variants", () => { it("accepts flat artifact substats with the same icons as their percentage variants", () => {
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targetGroups: [], talents: [], note: null }); const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targetGroups: [], talents: [], note: null });
expect(result.success).toBe(true); expect(result.success).toBe(true);
+8
View File
@@ -97,6 +97,14 @@ export const artifactSectionSchema = z.strictObject({
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3), goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3), circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),
substats: z.array(z.enum(substatOptions)).max(substatOptions.length), substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
recommendedGroups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
sands: z.array(z.enum(mainStatOptions.sands)).max(3),
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
})).min(1).max(20).optional(),
targetGroups: z.array(z.strictObject({ targetGroups: z.array(z.strictObject({
name: conditionName, name: conditionName,
note: z.string().trim().max(2_000).nullable(), note: z.string().trim().max(2_000).nullable(),
+4 -2
View File
@@ -61,9 +61,11 @@ export function statPriorityColorClass(index: number): string {
? "text-tier-gold" ? "text-tier-gold"
: index === 1 : index === 1
? "text-tier-silver" ? "text-tier-silver"
: "text-tier-copper"; : index === 2
? "text-tier-copper"
: "text-foreground";
} }
export function statPriorityColor(index: number): string { export function statPriorityColor(index: number): string {
return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : "#bd8055"; return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : index === 2 ? "#bd8055" : "#ffffff";
} }