Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-23 21:51:56 +00:00
co-authored by renee-png
parent b4c8c8ee91
commit 95e84ad842
+163
View File
@@ -0,0 +1,163 @@
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 { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Loader2, Plus, ExternalLink, Pencil, Trash2, FileText } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/public-pages/")({
component: PublicPagesSettingsPage,
});
type PageRow = {
id: string;
slug: string;
title: string;
status: string;
show_in_nav: boolean;
sort_order: number;
updated_at: string;
};
function PublicPagesSettingsPage() {
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [pages, setPages] = useState<PageRow[]>([]);
const [newOpen, setNewOpen] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("public_pages")
.select("id,slug,title,status,show_in_nav,sort_order,updated_at")
.order("sort_order", { ascending: true })
.order("title", { ascending: true });
setPages((data ?? []) as PageRow[]);
setLoading(false);
};
useEffect(() => { load(); }, []);
const slugify = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const create = async () => {
if (!newTitle.trim()) { toast.error("Title is required"); return; }
const slug = (newSlug.trim() || slugify(newTitle)).toLowerCase();
if (!slug) { toast.error("Invalid slug"); return; }
setCreating(true);
const { data, error } = await supabase
.from("public_pages")
.insert({ title: newTitle.trim(), slug, status: "draft" })
.select("id")
.single();
setCreating(false);
if (error) { toast.error("Could not create", { description: error.message }); return; }
toast.success("Page created");
setNewOpen(false);
setNewTitle(""); setNewSlug("");
navigate({ to: "/settings/public-pages/$pageId", params: { pageId: data.id } });
};
const remove = async (id: string) => {
if (!confirm("Delete this page and all its sections?")) return;
const { error } = await supabase.from("public_pages").delete().eq("id", id);
if (error) { toast.error("Could not delete", { description: error.message }); return; }
toast.success("Deleted");
load();
};
if (!isAdmin) return <p className="text-sm text-muted-foreground">Admins only.</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Public pages</h2>
<p className="text-sm text-muted-foreground">
Manage marketing pages rendered at <code>/p/&#123;slug&#125;</code>.
</p>
</div>
<Button onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-2" />New page</Button>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Pages</CardTitle></CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : pages.length === 0 ? (
<div className="text-center py-10 text-sm text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
No pages yet. Create your first one.
</div>
) : (
<div className="divide-y">
{pages.map((p) => (
<div key={p.id} className="flex items-center justify-between py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{p.title}</span>
<Badge variant={p.status === "published" ? "default" : "secondary"}>{p.status}</Badge>
{!p.show_in_nav && <Badge variant="outline">hidden in nav</Badge>}
</div>
<div className="text-xs text-muted-foreground">/p/{p.slug}</div>
</div>
<div className="flex items-center gap-1">
{p.status === "published" && (
<Button asChild variant="ghost" size="sm">
<a href={`/p/${p.slug}`} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4" /></a>
</Button>
)}
<Button asChild variant="ghost" size="sm">
<Link to="/settings/public-pages/$pageId" params={{ pageId: p.id }}><Pencil className="h-4 w-4" /></Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => remove(p.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent>
<DialogHeader><DialogTitle>New public page</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>Title</Label>
<Input value={newTitle} onChange={(e) => {
setNewTitle(e.target.value);
if (!newSlug) setNewSlug(slugify(e.target.value));
}} placeholder="About Us" />
</div>
<div>
<Label>Slug</Label>
<Input value={newSlug} onChange={(e) => setNewSlug(slugify(e.target.value))} placeholder="about-us" />
<p className="text-xs text-muted-foreground mt-1">URL: /p/{newSlug || "your-slug"}</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancel</Button>
<Button onClick={create} disabled={creating}>{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}