feat(overview) : add YouTube video section
This commit is contained in:
@@ -7,23 +7,28 @@ import { saveGuideOverview } from "@/app/admin/actions";
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAutosave } from "@/hooks/use-autosave";
|
||||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
|
||||
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
|
||||
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; youtubeUrl: string | null; coverMediaId: string; coverUrl: string; name: string } }) {
|
||||
const [overview, setOverview] = useState(guide.overview);
|
||||
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
|
||||
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
|
||||
const value = { coverMediaId: cover.id, overview };
|
||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !parseYouTubeUrl(youtubeUrl.trim()));
|
||||
const value = { coverMediaId: cover.id, overview, youtubeUrl };
|
||||
const incompleteIssues = [
|
||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||
!overview.trim() ? "กรุณากรอก Overview" : null,
|
||||
invalidYoutubeUrl ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
|
||||
].filter((issue): issue is string => Boolean(issue));
|
||||
const localDraft = { overview, cover };
|
||||
const localDraft = { overview, youtubeUrl, cover };
|
||||
const autosave = useAutosave({
|
||||
value,
|
||||
valid: Boolean(cover.id && overview.trim()),
|
||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||
initialVersion: guide.version,
|
||||
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
||||
storageKey: adminDraftKey(guide.id, "overview"),
|
||||
@@ -33,12 +38,19 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
|
||||
const draft = candidate as Record<string, unknown>;
|
||||
if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null;
|
||||
const draftCover = draft.cover as Record<string, unknown>;
|
||||
return typeof draftCover.id === "string" && typeof draftCover.url === "string" ? candidate as typeof localDraft : null;
|
||||
if (typeof draftCover.id !== "string" || typeof draftCover.url !== "string") return null;
|
||||
if (draft.youtubeUrl !== undefined && typeof draft.youtubeUrl !== "string") return null;
|
||||
return {
|
||||
overview: draft.overview,
|
||||
youtubeUrl: typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
||||
cover: { id: draftCover.id, url: draftCover.url },
|
||||
};
|
||||
},
|
||||
restoreLocalDraft: (draft) => {
|
||||
setOverview(draft.overview);
|
||||
setYoutubeUrl(draft.youtubeUrl);
|
||||
setCover(draft.cover);
|
||||
},
|
||||
});
|
||||
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={incompleteIssues} onSave={autosave.saveNow} /></CardFooter></Card>;
|
||||
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพ ข้อความ และวิดีโอนี้แสดงบนหน้าแรกของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field><Field data-invalid={invalidYoutubeUrl}><FieldLabel htmlFor="guide-youtube-url">YouTube video link</FieldLabel><Input id="guide-youtube-url" type="url" inputMode="url" value={youtubeUrl} maxLength={2_048} placeholder="https://www.youtube.com/watch?v=..." aria-invalid={invalidYoutubeUrl} onChange={(event) => setYoutubeUrl(event.target.value)} /><FieldDescription>ไม่บังคับ รองรับลิงก์ YouTube, youtu.be, Shorts และ Live</FieldDescription>{invalidYoutubeUrl ? <FieldError>กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง</FieldError> : null}</Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={incompleteIssues} onSave={autosave.saveNow} /></CardFooter></Card>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
talentRelationSymbol,
|
||||
talentTypeLabel,
|
||||
} from "@/lib/guides/talents";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function hasSection(guide: PublicGuide, slug: string): boolean {
|
||||
@@ -112,54 +114,51 @@ export function StructuredGuideShell({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableNav
|
||||
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
||||
className="mx-auto min-h-11 w-full max-w-7xl rounded-xl border bg-card p-2"
|
||||
>
|
||||
<Link
|
||||
href={`/${guide.slug}`}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!section
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch
|
||||
{section ? (
|
||||
<ScrollableNav
|
||||
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
||||
className="mx-auto min-h-11 w-full max-w-7xl rounded-xl border bg-card p-2"
|
||||
>
|
||||
<Image
|
||||
src={navigationIconPath("overview")!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
Overview
|
||||
</Link>
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
section === item.slug
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
href={`/${guide.slug}`}
|
||||
className="flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
prefetch
|
||||
>
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
{item.title}
|
||||
<Image
|
||||
src={navigationIconPath("overview")!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
Overview
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableNav>
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
section === item.slug
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch
|
||||
>
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableNav>
|
||||
) : null}
|
||||
</div>
|
||||
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
|
||||
{section ? (
|
||||
@@ -179,6 +178,10 @@ function GuideOverview({
|
||||
guide: PublicGuide;
|
||||
sections: ReturnType<typeof publicSections>;
|
||||
}) {
|
||||
const youtubeVideo = guide.youtubeUrl
|
||||
? parseYouTubeUrl(guide.youtubeUrl)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
|
||||
@@ -186,55 +189,94 @@ function GuideOverview({
|
||||
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
|
||||
<p className="text-lg font-semibold">{guide.overview}</p>
|
||||
</div>
|
||||
<div className="relative mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-xl border bg-muted">
|
||||
<Image
|
||||
src={guide.coverUrl}
|
||||
alt={`ไกด์ ${guide.name}`}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
{sections.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{sections.map((item) => (
|
||||
<Link key={item.id} href={`/${guide.slug}/${item.slug}`} prefetch>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<CardTitle>
|
||||
{publicSectionTitle(item.slug, item.title)}
|
||||
</CardTitle>
|
||||
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
|
||||
<CardAction>
|
||||
<ArrowUpRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 text-primary"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-muted">
|
||||
<Image
|
||||
src={guide.coverUrl}
|
||||
alt={`ไกด์ ${guide.name}`}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
|
||||
<CardDescription>
|
||||
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
{sections.length ? (
|
||||
<nav
|
||||
aria-label={`เมนูคู่มือ ${guide.name}`}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
prefetch
|
||||
>
|
||||
<Card size="sm" className="transition-colors hover:bg-muted/50">
|
||||
<CardHeader>
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<CardTitle>
|
||||
{publicSectionTitle(item.slug, item.title)}
|
||||
</CardTitle>
|
||||
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
|
||||
<CardAction>
|
||||
<ArrowUpRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 text-primary"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
|
||||
<CardDescription>
|
||||
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{youtubeVideo ? (
|
||||
<section className="flex flex-col items-center gap-3">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
||||
<iframe
|
||||
src={youtubeVideo.embedUrl}
|
||||
title={`วิดีโอไกด์ ${guide.name}`}
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
render={
|
||||
<a
|
||||
href={youtubeVideo.watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
}
|
||||
nativeButton={false}
|
||||
>
|
||||
ดูวิดีโอต้นฉบับบน YouTube
|
||||
<ArrowUpRightIcon data-icon="inline-end" />
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -489,6 +489,7 @@ export const guides = guidesSchema.table(
|
||||
slug: varchar("slug", { length: 120 }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
overview: text("overview").notNull(),
|
||||
youtubeUrl: text("youtube_url"),
|
||||
coverMediaId: uuid("cover_media_id")
|
||||
.notNull()
|
||||
.references(() => media.id, { onDelete: "restrict" }),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "guides"."guide" ADD COLUMN "youtube_url" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1788326787914,
|
||||
"tag": "0019_equal_lord_hawal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1788411911516,
|
||||
"tag": "0020_aberrant_dark_beast",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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