fix : qol
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user