fix : qol
CI / Verify (push) Successful in 1m2s
CI / Build immutable images and deploy (push) Successful in 2m50s

This commit is contained in:
2026-09-03 18:29:52 +07:00 Unverified
parent 76395a0b42
commit 381ee216dd
6 changed files with 2713 additions and 328 deletions
@@ -37,7 +37,9 @@ export function DeleteConfirmationDialog({
}: DeleteConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger ? <AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger> : null}
{trigger ? (
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
) : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
@@ -45,7 +47,9 @@ export function DeleteConfirmationDialog({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
+164 -34
View File
@@ -104,32 +104,48 @@ export function EditorInspector({
<Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col">
<div className="border-b p-3">
<TabsList className="w-full">
<TabsTrigger value="inspector" className="flex-1">Inspector</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">Live Preview</TabsTrigger>
<TabsTrigger value="inspector" className="flex-1">
Inspector
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Live Preview
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4">
{selected ? (
<FieldGroup>
<div>
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
<Badge variant="outline">
{blockLabels[selected.type] ?? selected.type}
</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{selected.id}
</p>
</div>
<MediaUploader onReady={onMediaReady} />
<Field>
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
<Select
value={String((selected.responsiveLayout as Record<string, unknown> | undefined)?.width ?? "content")}
value={String(
(
selected.responsiveLayout as
| Record<string, unknown>
| undefined
)?.width ?? "content",
)}
onValueChange={(width) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
...((selected.responsiveLayout as object) ?? {}),
width,
},
})
}
>
<SelectTrigger id="block-width" className="w-full"><SelectValue /></SelectTrigger>
<SelectTrigger id="block-width" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem>
<SelectItem value="wide">พื้นที่กว้าง</SelectItem>
@@ -140,14 +156,22 @@ export function EditorInspector({
<Field orientation="horizontal">
<div className="flex-1">
<FieldTitle>ซ่อนบนมือถือ</FieldTitle>
<FieldDescription>เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต</FieldDescription>
<FieldDescription>
เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต
</FieldDescription>
</div>
<Switch
checked={Boolean((selected.responsiveLayout as Record<string, unknown> | undefined)?.hideOnMobile)}
checked={Boolean(
(
selected.responsiveLayout as
| Record<string, unknown>
| undefined
)?.hideOnMobile,
)}
onCheckedChange={(hideOnMobile) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
...((selected.responsiveLayout as object) ?? {}),
hideOnMobile,
},
})
@@ -175,7 +199,10 @@ export function EditorInspector({
</div>
)}
</TabsContent>
<TabsContent value="preview" className="min-h-0 overflow-y-auto bg-muted/30 p-4">
<TabsContent
value="preview"
className="min-h-0 overflow-y-auto bg-muted/30 p-4"
>
<div className="origin-top scale-[0.92] rounded-xl bg-background p-4 shadow-sm ring-1 ring-border">
<PublicBlockRenderer snapshot={preview} />
</div>
@@ -200,12 +227,18 @@ function AdvancedBlockSettings({
<FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel>
<Select
value={String(config.dataSourceId)}
onValueChange={(dataSourceId) => onConfigChange({ ...config, dataSourceId })}
onValueChange={(dataSourceId) =>
onConfigChange({ ...config, dataSourceId })
}
>
<SelectTrigger id="data-source" className="w-full"><SelectValue /></SelectTrigger>
<SelectTrigger id="data-source" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(sources).map((source) => (
<SelectItem key={source.id} value={source.id}>{source.name} · v{source.version}</SelectItem>
<SelectItem key={source.id} value={source.id}>
{source.name} · v{source.version}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -226,7 +259,11 @@ function DataSourceEditor({
}) {
const [rowId, setRowId] = useState<string | null>(null);
const evaluated = useMemo(() => {
try { return evaluateDataSource(source); } catch { return null; }
try {
return evaluateDataSource(source);
} catch {
return null;
}
}, [source]);
const selectedRow = source.rows.find((row) => row.id === rowId);
const formulaFields = source.fields.filter((field) => field.formula);
@@ -246,10 +283,13 @@ function DataSourceEditor({
<div className="flex items-center justify-between gap-2">
<div>
<FieldTitle>{source.name}</FieldTitle>
<FieldDescription>แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}</FieldDescription>
<FieldDescription>
แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}
</FieldDescription>
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={saving}>
<SaveIcon />{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
<SaveIcon />
{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
</Button>
</div>
@@ -257,9 +297,15 @@ function DataSourceEditor({
const cell = previewRow?.cells[field.key];
return (
<Field key={field.key} data-invalid={Boolean(cell?.error)}>
<FieldLabel htmlFor={`formula-${field.key}`}>{field.label}</FieldLabel>
<FieldLabel htmlFor={`formula-${field.key}`}>
{field.label}
</FieldLabel>
<InputGroup>
<InputGroupAddon><InputGroupText className="font-mono text-primary">fx</InputGroupText></InputGroupAddon>
<InputGroupAddon>
<InputGroupText className="font-mono text-primary">
fx
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={`formula-${field.key}`}
className="font-mono"
@@ -267,20 +313,67 @@ function DataSourceEditor({
list={`fields-${source.id}`}
onChange={(event) => updateField(field.key, event.target.value)}
/>
<InputGroupAddon align="inline-end"><FunctionSquareIcon aria-hidden="true" /></InputGroupAddon>
<InputGroupAddon align="inline-end">
<FunctionSquareIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
<datalist id={`fields-${source.id}`}>
{source.fields.filter((candidate) => candidate.key !== field.key).map((candidate) => <option key={candidate.key} value={candidate.key}>{candidate.label}</option>)}
{source.fields
.filter((candidate) => candidate.key !== field.key)
.map((candidate) => (
<option key={candidate.key} value={candidate.key}>
{candidate.label}
</option>
))}
</datalist>
{cell?.error ? <FieldError>สูตรนี้ยังคำนวณไม่ได้: ตรวจสอบชื่อฟิลด์และตัวหาร</FieldError> : <FieldDescription>ตัวอย่างผลลัพธ์: <span className="font-mono text-foreground">{cell?.display ?? "-"}</span></FieldDescription>}
{cell?.error ? (
<FieldError>
สูตรนี้ยังคำนวณไม่ได้: ตรวจสอบชื่อฟิลด์และตัวหาร
</FieldError>
) : (
<FieldDescription>
ตัวอย่างผลลัพธ์:{" "}
<span className="font-mono text-foreground">
{cell?.display ?? "-"}
</span>
</FieldDescription>
)}
</Field>
);
})}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader><TableRow>{source.fields.slice(0, 3).map((field) => <TableHead key={field.key}>{field.label}</TableHead>)}<TableHead className="w-12" /></TableRow></TableHeader>
<TableBody>{source.rows.slice(0, 10).map((row) => <TableRow key={row.id}>{source.fields.slice(0, 3).map((field) => <TableCell key={field.key} className="max-w-28 truncate">{row.values[field.key] ?? previewRow?.cells[field.key]?.display ?? "-"}</TableCell>)}<TableCell><Button size="xs" variant="ghost" onClick={() => setRowId(row.id)}>แก้ไข</Button></TableCell></TableRow>)}</TableBody>
<TableHeader>
<TableRow>
{source.fields.slice(0, 3).map((field) => (
<TableHead key={field.key}>{field.label}</TableHead>
))}
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{source.rows.slice(0, 10).map((row) => (
<TableRow key={row.id}>
{source.fields.slice(0, 3).map((field) => (
<TableCell key={field.key} className="max-w-28 truncate">
{row.values[field.key] ??
previewRow?.cells[field.key]?.display ??
"-"}
</TableCell>
))}
<TableCell>
<Button
size="xs"
variant="ghost"
onClick={() => setRowId(row.id)}
>
แก้ไข
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button
@@ -289,37 +382,74 @@ function DataSourceEditor({
className="w-full"
onClick={() => {
const id = crypto.randomUUID();
onChange({ ...source, rows: [...source.rows, { id, values: Object.fromEntries(source.fields.map((field) => [field.key, null])) }] });
onChange({
...source,
rows: [
...source.rows,
{
id,
values: Object.fromEntries(
source.fields.map((field) => [field.key, null]),
),
},
],
});
setRowId(id);
}}
><PlusIcon />เพิ่มแถว</Button>
>
<PlusIcon />
เพิ่มแถว
</Button>
<Dialog open={Boolean(selectedRow)} onOpenChange={(open) => { if (!open) setRowId(null); }}>
<Dialog
open={Boolean(selectedRow)}
onOpenChange={(open) => {
if (!open) setRowId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขแถวข้อมูล</DialogTitle>
<DialogDescription>กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ</DialogDescription>
<DialogDescription>
กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ
</DialogDescription>
</DialogHeader>
{selectedRow ? (
<FieldGroup className="max-h-[60vh] overflow-y-auto pr-1">
{source.fields.map((field) => (
<Field key={field.key}>
<FieldLabel htmlFor={`row-${selectedRow.id}-${field.key}`}>{field.label}</FieldLabel>
<FieldLabel htmlFor={`row-${selectedRow.id}-${field.key}`}>
{field.label}
</FieldLabel>
<Input
id={`row-${selectedRow.id}-${field.key}`}
value={selectedRow.values[field.key] ?? ""}
disabled={Boolean(field.formula)}
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange({
...source,
rows: source.rows.map((row) => row.id === selectedRow.id ? { ...row, values: { ...row.values, [field.key]: event.target.value || null } } : row),
})}
onChange={(event) =>
onChange({
...source,
rows: source.rows.map((row) =>
row.id === selectedRow.id
? {
...row,
values: {
...row.values,
[field.key]: event.target.value || null,
},
}
: row,
),
})
}
/>
</Field>
))}
</FieldGroup>
) : null}
<DialogFooter><Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button></DialogFooter>
<DialogFooter>
<Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
+119 -11
View File
@@ -6,19 +6,49 @@ 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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; youtubeUrl: string | null; coverMediaId: string; coverUrl: string; name: string } }) {
export function GuideOverviewForm({
guide,
}: {
guide: {
id: string;
version: number;
overview: string;
youtubeUrl: string | null;
coverMediaId: string;
coverUrl: string;
name: string;
};
}) {
const [overview, setOverview] = useState(guide.overview);
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !parseYouTubeUrl(youtubeUrl.trim()));
const [cover, setCover] = useState({
id: guide.coverMediaId,
url: guide.coverUrl,
});
const invalidYoutubeUrl = Boolean(
youtubeUrl.trim() && !parseYouTubeUrl(youtubeUrl.trim()),
);
const value = { coverMediaId: cover.id, overview, youtubeUrl };
const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
@@ -30,19 +60,34 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
value,
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
persist: (next, expectedVersion) =>
saveGuideOverview(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "overview"),
localDraft,
parseLocalDraft: (candidate) => {
if (!candidate || typeof candidate !== "object") return null;
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>;
if (typeof draftCover.id !== "string" || typeof draftCover.url !== "string") return null;
if (draft.youtubeUrl !== undefined && typeof draft.youtubeUrl !== "string") return 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 : "",
youtubeUrl:
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
cover: { id: draftCover.id, url: draftCover.url },
};
},
@@ -52,5 +97,68 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
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><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>;
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>
);
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -30,4 +30,16 @@ describe("custom Traveler catalog", () => {
expect(isCustomTravelerKey("traveler_hydro")).toBe(true);
expect(isCustomTravelerKey("traveler_unknown")).toBe(false);
});
it("uses element-first display names without changing stable keys", () => {
expect(customTravelers.map(({ key, name }) => ({ key, name }))).toEqual([
{ key: "traveler_pyro", name: "Pyro Traveler" },
{ key: "traveler_hydro", name: "Hydro Traveler" },
{ key: "traveler_anemo", name: "Anemo Traveler" },
{ key: "traveler_geo", name: "Geo Traveler" },
{ key: "traveler_electro", name: "Electro Traveler" },
{ key: "traveler_dendro", name: "Dendro Traveler" },
{ key: "traveler_cryo", name: "Cryo Traveler" },
]);
});
});
+7 -7
View File
@@ -1,11 +1,11 @@
export const customTravelers = [
{ key: "traveler_pyro", name: "Traveler Pyro", element: "Pyro", rarity: 5, detailKey: "10000005_PYRO", asset: "traveler_pyro.webp" },
{ key: "traveler_hydro", name: "Traveler Hydro", element: "Hydro", rarity: 5, detailKey: "10000005_HYDRO", asset: "traveler_hydro.webp" },
{ key: "traveler_anemo", name: "Traveler Anemo", element: "Anemo", rarity: 5, detailKey: "10000005_ANEMO", asset: "traveler_anemo.webp" },
{ key: "traveler_geo", name: "Traveler Geo", element: "Geo", rarity: 5, detailKey: "10000005_GEO", asset: "traveler_geo.webp" },
{ key: "traveler_electro", name: "Traveler Electro", element: "Electro", rarity: 5, detailKey: "10000005_ELECTRO", asset: "traveler_electro.webp" },
{ key: "traveler_dendro", name: "Traveler Dendro", element: "Dendro", rarity: 5, detailKey: "10000005_DENDRO", asset: "traveler_dendro.webp" },
{ key: "traveler_cryo", name: "Traveler Cryo", element: "Cryo", rarity: 5, detailKey: "10000005_CRYO", asset: "traveler_cyro.webp" },
{ key: "traveler_pyro", name: "Pyro Traveler", element: "Pyro", rarity: 5, detailKey: "10000005_PYRO", asset: "traveler_pyro.webp" },
{ key: "traveler_hydro", name: "Hydro Traveler", element: "Hydro", rarity: 5, detailKey: "10000005_HYDRO", asset: "traveler_hydro.webp" },
{ key: "traveler_anemo", name: "Anemo Traveler", element: "Anemo", rarity: 5, detailKey: "10000005_ANEMO", asset: "traveler_anemo.webp" },
{ key: "traveler_geo", name: "Geo Traveler", element: "Geo", rarity: 5, detailKey: "10000005_GEO", asset: "traveler_geo.webp" },
{ key: "traveler_electro", name: "Electro Traveler", element: "Electro", rarity: 5, detailKey: "10000005_ELECTRO", asset: "traveler_electro.webp" },
{ key: "traveler_dendro", name: "Dendro Traveler", element: "Dendro", rarity: 5, detailKey: "10000005_DENDRO", asset: "traveler_dendro.webp" },
{ key: "traveler_cryo", name: "Cryo Traveler", element: "Cryo", rarity: 5, detailKey: "10000005_CRYO", asset: "traveler_cyro.webp" },
] as const;
export function isCustomTravelerKey(key: string): boolean {