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
129
admin/src/components/docs/DocsEditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
admin/src/components/docs/mkdocs-snippets.ts
Normal 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: '' },
|
||||
{ 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();
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: '' },
|
||||
{ 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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -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) */
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 28 KiB |