feat : update
This commit is contained in:
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
|
GripVerticalIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, type ComponentProps } from "react";
|
import { useState, type ComponentProps } from "react";
|
||||||
@@ -35,9 +37,33 @@ import {
|
|||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ExtraLink = { id: string; slug: string; title: string };
|
type ExtraLink = { id: string; slug: string; title: string };
|
||||||
|
|
||||||
|
function SortableExtraCard({ extra, index, characterKey, disabled }: { extra: ExtraLink; index: number; characterKey: string; disabled: boolean }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: extra.id, disabled });
|
||||||
|
return (
|
||||||
|
<Card ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn(isDragging && "relative z-10 opacity-70 shadow-lg")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{extra.title}</CardTitle>
|
||||||
|
<CardDescription>Extra section {index + 1}</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled} className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับ ${extra.title}`} {...attributes} {...listeners}>
|
||||||
|
<GripVerticalIcon />
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" render={<Link href={`/admin/${characterKey}/${extra.slug}`} />} nativeButton={false}>
|
||||||
|
แก้ไข Extra
|
||||||
|
<ArrowUpRightIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AddExtraButton({
|
export function AddExtraButton({
|
||||||
guideId,
|
guideId,
|
||||||
characterKey,
|
characterKey,
|
||||||
@@ -146,21 +172,23 @@ export function ExtraOverview({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [extras, setExtras] = useState(initialExtras);
|
const [extras, setExtras] = useState(initialExtras);
|
||||||
const [version, setVersion] = useState(initialVersion);
|
const [version, setVersion] = useState(initialVersion);
|
||||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
const [pending, setPending] = useState(false);
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
||||||
|
|
||||||
const move = async (index: number, direction: -1 | 1) => {
|
const reorder = async (event: DragEndEvent) => {
|
||||||
const target = index + direction;
|
if (pending || !event.over || event.active.id === event.over.id) return;
|
||||||
if (target < 0 || target >= extras.length) return;
|
const oldIndex = extras.findIndex((extra) => extra.id === event.active.id);
|
||||||
|
const newIndex = extras.findIndex((extra) => extra.id === event.over?.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
const previous = extras;
|
const previous = extras;
|
||||||
const next = [...extras];
|
const next = arrayMove(extras, oldIndex, newIndex);
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
|
||||||
setExtras(next);
|
setExtras(next);
|
||||||
setPendingId(extras[index].id);
|
setPending(true);
|
||||||
const result = await reorderGuideExtras(guideId, {
|
const result = await reorderGuideExtras(guideId, {
|
||||||
expectedVersion: version,
|
expectedVersion: version,
|
||||||
sectionIds: next.map((extra) => extra.id),
|
sectionIds: next.map((extra) => extra.id),
|
||||||
});
|
});
|
||||||
setPendingId(null);
|
setPending(false);
|
||||||
if (result.status !== "saved") {
|
if (result.status !== "saved") {
|
||||||
setExtras(previous);
|
setExtras(previous);
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
@@ -185,48 +213,13 @@ export function ExtraOverview({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{extras.length ? (
|
{extras.length ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => void reorder(event)}>
|
||||||
{extras.map((extra, index) => (
|
<SortableContext items={extras.map((extra) => extra.id)} strategy={rectSortingStrategy}>
|
||||||
<Card key={extra.id}>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<CardHeader>
|
{extras.map((extra, index) => <SortableExtraCard key={extra.id} extra={extra} index={index} characterKey={characterKey} disabled={pending} />)}
|
||||||
<CardTitle>{extra.title}</CardTitle>
|
</div>
|
||||||
<CardDescription>Extra section {index + 1}</CardDescription>
|
</SortableContext>
|
||||||
<CardAction className="flex items-center gap-1">
|
</DndContext>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={index === 0 || pendingId !== null}
|
|
||||||
aria-label={`เลื่อน ${extra.title} ขึ้น`}
|
|
||||||
onClick={() => void move(index, -1)}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={index === extras.length - 1 || pendingId !== null}
|
|
||||||
aria-label={`เลื่อน ${extra.title} ลง`}
|
|
||||||
onClick={() => void move(index, 1)}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon />
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
render={<Link href={`/admin/${characterKey}/${extra.slug}`} />}
|
|
||||||
nativeButton={false}
|
|
||||||
>
|
|
||||||
แก้ไข Extra
|
|
||||||
<ArrowUpRightIcon data-icon="inline-end" />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Image from "next/image";
|
|||||||
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { GripVerticalIcon, Trash2Icon } from "lucide-react";
|
import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { saveGuideOverview } from "@/app/admin/actions";
|
import { saveGuideOverview } from "@/app/admin/actions";
|
||||||
@@ -54,6 +54,11 @@ type BleedingLayer = {
|
|||||||
blendMode: PosterBlendMode;
|
blendMode: PosterBlendMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VideoLink = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
||||||
const blendModeItems = blendModes.map((value) => ({
|
const blendModeItems = blendModes.map((value) => ({
|
||||||
value,
|
value,
|
||||||
@@ -68,6 +73,39 @@ function createBleedingLayerId(): string {
|
|||||||
return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`;
|
return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createVideoLinkId(): string {
|
||||||
|
const browserCrypto = globalThis.crypto;
|
||||||
|
if (typeof browserCrypto?.randomUUID === "function") return `video-${browserCrypto.randomUUID()}`;
|
||||||
|
bleedingLayerIdCounter += 1;
|
||||||
|
return `video-${Date.now()}-${bleedingLayerIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableVideoLink({ link, index, guideName, onChange, onRemove }: { link: VideoLink; index: number; guideName: string; onChange: (url: string) => void; onRemove: () => void }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: link.id });
|
||||||
|
const video = parseYouTubeUrl(link.url.trim());
|
||||||
|
const invalid = Boolean(link.url.trim() && !video);
|
||||||
|
return (
|
||||||
|
<Field ref={setNodeRef} data-invalid={invalid} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("rounded-lg border p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับวิดีโอ ${index + 1}`} {...attributes} {...listeners}>
|
||||||
|
<GripVerticalIcon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
<FieldLabel htmlFor={`guide-youtube-url-${link.id}`} className="flex-1">YouTube video {index + 1}</FieldLabel>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label={`ลบวิดีโอ ${index + 1}`}>
|
||||||
|
<Trash2Icon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input id={`guide-youtube-url-${link.id}`} type="url" inputMode="url" value={link.url} maxLength={2_048} placeholder="https://www.youtube.com/watch?v=..." aria-invalid={invalid} onChange={(event) => onChange(event.target.value)} />
|
||||||
|
{invalid ? <FieldError>กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง</FieldError> : null}
|
||||||
|
{video ? (
|
||||||
|
<div className="aspect-video w-full max-w-64 overflow-hidden rounded-lg border bg-muted">
|
||||||
|
<iframe src={video.embedUrl} title={`ตัวอย่างวิดีโอไกด์ ${guideName} ${index + 1}`} 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>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "blendMode">>) => void; onRemove: () => void }) {
|
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "blendMode">>) => void; onRemove: () => void }) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
|
||||||
const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
|
const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
|
||||||
@@ -109,7 +147,7 @@ export function GuideOverviewForm({
|
|||||||
id: string;
|
id: string;
|
||||||
version: number;
|
version: number;
|
||||||
overview: string;
|
overview: string;
|
||||||
youtubeUrl: string | null;
|
youtubeUrls: string[];
|
||||||
coverMediaId: string;
|
coverMediaId: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -125,7 +163,7 @@ export function GuideOverviewForm({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const [overview, setOverview] = useState(guide.overview);
|
const [overview, setOverview] = useState(guide.overview);
|
||||||
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
|
const [youtubeLinks, setYoutubeLinks] = useState<VideoLink[]>(() => guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url })));
|
||||||
const [cover, setCover] = useState({
|
const [cover, setCover] = useState({
|
||||||
id: guide.coverMediaId,
|
id: guide.coverMediaId,
|
||||||
url: guide.coverUrl,
|
url: guide.coverUrl,
|
||||||
@@ -143,12 +181,11 @@ export function GuideOverviewForm({
|
|||||||
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
||||||
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
|
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
|
||||||
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
||||||
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
const invalidYoutubeLinks = youtubeLinks.some((link) => !parseYouTubeUrl(link.url.trim()));
|
||||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
|
||||||
const value = {
|
const value = {
|
||||||
coverMediaId: cover.id,
|
coverMediaId: cover.id,
|
||||||
overview,
|
overview,
|
||||||
youtubeUrl,
|
youtubeUrls: youtubeLinks.map((link) => link.url),
|
||||||
posterArtMediaId: posterArt.id,
|
posterArtMediaId: posterArt.id,
|
||||||
posterArtOffsetX: posterArtOffset.x,
|
posterArtOffsetX: posterArtOffset.x,
|
||||||
posterArtOffsetY: posterArtOffset.y,
|
posterArtOffsetY: posterArtOffset.y,
|
||||||
@@ -160,14 +197,14 @@ export function GuideOverviewForm({
|
|||||||
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,
|
invalidYoutubeLinks ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
|
||||||
].filter((issue): issue is string => Boolean(issue));
|
].filter((issue): issue is string => Boolean(issue));
|
||||||
const invalidCover = !cover.id;
|
const invalidCover = !cover.id;
|
||||||
const invalidOverview = !overview.trim();
|
const invalidOverview = !overview.trim();
|
||||||
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
|
const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
|
||||||
const autosave = useAutosave({
|
const autosave = useAutosave({
|
||||||
value,
|
value,
|
||||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks),
|
||||||
initialVersion: guide.version,
|
initialVersion: guide.version,
|
||||||
persist: (next, expectedVersion) =>
|
persist: (next, expectedVersion) =>
|
||||||
saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
||||||
@@ -188,15 +225,18 @@ export function GuideOverviewForm({
|
|||||||
typeof draftCover.url !== "string"
|
typeof draftCover.url !== "string"
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (
|
const youtubeLinks = Array.isArray(draft.youtubeLinks)
|
||||||
draft.youtubeUrl !== undefined &&
|
? draft.youtubeLinks.flatMap((item) => {
|
||||||
typeof draft.youtubeUrl !== "string"
|
if (!item || typeof item !== "object") return [];
|
||||||
)
|
const link = item as Record<string, unknown>;
|
||||||
return null;
|
return typeof link.id === "string" && typeof link.url === "string" ? [{ id: link.id, url: link.url }] : [];
|
||||||
|
})
|
||||||
|
: typeof draft.youtubeUrl === "string" && draft.youtubeUrl
|
||||||
|
? [{ id: createVideoLinkId(), url: draft.youtubeUrl }]
|
||||||
|
: guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url }));
|
||||||
return {
|
return {
|
||||||
overview: draft.overview,
|
overview: draft.overview,
|
||||||
youtubeUrl:
|
youtubeLinks,
|
||||||
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
|
||||||
cover: { id: draftCover.id, url: draftCover.url },
|
cover: { id: draftCover.id, url: draftCover.url },
|
||||||
posterArt: draft.posterArt && typeof draft.posterArt === "object"
|
posterArt: draft.posterArt && typeof draft.posterArt === "object"
|
||||||
? {
|
? {
|
||||||
@@ -234,7 +274,7 @@ export function GuideOverviewForm({
|
|||||||
},
|
},
|
||||||
restoreLocalDraft: (draft) => {
|
restoreLocalDraft: (draft) => {
|
||||||
setOverview(draft.overview);
|
setOverview(draft.overview);
|
||||||
setYoutubeUrl(draft.youtubeUrl);
|
setYoutubeLinks(draft.youtubeLinks);
|
||||||
setCover(draft.cover);
|
setCover(draft.cover);
|
||||||
setPosterArt(draft.posterArt);
|
setPosterArt(draft.posterArt);
|
||||||
setPosterArtOffset(draft.posterArtOffset);
|
setPosterArtOffset(draft.posterArtOffset);
|
||||||
@@ -311,6 +351,15 @@ export function GuideOverviewForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVideoDragEnd(event: DragEndEvent) {
|
||||||
|
if (!event.over || event.active.id === event.over.id) return;
|
||||||
|
setYoutubeLinks((current) => {
|
||||||
|
const oldIndex = current.findIndex((link) => link.id === event.active.id);
|
||||||
|
const newIndex = current.findIndex((link) => link.id === event.over?.id);
|
||||||
|
return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -323,99 +372,67 @@ export function GuideOverviewForm({
|
|||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={invalidCover}>
|
<Field data-invalid={invalidCover}>
|
||||||
<FieldLabel>Guide Image</FieldLabel>
|
<FieldLabel>Guide Image</FieldLabel>
|
||||||
<div className={cn(
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
"relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border",
|
<div className={cn(
|
||||||
invalidCover && "border-destructive",
|
"relative aspect-square w-full overflow-hidden rounded-lg border",
|
||||||
)}>
|
invalidCover && "border-destructive",
|
||||||
<Image
|
)}>
|
||||||
src={cover.url}
|
<Image
|
||||||
alt={`Guide ${guide.name}`}
|
src={cover.url}
|
||||||
fill
|
alt={`Guide ${guide.name}`}
|
||||||
className="object-cover"
|
fill
|
||||||
/>
|
className="object-cover"
|
||||||
</div>
|
|
||||||
<MediaUploader onReady={(asset) => setCover(asset)} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={invalidOverview}>
|
|
||||||
<FieldLabel htmlFor="guide-overview">Overview</FieldLabel>
|
|
||||||
<Textarea
|
|
||||||
id="guide-overview"
|
|
||||||
value={overview}
|
|
||||||
aria-invalid={invalidOverview}
|
|
||||||
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}
|
|
||||||
{youtubeVideo ? (
|
|
||||||
<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>
|
</div>
|
||||||
) : null}
|
<div className="rounded-lg border p-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Upload Guide Image</FieldLabel>
|
||||||
|
<FieldDescription>อัปโหลดภาพสี่เหลี่ยมสำหรับหน้าแรกของ Guide</FieldDescription>
|
||||||
|
<MediaUploader onReady={(asset) => setCover(asset)} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={invalidOverview}>
|
||||||
|
<FieldLabel htmlFor="guide-overview">Overview</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="guide-overview"
|
||||||
|
value={overview}
|
||||||
|
aria-invalid={invalidOverview}
|
||||||
|
maxLength={500}
|
||||||
|
onChange={(event) => setOverview(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>YouTube videos</FieldLabel>
|
||||||
|
<FieldDescription>ไม่บังคับ รองรับ YouTube, youtu.be, Shorts และ Live · ลากเพื่อจัดลำดับ</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={youtubeLinks.length >= 20} onClick={() => setYoutubeLinks((current) => [...current, { id: createVideoLinkId(), url: "" }])}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
เพิ่มลิงก์
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{youtubeLinks.length ? (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleVideoDragEnd}>
|
||||||
|
<SortableContext items={youtubeLinks.map((link) => link.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{youtubeLinks.map((link, index) => (
|
||||||
|
<SortableVideoLink key={link.id} link={link} index={index} guideName={guide.name} onChange={(url) => setYoutubeLinks((current) => current.map((item) => item.id === link.id ? { ...item, url } : item))} onRemove={() => setYoutubeLinks((current) => current.filter((item) => item.id !== link.id))} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : <p className="text-sm text-muted-foreground">ยังไม่มีวิดีโอใน Overview</p>}
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Materials Overview</FieldLabel>
|
<FieldLabel>Materials Overview</FieldLabel>
|
||||||
<div className="mx-auto w-full max-w-md overflow-hidden rounded-lg border">
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
<MaterialPoster
|
<div className="w-full overflow-hidden rounded-lg border">
|
||||||
character={guide.character}
|
|
||||||
characterArtUrl={posterArt.url}
|
|
||||||
artworkOffsetX={posterArtOffset.x}
|
|
||||||
artworkOffsetY={posterArtOffset.y}
|
|
||||||
artworkScale={posterArtScale}
|
|
||||||
topColors={posterTopColors}
|
|
||||||
bottomColors={posterBottomColors}
|
|
||||||
bleeding={bleeding}
|
|
||||||
interactive
|
|
||||||
onArtworkMove={setPosterArtOffset}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
||||||
<MediaUploader
|
|
||||||
onReady={setUploadedPoster}
|
|
||||||
onFileSelected={detectPosterColorsFromFile}
|
|
||||||
/>
|
|
||||||
{posterArt.id ? (
|
|
||||||
<Button type="button" variant="outline" onClick={removeUploadedPoster}>
|
|
||||||
<Trash2Icon data-icon="inline-start" />
|
|
||||||
ลบภาพตัวละคร
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<GuideImageExport
|
|
||||||
width={1600}
|
|
||||||
height={2000}
|
|
||||||
format="png"
|
|
||||||
disabled={!posterArt.url}
|
|
||||||
fileName={`${guide.name}-materials.png`}
|
|
||||||
captureClassName="p-0"
|
|
||||||
>
|
|
||||||
<MaterialPoster
|
<MaterialPoster
|
||||||
character={guide.character}
|
character={guide.character}
|
||||||
characterArtUrl={posterArt.url}
|
characterArtUrl={posterArt.url}
|
||||||
@@ -425,34 +442,69 @@ export function GuideOverviewForm({
|
|||||||
topColors={posterTopColors}
|
topColors={posterTopColors}
|
||||||
bottomColors={posterBottomColors}
|
bottomColors={posterBottomColors}
|
||||||
bleeding={bleeding}
|
bleeding={bleeding}
|
||||||
|
interactive
|
||||||
|
onArtworkMove={setPosterArtOffset}
|
||||||
/>
|
/>
|
||||||
</GuideImageExport>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Bleeding layers</FieldLabel>
|
|
||||||
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={!posterArt.id || !posterArt.url} onClick={addBleedingEffect}>เพิ่ม effect</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{bleeding.length ? (
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
||||||
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
|
<MediaUploader
|
||||||
<div className="flex flex-col gap-2">
|
onReady={setUploadedPoster}
|
||||||
{bleeding.map((layer) => <SortableBleedingLayer key={layer.id} layer={layer} onChange={(patch) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)}
|
onFileSelected={detectPosterColorsFromFile}
|
||||||
|
/>
|
||||||
|
{posterArt.id ? (
|
||||||
|
<Button type="button" variant="outline" onClick={removeUploadedPoster}>
|
||||||
|
<Trash2Icon data-icon="inline-start" />
|
||||||
|
ลบภาพตัวละคร
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<GuideImageExport
|
||||||
|
width={1600}
|
||||||
|
height={2000}
|
||||||
|
format="png"
|
||||||
|
disabled={!posterArt.url}
|
||||||
|
fileName={`${guide.name}-materials.png`}
|
||||||
|
captureClassName="p-0"
|
||||||
|
>
|
||||||
|
<MaterialPoster
|
||||||
|
character={guide.character}
|
||||||
|
characterArtUrl={posterArt.url}
|
||||||
|
artworkOffsetX={posterArtOffset.x}
|
||||||
|
artworkOffsetY={posterArtOffset.y}
|
||||||
|
artworkScale={posterArtScale}
|
||||||
|
topColors={posterTopColors}
|
||||||
|
bottomColors={posterBottomColors}
|
||||||
|
bleeding={bleeding}
|
||||||
|
/>
|
||||||
|
</GuideImageExport>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Bleeding layers</FieldLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน</p>
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
<Button type="button" variant="outline" size="sm" disabled={!posterArt.id || !posterArt.url} onClick={addBleedingEffect}>เพิ่ม effect</Button>
|
||||||
</DndContext>
|
</div>
|
||||||
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
{bleeding.length ? (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
||||||
|
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{bleeding.map((layer) => <SortableBleedingLayer key={layer.id} layer={layer} onChange={(patch) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
||||||
|
</div>
|
||||||
|
<Field className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-scale">Character image zoom</FieldLabel><span className="text-sm text-muted-foreground">{posterArtScale}%</span></div>
|
||||||
|
<Slider id="poster-art-scale" min={50} max={500} step={5} value={[posterArtScale]} onValueChange={(value) => setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} />
|
||||||
|
</Field>
|
||||||
|
<FieldDescription>
|
||||||
|
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||||
|
</FieldDescription>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-scale">Character image zoom</FieldLabel><span className="text-sm text-muted-foreground">{posterArtScale}%</span></div>
|
|
||||||
<Slider id="poster-art-scale" min={50} max={500} step={5} value={[posterArtScale]} onValueChange={(value) => setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} />
|
|
||||||
</Field>
|
|
||||||
<FieldDescription>
|
|
||||||
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
|
||||||
</FieldDescription>
|
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -198,9 +198,10 @@ function GuideOverview({
|
|||||||
guide: PublicGuide;
|
guide: PublicGuide;
|
||||||
sections: ReturnType<typeof publicSections>;
|
sections: ReturnType<typeof publicSections>;
|
||||||
}) {
|
}) {
|
||||||
const youtubeVideo = guide.youtubeUrl
|
const youtubeVideos = guide.youtubeUrls.flatMap((url) => {
|
||||||
? parseYouTubeUrl(guide.youtubeUrl)
|
const video = parseYouTubeUrl(url);
|
||||||
: null;
|
return video ? [video] : [];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -222,7 +223,7 @@ function GuideOverview({
|
|||||||
<div className="order-3 lg:col-start-2 lg:row-start-1">
|
<div className="order-3 lg:col-start-2 lg:row-start-1">
|
||||||
<GuideNavigation guide={guide} sections={sections} />
|
<GuideNavigation guide={guide} sections={sections} />
|
||||||
</div>
|
</div>
|
||||||
{guide.posterArtUrl || youtubeVideo ? (
|
{guide.posterArtUrl || youtubeVideos.length ? (
|
||||||
<>
|
<>
|
||||||
{guide.posterArtUrl ? (
|
{guide.posterArtUrl ? (
|
||||||
<section className="order-2 flex flex-col items-center gap-3 lg:col-start-1 lg:row-start-2">
|
<section className="order-2 flex flex-col items-center gap-3 lg:col-start-1 lg:row-start-2">
|
||||||
@@ -256,34 +257,28 @@ function GuideOverview({
|
|||||||
</GuideImageExport>
|
</GuideImageExport>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{youtubeVideo ? (
|
{youtubeVideos.length ? (
|
||||||
<section className="order-4 flex flex-col items-center gap-3 lg:col-start-2 lg:row-start-2">
|
<div className="order-4 flex flex-col gap-6 lg:col-start-2 lg:row-start-2">
|
||||||
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
{youtubeVideos.map((youtubeVideo, index) => (
|
||||||
<iframe
|
<section key={`${youtubeVideo.videoId}-${index}`} className="flex flex-col items-center gap-3">
|
||||||
src={youtubeVideo.embedUrl}
|
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
||||||
title={`วิดีโอไกด์ ${guide.name}`}
|
<iframe
|
||||||
loading="lazy"
|
src={youtubeVideo.embedUrl}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
title={`วิดีโอไกด์ ${guide.name} ${index + 1}`}
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
loading="lazy"
|
||||||
allowFullScreen
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
className="size-full"
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
/>
|
allowFullScreen
|
||||||
</div>
|
className="size-full"
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
</div>
|
||||||
render={
|
<Button variant="outline" render={<a href={youtubeVideo.watchUrl} target="_blank" rel="noopener noreferrer" />} nativeButton={false}>
|
||||||
<a
|
ดูวิดีโอต้นฉบับบน YouTube
|
||||||
href={youtubeVideo.watchUrl}
|
<ArrowUpRightIcon data-icon="inline-end" />
|
||||||
target="_blank"
|
</Button>
|
||||||
rel="noopener noreferrer"
|
</section>
|
||||||
/>
|
))}
|
||||||
}
|
</div>
|
||||||
nativeButton={false}
|
|
||||||
>
|
|
||||||
ดูวิดีโอต้นฉบับบน YouTube
|
|
||||||
<ArrowUpRightIcon data-icon="inline-end" />
|
|
||||||
</Button>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+1
-1
@@ -536,7 +536,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"),
|
youtubeUrls: jsonb("youtube_urls").$type<string[]>().default([]).notNull(),
|
||||||
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,5 @@
|
|||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "youtube_urls" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE "guides"."guide"
|
||||||
|
SET "youtube_urls" = jsonb_build_array("youtube_url")
|
||||||
|
WHERE "youtube_url" IS NOT NULL AND btrim("youtube_url") <> '';--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" DROP COLUMN "youtube_url";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
|||||||
"when": 1788684897536,
|
"when": 1788684897536,
|
||||||
"tag": "0035_condemned_meltdown",
|
"tag": "0035_condemned_meltdown",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788689979183,
|
||||||
|
"tag": "0036_third_warbound",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
|||||||
await tx.update(guides).set({
|
await tx.update(guides).set({
|
||||||
overview: data.overview,
|
overview: data.overview,
|
||||||
coverMediaId: data.coverMediaId,
|
coverMediaId: data.coverMediaId,
|
||||||
...(data.youtubeUrl !== undefined ? { youtubeUrl: data.youtubeUrl } : {}),
|
youtubeUrls: data.youtubeUrls,
|
||||||
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
||||||
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
||||||
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
||||||
|
|||||||
@@ -29,16 +29,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", () => {
|
it("normalizes and orders multiple optional YouTube videos", () => {
|
||||||
const base = {
|
const base = {
|
||||||
expectedVersion: 1,
|
expectedVersion: 1,
|
||||||
coverMediaId: "550e8400-e29b-41d4-a716-446655440000",
|
coverMediaId: "550e8400-e29b-41d4-a716-446655440000",
|
||||||
overview: "Overview",
|
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, youtubeUrls: ["https://youtu.be/dQw4w9WgXcQ?si=share", "https://youtube.com/shorts/aqz-KE-bpKQ"] }).youtubeUrls).toEqual([
|
||||||
expect(updateGuideSchema.parse({ ...base, youtubeUrl: "" }).youtubeUrl).toBeNull();
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
expect(updateGuideSchema.parse(base).youtubeUrl).toBeUndefined();
|
"https://www.youtube.com/watch?v=aqz-KE-bpKQ",
|
||||||
expect(updateGuideSchema.safeParse({ ...base, youtubeUrl: "https://example.com/video" }).success).toBe(false);
|
]);
|
||||||
|
expect(updateGuideSchema.parse({ ...base, youtubeUrls: [] }).youtubeUrls).toEqual([]);
|
||||||
|
expect(updateGuideSchema.safeParse({ ...base, youtubeUrls: ["https://example.com/video"] }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires at least one weapon percentage", () => {
|
it("requires at least one weapon percentage", () => {
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ 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) => {
|
const youtubeUrlSchema = z.string().trim().min(1).max(2_048).transform((value, context) => {
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (!value) return null;
|
|
||||||
const video = parseYouTubeUrl(value);
|
const video = parseYouTubeUrl(value);
|
||||||
if (video) return video.watchUrl;
|
if (video) return video.watchUrl;
|
||||||
context.addIssue({ code: "custom", message: "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" });
|
context.addIssue({ code: "custom", message: "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" });
|
||||||
@@ -35,7 +33,7 @@ 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,
|
youtubeUrls: z.array(youtubeUrlSchema).max(20),
|
||||||
posterArtMediaId: z.string().uuid().nullable().optional(),
|
posterArtMediaId: z.string().uuid().nullable().optional(),
|
||||||
posterArtOffsetX: z.number().finite().min(-1000).max(1000).optional(),
|
posterArtOffsetX: z.number().finite().min(-1000).max(1000).optional(),
|
||||||
posterArtOffsetY: z.number().finite().min(-1000).max(1000).optional(),
|
posterArtOffsetY: z.number().finite().min(-1000).max(1000).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user