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) { }: DeleteConfirmationDialogProps) {
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger ? <AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger> : null} {trigger ? (
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
) : null}
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
@@ -45,7 +47,9 @@ export function DeleteConfirmationDialog({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel> <AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>{confirmLabel}</AlertDialogAction> <AlertDialogAction variant="destructive" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
+164 -34
View File
@@ -104,32 +104,48 @@ export function EditorInspector({
<Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col"> <Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col">
<div className="border-b p-3"> <div className="border-b p-3">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="inspector" className="flex-1">Inspector</TabsTrigger> <TabsTrigger value="inspector" className="flex-1">
<TabsTrigger value="preview" className="flex-1">Live Preview</TabsTrigger> Inspector
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Live Preview
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4"> <TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4">
{selected ? ( {selected ? (
<FieldGroup> <FieldGroup>
<div> <div>
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge> <Badge variant="outline">
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p> {blockLabels[selected.type] ?? selected.type}
</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{selected.id}
</p>
</div> </div>
<MediaUploader onReady={onMediaReady} /> <MediaUploader onReady={onMediaReady} />
<Field> <Field>
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel> <FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
<Select <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) => onValueChange={(width) =>
patchSelected({ patchSelected({
responsiveLayout: { responsiveLayout: {
...(selected.responsiveLayout as object ?? {}), ...((selected.responsiveLayout as object) ?? {}),
width, width,
}, },
}) })
} }
> >
<SelectTrigger id="block-width" className="w-full"><SelectValue /></SelectTrigger> <SelectTrigger id="block-width" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem> <SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem>
<SelectItem value="wide">พื้นที่กว้าง</SelectItem> <SelectItem value="wide">พื้นที่กว้าง</SelectItem>
@@ -140,14 +156,22 @@ export function EditorInspector({
<Field orientation="horizontal"> <Field orientation="horizontal">
<div className="flex-1"> <div className="flex-1">
<FieldTitle>ซ่อนบนมือถือ</FieldTitle> <FieldTitle>ซ่อนบนมือถือ</FieldTitle>
<FieldDescription>เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต</FieldDescription> <FieldDescription>
เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต
</FieldDescription>
</div> </div>
<Switch <Switch
checked={Boolean((selected.responsiveLayout as Record<string, unknown> | undefined)?.hideOnMobile)} checked={Boolean(
(
selected.responsiveLayout as
| Record<string, unknown>
| undefined
)?.hideOnMobile,
)}
onCheckedChange={(hideOnMobile) => onCheckedChange={(hideOnMobile) =>
patchSelected({ patchSelected({
responsiveLayout: { responsiveLayout: {
...(selected.responsiveLayout as object ?? {}), ...((selected.responsiveLayout as object) ?? {}),
hideOnMobile, hideOnMobile,
}, },
}) })
@@ -175,7 +199,10 @@ export function EditorInspector({
</div> </div>
)} )}
</TabsContent> </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"> <div className="origin-top scale-[0.92] rounded-xl bg-background p-4 shadow-sm ring-1 ring-border">
<PublicBlockRenderer snapshot={preview} /> <PublicBlockRenderer snapshot={preview} />
</div> </div>
@@ -200,12 +227,18 @@ function AdvancedBlockSettings({
<FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel> <FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel>
<Select <Select
value={String(config.dataSourceId)} 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> <SelectContent>
{Object.values(sources).map((source) => ( {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> </SelectContent>
</Select> </Select>
@@ -226,7 +259,11 @@ function DataSourceEditor({
}) { }) {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const evaluated = useMemo(() => { const evaluated = useMemo(() => {
try { return evaluateDataSource(source); } catch { return null; } try {
return evaluateDataSource(source);
} catch {
return null;
}
}, [source]); }, [source]);
const selectedRow = source.rows.find((row) => row.id === rowId); const selectedRow = source.rows.find((row) => row.id === rowId);
const formulaFields = source.fields.filter((field) => field.formula); const formulaFields = source.fields.filter((field) => field.formula);
@@ -246,10 +283,13 @@ function DataSourceEditor({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<FieldTitle>{source.name}</FieldTitle> <FieldTitle>{source.name}</FieldTitle>
<FieldDescription>แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}</FieldDescription> <FieldDescription>
แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}
</FieldDescription>
</div> </div>
<Button size="sm" variant="outline" onClick={onSave} disabled={saving}> <Button size="sm" variant="outline" onClick={onSave} disabled={saving}>
<SaveIcon />{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"} <SaveIcon />
{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
</Button> </Button>
</div> </div>
@@ -257,9 +297,15 @@ function DataSourceEditor({
const cell = previewRow?.cells[field.key]; const cell = previewRow?.cells[field.key];
return ( return (
<Field key={field.key} data-invalid={Boolean(cell?.error)}> <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> <InputGroup>
<InputGroupAddon><InputGroupText className="font-mono text-primary">fx</InputGroupText></InputGroupAddon> <InputGroupAddon>
<InputGroupText className="font-mono text-primary">
fx
</InputGroupText>
</InputGroupAddon>
<InputGroupInput <InputGroupInput
id={`formula-${field.key}`} id={`formula-${field.key}`}
className="font-mono" className="font-mono"
@@ -267,20 +313,67 @@ function DataSourceEditor({
list={`fields-${source.id}`} list={`fields-${source.id}`}
onChange={(event) => updateField(field.key, event.target.value)} 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> </InputGroup>
<datalist id={`fields-${source.id}`}> <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> </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> </Field>
); );
})} })}
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<Table> <Table>
<TableHeader><TableRow>{source.fields.slice(0, 3).map((field) => <TableHead key={field.key}>{field.label}</TableHead>)}<TableHead className="w-12" /></TableRow></TableHeader> <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> <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> </Table>
</div> </div>
<Button <Button
@@ -289,37 +382,74 @@ function DataSourceEditor({
className="w-full" className="w-full"
onClick={() => { onClick={() => {
const id = crypto.randomUUID(); 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); 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> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>แก้ไขแถวข้อมูล</DialogTitle> <DialogTitle>แก้ไขแถวข้อมูล</DialogTitle>
<DialogDescription>กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ</DialogDescription> <DialogDescription>
กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ
</DialogDescription>
</DialogHeader> </DialogHeader>
{selectedRow ? ( {selectedRow ? (
<FieldGroup className="max-h-[60vh] overflow-y-auto pr-1"> <FieldGroup className="max-h-[60vh] overflow-y-auto pr-1">
{source.fields.map((field) => ( {source.fields.map((field) => (
<Field key={field.key}> <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 <Input
id={`row-${selectedRow.id}-${field.key}`} id={`row-${selectedRow.id}-${field.key}`}
value={selectedRow.values[field.key] ?? ""} value={selectedRow.values[field.key] ?? ""}
disabled={Boolean(field.formula)} disabled={Boolean(field.formula)}
inputMode={field.type === "number" ? "decimal" : undefined} inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange({ onChange={(event) =>
...source, onChange({
rows: source.rows.map((row) => row.id === selectedRow.id ? { ...row, values: { ...row.values, [field.key]: event.target.value || null } } : row), ...source,
})} rows: source.rows.map((row) =>
row.id === selectedRow.id
? {
...row,
values: {
...row.values,
[field.key]: event.target.value || null,
},
}
: row,
),
})
}
/> />
</Field> </Field>
))} ))}
</FieldGroup> </FieldGroup>
) : null} ) : null}
<DialogFooter><Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button></DialogFooter> <DialogFooter>
<Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
+119 -11
View File
@@ -6,19 +6,49 @@ import { useState } from "react";
import { saveGuideOverview } from "@/app/admin/actions"; import { saveGuideOverview } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status"; import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader"; import { MediaUploader } from "@/components/admin/media-uploader";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import {
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; 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 { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave"; import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft"; import { adminDraftKey } from "@/lib/editor/local-draft";
import { parseYouTubeUrl } from "@/lib/guides/youtube"; 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 [overview, setOverview] = useState(guide.overview);
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? ""); const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl }); const [cover, setCover] = useState({
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !parseYouTubeUrl(youtubeUrl.trim())); id: guide.coverMediaId,
url: guide.coverUrl,
});
const invalidYoutubeUrl = Boolean(
youtubeUrl.trim() && !parseYouTubeUrl(youtubeUrl.trim()),
);
const value = { coverMediaId: cover.id, overview, youtubeUrl }; const value = { coverMediaId: cover.id, overview, youtubeUrl };
const incompleteIssues = [ const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null, !cover.id ? "กรุณาอัปโหลด Guide Image" : null,
@@ -30,19 +60,34 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
value, value,
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl), valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
initialVersion: guide.version, initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }), persist: (next, expectedVersion) =>
saveGuideOverview(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "overview"), storageKey: adminDraftKey(guide.id, "overview"),
localDraft, localDraft,
parseLocalDraft: (candidate) => { parseLocalDraft: (candidate) => {
if (!candidate || typeof candidate !== "object") return null; if (!candidate || typeof candidate !== "object") return null;
const draft = candidate as Record<string, unknown>; const draft = candidate as Record<string, unknown>;
if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null; if (
typeof draft.overview !== "string" ||
!draft.cover ||
typeof draft.cover !== "object"
)
return null;
const draftCover = draft.cover as Record<string, unknown>; const draftCover = draft.cover as Record<string, unknown>;
if (typeof draftCover.id !== "string" || typeof draftCover.url !== "string") return null; if (
if (draft.youtubeUrl !== undefined && typeof draft.youtubeUrl !== "string") return null; typeof draftCover.id !== "string" ||
typeof draftCover.url !== "string"
)
return null;
if (
draft.youtubeUrl !== undefined &&
typeof draft.youtubeUrl !== "string"
)
return null;
return { return {
overview: draft.overview, overview: draft.overview,
youtubeUrl: typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "", youtubeUrl:
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
cover: { id: draftCover.id, url: draftCover.url }, cover: { id: draftCover.id, url: draftCover.url },
}; };
}, },
@@ -52,5 +97,68 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
setCover(draft.cover); setCover(draft.cover);
}, },
}); });
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพ ข้อความ และวิดีโอนี้แสดงบนหน้าแรกของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field><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_hydro")).toBe(true);
expect(isCustomTravelerKey("traveler_unknown")).toBe(false); 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 = [ export const customTravelers = [
{ key: "traveler_pyro", name: "Traveler Pyro", element: "Pyro", rarity: 5, detailKey: "10000005_PYRO", asset: "traveler_pyro.webp" }, { key: "traveler_pyro", name: "Pyro Traveler", 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_hydro", name: "Hydro Traveler", 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_anemo", name: "Anemo Traveler", 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_geo", name: "Geo Traveler", 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_electro", name: "Electro Traveler", 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_dendro", name: "Dendro Traveler", 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_cryo", name: "Cryo Traveler", element: "Cryo", rarity: 5, detailKey: "10000005_CRYO", asset: "traveler_cyro.webp" },
] as const; ] as const;
export function isCustomTravelerKey(key: string): boolean { export function isCustomTravelerKey(key: string): boolean {