Add pagination to public endpoints, Pangolin site picker, and docs editor toolbar

- Paginate public APIs: campaigns, petitions, shifts, products, pages, shop
- Add safety caps (take limits) to gallery ads, cuts, plans, donation pages
- Add Pangolin connect-site endpoint with .env writer and site ID validation
- Add formatting toolbar + keyboard shortcuts to shared doc editor
- Fix Dockerfile to support su-exec privilege dropping for mounted volumes
- Fix duplicate WebSocket headers in nginx API location block
- Update MkDocs site build and social card assets

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-07 16:50:20 -06:00
parent 513b8cfea5
commit d010993994
338 changed files with 6579 additions and 11740 deletions

View File

@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { Button, Dropdown, Tooltip } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
StrikethroughOutlined,
HighlightOutlined,
CodeOutlined,
FontSizeOutlined,
AlertOutlined,
PlusOutlined,
DownOutlined,
LinkOutlined,
FileMarkdownOutlined,
TableOutlined,
} from '@ant-design/icons';
import type { editor as monacoEditor } from 'monaco-editor';
import { SNIPPETS, PLATFORM_INSERT_IDS, applySnippet } from './mkdocs-snippets';
interface DocsEditorToolbarProps {
editorRef: React.RefObject<monacoEditor.IStandaloneCodeEditor | null>;
monacoRef: React.RefObject<typeof import('monaco-editor') | null>;
/** If true, show platform-specific inserts (video card, donate, etc.) */
showPlatformInserts?: boolean;
/** Custom handler for snippet IDs that need special treatment (modals, etc.) */
onCustomSnippet?: (snippetId: string) => boolean;
/** Background color — defaults to transparent */
background?: string;
/** Border color — defaults to rgba(255,255,255,0.08) */
borderColor?: string;
}
export default function DocsEditorToolbar({
editorRef,
monacoRef,
showPlatformInserts = false,
onCustomSnippet,
background = 'transparent',
borderColor = 'rgba(255,255,255,0.08)',
}: DocsEditorToolbarProps) {
const handleSnippet = useCallback((snippetId: string) => {
if (onCustomSnippet?.(snippetId)) return;
const snippet = SNIPPETS.find(s => s.id === snippetId);
if (!snippet || !editorRef.current || !monacoRef.current) return;
applySnippet(editorRef.current, snippet, monacoRef.current);
}, [editorRef, monacoRef, onCustomSnippet]);
const insertSnippets = SNIPPETS.filter(s =>
s.group === 'insert' && (showPlatformInserts || !PLATFORM_INSERT_IDS.has(s.id))
);
const getInsertIcon = (id: string) => {
if (id === 'link') return <LinkOutlined />;
if (id === 'image') return <FileMarkdownOutlined />;
if (id === 'table') return <TableOutlined />;
return <PlusOutlined />;
};
const btnStyle = { width: 26, height: 24 };
return (
<div
style={{
height: 28,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
background,
borderBottom: `1px solid ${borderColor}`,
gap: 2,
flexShrink: 0,
overflow: 'hidden',
}}
>
<Tooltip title="Bold (Ctrl+B)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => handleSnippet('bold')} style={btnStyle} />
</Tooltip>
<Tooltip title="Italic (Ctrl+I)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => handleSnippet('italic')} style={btnStyle} />
</Tooltip>
<Tooltip title="Strikethrough" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<StrikethroughOutlined />} onClick={() => handleSnippet('strikethrough')} style={btnStyle} />
</Tooltip>
<Tooltip title="Highlight" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<HighlightOutlined />} onClick={() => handleSnippet('highlight')} style={btnStyle} />
</Tooltip>
<Tooltip title="Inline Code" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => handleSnippet('inline-code')} style={btnStyle} />
</Tooltip>
<Tooltip title="Keyboard Key" mouseEnterDelay={0.4}>
<Button type="text" size="small" style={{ ...btnStyle, fontSize: 11, fontWeight: 700 }} onClick={() => handleSnippet('kbd')}>K</Button>
</Tooltip>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'heading').map(s => ({ key: s.id, label: s.label, icon: <FontSizeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<FontSizeOutlined /> H <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'admonition').map(s => ({ key: s.id, label: s.label, icon: <AlertOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<AlertOutlined /> Admonitions <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'code').map(s => ({ key: s.id, label: s.label, icon: <CodeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<CodeOutlined /> Code <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: insertSnippets.map(s => ({
key: s.id,
label: s.label,
icon: getInsertIcon(s.id),
onClick: () => handleSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<PlusOutlined /> Insert <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
</div>
);
}

View File

@ -0,0 +1,117 @@
import type { editor as monacoEditor } from 'monaco-editor';
export interface MkDocsSnippet {
id: string;
label: string;
group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';
type: 'wrap' | 'block' | 'insert';
prefix?: string;
suffix?: string;
template?: string;
keybinding?: 'ctrl+b' | 'ctrl+i';
}
export const SNIPPETS: MkDocsSnippet[] = [
// Formatting
{ id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },
{ id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },
{ id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' },
{ id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' },
{ id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' },
{ id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' },
{ id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },
{ id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' },
{ id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' },
// Admonitions
...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({
id: `admonition-${t}`,
label: `${t.charAt(0).toUpperCase() + t.slice(1)}`,
group: 'admonition' as const,
type: 'block' as const,
template: `!!! ${t} "Title"\n Content here`,
})),
{ id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' },
{ id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' },
// Code
{ id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' },
{ id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' },
// Inserts (standard markdown — no auth required)
{ id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },
{ id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },
{ id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' },
{ id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' },
{ id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' },
{ id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' },
{ id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' },
{ id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' },
{ id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' },
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
// Platform-specific inserts (require auth — handled by DocsPage modals)
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
{ id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' },
];
/** IDs of insert snippets that require authenticated API access (modal-based) */
export const PLATFORM_INSERT_IDS = new Set([
'video-card', 'photo-insert', 'donate-button', 'pricing-table',
'product-card', 'ad-insert', 'scheduling-poll', 'wiki-link',
]);
export function applySnippet(
ed: monacoEditor.IStandaloneCodeEditor,
snippet: MkDocsSnippet,
monaco: typeof import('monaco-editor'),
) {
const sel = ed.getSelection();
const model = ed.getModel();
if (!sel || !model) return;
const selectedText = model.getValueInRange(sel);
if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {
if (selectedText) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + selectedText + snippet.suffix,
}]);
} else {
const placeholder = 'text';
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + placeholder + snippet.suffix,
}]);
const pos = sel.getStartPosition();
const startCol = pos.column + snippet.prefix.length;
ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));
}
} else if (snippet.type === 'block' && snippet.template) {
const pos = sel.getStartPosition();
let text = snippet.template.replace('$CURSOR', selectedText);
const lineContent = model.getLineContent(pos.lineNumber);
if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) {
text = '\n' + text;
}
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text,
}]);
} else if (snippet.type === 'insert' && snippet.template) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.template,
}]);
}
ed.focus();
}

View File

@ -35,8 +35,8 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
if (open && products.length === 0) {
setLoading(true);
setError(null);
axios.get('/api/payments/products')
.then(({ data }) => setProducts(data))
axios.get('/api/payments/products', { params: { limit: 50 } })
.then(({ data }) => setProducts(data.products))
.catch(() => setError('Failed to load products'))
.finally(() => setLoading(false));
}

View File

@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
return;
}
axios.get('/api/payments/products')
axios.get('/api/payments/products', { params: { limit: 50 } })
.then(({ data }) => {
const found = (data as Product[]).find(p => p.slug === productSlug);
const found = (data.products as Product[]).find(p => p.slug === productSlug);
if (found) {
setProduct(found);
} else {

View File

@ -329,68 +329,9 @@ function filterTree(nodes: FileNode[], query: string): FileNode[] {
return filtered;
}
// --- MkDocs Snippet System ---
interface MkDocsSnippet {
id: string;
label: string;
group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';
type: 'wrap' | 'block' | 'insert';
prefix?: string;
suffix?: string;
template?: string;
keybinding?: 'ctrl+b' | 'ctrl+i';
}
const SNIPPETS: MkDocsSnippet[] = [
// Formatting
{ id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },
{ id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },
{ id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' },
{ id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' },
{ id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' },
{ id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' },
{ id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },
{ id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' },
{ id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' },
// Admonitions
...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({
id: `admonition-${t}`,
label: `${t.charAt(0).toUpperCase() + t.slice(1)}`,
group: 'admonition' as const,
type: 'block' as const,
template: `!!! ${t} "Title"\n Content here`,
})),
{ id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' },
{ id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' },
// Code
{ id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' },
{ id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' },
// Inserts
{ id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },
{ id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },
{ id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' },
{ id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' },
{ id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' },
{ id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' },
{ id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' },
{ id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' },
{ id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' },
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
{ id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
];
// --- MkDocs Snippet System (shared) ---
import { SNIPPETS, applySnippet as applySnippetShared } from '@/components/docs/mkdocs-snippets';
import type { MkDocsSnippet } from '@/components/docs/mkdocs-snippets';
// --- Inline Donate Block Generator ---
// Produces HTML with data-role attributes for hydration by payment-widgets.js.
@ -523,55 +464,7 @@ function generateInlineProductHtml(o: InlineProductOpts): string {
return lines.filter(Boolean).join('\n');
}
function applySnippet(
ed: monacoEditor.IStandaloneCodeEditor,
snippet: MkDocsSnippet,
monaco: typeof import('monaco-editor'),
) {
const sel = ed.getSelection();
const model = ed.getModel();
if (!sel || !model) return;
const selectedText = model.getValueInRange(sel);
if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {
if (selectedText) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + selectedText + snippet.suffix,
}]);
} else {
const placeholder = 'text';
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + placeholder + snippet.suffix,
}]);
// Select the placeholder so user can type over it
const pos = sel.getStartPosition();
const startCol = pos.column + snippet.prefix.length;
ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));
}
} else if (snippet.type === 'block' && snippet.template) {
const pos = sel.getStartPosition();
let text = snippet.template.replace('$CURSOR', selectedText);
// If cursor is in the middle of a line, prepend newline
const lineContent = model.getLineContent(pos.lineNumber);
if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) {
text = '\n' + text;
}
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text,
}]);
} else if (snippet.type === 'insert' && snippet.template) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.template,
}]);
}
ed.focus();
}
const applySnippet = applySnippetShared;
/** Wrapper component so useDocsEditor() hook only runs on mobile */
function MobileDocsEditorWrapper() {
@ -1648,9 +1541,9 @@ export default function DocsPage() {
{isSuperAdmin && (
<>
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
<Tooltip title="Build static site">
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
</Tooltip>
<Button type="primary" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle">
Build
</Button>
</>
)}
</Space>

View File

@ -304,14 +304,14 @@ export default function MkDocsSettingsPage() {
const [configRes, filesRes, campaignsRes] = await Promise.all([
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
api.get<FileNode[]>('/docs/files'),
api.get<Campaign[]>('/campaigns/public').catch(() => ({ data: [] as Campaign[] })),
api.get('/campaigns/public', { params: { limit: 50 } }).catch(() => ({ data: { campaigns: [] } })),
]);
const content = configRes.data.content;
setRawYaml(content);
setOriginalYaml(content);
setEditorYaml(content);
setFileTree(filesRes.data);
setCampaigns(campaignsRes.data);
setCampaigns(campaignsRes.data.campaigns);
// Parse for settings tab
syncSettingsFromYaml(content);

View File

@ -12,7 +12,7 @@ import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
PangolinStatus, PangolinConfig, PangolinResource, PangolinNewtStatus, PangolinExitNode,
ResourceStatusResponse, ResourceStatusItem, SyncResult,
PangolinSite, ConnectSiteResult, ResourceStatusResponse, ResourceStatusItem, SyncResult,
} from '@/types/api';
const { Text, Paragraph } = Typography;
@ -90,6 +90,9 @@ export default function PangolinPage() {
const [resourceStatus, setResourceStatus] = useState<ResourceStatusResponse | null>(null);
const [statusLoading, setStatusLoading] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [orgSites, setOrgSites] = useState<(PangolinSite & { isCurrentSite?: boolean })[]>([]);
const [sitesLoading, setSitesLoading] = useState(false);
const [connectLoading, setConnectLoading] = useState<string | null>(null); // siteId being connected
useEffect(() => {
setPageHeader({ title: 'Tunnel Management' });
@ -155,6 +158,46 @@ export default function PangolinPage() {
}
}, [status?.configured, config?.siteId, fetchResourceStatus]);
// Fetch org sites when site ID is stale/missing (for site picker)
const fetchOrgSites = useCallback(async () => {
setSitesLoading(true);
try {
const res = await api.get<{ sites: (PangolinSite & { isCurrentSite?: boolean })[]; currentNewtId: string | null }>('/pangolin/sites');
setOrgSites(res.data.sites);
} catch {
message.error('Failed to load sites from Pangolin');
} finally {
setSitesLoading(false);
}
}, [message]);
useEffect(() => {
// Load org sites when: configured but site ID is stale/missing (for site picker)
if (status?.configured && (status?.siteIdMismatch || !config?.siteId)) {
fetchOrgSites();
}
}, [status?.configured, status?.siteIdMismatch, config?.siteId, fetchOrgSites]);
const handleConnectSite = async (siteId: string) => {
setConnectLoading(siteId);
try {
const res = await api.post<ConnectSiteResult>('/pangolin/connect-site', { siteId });
if (res.data.success) {
message.success(res.data.message);
// Refresh everything after connecting
setTimeout(() => {
fetchData();
fetchNewtStatus();
}, 1500);
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ?? 'Failed to connect to site';
message.error(msg);
} finally {
setConnectLoading(null);
}
};
// Fetch exit nodes for site creation
useEffect(() => {
if (status?.configured && !config?.siteId) {
@ -312,11 +355,105 @@ export default function PangolinPage() {
<Text code>{config?.orgId || 'Not set'}</Text>
</Descriptions.Item>
<Descriptions.Item label="Site ID">
<Text code>{config?.siteId || 'Not set'}</Text>
<Space>
<Text code>{config?.siteId || 'Not set'}</Text>
{status?.siteIdMismatch && (
<Tag icon={<ExclamationCircleOutlined />} color="warning">Stale</Tag>
)}
{status?.siteIdValid === true && (
<Tag icon={<CheckCircleOutlined />} color="success">Valid</Tag>
)}
</Space>
</Descriptions.Item>
</Descriptions>
</Card>
{/* Site Picker — shown when site ID is stale or mismatched */}
{isConfigured && status?.siteIdMismatch && (
<Card title={<><ExclamationCircleOutlined /> Site ID Mismatch</>}>
<Alert
type="warning"
showIcon
message="Your PANGOLIN_SITE_ID no longer matches this organization"
description={
<div>
<Paragraph>
The site ID <Text code>{config?.siteId}</Text> in your <Text code>.env</Text> file
{status?.resolvedSiteId
? <> does not match the detected site <Text code>{status.resolvedSiteId}</Text>.</>
: <> was not found in the organization.</>
}
{' '}The Newt tunnel may still be working, but resource management (sync, status checks) will fail.
</Paragraph>
<Paragraph>Select the correct site below to fix this:</Paragraph>
</div>
}
style={{ marginBottom: 16 }}
/>
<Table<PangolinSite & { isCurrentSite?: boolean }>
dataSource={orgSites}
rowKey="siteId"
size="small"
loading={sitesLoading}
pagination={false}
columns={[
{
title: 'Site Name',
dataIndex: 'name',
key: 'name',
render: (name: string, record) => (
<Space>
<Text strong>{sanitizeText(name)}</Text>
{record.isCurrentSite && <Tag color="blue">Matches Newt ID</Tag>}
</Space>
),
},
{
title: 'Site ID',
dataIndex: 'siteId',
key: 'siteId',
render: (id: string) => <Text code>{id}</Text>,
},
...(!isMobile ? [{
title: 'Status',
key: 'online',
width: 100,
render: (_: unknown, record: PangolinSite) =>
record.online
? <Tag color="success">Online</Tag>
: <Tag color="default">Offline</Tag>,
}] : []),
...(!isMobile ? [{
title: 'Last Seen',
dataIndex: 'lastSeen',
key: 'lastSeen',
render: (d: string) => d ? new Date(d).toLocaleString() : '—',
}] : []),
{
title: '',
key: 'action',
width: 120,
render: (_: unknown, record: PangolinSite) => (
<Button
type={record.isCurrentSite ? 'primary' : 'default'}
size="small"
loading={connectLoading === record.siteId}
onClick={() => handleConnectSite(record.siteId)}
>
Connect
</Button>
),
},
]}
/>
<div style={{ marginTop: 12 }}>
<Button icon={<ReloadOutlined />} loading={sitesLoading} onClick={fetchOrgSites} size="small">
Refresh Sites
</Button>
</div>
</Card>
)}
{/* Setup Card — shown when API credentials configured but no site yet */}
{isConfigured && !config?.siteId && (
<Card title={<><RocketOutlined /> Automated Setup</>}>
@ -437,6 +574,68 @@ export default function PangolinPage() {
},
]}
/>
{/* Connect to existing site — shown when org already has sites */}
{orgSites.length > 0 && (
<>
<Paragraph strong style={{ marginTop: 24 }}>Or Connect to an Existing Site</Paragraph>
<Alert
type="info"
message="Sites already exist in this organization. You can connect to one instead of creating a new one."
style={{ marginBottom: 12 }}
/>
<Table<PangolinSite & { isCurrentSite?: boolean }>
dataSource={orgSites}
rowKey="siteId"
size="small"
loading={sitesLoading}
pagination={false}
columns={[
{
title: 'Site Name',
dataIndex: 'name',
key: 'name',
render: (name: string, record) => (
<Space>
<Text strong>{sanitizeText(name)}</Text>
{record.isCurrentSite && <Tag color="blue">Matches Newt ID</Tag>}
</Space>
),
},
{
title: 'Site ID',
dataIndex: 'siteId',
key: 'siteId',
render: (id: string) => <Text code>{id}</Text>,
},
...(!isMobile ? [{
title: 'Status',
key: 'online',
width: 100,
render: (_: unknown, record: PangolinSite) =>
record.online
? <Tag color="success">Online</Tag>
: <Tag color="default">Offline</Tag>,
}] : []),
{
title: '',
key: 'action',
width: 120,
render: (_: unknown, record: PangolinSite) => (
<Button
type={record.isCurrentSite ? 'primary' : 'default'}
size="small"
loading={connectLoading === record.siteId}
onClick={() => handleConnectSite(record.siteId)}
>
Connect
</Button>
),
},
]}
/>
</>
)}
</Card>
)}

View File

@ -309,11 +309,10 @@ export default function AdAnalyticsDashboardPage() {
<Table
dataSource={data.daily}
columns={dailyColumns}
scroll={{ x: 'max-content' }}
scroll={{ x: 'max-content', y: 400 }}
rowKey="date"
pagination={false}
size="small"
scroll={{ y: 400 }}
/>
)}
</Card>

View File

@ -19,6 +19,7 @@ import {
Grid,
Tooltip,
Popover,
Pagination,
message,
theme,
} from 'antd';
@ -55,6 +56,8 @@ export default function CampaignsListPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
@ -85,14 +88,15 @@ export default function CampaignsListPage() {
useEffect(() => {
fetchCampaigns();
}, []);
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchCampaigns = async () => {
setLoading(true);
setError(false);
try {
const { data } = await axios.get<Campaign[]>('/api/campaigns/public');
setCampaigns(data);
const { data } = await axios.get('/api/campaigns/public', { params: { page, limit: 20 } });
setCampaigns(data.campaigns);
setTotal(data.pagination.total);
} catch {
setError(true);
} finally {
@ -527,6 +531,18 @@ export default function CampaignsListPage() {
</Row>
)}
{total > 20 && (
<div style={{ textAlign: 'center', marginTop: 32 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
showSizeChanger={false}
/>
</div>
)}
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Typography, Card, Row, Col, Spin, Empty, Grid, theme } from 'antd';
import { Typography, Card, Row, Col, Spin, Empty, Grid, Pagination, theme } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
@ -19,6 +19,8 @@ interface ListedPage {
export default function PagesIndexPage() {
const [pages, setPages] = useState<ListedPage[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
@ -29,11 +31,15 @@ export default function PagesIndexPage() {
}, [settings?.organizationName]);
useEffect(() => {
axios.get<ListedPage[]>('/api/pages/listed')
.then(({ data }) => setPages(data))
setLoading(true);
axios.get('/api/pages/listed', { params: { page, limit: 20 } })
.then(({ data }) => {
setPages(data.pages);
setTotal(data.pagination.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
}, [page]);
if (loading) {
return (
@ -122,6 +128,18 @@ export default function PagesIndexPage() {
))}
</Row>
)}
{total > 20 && (
<div style={{ textAlign: 'center', marginTop: 32 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
showSizeChanger={false}
/>
</div>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Typography, Card, Row, Col, Spin, Empty, Progress, Grid, theme } from 'antd';
import { Typography, Card, Row, Col, Spin, Empty, Progress, Grid, Pagination, theme } from 'antd';
import { FileTextOutlined, TeamOutlined } from '@ant-design/icons';
import axios from 'axios';
import type { Petition } from '@/types/api';
@ -11,22 +11,22 @@ const API = '/api';
export default function PetitionsListPage() {
const [petitions, setPetitions] = useState<Petition[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
useEffect(() => {
(async () => {
try {
const { data } = await axios.get(`${API}/petitions/public`);
setPetitions(data);
} catch {
/* ignore */
} finally {
setLoading(false);
}
})();
}, []);
setLoading(true);
axios.get(`${API}/petitions/public`, { params: { page, limit: 20 } })
.then(({ data }) => {
setPetitions(data.petitions);
setTotal(data.pagination.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [page]);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!petitions.length) return <Empty description="No active petitions" style={{ marginTop: 80 }} />;
@ -84,6 +84,18 @@ export default function PetitionsListPage() {
);
})}
</Row>
{total > 20 && (
<div style={{ textAlign: 'center', marginTop: 32 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
showSizeChanger={false}
/>
</div>
)}
</div>
);
}

View File

@ -14,6 +14,8 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { MonacoBinding } from 'y-monaco';
import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import DocsEditorToolbar from '@/components/docs/DocsEditorToolbar';
import { SNIPPETS, applySnippet } from '@/components/docs/mkdocs-snippets';
const { Header, Content } = Layout;
const { Title, Text } = Typography;
@ -36,6 +38,7 @@ export default function SharedDocEditorPage() {
const [pageState, setPageState] = useState<PageState>({ status: 'loading' });
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof import('monaco-editor') | null>(null);
const monacoBindingRef = useRef<MonacoBinding | null>(null);
const [editorReady, setEditorReady] = useState(false);
@ -95,9 +98,23 @@ export default function SharedDocEditorPage() {
}, [shareToken]);
// Monaco editor mount handler
const handleEditorMount: OnMount = useCallback((editor) => {
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
monacoEditorRef.current = editor;
monacoRef.current = monaco;
setEditorReady(true);
// Register Ctrl+B / Ctrl+I keyboard shortcuts for markdown formatting
SNIPPETS.filter(s => s.keybinding).forEach(snippet => {
const kb = snippet.keybinding === 'ctrl+b'
? monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB
: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI;
editor.addAction({
id: `mkdocs.${snippet.id}`,
label: snippet.label,
keybindings: [kb],
run: (ed) => applySnippet(ed as monacoEditor.IStandaloneCodeEditor, snippet, monaco),
});
});
}, []);
// MonacoBinding effect: binds Y.Text to Monaco editor when both are ready
@ -150,7 +167,7 @@ export default function SharedDocEditorPage() {
},
}}
>
<Layout style={{ minHeight: '100vh', background: '#0d1b2a' }}>
<Layout style={{ height: '100vh', background: '#0d1b2a' }}>
{/* Header */}
<Header
style={{
@ -248,7 +265,7 @@ export default function SharedDocEditorPage() {
)}
{pageState.status === 'ready' && (
<div style={{ flex: 1, position: 'relative' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
{!collab.active && (
<div
style={{
@ -268,22 +285,33 @@ export default function SharedDocEditorPage() {
</div>
)}
<Editor
height="100%"
language={getLanguage(shareData?.documentPath ?? '')}
theme="vs-dark"
options={{
readOnly: !shareData?.canEdit,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
padding: { top: 12 },
}}
onMount={handleEditorMount}
/>
{/* Formatting toolbar for editable markdown files */}
{shareData?.canEdit && shareData.documentPath.endsWith('.md') && (
<DocsEditorToolbar
editorRef={monacoEditorRef}
monacoRef={monacoRef}
background="#1b2838"
/>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language={getLanguage(shareData?.documentPath ?? '')}
theme="vs-dark"
options={{
readOnly: !shareData?.canEdit,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
padding: { top: 12 },
}}
onMount={handleEditorMount}
/>
</div>
</div>
)}
</Content>

View File

@ -10,6 +10,7 @@ import {
message,
Spin,
Result,
Pagination,
Grid,
theme,
} from 'antd';
@ -57,6 +58,8 @@ export default function PublicShiftsPage() {
const [shifts, setShifts] = useState<PublicShift[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [signupModalOpen, setSignupModalOpen] = useState(false);
const [selectedShift, setSelectedShift] = useState<PublicShift | null>(null);
const [relatedCampaigns, setRelatedCampaigns] = useState<any[]>([]);
@ -74,13 +77,14 @@ export default function PublicShiftsPage() {
useEffect(() => {
fetchShifts();
}, []);
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchShifts = async () => {
setLoading(true);
try {
const { data } = await axios.get<PublicShift[]>(`${apiBase}/map/shifts/public`);
setShifts(data);
const { data } = await axios.get(`${apiBase}/map/shifts/public`, { params: { page, limit: 20 } });
setShifts(data.shifts);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load volunteer opportunities');
} finally {
@ -239,6 +243,18 @@ export default function PublicShiftsPage() {
</Row>
)}
{total > 20 && (
<div style={{ textAlign: 'center', marginTop: 32 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
showSizeChanger={false}
/>
</div>
)}
{/* Related Campaigns */}
<RelatedContent campaigns={relatedCampaigns} />

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Button, Typography, Tag, Spin, Select, Space, App } from 'antd';
import { Card, Row, Col, Button, Typography, Tag, Spin, Select, Space, Pagination, App } from 'antd';
import { ShoppingCartOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
@ -15,6 +15,8 @@ export default function ShopPage() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState<string>();
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const { user, isAuthenticated } = useAuthStore();
const { message } = App.useApp();
const { settings: siteSettings } = useSettingsStore();
@ -26,13 +28,17 @@ export default function ShopPage() {
}, [siteSettings?.organizationName]);
useEffect(() => {
const params: Record<string, string> = {};
setLoading(true);
const params: Record<string, string | number> = { page, limit: 20 };
if (typeFilter) params.type = typeFilter;
axios.get('/api/payments/products', { params })
.then(({ data }) => setProducts(data))
.then(({ data }) => {
setProducts(data.products);
setTotal(data.pagination.total);
})
.catch(() => message.error('Failed to load products'))
.finally(() => setLoading(false));
}, [typeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
}, [typeFilter, page]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePurchase = async (e: React.MouseEvent, product: Product) => {
e.stopPropagation(); // prevent card click navigation
@ -80,7 +86,7 @@ export default function ShopPage() {
placeholder="Filter by type"
allowClear
value={typeFilter}
onChange={setTypeFilter}
onChange={(v) => { setTypeFilter(v); setPage(1); }}
style={{ width: 200 }}
options={[
{ value: 'DIGITAL', label: 'Digital Products' },
@ -202,6 +208,18 @@ export default function ShopPage() {
})}
</Row>
)}
{total > 20 && (
<div style={{ textAlign: 'center', marginTop: 32 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
showSizeChanger={false}
/>
</div>
)}
</div>
);
}

View File

@ -1304,6 +1304,9 @@ export interface PangolinStatus {
orgId: string | null;
siteId: string | null;
newtConfigured: boolean;
siteIdValid: boolean | null;
resolvedSiteId: string | null;
siteIdMismatch: boolean;
}
export interface PangolinConfig {
@ -1329,6 +1332,24 @@ export interface PangolinSite {
lastSeen?: string;
online?: boolean;
type?: string;
isCurrentSite?: boolean;
}
export interface ConnectSiteResult {
success: boolean;
site: {
siteId: string;
name: string;
niceId: string;
online: boolean;
};
envUpdate: {
success: boolean;
updated: string[];
added: string[];
error?: string;
};
message: string;
}
export interface PangolinExitNode {

View File

@ -25,6 +25,8 @@ RUN npm run build
# Production stage
FROM node:22-alpine AS production
WORKDIR /app
# su-exec for dropping privileges after fixing mounted volume permissions
RUN apk add --no-cache su-exec
# Copy compiled output and manifests
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
@ -37,8 +39,10 @@ COPY --from=build /app/tsconfig.json ./
RUN npm ci --omit=dev && npm install tsx && npx prisma generate
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
&& mkdir -p /app/uploads && chown -R node:node /app/uploads
&& mkdir -p /app/uploads /app/logs /data/geoip \
&& chown -R node:node /app/uploads /app/logs /data/geoip
USER node
# Note: USER node is NOT set here — entrypoint runs as root to fix
# mounted volume permissions, then drops to node via su-exec
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["npm", "start"]

View File

@ -0,0 +1,3 @@
-- Change default for public map toggles so fresh installs start with map disabled
ALTER TABLE "map_settings" ALTER COLUMN "publicMapEnabled" SET DEFAULT false;
ALTER TABLE "site_settings" ALTER COLUMN "enableMap" SET DEFAULT false;

View File

@ -142,6 +142,7 @@ class GalleryAdsService {
],
},
orderBy: { position: 'asc' },
take: 100, // Safety cap: post-fetch filter may reduce further
});
// Filter in application layer for complex logic

View File

@ -5,13 +5,15 @@ import { redis } from '../../../config/redis';
const router = Router();
// GET /api/campaigns/public — list all active campaigns (public)
// GET /api/campaigns/public?page=1&limit=20 — list active campaigns (public, paginated)
router.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaigns = await campaignsService.findActiveCampaigns();
res.json(campaigns);
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await campaignsService.findActiveCampaigns(page, limit);
res.json(result);
} catch (err) {
next(err);
}

View File

@ -256,15 +256,28 @@ export const campaignsService = {
return campaign;
},
async findActiveCampaigns() {
return prisma.campaign.findMany({
where: { status: 'ACTIVE' },
select: publicCampaignSelect,
orderBy: [
{ highlightCampaign: 'desc' },
{ createdAt: 'desc' },
],
});
async findActiveCampaigns(page: number = 1, limit: number = 20) {
const where = { status: 'ACTIVE' as const };
const skip = (page - 1) * limit;
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
select: publicCampaignSelect,
orderBy: [
{ highlightCampaign: 'desc' },
{ createdAt: 'desc' },
],
skip,
take: limit,
}),
prisma.campaign.count({ where }),
]);
return {
campaigns,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async findBySlugPublic(slug: string) {

View File

@ -6,13 +6,15 @@ import { petitionSignRateLimit } from '../../../middleware/rate-limit';
const router = Router();
// GET /api/petitions/public — list active petitions
// GET /api/petitions/public?page=1&limit=20 — list active petitions (paginated)
router.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const petitions = await petitionsService.findActivePetitions();
res.json(petitions);
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await petitionsService.findActivePetitions(page, limit);
res.json(result);
} catch (err) { next(err); }
}
);

View File

@ -251,21 +251,34 @@ export const petitionsService = {
// ─── Public Routes ───────────────────────────────────────────────────
async findActivePetitions() {
return prisma.petition.findMany({
where: {
status: 'ACTIVE',
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
],
},
select: publicPetitionSelect,
orderBy: [
{ highlightPetition: 'desc' },
{ createdAt: 'desc' },
async findActivePetitions(page: number = 1, limit: number = 20) {
const where = {
status: 'ACTIVE' as const,
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' as const },
],
});
};
const skip = (page - 1) * limit;
const [petitions, total] = await Promise.all([
prisma.petition.findMany({
where,
select: publicPetitionSelect,
orderBy: [
{ highlightPetition: 'desc' },
{ createdAt: 'desc' },
],
skip,
take: limit,
}),
prisma.petition.count({ where }),
]);
return {
petitions,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async findBySlugPublic(slug: string) {

View File

@ -175,12 +175,13 @@ adminRouter.get(
// --- Public Router ---
const publicRouter = Router();
// GET /api/map/cuts/public all public cuts for map display
// GET /api/map/cuts/public?limit=50 — public cuts for map display
publicRouter.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const cuts = await cutsService.getPublicCuts();
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cuts = await cutsService.getPublicCuts(limit);
res.json(cuts);
} catch (err) {
next(err);

View File

@ -125,7 +125,7 @@ export const cutsService = {
await prisma.cut.delete({ where: { id } });
},
async getPublicCuts() {
async getPublicCuts(limit: number = 50) {
const cuts = await prisma.cut.findMany({
where: { isPublic: true },
select: {
@ -139,6 +139,7 @@ export const cutsService = {
bounds: true,
},
orderBy: { name: 'asc' },
take: limit,
});
return cuts;
},

View File

@ -260,13 +260,15 @@ volunteerRouter.delete(
// --- Public Router ---
const publicRouter = Router();
// GET /api/map/shifts/public — list upcoming public shifts
// GET /api/map/shifts/public?page=1&limit=20 — list upcoming public shifts (paginated)
publicRouter.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const shifts = await shiftsService.getPublicShifts();
res.json(shifts);
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await shiftsService.getPublicShifts(page, limit);
res.json(result);
} catch (err) {
next(err);
}

View File

@ -1121,30 +1121,41 @@ export const shiftsService = {
return signups;
},
async getPublicShifts() {
const shifts = await prisma.shift.findMany({
where: {
isPublic: true,
status: { not: ShiftStatus.CANCELLED },
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
},
select: {
id: true,
title: true,
description: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
async getPublicShifts(page: number = 1, limit: number = 20) {
const where = {
isPublic: true,
status: { not: ShiftStatus.CANCELLED },
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
};
const skip = (page - 1) * limit;
return shifts;
const [shifts, total] = await Promise.all([
prisma.shift.findMany({
where,
select: {
id: true,
title: true,
description: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
skip,
take: limit,
}),
prisma.shift.count({ where: { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) } } }),
]);
return {
shifts,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async emailShiftDetails(shiftId: string) {

View File

@ -4,23 +4,37 @@ import { pagesService } from './pages.service';
const router = Router();
// GET /api/pages/listed — get published + listed pages for public index (no auth)
// GET /api/pages/listed?page=1&limit=20 — get published + listed pages for public index (no auth, paginated)
router.get(
'/listed',
async (_req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const pages = await prisma.landingPage.findMany({
where: { published: true, listed: true },
select: {
slug: true,
title: true,
description: true,
seoImage: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const where = { published: true, listed: true };
const skip = (page - 1) * limit;
const [pages, total] = await Promise.all([
prisma.landingPage.findMany({
where,
select: {
slug: true,
title: true,
description: true,
seoImage: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
}),
prisma.landingPage.count({ where }),
]);
res.json({
pages,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
res.json(pages);
} catch (err) {
next(err);
}

View File

@ -172,14 +172,44 @@ const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN'));
// GET /api/pangolin/status — Health + connection info
// GET /api/pangolin/status — Health + connection info + site ID validation
router.get('/status', async (_req: Request, res: Response) => {
try {
const configured = pangolinClient.configured;
let healthy = false;
let siteIdValid: boolean | null = null;
let resolvedSiteId: string | null = null;
let siteIdMismatch = false;
if (configured) {
healthy = await pangolinClient.healthCheck();
// Validate site ID by checking if it exists in the org
// Pangolin returns siteId as a number; env stores it as a string — compare with String()
if (healthy) {
const envSiteId = env.PANGOLIN_SITE_ID;
if (envSiteId) {
try {
const sites = await pangolinClient.listSites();
// Match env siteId against org sites (coerce types for comparison)
const match = sites.find(s =>
String(s.siteId) === envSiteId || s.niceId === envSiteId
);
if (match) {
siteIdValid = true;
resolvedSiteId = String(match.siteId);
} else {
siteIdValid = false;
siteIdMismatch = true;
logger.warn(`PANGOLIN_SITE_ID "${envSiteId}" not found in org (${sites.length} sites available)`);
}
} catch (err) {
logger.warn('Could not validate site ID (non-critical):', err instanceof Error ? err.message : err);
}
}
}
}
res.json({
@ -189,6 +219,9 @@ router.get('/status', async (_req: Request, res: Response) => {
orgId: env.PANGOLIN_ORG_ID || null,
siteId: env.PANGOLIN_SITE_ID || null,
newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET),
siteIdValid,
resolvedSiteId,
siteIdMismatch,
});
} catch (err) {
logger.error('Pangolin status check failed:', err);
@ -265,17 +298,95 @@ router.post('/newt-restart', async (_req: Request, res: Response) => {
}
});
// GET /api/pangolin/sites — List sites
// GET /api/pangolin/sites — List sites (with newtId matching for site picker)
router.get('/sites', async (_req: Request, res: Response) => {
try {
const sites = await pangolinClient.listSites();
res.json({ sites });
const currentNewtId = env.PANGOLIN_NEWT_ID || null;
const currentSiteId = env.PANGOLIN_SITE_ID || null;
// Annotate each site with whether it matches current env config
// Pangolin returns siteId as a number; env stores it as a string — compare with String()
const annotatedSites = sites.map(s => ({
...s,
isCurrentSite: currentSiteId
? (String(s.siteId) === currentSiteId || s.niceId === currentSiteId)
: false,
}));
res.json({ sites: annotatedSites, currentNewtId, currentSiteId });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } });
}
});
// POST /api/pangolin/connect-site — Connect to an existing site (write site ID + newt creds to .env)
const connectSiteSchema = z.object({
siteId: z.union([z.string().min(1).max(200), z.number().int().positive()]).transform(String),
});
router.post('/connect-site', pangolinSetupLimiter, async (req: Request, res: Response) => {
try {
if (!pangolinClient.configured) {
res.status(400).json({
error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' },
});
return;
}
const { siteId } = connectSiteSchema.parse(req.body);
// Verify the site exists in the org
const sites = await pangolinClient.listSites();
const site = sites.find(s => String(s.siteId) === siteId || s.niceId === siteId);
if (!site) {
res.status(404).json({
error: { message: `Site "${siteId}" not found in organization`, code: 'SITE_NOT_FOUND' },
});
return;
}
// Build env updates — always write the site ID (coerce to string for .env)
const envUpdates: Record<string, string> = {
PANGOLIN_SITE_ID: String(site.siteId),
};
// If the site has a Pangolin endpoint, write that too
if (env.PANGOLIN_API_URL) {
// Derive the endpoint from the API URL (strip /v1 path)
const endpoint = env.PANGOLIN_API_URL.replace(/\/v1\/?$/, '');
envUpdates.PANGOLIN_ENDPOINT = endpoint;
}
// Write to .env
const envResult = updateEnvFile(envUpdates);
logger.info(`Connected to Pangolin site: ${site.siteId} (name: ${site.name})`);
res.json({
success: true,
site: {
siteId: site.siteId,
name: site.name,
niceId: site.niceId,
online: site.online,
},
envUpdate: envResult,
message: `Connected to site "${site.name}". Restart the API container to apply the new PANGOLIN_SITE_ID.`,
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: { message: 'Invalid request body', code: 'VALIDATION_ERROR' } });
return;
}
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.error('Connect site failed:', err);
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } });
}
});
// GET /api/pangolin/exit-nodes — List available exit nodes
router.get('/exit-nodes', async (_req: Request, res: Response) => {
try {

View File

@ -8,10 +8,11 @@ import { donationPageCheckoutSchema } from './donation-pages.schemas';
const router = Router();
// GET /api/donation-pages — list active pages (with stats)
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
// GET /api/donation-pages?limit=20 — list active pages (with stats)
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const pages = await donationPagesService.findActivePages();
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const pages = await donationPagesService.findActivePages(limit);
res.json(pages);
} catch (err) {
next(err);

View File

@ -193,13 +193,14 @@ export const donationPagesService = {
},
/** Public: list active donation pages with stats */
async findActivePages() {
async findActivePages(limit: number = 20) {
const pages = await prisma.donationPage.findMany({
where: { status: 'ACTIVE' },
orderBy: [
{ highlightPage: 'desc' },
{ createdAt: 'desc' },
],
take: limit,
});
return Promise.all(

View File

@ -36,10 +36,11 @@ router.get('/config', async (_req: Request, res: Response, next: NextFunction) =
}
});
// GET /api/payments/plans — list active subscription plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
// GET /api/payments/plans?limit=50 — list active subscription plans
router.get('/plans', async (req: Request, res: Response, next: NextFunction) => {
try {
const plans = await plansService.listActivePlans();
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const plans = await plansService.listActivePlans(limit);
res.json(plans);
} catch (err) {
next(err);
@ -57,12 +58,14 @@ router.get('/plans/:slug', async (req: Request, res: Response, next: NextFunctio
}
});
// GET /api/payments/products — list active products
// GET /api/payments/products?page=1&limit=20 — list active products (paginated)
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.query.type as string | undefined;
const products = await productsService.listActive(type);
res.json(products);
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await productsService.listActive(type, page, limit);
res.json(result);
} catch (err) {
next(err);
}

View File

@ -152,13 +152,14 @@ export const plansService = {
},
/** Public: list active plans for pricing page */
async listActivePlans() {
async listActivePlans(limit: number = 50) {
return prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: [
{ highlightPlan: 'desc' },
{ displayOrder: 'asc' },
],
take: limit,
});
},

View File

@ -88,15 +88,26 @@ function productAdDefaults(product: { title: string; description: string | null;
}
export const productsService = {
/** List active products (public) */
async listActive(type?: string) {
/** List active products (public, paginated) */
async listActive(type?: string, page: number = 1, limit: number = 20) {
const where: Prisma.ProductWhereInput = { isActive: true };
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
const products = await prisma.product.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return products.map(resolveMediaUrls);
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
return {
products: products.map(resolveMediaUrls),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** List all products (admin) */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show More