feat(guide) : improve extra cards and navigation
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user