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