feat(overview) : add YouTube video section
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user