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) {
|
if (open && products.length === 0) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
axios.get('/api/payments/products')
|
axios.get('/api/payments/products', { params: { limit: 50 } })
|
||||||
.then(({ data }) => setProducts(data))
|
.then(({ data }) => setProducts(data.products))
|
||||||
.catch(() => setError('Failed to load products'))
|
.catch(() => setError('Failed to load products'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios.get('/api/payments/products')
|
axios.get('/api/payments/products', { params: { limit: 50 } })
|
||||||
.then(({ data }) => {
|
.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) {
|
if (found) {
|
||||||
setProduct(found);
|
setProduct(found);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -329,68 +329,9 @@ function filterTree(nodes: FileNode[], query: string): FileNode[] {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MkDocs Snippet System ---
|
// --- MkDocs Snippet System (shared) ---
|
||||||
|
import { SNIPPETS, applySnippet as applySnippetShared } from '@/components/docs/mkdocs-snippets';
|
||||||
interface MkDocsSnippet {
|
import type { MkDocsSnippet } from '@/components/docs/mkdocs-snippets';
|
||||||
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: '---' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Inline Donate Block Generator ---
|
// --- Inline Donate Block Generator ---
|
||||||
// Produces HTML with data-role attributes for hydration by payment-widgets.js.
|
// 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');
|
return lines.filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnippet(
|
const applySnippet = applySnippetShared;
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wrapper component so useDocsEditor() hook only runs on mobile */
|
/** Wrapper component so useDocsEditor() hook only runs on mobile */
|
||||||
function MobileDocsEditorWrapper() {
|
function MobileDocsEditorWrapper() {
|
||||||
@ -1648,9 +1541,9 @@ export default function DocsPage() {
|
|||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
|
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
|
||||||
<Tooltip title="Build static site">
|
<Button type="primary" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle">
|
||||||
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
|
Build
|
||||||
</Tooltip>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@ -304,14 +304,14 @@ export default function MkDocsSettingsPage() {
|
|||||||
const [configRes, filesRes, campaignsRes] = await Promise.all([
|
const [configRes, filesRes, campaignsRes] = await Promise.all([
|
||||||
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
|
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
|
||||||
api.get<FileNode[]>('/docs/files'),
|
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;
|
const content = configRes.data.content;
|
||||||
setRawYaml(content);
|
setRawYaml(content);
|
||||||
setOriginalYaml(content);
|
setOriginalYaml(content);
|
||||||
setEditorYaml(content);
|
setEditorYaml(content);
|
||||||
setFileTree(filesRes.data);
|
setFileTree(filesRes.data);
|
||||||
setCampaigns(campaignsRes.data);
|
setCampaigns(campaignsRes.data.campaigns);
|
||||||
|
|
||||||
// Parse for settings tab
|
// Parse for settings tab
|
||||||
syncSettingsFromYaml(content);
|
syncSettingsFromYaml(content);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type {
|
import type {
|
||||||
PangolinStatus, PangolinConfig, PangolinResource, PangolinNewtStatus, PangolinExitNode,
|
PangolinStatus, PangolinConfig, PangolinResource, PangolinNewtStatus, PangolinExitNode,
|
||||||
ResourceStatusResponse, ResourceStatusItem, SyncResult,
|
PangolinSite, ConnectSiteResult, ResourceStatusResponse, ResourceStatusItem, SyncResult,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
@ -90,6 +90,9 @@ export default function PangolinPage() {
|
|||||||
const [resourceStatus, setResourceStatus] = useState<ResourceStatusResponse | null>(null);
|
const [resourceStatus, setResourceStatus] = useState<ResourceStatusResponse | null>(null);
|
||||||
const [statusLoading, setStatusLoading] = useState(false);
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Tunnel Management' });
|
setPageHeader({ title: 'Tunnel Management' });
|
||||||
@ -155,6 +158,46 @@ export default function PangolinPage() {
|
|||||||
}
|
}
|
||||||
}, [status?.configured, config?.siteId, fetchResourceStatus]);
|
}, [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
|
// Fetch exit nodes for site creation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.configured && !config?.siteId) {
|
if (status?.configured && !config?.siteId) {
|
||||||
@ -312,11 +355,105 @@ export default function PangolinPage() {
|
|||||||
<Text code>{config?.orgId || 'Not set'}</Text>
|
<Text code>{config?.orgId || 'Not set'}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Site ID">
|
<Descriptions.Item label="Site ID">
|
||||||
|
<Space>
|
||||||
<Text code>{config?.siteId || 'Not set'}</Text>
|
<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.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</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 */}
|
{/* Setup Card — shown when API credentials configured but no site yet */}
|
||||||
{isConfigured && !config?.siteId && (
|
{isConfigured && !config?.siteId && (
|
||||||
<Card title={<><RocketOutlined /> Automated Setup</>}>
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -309,11 +309,10 @@ export default function AdAnalyticsDashboardPage() {
|
|||||||
<Table
|
<Table
|
||||||
dataSource={data.daily}
|
dataSource={data.daily}
|
||||||
columns={dailyColumns}
|
columns={dailyColumns}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content', y: 400 }}
|
||||||
rowKey="date"
|
rowKey="date"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
scroll={{ y: 400 }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Popover,
|
Popover,
|
||||||
|
Pagination,
|
||||||
message,
|
message,
|
||||||
theme,
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -55,6 +56,8 @@ export default function CampaignsListPage() {
|
|||||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
@ -85,14 +88,15 @@ export default function CampaignsListPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCampaigns();
|
fetchCampaigns();
|
||||||
}, []);
|
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const fetchCampaigns = async () => {
|
const fetchCampaigns = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<Campaign[]>('/api/campaigns/public');
|
const { data } = await axios.get('/api/campaigns/public', { params: { page, limit: 20 } });
|
||||||
setCampaigns(data);
|
setCampaigns(data.campaigns);
|
||||||
|
setTotal(data.pagination.total);
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
@ -527,6 +531,18 @@ export default function CampaignsListPage() {
|
|||||||
</Row>
|
</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
|
<AuthModal
|
||||||
open={authModalOpen}
|
open={authModalOpen}
|
||||||
onCancel={() => setAuthModalOpen(false)}
|
onCancel={() => setAuthModalOpen(false)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { FileTextOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -19,6 +19,8 @@ interface ListedPage {
|
|||||||
export default function PagesIndexPage() {
|
export default function PagesIndexPage() {
|
||||||
const [pages, setPages] = useState<ListedPage[]>([]);
|
const [pages, setPages] = useState<ListedPage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
@ -29,11 +31,15 @@ export default function PagesIndexPage() {
|
|||||||
}, [settings?.organizationName]);
|
}, [settings?.organizationName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get<ListedPage[]>('/api/pages/listed')
|
setLoading(true);
|
||||||
.then(({ data }) => setPages(data))
|
axios.get('/api/pages/listed', { params: { page, limit: 20 } })
|
||||||
|
.then(({ data }) => {
|
||||||
|
setPages(data.pages);
|
||||||
|
setTotal(data.pagination.total);
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -122,6 +128,18 @@ export default function PagesIndexPage() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { FileTextOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Petition } from '@/types/api';
|
import type { Petition } from '@/types/api';
|
||||||
@ -11,22 +11,22 @@ const API = '/api';
|
|||||||
export default function PetitionsListPage() {
|
export default function PetitionsListPage() {
|
||||||
const [petitions, setPetitions] = useState<Petition[]>([]);
|
const [petitions, setPetitions] = useState<Petition[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
setLoading(true);
|
||||||
try {
|
axios.get(`${API}/petitions/public`, { params: { page, limit: 20 } })
|
||||||
const { data } = await axios.get(`${API}/petitions/public`);
|
.then(({ data }) => {
|
||||||
setPetitions(data);
|
setPetitions(data.petitions);
|
||||||
} catch {
|
setTotal(data.pagination.total);
|
||||||
/* ignore */
|
})
|
||||||
} finally {
|
.catch(() => {})
|
||||||
setLoading(false);
|
.finally(() => setLoading(false));
|
||||||
}
|
}, [page]);
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||||
if (!petitions.length) return <Empty description="No active petitions" style={{ marginTop: 80 }} />;
|
if (!petitions.length) return <Empty description="No active petitions" style={{ marginTop: 80 }} />;
|
||||||
@ -84,6 +84,18 @@ export default function PetitionsListPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Row>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import type { editor as monacoEditor } from 'monaco-editor';
|
|||||||
import { MonacoBinding } from 'y-monaco';
|
import { MonacoBinding } from 'y-monaco';
|
||||||
import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration';
|
import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration';
|
||||||
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
|
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 { Header, Content } = Layout;
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@ -36,6 +38,7 @@ export default function SharedDocEditorPage() {
|
|||||||
const [pageState, setPageState] = useState<PageState>({ status: 'loading' });
|
const [pageState, setPageState] = useState<PageState>({ status: 'loading' });
|
||||||
|
|
||||||
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const monacoRef = useRef<typeof import('monaco-editor') | null>(null);
|
||||||
const monacoBindingRef = useRef<MonacoBinding | null>(null);
|
const monacoBindingRef = useRef<MonacoBinding | null>(null);
|
||||||
const [editorReady, setEditorReady] = useState(false);
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
|
|
||||||
@ -95,9 +98,23 @@ export default function SharedDocEditorPage() {
|
|||||||
}, [shareToken]);
|
}, [shareToken]);
|
||||||
|
|
||||||
// Monaco editor mount handler
|
// Monaco editor mount handler
|
||||||
const handleEditorMount: OnMount = useCallback((editor) => {
|
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||||
monacoEditorRef.current = editor;
|
monacoEditorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
setEditorReady(true);
|
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
|
// 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 */}
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
@ -248,7 +265,7 @@ export default function SharedDocEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState.status === 'ready' && (
|
{pageState.status === 'ready' && (
|
||||||
<div style={{ flex: 1, position: 'relative' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||||
{!collab.active && (
|
{!collab.active && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -268,6 +285,16 @@ export default function SharedDocEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
language={getLanguage(shareData?.documentPath ?? '')}
|
language={getLanguage(shareData?.documentPath ?? '')}
|
||||||
@ -285,6 +312,7 @@ export default function SharedDocEditorPage() {
|
|||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
Spin,
|
Spin,
|
||||||
Result,
|
Result,
|
||||||
|
Pagination,
|
||||||
Grid,
|
Grid,
|
||||||
theme,
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -57,6 +58,8 @@ export default function PublicShiftsPage() {
|
|||||||
|
|
||||||
const [shifts, setShifts] = useState<PublicShift[]>([]);
|
const [shifts, setShifts] = useState<PublicShift[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [signupModalOpen, setSignupModalOpen] = useState(false);
|
const [signupModalOpen, setSignupModalOpen] = useState(false);
|
||||||
const [selectedShift, setSelectedShift] = useState<PublicShift | null>(null);
|
const [selectedShift, setSelectedShift] = useState<PublicShift | null>(null);
|
||||||
const [relatedCampaigns, setRelatedCampaigns] = useState<any[]>([]);
|
const [relatedCampaigns, setRelatedCampaigns] = useState<any[]>([]);
|
||||||
@ -74,13 +77,14 @@ export default function PublicShiftsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShifts();
|
fetchShifts();
|
||||||
}, []);
|
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const fetchShifts = async () => {
|
const fetchShifts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<PublicShift[]>(`${apiBase}/map/shifts/public`);
|
const { data } = await axios.get(`${apiBase}/map/shifts/public`, { params: { page, limit: 20 } });
|
||||||
setShifts(data);
|
setShifts(data.shifts);
|
||||||
|
setTotal(data.pagination.total);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to load volunteer opportunities');
|
message.error('Failed to load volunteer opportunities');
|
||||||
} finally {
|
} finally {
|
||||||
@ -239,6 +243,18 @@ export default function PublicShiftsPage() {
|
|||||||
</Row>
|
</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 */}
|
{/* Related Campaigns */}
|
||||||
<RelatedContent campaigns={relatedCampaigns} />
|
<RelatedContent campaigns={relatedCampaigns} />
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { ShoppingCartOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@ -15,6 +15,8 @@ export default function ShopPage() {
|
|||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [typeFilter, setTypeFilter] = useState<string>();
|
const [typeFilter, setTypeFilter] = useState<string>();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { settings: siteSettings } = useSettingsStore();
|
const { settings: siteSettings } = useSettingsStore();
|
||||||
@ -26,13 +28,17 @@ export default function ShopPage() {
|
|||||||
}, [siteSettings?.organizationName]);
|
}, [siteSettings?.organizationName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params: Record<string, string> = {};
|
setLoading(true);
|
||||||
|
const params: Record<string, string | number> = { page, limit: 20 };
|
||||||
if (typeFilter) params.type = typeFilter;
|
if (typeFilter) params.type = typeFilter;
|
||||||
axios.get('/api/payments/products', { params })
|
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'))
|
.catch(() => message.error('Failed to load products'))
|
||||||
.finally(() => setLoading(false));
|
.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) => {
|
const handlePurchase = async (e: React.MouseEvent, product: Product) => {
|
||||||
e.stopPropagation(); // prevent card click navigation
|
e.stopPropagation(); // prevent card click navigation
|
||||||
@ -80,7 +86,7 @@ export default function ShopPage() {
|
|||||||
placeholder="Filter by type"
|
placeholder="Filter by type"
|
||||||
allowClear
|
allowClear
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={setTypeFilter}
|
onChange={(v) => { setTypeFilter(v); setPage(1); }}
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'DIGITAL', label: 'Digital Products' },
|
{ value: 'DIGITAL', label: 'Digital Products' },
|
||||||
@ -202,6 +208,18 @@ export default function ShopPage() {
|
|||||||
})}
|
})}
|
||||||
</Row>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1304,6 +1304,9 @@ export interface PangolinStatus {
|
|||||||
orgId: string | null;
|
orgId: string | null;
|
||||||
siteId: string | null;
|
siteId: string | null;
|
||||||
newtConfigured: boolean;
|
newtConfigured: boolean;
|
||||||
|
siteIdValid: boolean | null;
|
||||||
|
resolvedSiteId: string | null;
|
||||||
|
siteIdMismatch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PangolinConfig {
|
export interface PangolinConfig {
|
||||||
@ -1329,6 +1332,24 @@ export interface PangolinSite {
|
|||||||
lastSeen?: string;
|
lastSeen?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
type?: string;
|
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 {
|
export interface PangolinExitNode {
|
||||||
|
|||||||
@ -25,6 +25,8 @@ RUN npm run build
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
WORKDIR /app
|
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 compiled output and manifests
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY --from=build /app/package.json ./
|
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
|
RUN npm ci --omit=dev && npm install tsx && npx prisma generate
|
||||||
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
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"]
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["npm", "start"]
|
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' },
|
orderBy: { position: 'asc' },
|
||||||
|
take: 100, // Safety cap: post-fetch filter may reduce further
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter in application layer for complex logic
|
// Filter in application layer for complex logic
|
||||||
|
|||||||
@ -5,13 +5,15 @@ import { redis } from '../../../config/redis';
|
|||||||
|
|
||||||
const router = Router();
|
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(
|
router.get(
|
||||||
'/public',
|
'/public',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const campaigns = await campaignsService.findActiveCampaigns();
|
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||||
res.json(campaigns);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||||
|
const result = await campaignsService.findActiveCampaigns(page, limit);
|
||||||
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -256,15 +256,28 @@ export const campaignsService = {
|
|||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
async findActiveCampaigns() {
|
async findActiveCampaigns(page: number = 1, limit: number = 20) {
|
||||||
return prisma.campaign.findMany({
|
const where = { status: 'ACTIVE' as const };
|
||||||
where: { status: 'ACTIVE' },
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [campaigns, total] = await Promise.all([
|
||||||
|
prisma.campaign.findMany({
|
||||||
|
where,
|
||||||
select: publicCampaignSelect,
|
select: publicCampaignSelect,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ highlightCampaign: 'desc' },
|
{ highlightCampaign: 'desc' },
|
||||||
{ createdAt: '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) {
|
async findBySlugPublic(slug: string) {
|
||||||
|
|||||||
@ -6,13 +6,15 @@ import { petitionSignRateLimit } from '../../../middleware/rate-limit';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /api/petitions/public — list active petitions
|
// GET /api/petitions/public?page=1&limit=20 — list active petitions (paginated)
|
||||||
router.get(
|
router.get(
|
||||||
'/public',
|
'/public',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const petitions = await petitionsService.findActivePetitions();
|
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||||
res.json(petitions);
|
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); }
|
} catch (err) { next(err); }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -251,21 +251,34 @@ export const petitionsService = {
|
|||||||
|
|
||||||
// ─── Public Routes ───────────────────────────────────────────────────
|
// ─── Public Routes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async findActivePetitions() {
|
async findActivePetitions(page: number = 1, limit: number = 20) {
|
||||||
return prisma.petition.findMany({
|
const where = {
|
||||||
where: {
|
status: 'ACTIVE' as const,
|
||||||
status: 'ACTIVE',
|
|
||||||
OR: [
|
OR: [
|
||||||
{ isUserGenerated: false },
|
{ isUserGenerated: false },
|
||||||
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
|
{ isUserGenerated: true, moderationStatus: 'APPROVED' as const },
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [petitions, total] = await Promise.all([
|
||||||
|
prisma.petition.findMany({
|
||||||
|
where,
|
||||||
select: publicPetitionSelect,
|
select: publicPetitionSelect,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ highlightPetition: 'desc' },
|
{ highlightPetition: 'desc' },
|
||||||
{ createdAt: '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) {
|
async findBySlugPublic(slug: string) {
|
||||||
|
|||||||
@ -175,12 +175,13 @@ adminRouter.get(
|
|||||||
// --- Public Router ---
|
// --- Public Router ---
|
||||||
const publicRouter = 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(
|
publicRouter.get(
|
||||||
'/public',
|
'/public',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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);
|
res.json(cuts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export const cutsService = {
|
|||||||
await prisma.cut.delete({ where: { id } });
|
await prisma.cut.delete({ where: { id } });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPublicCuts() {
|
async getPublicCuts(limit: number = 50) {
|
||||||
const cuts = await prisma.cut.findMany({
|
const cuts = await prisma.cut.findMany({
|
||||||
where: { isPublic: true },
|
where: { isPublic: true },
|
||||||
select: {
|
select: {
|
||||||
@ -139,6 +139,7 @@ export const cutsService = {
|
|||||||
bounds: true,
|
bounds: true,
|
||||||
},
|
},
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
|
take: limit,
|
||||||
});
|
});
|
||||||
return cuts;
|
return cuts;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -260,13 +260,15 @@ volunteerRouter.delete(
|
|||||||
// --- Public Router ---
|
// --- Public Router ---
|
||||||
const publicRouter = 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(
|
publicRouter.get(
|
||||||
'/public',
|
'/public',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const shifts = await shiftsService.getPublicShifts();
|
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||||
res.json(shifts);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||||
|
const result = await shiftsService.getPublicShifts(page, limit);
|
||||||
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1121,13 +1121,17 @@ export const shiftsService = {
|
|||||||
return signups;
|
return signups;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPublicShifts() {
|
async getPublicShifts(page: number = 1, limit: number = 20) {
|
||||||
const shifts = await prisma.shift.findMany({
|
const where = {
|
||||||
where: {
|
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
status: { not: ShiftStatus.CANCELLED },
|
status: { not: ShiftStatus.CANCELLED },
|
||||||
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
|
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
|
||||||
},
|
};
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [shifts, total] = await Promise.all([
|
||||||
|
prisma.shift.findMany({
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
@ -1142,9 +1146,16 @@ export const shiftsService = {
|
|||||||
meeting: { select: { id: true, slug: true, isActive: true } },
|
meeting: { select: { id: true, slug: true, isActive: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
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;
|
return {
|
||||||
|
shifts,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async emailShiftDetails(shiftId: string) {
|
async emailShiftDetails(shiftId: string) {
|
||||||
|
|||||||
@ -4,13 +4,19 @@ import { pagesService } from './pages.service';
|
|||||||
|
|
||||||
const router = Router();
|
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(
|
router.get(
|
||||||
'/listed',
|
'/listed',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const pages = await prisma.landingPage.findMany({
|
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||||
where: { published: true, listed: true },
|
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: {
|
select: {
|
||||||
slug: true,
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
@ -19,8 +25,16 @@ router.get(
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -172,14 +172,44 @@ const router = Router();
|
|||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole('SUPER_ADMIN'));
|
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) => {
|
router.get('/status', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const configured = pangolinClient.configured;
|
const configured = pangolinClient.configured;
|
||||||
let healthy = false;
|
let healthy = false;
|
||||||
|
let siteIdValid: boolean | null = null;
|
||||||
|
let resolvedSiteId: string | null = null;
|
||||||
|
let siteIdMismatch = false;
|
||||||
|
|
||||||
if (configured) {
|
if (configured) {
|
||||||
healthy = await pangolinClient.healthCheck();
|
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({
|
res.json({
|
||||||
@ -189,6 +219,9 @@ router.get('/status', async (_req: Request, res: Response) => {
|
|||||||
orgId: env.PANGOLIN_ORG_ID || null,
|
orgId: env.PANGOLIN_ORG_ID || null,
|
||||||
siteId: env.PANGOLIN_SITE_ID || null,
|
siteId: env.PANGOLIN_SITE_ID || null,
|
||||||
newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET),
|
newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET),
|
||||||
|
siteIdValid,
|
||||||
|
resolvedSiteId,
|
||||||
|
siteIdMismatch,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Pangolin status check failed:', 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) => {
|
router.get('/sites', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const sites = await pangolinClient.listSites();
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_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
|
// GET /api/pangolin/exit-nodes — List available exit nodes
|
||||||
router.get('/exit-nodes', async (_req: Request, res: Response) => {
|
router.get('/exit-nodes', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -8,10 +8,11 @@ import { donationPageCheckoutSchema } from './donation-pages.schemas';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /api/donation-pages — list active pages (with stats)
|
// GET /api/donation-pages?limit=20 — list active pages (with stats)
|
||||||
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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);
|
res.json(pages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -193,13 +193,14 @@ export const donationPagesService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Public: list active donation pages with stats */
|
/** Public: list active donation pages with stats */
|
||||||
async findActivePages() {
|
async findActivePages(limit: number = 20) {
|
||||||
const pages = await prisma.donationPage.findMany({
|
const pages = await prisma.donationPage.findMany({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE' },
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ highlightPage: 'desc' },
|
{ highlightPage: 'desc' },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
],
|
],
|
||||||
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(
|
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
|
// GET /api/payments/plans?limit=50 — list active subscription plans
|
||||||
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
|
router.get('/plans', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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);
|
res.json(plans);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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) => {
|
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const type = req.query.type as string | undefined;
|
const type = req.query.type as string | undefined;
|
||||||
const products = await productsService.listActive(type);
|
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||||
res.json(products);
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -152,13 +152,14 @@ export const plansService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Public: list active plans for pricing page */
|
/** Public: list active plans for pricing page */
|
||||||
async listActivePlans() {
|
async listActivePlans(limit: number = 50) {
|
||||||
return prisma.subscriptionPlan.findMany({
|
return prisma.subscriptionPlan.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ highlightPlan: 'desc' },
|
{ highlightPlan: 'desc' },
|
||||||
{ displayOrder: 'asc' },
|
{ displayOrder: 'asc' },
|
||||||
],
|
],
|
||||||
|
take: limit,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -88,15 +88,26 @@ function productAdDefaults(product: { title: string; description: string | null;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const productsService = {
|
export const productsService = {
|
||||||
/** List active products (public) */
|
/** List active products (public, paginated) */
|
||||||
async listActive(type?: string) {
|
async listActive(type?: string, page: number = 1, limit: number = 20) {
|
||||||
const where: Prisma.ProductWhereInput = { isActive: true };
|
const where: Prisma.ProductWhereInput = { isActive: true };
|
||||||
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
|
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
|
||||||
const products = await prisma.product.findMany({
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [products, total] = await Promise.all([
|
||||||
|
prisma.product.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
skip,
|
||||||
return products.map(resolveMediaUrls);
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.product.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: products.map(resolveMediaUrls),
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/** List all products (admin) */
|
/** 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 |