feat: overhaul public landing page and backup tooling
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
|
||||
export const CMSPageEnhanced: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +21,11 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
|
||||
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
@@ -26,6 +33,16 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
|
||||
queryKey: ['public-site-defaults'],
|
||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
||||
});
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
@@ -45,6 +62,53 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmedHtml = publicSiteHtml.trim();
|
||||
if (publicSiteEnabled && !trimmedHtml) {
|
||||
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
|
||||
}
|
||||
|
||||
await settingsService.updatePublicSite({
|
||||
enabled: publicSiteEnabled,
|
||||
html: trimmedHtml || '',
|
||||
css: publicSiteCss,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(t('settings.publicSite.saveSuccess'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
|
||||
toast.error(t('settings.publicSite.htmlRequired'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('settings.publicSite.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteResetMutation = useMutation({
|
||||
mutationFn: () => settingsService.resetPublicSite(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('settings.publicSite.resetSuccess'));
|
||||
setPublicSiteHtml(data.html || '');
|
||||
setPublicSiteCss(data.css || '');
|
||||
setPublicSiteBaseCss(data.baseCss || '');
|
||||
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.publicSite.resetError'));
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save functionality
|
||||
const autoSave = useCallback(
|
||||
debounce(() => {
|
||||
@@ -59,6 +123,23 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
[hasUnsavedChanges, editForm, selectedPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicSiteDefaults) {
|
||||
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
|
||||
setPublicSiteBranding(publicSiteDefaults.branding);
|
||||
}
|
||||
}, [publicSiteDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
|
||||
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
|
||||
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
|
||||
}, [adminSettings]);
|
||||
|
||||
// Trigger auto-save when content changes
|
||||
useEffect(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
@@ -113,6 +194,126 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
||||
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
|
||||
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_ATTR: ['data-*'],
|
||||
}), [publicSiteHtml]);
|
||||
|
||||
const sanitizeCss = (css: string) => {
|
||||
if (!css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
};
|
||||
|
||||
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
|
||||
|
||||
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
|
||||
if (!html || !branding) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens: Record<string, string> = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
|
||||
};
|
||||
|
||||
const publicSitePreview = useMemo(() => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
|
||||
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
|
||||
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
|
||||
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
|
||||
|
||||
const displayName = branding?.companyName || 'Celebration Stories';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#collections">Collections</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#stories">Stories</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
${substitutedHtml}
|
||||
</main>
|
||||
<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<span>${support}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
|
||||
|
||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -121,8 +322,6 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -130,6 +329,135 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary-600 mb-1">
|
||||
<Globe className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={publicSiteEnabled}
|
||||
onChange={() => setPublicSiteEnabled((prev) => !prev)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{publicSiteLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<Loading size="lg" text={t('settings.publicSite.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.htmlLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteHtml}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.htmlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.cssLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteCss}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.cssHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => publicSiteSaveMutation.mutate()}
|
||||
disabled={publicSiteSaveMutation.isPending}
|
||||
isLoading={publicSiteSaveMutation.isPending}
|
||||
>
|
||||
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => publicSiteResetMutation.mutate()}
|
||||
disabled={publicSiteResetMutation.isPending}
|
||||
isLoading={publicSiteResetMutation.isPending}
|
||||
>
|
||||
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
||||
{t('settings.publicSite.previewTitle')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
||||
</div>
|
||||
{publicSiteEnabled ? (
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||
<iframe
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[480px] bg-white"
|
||||
srcDoc={publicSitePreview}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
||||
{t('settings.publicSite.previewDisabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
@@ -287,4 +615,4 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user