Update admin modals and page components for mobile responsiveness

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-09 11:43:23 -06:00
parent 849dea7ce2
commit f0d994074d
81 changed files with 1271 additions and 588 deletions

View File

@ -269,6 +269,7 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
# --- Code Server ---
CODE_SERVER_PORT=8888
CODE_SERVER_URL=http://code-server-changemaker:8443
USER_NAME=coder
# --- Homepage ---
HOMEPAGE_PORT=3010

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import {
Modal,
Drawer,
Form,
Input,
DatePicker,
@ -169,13 +169,20 @@ export default function CalendarItemModal({
};
return (
<Modal
<Drawer
open={open}
onCancel={onCancel}
onClose={onCancel}
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
footer={null}
width={520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
}
>
<Form
form={form}
@ -454,26 +461,18 @@ export default function CalendarItemModal({
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<div>
{isEditing && onDelete && (
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
>
Delete
</Button>
)}
</div>
<Space>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
{isEditing && onDelete && (
<div style={{ marginTop: 8 }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
>
Delete
</Button>
</Space>
</div>
</div>
)}
</Form>
</Modal>
</Drawer>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid,
Drawer, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid, Space,
} from 'antd';
import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
@ -152,32 +152,34 @@ export default function ExportContactsModal({
};
return (
<Modal
<Drawer
title="Export Canvass Contacts to Campaign"
open={open}
onCancel={onClose}
onClose={onClose}
width={isMobile ? '95vw' : 640}
footer={[
<Button key="cancel" onClick={onClose}>Cancel</Button>,
<Button
key="preview"
icon={<EyeOutlined />}
onClick={handlePreview}
loading={previewing}
>
Preview
</Button>,
<Button
key="export"
type="primary"
icon={<ExportOutlined />}
onClick={handleExport}
loading={exporting}
disabled={!preview || preview.contactsWithEmail === 0}
>
Export
</Button>,
]}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button
icon={<EyeOutlined />}
onClick={handlePreview}
loading={previewing}
>
Preview
</Button>
<Button
type="primary"
icon={<ExportOutlined />}
onClick={handleExport}
loading={exporting}
disabled={!preview || preview.contactsWithEmail === 0}
>
Export
</Button>
</Space>
}
>
<Form form={form} layout="vertical" size="small">
<Form.Item
@ -294,6 +296,6 @@ export default function ExportContactsModal({
)}
</div>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import {
Modal,
Drawer,
Button,
Input,
Space,
@ -150,7 +150,7 @@ export function AuthorsManagementModal({
const authorEntries = Object.entries(localAuthors);
return (
<Modal
<Drawer
title={
<span>
<UserOutlined style={{ marginRight: 8 }} />
@ -158,23 +158,23 @@ export function AuthorsManagementModal({
</span>
}
open={open}
onCancel={onClose}
footer={
<Space>
<Button onClick={onClose}>Close</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
</Space>
}
destroyOnHidden
onClose={onClose}
width={560}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
}
>
{contextHolder}
@ -236,7 +236,7 @@ export function AuthorsManagementModal({
</Button>
)}
</div>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { Modal, Input, List, theme, Typography } from 'antd';
import { Drawer, Input, List, theme, Typography } from 'antd';
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
@ -71,13 +71,15 @@ export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: Mov
const fileName = sourcePath.split('/').pop() || sourcePath;
return (
<Modal
<Drawer
title={`Move "${fileName}"`}
open={open}
onCancel={handleClose}
footer={null}
destroyOnHidden
onClose={handleClose}
width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Input.Search
placeholder="Search directories..."
@ -149,6 +151,6 @@ export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: Mov
}}
/>
</div>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Modal, Form, Input, DatePicker, Select, Switch, message, theme } from 'antd';
import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
import { FileMarkdownOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
@ -84,7 +84,7 @@ export function NewBlogPostModal({
};
return (
<Modal
<Drawer
title={
<span>
<FileMarkdownOutlined style={{ marginRight: 8 }} />
@ -92,12 +92,17 @@ export function NewBlogPostModal({
</span>
}
open={open}
onCancel={handleClose}
onOk={handleSubmit}
okText="Create"
confirmLoading={submitting}
destroyOnHidden
onClose={handleClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={handleSubmit} loading={submitting}>
Create
</Button>
}
>
{contextHolder}
<Form
@ -160,6 +165,6 @@ export function NewBlogPostModal({
<Switch />
</Form.Item>
</Form>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { Modal, Input, List, theme, Typography, Tag } from 'antd';
import { Drawer, Input, List, theme, Typography, Tag } from 'antd';
import { FileOutlined, PictureOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
@ -62,13 +62,15 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
};
return (
<Modal
<Drawer
title="Insert Wiki Link"
open={open}
onCancel={() => { onClose(); setSearch(''); }}
footer={null}
destroyOnHidden
onClose={() => { onClose(); setSearch(''); }}
width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Input.Search
placeholder="Search files..."
@ -148,6 +150,6 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
</div>
)}
</div>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
import { Drawer, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -118,19 +118,19 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
];
return (
<Modal
<Drawer
title={`Send Test Email: ${template.name}`}
open={open}
onCancel={onClose}
onClose={onClose}
width={isMobile ? '95vw' : 900}
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="send" type="primary" loading={sending} onClick={handleSend}>
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" loading={sending} onClick={handleSend}>
Send Test Email
</Button>,
]}
</Button>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Form form={form} layout="vertical">
@ -244,6 +244,6 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]}
/>
</Space>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { Drawer, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
@ -152,13 +152,19 @@ export default function AddToPlaylistModal({
};
return (
<Modal
<Drawer
title="Add to Playlist"
open={open}
onOk={handleSave}
onCancel={onClose}
confirmLoading={saving}
okText="Save"
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
</Button>
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
@ -238,6 +244,6 @@ export default function AddToPlaylistModal({
)}
</>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,4 +1,4 @@
import { Modal, Select, message } from 'antd';
import { Drawer, Select, Button, message } from 'antd';
import { useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
@ -37,13 +37,19 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
};
return (
<Modal
<Drawer
title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`}
open={open}
onOk={handleOk}
onCancel={onClose}
confirmLoading={loading}
okText="Apply"
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} loading={loading}>
Apply
</Button>
}
>
<div style={{ marginTop: 16 }}>
<Select
@ -54,6 +60,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
size="large"
/>
</div>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { Drawer, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media';
@ -113,14 +113,19 @@ export default function BulkAddToPlaylistModal({
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return (
<Modal
<Drawer
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open}
onOk={handleAdd}
onCancel={onClose}
confirmLoading={saving}
okText="Add"
okButtonProps={{ disabled: !selectedPlaylistId }}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleAdd} loading={saving} disabled={!selectedPlaylistId}>
Add
</Button>
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
@ -184,6 +189,6 @@ export default function BulkAddToPlaylistModal({
)}
</>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Modal, Form, Input, message } from 'antd';
import { Drawer, Form, Input, Button, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
@ -41,13 +41,19 @@ export default function CreateAlbumModal({
};
return (
<Modal
<Drawer
title="Create Album"
open={open}
onOk={handleCreate}
onCancel={onClose}
confirmLoading={loading}
okText="Create"
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleCreate} loading={loading}>
Create
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
@ -62,6 +68,6 @@ export default function CreateAlbumModal({
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
</div>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
import { Drawer, Form, Input, Switch, Button, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
@ -53,17 +53,12 @@ export default function CreatePlaylistModal({
}}
placement="right"
width={420}
style={{ top: 64 }}
styles={{ body: { paddingTop: 24 } }}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
}
>
<Form form={form} layout="vertical">

View File

@ -130,7 +130,8 @@ export default function EditPlaylistModal({
}}
placement="right"
width={isMobile ? '100%' : 520}
style={{ top: 64 }}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
loading={loading}
>
<Tabs

View File

@ -1,4 +1,4 @@
import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd';
import { Drawer, DatePicker, Select, Space, Alert, Switch, Button, message, Grid } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
@ -152,7 +152,7 @@ export default function SchedulePublishModal({
const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC');
return (
<Modal
<Drawer
title={
<Space>
<ClockCircleOutlined />
@ -160,15 +160,16 @@ export default function SchedulePublishModal({
</Space>
}
open={open}
onCancel={onClose}
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
onClose={onClose}
width={isMobile ? '95vw' : 600}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
}}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSchedule} loading={loading}>
{publishNow ? 'Publish Now' : 'Schedule'}
</Button>
}
aria-label="Schedule video publishing"
>
{video && (
@ -302,6 +303,6 @@ export default function SchedulePublishModal({
)}
</div>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme, Grid } from 'antd';
import { Drawer, Radio, InputNumber, Spin, Typography, Space, Button, theme, Grid } from 'antd';
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
import axios from 'axios';
@ -80,14 +80,23 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
});
return (
<Modal
<Drawer
title="Insert Donate Block"
open={open}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
onClose={onClose}
width={isMobile ? '95vw' : 520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
onClick={handleOk}
disabled={variant === 'set-amount' && (!amount || amount <= 0)}
>
Insert
</Button>
}
>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a donation block style to insert into your document.
@ -176,6 +185,6 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
</div>
</Space>
</Radio.Group>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Grid } from 'antd';
import { Drawer, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Button, Grid } from 'antd';
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import type { Product, ProductType } from '@/types/api';
@ -60,14 +60,19 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
});
return (
<Modal
<Drawer
title="Insert Product Card"
open={open}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: !selectedId }}
onClose={onClose}
width={isMobile ? '95vw' : 640}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} disabled={!selectedId}>
Insert
</Button>
}
>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
Select a product to embed as an inline purchase card.
@ -148,6 +153,6 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
})}
</Row>
</div>
</Modal>
</Drawer>
);
}

View File

@ -1,4 +1,4 @@
import { Modal, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { Drawer, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { useState } from 'react';
import { CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
@ -76,13 +76,20 @@ export default function CreateUserFromContactModal({
};
return (
<Modal
<Drawer
title="Create User Account"
open={open}
onCancel={() => { form.resetFields(); onClose(); }}
footer={null}
destroyOnHidden
onClose={() => { form.resetFields(); onClose(); }}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={submitting}>
Create Account
</Button>
}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text>
@ -123,17 +130,12 @@ export default function CreateUserFromContactModal({
<Switch />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit" loading={submitting}>
Create Account
</Button>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
</Space>
<Form.Item style={{ marginBottom: 0, display: 'none' }}>
<Button type="primary" htmlType="submit">
Create Account
</Button>
</Form.Item>
</Form>
</Modal>
</Drawer>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react';
import {
Modal,
Drawer,
Select,
Typography,
Radio,
@ -155,7 +155,7 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
];
return (
<Modal
<Drawer
title={
<Space>
<SwapOutlined />
@ -163,14 +163,13 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Space>
}
open={open}
onCancel={handleClose}
onClose={handleClose}
width={700}
footer={[
<Button key="cancel" onClick={handleClose}>
Cancel
</Button>,
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
key="merge"
type="primary"
danger
onClick={handleMerge}
@ -178,8 +177,8 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
disabled={!sourcePerson}
>
Confirm Merge
</Button>,
]}
</Button>
}
>
{/* Search for source person */}
<div style={{ marginBottom: 20 }}>
@ -301,6 +300,6 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Typography.Text>
</>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
import { Drawer, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
@ -130,12 +130,14 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
];
return (
<Modal
<Drawer
open={open}
onCancel={onCancel}
onClose={onCancel}
title="Insert Scheduling Poll"
footer={null}
width={700}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Tabs
@ -232,6 +234,6 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
},
]}
/>
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal, Input, Select, Typography, message, Space } from 'antd';
import { Drawer, Input, Select, Typography, message, Space, Button } from 'antd';
import { VideoCameraOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { mediaApi } from '@/lib/media-api';
@ -100,14 +100,19 @@ export default function RecommendVideoModal({
};
return (
<Modal
<Drawer
title="Recommend a Video"
open={open}
onOk={handleSend}
onCancel={onClose}
okText="Send"
confirmLoading={sending}
okButtonProps={{ disabled: !selectedFriendId || !selectedVideoId }}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSend} loading={sending} disabled={!selectedFriendId || !selectedVideoId}>
Send
</Button>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
@ -165,6 +170,6 @@ export default function RecommendVideoModal({
/>
</div>
</Space>
</Modal>
</Drawer>
);
}

View File

@ -13,7 +13,6 @@ import {
Statistic,
Row,
Col,
Modal,
Grid,
} from 'antd';
import {
@ -216,8 +215,18 @@ export default function CampaignModerationPage() {
},
];
const activeDrawerWidth = !isMobile
? (drawerOpen ? 600 : actionModalOpen ? 480 : 0)
: 0;
return (
<div>
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
{/* Stats Row */}
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
@ -284,12 +293,16 @@ export default function CampaignModerationPage() {
scroll={{ x: 'max-content' }}
/>
</div>
{/* Detail Drawer */}
<Drawer
title="Campaign Details"
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setSelectedCampaign(null); }}
width={isMobile ? '100%' : 600}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{selectedCampaign && (
<div>
@ -388,18 +401,29 @@ export default function CampaignModerationPage() {
)}
</Drawer>
{/* Action Modal (for rejection reason / change request) */}
<Modal
{/* Action Drawer (for rejection reason / change request) */}
<Drawer
title={actionType === 'reject' ? 'Reject Campaign' : 'Request Changes'}
open={actionModalOpen}
onCancel={() => { setActionModalOpen(false); setActionType(null); }}
onOk={() => {
if (selectedCampaign && actionType) {
handleModerate(selectedCampaign.id, actionType, actionReason, actionNotes);
}
}}
okText={actionType === 'reject' ? 'Reject' : 'Send Request'}
okButtonProps={{ danger: actionType === 'reject', loading: actionLoading }}
onClose={() => { setActionModalOpen(false); setActionType(null); }}
placement="right"
width={isMobile ? '100%' : 480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
danger={actionType === 'reject'}
loading={actionLoading}
onClick={() => {
if (selectedCampaign && actionType) {
handleModerate(selectedCampaign.id, actionType, actionReason, actionNotes);
}
}}
>
{actionType === 'reject' ? 'Reject' : 'Send Request'}
</Button>
}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Reason</Text>
@ -419,7 +443,7 @@ export default function CampaignModerationPage() {
placeholder="Notes for other admins"
/>
</div>
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Modal,
Drawer,
Form,
Input,
Select,
@ -262,54 +262,71 @@ export default function ImpactStoriesPage() {
{ key: 'ARCHIVED', label: 'Archived' },
];
const drawerWidth = 540;
const activeDrawerWidth = !isMobile && modalOpen ? drawerWidth : 0;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Select
placeholder="Filter by campaign"
allowClear
style={{ width: 260 }}
value={selectedCampaignId}
onChange={(val) => setSelectedCampaignId(val)}
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Story
</Button>
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Select
placeholder="Filter by campaign"
allowClear
style={{ width: 260 }}
value={selectedCampaignId}
onChange={(val) => setSelectedCampaignId(val)}
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Story
</Button>
</div>
<Tabs
activeKey={statusFilter || 'all'}
onChange={(key) => setStatusFilter(key === 'all' ? undefined : key)}
items={tabItems}
/>
<Table
scroll={{ x: 'max-content' }}
rowKey="id"
columns={columns}
dataSource={stories}
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} stories`,
}}
onChange={handleTableChange}
size="small"
/>
</div>
<Tabs
activeKey={statusFilter || 'all'}
onChange={(key) => setStatusFilter(key === 'all' ? undefined : key)}
items={tabItems}
/>
<Table
scroll={{ x: 'max-content' }}
rowKey="id"
columns={columns}
dataSource={stories}
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} stories`,
}}
onChange={handleTableChange}
size="small"
/>
<Modal
<Drawer
title={editingStory ? 'Edit Story' : 'Create Story'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
width={isMobile ? '100%' : 640}
onClose={() => setModalOpen(false)}
placement="right"
width={isMobile ? '100%' : drawerWidth}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnHidden
extra={
<Button type="primary" onClick={handleSave}>
{editingStory ? 'Save' : 'Create'}
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="campaignId" label="Campaign" rules={[{ required: true }]}>
@ -348,7 +365,7 @@ export default function ImpactStoriesPage() {
</>
)}
</Form>
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Button, Space, Tag, Input, Select, Drawer, Typography, message,
Statistic, Row, Col, Modal, Grid,
Statistic, Row, Col, Grid,
} from 'antd';
import {
CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined,
@ -149,8 +149,19 @@ export default function PetitionModerationPage() {
},
];
const activeDrawerWidth = !isMobile
? (drawerOpen ? 500 : actionModalOpen ? 480 : 0)
: 0;
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<>
<div
style={{
padding: isMobile ? 12 : 24,
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={6}><Card size="small"><Statistic title="Pending" value={stats.pending} valueStyle={{ color: '#faad14' }} /></Card></Col>
@ -183,11 +194,15 @@ export default function PetitionModerationPage() {
/>
</Card>
</div>
<Drawer
title="Review Petition"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 500}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{selectedPetition && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
@ -222,14 +237,24 @@ export default function PetitionModerationPage() {
)}
</Drawer>
<Modal
<Drawer
title={actionType === 'reject' ? 'Reject Petition' : 'Request Changes'}
open={actionModalOpen}
onCancel={() => { setActionModalOpen(false); setActionReason(''); }}
onOk={handleActionSubmit}
confirmLoading={actionLoading}
okText={actionType === 'reject' ? 'Reject' : 'Send'}
okButtonProps={actionType === 'reject' ? { danger: true } : {}}
onClose={() => { setActionModalOpen(false); setActionReason(''); }}
placement="right"
width={isMobile ? '100%' : 480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
danger={actionType === 'reject'}
loading={actionLoading}
onClick={handleActionSubmit}
>
{actionType === 'reject' ? 'Reject' : 'Send'}
</Button>
}
>
<TextArea
rows={4}
@ -237,7 +262,7 @@ export default function PetitionModerationPage() {
onChange={e => setActionReason(e.target.value)}
placeholder={actionType === 'reject' ? 'Reason for rejection...' : 'What changes are needed...'}
/>
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -219,8 +219,17 @@ export default function PetitionsPage() {
},
];
const activeDrawerWidth = !isMobile && drawerOpen ? 600 : 0;
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<>
<div
style={{
padding: isMobile ? 12 : 24,
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}><Card size="small"><Statistic title="Total" value={total} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="Active" value={petitions.filter(p => p.status === 'ACTIVE').length} valueStyle={{ color: '#52c41a' }} /></Card></Col>
@ -256,11 +265,15 @@ export default function PetitionsPage() {
/>
</Card>
</div>
<Drawer
title={editingPetition ? 'Edit Petition' : 'New Petition'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 600}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => setDrawerOpen(false)}>Cancel</Button>
@ -385,6 +398,6 @@ export default function PetitionsPage() {
}}
title="Select Cover Image"
/>
</div>
</>
);
}

View File

@ -362,8 +362,16 @@ export default function GalleryAdsPage() {
},
];
const activeDrawerWidth = !isMobile && drawerOpen ? 520 : 0;
return (
<FeatureGate feature="enableGalleryAds">
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Ad
@ -406,12 +414,16 @@ export default function GalleryAdsPage() {
size="middle"
/>
</div>
{/* Edit/Create Drawer */}
<Drawer
title={editingAd ? 'Edit Ad' : 'Create Ad'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 520}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
@ -605,6 +617,8 @@ export default function GalleryAdsPage() {
open={!!analyticsAd}
onClose={() => { setAnalyticsAd(null); setAnalytics(null); }}
width={isMobile ? '100%' : 520}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{analyticsLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>

View File

@ -239,8 +239,16 @@ export default function DonationPagesPage() {
},
];
const activeDrawerWidth = !isMobile && drawerOpen ? 640 : 0;
return (
<div>
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
@ -283,11 +291,15 @@ export default function DonationPagesPage() {
}}
/>
</div>
<Drawer
title={editing ? 'Edit Donation Page' : 'Create Donation Page'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 640}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
{editing ? 'Update' : 'Create'}
@ -370,6 +382,6 @@ export default function DonationPagesPage() {
}}
title="Choose Cover Photo"
/>
</div>
</>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Card, Input, Typography, App, Tag, Button, Space, Modal, Select, Grid } from 'antd';
import { Table, Card, Input, Typography, App, Tag, Button, Space, Drawer, Select, Grid } from 'antd';
import { SearchOutlined, DownloadOutlined, RollbackOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
@ -173,62 +173,73 @@ export default function DonationsPage() {
},
];
const drawerWidth = 480;
const activeDrawerWidth = !isMobile && refundModalOpen ? drawerWidth : 0;
return (
<div>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="Search by name or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={() => fetchDonations(1)}
style={{ width: 250 }}
/>
<Select
placeholder="Donation Page"
allowClear
value={donationPageFilter}
onChange={(v) => setDonationPageFilter(v)}
style={{ width: 200 }}
>
<Select.Option value="general">General (no page)</Select.Option>
{donationPageOptions.map((p) => (
<Select.Option key={p.id} value={p.id}>{p.title}</Select.Option>
))}
</Select>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
Export CSV
</Button>
</Space>
</Card>
<Table
dataSource={donations}
columns={columns}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (p) => fetchDonations(p),
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
/>
>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="Search by name or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={() => fetchDonations(1)}
style={{ width: 250 }}
/>
<Select
placeholder="Donation Page"
allowClear
value={donationPageFilter}
onChange={(v) => setDonationPageFilter(v)}
style={{ width: 200 }}
>
<Select.Option value="general">General (no page)</Select.Option>
{donationPageOptions.map((p) => (
<Select.Option key={p.id} value={p.id}>{p.title}</Select.Option>
))}
</Select>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
Export CSV
</Button>
</Space>
</Card>
<Modal
<Table
dataSource={donations}
columns={columns}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (p) => fetchDonations(p),
}}
/>
</div>
<Drawer
title="Refund Donation"
open={refundModalOpen}
onCancel={() => { setRefundModalOpen(false); setRefundTarget(null); }}
footer={[
<Button key="cancel" onClick={() => { setRefundModalOpen(false); setRefundTarget(null); }}>
Cancel
</Button>,
<Button key="refund" danger type="primary" loading={refundLoading} onClick={handleRefund}>
onClose={() => { setRefundModalOpen(false); setRefundTarget(null); }}
placement="right"
width={isMobile ? '100%' : drawerWidth}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button danger type="primary" loading={refundLoading} onClick={handleRefund}>
Confirm Refund
</Button>,
]}
</Button>
}
>
{refundTarget && (
<div>
@ -252,7 +263,7 @@ export default function DonationsPage() {
</p>
</div>
)}
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -241,8 +241,16 @@ export default function PlansPage() {
},
];
const activeDrawerWidth = !isMobile && drawerOpen ? 640 : 0;
return (
<div>
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
@ -283,11 +291,15 @@ export default function PlansPage() {
}}
/>
</div>
<Drawer
title={editing ? 'Edit Plan' : 'Create Plan'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 640}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
{editing ? 'Update' : 'Create'}
@ -400,6 +412,6 @@ export default function PlansPage() {
}}
title="Choose Cover Photo"
/>
</div>
</>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Modal, Input, List, Tag, Typography, Space, Button, App } from 'antd';
import { Drawer, Input, List, Tag, Typography, Space, Button, App } from 'antd';
import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useDebounce } from '@/hooks/useDebounce';
@ -105,13 +105,15 @@ export default function NewConversationModal({ open, onClose, onCreated }: Props
};
return (
<Modal
<Drawer
title="New Conversation"
open={open}
onCancel={handleReset}
footer={null}
onClose={handleReset}
destroyOnHidden
placement="right"
width={480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{!selectedContact ? (
// Step 1: Contact Search
@ -226,6 +228,6 @@ export default function NewConversationModal({ open, onClose, onCreated }: Props
</div>
</div>
)}
</Modal>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Drawer, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag, Modal } from 'antd';
import { Table, Button, Drawer, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined, UserAddOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
@ -658,15 +658,20 @@ export default function SmsContactsPage() {
</Form>
</Drawer>
{/* Add to List Modal */}
<Modal
{/* Add to List Drawer */}
<Drawer
title={`Add ${selectedRowKeys.length} contact${selectedRowKeys.length !== 1 ? 's' : ''} to list`}
open={addToListModalOpen}
onCancel={() => { setAddToListModalOpen(false); setAddToListTargetId(undefined); }}
onOk={handleAddToList}
okText="Add to List"
confirmLoading={addToListLoading}
okButtonProps={{ disabled: !addToListTargetId }}
onClose={() => { setAddToListModalOpen(false); setAddToListTargetId(undefined); }}
placement="right"
width={420}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" loading={addToListLoading} disabled={!addToListTargetId} onClick={handleAddToList}>
Add to List
</Button>
}
>
<Select
placeholder="Select target list"
@ -677,7 +682,7 @@ export default function SmsContactsPage() {
onChange={setAddToListTargetId}
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
/>
</Modal>
</Drawer>
{/* Import Contacts Drawer */}
<Drawer

View File

@ -4,7 +4,7 @@ import {
Button,
Tag,
Space,
Modal,
Drawer,
Form,
Input,
Select,
@ -235,45 +235,61 @@ export default function ChallengesAdminPage() {
},
];
const drawerWidth = 520;
const activeDrawerWidth = !isMobile && modalOpen ? drawerWidth : 0;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', marginBottom: 16, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 8 : 0 }}>
<Tabs
activeKey={statusFilter}
onChange={(key) => { setStatusFilter(key); setPage(1); }}
items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))}
size={isMobile ? 'small' : 'middle'}
style={{ marginBottom: 0 }}
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', marginBottom: 16, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 8 : 0 }}>
<Tabs
activeKey={statusFilter}
onChange={(key) => { setStatusFilter(key); setPage(1); }}
items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))}
size={isMobile ? 'small' : 'middle'}
style={{ marginBottom: 0 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Challenge
</Button>
</div>
<Table
dataSource={challenges}
columns={columns}
scroll={{ x: 'max-content' }}
rowKey="id"
size="small"
loading={loading}
pagination={{
current: page,
total: pagination?.total ?? 0,
pageSize: 20,
showSizeChanger: false,
onChange: setPage,
}}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Challenge
</Button>
</div>
<Table
dataSource={challenges}
columns={columns}
scroll={{ x: 'max-content' }}
rowKey="id"
size="small"
loading={loading}
pagination={{
current: page,
total: pagination?.total ?? 0,
pageSize: 20,
showSizeChanger: false,
onChange: setPage,
}}
/>
<Modal
<Drawer
title={editingId ? 'Edit Challenge' : 'New Challenge'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
onClose={() => setModalOpen(false)}
placement="right"
width={isMobile ? '100%' : drawerWidth}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnHidden
width={600}
extra={
<Button type="primary" loading={saving} onClick={handleSave}>
{editingId ? 'Save' : 'Create'}
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
@ -300,7 +316,7 @@ export default function ChallengesAdminPage() {
</Form.Item>
</Space>
</Form>
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Tabs, Table, Card, Statistic, Row, Col, Tag, Button, Modal, Select,
Popconfirm, Typography, Progress, Space, Spin, message,
Tabs, Table, Card, Statistic, Row, Col, Tag, Button, Drawer, Select,
Popconfirm, Typography, Progress, Space, Spin, message, Grid,
} from 'antd';
import {
StopOutlined,
@ -99,6 +99,8 @@ export default function SocialModerationPage() {
const [grantAchievementId, setGrantAchievementId] = useState('');
const [grantLoading, setGrantLoading] = useState(false);
const [userOptions, setUserOptions] = useState<Array<{ value: string; label: string }>>([]);
const { md } = Grid.useBreakpoint();
const isMobileScreen = !md;
useEffect(() => {
setPageHeader({ title: 'Social Moderation' });
@ -178,9 +180,17 @@ export default function SocialModerationPage() {
if (!data) return null;
const achievementDefMap = new Map(ACHIEVEMENT_DEFS.map((a) => [a.id, a]));
const drawerWidth = 480;
const activeDrawerWidth = !isMobileScreen && grantModalOpen ? drawerWidth : 0;
return (
<>
<div
style={{
marginRight: activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Tabs
defaultActiveKey="blocks"
items={[
@ -404,16 +414,27 @@ export default function SocialModerationPage() {
},
]}
/>
</div>
{/* Grant Achievement Modal */}
<Modal
{/* Grant Achievement Drawer */}
<Drawer
title="Grant Achievement"
open={grantModalOpen}
onOk={handleGrant}
onCancel={() => { setGrantModalOpen(false); setGrantUserId(''); setGrantAchievementId(''); }}
confirmLoading={grantLoading}
okText="Grant"
okButtonProps={{ disabled: !grantUserId || !grantAchievementId }}
onClose={() => { setGrantModalOpen(false); setGrantUserId(''); setGrantAchievementId(''); }}
placement="right"
width={isMobileScreen ? '100%' : drawerWidth}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
loading={grantLoading}
disabled={!grantUserId || !grantAchievementId}
onClick={handleGrant}
>
Grant
</Button>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
@ -443,7 +464,7 @@ export default function SocialModerationPage() {
/>
</div>
</Space>
</Modal>
</Drawer>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, Popconfirm,
message, Typography, Tabs,
Table, Tag, Button, Space, Drawer, Form, Input, Select, DatePicker, Popconfirm,
message, Typography, Tabs, Grid,
} from 'antd';
import {
PlusOutlined, CheckOutlined, StarOutlined, InboxOutlined, EditOutlined,
@ -173,6 +173,12 @@ export default function SpotlightAdminPage() {
setFeatureOpen(true);
};
const { md } = Grid.useBreakpoint();
const isMobile = !md;
const activeDrawerWidth = !isMobile
? (nominateOpen || editOpen || featureOpen ? 480 : 0)
: 0;
const columns: ColumnsType<Spotlight> = [
{
title: 'Volunteer',
@ -264,49 +270,63 @@ export default function SpotlightAdminPage() {
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Volunteer Spotlight</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNominateOpen(true)}>
Nominate
</Button>
<>
<div
style={{
marginRight: isMobile ? 0 : activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Volunteer Spotlight</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNominateOpen(true)}>
Nominate
</Button>
</div>
<Tabs
activeKey={statusFilter}
onChange={(key) => setStatusFilter(key)}
items={[
{ key: 'all', label: 'All' },
{ key: 'NOMINATED', label: 'Nominated' },
{ key: 'APPROVED', label: 'Approved' },
{ key: 'FEATURED', label: 'Featured' },
{ key: 'ARCHIVED', label: 'Archived' },
]}
style={{ marginBottom: 16 }}
/>
<Table
dataSource={spotlights}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
total: pagination.total,
pageSize: pagination.limit,
onChange: (page) => fetchSpotlights(page),
showTotal: (total) => `${total} spotlights`,
}}
scroll={{ x: 800 }}
/>
</div>
<Tabs
activeKey={statusFilter}
onChange={(key) => setStatusFilter(key)}
items={[
{ key: 'all', label: 'All' },
{ key: 'NOMINATED', label: 'Nominated' },
{ key: 'APPROVED', label: 'Approved' },
{ key: 'FEATURED', label: 'Featured' },
{ key: 'ARCHIVED', label: 'Archived' },
]}
style={{ marginBottom: 16 }}
/>
<Table
dataSource={spotlights}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
total: pagination.total,
pageSize: pagination.limit,
onChange: (page) => fetchSpotlights(page),
showTotal: (total) => `${total} spotlights`,
}}
scroll={{ x: 800 }}
/>
{/* Nominate Modal */}
<Modal
{/* Nominate Drawer */}
<Drawer
title="Nominate Volunteer"
open={nominateOpen}
onCancel={() => { setNominateOpen(false); nominateForm.resetFields(); }}
onOk={() => nominateForm.submit()}
okText="Nominate"
onClose={() => { setNominateOpen(false); nominateForm.resetFields(); }}
placement="right"
width={isMobile ? '100%' : 480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={() => nominateForm.submit()}>
Nominate
</Button>
}
>
<Form form={nominateForm} layout="vertical" onFinish={handleNominate}>
<Form.Item name="userId" label="Volunteer" rules={[{ required: true, message: 'Select a volunteer' }]}>
@ -329,15 +349,22 @@ export default function SpotlightAdminPage() {
<TextArea rows={4} maxLength={2000} placeholder="Tell us why this volunteer deserves recognition" showCount />
</Form.Item>
</Form>
</Modal>
</Drawer>
{/* Edit Modal */}
<Modal
{/* Edit Drawer */}
<Drawer
title="Edit Spotlight"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => editForm.submit()}
okText="Save"
onClose={() => setEditOpen(false)}
placement="right"
width={isMobile ? '100%' : 480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
}
>
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
<Form.Item name="headline" label="Headline">
@ -347,16 +374,22 @@ export default function SpotlightAdminPage() {
<TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
</Form>
</Modal>
</Drawer>
{/* Feature Modal */}
<Modal
{/* Feature Drawer */}
<Drawer
title="Feature Spotlight"
open={featureOpen}
onCancel={() => setFeatureOpen(false)}
onOk={handleFeature}
okText="Feature"
okButtonProps={{ disabled: !featureMonth }}
onClose={() => setFeatureOpen(false)}
placement="right"
width={isMobile ? '100%' : 480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" disabled={!featureMonth} onClick={handleFeature}>
Feature
</Button>
}
>
<Text>Select the month to feature this volunteer:</Text>
<div style={{ marginTop: 12 }}>
@ -367,7 +400,7 @@ export default function SpotlightAdminPage() {
style={{ width: '100%' }}
/>
</div>
</Modal>
</div>
</Drawer>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card, Button, Row, Col, Statistic, Table, Typography, Space,
Input, InputNumber, Form, Modal, Spin, Grid, App, Empty,
Input, InputNumber, Form, Drawer, Spin, Grid, App, Empty,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, GiftOutlined, TeamOutlined } from '@ant-design/icons';
@ -192,14 +192,21 @@ export default function ReferralsPage() {
</Card>
</Space>
{/* Create Code Modal */}
<Modal
{/* Create Code Drawer */}
<Drawer
title="Create Invite Code"
open={createOpen}
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={creating}
onClose={() => { setCreateOpen(false); form.resetFields(); }}
placement="right"
width={420}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnHidden
extra={
<Button type="primary" loading={creating} onClick={() => form.submit()}>
Create
</Button>
}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="maxUses" label="Max Uses (0 = unlimited)">
@ -212,7 +219,7 @@ export default function ReferralsPage() {
<Input.TextArea maxLength={200} rows={2} placeholder="Optional note for your reference" />
</Form.Item>
</Form>
</Modal>
</Drawer>
</div>
);
}

View File

@ -5,7 +5,7 @@ import {
Card,
Row,
Col,
Modal,
Drawer,
Form,
Input,
Select,
@ -209,13 +209,19 @@ export default function SharedCalendarsPage() {
</Row>
)}
<Modal
<Drawer
title="Create Shared Calendar"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={creating}
okText="Create"
onClose={() => setCreateModalOpen(false)}
placement="right"
width={480}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" loading={creating} onClick={() => form.submit()}>
Create
</Button>
}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
@ -248,7 +254,7 @@ export default function SharedCalendarsPage() {
/>
</Form.Item>
</Form>
</Modal>
</Drawer>
</div>
</FeatureGate>
);

View File

@ -1,5 +1,9 @@
{
"query": {
"folder": "/config/workspace"
},
"update": {
"checked": 1775689146867,
"version": "4.115.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -112,7 +112,7 @@
"assets/images/social/docs/getting-started/environment-variables.png": "1331e7c713c710c16546f70f49d54bd9a31a9200",
"assets/images/social/docs/getting-started/features.png": "35c28a26e0eafb6ec6b0eeab603d42db5210fe2e",
"assets/images/social/docs/getting-started/first-steps.png": "a164f53c692196d5a2331016e4999540498c7f98",
"assets/images/social/docs/getting-started/index.png": "ac74959998bde112e05a4fddbc3e391630139c74",
"assets/images/social/docs/getting-started/index.png": "d4dbe1a6d0b5fd5feeed232fab5e76aef49333aa",
"assets/images/social/docs/getting-started/installation.png": "8867754bd8e0597a1adf63be08683ace57287fc6",
"assets/images/social/docs/getting-started/prerequisites.png": "bf4ef7d8f278b7960f51ec489c9e371de159f965",
"assets/images/social/docs/getting-started/services.png": "6d65624499df594ee550dfafa0b976650931b078",

View File

@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 0,
"updated_at": "2026-04-03T08:52:26-06:00",
"updated_at": "2026-04-07T17:26:04-06:00",
"created_at": "2025-05-28T14:54:59-06:00",
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
"default_branch": "main",
"last_build_update": "2026-04-03T08:52:26-06:00"
"last_build_update": "2026-04-07T17:26:04-06:00"
}

View File

@ -4,10 +4,10 @@
"description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.",
"html_url": "https://github.com/anthropics/claude-code",
"language": "Shell",
"stars_count": 110576,
"forks_count": 18412,
"open_issues_count": 9331,
"updated_at": "2026-04-07T21:20:13Z",
"stars_count": 110600,
"forks_count": 18415,
"open_issues_count": 9302,
"updated_at": "2026-04-07T23:27:04Z",
"created_at": "2025-02-22T17:41:21Z",
"clone_url": "https://github.com/anthropics/claude-code.git",
"ssh_url": "git@github.com:anthropics/claude-code.git",

View File

@ -4,10 +4,10 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 76996,
"stars_count": 76998,
"forks_count": 6596,
"open_issues_count": 143,
"updated_at": "2026-04-07T19:48:34Z",
"updated_at": "2026-04-07T23:08:42Z",
"created_at": "2019-02-27T16:50:41Z",
"clone_url": "https://github.com/coder/code-server.git",
"ssh_url": "git@github.com:coder/code-server.git",

View File

@ -4,10 +4,10 @@
"description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.",
"html_url": "https://github.com/gethomepage/homepage",
"language": "JavaScript",
"stars_count": 29400,
"stars_count": 29403,
"forks_count": 1853,
"open_issues_count": 2,
"updated_at": "2026-04-07T21:20:06Z",
"updated_at": "2026-04-07T23:17:05Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",

View File

@ -7,7 +7,7 @@
"stars_count": 54776,
"forks_count": 6537,
"open_issues_count": 2822,
"updated_at": "2026-04-07T21:08:31Z",
"updated_at": "2026-04-07T22:34:41Z",
"created_at": "2016-11-01T02:13:26Z",
"clone_url": "https://github.com/go-gitea/gitea.git",
"ssh_url": "git@github.com:go-gitea/gitea.git",

View File

@ -4,10 +4,10 @@
"description": "High performance, self-hosted, newsletter and mailing list manager with a modern dashboard. Single binary app.",
"html_url": "https://github.com/knadh/listmonk",
"language": "Go",
"stars_count": 19473,
"stars_count": 19474,
"forks_count": 1986,
"open_issues_count": 97,
"updated_at": "2026-04-07T17:09:29Z",
"updated_at": "2026-04-07T22:55:19Z",
"created_at": "2019-06-26T05:08:39Z",
"clone_url": "https://github.com/knadh/listmonk.git",
"ssh_url": "git@github.com:knadh/listmonk.git",

View File

@ -4,13 +4,13 @@
"description": "Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.",
"html_url": "https://github.com/n8n-io/n8n",
"language": "TypeScript",
"stars_count": 182862,
"forks_count": 56569,
"open_issues_count": 1503,
"updated_at": "2026-04-07T21:18:33Z",
"stars_count": 182875,
"forks_count": 56576,
"open_issues_count": 1502,
"updated_at": "2026-04-07T23:16:46Z",
"created_at": "2019-06-22T09:24:21Z",
"clone_url": "https://github.com/n8n-io/n8n.git",
"ssh_url": "git@github.com:n8n-io/n8n.git",
"default_branch": "master",
"last_build_update": "2026-04-07T21:11:12Z"
"last_build_update": "2026-04-07T23:30:49Z"
}

View File

@ -4,10 +4,10 @@
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
"html_url": "https://github.com/nocodb/nocodb",
"language": "TypeScript",
"stars_count": 62627,
"stars_count": 62628,
"forks_count": 4714,
"open_issues_count": 668,
"updated_at": "2026-04-07T21:10:45Z",
"open_issues_count": 669,
"updated_at": "2026-04-07T21:46:28Z",
"created_at": "2017-10-29T18:51:48Z",
"clone_url": "https://github.com/nocodb/nocodb.git",
"ssh_url": "git@github.com:nocodb/nocodb.git",

View File

@ -4,13 +4,13 @@
"description": "Get up and running with Kimi-K2.5, GLM-5, MiniMax, DeepSeek, gpt-oss, Qwen, Gemma and other models.",
"html_url": "https://github.com/ollama/ollama",
"language": "Go",
"stars_count": 168028,
"forks_count": 15420,
"open_issues_count": 2875,
"updated_at": "2026-04-07T21:14:42Z",
"stars_count": 168040,
"forks_count": 15419,
"open_issues_count": 2871,
"updated_at": "2026-04-07T23:28:46Z",
"created_at": "2023-06-26T19:39:32Z",
"clone_url": "https://github.com/ollama/ollama.git",
"ssh_url": "git@github.com:ollama/ollama.git",
"default_branch": "main",
"last_build_update": "2026-04-07T16:18:40Z"
"last_build_update": "2026-04-07T23:28:36Z"
}

View File

@ -4,10 +4,10 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
"stars_count": 26467,
"stars_count": 26468,
"forks_count": 4069,
"open_issues_count": 1,
"updated_at": "2026-04-07T19:22:44Z",
"updated_at": "2026-04-07T22:53:37Z",
"created_at": "2016-01-28T22:09:23Z",
"clone_url": "https://github.com/squidfunk/mkdocs-material.git",
"ssh_url": "git@github.com:squidfunk/mkdocs-material.git",

View File

@ -11,6 +11,9 @@ tags:
# Changemaker Control Panel (CCP)
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
The Changemaker Control Panel is a **multi-tenant management layer** for operators who run multiple Changemaker Lite instances from a single server. It provides a web UI to provision, monitor, and maintain a fleet of instances without manual configuration.
!!! info "Single instance?"

View File

@ -11,6 +11,9 @@ tags:
# Environment Variables
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Changemaker Lite uses a single `.env` file at the project root to configure all services. Copy the example file to get started:
```bash

View File

@ -11,6 +11,9 @@ search:
# Features at a Glance
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from **Settings** in the admin panel.
---

View File

@ -12,6 +12,9 @@ search:
# First Steps
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
You've installed Changemaker Lite — here's what to do next.
---

View File

@ -1,43 +1,127 @@
---
title: Getting Started
description: Install and configure Changemaker Lite from scratch.
description: Everything you need to deploy Changemaker Lite — from prerequisites to your first login.
icon: material/rocket-launch
tags:
- guide
- getting-started
- operator
- planning
search:
boost: 2
---
# Getting Started
This guide walks you through installing Changemaker Lite, running your first deployment, and logging into the admin dashboard.
Changemaker Lite is a self-hosted campaign platform that runs entirely on Docker. This guide takes you from zero to a working deployment — whether you're evaluating locally or launching for a live campaign.
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
![Admin Dashboard](../../assets/images/screenshots/getting-started/dashboard.png){ loading=lazy }
---
## Prerequisites
## What You'll Need
- **Docker** 24+ and **Docker Compose** v2
- **OpenSSL** (for secret generation)
- A Linux server (Ubuntu 22.04+ recommended) or macOS for development
- At least 2 GB RAM and 10 GB disk space
- A domain name (optional, but recommended for production)
Before deploying, gather the essentials. The requirements differ depending on whether you're running a **quick local test** or a **production deployment** serving real users.
## Quick Install (Pre-built Images)
### :material-laptop:{ .lg } Development (Local Testing)
The fastest way to deploy — no source code, no compilation:
All you need is Docker — no domain, tunnel, or SMTP required:
<div class="grid cards" markdown>
- :material-docker:{ .lg .middle } **Docker 24+ & Compose v2**
---
The only hard requirement. All 30+ services run as containers.
- :material-lock:{ .lg .middle } **OpenSSL**
---
Used by the config wizard to auto-generate 21 secrets (JWT, encryption keys, passwords).
- :material-memory:{ .lg .middle } **2 GB RAM / 10 GB Disk**
---
Minimum for core services. 4 GB recommended if enabling media, chat, or monitoring.
</div>
!!! tip "MailHog captures all emails locally"
In dev mode, every outbound email is caught by MailHog at `http://localhost:8025` — no SMTP provider needed.
### :material-server:{ .lg } Production Deployment
For a deployment that serves real users, you'll also need:
<div class="grid cards" markdown>
- :material-web:{ .lg .middle } **A Domain Name**
---
Changemaker Lite uses **subdomain routing**`app.`, `api.`, `docs.`, `git.`, and 10+ more subdomains are created automatically. Wildcard DNS (`*.yourdomain.org`) is the simplest approach.
Budget ~$1015/year from any registrar.
- :material-tunnel:{ .lg .middle } **A Reverse Tunnel or Public IP**
---
Your server needs to be reachable from the internet. Built-in support for **[Pangolin](https://github.com/fosrl/pangolin)** — a self-hosted tunnel with SSL, subdomain routing, and access control. The admin dashboard includes a one-click setup wizard.
*Alternatives: Cloudflare Tunnel, a VPS with public IP, or any reverse proxy with SSL.*
- :material-email-fast:{ .lg .middle } **SMTP Email Provider**
---
Campaign messages, password resets, volunteer invitations, and newsletters all need real email delivery.
Recommended: **[Proton Mail](https://proton.me/mail)** (privacy-focused), **[Mailgun](https://www.mailgun.com)** (100/day free), **[Amazon SES](https://aws.amazon.com/ses/)** (cheapest at scale), or **[Brevo](https://www.brevo.com)** (300/day free).
- :material-linux:{ .lg .middle } **A Linux Server**
---
Any Linux with Docker. Ubuntu 22.04+ LTS recommended. A VPS from [DigitalOcean](https://www.digitalocean.com), [Hetzner](https://www.hetzner.com), or [Linode](https://www.linode.com) works great — or a spare machine on your network if using a tunnel.
</div>
??? info "Optional services that enhance your deployment"
These aren't required but unlock additional features:
| Service | Purpose | Free Tier |
|---------|---------|-----------|
| **[Stripe](https://stripe.com)** | Donations, merchandise, membership plans | Free account, pay-as-you-go |
| **[Mapbox](https://www.mapbox.com/pricing)** or **[Google Maps](https://developers.google.com/maps)** | Better geocoding accuracy for mapping | 100K req/mo (Mapbox) or $200/mo credit (Google) |
| **[MaxMind GeoLite2](https://www.maxmind.com/en/geolite2/signup)** | Geographic analytics (visitor locations) | Free account |
| **[Android phone + Termux](https://termux.dev)** | SMS campaigns via physical phone gateway | Free — no third-party SMS costs |
| **Public IP + UDP 10000** | Jitsi self-hosted video conferencing | Free (requires open firewall port) |
See [Prerequisites & External Services](prerequisites.md) for setup details on each.
---
## :material-lightning-bolt: Quick Install
### Pre-built Images (Recommended)
The fastest path — no source code, no compilation:
```bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
```
This downloads a lightweight release package (~2 MB), runs the configuration wizard, and pulls pre-built Docker images. First startup takes ~2 minutes. See [Installation](installation.md#pre-built-image-installation) for details.
This downloads a lightweight release package (~2 MB), runs the **14-step configuration wizard**, and pulls pre-built Docker images. First startup takes ~2 minutes.
## Quick Start (From Source)
### From Source (Development)
For development or customization, clone the full repository:
For development or customization:
```bash
git clone https://gitea.bnkops.com/admin/changemaker.lite
@ -52,12 +136,12 @@ bash config.sh
docker compose up -d
```
Open **http://localhost:3000** and sign in with the admin email and password you configured. The API container automatically runs database migrations and seeding on first startup — no manual steps needed.
Open **http://localhost:3000** and sign in with the admin credentials you configured. The API container automatically runs database migrations and seeding on first startup.
!!! warning "Change your password"
If you used the wizard's generated password, change it immediately from the admin dashboard.
For the full setup walkthrough, see [Installation](installation.md).
---
## Configuration Wizard
@ -82,7 +166,25 @@ The `config.sh` wizard produces a fully populated `.env` file in **14 steps**:
See [Installation](installation.md) for detailed documentation of each step.
## Services
---
## Pre-Installation Checklist
Use this to make sure you're ready before running the installer:
- [ ] **Docker 24+** and **Docker Compose v2** installed
- [ ] **OpenSSL** installed (for secret generation)
- [ ] **Domain name** registered and DNS accessible
- [ ] **DNS configured** — wildcard `*.yourdomain.org` or individual subdomains pointing to your tunnel/server
- [ ] **Tunnel or public IP** — Pangolin credentials (API key + Org ID), or server with public IP + SSL
- [ ] **SMTP credentials** — host, port, username, password from your email provider
- [ ] *(Optional)* Stripe account for payments
- [ ] *(Optional)* Mapbox or Google Maps API key for geocoding
- [ ] *(Optional)* MaxMind account for geographic analytics
---
## Services at a Glance
Changemaker Lite includes **30+ Docker services** organized into 8 categories:
@ -99,15 +201,58 @@ Changemaker Lite includes **30+ Docker services** organized into 8 categories:
See [Services Overview](services.md) for the complete catalog with ports, feature flags, and detailed descriptions.
---
## Next Steps
- [Prerequisites](prerequisites.md) — external services checklist (domain, SMTP, tunnel)
- [Installation](installation.md) — detailed setup walkthrough and manual configuration
- [Services Overview](services.md) — complete service catalog (30+ containers)
- [Environment Variables](environment-variables.md) — complete `.env` reference
- [First Steps](first-steps.md) — create your first campaign and add locations
- [Updates & Upgrades](upgrades.md) — keep your installation current
- [Control Panel (CCP)](control-panel.md) — multi-instance management
- [Features at a Glance](features.md) — visual overview of every module
- [Admin Guide](../admin/index.md) — full administration reference
- [Deployment](../deployment/index.md) — production setup with SSL and tunneling
<div class="grid cards" markdown>
- :material-clipboard-check-outline:{ .lg .middle } **[Prerequisites](prerequisites.md)**
---
Full details on every external service — domain setup, SMTP providers, tunnel options, and optional integrations.
- :material-download:{ .lg .middle } **[Installation](installation.md)**
---
Detailed setup walkthrough, manual configuration, and the full config wizard reference.
- :material-view-grid:{ .lg .middle } **[Services Overview](services.md)**
---
Complete catalog of 30+ containers with ports, feature flags, and descriptions.
- :material-cog:{ .lg .middle } **[Environment Variables](environment-variables.md)**
---
Complete `.env` reference for every setting.
- :material-flag-checkered:{ .lg .middle } **[First Steps](first-steps.md)**
---
Create your first campaign, add locations to the map, and invite volunteers.
- :material-arrow-up-circle:{ .lg .middle } **[Updates & Upgrades](upgrades.md)**
---
Keep your installation current with zero-downtime upgrades.
- :material-monitor-dashboard:{ .lg .middle } **[Control Panel](control-panel.md)**
---
Manage multiple Changemaker Lite instances from a single dashboard.
- :material-star-shooting:{ .lg .middle } **[Features at a Glance](features.md)**
---
Visual overview of every module — advocacy, mapping, media, payments, and more.
</div>

View File

@ -13,6 +13,9 @@ search:
# Installation
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose. The `config.sh` wizard handles all configuration — or you can set things up manually.
!!! info "Have your external services ready?"

View File

@ -13,6 +13,9 @@ search:
# Prerequisites & External Services
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Before running the installer, gather the external services and accounts listed below. Having these ready makes the configuration wizard a smooth, uninterrupted process.
!!! tip "Don't have these yet?"
@ -183,7 +186,7 @@ Setting up infrastructure — domains, tunnels, SMTP, servers — can be the har
!!! quote "Built by organizers, for organizers"
Bunker Operations exists so campaign teams can focus on **building power** — not wrestling with infrastructure. We provide the plumbing so you can focus on the mission.
**Get in touch:** [bnkops.com](https://bnkops.com) or email `hello@bnkops.com`
**Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
---

View File

@ -11,6 +11,9 @@ tags:
# Services Overview
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Changemaker Lite runs as **30+ Docker containers** orchestrated by Docker Compose. This page catalogs every service, organized by category.
!!! tip "Quick reference"

View File

@ -10,6 +10,9 @@ tags:
# Updates & Upgrades
!!! tip "Need help getting set up?"
**Bunker Operations** provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. **Get in touch:** [bnkops.com](https://bnkops.com) | `admin@bnkops.ca`
Changemaker Lite includes a built-in upgrade system that pulls code updates, rebuilds containers, runs database migrations, and restarts services — all while preserving your customizations.
There are two ways to upgrade:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 0,
"updated_at": "2026-04-03T08:52:26-06:00",
"updated_at": "2026-04-07T17:26:04-06:00",
"created_at": "2025-05-28T14:54:59-06:00",
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
"default_branch": "main",
"last_build_update": "2026-04-03T08:52:26-06:00"
"last_build_update": "2026-04-07T17:26:04-06:00"
}

View File

@ -4,10 +4,10 @@
"description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.",
"html_url": "https://github.com/anthropics/claude-code",
"language": "Shell",
"stars_count": 110576,
"forks_count": 18412,
"open_issues_count": 9331,
"updated_at": "2026-04-07T21:20:13Z",
"stars_count": 110600,
"forks_count": 18415,
"open_issues_count": 9302,
"updated_at": "2026-04-07T23:27:04Z",
"created_at": "2025-02-22T17:41:21Z",
"clone_url": "https://github.com/anthropics/claude-code.git",
"ssh_url": "git@github.com:anthropics/claude-code.git",

View File

@ -4,10 +4,10 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 76996,
"stars_count": 76998,
"forks_count": 6596,
"open_issues_count": 143,
"updated_at": "2026-04-07T19:48:34Z",
"updated_at": "2026-04-07T23:08:42Z",
"created_at": "2019-02-27T16:50:41Z",
"clone_url": "https://github.com/coder/code-server.git",
"ssh_url": "git@github.com:coder/code-server.git",

View File

@ -4,10 +4,10 @@
"description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.",
"html_url": "https://github.com/gethomepage/homepage",
"language": "JavaScript",
"stars_count": 29400,
"stars_count": 29403,
"forks_count": 1853,
"open_issues_count": 2,
"updated_at": "2026-04-07T21:20:06Z",
"updated_at": "2026-04-07T23:17:05Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",

View File

@ -7,7 +7,7 @@
"stars_count": 54776,
"forks_count": 6537,
"open_issues_count": 2822,
"updated_at": "2026-04-07T21:08:31Z",
"updated_at": "2026-04-07T22:34:41Z",
"created_at": "2016-11-01T02:13:26Z",
"clone_url": "https://github.com/go-gitea/gitea.git",
"ssh_url": "git@github.com:go-gitea/gitea.git",

View File

@ -4,10 +4,10 @@
"description": "High performance, self-hosted, newsletter and mailing list manager with a modern dashboard. Single binary app.",
"html_url": "https://github.com/knadh/listmonk",
"language": "Go",
"stars_count": 19473,
"stars_count": 19474,
"forks_count": 1986,
"open_issues_count": 97,
"updated_at": "2026-04-07T17:09:29Z",
"updated_at": "2026-04-07T22:55:19Z",
"created_at": "2019-06-26T05:08:39Z",
"clone_url": "https://github.com/knadh/listmonk.git",
"ssh_url": "git@github.com:knadh/listmonk.git",

View File

@ -4,13 +4,13 @@
"description": "Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.",
"html_url": "https://github.com/n8n-io/n8n",
"language": "TypeScript",
"stars_count": 182862,
"forks_count": 56569,
"open_issues_count": 1503,
"updated_at": "2026-04-07T21:18:33Z",
"stars_count": 182875,
"forks_count": 56576,
"open_issues_count": 1502,
"updated_at": "2026-04-07T23:16:46Z",
"created_at": "2019-06-22T09:24:21Z",
"clone_url": "https://github.com/n8n-io/n8n.git",
"ssh_url": "git@github.com:n8n-io/n8n.git",
"default_branch": "master",
"last_build_update": "2026-04-07T21:11:12Z"
"last_build_update": "2026-04-07T23:30:49Z"
}

View File

@ -4,10 +4,10 @@
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
"html_url": "https://github.com/nocodb/nocodb",
"language": "TypeScript",
"stars_count": 62627,
"stars_count": 62628,
"forks_count": 4714,
"open_issues_count": 668,
"updated_at": "2026-04-07T21:10:45Z",
"open_issues_count": 669,
"updated_at": "2026-04-07T21:46:28Z",
"created_at": "2017-10-29T18:51:48Z",
"clone_url": "https://github.com/nocodb/nocodb.git",
"ssh_url": "git@github.com:nocodb/nocodb.git",

View File

@ -4,13 +4,13 @@
"description": "Get up and running with Kimi-K2.5, GLM-5, MiniMax, DeepSeek, gpt-oss, Qwen, Gemma and other models.",
"html_url": "https://github.com/ollama/ollama",
"language": "Go",
"stars_count": 168028,
"forks_count": 15420,
"open_issues_count": 2875,
"updated_at": "2026-04-07T21:14:42Z",
"stars_count": 168040,
"forks_count": 15419,
"open_issues_count": 2871,
"updated_at": "2026-04-07T23:28:46Z",
"created_at": "2023-06-26T19:39:32Z",
"clone_url": "https://github.com/ollama/ollama.git",
"ssh_url": "git@github.com:ollama/ollama.git",
"default_branch": "main",
"last_build_update": "2026-04-07T16:18:40Z"
"last_build_update": "2026-04-07T23:28:36Z"
}

View File

@ -4,10 +4,10 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
"stars_count": 26467,
"stars_count": 26468,
"forks_count": 4069,
"open_issues_count": 1,
"updated_at": "2026-04-07T19:22:44Z",
"updated_at": "2026-04-07T22:53:37Z",
"created_at": "2016-01-28T22:09:23Z",
"clone_url": "https://github.com/squidfunk/mkdocs-material.git",
"ssh_url": "git@github.com:squidfunk/mkdocs-material.git",

View File

@ -2990,6 +2990,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="changemaker-control-panel-ccp">Changemaker Control Panel (CCP)<a class="headerlink" href="#changemaker-control-panel-ccp" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>The Changemaker Control Panel is a <strong>multi-tenant management layer</strong> for operators who run multiple Changemaker Lite instances from a single server. It provides a web UI to provision, monitor, and maintain a fleet of instances without manual configuration.</p>
<div class="admonition info">
<p class="admonition-title">Single instance?</p>

View File

@ -3437,6 +3437,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="environment-variables">Environment Variables<a class="headerlink" href="#environment-variables" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Changemaker Lite uses a single <code>.env</code> file at the project root to configure all services. Copy the example file to get started:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a>cp<span class="w"> </span>.env.example<span class="w"> </span>.env
</span></code></pre></div>

View File

@ -2521,6 +2521,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="features-at-a-glance">Features at a Glance<a class="headerlink" href="#features-at-a-glance" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from <strong>Settings</strong> in the admin panel.</p>
<hr />
<h2 id="core-features">Core Features<a class="headerlink" href="#core-features" title="Permanent link">&para;</a></h2>

View File

@ -2572,6 +2572,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="first-steps">First Steps<a class="headerlink" href="#first-steps" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>You've installed Changemaker Lite — here's what to do next.</p>
<hr />
<h2 id="1-log-in">1. Log In<a class="headerlink" href="#1-log-in" title="Permanent link">&para;</a></h2>

View File

@ -7,7 +7,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Install and configure Changemaker Lite from scratch.">
<meta name="description" content="Everything you need to deploy Changemaker Lite — from prerequisites to your first login.">
<meta name="author" content="Bunker Operations">
@ -77,7 +77,7 @@
<meta property="og:type" content="website" />
<meta property="og:title" content="Getting Started - Changemaker Lite" />
<meta property="og:description" content="Install and configure Changemaker Lite from scratch." />
<meta property="og:description" content="Everything you need to deploy Changemaker Lite — from prerequisites to your first login." />
<meta property="og:image" content="https://cmlite.org/assets/images/social/docs/getting-started/index.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
@ -85,7 +85,7 @@
<meta property="og:url" content="https://cmlite.org/docs/getting-started/" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Getting Started - Changemaker Lite" />
<meta property="twitter:description" content="Install and configure Changemaker Lite from scratch." />
<meta property="twitter:description" content="Everything you need to deploy Changemaker Lite — from prerequisites to your first login." />
<meta property="twitter:image" content="https://cmlite.org/assets/images/social/docs/getting-started/index.png" />
</head>
@ -2221,36 +2221,81 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
<li class="md-nav__item">
<a href="#prerequisites" class="md-nav__link">
<a href="#what-youll-need" class="md-nav__link">
<span class="md-ellipsis">
Prerequisites
What You'll Need
</span>
</a>
<nav class="md-nav" aria-label="What You&#39;ll Need">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#development-local-testing" class="md-nav__link">
<span class="md-ellipsis">
Development (Local Testing)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#quick-install-pre-built-images" class="md-nav__link">
<li class="md-nav__item">
<a href="#production-deployment" class="md-nav__link">
<span class="md-ellipsis">
Quick Install (Pre-built Images)
Production Deployment
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#quick-start-from-source" class="md-nav__link">
<a href="#quick-install" class="md-nav__link">
<span class="md-ellipsis">
Quick Start (From Source)
Quick Install
</span>
</a>
<nav class="md-nav" aria-label="Quick Install">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#pre-built-images-recommended" class="md-nav__link">
<span class="md-ellipsis">
Pre-built Images (Recommended)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#from-source-development" class="md-nav__link">
<span class="md-ellipsis">
From Source (Development)
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
@ -2265,10 +2310,21 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
</li>
<li class="md-nav__item">
<a href="#services" class="md-nav__link">
<a href="#pre-installation-checklist" class="md-nav__link">
<span class="md-ellipsis">
Services
Pre-Installation Checklist
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#services-at-a-glance" class="md-nav__link">
<span class="md-ellipsis">
Services at a Glance
</span>
</a>
@ -2390,6 +2446,13 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<span class="md-tag">operator</span>
<span class="md-tag">planning</span>
</nav>
@ -2411,23 +2474,118 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="getting-started">Getting Started<a class="headerlink" href="#getting-started" title="Permanent link">&para;</a></h1>
<p>This guide walks you through installing Changemaker Lite, running your first deployment, and logging into the admin dashboard.</p>
<p><img alt="Admin Dashboard" loading="lazy" src="../../assets/images/screenshots/getting-started/dashboard.png" /></p>
<h2 id="prerequisites">Prerequisites<a class="headerlink" href="#prerequisites" title="Permanent link">&para;</a></h2>
<p>Changemaker Lite is a self-hosted campaign platform that runs entirely on Docker. This guide takes you from zero to a working deployment — whether you're evaluating locally or launching for a live campaign.</p>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<hr />
<h2 id="what-youll-need">What You'll Need<a class="headerlink" href="#what-youll-need" title="Permanent link">&para;</a></h2>
<p>Before deploying, gather the essentials. The requirements differ depending on whether you're running a <strong>quick local test</strong> or a <strong>production deployment</strong> serving real users.</p>
<h3 id="development-local-testing"><span class="twemoji lg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 6h16v10H4m16 2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2H0v2h24v-2z"/></svg></span> Development (Local Testing)<a class="headerlink" href="#development-local-testing" title="Permanent link">&para;</a></h3>
<p>All you need is Docker — no domain, tunnel, or SMTP required:</p>
<div class="grid cards">
<ul>
<li><strong>Docker</strong> 24+ and <strong>Docker Compose</strong> v2</li>
<li><strong>OpenSSL</strong> (for secret generation)</li>
<li>A Linux server (Ubuntu 22.04+ recommended) or macOS for development</li>
<li>At least 2 GB RAM and 10 GB disk space</li>
<li>A domain name (optional, but recommended for production)</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.81 10.25c-.06-.04-.56-.43-1.64-.43-.28 0-.56.03-.84.08-.21-1.4-1.38-2.11-1.43-2.14l-.29-.17-.18.27c-.24.36-.43.77-.51 1.19-.2.8-.08 1.56.33 2.21-.49.28-1.29.35-1.46.35H2.62c-.34 0-.62.28-.62.63 0 1.15.18 2.3.58 3.38.45 1.19 1.13 2.07 2 2.61.98.6 2.59.94 4.42.94.79 0 1.61-.07 2.42-.22 1.12-.2 2.2-.59 3.19-1.16A8.3 8.3 0 0 0 16.78 16c1.05-1.17 1.67-2.5 2.12-3.65h.19c1.14 0 1.85-.46 2.24-.85.26-.24.45-.53.59-.87l.08-.24zm-17.96.99h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H3.85c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.43 0h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H6.28c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.47 0h1.75c.1 0 .17-.07.17-.16V9.5c0-.08-.06-.16-.17-.16H8.75c-.08 0-.15.07-.15.16v1.58c0 .09.06.16.15.16m2.44 0h1.77c.08 0 .15-.07.15-.16V9.5c0-.08-.06-.16-.15-.16h-1.77c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16M6.28 9h1.76c.08 0 .16-.09.16-.18V7.25c0-.09-.07-.16-.16-.16H6.28c-.09 0-.16.06-.16.16v1.57c.01.09.07.18.16.18m2.47 0h1.75c.1 0 .17-.09.17-.18V7.25c0-.09-.06-.16-.17-.16H8.75c-.08 0-.15.06-.15.16v1.57c0 .09.06.18.15.18m2.44 0h1.77c.08 0 .15-.09.15-.18V7.25c0-.09-.07-.16-.15-.16h-1.77c-.08 0-.15.06-.15.16v1.57c0 .09.07.18.15.18m0-2.28h1.77c.08 0 .15-.07.15-.16V5c0-.1-.07-.17-.15-.17h-1.77c-.08 0-.15.06-.15.17v1.56c0 .08.07.16.15.16m2.46 4.52h1.76c.09 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16h-1.76c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16"/></svg></span> <strong>Docker 24+ &amp; Compose v2</strong></p>
<hr />
<p>The only hard requirement. All 30+ services run as containers.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 17a2 2 0 0 0 2-2 2 2 0 0 0-2-2 2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2zm-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3"/></svg></span> <strong>OpenSSL</strong></p>
<hr />
<p>Used by the config wizard to auto-generate 21 secrets (JWT, encryption keys, passwords).</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17 17H7V7h10m4 4V9h-2V7a2 2 0 0 0-2-2h-2V3h-2v2h-2V3H9v2H7c-1.11 0-2 .89-2 2v2H3v2h2v2H3v2h2v2a2 2 0 0 0 2 2h2v2h2v-2h2v2h2v-2h2a2 2 0 0 0 2-2v-2h2v-2h-2v-2m-6 2h-2v-2h2m2-2H9v6h6z"/></svg></span> <strong>2 GB RAM / 10 GB Disk</strong></p>
<hr />
<p>Minimum for core services. 4 GB recommended if enabling media, chat, or monitoring.</p>
</li>
</ul>
<h2 id="quick-install-pre-built-images">Quick Install (Pre-built Images)<a class="headerlink" href="#quick-install-pre-built-images" title="Permanent link">&para;</a></h2>
<p>The fastest way to deploy — no source code, no compilation:</p>
</div>
<div class="admonition tip">
<p class="admonition-title">MailHog captures all emails locally</p>
<p>In dev mode, every outbound email is caught by MailHog at <code>http://localhost:8025</code> — no SMTP provider needed.</p>
</div>
<h3 id="production-deployment"><span class="twemoji lg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 8h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1m0 8h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1M9 5h1V3H9zm0 8h1v-2H9zm0 8h1v-2H9zM5 3v2h2V3zm0 8v2h2v-2zm0 8v2h2v-2z"/></svg></span> Production Deployment<a class="headerlink" href="#production-deployment" title="Permanent link">&para;</a></h3>
<p>For a deployment that serves real users, you'll also need:</p>
<div class="grid cards">
<ul>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg></span> <strong>A Domain Name</strong></p>
<hr />
<p>Changemaker Lite uses <strong>subdomain routing</strong><code>app.</code>, <code>api.</code>, <code>docs.</code>, <code>git.</code>, and 10+ more subdomains are created automatically. Wildcard DNS (<code>*.yourdomain.org</code>) is the simplest approach.</p>
<p>Budget ~$1015/year from any registrar.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12v10h20V12c0-5.5-4.5-10-10-10M7.1 5.69A7.94 7.94 0 0 1 11 4.07v2.02c-.91.15-1.75.51-2.47 1.02zm8.37 1.42A5.95 5.95 0 0 0 13 6.09V4.07c1.46.18 2.79.76 3.9 1.62zM5.69 7.1l1.42 1.43A5.95 5.95 0 0 0 6.09 11H4.07c.18-1.46.76-2.79 1.62-3.9M6 13v2.5H4V13zm-2 7v-2.5h2V20zM16.89 8.53l1.42-1.43a7.94 7.94 0 0 1 1.62 3.9h-2.02a5.95 5.95 0 0 0-1.02-2.47M18 13h2v2.5h-2zm0 7v-2.5h2V20z"/></svg></span> <strong>A Reverse Tunnel or Public IP</strong></p>
<hr />
<p>Your server needs to be reachable from the internet. Built-in support for <strong><a href="https://github.com/fosrl/pangolin">Pangolin</a></strong> — a self-hosted tunnel with SSL, subdomain routing, and access control. The admin dashboard includes a one-click setup wizard.</p>
<p><em>Alternatives: Cloudflare Tunnel, a VPS with public IP, or any reverse proxy with SSL.</em></p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 5.5H9c-1.1 0-2 .9-2 2v9a2 2 0 0 0 2 2h13c1.11 0 2-.89 2-2v-9a2 2 0 0 0-2-2m0 3.67-6.5 3.33L9 9.17V7.5l6.5 3.31L22 7.5zM5 16.5c0 .17.03.33.05.5H1c-.552 0-1-.45-1-1s.448-1 1-1h4zM3 7h2.05c-.02.17-.05.33-.05.5V9H3c-.55 0-1-.45-1-1s.45-1 1-1m-2 5c0-.55.45-1 1-1h3v2H2c-.55 0-1-.45-1-1"/></svg></span> <strong>SMTP Email Provider</strong></p>
<hr />
<p>Campaign messages, password resets, volunteer invitations, and newsletters all need real email delivery.</p>
<p>Recommended: <strong><a href="https://proton.me/mail">Proton Mail</a></strong> (privacy-focused), <strong><a href="https://www.mailgun.com">Mailgun</a></strong> (100/day free), <strong><a href="https://aws.amazon.com/ses/">Amazon SES</a></strong> (cheapest at scale), or <strong><a href="https://www.brevo.com">Brevo</a></strong> (300/day free).</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.62 8.35c-.42.28-1.75 1.04-1.95 1.19-.39.31-.75.29-1.14-.01-.2-.16-1.53-.92-1.95-1.19-.48-.31-.45-.7.08-.92 1.64-.69 3.28-.64 4.91.03.49.21.51.6.05.9m7.22 7.28c-.93-2.09-2.2-3.99-3.84-5.66a4.3 4.3 0 0 1-1.06-1.88c-.1-.33-.17-.67-.24-1.01-.2-.88-.29-1.78-.7-2.61-.73-1.58-2-2.4-3.84-2.47-1.81.05-3.16.81-3.95 2.4-.21.43-.36.88-.46 1.34-.17.76-.32 1.55-.5 2.32-.15.65-.45 1.21-.96 1.71-1.61 1.57-2.9 3.37-3.88 5.35-.14.29-.28.58-.37.88-.19.66.29 1.12.99.96.44-.09.88-.18 1.3-.31.41-.15.57-.05.67.35.65 2.15 2.07 3.66 4.24 4.5 4.12 1.56 8.93-.66 9.97-4.58.07-.27.17-.37.47-.27.46.14.93.24 1.4.35.49.09.85-.16.92-.64.03-.26-.06-.49-.16-.73"/></svg></span> <strong>A Linux Server</strong></p>
<hr />
<p>Any Linux with Docker. Ubuntu 22.04+ LTS recommended. A VPS from <a href="https://www.digitalocean.com">DigitalOcean</a>, <a href="https://www.hetzner.com">Hetzner</a>, or <a href="https://www.linode.com">Linode</a> works great — or a spare machine on your network if using a tunnel.</p>
</li>
</ul>
</div>
<details class="info">
<summary>Optional services that enhance your deployment</summary>
<p>These aren't required but unlock additional features:</p>
<table>
<thead>
<tr>
<th>Service</th>
<th>Purpose</th>
<th>Free Tier</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong><a href="https://stripe.com">Stripe</a></strong></td>
<td>Donations, merchandise, membership plans</td>
<td>Free account, pay-as-you-go</td>
</tr>
<tr>
<td><strong><a href="https://www.mapbox.com/pricing">Mapbox</a></strong> or <strong><a href="https://developers.google.com/maps">Google Maps</a></strong></td>
<td>Better geocoding accuracy for mapping</td>
<td>100K req/mo (Mapbox) or $200/mo credit (Google)</td>
</tr>
<tr>
<td><strong><a href="https://www.maxmind.com/en/geolite2/signup">MaxMind GeoLite2</a></strong></td>
<td>Geographic analytics (visitor locations)</td>
<td>Free account</td>
</tr>
<tr>
<td><strong><a href="https://termux.dev">Android phone + Termux</a></strong></td>
<td>SMS campaigns via physical phone gateway</td>
<td>Free — no third-party SMS costs</td>
</tr>
<tr>
<td><strong>Public IP + UDP 10000</strong></td>
<td>Jitsi self-hosted video conferencing</td>
<td>Free (requires open firewall port)</td>
</tr>
</tbody>
</table>
<p>See <a href="prerequisites/">Prerequisites &amp; External Services</a> for setup details on each.</p>
</details>
<hr />
<h2 id="quick-install"><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 15H6l7-14v8h5l-7 14z"/></svg></span> Quick Install<a class="headerlink" href="#quick-install" title="Permanent link">&para;</a></h2>
<h3 id="pre-built-images-recommended">Pre-built Images (Recommended)<a class="headerlink" href="#pre-built-images-recommended" title="Permanent link">&para;</a></h3>
<p>The fastest path — no source code, no compilation:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a>curl<span class="w"> </span>-fsSL<span class="w"> </span>https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh<span class="w"> </span><span class="p">|</span><span class="w"> </span>bash
</span></code></pre></div>
<p>This downloads a lightweight release package (~2 MB), runs the configuration wizard, and pulls pre-built Docker images. First startup takes ~2 minutes. See <a href="installation/#pre-built-image-installation">Installation</a> for details.</p>
<h2 id="quick-start-from-source">Quick Start (From Source)<a class="headerlink" href="#quick-start-from-source" title="Permanent link">&para;</a></h2>
<p>For development or customization, clone the full repository:</p>
<p>This downloads a lightweight release package (~2 MB), runs the <strong>14-step configuration wizard</strong>, and pulls pre-built Docker images. First startup takes ~2 minutes.</p>
<h3 id="from-source-development">From Source (Development)<a class="headerlink" href="#from-source-development" title="Permanent link">&para;</a></h3>
<p>For development or customization:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a>git<span class="w"> </span>clone<span class="w"> </span>https://gitea.bnkops.com/admin/changemaker.lite
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2" href="#__codelineno-1-2"></a><span class="nb">cd</span><span class="w"> </span>changemaker.lite
</span></code></pre></div>
@ -2435,12 +2593,12 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
</span></code></pre></div>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>up<span class="w"> </span>-d
</span></code></pre></div>
<p>Open <strong>http://localhost:3000</strong> and sign in with the admin email and password you configured. The API container automatically runs database migrations and seeding on first startup — no manual steps needed.</p>
<p>Open <strong>http://localhost:3000</strong> and sign in with the admin credentials you configured. The API container automatically runs database migrations and seeding on first startup.</p>
<div class="admonition warning">
<p class="admonition-title">Change your password</p>
<p>If you used the wizard's generated password, change it immediately from the admin dashboard.</p>
</div>
<p>For the full setup walkthrough, see <a href="installation/">Installation</a>.</p>
<hr />
<h2 id="configuration-wizard">Configuration Wizard<a class="headerlink" href="#configuration-wizard" title="Permanent link">&para;</a></h2>
<p>The <code>config.sh</code> wizard produces a fully populated <code>.env</code> file in <strong>14 steps</strong>:</p>
<table>
@ -2510,7 +2668,22 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
</tbody>
</table>
<p>See <a href="installation/">Installation</a> for detailed documentation of each step.</p>
<h2 id="services">Services<a class="headerlink" href="#services" title="Permanent link">&para;</a></h2>
<hr />
<h2 id="pre-installation-checklist">Pre-Installation Checklist<a class="headerlink" href="#pre-installation-checklist" title="Permanent link">&para;</a></h2>
<p>Use this to make sure you're ready before running the installer:</p>
<ul class="task-list">
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>Docker 24+</strong> and <strong>Docker Compose v2</strong> installed</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>OpenSSL</strong> installed (for secret generation)</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>Domain name</strong> registered and DNS accessible</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>DNS configured</strong> — wildcard <code>*.yourdomain.org</code> or individual subdomains pointing to your tunnel/server</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>Tunnel or public IP</strong> — Pangolin credentials (API key + Org ID), or server with public IP + SSL</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <strong>SMTP credentials</strong> — host, port, username, password from your email provider</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <em>(Optional)</em> Stripe account for payments</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <em>(Optional)</em> Mapbox or Google Maps API key for geocoding</li>
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <em>(Optional)</em> MaxMind account for geographic analytics</li>
</ul>
<hr />
<h2 id="services-at-a-glance">Services at a Glance<a class="headerlink" href="#services-at-a-glance" title="Permanent link">&para;</a></h2>
<p>Changemaker Lite includes <strong>30+ Docker services</strong> organized into 8 categories:</p>
<table>
<thead>
@ -2564,19 +2737,52 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
</tbody>
</table>
<p>See <a href="services/">Services Overview</a> for the complete catalog with ports, feature flags, and detailed descriptions.</p>
<hr />
<h2 id="next-steps">Next Steps<a class="headerlink" href="#next-steps" title="Permanent link">&para;</a></h2>
<div class="grid cards">
<ul>
<li><a href="prerequisites/">Prerequisites</a> — external services checklist (domain, SMTP, tunnel)</li>
<li><a href="installation/">Installation</a> — detailed setup walkthrough and manual configuration</li>
<li><a href="services/">Services Overview</a> — complete service catalog (30+ containers)</li>
<li><a href="environment-variables/">Environment Variables</a> — complete <code>.env</code> reference</li>
<li><a href="first-steps/">First Steps</a> — create your first campaign and add locations</li>
<li><a href="upgrades/">Updates &amp; Upgrades</a> — keep your installation current</li>
<li><a href="control-panel/">Control Panel (CCP)</a> — multi-instance management</li>
<li><a href="features/">Features at a Glance</a> — visual overview of every module</li>
<li><a href="../admin/">Admin Guide</a> — full administration reference</li>
<li><a href="../deployment/">Deployment</a> — production setup with SSL and tunneling</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3h-4.18C14.4 1.84 13.3 1 12 1s-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm.5 6.5L9 12l2 2 4.5-4.5L17 11l-6 6z"/></svg></span> <strong><a href="prerequisites/">Prerequisites</a></strong></p>
<hr />
<p>Full details on every external service — domain setup, SMTP providers, tunnel options, and optional integrations.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z"/></svg></span> <strong><a href="installation/">Installation</a></strong></p>
<hr />
<p>Detailed setup walkthrough, manual configuration, and the full config wizard reference.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 11h8V3H3m0 18h8v-8H3m10 8h8v-8h-8m0-10v8h8V3"/></svg></span> <strong><a href="services/">Services Overview</a></strong></p>
<hr />
<p>Complete catalog of 30+ containers with ports, feature flags, and descriptions.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg></span> <strong><a href="environment-variables/">Environment Variables</a></strong></p>
<hr />
<p>Complete <code>.env</code> reference for every setting.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.4 6H20v10h-7l-.4-2H7v7H5V4h9zm-.4 8h2v-2h2v-2h-2V8h-2v2l-1-2V6h-2v2H9V6H7v2h2v2H7v2h2v-2h2v2h2v-2l1 2zm-3-4V8h2v2zm3 0h2v2h-2z"/></svg></span> <strong><a href="first-steps/">First Steps</a></strong></p>
<hr />
<p>Create your first campaign, add locations to the map, and invite volunteers.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 18v-8l3.5 3.5 1.42-1.42L12 6.16l-5.92 5.92L7.5 13.5 11 10v8zM12 2a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2"/></svg></span> <strong><a href="upgrades/">Updates &amp; Upgrades</a></strong></p>
<hr />
<p>Keep your installation current with zero-downtime upgrades.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 16V4H3v12zm0-14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-7v2h2v2H8v-2h2v-2H3a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2zM5 6h9v5H5zm10 0h4v2h-4zm4 3v5h-4V9zM5 12h4v2H5zm5 0h4v2h-4z"/></svg></span> <strong><a href="control-panel/">Control Panel</a></strong></p>
<hr />
<p>Manage multiple Changemaker Lite instances from a single dashboard.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m18.09 11.77 1.47 6.33L14 14.74 8.44 18.1l1.46-6.33L5 7.5l6.47-.54L14 1l2.53 5.96L23 7.5zM2 12.43c.19 0 .38-.06.55-.17l3.2-2.11-1.57-1.36-2.73 1.8c-.461.3-.589.91-.29 1.41.2.27.52.43.84.43m-.84 9.12c.2.29.52.45.84.45.19 0 .38-.05.55-.16l4.11-2.71.34-1.37.31-1.45-5.86 3.85c-.461.31-.589.93-.29 1.39m.29-6.17a1 1 0 0 0-.29 1.38c.2.3.52.45.84.45.19 0 .38-.05.55-.16l5.42-3.55.27-1.19-.92-.81z"/></svg></span> <strong><a href="features/">Features at a Glance</a></strong></p>
<hr />
<p>Visual overview of every module — advocacy, mapping, media, payments, and more.</p>
</li>
</ul>
</div>

View File

@ -3055,6 +3055,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="installation">Installation<a class="headerlink" href="#installation" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose. The <code>config.sh</code> wizard handles all configuration — or you can set things up manually.</p>
<div class="admonition info">
<p class="admonition-title">Have your external services ready?</p>

View File

@ -2735,6 +2735,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="prerequisites-external-services">Prerequisites &amp; External Services<a class="headerlink" href="#prerequisites-external-services" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Before running the installer, gather the external services and accounts listed below. Having these ready makes the configuration wizard a smooth, uninterrupted process.</p>
<div class="admonition tip">
<p class="admonition-title">Don't have these yet?</p>
@ -2962,7 +2966,7 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<div class="admonition quote">
<p class="admonition-title">Built by organizers, for organizers</p>
<p>Bunker Operations exists so campaign teams can focus on <strong>building power</strong> — not wrestling with infrastructure. We provide the plumbing so you can focus on the mission.</p>
<p><strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> or email <code>hello@bnkops.com</code></p>
<p><strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<hr />
<h2 id="next-steps">Next Steps<a class="headerlink" href="#next-steps" title="Permanent link">&para;</a></h2>

View File

@ -2745,6 +2745,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="services-overview">Services Overview<a class="headerlink" href="#services-overview" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Changemaker Lite runs as <strong>30+ Docker containers</strong> orchestrated by Docker Compose. This page catalogs every service, organized by category.</p>
<div class="admonition tip">
<p class="admonition-title">Quick reference</p>

View File

@ -3018,6 +3018,10 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="updates-upgrades">Updates &amp; Upgrades<a class="headerlink" href="#updates-upgrades" title="Permanent link">&para;</a></h1>
<div class="admonition tip">
<p class="admonition-title">Need help getting set up?</p>
<p><strong>Bunker Operations</strong> provides managed infrastructure and hands-on setup assistance for organizations running Changemaker Lite. We handle domains, tunnels, SMTP, and servers so you can focus on your campaign. <strong>Get in touch:</strong> <a href="https://bnkops.com">bnkops.com</a> | <code>admin@bnkops.ca</code></p>
</div>
<p>Changemaker Lite includes a built-in upgrade system that pulls code updates, rebuilds containers, runs database migrations, and restarts services — all while preserving your customizations.</p>
<p>There are two ways to upgrade:</p>
<ol>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1346,6 +1346,7 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<h1 id="test">test<a class="headerlink" href="#test" title="Permanent link">&para;</a></h1>
<p>Hello! </p>
<p>Testing this editing system. </p>