feat(guide) : improve extra cards and navigation
CI / Verify (push) Successful in 1m25s
CI / Build immutable images and deploy (push) Failing after 50s

This commit is contained in:
2026-09-04 22:40:27 +07:00 Unverified
parent 5f74ce1de6
commit ced9b41737
16 changed files with 3780 additions and 224 deletions
-17
View File
@@ -1,17 +0,0 @@
import { SiteHeader } from "@/components/public/site-header";
import { Skeleton } from "@/components/ui/skeleton";
export default function GuideLoading() {
return (
<div className="min-h-svh">
<SiteHeader />
<div className="border-b p-4"><Skeleton className="mx-auto h-16 max-w-7xl" /></div>
<main className="mx-auto max-w-5xl space-y-5 px-4 py-10">
<Skeleton className="h-72 w-full" />
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-28 w-full" />
<Skeleton className="h-80 w-full" />
</main>
</div>
);
}
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]); const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
if (!catalog || !content) notFound(); if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey); const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ id: row.id, mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, backgroundColor: row.backgroundColor, textColor: row.textColor, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>; return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ id: row.id, mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, backgroundColor: row.backgroundColor, highlights: row.highlights, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>;
} }
-14
View File
@@ -1,14 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminLoading() {
return (
<div className="min-h-svh">
<Skeleton className="h-16 w-full rounded-none" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 p-6">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-72 w-full" />
</div>
</div>
);
}
-12
View File
@@ -130,11 +130,6 @@
} }
} }
::view-transition-group(.guide-nav-pill) {
animation-duration: 220ms;
animation-timing-function: ease-in-out;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -144,11 +139,4 @@
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-delay: 0s !important;
animation-duration: 0.01ms !important;
}
} }
-19
View File
@@ -1,19 +0,0 @@
import { DirectorySkeleton } from "@/components/public/directory-skeleton";
import { SiteHeader } from "@/components/public/site-header";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="min-h-svh">
<SiteHeader />
<main className="mx-auto w-full max-w-7xl space-y-8 px-4 py-10 sm:px-6 sm:py-14">
<div className="space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full max-w-2xl" />
<Skeleton className="h-6 w-full max-w-xl" />
</div>
<DirectorySkeleton />
</main>
</div>
);
}
+210 -89
View File
@@ -260,7 +260,7 @@ type TeamValue = {
let clientIdCounter = 0; let clientIdCounter = 0;
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C"; const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
const DEFAULT_EXTRA_TEXT_COLOR = "#F5F2EA"; const DEFAULT_EXTRA_HIGHLIGHT_COLOR = "#FF3B30";
function createClientId(prefix: string): string { function createClientId(prefix: string): string {
const browserCrypto = globalThis.crypto; const browserCrypto = globalThis.crypto;
@@ -314,7 +314,7 @@ export interface GuideSectionEditorProps {
body: string; body: string;
percent: string | null; percent: string | null;
backgroundColor: string | null; backgroundColor: string | null;
textColor: string | null; highlights: Array<{ text: string; color: string }>;
imageUrl: string | null; imageUrl: string | null;
imageWidth: number | null; imageWidth: number | null;
imageHeight: number | null; imageHeight: number | null;
@@ -3324,7 +3324,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
? null ? null
: Number(row.percent), : Number(row.percent),
backgroundColor: row.backgroundColor, backgroundColor: row.backgroundColor,
textColor: row.textColor, highlights: row.highlights.map(({ text, color }) => ({ text, color })),
})), })),
})), })),
}; };
@@ -3354,7 +3354,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
...row, ...row,
id: row.id || createClientId("extra-row-record"), id: row.id || createClientId("extra-row-record"),
backgroundColor: row.backgroundColor ?? null, backgroundColor: row.backgroundColor ?? null,
textColor: row.textColor ?? null, highlights: row.highlights ?? [],
clientId: row.clientId || createClientId("extra-row"), clientId: row.clientId || createClientId("extra-row"),
})), })),
})), })),
@@ -3701,101 +3701,53 @@ function ExtraEditor({ guide, initial }: EditorBase) {
/> />
</Field> </Field>
<FieldSet> <FieldSet>
<FieldLegend>สีการ์ด (optional)</FieldLegend> <FieldLegend>สีพื้นหลังการ์ด (optional)</FieldLegend>
<FieldDescription> <FieldDescription>
สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide
</FieldDescription> </FieldDescription>
<FieldGroup className="sm:grid sm:grid-cols-2"> <Field
<Field data-invalid={extraIssue(
data-invalid={extraIssue( "sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
) || undefined}
>
<FieldLabel>สีพื้นหลัง</FieldLabel>
<Input
type="color"
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
aria-invalid={extraIssue(
"sections", "sections",
sectionIndex, sectionIndex,
"rows", "rows",
rowIndex, rowIndex,
"backgroundColor", "backgroundColor",
) || undefined} )}
> onChange={(event) =>
<FieldLabel>สีพื้นหลัง</FieldLabel> setSections(
<Input sections.map((item, index) =>
type="color" index === sectionIndex
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR} ? {
aria-invalid={extraIssue( ...item,
"sections", rows: item.rows.map((candidate, inner) =>
sectionIndex, inner === rowIndex
"rows", ? { ...candidate, backgroundColor: event.target.value }
rowIndex, : candidate,
"backgroundColor", ),
)} }
onChange={(event) => : item,
setSections( ),
sections.map((item, index) => )
index === sectionIndex }
? { />
...item, </Field>
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
backgroundColor: event.target.value,
textColor: candidate.textColor ?? DEFAULT_EXTRA_TEXT_COLOR,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"textColor",
) || undefined}
>
<FieldLabel>สีข้อความ</FieldLabel>
<Input
type="color"
value={row.textColor ?? DEFAULT_EXTRA_TEXT_COLOR}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"textColor",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
backgroundColor: candidate.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR,
textColor: event.target.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
</FieldGroup>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={row.backgroundColor === null && row.textColor === null} disabled={row.backgroundColor === null}
onClick={() => onClick={() =>
setSections( setSections(
sections.map((item, index) => sections.map((item, index) =>
@@ -3804,7 +3756,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
...item, ...item,
rows: item.rows.map((candidate, inner) => rows: item.rows.map((candidate, inner) =>
inner === rowIndex inner === rowIndex
? { ...candidate, backgroundColor: null, textColor: null } ? { ...candidate, backgroundColor: null }
: candidate, : candidate,
), ),
} }
@@ -3817,6 +3769,175 @@ function ExtraEditor({ guide, initial }: EditorBase) {
ใช้สีตามธีม ใช้สีตามธีม
</Button> </Button>
</FieldSet> </FieldSet>
<FieldSet>
<FieldLegend>ไฮไลต์คำหรือวลี</FieldLegend>
<FieldDescription>
ระบุข้อความให้ตรงกับเนื้อหา ระบบจะเปลี่ยนสีทุกตำแหน่งที่พบ
</FieldDescription>
{row.highlights.map((highlight, highlightIndex) => (
<FieldGroup
key={highlightIndex}
className="sm:grid sm:grid-cols-[1fr_auto_auto] sm:items-end"
>
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"highlights",
highlightIndex,
"text",
) || undefined}
>
<FieldLabel>คำหรือวลี</FieldLabel>
<Input
value={highlight.text}
placeholder="MASSIVE"
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"highlights",
highlightIndex,
"text",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
highlights: candidate.highlights.map((rule, ruleIndex) =>
ruleIndex === highlightIndex
? { ...rule, text: event.target.value }
: rule,
),
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"highlights",
highlightIndex,
"color",
) || undefined}
>
<FieldLabel>สี</FieldLabel>
<Input
type="color"
value={highlight.color}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"highlights",
highlightIndex,
"color",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
highlights: candidate.highlights.map((rule, ruleIndex) =>
ruleIndex === highlightIndex
? { ...rule, color: event.target.value }
: rule,
),
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`ลบไฮไลต์ ${highlight.text || highlightIndex + 1}`}
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
highlights: candidate.highlights.filter((_, ruleIndex) => ruleIndex !== highlightIndex),
}
: candidate,
),
}
: item,
),
)
}
>
<Trash2Icon />
</Button>
</FieldGroup>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={row.highlights.length >= 20}
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
highlights: [
...candidate.highlights,
{ text: "", color: DEFAULT_EXTRA_HIGHLIGHT_COLOR },
],
}
: candidate,
),
}
: item,
),
)
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มคำไฮไลต์
</Button>
</FieldSet>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
</Card> </Card>
@@ -3846,7 +3967,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
body: "", body: "",
percent: null, percent: null,
backgroundColor: null, backgroundColor: null,
textColor: null, highlights: [],
clientId: createClientId("extra-row"), clientId: createClientId("extra-row"),
}, },
], ],
+31 -60
View File
@@ -1,7 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react"; import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
import { ViewTransition } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -39,6 +38,7 @@ import {
talentTypeLabel, talentTypeLabel,
} from "@/lib/guides/talents"; } from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { highlightTextSegments } from "@/lib/guides/highlights";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean { function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -125,66 +125,44 @@ export function StructuredGuideShell({
<Link <Link
href={`/${guide.slug}`} href={`/${guide.slug}`}
className={cn( className={cn(
"relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", "flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section !section
? "text-foreground" ? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
prefetch prefetch
> >
{!section ? ( <Image
<ViewTransition src={navigationIconPath("overview")!}
name={`guide-nav-active-${guide.id}`} alt=""
share="guide-nav-pill" width={20}
default="none" height={20}
> className="size-5 object-contain"
<span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" /> />
</ViewTransition> Overview
) : null}
<span className="relative flex items-center gap-2">
<Image
src={navigationIconPath("overview")!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
Overview
</span>
</Link> </Link>
{sections.map((item) => ( {sections.map((item) => (
<Link <Link
key={item.id} key={item.id}
href={`/${guide.slug}/${item.slug}`} href={`/${guide.slug}/${item.slug}`}
className={cn( className={cn(
"relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", "flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
section === item.slug section === item.slug
? "text-foreground" ? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
prefetch prefetch
> >
{section === item.slug ? ( {navigationIconPath(item.slug) ? (
<ViewTransition <Image
name={`guide-nav-active-${guide.id}`} src={navigationIconPath(item.slug)!}
share="guide-nav-pill" alt=""
default="none" width={20}
> height={20}
<span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" /> className="size-5 object-contain"
</ViewTransition> />
) : null} ) : null}
<span className="relative flex items-center gap-2"> {item.title}
{navigationIconPath(item.slug) ? (
<Image
src={navigationIconPath(item.slug)!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
) : null}
{item.title}
</span>
</Link> </Link>
))} ))}
</ScrollableNav> </ScrollableNav>
@@ -1029,9 +1007,7 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
<Card <Card
key={row.id} key={row.id}
className="overflow-hidden py-0" className="overflow-hidden py-0"
style={row.backgroundColor && row.textColor style={row.backgroundColor ? { backgroundColor: row.backgroundColor } : undefined}
? { backgroundColor: row.backgroundColor, color: row.textColor }
: undefined}
> >
{row.imageUrl ? ( {row.imageUrl ? (
<div <div
@@ -1052,20 +1028,15 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
</div> </div>
) : null} ) : null}
<CardHeader className="pt-5"> <CardHeader className="pt-5">
<CardTitle style={row.textColor ? { color: row.textColor } : undefined}> <CardTitle>{row.heading}</CardTitle>
{row.heading} <CardDescription className="whitespace-pre-wrap">
</CardTitle> {highlightTextSegments(row.body, row.highlights).map((segment, index) => (
<CardDescription <span key={index} style={segment.color ? { color: segment.color } : undefined}>
className="whitespace-pre-wrap" {segment.text}
style={row.textColor ? { color: row.textColor } : undefined} </span>
> ))}
{row.body}
</CardDescription> </CardDescription>
{row.percent ? ( {row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
<Badge style={row.textColor ? { color: row.textColor } : undefined}>
{metric(row.percent)}
</Badge>
) : null}
</CardHeader> </CardHeader>
</Card> </Card>
))} ))}
+1
View File
@@ -660,6 +660,7 @@ export const extraSectionRows = guidesSchema.table(
percent: numeric("percent", { precision: 8, scale: 2 }), percent: numeric("percent", { precision: 8, scale: 2 }),
backgroundColor: varchar("background_color", { length: 7 }), backgroundColor: varchar("background_color", { length: 7 }),
textColor: varchar("text_color", { length: 7 }), textColor: varchar("text_color", { length: 7 }),
highlights: jsonb("highlights").$type<Array<{ text: string; color: string }>>().default([]).notNull(),
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
}, },
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)], (table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."extra_section_row" ADD COLUMN "highlights" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -190,6 +190,13 @@
"when": 1788533844823, "when": 1788533844823,
"tag": "0026_red_masque", "tag": "0026_red_masque",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1788535891970,
"tag": "0027_condemned_skullbuster",
"breakpoints": true
} }
] ]
} }
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { highlightTextSegments } from "@/lib/guides/highlights";
describe("Extra text highlights", () => {
it("colors every exact occurrence while preserving surrounding text", () => {
expect(highlightTextSegments("MASSIVE damage\nMASSIVE range", [
{ text: "MASSIVE", color: "#FF0000" },
])).toEqual([
{ text: "MASSIVE", color: "#FF0000" },
{ text: " damage\n", color: null },
{ text: "MASSIVE", color: "#FF0000" },
{ text: " range", color: null },
]);
});
it("prefers the longer phrase when highlights overlap", () => {
expect(highlightTextSegments("very high damage", [
{ text: "high", color: "#00FF00" },
{ text: "high damage", color: "#FF0000" },
])).toEqual([
{ text: "very ", color: null },
{ text: "high damage", color: "#FF0000" },
]);
});
});
+43
View File
@@ -0,0 +1,43 @@
export interface TextHighlight {
text: string;
color: string;
}
export interface HighlightedTextSegment {
text: string;
color: string | null;
}
export function highlightTextSegments(
value: string,
highlights: readonly TextHighlight[],
): HighlightedTextSegment[] {
const rules = highlights
.filter((highlight) => highlight.text.length > 0)
.toSorted((left, right) => right.text.length - left.text.length);
if (!rules.length) return [{ text: value, color: null }];
const segments: HighlightedTextSegment[] = [];
let cursor = 0;
while (cursor < value.length) {
let matchIndex = -1;
let match: TextHighlight | undefined;
for (const rule of rules) {
const index = value.indexOf(rule.text, cursor);
if (index !== -1 && (matchIndex === -1 || index < matchIndex)) {
matchIndex = index;
match = rule;
}
}
if (!match || matchIndex === -1) {
segments.push({ text: value.slice(cursor), color: null });
break;
}
if (matchIndex > cursor) {
segments.push({ text: value.slice(cursor, matchIndex), color: null });
}
segments.push({ text: match.text, color: match.color });
cursor = matchIndex + match.text.length;
}
return segments;
}
+8 -7
View File
@@ -139,23 +139,24 @@ describe("structured guide validation", () => {
expect(teamSectionSchema.safeParse(input).success).toBe(true); expect(teamSectionSchema.safeParse(input).success).toBe(true);
}); });
it("accepts Extra card colors only as a valid pair", () => { it("validates Extra card backgrounds and word highlights", () => {
const row = { const row = {
mediaId: null, mediaId: null,
heading: "Extra card", heading: "Extra card",
body: "First line\nSecond line", body: "First line\nSecond line",
percent: null, percent: null,
}; };
const input = (colors: { backgroundColor: string | null; textColor: string | null }) => ({ const input = (style: { backgroundColor: string | null; highlights: Array<{ text: string; color: string }> }) => ({
expectedVersion: 1, expectedVersion: 1,
sections: [{ title: "Extra", rows: [{ ...row, ...colors }] }], sections: [{ title: "Extra", rows: [{ ...row, ...style }] }],
}); });
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, textColor: null })).success).toBe(true); expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [] })).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: "#F5F2EA" })).success).toBe(true); expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", highlights: [{ text: "MASSIVE", color: "#FF0000" }] })).success).toBe(true);
expect(extraSectionSchema.safeParse({ expectedVersion: 1, sections: [{ title: "Extra", rows: [row] }] }).success).toBe(true); expect(extraSectionSchema.safeParse({ expectedVersion: 1, sections: [{ title: "Extra", rows: [row] }] }).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: null })).success).toBe(false); expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", highlights: [] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", textColor: "#F5F2EA" })).success).toBe(false); expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
}); });
}); });
+8 -5
View File
@@ -139,11 +139,14 @@ const extraRowSchema = z.strictObject({
body: z.string().trim().max(2_000), body: z.string().trim().max(2_000),
percent: percent.nullable(), percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null), backgroundColor: hexColor.nullable().default(null),
textColor: hexColor.nullable().default(null), highlights: z.array(z.strictObject({
}).refine( text: z.string().trim().min(1).max(80),
(row) => (row.backgroundColor === null) === (row.textColor === null), color: hexColor,
{ message: "ต้องเลือกสีพื้นหลังและสีข้อความพร้อมกัน", path: ["backgroundColor"] }, })).max(20).refine(
); (highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
});
export const extraSectionSchema = z.strictObject({ export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(), expectedVersion: z.number().int().positive(),
+1
View File
@@ -116,6 +116,7 @@ export async function generateOgImage({
height: "100%", height: "100%",
objectFit: "cover", objectFit: "cover",
objectPosition: "center", objectPosition: "center",
maskImage: "linear-gradient(90deg, transparent 0%, black 28%, black 100%)",
}} }}
/> />
) : null} ) : null}