feat : update
This commit is contained in:
@@ -259,6 +259,8 @@ type TeamValue = {
|
||||
};
|
||||
|
||||
let clientIdCounter = 0;
|
||||
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
|
||||
const DEFAULT_EXTRA_TEXT_COLOR = "#F5F2EA";
|
||||
|
||||
function createClientId(prefix: string): string {
|
||||
const browserCrypto = globalThis.crypto;
|
||||
@@ -303,12 +305,16 @@ export interface GuideSectionEditorProps {
|
||||
constellationGroups: ConstellationGroupValue[];
|
||||
teams: TeamValue[];
|
||||
extras: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
rows: Array<{
|
||||
id: string;
|
||||
mediaId: string | null;
|
||||
heading: string;
|
||||
body: string;
|
||||
percent: string | null;
|
||||
backgroundColor: string | null;
|
||||
textColor: string | null;
|
||||
imageUrl: string | null;
|
||||
imageWidth: number | null;
|
||||
imageHeight: number | null;
|
||||
@@ -769,6 +775,46 @@ function SortableTeamMember({
|
||||
);
|
||||
}
|
||||
|
||||
function SortableExtraItem({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
children: (dragHandle: ReactNode) => ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(isDragging && "opacity-60")}
|
||||
>
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuideSectionEditor(props: GuideSectionEditorProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -3250,7 +3296,22 @@ function TeamEditor({
|
||||
}
|
||||
|
||||
function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
const [sections, setSections] = useState(initial.extras);
|
||||
const extraSensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const [sections, setSections] = useState(() =>
|
||||
initial.extras.map((section) => ({
|
||||
...section,
|
||||
clientId: section.id,
|
||||
rows: section.rows.map((row) => ({
|
||||
...row,
|
||||
clientId: row.id,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
const value = {
|
||||
sections: sections.map((section) => ({
|
||||
title: section.title,
|
||||
@@ -3262,6 +3323,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
row.percent === null || row.percent === ""
|
||||
? null
|
||||
: Number(row.percent),
|
||||
backgroundColor: row.backgroundColor,
|
||||
textColor: row.textColor,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
@@ -3282,8 +3345,40 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
localDraft,
|
||||
parseLocalDraft: (candidate) =>
|
||||
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
|
||||
restoreLocalDraft: (draft) => setSections(draft.sections),
|
||||
restoreLocalDraft: (draft) => setSections(
|
||||
draft.sections.map((section) => ({
|
||||
...section,
|
||||
id: section.id || createClientId("extra-section-record"),
|
||||
clientId: section.clientId || createClientId("extra-section"),
|
||||
rows: section.rows.map((row) => ({
|
||||
...row,
|
||||
id: row.id || createClientId("extra-row-record"),
|
||||
backgroundColor: row.backgroundColor ?? null,
|
||||
textColor: row.textColor ?? null,
|
||||
clientId: row.clientId || createClientId("extra-row"),
|
||||
})),
|
||||
})),
|
||||
),
|
||||
});
|
||||
const handleSectionDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
setSections((current) => {
|
||||
const from = current.findIndex((section) => section.clientId === active.id);
|
||||
const to = current.findIndex((section) => section.clientId === over.id);
|
||||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
||||
});
|
||||
};
|
||||
const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
setSections((current) => current.map((section) => {
|
||||
if (section.clientId !== sectionId) return section;
|
||||
const from = section.rows.findIndex((row) => row.clientId === active.id);
|
||||
const to = section.rows.findIndex((row) => row.clientId === over.id);
|
||||
return from === -1 || to === -1
|
||||
? section
|
||||
: { ...section, rows: arrayMove(section.rows, from, to) };
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -3294,9 +3389,23 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<DndContext
|
||||
sensors={extraSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSectionDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map((section) => section.clientId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<SortableExtraItem
|
||||
key={section.clientId}
|
||||
id={section.clientId}
|
||||
label={`ลากเพื่อจัดลำดับ ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
||||
>
|
||||
{(sectionDragHandle) => (
|
||||
<Card
|
||||
key={sectionIndex}
|
||||
size="sm"
|
||||
className={cn(
|
||||
extraIssue("sections", sectionIndex, "title") && "border-destructive",
|
||||
@@ -3319,7 +3428,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
}
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{sectionDragHandle}
|
||||
<DeleteConfirmationDialog
|
||||
trigger={
|
||||
<Button type="button" variant="ghost" size="icon-sm" />
|
||||
@@ -3338,9 +3448,23 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{section.rows.map((row, rowIndex) => (
|
||||
<DndContext
|
||||
sensors={extraSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => handleRowDragEnd(section.clientId, event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={section.rows.map((row) => row.clientId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{section.rows.map((row, rowIndex) => (
|
||||
<SortableExtraItem
|
||||
key={row.clientId}
|
||||
id={row.clientId}
|
||||
label={`ลากเพื่อจัดลำดับ ${row.heading || `แถว ${rowIndex + 1}`}`}
|
||||
>
|
||||
{(rowDragHandle) => (
|
||||
<Card
|
||||
key={rowIndex}
|
||||
size="sm"
|
||||
className={cn(
|
||||
extraIssue("sections", sectionIndex, "rows", rowIndex) &&
|
||||
@@ -3351,7 +3475,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
<CardTitle>
|
||||
{row.heading || `แถว ${rowIndex + 1}`}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{rowDragHandle}
|
||||
<DeleteConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -3575,10 +3700,131 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<FieldSet>
|
||||
<FieldLegend>สีการ์ด (optional)</FieldLegend>
|
||||
<FieldDescription>
|
||||
สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide
|
||||
</FieldDescription>
|
||||
<FieldGroup className="sm:grid sm:grid-cols-2">
|
||||
<Field
|
||||
data-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"backgroundColor",
|
||||
) || undefined}
|
||||
>
|
||||
<FieldLabel>สีพื้นหลัง</FieldLabel>
|
||||
<Input
|
||||
type="color"
|
||||
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
|
||||
aria-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"backgroundColor",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
backgroundColor: event.target.value,
|
||||
textColor: candidate.textColor ?? DEFAULT_EXTRA_TEXT_COLOR,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
data-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"textColor",
|
||||
) || undefined}
|
||||
>
|
||||
<FieldLabel>สีข้อความ</FieldLabel>
|
||||
<Input
|
||||
type="color"
|
||||
value={row.textColor ?? DEFAULT_EXTRA_TEXT_COLOR}
|
||||
aria-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"textColor",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
backgroundColor: candidate.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR,
|
||||
textColor: event.target.value,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={row.backgroundColor === null && row.textColor === null}
|
||||
onClick={() =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? { ...candidate, backgroundColor: null, textColor: null }
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCcwIcon data-icon="inline-start" />
|
||||
ใช้สีตามธีม
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</SortableExtraItem>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -3591,6 +3837,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
rows: [
|
||||
...item.rows,
|
||||
{
|
||||
id: createClientId("extra-row-record"),
|
||||
mediaId: null,
|
||||
imageUrl: null,
|
||||
imageWidth: null,
|
||||
@@ -3598,6 +3845,9 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
heading: "",
|
||||
body: "",
|
||||
percent: null,
|
||||
backgroundColor: null,
|
||||
textColor: null,
|
||||
clientId: createClientId("extra-row"),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -3611,13 +3861,25 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</SortableExtraItem>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setSections([...sections, { title: "Extra", rows: [] }])
|
||||
setSections([
|
||||
...sections,
|
||||
{
|
||||
id: createClientId("extra-section-record"),
|
||||
title: "Extra",
|
||||
rows: [],
|
||||
clientId: createClientId("extra-section"),
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -124,44 +125,66 @@ export function StructuredGuideShell({
|
||||
<Link
|
||||
href={`/${guide.slug}`}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!section
|
||||
? "bg-muted text-foreground"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch
|
||||
>
|
||||
<Image
|
||||
src={navigationIconPath("overview")!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
Overview
|
||||
{!section ? (
|
||||
<ViewTransition
|
||||
name={`guide-nav-active-${guide.id}`}
|
||||
share="guide-nav-pill"
|
||||
default="none"
|
||||
>
|
||||
<span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" />
|
||||
</ViewTransition>
|
||||
) : null}
|
||||
<span className="relative flex items-center gap-2">
|
||||
<Image
|
||||
src={navigationIconPath("overview")!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
Overview
|
||||
</span>
|
||||
</Link>
|
||||
{sections.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
className={cn(
|
||||
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
section === item.slug
|
||||
? "bg-muted text-foreground"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
prefetch
|
||||
>
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
{section === item.slug ? (
|
||||
<ViewTransition
|
||||
name={`guide-nav-active-${guide.id}`}
|
||||
share="guide-nav-pill"
|
||||
default="none"
|
||||
>
|
||||
<span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" />
|
||||
</ViewTransition>
|
||||
) : null}
|
||||
{item.title}
|
||||
<span className="relative flex items-center gap-2">
|
||||
{navigationIconPath(item.slug) ? (
|
||||
<Image
|
||||
src={navigationIconPath(item.slug)!}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableNav>
|
||||
@@ -1003,7 +1026,13 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
||||
</header>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{extra.rows.map((row) => (
|
||||
<Card key={row.id} className="overflow-hidden py-0">
|
||||
<Card
|
||||
key={row.id}
|
||||
className="overflow-hidden py-0"
|
||||
style={row.backgroundColor && row.textColor
|
||||
? { backgroundColor: row.backgroundColor, color: row.textColor }
|
||||
: undefined}
|
||||
>
|
||||
{row.imageUrl ? (
|
||||
<div
|
||||
className="relative bg-muted"
|
||||
@@ -1023,9 +1052,20 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader className="pt-5">
|
||||
<CardTitle>{row.heading}</CardTitle>
|
||||
<CardDescription>{row.body}</CardDescription>
|
||||
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
|
||||
<CardTitle style={row.textColor ? { color: row.textColor } : undefined}>
|
||||
{row.heading}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
className="whitespace-pre-wrap"
|
||||
style={row.textColor ? { color: row.textColor } : undefined}
|
||||
>
|
||||
{row.body}
|
||||
</CardDescription>
|
||||
{row.percent ? (
|
||||
<Badge style={row.textColor ? { color: row.textColor } : undefined}>
|
||||
{metric(row.percent)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user