feat : update
This commit is contained in:
@@ -29,6 +29,15 @@ import {
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAutosave } from "@/hooks/use-autosave";
|
||||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
||||
@@ -42,28 +51,53 @@ type BleedingLayer = {
|
||||
mediaId: string;
|
||||
imageUrl: string | null;
|
||||
amount: number;
|
||||
scale: number;
|
||||
blendMode: PosterBlendMode;
|
||||
};
|
||||
|
||||
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) => ({
|
||||
value,
|
||||
label: value.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "),
|
||||
}));
|
||||
let bleedingLayerIdCounter = 0;
|
||||
|
||||
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale" | "blendMode">>) => void; onRemove: () => void }) {
|
||||
function createBleedingLayerId(): string {
|
||||
const browserCrypto = globalThis.crypto;
|
||||
if (typeof browserCrypto?.randomUUID === "function") return `bleeding-${browserCrypto.randomUUID()}`;
|
||||
bleedingLayerIdCounter += 1;
|
||||
return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`;
|
||||
}
|
||||
|
||||
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 selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-center gap-2 rounded-lg border bg-card p-2", isDragging && "z-10 opacity-70 shadow-lg")}>
|
||||
<button type="button" className="touch-none text-muted-foreground" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon className="size-5" /></button>
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-start gap-2 rounded-lg border bg-card p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon data-icon="inline-start" /></Button>
|
||||
{layer.imageUrl ? <Image src={layer.imageUrl} alt="" width={48} height={48} className="size-12 rounded object-contain bg-muted" /> : <div className="size-12 rounded bg-muted" />}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex justify-between text-xs"><span>Amount</span><span>{layer.amount}%</span></div>
|
||||
<Input type="range" min={0} max={100} value={layer.amount} onChange={(event) => onChange({ amount: Number(event.target.value) })} aria-label="Bleeding amount" />
|
||||
<div className="flex justify-between text-xs"><span>Scale</span><span>{layer.scale}%</span></div>
|
||||
<Input type="range" min={25} max={300} value={layer.scale} onChange={(event) => onChange({ scale: Number(event.target.value) })} aria-label="Bleeding scale" />
|
||||
<select className="h-8 w-full rounded-lg border border-input bg-background px-2 text-xs" value={layer.blendMode} onChange={(event) => onChange({ blendMode: event.target.value as PosterBlendMode })} aria-label="Bleeding blend mode">
|
||||
{blendModes.map((mode) => <option key={mode} value={mode}>{mode}</option>)}
|
||||
</select>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<Combobox
|
||||
items={blendModeItems}
|
||||
value={selectedMode}
|
||||
onValueChange={(value) => value && onChange({ blendMode: value.value })}
|
||||
itemToStringLabel={(item) => item.label}
|
||||
itemToStringValue={(item) => item.value}
|
||||
filter={(item, query) => `${item.label} ${item.value}`.toLowerCase().includes(query.trim().toLowerCase())}
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => <ComboboxItem key={item.value} value={item}>{item.label}</ComboboxItem>}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`bleeding-amount-${layer.id}`}>Amount {layer.amount}%</FieldLabel>
|
||||
<Slider id={`bleeding-amount-${layer.id}`} min={0} max={100} step={5} value={[layer.amount]} onValueChange={(value) => onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon data-icon="inline-start" /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +141,7 @@ export function GuideOverviewForm({
|
||||
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
|
||||
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
||||
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
||||
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, 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 youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
||||
@@ -121,7 +155,7 @@ export function GuideOverviewForm({
|
||||
posterArtScale,
|
||||
posterTopColors,
|
||||
posterBottomColors,
|
||||
posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })),
|
||||
posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })),
|
||||
};
|
||||
const incompleteIssues = [
|
||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||
@@ -192,10 +226,10 @@ export function GuideOverviewForm({
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const layer = item as Record<string, unknown>;
|
||||
return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string"
|
||||
? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, scale: typeof layer.scale === "number" ? Math.max(25, Math.min(300, layer.scale)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
|
||||
? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
|
||||
: [];
|
||||
})
|
||||
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })),
|
||||
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })),
|
||||
};
|
||||
},
|
||||
restoreLocalDraft: (draft) => {
|
||||
@@ -244,7 +278,16 @@ export function GuideOverviewForm({
|
||||
setPosterArtOffset({ x: 0, y: 0 });
|
||||
setBleeding((current) => current.length
|
||||
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
|
||||
: [{ id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 65, scale: 100, blendMode: "multiply" }]);
|
||||
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
|
||||
}
|
||||
|
||||
function removeUploadedPoster() {
|
||||
setPosterArt({ id: null, url: null });
|
||||
setPosterArtOffset({ x: 0, y: 0 });
|
||||
setPosterArtScale(100);
|
||||
setPosterTopColors(["#000000", "#000000"]);
|
||||
setPosterBottomColors(["#000000", "#000000"]);
|
||||
setBleeding([]);
|
||||
}
|
||||
|
||||
function detectPosterColorsFromFile(file: File) {
|
||||
@@ -256,7 +299,7 @@ export function GuideOverviewForm({
|
||||
if (!posterArt.id || !posterArt.url) return;
|
||||
const mediaId = posterArt.id;
|
||||
const imageUrl = posterArt.url;
|
||||
setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId, imageUrl, amount: 65, scale: 100, blendMode: "multiply" }]);
|
||||
setBleeding((current) => [...current, { id: createBleedingLayerId(), mediaId, imageUrl, amount: 65, blendMode: "multiply" }]);
|
||||
}
|
||||
|
||||
function handleBleedingDragEnd(event: DragEndEvent) {
|
||||
@@ -359,12 +402,18 @@ export function GuideOverviewForm({
|
||||
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="webp"
|
||||
format="png"
|
||||
disabled={!posterArt.url}
|
||||
fileName={`${guide.name}-materials.webp`}
|
||||
fileName={`${guide.name}-materials.png`}
|
||||
captureClassName="p-0"
|
||||
>
|
||||
<MaterialPoster
|
||||
@@ -379,7 +428,7 @@ export function GuideOverviewForm({
|
||||
/>
|
||||
</GuideImageExport>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<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>
|
||||
@@ -390,17 +439,17 @@ export function GuideOverviewForm({
|
||||
{bleeding.length ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
||||
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex justify-between text-sm"><span>Character image zoom</span><span>{posterArtScale}%</span></div>
|
||||
<Input type="range" min={50} max={500} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" />
|
||||
</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>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MediaUploader({
|
||||
return;
|
||||
}
|
||||
if (file.size <= 0 || file.size > MAX_MEDIA_BYTES) {
|
||||
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 20 MB");
|
||||
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 100 MB");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MediaUploader({
|
||||
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
|
||||
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
|
||||
</Button>
|
||||
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB</FieldDescription>
|
||||
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 100 MB</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CatalogAscensionGroup,
|
||||
CatalogBaseStat,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import { accentGradient } from "@/lib/overview-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PosterMaterial = {
|
||||
@@ -37,6 +38,14 @@ function statLabel(key: string): string {
|
||||
return statLabels[key.toLowerCase()] ?? key;
|
||||
}
|
||||
|
||||
function statIconName(key: string): string | null {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z]/gu, "");
|
||||
if (["hp", "atk", "def", "em", "er"].includes(normalized)) return normalized;
|
||||
if (["critrate", "crit", "criticalrate"].includes(normalized)) return "critrate";
|
||||
if (["critdmg", "critdamage", "criticaldamage"].includes(normalized)) return "critdmg";
|
||||
return null;
|
||||
}
|
||||
|
||||
function statValue(key: string, value: number | string): string {
|
||||
const formatted = typeof value === "number"
|
||||
? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
||||
@@ -67,11 +76,10 @@ export function MaterialPoster({
|
||||
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||
className?: string;
|
||||
artworkScale: number;
|
||||
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>;
|
||||
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>;
|
||||
}) {
|
||||
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const preferredStats = ["hp", "atk", "def", "em"];
|
||||
const stats = preferredStats.map((key) => character.baseStats.find((stat) => stat.key.toLowerCase() === key));
|
||||
const stats = character.baseStats.slice(0, 4);
|
||||
const materials = [...character.ascension, ...character.leveling].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%)`;
|
||||
|
||||
@@ -143,49 +151,67 @@ export function MaterialPoster({
|
||||
className="object-contain object-center"
|
||||
priority={!interactive}
|
||||
/>
|
||||
{[...bleeding].reverse().map((layer) => (
|
||||
<Image
|
||||
key={layer.id}
|
||||
src={characterArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 800px, 100vw"
|
||||
className="object-contain object-center"
|
||||
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, mixBlendMode: layer.blendMode }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{bleeding.map((layer) => layer.imageUrl ? (
|
||||
<Image
|
||||
key={layer.id}
|
||||
src={layer.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 800px, 100vw"
|
||||
className="object-contain object-center"
|
||||
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, mixBlendMode: layer.blendMode, transform: `scale(${Math.max(25, Math.min(300, layer.scale)) / 100})` }}
|
||||
/>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[1.35cqw]"
|
||||
style={{ backgroundImage: accentGradient(...topColors) }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[1.35cqw]"
|
||||
style={{ backgroundImage: accentGradient(...bottomColors) }}
|
||||
/>
|
||||
|
||||
<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-[2.5%]">
|
||||
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
|
||||
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>BASE STATS</h2>
|
||||
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.2cqw]">
|
||||
{Array.from({ length: 4 }, (_, index) => {
|
||||
const stat = stats[index];
|
||||
const iconName = stat ? statIconName(stat.key) : null;
|
||||
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>
|
||||
<span className="flex min-w-0 items-center gap-[1cqw] whitespace-nowrap">
|
||||
{iconName ? (
|
||||
<Image
|
||||
src={`/icon/stats/${iconName}.webp`}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="size-[3.5cqw] shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span>{stat ? statLabel(stat.key) : "—"}</span>
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[2.7cqw]">{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>
|
||||
<div className="absolute inset-x-[4%] top-[49%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
|
||||
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>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 key={`${material.key}-${index}`} className="aspect-[0.72] 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>
|
||||
<span className="whitespace-nowrap text-[1.8cqw]">×{material.amount.toLocaleString("en-US")}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -209,8 +209,8 @@ 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="grid items-start gap-4 lg:grid-cols-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-muted">
|
||||
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">
|
||||
<Image
|
||||
src={guide.coverUrl}
|
||||
alt={`ไกด์ ${guide.name}`}
|
||||
@@ -219,59 +219,13 @@ function GuideOverview({
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
{guide.posterArtUrl || youtubeVideo ? (
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)]">
|
||||
<div className="order-3 lg:col-start-2 lg:row-start-1">
|
||||
<GuideNavigation guide={guide} sections={sections} />
|
||||
</div>
|
||||
{guide.posterArtUrl || youtubeVideo ? (
|
||||
<>
|
||||
{guide.posterArtUrl ? (
|
||||
<section className="order-1 lg:order-2">
|
||||
<section className="order-2 flex flex-col items-center gap-3 lg:col-start-1 lg:row-start-2">
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={guide.posterArtUrl}
|
||||
@@ -282,10 +236,28 @@ function GuideOverview({
|
||||
bottomColors={guide.posterBottomColors}
|
||||
bleeding={guide.posterBleeding}
|
||||
/>
|
||||
<GuideImageExport
|
||||
width={1600}
|
||||
height={2000}
|
||||
format="png"
|
||||
fileName={`${guide.name}-materials.png`}
|
||||
captureClassName="p-0"
|
||||
>
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={guide.posterArtUrl}
|
||||
artworkOffsetX={guide.posterArtOffsetX}
|
||||
artworkOffsetY={guide.posterArtOffsetY}
|
||||
artworkScale={guide.posterArtScale}
|
||||
topColors={guide.posterTopColors}
|
||||
bottomColors={guide.posterBottomColors}
|
||||
bleeding={guide.posterBleeding}
|
||||
/>
|
||||
</GuideImageExport>
|
||||
</section>
|
||||
) : null}
|
||||
{youtubeVideo ? (
|
||||
<section className="order-2 flex flex-col items-center gap-3 lg:order-1">
|
||||
<section className="order-4 flex flex-col items-center gap-3 lg:col-start-2 lg:row-start-2">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
||||
<iframe
|
||||
src={youtubeVideo.embedUrl}
|
||||
@@ -313,12 +285,60 @@ function GuideOverview({
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideNavigation({
|
||||
guide,
|
||||
sections,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
sections: ReturnType<typeof publicSections>;
|
||||
}) {
|
||||
return 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>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideSection({
|
||||
guide,
|
||||
section,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: SliderPrimitive.Root.Props) {
|
||||
const _values = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max]
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
thumbAlignment="edge"
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||
>
|
||||
<SliderPrimitive.Indicator
|
||||
data-slot="slider-range"
|
||||
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Control>
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
Reference in New Issue
Block a user