feat : update
This commit is contained in:
@@ -6,6 +6,8 @@ import { useState } from "react";
|
||||
import { saveGuideOverview } from "@/app/admin/actions";
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { MaterialPoster } from "@/components/public/material-poster";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -26,6 +28,8 @@ 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";
|
||||
import { detectThemeColorsFromPixels } from "@/lib/overview-image";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GuideOverviewForm({
|
||||
@@ -39,6 +43,13 @@ export function GuideOverviewForm({
|
||||
coverMediaId: string;
|
||||
coverUrl: string;
|
||||
name: string;
|
||||
posterArtMediaId: string | null;
|
||||
posterArtUrl: string | null;
|
||||
posterArtOffsetX: number;
|
||||
posterArtOffsetY: number;
|
||||
posterTopColors: [string, string];
|
||||
posterBottomColors: [string, string];
|
||||
character: Omit<PublicGuide["character"], "aliases">;
|
||||
};
|
||||
}) {
|
||||
const [overview, setOverview] = useState(guide.overview);
|
||||
@@ -47,9 +58,28 @@ export function GuideOverviewForm({
|
||||
id: guide.coverMediaId,
|
||||
url: guide.coverUrl,
|
||||
});
|
||||
const [posterArt, setPosterArt] = useState({
|
||||
id: guide.posterArtMediaId,
|
||||
url: guide.posterArtUrl,
|
||||
});
|
||||
const [posterArtOffset, setPosterArtOffset] = useState({
|
||||
x: guide.posterArtOffsetX,
|
||||
y: guide.posterArtOffsetY,
|
||||
});
|
||||
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
||||
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
||||
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
||||
const value = { coverMediaId: cover.id, overview, youtubeUrl };
|
||||
const value = {
|
||||
coverMediaId: cover.id,
|
||||
overview,
|
||||
youtubeUrl,
|
||||
posterArtMediaId: posterArt.id,
|
||||
posterArtOffsetX: posterArtOffset.x,
|
||||
posterArtOffsetY: posterArtOffset.y,
|
||||
posterTopColors,
|
||||
posterBottomColors,
|
||||
};
|
||||
const incompleteIssues = [
|
||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||
!overview.trim() ? "กรุณากรอก Overview" : null,
|
||||
@@ -57,7 +87,7 @@ export function GuideOverviewForm({
|
||||
].filter((issue): issue is string => Boolean(issue));
|
||||
const invalidCover = !cover.id;
|
||||
const invalidOverview = !overview.trim();
|
||||
const localDraft = { overview, youtubeUrl, cover };
|
||||
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors };
|
||||
const autosave = useAutosave({
|
||||
value,
|
||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||
@@ -91,14 +121,78 @@ export function GuideOverviewForm({
|
||||
youtubeUrl:
|
||||
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
||||
cover: { id: draftCover.id, url: draftCover.url },
|
||||
posterArt: draft.posterArt && typeof draft.posterArt === "object"
|
||||
? {
|
||||
id: typeof (draft.posterArt as Record<string, unknown>).id === "string"
|
||||
? (draft.posterArt as Record<string, unknown>).id as string
|
||||
: null,
|
||||
url: typeof (draft.posterArt as Record<string, unknown>).url === "string"
|
||||
? (draft.posterArt as Record<string, unknown>).url as string
|
||||
: null,
|
||||
}
|
||||
: { id: guide.posterArtMediaId, url: guide.posterArtUrl },
|
||||
posterArtOffset: draft.posterArtOffset && typeof draft.posterArtOffset === "object"
|
||||
? {
|
||||
x: typeof (draft.posterArtOffset as Record<string, unknown>).x === "number" ? (draft.posterArtOffset as Record<string, unknown>).x as number : 0,
|
||||
y: typeof (draft.posterArtOffset as Record<string, unknown>).y === "number" ? (draft.posterArtOffset as Record<string, unknown>).y as number : 0,
|
||||
}
|
||||
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
|
||||
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
|
||||
? draft.posterTopColors as [string, string]
|
||||
: guide.posterTopColors,
|
||||
posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2
|
||||
? draft.posterBottomColors as [string, string]
|
||||
: guide.posterBottomColors,
|
||||
};
|
||||
},
|
||||
restoreLocalDraft: (draft) => {
|
||||
setOverview(draft.overview);
|
||||
setYoutubeUrl(draft.youtubeUrl);
|
||||
setCover(draft.cover);
|
||||
setPosterArt(draft.posterArt);
|
||||
setPosterArtOffset(draft.posterArtOffset);
|
||||
setPosterTopColors(draft.posterTopColors);
|
||||
setPosterBottomColors(draft.posterBottomColors);
|
||||
},
|
||||
});
|
||||
|
||||
async function detectPosterColors(url: string) {
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const candidate = new window.Image();
|
||||
candidate.crossOrigin = "anonymous";
|
||||
candidate.onload = () => resolve(candidate);
|
||||
candidate.onerror = reject;
|
||||
candidate.src = url;
|
||||
});
|
||||
const scale = Math.min(1, 512 / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const colors = detectThemeColorsFromPixels(
|
||||
context.getImageData(0, 0, canvas.width, canvas.height).data,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
setPosterTopColors(colors.top);
|
||||
setPosterBottomColors(colors.bottom);
|
||||
} catch {
|
||||
// Keep the current colors when the uploaded media host disallows pixel reads.
|
||||
}
|
||||
}
|
||||
|
||||
function setUploadedPoster(asset: { id: string; url: string }) {
|
||||
setPosterArt(asset);
|
||||
setPosterArtOffset({ x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function detectPosterColorsFromFile(file: File) {
|
||||
const url = URL.createObjectURL(file);
|
||||
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -169,6 +263,47 @@ export function GuideOverviewForm({
|
||||
</div>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Materials Overview</FieldLabel>
|
||||
<div className="mx-auto w-full max-w-md overflow-hidden rounded-lg border">
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={posterArt.url}
|
||||
artworkOffsetX={posterArtOffset.x}
|
||||
artworkOffsetY={posterArtOffset.y}
|
||||
topColors={posterTopColors}
|
||||
bottomColors={posterBottomColors}
|
||||
interactive
|
||||
onArtworkMove={setPosterArtOffset}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<MediaUploader
|
||||
onReady={setUploadedPoster}
|
||||
onFileSelected={detectPosterColorsFromFile}
|
||||
/>
|
||||
<GuideImageExport
|
||||
width={1600}
|
||||
height={2000}
|
||||
format="webp"
|
||||
disabled={!posterArt.url}
|
||||
fileName={`${guide.name}-materials.webp`}
|
||||
captureClassName="p-0"
|
||||
>
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={posterArt.url}
|
||||
artworkOffsetX={posterArtOffset.x}
|
||||
artworkOffsetY={posterArtOffset.y}
|
||||
topColors={posterTopColors}
|
||||
bottomColors={posterBottomColors}
|
||||
/>
|
||||
</GuideImageExport>
|
||||
</div>
|
||||
<FieldDescription>
|
||||
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
|
||||
@@ -19,8 +19,10 @@ export interface UploadedMedia {
|
||||
|
||||
export function MediaUploader({
|
||||
onReady,
|
||||
onFileSelected,
|
||||
}: {
|
||||
onReady: (media: UploadedMedia) => void;
|
||||
onFileSelected?: (file: File) => void;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const [state, setState] = useState<"idle" | "uploading">("idle");
|
||||
@@ -67,7 +69,10 @@ export function MediaUploader({
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void upload(file);
|
||||
if (file) {
|
||||
onFileSelected?.(file);
|
||||
void upload(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -20,12 +20,16 @@ export function GuideImageExport({
|
||||
fileName,
|
||||
width = EXPORT_WIDTH,
|
||||
height,
|
||||
format = "png",
|
||||
disabled = false,
|
||||
captureClassName,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fileName: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: "png" | "webp";
|
||||
disabled?: boolean;
|
||||
captureClassName?: string;
|
||||
}) {
|
||||
const captureRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,6 +49,7 @@ export function GuideImageExport({
|
||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||
const blob = await domToBlob(node, {
|
||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||
type: `image/${format}`,
|
||||
scale: 1,
|
||||
width,
|
||||
height,
|
||||
@@ -70,7 +75,7 @@ export function GuideImageExport({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [capturing, fileName, height, width]);
|
||||
}, [capturing, fileName, format, height, width]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,7 +83,7 @@ export function GuideImageExport({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={capturing}
|
||||
disabled={capturing || disabled}
|
||||
onClick={() => setCapturing(true)}
|
||||
>
|
||||
{capturing ? (
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRef, type PointerEvent } from "react";
|
||||
|
||||
import type {
|
||||
CatalogAscensionGroup,
|
||||
CatalogBaseStat,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PosterMaterial = {
|
||||
key: string;
|
||||
amount: number;
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
};
|
||||
|
||||
type PosterCharacter = {
|
||||
name: string;
|
||||
nameCardImageUrl: string | null;
|
||||
baseStats: CatalogBaseStat[];
|
||||
ascension: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
|
||||
};
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
hp: "HP",
|
||||
atk: "ATK",
|
||||
def: "DEF",
|
||||
em: "EM",
|
||||
};
|
||||
|
||||
function statLabel(key: string): string {
|
||||
return statLabels[key.toLowerCase()] ?? key;
|
||||
}
|
||||
|
||||
function statValue(key: string, value: number | string): string {
|
||||
const formatted = typeof value === "number"
|
||||
? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
||||
: value;
|
||||
return key.includes("%") ? `${formatted}%` : formatted;
|
||||
}
|
||||
|
||||
function clampOffset(value: number): number {
|
||||
return Math.max(-50, Math.min(50, value));
|
||||
}
|
||||
|
||||
export function MaterialPoster({
|
||||
character,
|
||||
characterArtUrl,
|
||||
artworkOffsetX,
|
||||
artworkOffsetY,
|
||||
topColors,
|
||||
bottomColors,
|
||||
interactive = false,
|
||||
onArtworkMove,
|
||||
className,
|
||||
}: {
|
||||
character: PosterCharacter;
|
||||
characterArtUrl: string | null;
|
||||
artworkOffsetX: number;
|
||||
artworkOffsetY: number;
|
||||
topColors: [string, string];
|
||||
bottomColors: [string, string];
|
||||
interactive?: boolean;
|
||||
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const stats = character.baseStats.slice(0, 4);
|
||||
const materials = character.ascension.flatMap((group) => group.items);
|
||||
const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||
if (!interactive || !characterArtUrl || !onArtworkMove) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStart.current = {
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: artworkOffsetX,
|
||||
offsetY: artworkOffsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
|
||||
if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
onArtworkMove({
|
||||
x: clampOffset(dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100),
|
||||
y: clampOffset(dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100),
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
|
||||
if (dragStart.current?.pointerId === event.pointerId) dragStart.current = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("@container relative aspect-[4/5] w-full select-none overflow-hidden bg-black text-white", interactive && characterArtUrl && "cursor-move", className)}
|
||||
style={{ backgroundImage: background }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
role={interactive && characterArtUrl ? "application" : undefined}
|
||||
aria-label={interactive && characterArtUrl ? "ลากเพื่อเลื่อนภาพตัวละคร" : undefined}
|
||||
>
|
||||
{character.nameCardImageUrl ? (
|
||||
<Image
|
||||
src={character.nameCardImageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 800px, 100vw"
|
||||
className="object-cover opacity-75"
|
||||
priority={!interactive}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-black/55" />
|
||||
<Image
|
||||
src="/img/mat/fade.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 800px, 100vw"
|
||||
className="pointer-events-none object-cover opacity-75"
|
||||
priority={!interactive}
|
||||
/>
|
||||
{characterArtUrl ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%)` }}
|
||||
>
|
||||
<Image
|
||||
src={characterArtUrl}
|
||||
alt={character.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 800px, 100vw"
|
||||
className="object-contain object-center"
|
||||
priority={!interactive}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
|
||||
|
||||
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
|
||||
<h2 className="text-[7cqw] leading-none">BASE STATS</h2>
|
||||
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.5%]">
|
||||
{Array.from({ length: 4 }, (_, index) => {
|
||||
const stat = stats[index];
|
||||
return (
|
||||
<div key={stat?.key ?? index} className="flex h-[7cqw] items-center justify-between rounded-[1.5cqw] bg-black/95 px-[4%] text-[3.2cqw] leading-none">
|
||||
<span>{stat ? statLabel(stat.key) : "—"}</span>
|
||||
<span>{stat ? statValue(stat.key, stat.value) : "—"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-[4%] top-[51%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
|
||||
<h2 className="text-[7cqw] leading-none">MATERIALS</h2>
|
||||
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
|
||||
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
|
||||
{materials.map((material, index) => (
|
||||
<div key={`${material.key}-${index}`} className="aspect-[0.65] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10">
|
||||
<div className="flex h-[68%] items-center justify-center rounded-br-[1.3cqw] bg-[#c1c1c1] p-[8%]">
|
||||
{material.imageUrl ? <Image src={material.imageUrl} alt="" width={96} height={96} className="size-full object-contain" /> : null}
|
||||
</div>
|
||||
<div className="flex h-[32%] items-center justify-center px-[4%] text-center text-[2.2cqw] leading-none">
|
||||
<span>×{material.amount.toLocaleString("en-US")}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||
import { MaterialPoster } from "@/components/public/material-poster";
|
||||
import { RichTextContent } from "@/components/rich-text-content";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||
@@ -267,34 +268,50 @@ function GuideOverview({
|
||||
</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"
|
||||
{guide.posterArtUrl || youtubeVideo ? (
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)]">
|
||||
{guide.posterArtUrl ? (
|
||||
<section className="order-1 lg:order-2">
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={guide.posterArtUrl}
|
||||
artworkOffsetX={guide.posterArtOffsetX}
|
||||
artworkOffsetY={guide.posterArtOffsetY}
|
||||
topColors={guide.posterTopColors}
|
||||
bottomColors={guide.posterBottomColors}
|
||||
/>
|
||||
}
|
||||
nativeButton={false}
|
||||
>
|
||||
ดูวิดีโอต้นฉบับบน YouTube
|
||||
<ArrowUpRightIcon data-icon="inline-end" />
|
||||
</Button>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
{youtubeVideo ? (
|
||||
<section className="order-2 flex flex-col items-center gap-3 lg:order-1">
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -520,7 +537,7 @@ function WeaponSectionContent({
|
||||
: "text-sm sm:text-base",
|
||||
)}
|
||||
>
|
||||
ดาเมททั้งทีม
|
||||
ดาเมจทั้งทีม
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user