feat(overview) : add YouTube video section
CI / Verify (push) Successful in 1m4s
CI / Build immutable images and deploy (push) Successful in 1m54s

This commit is contained in:
2026-09-03 12:06:36 +07:00 Unverified
parent dd672e5331
commit cfc6f0c146
11 changed files with 3585 additions and 100 deletions
+5 -1
View File
@@ -119,7 +119,11 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId }).where(eq(guides.id, guideId));
await tx.update(guides).set({
overview: data.overview,
coverMediaId: data.coverMediaId,
...(data.youtubeUrl !== undefined ? { youtubeUrl: data.youtubeUrl } : {}),
}).where(eq(guides.id, guideId));
if (current.coverMediaId !== data.coverMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
+13 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations";
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
import { recommendedStatIconName, targetStatIconName } from "@/lib/guides/stats";
describe("structured guide validation", () => {
@@ -13,6 +13,18 @@ describe("structured guide validation", () => {
expect(createGuideSchema.safeParse({ characterKey: "1", coverMediaId: "550e8400-e29b-41d4-a716-446655440000", overview: "" }).success).toBe(true);
});
it("normalizes, clears, and omits an optional YouTube video", () => {
const base = {
expectedVersion: 1,
coverMediaId: "550e8400-e29b-41d4-a716-446655440000",
overview: "Overview",
};
expect(updateGuideSchema.parse({ ...base, youtubeUrl: "https://youtu.be/dQw4w9WgXcQ?si=share" }).youtubeUrl).toBe("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
expect(updateGuideSchema.parse({ ...base, youtubeUrl: "" }).youtubeUrl).toBeNull();
expect(updateGuideSchema.parse(base).youtubeUrl).toBeUndefined();
expect(updateGuideSchema.safeParse({ ...base, youtubeUrl: "https://example.com/video" }).success).toBe(false);
});
it("requires at least one weapon percentage", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
+11
View File
@@ -1,6 +1,7 @@
import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
@@ -14,10 +15,20 @@ export const createGuideSchema = z.strictObject({
overview: z.string().trim().max(500),
});
const youtubeUrlSchema = z.string().trim().max(2_048).optional().transform((value, context) => {
if (value === undefined) return undefined;
if (!value) return null;
const video = parseYouTubeUrl(value);
if (video) return video.watchUrl;
context.addIssue({ code: "custom", message: "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" });
return z.NEVER;
});
export const updateGuideSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
coverMediaId: z.string().uuid(),
overview: z.string().trim().min(1).max(500),
youtubeUrl: youtubeUrlSchema,
});
export const weaponSectionSchema = z.strictObject({
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
describe("YouTube URLs", () => {
const videoId = "dQw4w9WgXcQ";
it.each([
`https://www.youtube.com/watch?v=${videoId}`,
`https://m.youtube.com/watch?v=${videoId}&t=12`,
`https://youtu.be/${videoId}?si=share`,
`https://youtube.com/shorts/${videoId}`,
`https://youtube.com/live/${videoId}`,
`https://youtube.com/embed/${videoId}`,
])("normalizes %s", (url) => {
expect(parseYouTubeUrl(url)).toEqual({
videoId,
watchUrl: `https://www.youtube.com/watch?v=${videoId}`,
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
});
});
it.each([
"",
"not a url",
"https://example.com/watch?v=dQw4w9WgXcQ",
"https://youtube.example.com/watch?v=dQw4w9WgXcQ",
"javascript:alert(1)",
"https://youtube.com/watch?v=too-short",
])("rejects %s", (url) => {
expect(parseYouTubeUrl(url)).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
]);
export interface YouTubeVideo {
videoId: string;
watchUrl: string;
embedUrl: string;
}
export function parseYouTubeUrl(value: string): YouTubeVideo | null {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
const hostname = url.hostname.toLowerCase();
let videoId: string | null = null;
if (hostname === "youtu.be") {
videoId = url.pathname.split("/").filter(Boolean)[0] ?? null;
} else if (YOUTUBE_HOSTS.has(hostname)) {
if (url.pathname === "/watch") {
videoId = url.searchParams.get("v");
} else {
const [kind, id] = url.pathname.split("/").filter(Boolean);
if (["embed", "live", "shorts"].includes(kind)) videoId = id ?? null;
}
}
if (!videoId || !YOUTUBE_VIDEO_ID.test(videoId)) return null;
return {
videoId,
watchUrl: `https://www.youtube.com/watch?v=${videoId}`,
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
};
}