Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowLeft,
|
|
HardHat,
|
|
Loader2,
|
|
Package,
|
|
Pencil,
|
|
Plus,
|
|
Save,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
createBcItem,
|
|
deleteBcItem,
|
|
updateBcItem,
|
|
updateBcPage,
|
|
} from "@/actions/catalog-bc";
|
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
import Link from "@/components/link";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useServerAction } from "@/hooks/use-server-action";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────
|
|
|
|
export interface BcPageData {
|
|
id: number;
|
|
parentId: number;
|
|
caption: string;
|
|
pageLayout: string;
|
|
iconColor: number;
|
|
iconImage: number;
|
|
orderNum: number;
|
|
visible: string;
|
|
enabled: string;
|
|
pageHeadline: string;
|
|
pageTeaser: string;
|
|
pageSpecial: string;
|
|
pageText1: string;
|
|
pageText2: string;
|
|
pageTextDetails: string;
|
|
pageTextTeaser: string;
|
|
}
|
|
|
|
export interface BcItemData {
|
|
id: number;
|
|
pageId: number;
|
|
itemIds: string;
|
|
catalogName: string;
|
|
orderNumber: number;
|
|
extradata: string;
|
|
}
|
|
|
|
interface BcPageDetailProps {
|
|
page: BcPageData;
|
|
items: BcItemData[];
|
|
canEdit: boolean;
|
|
}
|
|
|
|
// ── Main Component ───────────────────────────────────────────────────
|
|
|
|
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|
const { isPending, run } = useServerAction();
|
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
|
|
const [addingItem, setAddingItem] = useState(false);
|
|
|
|
const [form, setForm] = useState({
|
|
caption: page.caption,
|
|
orderNum: page.orderNum,
|
|
enabled: page.enabled as "0" | "1",
|
|
visible: page.visible as "0" | "1",
|
|
pageHeadline: page.pageHeadline,
|
|
pageTeaser: page.pageTeaser,
|
|
pageSpecial: page.pageSpecial,
|
|
pageText1: page.pageText1,
|
|
pageText2: page.pageText2,
|
|
pageTextDetails: page.pageTextDetails,
|
|
pageTextTeaser: page.pageTextTeaser,
|
|
});
|
|
|
|
// Ctrl+S / Cmd+S to save
|
|
useEffect(() => {
|
|
if (!canEdit) return;
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
formRef.current?.requestSubmit();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
}, [canEdit]);
|
|
|
|
function update<K extends keyof typeof form>(
|
|
key: K,
|
|
value: (typeof form)[K],
|
|
) {
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
run(() => updateBcPage({ id: page.id, ...form }), {
|
|
successMessage: "BC page updated and catalog synced.",
|
|
errorMessage: "Failed to update.",
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{confirmDialog}
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/admin/catalog?catalog=bc"
|
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
BC Catalog
|
|
</Link>
|
|
<span className="text-muted-foreground">/</span>
|
|
<div className="flex items-center gap-2">
|
|
<HardHat className="h-5 w-5 text-[var(--admin-warning)]" />
|
|
<h1 className="text-2xl font-bold tracking-tight">{page.caption}</h1>
|
|
<Badge variant="outline" className="font-mono text-xs">
|
|
#{page.id}
|
|
</Badge>
|
|
<Badge variant="secondary" className="font-mono text-xs">
|
|
{page.pageLayout}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<form ref={formRef} onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
|
{/* ── Left column: form fields ─────────────── */}
|
|
<div className="lg:col-span-2 space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Page Settings</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="caption">Caption</Label>
|
|
<Input
|
|
id="caption"
|
|
value={form.caption}
|
|
onChange={(e) => update("caption", e.target.value)}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="orderNum">Order</Label>
|
|
<Input
|
|
id="orderNum"
|
|
type="number"
|
|
value={form.orderNum}
|
|
onChange={(e) =>
|
|
update("orderNum", parseInt(e.target.value, 10) || 0)
|
|
}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Headline</Label>
|
|
<Input
|
|
value={form.pageHeadline}
|
|
onChange={(e) => update("pageHeadline", e.target.value)}
|
|
disabled={!canEdit}
|
|
placeholder="Headline image path"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Teaser</Label>
|
|
<Input
|
|
value={form.pageTeaser}
|
|
onChange={(e) => update("pageTeaser", e.target.value)}
|
|
disabled={!canEdit}
|
|
placeholder="Teaser image path"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Text 1</Label>
|
|
<Textarea
|
|
value={form.pageText1}
|
|
onChange={(e) => update("pageText1", e.target.value)}
|
|
disabled={!canEdit}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Text 2</Label>
|
|
<Textarea
|
|
value={form.pageText2}
|
|
onChange={(e) => update("pageText2", e.target.value)}
|
|
disabled={!canEdit}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Details</Label>
|
|
<Textarea
|
|
value={form.pageTextDetails}
|
|
onChange={(e) =>
|
|
update("pageTextDetails", e.target.value)
|
|
}
|
|
disabled={!canEdit}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Text Teaser</Label>
|
|
<Textarea
|
|
value={form.pageTextTeaser}
|
|
onChange={(e) => update("pageTextTeaser", e.target.value)}
|
|
disabled={!canEdit}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Special</Label>
|
|
<Textarea
|
|
value={form.pageSpecial}
|
|
onChange={(e) => update("pageSpecial", e.target.value)}
|
|
disabled={!canEdit}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Items */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Items ({items.length})
|
|
</CardTitle>
|
|
{canEdit && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setAddingItem(true)}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
Add Item
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{items.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No items in this page.
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">ID</TableHead>
|
|
<TableHead className="text-xs">Name</TableHead>
|
|
<TableHead className="text-xs">Item IDs</TableHead>
|
|
<TableHead className="text-xs">Order</TableHead>
|
|
<TableHead className="text-xs">Extra Data</TableHead>
|
|
{canEdit && (
|
|
<TableHead className="text-xs w-20">
|
|
Actions
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="text-xs font-mono">
|
|
{item.id}
|
|
</TableCell>
|
|
<TableCell className="text-xs font-medium">
|
|
{item.catalogName}
|
|
</TableCell>
|
|
<TableCell className="text-xs font-mono text-muted-foreground">
|
|
{item.itemIds}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{item.orderNumber}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
|
{item.extradata || "\u2014"}
|
|
</TableCell>
|
|
{canEdit && (
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={() => setEditingItem(item)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
|
onClick={async () => {
|
|
const ok = await confirm({
|
|
title: "Delete BC item",
|
|
description: `Delete item "${item.catalogName}"?`,
|
|
confirmLabel: "Delete",
|
|
});
|
|
if (!ok) return;
|
|
run(() => deleteBcItem({ id: item.id }), {
|
|
successMessage: "Item deleted.",
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* ── Right column: status ─────────────────── */}
|
|
<div className="lg:col-span-1">
|
|
<Card className="lg:sticky lg:top-4">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Status</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-3">
|
|
<ToggleRow
|
|
id="tg-enabled"
|
|
label="Enabled"
|
|
description="Active in the BC catalog"
|
|
checked={form.enabled === "1"}
|
|
onChange={(v) => update("enabled", v ? "1" : "0")}
|
|
disabled={!canEdit}
|
|
/>
|
|
<ToggleRow
|
|
id="tg-visible"
|
|
label="Visible"
|
|
description="Shown in BC navigation"
|
|
checked={form.visible === "1"}
|
|
onChange={(v) => update("visible", v ? "1" : "0")}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
|
<span>
|
|
Layout: <span className="font-mono">{page.pageLayout}</span>
|
|
</span>
|
|
<span>
|
|
Parent:{" "}
|
|
<span className="font-mono">
|
|
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{canEdit && (
|
|
<div className="space-y-2 border-t pt-3">
|
|
<Button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="w-full"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
Save & Sync
|
|
</Button>
|
|
<p className="text-center text-[10px] text-muted-foreground">
|
|
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
|
save
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Edit Item Dialog */}
|
|
{editingItem && (
|
|
<ItemDialog
|
|
item={editingItem}
|
|
mode="edit"
|
|
isPending={isPending}
|
|
onClose={() => setEditingItem(null)}
|
|
onSubmit={(data) => {
|
|
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
|
successMessage: "Item updated.",
|
|
onSuccess: () => setEditingItem(null),
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Item Dialog */}
|
|
{addingItem && (
|
|
<ItemDialog
|
|
item={{
|
|
id: 0,
|
|
pageId: page.id,
|
|
itemIds: "",
|
|
catalogName: "",
|
|
orderNumber: 1,
|
|
extradata: "",
|
|
}}
|
|
mode="add"
|
|
isPending={isPending}
|
|
onClose={() => setAddingItem(false)}
|
|
onSubmit={(data) => {
|
|
run(() => createBcItem({ pageId: page.id, ...data }), {
|
|
successMessage: "Item created.",
|
|
onSuccess: () => setAddingItem(false),
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Toggle row ───────────────────────────────────────────────────────
|
|
|
|
function ToggleRow({
|
|
id,
|
|
label,
|
|
description,
|
|
checked,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
|
|
{label}
|
|
</Label>
|
|
<p className="text-[11px] text-muted-foreground">{description}</p>
|
|
</div>
|
|
<Switch
|
|
id={id}
|
|
checked={checked}
|
|
onCheckedChange={onChange}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Item Dialog ──────────────────────────────────────────────────────
|
|
|
|
function ItemDialog({
|
|
item,
|
|
mode,
|
|
isPending,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
item: BcItemData;
|
|
mode: "add" | "edit";
|
|
isPending: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (data: {
|
|
itemIds: string;
|
|
catalogName: string;
|
|
orderNumber: number;
|
|
extradata: string;
|
|
}) => void;
|
|
}) {
|
|
const [form, setForm] = useState({
|
|
itemIds: item.itemIds,
|
|
catalogName: item.catalogName,
|
|
orderNumber: item.orderNumber,
|
|
extradata: item.extradata,
|
|
});
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
<Label>Catalog Name</Label>
|
|
<Input
|
|
value={form.catalogName}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, catalogName: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Item IDs (semicolon-separated)</Label>
|
|
<Input
|
|
value={form.itemIds}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, itemIds: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Order</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.orderNumber}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
orderNumber: parseInt(e.target.value, 10) || 0,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Extra Data</Label>
|
|
<Input
|
|
value={form.extradata}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, extradata: e.target.value }))
|
|
}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => onSubmit(form)}
|
|
disabled={isPending || !form.catalogName || !form.itemIds}
|
|
>
|
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{mode === "add" ? "Add Item" : "Save"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|