Files
mylegal-stage-law/src/routes/settings.public-pages.$pageId.tsx
T
2026-04-23 21:53:26 +00:00

352 lines
17 KiB
TypeScript

import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { RichTextEditor } from "@/components/documents/rich-text-editor";
import {
Loader2, ArrowLeft, Plus, Trash2, ChevronUp, ChevronDown, Save, ExternalLink, Image as ImageIcon, X,
} from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/public-pages/$pageId")({
component: PublicPageEditor,
});
type PageRow = {
id: string; slug: string; title: string; meta_description: string | null;
status: string; show_in_nav: boolean; sort_order: number;
};
type SectionRow = {
id: string; page_id: string; section_type: string; sort_order: number; content: any;
};
const SECTION_TYPES = [
{ value: "hero", label: "Hero (heading + subtext)" },
{ value: "rich_text", label: "Rich text block" },
{ value: "feature_grid", label: "Feature grid" },
{ value: "cta", label: "Call-to-action" },
{ value: "contact", label: "Contact info" },
{ value: "faq", label: "FAQ list" },
];
function defaultContent(type: string): any {
switch (type) {
case "hero": return { heading: "Heading", subheading: "Subheading", eyebrow: "" };
case "rich_text": return { html: "<p>Write something...</p>" };
case "feature_grid": return { heading: "Features", items: [{ title: "Feature 1", body: "Description" }] };
case "cta": return { heading: "Ready to start?", subheading: "", button_label: "Contact us", button_url: "/p/contact" };
case "contact": return { heading: "Get in touch", phone: "", email: "", address: "" };
case "faq": return { heading: "FAQ", items: [{ q: "Question?", a: "Answer." }] };
default: return {};
}
}
function PublicPageEditor() {
const { pageId } = Route.useParams();
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState<PageRow | null>(null);
const [sections, setSections] = useState<SectionRow[]>([]);
const load = async () => {
setLoading(true);
const [pRes, sRes] = await Promise.all([
supabase.from("public_pages").select("*").eq("id", pageId).maybeSingle(),
supabase.from("public_page_sections").select("*").eq("page_id", pageId).order("sort_order", { ascending: true }),
]);
if (pRes.data) setPage(pRes.data as PageRow);
setSections((sRes.data ?? []) as SectionRow[]);
setLoading(false);
};
useEffect(() => { load(); }, [pageId]);
const savePage = async () => {
if (!page) return;
setSaving(true);
const updates: any = {
title: page.title, slug: page.slug, meta_description: page.meta_description,
status: page.status, show_in_nav: page.show_in_nav, sort_order: page.sort_order,
};
if (page.status === "published") updates.published_at = new Date().toISOString();
const { error } = await supabase.from("public_pages").update(updates).eq("id", pageId);
setSaving(false);
if (error) { toast.error("Could not save", { description: error.message }); return; }
toast.success("Page saved");
};
const addSection = async (type: string) => {
const sort_order = sections.length > 0 ? Math.max(...sections.map(s => s.sort_order)) + 1 : 0;
const { data, error } = await supabase
.from("public_page_sections")
.insert({ page_id: pageId, section_type: type, sort_order, content: defaultContent(type) })
.select("*").single();
if (error) { toast.error("Could not add section", { description: error.message }); return; }
setSections([...sections, data as SectionRow]);
};
const updateSection = (id: string, content: any) => {
setSections(sections.map(s => s.id === id ? { ...s, content } : s));
};
const persistSection = async (id: string) => {
const s = sections.find(x => x.id === id);
if (!s) return;
const { error } = await supabase.from("public_page_sections").update({ content: s.content }).eq("id", id);
if (error) toast.error("Could not save section", { description: error.message });
else toast.success("Section saved");
};
const removeSection = async (id: string) => {
if (!confirm("Delete this section?")) return;
const { error } = await supabase.from("public_page_sections").delete().eq("id", id);
if (error) { toast.error("Could not delete", { description: error.message }); return; }
setSections(sections.filter(s => s.id !== id));
};
const move = async (id: string, dir: -1 | 1) => {
const idx = sections.findIndex(s => s.id === id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= sections.length) return;
const a = sections[idx]; const b = sections[swapIdx];
const newSections = [...sections];
newSections[idx] = { ...b, sort_order: a.sort_order };
newSections[swapIdx] = { ...a, sort_order: b.sort_order };
setSections(newSections);
await Promise.all([
supabase.from("public_page_sections").update({ sort_order: b.sort_order }).eq("id", a.id),
supabase.from("public_page_sections").update({ sort_order: a.sort_order }).eq("id", b.id),
]);
};
if (!isAdmin) return <p className="text-sm text-muted-foreground">Admins only.</p>;
if (loading) return <div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>;
if (!page) return <p className="text-sm text-muted-foreground">Page not found.</p>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" asChild>
<Link to="/settings/public-pages"><ArrowLeft className="h-4 w-4 mr-2" />All pages</Link>
</Button>
<div className="flex gap-2">
{page.status === "published" && (
<Button variant="outline" size="sm" asChild>
<a href={`/p/${page.slug}`} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4 mr-2" />View</a>
</Button>
)}
<Button onClick={savePage} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save page
</Button>
</div>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Page settings</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<Label>Title</Label>
<Input value={page.title} onChange={(e) => setPage({ ...page, title: e.target.value })} />
</div>
<div>
<Label>Slug</Label>
<Input value={page.slug} onChange={(e) => setPage({ ...page, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") })} />
</div>
</div>
<div>
<Label>Meta description (SEO)</Label>
<Textarea rows={2} value={page.meta_description ?? ""} onChange={(e) => setPage({ ...page, meta_description: e.target.value })} />
</div>
<div className="grid sm:grid-cols-3 gap-3">
<div>
<Label>Status</Label>
<Select value={page.status} onValueChange={(v) => setPage({ ...page, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Sort order</Label>
<Input type="number" value={page.sort_order} onChange={(e) => setPage({ ...page, sort_order: parseInt(e.target.value) || 0 })} />
</div>
<div className="flex items-end gap-2 pb-2">
<Switch checked={page.show_in_nav} onCheckedChange={(v) => setPage({ ...page, show_in_nav: v })} id="nav" />
<Label htmlFor="nav">Show in nav</Label>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Sections</CardTitle>
<Select onValueChange={addSection}>
<SelectTrigger className="w-[220px]"><SelectValue placeholder="+ Add section" /></SelectTrigger>
<SelectContent>
{SECTION_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-4">
{sections.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">No sections yet. Add one above.</p>
)}
{sections.map((s, i) => (
<Card key={s.id} className="border-2">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">{SECTION_TYPES.find(t => t.value === s.section_type)?.label ?? s.section_type}</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => move(s.id, -1)} disabled={i === 0}><ChevronUp className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => move(s.id, 1)} disabled={i === sections.length - 1}><ChevronDown className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => persistSection(s.id)}><Save className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => removeSection(s.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
</div>
</CardHeader>
<CardContent>
<SectionEditor section={s} onChange={(c) => updateSection(s.id, c)} />
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
}
function SectionEditor({ section, onChange }: { section: SectionRow; onChange: (c: any) => void }) {
const c = section.content || {};
const upd = (patch: any) => onChange({ ...c, ...patch });
const uploadImage = async (file: File): Promise<string | null> => {
const ext = file.name.split(".").pop() || "png";
const path = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const { error } = await supabase.storage.from("public-page-images").upload(path, file, { upsert: false, contentType: file.type });
if (error) { toast.error("Upload failed", { description: error.message }); return null; }
const { data } = supabase.storage.from("public-page-images").getPublicUrl(path);
return data.publicUrl;
};
switch (section.section_type) {
case "hero":
return (
<div className="space-y-3">
<div><Label>Eyebrow (small label above heading)</Label><Input value={c.eyebrow ?? ""} onChange={(e) => upd({ eyebrow: e.target.value })} /></div>
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
<div><Label>Subheading</Label><Textarea rows={2} value={c.subheading ?? ""} onChange={(e) => upd({ subheading: e.target.value })} /></div>
</div>
);
case "rich_text":
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Body</Label>
<Button type="button" variant="outline" size="sm" onClick={() => {
const input = document.createElement("input");
input.type = "file"; input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0]; if (!file) return;
const url = await uploadImage(file);
if (url) upd({ html: (c.html ?? "") + `<p><img src="${url}" alt="" style="max-width:100%;" /></p>` });
};
input.click();
}}><ImageIcon className="h-4 w-4 mr-2" />Insert image</Button>
</div>
<RichTextEditor value={c.html ?? ""} onChange={(html) => upd({ html })} minHeight={250} />
</div>
);
case "feature_grid": {
const items = Array.isArray(c.items) ? c.items : [];
return (
<div className="space-y-3">
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
<div className="space-y-2">
{items.map((it: any, idx: number) => (
<div key={idx} className="flex gap-2 items-start border rounded-md p-2">
<div className="flex-1 space-y-2">
<Input placeholder="Title" value={it.title ?? ""} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, title: e.target.value }; upd({ items: next });
}} />
<Textarea rows={2} placeholder="Description" value={it.body ?? ""} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, body: e.target.value }; upd({ items: next });
}} />
</div>
<Button variant="ghost" size="sm" onClick={() => upd({ items: items.filter((_: any, i: number) => i !== idx) })}><X className="h-4 w-4" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => upd({ items: [...items, { title: "", body: "" }] })}><Plus className="h-4 w-4 mr-2" />Add feature</Button>
</div>
</div>
);
}
case "cta":
return (
<div className="space-y-3">
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
<div><Label>Subheading</Label><Textarea rows={2} value={c.subheading ?? ""} onChange={(e) => upd({ subheading: e.target.value })} /></div>
<div className="grid sm:grid-cols-2 gap-3">
<div><Label>Button label</Label><Input value={c.button_label ?? ""} onChange={(e) => upd({ button_label: e.target.value })} /></div>
<div><Label>Button URL</Label><Input value={c.button_url ?? ""} onChange={(e) => upd({ button_url: e.target.value })} /></div>
</div>
</div>
);
case "contact":
return (
<div className="space-y-3">
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
<div className="grid sm:grid-cols-2 gap-3">
<div><Label>Phone</Label><Input value={c.phone ?? ""} onChange={(e) => upd({ phone: e.target.value })} /></div>
<div><Label>Email</Label><Input value={c.email ?? ""} onChange={(e) => upd({ email: e.target.value })} /></div>
</div>
<div><Label>Address</Label><Textarea rows={2} value={c.address ?? ""} onChange={(e) => upd({ address: e.target.value })} /></div>
</div>
);
case "faq": {
const items = Array.isArray(c.items) ? c.items : [];
return (
<div className="space-y-3">
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
<div className="space-y-2">
{items.map((it: any, idx: number) => (
<div key={idx} className="flex gap-2 items-start border rounded-md p-2">
<div className="flex-1 space-y-2">
<Input placeholder="Question" value={it.q ?? ""} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, q: e.target.value }; upd({ items: next });
}} />
<Textarea rows={2} placeholder="Answer" value={it.a ?? ""} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, a: e.target.value }; upd({ items: next });
}} />
</div>
<Button variant="ghost" size="sm" onClick={() => upd({ items: items.filter((_: any, i: number) => i !== idx) })}><X className="h-4 w-4" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => upd({ items: [...items, { q: "", a: "" }] })}><Plus className="h-4 w-4 mr-2" />Add Q&amp;A</Button>
</div>
</div>
);
}
default: return <p className="text-sm text-muted-foreground">Unknown section type.</p>;
}
}