feat : update
CI / Verify (push) Successful in 1m28s
CI / Build immutable images and deploy (push) Successful in 2m5s

This commit is contained in:
2026-09-05 18:15:06 +07:00 Unverified
parent e66b5a3a0d
commit 5aa0ae80c1
42 changed files with 4613 additions and 67 deletions
+15
View File
@@ -11,6 +11,8 @@ import {
matrixToExtraSheet,
moveExtraSheetColumn,
moveExtraSheetRow,
normalizeExtraSheetRichText,
updateExtraSheetCell,
} from "@/lib/guides/extra-sheet";
function idFactory() {
@@ -87,4 +89,17 @@ describe("Extra sheet", () => {
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
}
});
it("updates and sanitizes rich-text cells while preserving legacy text", () => {
const initial = createExtraSheet(idFactory());
const updated = updateExtraSheetCell(initial, 0, 0, "legacy\ntext");
const columnId = updated.columns[0].id;
expect(normalizeExtraSheetRichText(updated).rows[0].cells[columnId]).toBe(
"<p>legacy<br>text</p>",
);
const unsafe = updateExtraSheetCell(updated, 0, 0, "<p><strong>safe</strong><script>bad()</script></p>");
expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe(
"<p><strong>safe</strong></p>",
);
});
});
+32
View File
@@ -1,5 +1,7 @@
import * as z from "zod";
import { normalizeExtraRichText } from "@/lib/rich-text";
const extraSheetId = z.string().min(1).max(128);
export const extraSheetSchema = z.strictObject({
@@ -158,3 +160,33 @@ export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraShee
if (sheet.rows.length === 1) return sheet;
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
}
export function updateExtraSheetCell(
sheet: ExtraSheet,
rowIndex: number,
columnIndex: number,
value: string,
): ExtraSheet {
const row = sheet.rows[rowIndex];
const column = sheet.columns[columnIndex];
if (!row || !column) return sheet;
return {
...sheet,
rows: sheet.rows.map((candidate, index) => index === rowIndex
? { ...candidate, cells: { ...candidate.cells, [column.id]: value } }
: candidate),
};
}
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
return {
...sheet,
rows: sheet.rows.map((row) => ({
...row,
cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => [
columnId,
normalizeExtraRichText(value),
])),
})),
};
}
+1 -1
View File
@@ -110,7 +110,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
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, note: group.note, sortOrder }).returning();
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
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 })));
}
return updated;
});
+19 -12
View File
@@ -16,6 +16,7 @@ import {
extraSectionRows,
guides,
guideSections,
glossaryAliases,
media,
teamCompositions,
teamMembers,
@@ -23,6 +24,7 @@ import {
weaponConditions,
} from "@/db/schema";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import { aliasesByItem } from "@/lib/glossary";
const catalogVersionSchema = z.object({ version: z.string() });
@@ -46,21 +48,23 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
export async function getActiveCatalog() {
const db = getDb();
const [characters, weapons, artifacts] = await Promise.all([
const [characters, weapons, artifacts, aliases] = await Promise.all([
db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
db.select().from(glossaryAliases),
]);
if (!characters.length) return null;
const groupedAliases = aliasesByItem(aliases);
const storage = await getMediaStorage();
const { version } = catalogVersionSchema.parse(
await storage.file("catalog/current.json").json(),
);
return {
version,
characters: characters.map(catalogCharacterWithUrls),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), aliases: groupedAliases.character.get(item.key) ?? [] })),
weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
};
}
@@ -75,13 +79,14 @@ export async function listGuideCards(includeTrash = false) {
}
export async function listPublicGuideCards() {
const rows = await getDb()
const [rows, aliases] = await Promise.all([getDb()
.select({ guide: guides, cover: media })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
.orderBy(asc(guides.sortOrder), asc(guides.name));
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
.orderBy(asc(guides.sortOrder), asc(guides.name)), getDb().select().from(glossaryAliases)]);
const grouped = aliasesByItem(aliases);
return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) }));
}
export async function getGuideByCharacterKey(characterKey: string) {
@@ -109,14 +114,16 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
))
.limit(1);
if (!row) return null;
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
getDb().select().from(glossaryAliases),
]);
const groupedAliases = aliasesByItem(aliases);
const elementIconUrls = Object.fromEntries(
(await getDb()
@@ -161,11 +168,11 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
return {
...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey),
character: catalogCharacterWithUrls(row.character),
character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] },
elementIconUrls,
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, 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) } })) })),
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, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellationGroups: constellationGroups.map((group) => ({
@@ -180,7 +187,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
};
}),
})),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
...extraRow,
catalogItems: extraRow.catalogItems.map((reference) => {
+7 -1
View File
@@ -35,6 +35,12 @@ describe("structured guide validation", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true);
});
it("supports an all-refinements weapon range", () => {
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: "1-5", overallPercent: 100, personalPercent: null, note: null }] }] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.groups[0].rows[0].refinement).toBe("1-5");
});
it("preserves negative weapon percentages", () => {
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] });
expect(result.success).toBe(true);
@@ -229,7 +235,7 @@ describe("structured guide validation", () => {
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("<p>=1+1</p>");
}
const invalidSheet = structuredClone(sheet);
+8 -2
View File
@@ -2,7 +2,7 @@ import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { extraSheetSchema } from "@/lib/guides/extra-sheet";
import { extraSheetSchema, normalizeExtraSheetRichText } from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
@@ -10,6 +10,11 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number];
const percent = z.coerce.number().finite().min(-9999).max(9999);
const catalogKey = z.string().trim().min(1).max(64);
export const weaponRefinementSchema = z.union([
z.number().int().min(1).max(5).transform(String),
z.enum(["1", "2", "3", "4", "5", "1-5"]),
]);
export type WeaponRefinement = z.output<typeof weaponRefinementSchema>;
export const createGuideSchema = z.strictObject({
characterKey: catalogKey,
@@ -40,7 +45,7 @@ export const weaponSectionSchema = z.strictObject({
note: z.string().trim().max(2_000).nullable(),
rows: z.array(z.strictObject({
weaponKey: catalogKey,
refinement: z.number().int().min(1).max(5),
refinement: weaponRefinementSchema,
overallPercent: percent.nullable(),
personalPercent: percent.nullable(),
note: z.string().trim().max(2_000).nullable(),
@@ -159,6 +164,7 @@ const extraRowSchema = z.strictObject({
}).transform((row) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
sheet: row.sheet ? normalizeExtraSheetRichText(row.sheet) : null,
highlights: [],
}));
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { matchesTeamSearch } from "./team-search";
import { matchesTeamSearch, sortTeamsByDps } from "./team-search";
const team = {
team: ["Vaporize Team"],
@@ -28,4 +28,14 @@ describe("team search", () => {
it("matches every team when the query is empty", () => {
expect(matchesTeamSearch(team, " ", "weapon")).toBe(true);
});
it("sorts every team by DPS and preserves order for ties", () => {
const teams = [
{ name: "middle", rotationSeconds: 10, members: [{ damage: 100 }] },
{ name: "best", rotationSeconds: 5, members: [{ damage: 100 }] },
{ name: "tie", rotationSeconds: 20, members: [{ damage: 200 }] },
];
expect(sortTeamsByDps(teams, "best").map((item) => item.name)).toEqual(["best", "middle", "tie"]);
expect(sortTeamsByDps(teams, "worst").map((item) => item.name)).toEqual(["middle", "tie", "best"]);
});
});
+26
View File
@@ -12,6 +12,32 @@ export interface TeamSearchValues {
artifact: string[];
}
export interface DpsTeam {
rotationSeconds: string | number;
members: Array<{ damage: string | number }>;
}
export function teamDps(team: DpsTeam): number {
const seconds = Number(team.rotationSeconds);
if (!(seconds > 0)) return 0;
return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds;
}
export function sortTeamsByDps<Team extends DpsTeam>(
teams: readonly Team[],
order: "best" | "worst",
): Team[] {
return teams
.map((team, index) => ({ team, index }))
.sort((left, right) => {
const difference = order === "best"
? teamDps(right.team) - teamDps(left.team)
: teamDps(left.team) - teamDps(right.team);
return difference || left.index - right.index;
})
.map(({ team }) => team);
}
export function matchesTeamSearch(
values: TeamSearchValues,
query: string,