feat(guide) : improve extra cards and navigation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
it("accepts Extra card colors only as a valid pair", () => {
|
||||
it("validates Extra card backgrounds and word highlights", () => {
|
||||
const row = {
|
||||
mediaId: null,
|
||||
heading: "Extra card",
|
||||
body: "First line\nSecond line",
|
||||
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,
|
||||
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: "#24232C", textColor: "#F5F2EA" })).success).toBe(true);
|
||||
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [] })).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(input({ backgroundColor: "#24232C", textColor: null })).success).toBe(false);
|
||||
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", textColor: "#F5F2EA" })).success).toBe(false);
|
||||
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", highlights: [] })).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),
|
||||
percent: percent.nullable(),
|
||||
backgroundColor: hexColor.nullable().default(null),
|
||||
textColor: hexColor.nullable().default(null),
|
||||
}).refine(
|
||||
(row) => (row.backgroundColor === null) === (row.textColor === null),
|
||||
{ message: "ต้องเลือกสีพื้นหลังและสีข้อความพร้อมกัน", path: ["backgroundColor"] },
|
||||
);
|
||||
highlights: z.array(z.strictObject({
|
||||
text: z.string().trim().min(1).max(80),
|
||||
color: hexColor,
|
||||
})).max(20).refine(
|
||||
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
|
||||
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
|
||||
).default([]),
|
||||
});
|
||||
|
||||
export const extraSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
|
||||
@@ -116,6 +116,7 @@ export async function generateOgImage({
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
maskImage: "linear-gradient(90deg, transparent 0%, black 28%, black 100%)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user