changemaker.lite/admin/src/pages/LandingPagesPage.tsx

654 lines
21 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Drawer,
Form,
Popconfirm,
message,
Row,
Col,
Radio,
Checkbox,
Divider,
Grid,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
EyeOutlined,
SettingOutlined,
SyncOutlined,
BuildOutlined,
ExclamationCircleOutlined,
QrcodeOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext, useLocation } from 'react-router-dom';
import { api } from '@/lib/api';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
import QrCodeModal from '@/components/QrCodeModal';
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams, AppOutletContext } from '@/types/api';
const { TextArea } = Input;
const publishedOptions = [
{ value: 'true', label: 'Published' },
{ value: 'false', label: 'Draft' },
];
export default function LandingPagesPage() {
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const location = useLocation();
const [pages, setPages] = useState<LandingPage[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [syncing, setSyncing] = useState(false);
const [validating, setValidating] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
const [editingPageId, setEditingPageId] = useState<string | null>(null);
const [qrPage, setQrPage] = useState<LandingPage | null>(null);
const [viewCounts, setViewCounts] = useState<Record<string, number>>({});
const [createForm] = Form.useForm();
const [settingsForm] = Form.useForm();
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
// Handle navigation state from command palette — auto-open editor for a page
useEffect(() => {
const editPageId = (location.state as { editPageId?: string } | null)?.editPageId;
if (!editPageId) return;
setEditingPageId(editPageId);
window.history.replaceState({}, '');
}, [location.state]);
const fetchPages = useCallback(async (params?: LandingPagesListParams) => {
setLoading(true);
try {
const { data } = await api.get<LandingPagesListResponse>('/pages', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
published: params?.published ?? publishedFilter,
},
});
setPages(data.pages);
setPagination(data.pagination);
} catch {
message.error('Failed to load pages');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, publishedFilter]);
useEffect(() => {
fetchPages({ page: 1 });
}, [debouncedSearch, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
api.get<Record<string, number>>('/pages/view-counts')
.then(({ data }) => setViewCounts(data))
.catch(() => {});
}, []);
const handleTableChange = (pag: TablePaginationConfig) => {
fetchPages({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: { title: string; description?: string; editorMode?: EditorMode }) => {
try {
const { data } = await api.post<LandingPage>('/pages', values);
message.success('Page created');
setCreateDrawerOpen(false);
createForm.resetFields();
setEditingPageId(data.id);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create page';
message.error(msg);
}
};
const handleSyncOverrides = async () => {
setSyncing(true);
try {
const { data } = await api.post<{ imported: number; updated: number; stubs: number }>('/pages/sync');
if (data.imported > 0 || data.updated > 0 || data.stubs > 0) {
message.success(`Synced: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`);
fetchPages();
} else {
message.info('No new overrides to sync');
}
} catch {
message.error('Failed to sync overrides');
} finally {
setSyncing(false);
}
};
const handleValidateExports = async () => {
setValidating(true);
try {
const { data } = await api.post<{validated: number; repaired: number; errors: Array<{pageId: string; slug: string; error: string}>}>('/pages/validate');
if (data.repaired > 0 || data.errors.length > 0) {
const msg = `Validated ${data.validated} pages: ${data.repaired} repaired`;
data.errors.length > 0 ? message.warning(`${msg}, ${data.errors.length} errors`) : message.success(msg);
fetchPages();
} else {
message.info(`Validated ${data.validated} pages - all OK`);
}
} catch {
message.error('Failed to validate exports');
} finally {
setValidating(false);
}
};
const handleSettingsSave = async (values: Record<string, unknown>) => {
if (!editingPage) return;
try {
await api.put(`/pages/${editingPage.id}`, values);
message.success('Page settings updated');
setSettingsDrawerOpen(false);
setEditingPage(null);
settingsForm.resetFields();
fetchPages();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update page';
message.error(msg);
}
};
const handleTogglePublished = async (page: LandingPage) => {
try {
await api.put(`/pages/${page.id}`, { published: !page.published });
message.success(page.published ? 'Page unpublished' : 'Page published');
fetchPages();
} catch {
message.error('Failed to update page');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/pages/${id}`);
message.success('Page deleted');
fetchPages();
} catch {
message.error('Failed to delete page');
}
};
// Critical MkDocs override pages that need a confirmation before editing
const CRITICAL_SLUGS = ['main', 'custom'];
const handleEditClick = (page: LandingPage) => {
if (CRITICAL_SLUGS.includes(page.slug)) {
Modal.confirm({
title: 'Edit critical MkDocs template?',
icon: <ExclamationCircleOutlined />,
content: (
<div>
<p>
<strong>{page.title}</strong> ({page.mkdocsPath || page.slug}) is a critical MkDocs
override template. Changes to this file affect the entire documentation site.
</p>
<p>Are you sure you want to edit it?</p>
</div>
),
okText: 'Edit',
okType: 'danger',
cancelText: 'Cancel',
onOk: () => setEditingPageId(page.id),
});
} else {
setEditingPageId(page.id);
}
};
const openSettings = (page: LandingPage) => {
setEditingPage(page);
settingsForm.setFieldsValue({
title: page.title,
description: page.description,
listed: page.listed ?? false,
mkdocsPath: page.mkdocsPath,
mkdocsExportMode: page.mkdocsExportMode,
mkdocsHideNav: page.mkdocsHideNav,
mkdocsHideToc: page.mkdocsHideToc,
mkdocsSkipExport: page.mkdocsSkipExport,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
seoImage: page.seoImage,
});
setSettingsDrawerOpen(true);
};
const columns: ColumnsType<LandingPage> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record: LandingPage) => (
<div>
<span style={{ fontWeight: 500 }}>{title}</span>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/p/{record.slug}</div>
</div>
),
},
{
title: 'Editor',
dataIndex: 'editorMode',
key: 'editorMode',
render: (mode: EditorMode) => (
<Tag color={mode === 'VISUAL' ? 'green' : 'blue'}>
{mode === 'VISUAL' ? 'Visual' : 'Code'}
</Tag>
),
responsive: ['sm'],
},
{
title: 'Status',
dataIndex: 'published',
key: 'published',
render: (published: boolean, record: LandingPage) => (
<Space size={4}>
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
{record.listed && <Tag color="blue">Listed</Tag>}
</Space>
),
},
{
title: 'MkDocs',
dataIndex: 'mkdocsPath',
key: 'mkdocsPath',
render: (_: string | null, record: LandingPage) => (
<div>
<div>{record.mkdocsPath || '--'}</div>
{record.mkdocsStubPath && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{record.mkdocsStubPath}</div>
)}
</div>
),
responsive: ['lg'],
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Updated',
dataIndex: 'updatedAt',
key: 'updatedAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Views (30d)',
key: 'views',
render: (_: unknown, record: LandingPage) => viewCounts[record.slug] ?? 0,
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: LandingPage) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditClick(record)}
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
/>
<Button
type="link"
size="small"
icon={<SettingOutlined />}
onClick={() => openSettings(record)}
title="Page settings"
/>
{record.published && (
<>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/p/${record.slug}`, '_blank')}
title="View page"
/>
<Button
type="link"
size="small"
icon={<QrcodeOutlined />}
onClick={() => setQrPage(record)}
title="QR code"
/>
</>
)}
{record.published ? (
<Popconfirm
title="Unpublish this page?"
description="It will no longer be publicly accessible."
onConfirm={() => handleTogglePublished(record)}
>
<Button type="link" size="small">
Unpublish
</Button>
</Popconfirm>
) : (
<Button
type="link"
size="small"
onClick={() => handleTogglePublished(record)}
>
Publish
</Button>
)}
<Popconfirm
title="Delete this page?"
description="This action cannot be undone."
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
</Popconfirm>
</Space>
),
},
];
// Set fullBleed when editor is open, title when in list mode
useEffect(() => {
if (editingPageId) {
setPageHeader({ fullBleed: true });
} else {
setPageHeader({ title: 'Landing Pages' });
}
return () => setPageHeader(null);
}, [editingPageId, setPageHeader]);
// If editing a page, show the editor instead of the list
if (editingPageId) {
return (
<LandingPageEditor
pageId={editingPageId}
onClose={() => {
setEditingPageId(null);
fetchPages(); // Refresh table data
}}
/>
);
}
const anyDrawerOpen = createDrawerOpen || settingsDrawerOpen;
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 520 : settingsDrawerOpen ? 560 : 0);
return (
<>
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Row justify="end" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space>
{isSuperAdmin && (
<Button
icon={<BuildOutlined />}
loading={building}
onClick={confirmAndBuild}
>
Build Site
</Button>
)}
<Button
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSyncOverrides}
>
Sync Overrides
</Button>
<Button
icon={<SyncOutlined spin={validating} />}
loading={validating}
onClick={handleValidateExports}
title="Validate MkDocs export files and repair if missing"
>
Validate Exports
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateDrawerOpen(true)}
>
Create Page
</Button>
</Space>
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={publishedOptions}
value={publishedFilter}
onChange={setPublishedFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<LandingPage>
columns={columns}
dataSource={pages}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} pages`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
/>
</div>
{/* Create Drawer */}
<Drawer
title="Create Landing Page"
open={createDrawerOpen}
destroyOnHidden
mask={false}
width={isMobile ? '100%' : 520}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setCreateDrawerOpen(false);
createForm.resetFields();
}}
extra={
<Space>
<Button onClick={() => { setCreateDrawerOpen(false); createForm.resetFields(); }}>
Cancel
</Button>
<Button type="primary" onClick={() => createForm.submit()}>
Create & Edit
</Button>
</Space>
}
>
<Form form={createForm} onFinish={handleCreate} layout="vertical" initialValues={{ editorMode: 'VISUAL' }}>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="e.g. Join Our Campaign" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={3} />
</Form.Item>
<Form.Item name="editorMode" label="Editor Mode">
<Radio.Group>
<Radio.Button value="VISUAL">Visual Editor</Radio.Button>
<Radio.Button value="CODE">Code Editor</Radio.Button>
</Radio.Group>
</Form.Item>
</Form>
</Drawer>
{/* QR Code Modal */}
{qrPage && (
<QrCodeModal
open={!!qrPage}
onClose={() => setQrPage(null)}
url={`${window.location.origin}/p/${qrPage.slug}`}
title={qrPage.title}
/>
)}
{/* Settings Drawer */}
<Drawer
title="Page Settings"
open={settingsDrawerOpen}
destroyOnHidden
mask={false}
width={isMobile ? '100%' : 560}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setSettingsDrawerOpen(false);
setEditingPage(null);
settingsForm.resetFields();
}}
extra={
<Space>
<Button onClick={() => { setSettingsDrawerOpen(false); setEditingPage(null); settingsForm.resetFields(); }}>
Cancel
</Button>
<Button type="primary" onClick={() => settingsForm.submit()}>
Save
</Button>
</Space>
}
>
<Form form={settingsForm} onFinish={handleSettingsSave} layout="vertical">
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="e.g. Join Our Campaign" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="seoTitle" label="SEO Title">
<Input />
</Form.Item>
<Form.Item name="seoDescription" label="SEO Description">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="seoImage" label="SEO Image URL">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item
name="listed"
valuePropName="checked"
help="Show this page in the public /pages directory when published."
>
<Checkbox>List in Pages Index</Checkbox>
</Form.Item>
<Divider>MkDocs Integration</Divider>
<Form.Item
name="mkdocsSkipExport"
valuePropName="checked"
help="When enabled, this page will not be exported to MkDocs even when published. Use for pages that should only be accessible via /p/:slug."
>
<Checkbox>Skip MkDocs Export</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mkdocsSkipExport !== cur.mkdocsSkipExport}>
{({ getFieldValue }) =>
!getFieldValue('mkdocsSkipExport') && (
<>
<Form.Item name="mkdocsPath" label="Override Path">
<Input placeholder="e.g. about.html" />
</Form.Item>
<Form.Item
name="mkdocsExportMode"
valuePropName="checked"
getValueFromEvent={(e: { target: { checked: boolean } }) => e.target.checked ? 'STANDALONE' : 'THEMED'}
getValueProps={(value: string) => ({ checked: value === 'STANDALONE' })}
help="Publish as a full HTML page with no MkDocs header, footer, or theme (like lander.html)"
>
<Checkbox>Full page MkDocs</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mkdocsExportMode !== cur.mkdocsExportMode}>
{({ getFieldValue }) =>
getFieldValue('mkdocsExportMode') !== 'STANDALONE' && (
<>
<Form.Item name="mkdocsHideNav" valuePropName="checked">
<Checkbox>Hide navigation sidebar</Checkbox>
</Form.Item>
<Form.Item name="mkdocsHideToc" valuePropName="checked">
<Checkbox>Hide table of contents</Checkbox>
</Form.Item>
</>
)
}
</Form.Item>
</>
)
}
</Form.Item>
</Form>
</Drawer>
</>
);
}