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 { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { useAutosave } from "@/hooks/use-autosave";
|
import { useAutosave } from "@/hooks/use-autosave";
|
||||||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
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 [overview, setOverview] = useState(guide.overview);
|
||||||
|
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
|
||||||
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
|
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 = [
|
const incompleteIssues = [
|
||||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||||
!overview.trim() ? "กรุณากรอก Overview" : null,
|
!overview.trim() ? "กรุณากรอก Overview" : null,
|
||||||
|
invalidYoutubeUrl ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
|
||||||
].filter((issue): issue is string => Boolean(issue));
|
].filter((issue): issue is string => Boolean(issue));
|
||||||
const localDraft = { overview, cover };
|
const localDraft = { overview, youtubeUrl, cover };
|
||||||
const autosave = useAutosave({
|
const autosave = useAutosave({
|
||||||
value,
|
value,
|
||||||
valid: Boolean(cover.id && overview.trim()),
|
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||||
initialVersion: guide.version,
|
initialVersion: guide.version,
|
||||||
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
||||||
storageKey: adminDraftKey(guide.id, "overview"),
|
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>;
|
const draft = candidate as Record<string, unknown>;
|
||||||
if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null;
|
if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null;
|
||||||
const draftCover = draft.cover as Record<string, unknown>;
|
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) => {
|
restoreLocalDraft: (draft) => {
|
||||||
setOverview(draft.overview);
|
setOverview(draft.overview);
|
||||||
|
setYoutubeUrl(draft.youtubeUrl);
|
||||||
setCover(draft.cover);
|
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 { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardAction,
|
CardAction,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
talentRelationSymbol,
|
talentRelationSymbol,
|
||||||
talentTypeLabel,
|
talentTypeLabel,
|
||||||
} from "@/lib/guides/talents";
|
} from "@/lib/guides/talents";
|
||||||
|
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function hasSection(guide: PublicGuide, slug: string): boolean {
|
function hasSection(guide: PublicGuide, slug: string): boolean {
|
||||||
@@ -112,54 +114,51 @@ export function StructuredGuideShell({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollableNav
|
{section ? (
|
||||||
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
<ScrollableNav
|
||||||
className="mx-auto min-h-11 w-full max-w-7xl rounded-xl border bg-card p-2"
|
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
|
|
||||||
>
|
>
|
||||||
<Image
|
|
||||||
src={navigationIconPath("overview")!}
|
|
||||||
alt=""
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="size-5 object-contain"
|
|
||||||
/>
|
|
||||||
Overview
|
|
||||||
</Link>
|
|
||||||
{sections.map((item) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
href={`/${guide.slug}`}
|
||||||
href={`/${guide.slug}/${item.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"
|
||||||
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
|
prefetch
|
||||||
>
|
>
|
||||||
{navigationIconPath(item.slug) ? (
|
<Image
|
||||||
<Image
|
src={navigationIconPath("overview")!}
|
||||||
src={navigationIconPath(item.slug)!}
|
alt=""
|
||||||
alt=""
|
width={20}
|
||||||
width={20}
|
height={20}
|
||||||
height={20}
|
className="size-5 object-contain"
|
||||||
className="size-5 object-contain"
|
/>
|
||||||
/>
|
Overview
|
||||||
) : null}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
{sections.map((item) => (
|
||||||
</ScrollableNav>
|
<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>
|
</div>
|
||||||
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
|
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
|
||||||
{section ? (
|
{section ? (
|
||||||
@@ -179,6 +178,10 @@ function GuideOverview({
|
|||||||
guide: PublicGuide;
|
guide: PublicGuide;
|
||||||
sections: ReturnType<typeof publicSections>;
|
sections: ReturnType<typeof publicSections>;
|
||||||
}) {
|
}) {
|
||||||
|
const youtubeVideo = guide.youtubeUrl
|
||||||
|
? parseYouTubeUrl(guide.youtubeUrl)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
|
<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>
|
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
|
||||||
<p className="text-lg font-semibold">{guide.overview}</p>
|
<p className="text-lg font-semibold">{guide.overview}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-xl border bg-muted">
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
<Image
|
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-muted">
|
||||||
src={guide.coverUrl}
|
<Image
|
||||||
alt={`ไกด์ ${guide.name}`}
|
src={guide.coverUrl}
|
||||||
fill
|
alt={`ไกด์ ${guide.name}`}
|
||||||
priority
|
fill
|
||||||
className="object-cover"
|
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>
|
</div>
|
||||||
) : (
|
{sections.length ? (
|
||||||
<Card>
|
<nav
|
||||||
<CardHeader>
|
aria-label={`เมนูคู่มือ ${guide.name}`}
|
||||||
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
|
className="flex flex-col gap-3"
|
||||||
<CardDescription>
|
>
|
||||||
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
|
{sections.map((item) => (
|
||||||
</CardDescription>
|
<Link
|
||||||
</CardHeader>
|
key={item.id}
|
||||||
</Card>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ export const guides = guidesSchema.table(
|
|||||||
slug: varchar("slug", { length: 120 }).notNull(),
|
slug: varchar("slug", { length: 120 }).notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
overview: text("overview").notNull(),
|
overview: text("overview").notNull(),
|
||||||
|
youtubeUrl: text("youtube_url"),
|
||||||
coverMediaId: uuid("cover_media_id")
|
coverMediaId: uuid("cover_media_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => media.id, { onDelete: "restrict" }),
|
.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,
|
"when": 1788326787914,
|
||||||
"tag": "0019_equal_lord_hawal",
|
"tag": "0019_equal_lord_hawal",
|
||||||
"breakpoints": true
|
"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 ที่อัปโหลดแล้ว");
|
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
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) {
|
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`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));
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
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";
|
import { recommendedStatIconName, targetStatIconName } from "@/lib/guides/stats";
|
||||||
|
|
||||||
describe("structured guide validation", () => {
|
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);
|
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", () => {
|
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: 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);
|
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 * as z from "zod";
|
||||||
|
|
||||||
import { targetStatOptions } from "@/lib/guides/stats";
|
import { targetStatOptions } from "@/lib/guides/stats";
|
||||||
|
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||||
|
|
||||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||||
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||||
@@ -14,10 +15,20 @@ export const createGuideSchema = z.strictObject({
|
|||||||
overview: z.string().trim().max(500),
|
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({
|
export const updateGuideSchema = z.strictObject({
|
||||||
expectedVersion: z.number().int().positive(),
|
expectedVersion: z.number().int().positive(),
|
||||||
coverMediaId: z.string().uuid(),
|
coverMediaId: z.string().uuid(),
|
||||||
overview: z.string().trim().min(1).max(500),
|
overview: z.string().trim().min(1).max(500),
|
||||||
|
youtubeUrl: youtubeUrlSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const weaponSectionSchema = z.strictObject({
|
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