Files
buzz-sheet/components/admin/guide-overview-form.tsx
T
gunshiz 6d0feb11ca
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m26s
feat : update
2026-10-07 19:01:45 +07:00

519 lines
27 KiB
TypeScript

"use client";
import Image from "@/components/ui/resilient-image";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
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 { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Slider } from "@/components/ui/slider";
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";
import { detectThemeColorsFromPixels } from "@/lib/overview-image";
import type { PublicGuide } from "@/lib/guides/queries";
import { cn } from "@/lib/utils";
type BleedingLayer = {
id: string;
mediaId: string;
imageUrl: string | null;
amount: number;
blendMode: PosterBlendMode;
};
type VideoLink = {
id: string;
url: string;
};
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
const blendModeItems = blendModes.map((value) => ({
value,
label: value.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "),
}));
let bleedingLayerIdCounter = 0;
function createBleedingLayerId(): string {
const browserCrypto = globalThis.crypto;
if (typeof browserCrypto?.randomUUID === "function") return `bleeding-${browserCrypto.randomUUID()}`;
bleedingLayerIdCounter += 1;
return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`;
}
function createVideoLinkId(): string {
const browserCrypto = globalThis.crypto;
if (typeof browserCrypto?.randomUUID === "function") return `video-${browserCrypto.randomUUID()}`;
bleedingLayerIdCounter += 1;
return `video-${Date.now()}-${bleedingLayerIdCounter}`;
}
function SortableVideoLink({ link, index, guideName, onChange, onRemove }: { link: VideoLink; index: number; guideName: string; onChange: (url: string) => void; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: link.id });
const video = parseYouTubeUrl(link.url.trim());
const invalid = Boolean(link.url.trim() && !video);
return (
<Field ref={setNodeRef} data-invalid={invalid} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("rounded-lg border p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับวิดีโอ ${index + 1}`} {...attributes} {...listeners}>
<GripVerticalIcon data-icon="inline-start" />
</Button>
<FieldLabel htmlFor={`guide-youtube-url-${link.id}`} className="flex-1">YouTube video {index + 1}</FieldLabel>
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label={`ลบวิดีโอ ${index + 1}`}>
<Trash2Icon data-icon="inline-start" />
</Button>
</div>
<Input id={`guide-youtube-url-${link.id}`} type="url" inputMode="url" value={link.url} maxLength={2_048} placeholder="https://www.youtube.com/watch?v=..." aria-invalid={invalid} onChange={(event) => onChange(event.target.value)} />
{invalid ? <FieldError>กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง</FieldError> : null}
{video ? (
<div className="aspect-video w-full max-w-64 overflow-hidden rounded-lg border bg-muted">
<iframe src={video.embedUrl} title={`ตัวอย่างวิดีโอไกด์ ${guideName} ${index + 1}`} loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerPolicy="strict-origin-when-cross-origin" allowFullScreen className="size-full" />
</div>
) : null}
</Field>
);
}
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "blendMode">>) => void; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-start gap-2 rounded-lg border bg-card p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon data-icon="inline-start" /></Button>
{layer.imageUrl ? <Image src={layer.imageUrl} alt="" width={48} height={48} className="size-12 rounded object-contain bg-muted" /> : <div className="size-12 rounded bg-muted" />}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<Combobox
items={blendModeItems}
value={selectedMode}
onValueChange={(value) => value && onChange({ blendMode: value.value })}
itemToStringLabel={(item) => item.label}
itemToStringValue={(item) => item.value}
filter={(item, query) => `${item.label} ${item.value}`.toLowerCase().includes(query.trim().toLowerCase())}
>
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
<ComboboxContent>
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
<ComboboxList>
{(item) => <ComboboxItem key={item.value} value={item}>{item.label}</ComboboxItem>}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Field>
<FieldLabel htmlFor={`bleeding-amount-${layer.id}`}>Amount {layer.amount}%</FieldLabel>
<Slider id={`bleeding-amount-${layer.id}`} min={0} max={100} step={5} value={[layer.amount]} onValueChange={(value) => onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} />
</Field>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon data-icon="inline-start" /></Button>
</div>
);
}
export function GuideOverviewForm({
guide,
}: {
guide: {
id: string;
version: number;
overview: string;
youtubeUrls: string[];
coverMediaId: string;
coverUrl: string;
name: string;
posterArtMediaId: string | null;
posterArtUrl: string | null;
posterArtOffsetX: number;
posterArtOffsetY: number;
posterArtScale: number;
posterArtRotation: number;
posterTopColors: [string, string];
posterBottomColors: [string, string];
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode; sortOrder: number }>;
character: Omit<PublicGuide["character"], "aliases">;
};
}) {
const [overview, setOverview] = useState(guide.overview);
const [youtubeLinks, setYoutubeLinks] = useState<VideoLink[]>(() => guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url })));
const [cover, setCover] = useState({
id: guide.coverMediaId,
url: guide.coverUrl,
});
const [posterArt, setPosterArt] = useState({
id: guide.posterArtMediaId,
url: guide.posterArtUrl,
});
const [posterArtOffset, setPosterArtOffset] = useState({
x: guide.posterArtOffsetX,
y: guide.posterArtOffsetY,
});
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
const [posterArtRotation, setPosterArtRotation] = useState(guide.posterArtRotation);
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
const invalidYoutubeLinks = youtubeLinks.some((link) => !parseYouTubeUrl(link.url.trim()));
const value = {
coverMediaId: cover.id,
overview,
youtubeUrls: youtubeLinks.map((link) => link.url),
posterArtMediaId: posterArt.id,
posterArtOffsetX: posterArtOffset.x,
posterArtOffsetY: posterArtOffset.y,
posterArtScale,
posterArtRotation,
posterTopColors,
posterBottomColors,
posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })),
};
const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
!overview.trim() ? "กรุณากรอก Overview" : null,
invalidYoutubeLinks ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
].filter((issue): issue is string => Boolean(issue));
const invalidCover = !cover.id;
const invalidOverview = !overview.trim();
const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterArtRotation, posterTopColors, posterBottomColors, bleeding };
const autosave = useAutosave({
guideId: guide.id,
scope: "overview",
value,
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks),
initialVersion: guide.version,
persist: (next, expectedVersion, intent) =>
saveGuideOverview(guide.id, { ...next, expectedVersion }, intent),
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;
const draftCover = draft.cover as Record<string, unknown>;
if (
typeof draftCover.id !== "string" ||
typeof draftCover.url !== "string"
)
return null;
const youtubeLinks = Array.isArray(draft.youtubeLinks)
? draft.youtubeLinks.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const link = item as Record<string, unknown>;
return typeof link.id === "string" && typeof link.url === "string" ? [{ id: link.id, url: link.url }] : [];
})
: typeof draft.youtubeUrl === "string" && draft.youtubeUrl
? [{ id: createVideoLinkId(), url: draft.youtubeUrl }]
: guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url }));
return {
overview: draft.overview,
youtubeLinks,
cover: { id: draftCover.id, url: draftCover.url },
posterArt: draft.posterArt && typeof draft.posterArt === "object"
? {
id: typeof (draft.posterArt as Record<string, unknown>).id === "string"
? (draft.posterArt as Record<string, unknown>).id as string
: null,
url: typeof (draft.posterArt as Record<string, unknown>).url === "string"
? (draft.posterArt as Record<string, unknown>).url as string
: null,
}
: { id: guide.posterArtMediaId, url: guide.posterArtUrl },
posterArtOffset: draft.posterArtOffset && typeof draft.posterArtOffset === "object"
? {
x: typeof (draft.posterArtOffset as Record<string, unknown>).x === "number" ? (draft.posterArtOffset as Record<string, unknown>).x as number : 0,
y: typeof (draft.posterArtOffset as Record<string, unknown>).y === "number" ? (draft.posterArtOffset as Record<string, unknown>).y as number : 0,
}
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(500, draft.posterArtScale)) : guide.posterArtScale,
posterArtRotation: typeof draft.posterArtRotation === "number" ? Math.max(-180, Math.min(180, draft.posterArtRotation)) : guide.posterArtRotation,
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
? draft.posterTopColors as [string, string]
: guide.posterTopColors,
posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2
? draft.posterBottomColors as [string, string]
: guide.posterBottomColors,
bleeding: Array.isArray(draft.bleeding)
? draft.bleeding.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const layer = item as Record<string, unknown>;
return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string"
? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
: [];
})
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })),
};
},
restoreLocalDraft: (draft) => {
setOverview(draft.overview);
setYoutubeLinks(draft.youtubeLinks);
setCover(draft.cover);
setPosterArt(draft.posterArt);
setPosterArtOffset(draft.posterArtOffset);
setPosterArtScale(draft.posterArtScale);
setPosterArtRotation(draft.posterArtRotation);
setPosterTopColors(draft.posterTopColors);
setPosterBottomColors(draft.posterBottomColors);
setBleeding(draft.bleeding);
},
});
async function detectPosterColors(url: string) {
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const candidate = new window.Image();
candidate.crossOrigin = "anonymous";
candidate.onload = () => resolve(candidate);
candidate.onerror = reject;
candidate.src = url;
});
const scale = Math.min(1, 512 / Math.max(image.naturalWidth, image.naturalHeight));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const context = canvas.getContext("2d");
if (!context) return;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const colors = detectThemeColorsFromPixels(
context.getImageData(0, 0, canvas.width, canvas.height).data,
canvas.width,
canvas.height,
);
setPosterTopColors(colors.top);
setPosterBottomColors(colors.bottom);
} catch {
// Keep the current colors when the uploaded media host disallows pixel reads.
}
}
function setUploadedPoster(asset: { id: string; url: string }) {
setPosterArt(asset);
setPosterArtOffset({ x: 0, y: 0 });
setPosterArtRotation(0);
setBleeding((current) => current.length
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
}
function removeUploadedPoster() {
setPosterArt({ id: null, url: null });
setPosterArtOffset({ x: 0, y: 0 });
setPosterArtScale(100);
setPosterArtRotation(0);
setPosterTopColors(["#000000", "#000000"]);
setPosterBottomColors(["#000000", "#000000"]);
setBleeding([]);
}
function detectPosterColorsFromFile(file: File) {
const url = URL.createObjectURL(file);
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
}
function addBleedingEffect() {
if (!posterArt.id || !posterArt.url) return;
const mediaId = posterArt.id;
const imageUrl = posterArt.url;
setBleeding((current) => [...current, { id: createBleedingLayerId(), mediaId, imageUrl, amount: 65, blendMode: "multiply" }]);
}
function handleBleedingDragEnd(event: DragEndEvent) {
if (!event.over || event.active.id === event.over.id) return;
setBleeding((current) => {
const oldIndex = current.findIndex((layer) => layer.id === event.active.id);
const newIndex = current.findIndex((layer) => layer.id === event.over?.id);
return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex);
});
}
function handleVideoDragEnd(event: DragEndEvent) {
if (!event.over || event.active.id === event.over.id) return;
setYoutubeLinks((current) => {
const oldIndex = current.findIndex((link) => link.id === event.active.id);
const newIndex = current.findIndex((link) => link.id === event.over?.id);
return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex);
});
}
return (
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>
ภาพ ข้อความ และวิดีโอนี้แสดงบนหน้าแรกของ Guide
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={invalidCover}>
<FieldLabel>Guide Image</FieldLabel>
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className={cn(
"relative aspect-square w-full overflow-hidden rounded-lg border",
invalidCover && "border-destructive",
)}>
<Image
src={cover.url}
alt={`Guide ${guide.name}`}
fill
className="object-cover"
/>
</div>
<div className="rounded-lg border p-4">
<FieldGroup>
<Field>
<FieldLabel>Upload Guide Image</FieldLabel>
<FieldDescription>อัปโหลดภาพสี่เหลี่ยมสำหรับหน้าแรกของ Guide</FieldDescription>
<MediaUploader onReady={(asset) => setCover(asset)} />
</Field>
<Field data-invalid={invalidOverview}>
<FieldLabel htmlFor="guide-overview">Overview</FieldLabel>
<Textarea
id="guide-overview"
value={overview}
aria-invalid={invalidOverview}
maxLength={500}
onChange={(event) => setOverview(event.target.value)}
/>
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
</Field>
<Field>
<div className="flex items-center justify-between gap-2">
<div>
<FieldLabel>YouTube videos</FieldLabel>
<FieldDescription>ไม่บังคับ รองรับ YouTube, youtu.be, Shorts และ Live - ลากเพื่อจัดลำดับ</FieldDescription>
</div>
<Button type="button" variant="outline" size="sm" disabled={youtubeLinks.length >= 20} onClick={() => setYoutubeLinks((current) => [...current, { id: createVideoLinkId(), url: "" }])}>
<PlusIcon data-icon="inline-start" />
เพิ่มลิงก์
</Button>
</div>
{youtubeLinks.length ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleVideoDragEnd}>
<SortableContext items={youtubeLinks.map((link) => link.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{youtubeLinks.map((link, index) => (
<SortableVideoLink key={link.id} link={link} index={index} guideName={guide.name} onChange={(url) => setYoutubeLinks((current) => current.map((item) => item.id === link.id ? { ...item, url } : item))} onRemove={() => setYoutubeLinks((current) => current.filter((item) => item.id !== link.id))} />
))}
</div>
</SortableContext>
</DndContext>
) : <p className="text-sm text-muted-foreground">ยังไม่มีวิดีโอใน Overview</p>}
</Field>
</FieldGroup>
</div>
</div>
</Field>
<Field>
<FieldLabel>Materials Overview</FieldLabel>
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className="w-full overflow-hidden rounded-lg border">
<MaterialPoster
character={guide.character}
characterArtUrl={posterArt.url}
artworkOffsetX={posterArtOffset.x}
artworkOffsetY={posterArtOffset.y}
artworkScale={posterArtScale}
artworkRotation={posterArtRotation}
topColors={posterTopColors}
bottomColors={posterBottomColors}
bleeding={bleeding}
interactive
onArtworkMove={setPosterArtOffset}
/>
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<MediaUploader
onReady={setUploadedPoster}
onFileSelected={detectPosterColorsFromFile}
/>
{posterArt.id ? (
<Button type="button" variant="outline" onClick={removeUploadedPoster}>
<Trash2Icon data-icon="inline-start" />
ลบภาพตัวละคร
</Button>
) : null}
</div>
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<FieldLabel>Bleeding layers</FieldLabel>
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode - รายการบนสุดจะแสดงก่อน</p>
</div>
<Button type="button" variant="outline" size="sm" disabled={!posterArt.id || !posterArt.url} onClick={addBleedingEffect}>เพิ่ม effect</Button>
</div>
{bleeding.length ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{bleeding.map((layer) => <SortableBleedingLayer key={layer.id} layer={layer} onChange={(patch) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)}
</div>
</SortableContext>
</DndContext>
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
</div>
<Field className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-scale">Character image zoom</FieldLabel><span className="text-sm text-muted-foreground">{posterArtScale}%</span></div>
<Slider id="poster-art-scale" min={50} max={500} step={5} value={[posterArtScale]} onValueChange={(value) => setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} />
</Field>
<Field className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-rotation">Character image rotation</FieldLabel><span className="text-sm text-muted-foreground">{posterArtRotation}°</span></div>
<Slider id="poster-art-rotation" min={-180} max={180} step={1} value={[posterArtRotation]} onValueChange={(value) => setPosterArtRotation(typeof value === "number" ? value : value[0] ?? 0)} />
</Field>
<FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription>
</div>
</div>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<AutosaveStatus
state={autosave.state}
conflictVersion={autosave.conflictVersion}
canOverwrite={autosave.canOverwrite}
onReloadLatest={autosave.reloadLatest}
onOverwrite={autosave.overwrite}
issues={incompleteIssues}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}