Added public page editor

X-Lovable-Edit-ID: edt-86109e4f-97e6-40f7-83b9-c987e841ae79
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:54:27 +00:00
co-authored by renee-png
7 changed files with 995 additions and 0 deletions
+80
View File
@@ -2775,6 +2775,86 @@ export type Database = {
}
Relationships: []
}
public_page_sections: {
Row: {
content: Json
created_at: string
id: string
page_id: string
section_type: string
sort_order: number
updated_at: string
}
Insert: {
content?: Json
created_at?: string
id?: string
page_id: string
section_type: string
sort_order?: number
updated_at?: string
}
Update: {
content?: Json
created_at?: string
id?: string
page_id?: string
section_type?: string
sort_order?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "public_page_sections_page_id_fkey"
columns: ["page_id"]
isOneToOne: false
referencedRelation: "public_pages"
referencedColumns: ["id"]
},
]
}
public_pages: {
Row: {
created_at: string
created_by: string | null
id: string
meta_description: string | null
published_at: string | null
show_in_nav: boolean
slug: string
sort_order: number
status: string
title: string
updated_at: string
}
Insert: {
created_at?: string
created_by?: string | null
id?: string
meta_description?: string | null
published_at?: string | null
show_in_nav?: boolean
slug: string
sort_order?: number
status?: string
title: string
updated_at?: string
}
Update: {
created_at?: string
created_by?: string | null
id?: string
meta_description?: string | null
published_at?: string | null
show_in_nav?: boolean
slug?: string
sort_order?: number
status?: string
title?: string
updated_at?: string
}
Relationships: []
}
smtp_settings: {
Row: {
created_at: string
+65
View File
@@ -41,6 +41,7 @@ import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields'
import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields'
import { Route as PayIdRouteImport } from './routes/pay.$id'
import { Route as PSlugRouteImport } from './routes/p.$slug'
import { Route as InvoicesNewRouteImport } from './routes/invoices.new'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
@@ -52,7 +53,9 @@ import { Route as CasesNewRouteImport } from './routes/cases.new'
import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
import { Route as ApiStripeWebhookRouteImport } from './routes/api.stripe-webhook'
import { Route as AdminUsersRouteImport } from './routes/admin.users'
import { Route as SettingsPublicPagesIndexRouteImport } from './routes/settings.public-pages.index'
import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index'
import { Route as SettingsPublicPagesPageIdRouteImport } from './routes/settings.public-pages.$pageId'
import { Route as LovableEmailSuppressionRouteImport } from './routes/lovable/email/suppression'
import { Route as InvoicesNewClientIdRouteImport } from './routes/invoices.new.$clientId'
import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new'
@@ -224,6 +227,11 @@ const PayIdRoute = PayIdRouteImport.update({
path: '/pay/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PSlugRoute = PSlugRouteImport.update({
id: '/p/$slug',
path: '/p/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const InvoicesNewRoute = InvoicesNewRouteImport.update({
id: '/invoices/new',
path: '/invoices/new',
@@ -279,11 +287,23 @@ const AdminUsersRoute = AdminUsersRouteImport.update({
path: '/admin/users',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsPublicPagesIndexRoute =
SettingsPublicPagesIndexRouteImport.update({
id: '/public-pages/',
path: '/public-pages/',
getParentRoute: () => SettingsRoute,
} as any)
const DocumentsTemplatesIndexRoute = DocumentsTemplatesIndexRouteImport.update({
id: '/documents/templates/',
path: '/documents/templates/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsPublicPagesPageIdRoute =
SettingsPublicPagesPageIdRouteImport.update({
id: '/public-pages/$pageId',
path: '/public-pages/$pageId',
getParentRoute: () => SettingsRoute,
} as any)
const LovableEmailSuppressionRoute = LovableEmailSuppressionRouteImport.update({
id: '/lovable/email/suppression',
path: '/lovable/email/suppression',
@@ -356,6 +376,7 @@ export interface FileRoutesByFullPath {
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -391,7 +412,9 @@ export interface FileRoutesByFullPath {
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
'/settings/public-pages/': typeof SettingsPublicPagesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
@@ -411,6 +434,7 @@ export interface FileRoutesByTo {
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -446,7 +470,9 @@ export interface FileRoutesByTo {
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
'/documents/templates': typeof DocumentsTemplatesIndexRoute
'/settings/public-pages': typeof SettingsPublicPagesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
@@ -468,6 +494,7 @@ export interface FileRoutesById {
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -503,7 +530,9 @@ export interface FileRoutesById {
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
'/settings/public-pages/': typeof SettingsPublicPagesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
@@ -526,6 +555,7 @@ export interface FileRouteTypes {
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -561,7 +591,9 @@ export interface FileRouteTypes {
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/settings/public-pages/$pageId'
| '/documents/templates/'
| '/settings/public-pages/'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
@@ -581,6 +613,7 @@ export interface FileRouteTypes {
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -616,7 +649,9 @@ export interface FileRouteTypes {
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/settings/public-pages/$pageId'
| '/documents/templates'
| '/settings/public-pages'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
@@ -637,6 +672,7 @@ export interface FileRouteTypes {
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -672,7 +708,9 @@ export interface FileRouteTypes {
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/settings/public-pages/$pageId'
| '/documents/templates/'
| '/settings/public-pages/'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
@@ -694,6 +732,7 @@ export interface RootRouteChildren {
HooksPollImapRoute: typeof HooksPollImapRoute
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
InvoicesNewRoute: typeof InvoicesNewRouteWithChildren
PSlugRoute: typeof PSlugRoute
PayIdRoute: typeof PayIdRoute
ArchiveIndexRoute: typeof ArchiveIndexRoute
CalendarIndexRoute: typeof CalendarIndexRoute
@@ -948,6 +987,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PayIdRouteImport
parentRoute: typeof rootRouteImport
}
'/p/$slug': {
id: '/p/$slug'
path: '/p/$slug'
fullPath: '/p/$slug'
preLoaderRoute: typeof PSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/invoices/new': {
id: '/invoices/new'
path: '/invoices/new'
@@ -1025,6 +1071,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminUsersRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/public-pages/': {
id: '/settings/public-pages/'
path: '/public-pages'
fullPath: '/settings/public-pages/'
preLoaderRoute: typeof SettingsPublicPagesIndexRouteImport
parentRoute: typeof SettingsRoute
}
'/documents/templates/': {
id: '/documents/templates/'
path: '/documents/templates'
@@ -1032,6 +1085,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocumentsTemplatesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/public-pages/$pageId': {
id: '/settings/public-pages/$pageId'
path: '/public-pages/$pageId'
fullPath: '/settings/public-pages/$pageId'
preLoaderRoute: typeof SettingsPublicPagesPageIdRouteImport
parentRoute: typeof SettingsRoute
}
'/lovable/email/suppression': {
id: '/lovable/email/suppression'
path: '/lovable/email/suppression'
@@ -1117,6 +1177,8 @@ interface SettingsRouteChildren {
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute
SettingsIndexRoute: typeof SettingsIndexRoute
SettingsPublicPagesPageIdRoute: typeof SettingsPublicPagesPageIdRoute
SettingsPublicPagesIndexRoute: typeof SettingsPublicPagesIndexRoute
}
const SettingsRouteChildren: SettingsRouteChildren = {
@@ -1131,6 +1193,8 @@ const SettingsRouteChildren: SettingsRouteChildren = {
SettingsWorkflowRoute: SettingsWorkflowRoute,
SettingsWorkflowsRoute: SettingsWorkflowsRoute,
SettingsIndexRoute: SettingsIndexRoute,
SettingsPublicPagesPageIdRoute: SettingsPublicPagesPageIdRoute,
SettingsPublicPagesIndexRoute: SettingsPublicPagesIndexRoute,
}
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
@@ -1177,6 +1241,7 @@ const rootRouteChildren: RootRouteChildren = {
HooksPollImapRoute: HooksPollImapRoute,
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
InvoicesNewRoute: InvoicesNewRouteWithChildren,
PSlugRoute: PSlugRoute,
PayIdRoute: PayIdRoute,
ArchiveIndexRoute: ArchiveIndexRoute,
CalendarIndexRoute: CalendarIndexRoute,
+218
View File
@@ -0,0 +1,218 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Loader2, Phone, Mail, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/p/$slug")({
component: PublicPagePage,
});
type PageRow = {
id: string; slug: string; title: string; meta_description: string | null; status: string;
};
type SectionRow = { id: string; section_type: string; sort_order: number; content: any };
type FirmRow = {
company_name: string | null; contact_email: string | null; contact_phone: string | null;
address_line1: string | null; address_line2: string | null; city: string | null;
state: string | null; postal_code: string | null; logo_storage_path: string | null;
};
type NavPage = { slug: string; title: string };
function PublicPagePage() {
const { slug } = Route.useParams();
const [loading, setLoading] = useState(true);
const [page, setPage] = useState<PageRow | null>(null);
const [sections, setSections] = useState<SectionRow[]>([]);
const [firm, setFirm] = useState<FirmRow | null>(null);
const [navPages, setNavPages] = useState<NavPage[]>([]);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
const [pRes, fRes, nRes] = await Promise.all([
supabase.from("public_pages").select("*").eq("slug", slug).eq("status", "published").maybeSingle(),
supabase.from("firm_settings").select("*").order("updated_at", { ascending: false }).limit(1).maybeSingle(),
supabase.from("public_pages").select("slug,title").eq("status", "published").eq("show_in_nav", true).order("sort_order", { ascending: true }),
]);
if (pRes.data) {
setPage(pRes.data as PageRow);
const { data: sData } = await supabase.from("public_page_sections")
.select("*").eq("page_id", (pRes.data as PageRow).id)
.order("sort_order", { ascending: true });
setSections((sData ?? []) as SectionRow[]);
}
if (fRes.data) {
setFirm(fRes.data as FirmRow);
if ((fRes.data as FirmRow).logo_storage_path) {
const { data } = supabase.storage.from("firm-logos").getPublicUrl((fRes.data as FirmRow).logo_storage_path!);
setLogoUrl(data.publicUrl);
}
}
setNavPages((nRes.data ?? []) as NavPage[]);
setLoading(false);
if (pRes.data) {
document.title = `${(pRes.data as PageRow).title} | ${(fRes.data as FirmRow | null)?.company_name ?? "Stage Law Firm"}`;
}
})();
}, [slug]);
if (loading) {
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
}
if (!page) {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="text-center">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-sm text-muted-foreground mt-2">This page doesn't exist or hasn't been published yet.</p>
</div>
</div>
);
}
const firmName = firm?.company_name ?? "Stage Law Firm, PLLC";
const cityLine = [firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", ");
return (
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between">
<Link to="/p/$slug" params={{ slug: navPages[0]?.slug ?? slug }} className="flex items-center gap-3">
{logoUrl ? (
<img src={logoUrl} alt={firmName} className="h-12 w-auto" />
) : (
<div className="h-12 w-12 bg-slate-100 rounded flex items-center justify-center text-xs">Logo</div>
)}
<span className="font-serif text-xl tracking-wide uppercase">{firmName}</span>
</Link>
<nav className="hidden md:flex items-center gap-6 text-sm font-medium uppercase tracking-wide">
{navPages.map((np) => (
<Link key={np.slug} to="/p/$slug" params={{ slug: np.slug }} className="hover:text-slate-600">
{np.title}
</Link>
))}
</nav>
</div>
</header>
<main className="flex-1">
{sections.length === 0 ? (
<div className="max-w-3xl mx-auto px-6 py-16">
<h1 className="text-3xl font-serif">{page.title}</h1>
<p className="text-muted-foreground mt-2">No content yet.</p>
</div>
) : sections.map((s) => <SectionRenderer key={s.id} section={s} />)}
</main>
<footer className="bg-[hsl(220_60%_15%)] text-white">
<div className="max-w-6xl mx-auto px-6 py-12 grid md:grid-cols-2 gap-8">
<div className="flex items-start gap-4">
{logoUrl && <img src={logoUrl} alt={firmName} className="h-16 w-auto bg-white/5 rounded p-1" />}
<div>
<h3 className="font-serif text-2xl">{firmName}</h3>
</div>
</div>
<div className="space-y-3 text-sm">
{firm?.contact_phone && (
<div className="flex items-center gap-3"><Phone className="h-4 w-4 opacity-70" /><a href={`tel:${firm.contact_phone}`}>{firm.contact_phone}</a></div>
)}
{firm?.contact_email && (
<div className="flex items-center gap-3"><Mail className="h-4 w-4 opacity-70" /><a href={`mailto:${firm.contact_email}`}>{firm.contact_email}</a></div>
)}
{(firm?.address_line1 || cityLine) && (
<div className="flex items-start gap-3"><MapPin className="h-4 w-4 opacity-70 mt-0.5" />
<div>{firm?.address_line1}{firm?.address_line2 ? <>, {firm.address_line2}</> : null}{cityLine ? <><br />{cityLine}</> : null}</div>
</div>
)}
</div>
</div>
<div className="bg-[hsl(15_60%_55%)] text-white py-3 text-center text-sm">
Copyright {new Date().getFullYear()}. All Rights Reserved.
</div>
</footer>
</div>
);
}
function SectionRenderer({ section }: { section: SectionRow }) {
const c = section.content || {};
switch (section.section_type) {
case "hero":
return (
<section className="bg-slate-50 border-b border-slate-200">
<div className="max-w-4xl mx-auto px-6 py-20 text-center">
{c.eyebrow && <p className="text-xs uppercase tracking-[0.3em] text-slate-500 mb-4">{c.eyebrow}</p>}
<h1 className="font-serif text-4xl md:text-5xl text-[hsl(220_60%_15%)]">{c.heading}</h1>
{c.subheading && <p className="mt-4 text-lg text-slate-600 max-w-2xl mx-auto">{c.subheading}</p>}
</div>
</section>
);
case "rich_text":
return (
<section className="max-w-3xl mx-auto px-6 py-12">
<div className="prose prose-slate max-w-none" dangerouslySetInnerHTML={{ __html: c.html ?? "" }} />
</section>
);
case "feature_grid": {
const items = Array.isArray(c.items) ? c.items : [];
return (
<section className="max-w-5xl mx-auto px-6 py-16">
{c.heading && <h2 className="font-serif text-3xl text-center mb-10 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
<div className="grid md:grid-cols-3 gap-8">
{items.map((it: any, i: number) => (
<div key={i} className="text-center">
<h3 className="font-serif text-xl mb-2">{it.title}</h3>
<p className="text-slate-600 text-sm">{it.body}</p>
</div>
))}
</div>
</section>
);
}
case "cta":
return (
<section className="bg-[hsl(220_60%_15%)] text-white">
<div className="max-w-3xl mx-auto px-6 py-16 text-center">
<h2 className="font-serif text-3xl">{c.heading}</h2>
{c.subheading && <p className="mt-3 text-white/80">{c.subheading}</p>}
{c.button_label && c.button_url && (
<Button asChild size="lg" className="mt-6 bg-white text-[hsl(220_60%_15%)] hover:bg-white/90">
<a href={c.button_url}>{c.button_label}</a>
</Button>
)}
</div>
</section>
);
case "contact": {
return (
<section className="max-w-3xl mx-auto px-6 py-16">
{c.heading && <h2 className="font-serif text-3xl text-center mb-8 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
<div className="grid sm:grid-cols-3 gap-6 text-center">
{c.phone && <div><Phone className="h-5 w-5 mx-auto mb-2 text-slate-500" /><a href={`tel:${c.phone}`} className="font-medium">{c.phone}</a></div>}
{c.email && <div><Mail className="h-5 w-5 mx-auto mb-2 text-slate-500" /><a href={`mailto:${c.email}`} className="font-medium">{c.email}</a></div>}
{c.address && <div><MapPin className="h-5 w-5 mx-auto mb-2 text-slate-500" /><div className="font-medium whitespace-pre-line">{c.address}</div></div>}
</div>
</section>
);
}
case "faq": {
const items = Array.isArray(c.items) ? c.items : [];
return (
<section className="max-w-3xl mx-auto px-6 py-16">
{c.heading && <h2 className="font-serif text-3xl text-center mb-8 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
<div className="space-y-4">
{items.map((it: any, i: number) => (
<div key={i} className="border-b border-slate-200 pb-4">
<h3 className="font-serif text-lg mb-2">{it.q}</h3>
<p className="text-slate-600 text-sm">{it.a}</p>
</div>
))}
</div>
</section>
);
}
default: return null;
}
}
@@ -0,0 +1,351 @@
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>;
}
}
+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>
);
}
+1
View File
@@ -18,6 +18,7 @@ const TABS = [
{ to: "/settings/form-templates", label: "Form templates" },
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
{ to: "/settings/public-pages", label: "Public pages" },
{ to: "/settings/smtp", label: "Email (SMTP)" },
{ to: "/settings/imap", label: "Email (IMAP)" },
{ to: "/settings/import", label: "CSV import" },
@@ -0,0 +1,117 @@
-- Public pages CMS tables
CREATE TABLE public.public_pages (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
meta_description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
sort_order INTEGER NOT NULL DEFAULT 0,
show_in_nav BOOLEAN NOT NULL DEFAULT true,
published_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_public_pages_slug ON public.public_pages(slug);
CREATE INDEX idx_public_pages_status ON public.public_pages(status);
CREATE TABLE public.public_page_sections (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
page_id UUID NOT NULL REFERENCES public.public_pages(id) ON DELETE CASCADE,
section_type TEXT NOT NULL CHECK (section_type IN ('hero','rich_text','feature_grid','cta','contact','faq')),
sort_order INTEGER NOT NULL DEFAULT 0,
content JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_public_page_sections_page_id ON public.public_page_sections(page_id);
-- Triggers for updated_at
CREATE TRIGGER trg_public_pages_updated_at
BEFORE UPDATE ON public.public_pages
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE TRIGGER trg_public_page_sections_updated_at
BEFORE UPDATE ON public.public_page_sections
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Enable RLS
ALTER TABLE public.public_pages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.public_page_sections ENABLE ROW LEVEL SECURITY;
-- Public can read published pages
CREATE POLICY "Anyone can view published pages"
ON public.public_pages FOR SELECT
USING (status = 'published');
CREATE POLICY "Admins can view all pages"
ON public.public_pages FOR SELECT
TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY "Admins can insert pages"
ON public.public_pages FOR INSERT
TO authenticated
WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY "Admins can update pages"
ON public.public_pages FOR UPDATE
TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY "Admins can delete pages"
ON public.public_pages FOR DELETE
TO authenticated
USING (public.is_admin(auth.uid()));
-- Sections inherit access from page
CREATE POLICY "Anyone can view sections of published pages"
ON public.public_page_sections FOR SELECT
USING (EXISTS (SELECT 1 FROM public.public_pages p WHERE p.id = page_id AND p.status = 'published'));
CREATE POLICY "Admins can view all sections"
ON public.public_page_sections FOR SELECT
TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY "Admins can insert sections"
ON public.public_page_sections FOR INSERT
TO authenticated
WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY "Admins can update sections"
ON public.public_page_sections FOR UPDATE
TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY "Admins can delete sections"
ON public.public_page_sections FOR DELETE
TO authenticated
USING (public.is_admin(auth.uid()));
-- Storage bucket for inline images
INSERT INTO storage.buckets (id, name, public)
VALUES ('public-page-images', 'public-page-images', true)
ON CONFLICT (id) DO NOTHING;
CREATE POLICY "Public page images are publicly accessible"
ON storage.objects FOR SELECT
USING (bucket_id = 'public-page-images');
CREATE POLICY "Admins can upload public page images"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));
CREATE POLICY "Admins can update public page images"
ON storage.objects FOR UPDATE
TO authenticated
USING (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));
CREATE POLICY "Admins can delete public page images"
ON storage.objects FOR DELETE
TO authenticated
USING (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));