Tonne of updates to things like social systems, calendars, and the documentation system (making it mobile friendly and fixing up navigation)

This commit is contained in:
bunker-admin 2026-03-07 13:10:08 -07:00
parent 08d8066157
commit 1cca51e518
188 changed files with 39689 additions and 2615 deletions

View File

@ -459,14 +459,15 @@ GET /api/admin/calendar/shared/:id/items — merged system-layer data for mat
### Phase C: .ics Integration
**Scope:**
- [ ] Prisma models: CalendarFeed, CalendarExportToken
- [ ] .ics feed parser (node-ical or similar)
- [ ] BullMQ job: refresh feeds on configured intervals
- [ ] Feed CRUD: subscribe, update, delete, force refresh
- [ ] Auto-create layer per feed, cache items as CalendarItem rows
- [ ] .ics export: generate feed from user's calendar, token-authenticated URL
- [ ] Export token management (create, revoke)
- [ ] CalendarFeedsPanel, CalendarExportPanel components
- [x] Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
- [x] .ics feed parser (node-ical v0.25.5)
- [x] BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
- [x] Feed CRUD: subscribe, update, delete, force refresh
- [x] Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
- [x] .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
- [x] Export token management (create, list, revoke)
- [x] CalendarFeedsPanel, CalendarExportPanel components
- [x] MyCalendarPage settings Drawer integration (gear icon)
### Phase D: Admin Shared Views
**Scope:**
@ -554,3 +555,15 @@ The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remai
- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June)
- Both API and Admin compile with zero TypeScript errors
- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation)
### 2026-03-07 — Phase C Implementation Complete
- Backend: feed.schemas.ts (3 Zod schemas), feed.service.ts (feed CRUD, ICS parsing, RRULE materialization, export generation), feed.routes.ts (1 public + 8 auth routes), calendar-feed-queue.service.ts (BullMQ 15min repeatable job)
- Dependencies: node-ical v0.25.5 (ICS parsing), ical-generator v10.0.0 (ICS output)
- Feed import: streaming body read with 5MB limit, 1000 event cap, RRULE materialization via rrule.between(), stale event cleanup, status tracking (OK/ERROR/PENDING)
- Feed export: 32-byte random token, configurable layer/personal inclusion, past 1 month + future 3 months, standard iCalendar output with Content-Type: text/calendar
- Frontend: CalendarFeedsPanel (add/edit/delete/refresh with status badges), CalendarExportPanel (create/copy/revoke tokens), settings Drawer in MyCalendarPage (gear icon)
- Types: CalendarFeed, CalendarExportToken, CalendarFeedStatus, CalendarFeedInterval added to admin/src/types/api.ts
- server.ts: feedRoutes mounted before calendarRoutes (public .ics route needs no auth), queue worker started on bootstrap, graceful shutdown
- Smoke tested: Google US Holidays feed → 317 events imported with status OK; export token → valid .ics with VEVENT entries; revoke → 404
- Docker gotcha: anonymous volume `/app/node_modules` caches old dependencies — must `docker compose rm -sf api` to clear when adding new npm packages
- Both API and Admin compile with zero TypeScript errors

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html><head><title>Auth Check</title></head>
<body>
<script>
// This page is loaded in a hidden iframe from the MkDocs header.
// It reads the auth state from this origin's localStorage and
// posts it back to the parent window via postMessage.
(function() {
var authenticated = false;
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
authenticated = true;
}
}
} catch(e) {}
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'cml-auth-status',
authenticated: authenticated
}, '*');
}
})();
</script>
</body>
</html>

View File

@ -128,6 +128,8 @@ import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarPage from '@/pages/AdminCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
import TicketedEventsPage from '@/pages/events/TicketedEventsPage';
import EventDetailPage from '@/pages/events/EventDetailPage';
import CheckInScannerPage from '@/pages/events/CheckInScannerPage';
@ -135,6 +137,9 @@ import TicketedEventDetailPage from '@/pages/public/TicketedEventDetailPage';
import TicketConfirmationPage from '@/pages/public/TicketConfirmationPage';
import MyTicketsPage from '@/pages/volunteer/MyTicketsPage';
import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
@ -326,6 +331,16 @@ export default function App() {
}
/>
{/* Full-screen volunteer chat — outside VolunteerLayout for max screen space */}
<Route
path="/volunteer/chat"
element={
<ProtectedRoute>
<VolunteerChatPage />
</ProtectedRoute>
}
/>
{/* Volunteer pages with VolunteerLayout */}
<Route
element={
@ -349,8 +364,10 @@ export default function App() {
<Route path="/volunteer/challenges" element={<ChallengesPage />} />
<Route path="/volunteer/challenges/:id" element={<ChallengeDetailPage />} />
<Route path="/volunteer/tickets" element={<MyTicketsPage />} />
<Route path="/volunteer/calendar/shared/:id" element={<SharedCalendarViewPage />} />
<Route path="/volunteer/calendar/shared" element={<SharedCalendarsPage />} />
<Route path="/volunteer/calendar/friend/:userId" element={<FriendCalendarPage />} />
<Route path="/volunteer/calendar" element={<MyCalendarPage />} />
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
<Route path="/volunteer/*" element={<NotFoundPage />} />
</Route>
@ -775,6 +792,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar-views/:id"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AdminCalendarViewPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar-views"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AdminCalendarPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar"
element={

View File

@ -15,7 +15,6 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
HomeOutlined,
ScissorOutlined,
CalendarOutlined,
ScheduleOutlined,
@ -64,6 +63,14 @@ import { hasAnyRole } from '@/utils/roles';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
import {
DEFAULT_NAV_ITEMS,
ICON_MAP,
mergeNavDefaults,
filterNavItems,
buildFeatureFlags,
applyAdminOverrides,
} from '@/lib/nav-defaults';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
@ -109,50 +116,12 @@ const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const { useBreakpoint } = Grid;
/** Default nav items for the admin header when navConfig is null */
const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
/** Map icon string IDs to Ant Design icon components for the admin header */
const ADMIN_ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
/** Admin icon overrides: some icons differ in the admin header context */
const ADMIN_ICON_OVERRIDES: Record<string, React.ReactNode> = {
SendOutlined: <SoundOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlaySquareOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <GlobalOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeAdminNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] {
const items: MenuProps['items'] = [
{
@ -209,6 +178,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
];
if (settings?.enableChat) {
broadcastChildren.push({ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' });
}
if (settings?.enableSms || isSuperAdmin) {
broadcastChildren.push(
{ key: '/app/sms/setup', icon: <SettingOutlined />, label: 'SMS Setup' },
@ -265,17 +237,24 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}
// Scheduling submenu — visible if Shifts, Meeting Planner, or Ticketed Events is enabled
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents) {
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents) {
const schedulingChildren: any[] = [];
if (settings?.enableMap !== false) {
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
}
if (settings?.enableMeetingPlanner) {
schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
schedulingChildren.push({ key: '/app/meeting-planner', icon: <ScheduleOutlined />, label: 'Meeting Planner' });
}
if (settings?.enableTicketedEvents) {
schedulingChildren.push({ key: '/app/events', icon: <TagOutlined />, label: 'Events' });
}
if (settings?.enableMeet) {
schedulingChildren.push({ key: '/app/services/jitsi', icon: <VideoCameraOutlined />, label: 'Video Meet' });
}
if (settings?.enableEvents) {
schedulingChildren.push({ key: '/app/services/gancio', icon: <GlobalOutlined />, label: 'Gancio' });
}
schedulingChildren.push({ key: '/app/scheduling/calendar-views', icon: <TeamOutlined />, label: 'Calendar Views' });
// Always add Calendar as the last item in scheduling
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
if (schedulingChildren.length > 0) {
@ -338,9 +317,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' }] : []),
...(settings?.enableMeet ? [{ key: '/app/services/jitsi', icon: <VideoCameraOutlined />, label: 'Video Meet' }] : []),
{ key: '/app/services/gancio', icon: <CalendarOutlined />, label: 'Events' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},
],
@ -626,58 +602,70 @@ export default function AppLayout() {
</Tooltip>
{pageHeader?.actions}
{(() => {
const items = mergeAdminNavDefaults(settings?.navConfig?.items ?? DEFAULT_ADMIN_NAV_ITEMS);
const featureFlags: Record<string, boolean | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
const withOverrides = applyAdminOverrides(merged);
const flags = buildFeatureFlags(settings);
const filtered = filterNavItems(withOverrides, flags);
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />;
const handleItemClick = (item: NavItem) => {
if (item.path.startsWith('$')) {
window.open(resolveNavUrl(item.path), '_blank');
} else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank');
} else if (item.external) {
window.open(item.path, '_blank');
} else {
navigate(item.path);
}
};
return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
.map(item => (
return filtered.map(item => {
if (item.type === 'group' && item.children) {
return (
<Dropdown
key={item.id}
menu={{
items: item.children.map(child => ({
key: child.id,
icon: getIcon(child.icon),
label: child.label,
onClick: () => handleItemClick(child),
})),
}}
placement="bottomRight"
>
<Button type="text" size="small" icon={getIcon(item.icon)}>
{!isMobile && !collapsed && item.label}
</Button>
</Dropdown>
);
}
return (
<Tooltip key={item.id} title={item.label}>
<Button
type="text"
size="small"
icon={ADMIN_ICON_MAP[item.icon] ?? <GlobalOutlined />}
onClick={() => {
if (item.path.startsWith('$')) {
window.open(resolveNavUrl(item.path), '_blank');
} else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank');
} else if (item.external) {
window.open(item.path, '_blank');
} else {
navigate(item.path);
}
}}
icon={getIcon(item.icon)}
onClick={() => handleItemClick(item)}
>
{!isMobile && !collapsed && item.label}
</Button>
</Tooltip>
));
);
});
})()}
{/* Canvass button — always tied to enableMap, not in navConfig */}
{settings?.enableMap !== false && (
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
size="small"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && !collapsed && 'Canvass'}
</Button>
</Tooltip>
)}
{/* Volunteer Portal button — always visible for quick switching */}
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
size="small"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && !collapsed && 'Volunteer'}
</Button>
</Tooltip>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && !collapsed && (

View File

@ -5,6 +5,13 @@ import { useSettingsStore } from '@/stores/settings.store';
import AuthModal from '@/components/AuthModal';
import PublicNavBar from '@/components/PublicNavBar';
import NewsletterSignup from '@/components/public/NewsletterSignup';
import {
DEFAULT_NAV_ITEMS,
mergeNavDefaults,
filterNavItems,
flattenNavItems,
buildFeatureFlags,
} from '@/lib/nav-defaults';
const { Content, Footer } = Layout;
@ -19,39 +26,14 @@ export default function PublicLayout() {
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
// Build footer links from navConfig (or defaults)
// Build footer links from navConfig (or defaults) — flatten groups for flat footer
const footerLinks = useMemo(() => {
const items = settings?.navConfig?.items;
if (!items) {
// Legacy fallback: hardcoded links
const links: { label: string; path: string; external?: boolean }[] = [];
if (settings?.enableInfluence !== false) links.push({ label: 'Campaigns', path: '/campaigns' });
if (settings?.enableMap !== false) {
links.push({ label: 'Map', path: '/map' });
links.push({ label: 'Shifts', path: '/shifts' });
}
if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' });
if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' });
if (settings?.enableSocial) links.push({ label: 'Wall of Fame', path: '/wall-of-fame' });
return links;
}
const featureFlags: Record<string, boolean | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
};
return items
.filter(item => item.enabled && item.id !== 'home') // Skip home in footer
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
const featureFlags = buildFeatureFlags(settings);
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
const filtered = filterNavItems(merged, featureFlags);
const flat = flattenNavItems(filtered);
return flat
.filter(item => item.id !== 'home')
.map(item => ({ label: item.label, path: item.path, external: item.external }));
}, [settings]);

View File

@ -1,69 +1,38 @@
import { useState, useEffect, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Typography, Space, Grid, Drawer, Button, Tooltip, message } from 'antd';
import { Typography, Space, Grid, Drawer, Dropdown, Button, Tooltip, message } from 'antd';
import {
HomeOutlined,
SendOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
MenuOutlined,
CloseOutlined,
LoginOutlined,
LogoutOutlined,
AppstoreOutlined,
TeamOutlined,
LinkOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
EllipsisOutlined,
SearchOutlined,
UserOutlined,
GlobalOutlined,
BookOutlined,
DownOutlined,
UpOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import PublicSearchModal from '@/components/PublicSearchModal';
import NotificationBell from '@/components/social/NotificationBell';
import { api } from '@/lib/api';
import { resolveNavUrl } from '@/lib/service-url';
import {
DEFAULT_NAV_ITEMS,
ICON_MAP,
mergeNavDefaults,
filterNavItems,
buildFeatureFlags,
isItemActive,
} from '@/lib/nav-defaults';
import type { NavItem } from '@/types/api';
// Map icon string IDs to Ant Design icon components
const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
/** Default nav items used when navConfig is null (matches plan's builtin items) */
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
const navItemStyle: React.CSSProperties = {
color: 'rgba(255, 255, 255, 0.85)',
textDecoration: 'none',
@ -87,19 +56,6 @@ function resolveItemUrl(item: NavItem): string {
return item.path;
}
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
interface PublicNavBarProps {
activePath?: string;
showAuth?: boolean;
@ -116,6 +72,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
const [searchOpen, setSearchOpen] = useState(false);
const [navCollapsed, setNavCollapsed] = useLocalStorage('public_nav_collapsed', false);
const [profileLoading, setProfileLoading] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const handleSignIn = onSignInClick ?? (() => navigate('/login'));
const handleMyProfile = async () => {
@ -166,46 +123,32 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
// Determine active route for nav highlight
const currentActive = activePath ?? (() => {
const p = location.pathname;
if (p === '/home') return '/home';
if (p.startsWith('/campaign')) return '/campaigns';
if (p.startsWith('/map')) return '/map';
if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts';
if (p.startsWith('/events')) return '/events';
if (p.startsWith('/gallery')) return '/gallery';
if (p.startsWith('/donate')) return '/donate';
if (p.startsWith('/pricing')) return '/pricing';
if (p.startsWith('/shop')) return '/shop';
return '';
})();
const currentActive = activePath ?? location.pathname;
// Feature flag map for filtering
const featureFlags: Record<string, boolean | undefined> = useMemo(() => ({
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
}), [settings]);
const featureFlags = useMemo(() => buildFeatureFlags(settings), [settings]);
// Get filtered, sorted nav items
// Get filtered, sorted nav items (with group support)
const navItems = useMemo(() => {
const items = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
// For payments flag, enablePayments defaults to false (opt-in)
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
// Other flags default to true
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order);
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
return filterNavItems(merged, featureFlags);
}, [settings?.navConfig, featureFlags]);
// Desktop overflow: group items beyond MAX_VISIBLE into "More" dropdown
const MAX_VISIBLE_NAV = 7;
const visibleNavItems = navCollapsed ? navItems : navItems.slice(0, MAX_VISIBLE_NAV);
const overflowNavItems = navCollapsed ? [] : navItems.slice(MAX_VISIBLE_NAV);
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
};
const renderDesktopLink = (item: NavItem) => {
const isActive = currentActive === item.path;
const isActive = isItemActive(item, currentActive);
const icon = ICON_MAP[item.icon] ?? null;
const linkStyle: React.CSSProperties = {
...navItemStyle,
@ -216,6 +159,38 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
paddingBottom: 2,
};
// Group item: render as Dropdown trigger
if (item.type === 'group' && item.children) {
const menuItems = item.children.map(child => ({
key: child.id,
icon: ICON_MAP[child.icon],
label: child.external ? (
<a href={resolveItemUrl(child)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{child.label}</a>
) : child.label,
onClick: child.external ? undefined : () => navigate(child.path),
}));
return (
<Dropdown
key={item.id}
menu={{ items: menuItems }}
placement="bottomRight"
>
<Tooltip title={navCollapsed ? item.label : ''}>
<span
style={linkStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<NavLabel label={item.label} />
{!navCollapsed && <DownOutlined style={{ fontSize: 10, marginLeft: -2 }} />}
</span>
</Tooltip>
</Dropdown>
);
}
if (item.external) {
return (
<Tooltip key={item.id} title={navCollapsed ? item.label : ''}>
@ -249,17 +224,17 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
);
};
const renderMobileLink = (item: NavItem) => {
const isActive = currentActive === item.path;
const renderMobileLink = (item: NavItem, indent = false) => {
const isActive = isItemActive(item, currentActive);
const icon = ICON_MAP[item.icon] ?? null;
const style: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 24px',
padding: indent ? '10px 24px 10px 44px' : '12px 24px',
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
textDecoration: 'none',
fontSize: 15,
fontSize: indent ? 14 : 15,
fontWeight: isActive ? 600 : 400,
background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent',
borderRadius: 4,
@ -294,6 +269,70 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
);
};
const renderMobileGroup = (item: NavItem) => {
const isActive = isItemActive(item, currentActive);
const icon = ICON_MAP[item.icon] ?? null;
const expanded = expandedGroups.has(item.id);
return (
<div key={item.id}>
<span
role="button"
tabIndex={0}
onClick={() => toggleGroup(item.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(item.id); } }}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 24px',
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
fontSize: 15,
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
background: 'none',
border: 'none',
font: 'inherit',
width: '100%',
textAlign: 'left',
}}
>
{icon}
<span style={{ flex: 1 }}>{item.label}</span>
{expanded ? <UpOutlined style={{ fontSize: 10 }} /> : <DownOutlined style={{ fontSize: 10 }} />}
</span>
{expanded && item.children?.map(child => renderMobileLink(child, true))}
</div>
);
};
// Build overflow menu items with group support (nested children)
const overflowMenuItems = overflowNavItems.map(item => {
if (item.type === 'group' && item.children) {
return {
key: item.id,
icon: ICON_MAP[item.icon],
label: item.label,
children: item.children.map(child => ({
key: child.id,
icon: ICON_MAP[child.icon],
label: child.external ? (
<a href={resolveItemUrl(child)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{child.label}</a>
) : child.label,
onClick: child.external ? undefined : () => navigate(child.path),
})),
};
}
return {
key: item.id,
icon: ICON_MAP[item.icon],
label: item.external ? (
<a href={resolveItemUrl(item)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{item.label}</a>
) : item.label,
onClick: item.external ? undefined : () => navigate(item.path),
};
});
return (
<>
<div
@ -323,16 +362,34 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
{/* Right: Navigation */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation menu"
style={{ padding: '4px 8px' }}
/>
<Space size={4}>
{isAuthenticated && settings?.enableSocial && <NotificationBell />}
<Button
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation menu"
style={{ padding: '4px 8px' }}
/>
</Space>
) : (
<Space size={navCollapsed ? 8 : 16}>
{navItems.map(renderDesktopLink)}
{visibleNavItems.map(renderDesktopLink)}
{overflowMenuItems.length > 0 && (
<Dropdown
menu={{ items: overflowMenuItems }}
placement="bottomRight"
>
<span
style={{ ...navItemStyle, cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<EllipsisOutlined />
<NavLabel label="More" />
</span>
</Dropdown>
)}
{/* Search button */}
<Tooltip title={navCollapsed ? 'Search (Ctrl+K)' : 'Search'}>
@ -371,55 +428,73 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
</span>
</Tooltip>
{/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{/* Notification bell (authenticated + social enabled) */}
{isAuthenticated && settings?.enableSocial && <NotificationBell />}
{/* Auth: user dropdown when logged in, Sign In when not */}
{isAuthenticated ? (
<>
<Tooltip title={navCollapsed ? 'My Profile' : ''}>
<span
role="button"
tabIndex={0}
onClick={handleMyProfile}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleMyProfile(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6, opacity: profileLoading ? 0.5 : 1 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<UserOutlined /><NavLabel label="My Profile" />
<Dropdown
menu={{
items: [
...(isAdmin ? [{
key: 'admin',
icon: <AppstoreOutlined />,
label: 'Admin Panel',
onClick: () => navigate('/app'),
style: { fontWeight: 600 },
}] : []),
{
key: 'volunteer',
icon: <TeamOutlined />,
label: 'Volunteer Portal',
onClick: () => navigate('/volunteer'),
style: isAdmin ? undefined : { fontWeight: 600 },
},
{ type: 'divider' as const },
{
key: 'profile',
icon: <UserOutlined />,
label: 'My Profile',
disabled: profileLoading,
onClick: handleMyProfile,
},
{ type: 'divider' as const },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: () => logout(),
},
],
}}
placement="bottomRight"
trigger={['click']}
>
<span
style={{
...navItemStyle,
gap: 6,
cursor: 'pointer',
borderLeft: '1px solid rgba(255,255,255,0.2)',
paddingLeft: 12,
marginLeft: 4,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<UserOutlined />
<span style={{
maxWidth: navCollapsed ? 0 : 120,
opacity: navCollapsed ? 0 : 1,
overflow: 'hidden',
transition: 'max-width 0.25s ease, opacity 0.2s ease',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
{user?.name || user?.email || 'Account'}
</span>
</Tooltip>
{isAdmin ? (
<Tooltip title={navCollapsed ? 'Admin' : ''}>
<Link to="/app" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<AppstoreOutlined /><NavLabel label="Admin" />
</Link>
</Tooltip>
) : (
<Tooltip title={navCollapsed ? 'Volunteer Portal' : ''}>
<Link to="/volunteer" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<TeamOutlined /><NavLabel label="Volunteer Portal" />
</Link>
</Tooltip>
)}
<Tooltip title={navCollapsed ? 'Logout' : ''}>
<span
role="button"
tabIndex={0}
onClick={() => logout()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); logout(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<LogoutOutlined /><NavLabel label="Logout" />
</span>
</Tooltip>
</>
</span>
</Dropdown>
) : showAuth && (
<Tooltip title={navCollapsed ? 'Sign In' : ''}>
<span
@ -453,23 +528,39 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{navItems.map(renderMobileLink)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{isAuthenticated ? (
{/* Highlighted portal/admin link at top when authenticated */}
{isAuthenticated && (
<>
<span
role="button"
tabIndex={0}
onClick={() => { handleMyProfile(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { handleMyProfile(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit', opacity: profileLoading ? 0.5 : 1 }}
>
<UserOutlined /> <span>My Profile</span>
</span>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: '#fff',
textDecoration: 'none', fontSize: 15,
fontWeight: 600,
borderRadius: 4,
margin: '0 8px 4px',
background: 'rgba(52,152,219,0.15)',
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Open Admin Panel' : 'Open Volunteer Portal'}</span>
</Link>
</>
)}
{navItems.map(item =>
item.type === 'group' && item.children
? renderMobileGroup(item)
: renderMobileLink(item)
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{isAuthenticated ? (
<>
<Link
to="/volunteer"
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
@ -478,9 +569,17 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
borderRadius: 4,
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
<TeamOutlined /> <span>Volunteer Portal</span>
</Link>
<span
role="button"
tabIndex={0}
onClick={() => { handleMyProfile(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { handleMyProfile(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit', opacity: profileLoading ? 0.5 : 1 }}
>
<UserOutlined /> <span>My Profile</span>
</span>
<span
role="button"
tabIndex={0}

View File

@ -65,7 +65,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
minHeight: 56,
minHeight: 44,
background: 'rgba(13, 27, 42, 0.95)',
borderTop: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
@ -80,24 +80,20 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
onClick={onMenuOpen}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
cursor: 'pointer',
padding: '6px 0',
padding: '10px 0',
color: menuActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s',
}}
>
<MenuOutlined style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: menuActive ? 600 : 400 }}>
Menu
</span>
<MenuOutlined style={{ fontSize: 22 }} />
</div>
)}
{NAV_ITEMS.map(({ key, icon: Icon, label }) => {
{NAV_ITEMS.map(({ key, icon: Icon }) => {
const isActive = activeKey === key;
return (
<div
@ -105,20 +101,16 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
onClick={() => navigate(key)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
cursor: 'pointer',
padding: '6px 0',
padding: '10px 0',
color: isActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s',
}}
>
<Icon style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: isActive ? 600 : 400 }}>
{label}
</span>
<Icon style={{ fontSize: 22 }} />
</div>
);
})}

View File

@ -1,37 +1,80 @@
import { useNavigate, Outlet } from 'react-router-dom';
import { ConfigProvider, Layout, Button, Typography, Dropdown, theme } from 'antd';
import { LogoutOutlined, UserOutlined, GlobalOutlined, HomeOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useState, useMemo } from 'react';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { ConfigProvider, Layout, Typography, theme, Drawer, Divider, Alert, Tag } from 'antd';
import {
LogoutOutlined,
UserOutlined,
GlobalOutlined,
AppstoreOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
NodeIndexOutlined,
CalendarOutlined,
TagOutlined,
TeamOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
import NotificationBell from '@/components/social/NotificationBell';
import { buildHomeUrl } from '@/lib/service-url';
import PublicNavBar from '@/components/PublicNavBar';
import { useSSE } from '@/hooks/useSSE';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const { Header, Content, Footer } = Layout;
const { Content, Footer } = Layout;
export default function VolunteerLayout() {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { settings } = useSettingsStore();
const [menuOpen, setMenuOpen] = useState(false);
const [welcomeDismissed, setWelcomeDismissed] = useLocalStorage('volunteer_welcome_dismissed', false);
// Initialize SSE connection for real-time notifications + online presence
useSSE();
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const userMenuItems: MenuProps['items'] = [
{ key: 'home', icon: <HomeOutlined />, label: 'Home', onClick: () => window.open(buildHomeUrl(), '_blank') },
{ key: 'browse', icon: <GlobalOutlined />, label: 'Browse Site', onClick: () => navigate('/campaigns') },
{ type: 'divider' },
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
];
// Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [
{ key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
];
if (settings?.enableSocialCalendar) {
items.push({ key: '/volunteer/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
}
if (settings?.enableTicketedEvents) {
items.push({ key: '/volunteer/tickets', icon: <TagOutlined />, label: 'Tickets' });
}
if (settings?.enableSocial) {
items.push({ key: '/volunteer/feed', icon: <TeamOutlined />, label: 'Social' });
}
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
}
return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
const activeKey = (() => {
const path = location.pathname;
if (path === '/volunteer') return '/volunteer';
for (const item of navItems) {
if (item.key !== '/volunteer' && path.startsWith(item.key)) return item.key;
}
return '/volunteer';
})();
return (
<ConfigProvider
@ -48,38 +91,27 @@ export default function VolunteerLayout() {
},
}}
>
<Layout style={{ minHeight: '100vh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
<Header
style={{
background: settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
height: 48,
gap: 12,
}}
>
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
{orgName}
</Typography.Text>
{settings?.enableSocial && <NotificationBell />}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>
{user?.name || user?.email || 'User'}
</Typography.Text>
</Button>
</Dropdown>
</Header>
<Layout style={{ minHeight: '100dvh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
<PublicNavBar />
<Content
style={{
maxWidth: 800,
width: '100%',
margin: '0 auto',
padding: '16px 12px max(72px, calc(56px + 16px + env(safe-area-inset-bottom))) 12px',
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px',
}}
>
{!welcomeDismissed && (
<Alert
message="Welcome to the Volunteer Portal!"
description="Here you can view your shifts, canvass your area, track your activity, and connect with your team."
type="info"
closable
onClose={() => setWelcomeDismissed(true)}
style={{ marginBottom: 16 }}
/>
)}
<Outlet />
</Content>
@ -93,9 +125,145 @@ export default function VolunteerLayout() {
zIndex: 100,
}}
>
<VolunteerFooterNav />
<VolunteerFooterNav
onMenuOpen={() => setMenuOpen(true)}
menuActive={menuOpen}
/>
</Footer>
</Layout>
{/* Navigation Menu Drawer */}
<Drawer
title={null}
placement="left"
onClose={() => setMenuOpen(false)}
open={menuOpen}
width={280}
styles={{
header: { display: 'none' },
body: { background: colorBgBase, padding: 0 },
}}
>
{/* User profile section */}
<div style={{
padding: '20px 20px 16px',
background: colorBgContainer,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'rgba(52,152,219,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<UserOutlined style={{ fontSize: 18, color: 'rgba(255,255,255,0.85)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong style={{ color: '#fff', display: 'block', fontSize: 14 }}>
{user?.name || 'Volunteer'}
</Typography.Text>
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', display: 'block', fontSize: 12 }}>
{user?.email}
</Typography.Text>
</div>
</div>
<Tag color="blue" style={{ marginTop: 8, fontSize: 11 }}>
{user?.role === 'USER' ? 'Volunteer' : user?.role?.replace('_', ' ') ?? 'Volunteer'}
</Tag>
</div>
{/* Navigation items */}
<div style={{ padding: '8px 0' }}>
{navItems.map(({ key, icon, label }) => {
const isActive = activeKey === key;
return (
<div
key={key}
onClick={() => { navigate(key); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: isActive ? '#fff' : 'rgba(255,255,255,0.7)',
fontWeight: isActive ? 600 : 400,
fontSize: 14,
background: isActive ? 'rgba(52,152,219,0.15)' : 'transparent',
borderRight: isActive ? '3px solid #3498db' : '3px solid transparent',
transition: 'all 0.2s',
}}
>
{icon}
<span>{label}</span>
</div>
);
})}
</div>
<Divider style={{ margin: '4px 20px', borderColor: 'rgba(255,255,255,0.1)' }} />
{/* Cross-navigation links */}
<div style={{ padding: '4px 0' }}>
<div
onClick={() => { navigate('/home'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<GlobalOutlined />
<span>Public Website</span>
</div>
{isAdmin && (
<div
onClick={() => { navigate('/app'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<AppstoreOutlined />
<span>Admin Panel</span>
</div>
)}
</div>
<Divider style={{ margin: '4px 20px', borderColor: 'rgba(255,255,255,0.1)' }} />
{/* Logout */}
<div style={{ padding: '4px 0' }}>
<div
onClick={() => { handleLogout(); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<LogoutOutlined />
<span>Logout</span>
</div>
</div>
</Drawer>
</ConfigProvider>
);
}

View File

@ -0,0 +1,252 @@
import { useState, useEffect, useCallback } from 'react';
import {
DatePicker,
Select,
Space,
Typography,
Skeleton,
Empty,
Tooltip,
theme,
} from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import type { AvailabilityResponse } from '@/types/api';
const { Text } = Typography;
const { RangePicker } = DatePicker;
interface AvailabilityFinderProps {
viewId: string;
onSlotClick?: (date: string, time: string) => void;
}
const DURATION_OPTIONS = [
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
export default function AvailabilityFinder({ viewId, onSlotClick }: AvailabilityFinderProps) {
const { token } = theme.useToken();
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
dayjs(),
dayjs().add(6, 'day'),
]);
const [dayStart, setDayStart] = useState(9);
const [dayEnd, setDayEnd] = useState(18);
const [slotDuration, setSlotDuration] = useState(30);
const [availability, setAvailability] = useState<AvailabilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const hourOptions = Array.from({ length: 24 }, (_, i) => ({
value: i,
label: `${String(i).padStart(2, '0')}:00`,
}));
const fetchAvailability = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<AvailabilityResponse>(
`/calendar/shared/${viewId}/availability`,
{
params: {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
dayStart,
dayEnd,
slotMinutes: slotDuration,
},
},
);
setAvailability(data);
} catch {
setAvailability(null);
} finally {
setLoading(false);
}
}, [viewId, dateRange, dayStart, dayEnd, slotDuration]);
useEffect(() => {
fetchAvailability();
}, [fetchAvailability]);
// Build time slots
const timeSlots: string[] = [];
for (let h = dayStart; h < dayEnd; h++) {
for (let m = 0; m < 60; m += slotDuration) {
if (h === dayEnd - 1 && m + slotDuration > 60) break;
timeSlots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
// Build date columns
const dates: string[] = [];
let d = dateRange[0];
while (d.isBefore(dateRange[1]) || d.isSame(dateRange[1], 'day')) {
dates.push(d.format('YYYY-MM-DD'));
d = d.add(1, 'day');
}
const statusColors: Record<string, string> = {
free: '#52c41a',
busy: '#ff4d4f',
tentative: '#faad14',
};
return (
<div>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
Find Availability
</Text>
<Space wrap style={{ marginBottom: 12 }}>
<RangePicker
size="small"
value={dateRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
setDateRange([vals[0], vals[1]]);
}
}}
/>
<Select
size="small"
value={dayStart}
options={hourOptions}
onChange={setDayStart}
style={{ width: 90 }}
placeholder="Start"
/>
<Select
size="small"
value={dayEnd}
options={hourOptions}
onChange={setDayEnd}
style={{ width: 90 }}
placeholder="End"
/>
<Select
size="small"
value={slotDuration}
options={DURATION_OPTIONS}
onChange={setSlotDuration}
style={{ width: 90 }}
/>
</Space>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : !availability || dates.length === 0 ? (
<Empty description="Select a date range" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 11 }}>
<thead>
<tr>
<th
style={{
padding: '4px 8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
position: 'sticky',
left: 0,
background: token.colorBgContainer,
zIndex: 1,
}}
/>
{dates.map((date) => (
<th
key={date}
style={{
padding: '4px 8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
whiteSpace: 'nowrap',
fontWeight: 500,
}}
>
{dayjs(date).format('ddd M/D')}
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((time) => (
<tr key={time}>
<td
style={{
padding: '3px 8px',
borderRight: `1px solid ${token.colorBorderSecondary}`,
whiteSpace: 'nowrap',
position: 'sticky',
left: 0,
background: token.colorBgContainer,
zIndex: 1,
color: 'rgba(255,255,255,0.6)',
}}
>
{time}
</td>
{dates.map((date) => {
const dayData = availability.dates[date];
const slot = dayData?.slots.find((s) => s.time === time);
const allFree = slot?.allFree;
return (
<td
key={date}
onClick={() => allFree && onSlotClick?.(date, time)}
style={{
padding: '3px 6px',
border: `1px solid ${token.colorBorderSecondary}`,
background: allFree
? 'rgba(82, 196, 26, 0.15)'
: 'transparent',
cursor: allFree ? 'pointer' : 'default',
textAlign: 'center',
}}
>
{slot && (
<Space size={2}>
{slot.members.map((m) => (
<Tooltip key={m.userId} title={`${m.userName}: ${m.status}`}>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColors[m.status] || '#999',
display: 'inline-block',
}}
/>
</Tooltip>
))}
</Space>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<Space style={{ marginTop: 8 }}>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#52c41a' }} />
<Text style={{ fontSize: 11 }}>Free</Text>
</Space>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ff4d4f' }} />
<Text style={{ fontSize: 11 }}>Busy</Text>
</Space>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#faad14' }} />
<Text style={{ fontSize: 11 }}>Tentative</Text>
</Space>
</Space>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useState, useEffect, useCallback } from 'react';
import { List, Input, Button, Typography, Space, Popconfirm, message, Empty, Skeleton } from 'antd';
import { DeleteOutlined, SendOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { SharedViewComment } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface CalendarCommentsProps {
viewId: string;
date: string;
currentUserId: string;
}
export default function CalendarComments({ viewId, date, currentUserId }: CalendarCommentsProps) {
const [comments, setComments] = useState<SharedViewComment[]>([]);
const [loading, setLoading] = useState(true);
const [newComment, setNewComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const fetchComments = useCallback(async () => {
try {
const { data } = await api.get<SharedViewComment[]>(
`/calendar/shared/${viewId}/comments`,
{ params: { date } },
);
setComments(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [viewId, date]);
useEffect(() => {
setLoading(true);
fetchComments();
}, [fetchComments]);
const handleSubmit = async () => {
if (!newComment.trim()) return;
setSubmitting(true);
try {
await api.post(`/calendar/shared/${viewId}/comments`, {
itemDate: date,
content: newComment.trim(),
});
setNewComment('');
await fetchComments();
} catch {
message.error('Failed to post comment');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
try {
await api.delete(`/calendar/shared/${viewId}/comments/${commentId}`);
setComments((prev) => prev.filter((c) => c.id !== commentId));
} catch {
message.error('Failed to delete comment');
}
};
if (loading) return <Skeleton active paragraph={{ rows: 2 }} />;
return (
<div>
<Text strong style={{ fontSize: 13, marginBottom: 8, display: 'block' }}>
Comments
</Text>
{comments.length === 0 ? (
<Empty description="No comments yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={comments}
renderItem={(comment) => (
<List.Item
style={{ padding: '6px 0', alignItems: 'flex-start' }}
actions={
comment.userId === currentUserId
? [
<Popconfirm
key="delete"
title="Delete comment?"
onConfirm={() => handleDelete(comment.id)}
okText="Delete"
okType="danger"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>,
]
: undefined
}
>
<div>
<Space size={4}>
<div
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'rgba(157, 78, 221, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
flexShrink: 0,
}}
>
{(comment.user.name || comment.user.email)[0]?.toUpperCase()}
</div>
<Text strong style={{ fontSize: 12 }}>
{comment.user.name || comment.user.email}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(comment.createdAt).fromNow()}
</Text>
</Space>
<div style={{ marginLeft: 28, marginTop: 2, fontSize: 13 }}>
{comment.content}
</div>
</div>
</List.Item>
)}
/>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Input
size="small"
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onPressEnter={handleSubmit}
disabled={submitting}
/>
<Button
size="small"
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!newComment.trim()}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react';
import {
List,
Button,
Modal,
Form,
Checkbox,
Select,
Typography,
message,
} from 'antd';
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { CalendarExportToken, CalendarLayer } from '@/types/api';
const { Text } = Typography;
interface Props {
layers: CalendarLayer[];
}
export default function CalendarExportPanel({ layers }: Props) {
const [tokens, setTokens] = useState<CalendarExportToken[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const fetchTokens = useCallback(async () => {
try {
const { data } = await api.get<{ tokens: CalendarExportToken[] }>('/calendar/export/tokens');
setTokens(data.tokens);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
await api.post('/calendar/export/tokens', {
includePersonal: values.includePersonal ?? true,
includeLayers: values.includeLayers?.length ? values.includeLayers : null,
});
message.success('Export link created');
setModalOpen(false);
form.resetFields();
await fetchTokens();
} catch {
message.error('Failed to create export link');
}
};
const handleRevoke = (token: CalendarExportToken) => {
Modal.confirm({
title: 'Revoke export link?',
content: 'Anyone using this link will no longer be able to access your calendar.',
okText: 'Revoke',
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/calendar/export/tokens/${token.id}`);
message.success('Export link revoked');
await fetchTokens();
} catch {
message.error('Failed to revoke link');
}
},
});
};
const copyUrl = (token: string) => {
const url = `${window.location.origin}/api/calendar/feed/${token}.ics`;
navigator.clipboard.writeText(url).then(
() => message.success('URL copied'),
() => message.error('Failed to copy'),
);
};
const describeScope = (t: CalendarExportToken) => {
const parts: string[] = [];
if (t.includePersonal) parts.push('Personal events');
if (t.includeLayers?.length) {
const names = t.includeLayers
.map((id) => layers.find((l) => l.id === id)?.name)
.filter(Boolean);
if (names.length) parts.push(names.join(', '));
}
return parts.length ? parts.join(' + ') : 'All layers';
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>Export Calendar</Text>
<Button size="small" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
Create Export Link
</Button>
</div>
<List
size="small"
loading={loading}
dataSource={tokens}
locale={{ emptyText: 'No export links' }}
renderItem={(t) => (
<List.Item
actions={[
<Button
key="copy"
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => copyUrl(t.token)}
/>,
<Button
key="revoke"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRevoke(t)}
/>,
]}
>
<List.Item.Meta
title={<Text style={{ fontSize: 13 }}>{describeScope(t)}</Text>}
description={
<Text style={{ fontSize: 11 }} type="secondary">
Created {dayjs(t.createdAt).format('MMM D, YYYY')}
</Text>
}
/>
</List.Item>
)}
/>
<Modal
title="Create Export Link"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="Create"
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ includePersonal: true }}>
<Form.Item name="includePersonal" valuePropName="checked">
<Checkbox>Include personal events</Checkbox>
</Form.Item>
<Form.Item name="includeLayers" label="Include specific layers (optional)">
<Select
mode="multiple"
allowClear
placeholder="All layers"
options={layers.map((l) => ({ value: l.id, label: l.name }))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,233 @@
import { useState, useEffect, useCallback } from 'react';
import {
List,
Button,
Tag,
Modal,
Form,
Input,
Select,
Tooltip,
Space,
message,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
SyncOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { CalendarFeed, CalendarFeedInterval } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const INTERVAL_OPTIONS: { value: CalendarFeedInterval; label: string }[] = [
{ value: 'FIFTEEN_MIN', label: 'Every 15 minutes' },
{ value: 'HOURLY', label: 'Hourly' },
{ value: 'SIX_HOUR', label: 'Every 6 hours' },
{ value: 'DAILY', label: 'Daily' },
];
export default function CalendarFeedsPanel() {
const [feeds, setFeeds] = useState<CalendarFeed[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingFeed, setEditingFeed] = useState<CalendarFeed | null>(null);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [form] = Form.useForm();
const fetchFeeds = useCallback(async () => {
try {
const { data } = await api.get<{ feeds: CalendarFeed[] }>('/calendar/feeds');
setFeeds(data.feeds);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFeeds();
}, [fetchFeeds]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingFeed) {
await api.patch(`/calendar/feeds/${editingFeed.id}`, values);
message.success('Feed updated');
} else {
await api.post('/calendar/feeds', values);
message.success('Feed added');
}
setModalOpen(false);
setEditingFeed(null);
form.resetFields();
await fetchFeeds();
} catch {
message.error('Failed to save feed');
}
};
const handleDelete = (feed: CalendarFeed) => {
Modal.confirm({
title: 'Delete feed?',
content: `Remove "${feed.name}" and all its imported events?`,
okText: 'Delete',
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/calendar/feeds/${feed.id}`);
message.success('Feed deleted');
await fetchFeeds();
} catch {
message.error('Failed to delete feed');
}
},
});
};
const handleRefresh = async (feed: CalendarFeed) => {
setRefreshingId(feed.id);
try {
await api.post(`/calendar/feeds/${feed.id}/refresh`);
message.success('Feed refreshed');
await fetchFeeds();
} catch {
message.error('Failed to refresh feed');
} finally {
setRefreshingId(null);
}
};
const openEdit = (feed: CalendarFeed) => {
setEditingFeed(feed);
form.setFieldsValue({
name: feed.name,
url: feed.url,
refreshInterval: feed.refreshInterval,
});
setModalOpen(true);
};
const openAdd = () => {
setEditingFeed(null);
form.resetFields();
setModalOpen(true);
};
const statusTag = (feed: CalendarFeed) => {
const colorMap: Record<string, string> = { OK: 'green', ERROR: 'red', PENDING: 'gold' };
const tag = (
<Tag color={colorMap[feed.lastStatus] ?? 'default'}>
{feed.lastStatus}
</Tag>
);
if (feed.lastStatus === 'ERROR' && feed.lastError) {
return <Tooltip title={feed.lastError}>{tag}</Tooltip>;
}
return tag;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>External Feeds</Text>
<Button size="small" icon={<PlusOutlined />} onClick={openAdd}>
Add Feed
</Button>
</div>
<List
size="small"
loading={loading}
dataSource={feeds}
locale={{ emptyText: 'No external feeds' }}
renderItem={(feed) => (
<List.Item
actions={[
<Button
key="edit"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(feed)}
/>,
<Button
key="refresh"
type="text"
size="small"
icon={<SyncOutlined spin={refreshingId === feed.id} />}
onClick={() => handleRefresh(feed)}
/>,
<Button
key="delete"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(feed)}
/>,
]}
>
<List.Item.Meta
title={
<Space size={8}>
<Text style={{ fontSize: 13 }}>{feed.name}</Text>
{statusTag(feed)}
</Space>
}
description={
<Space size={4} style={{ fontSize: 11 }}>
<span>{feed.itemCount} events</span>
{feed.lastFetchedAt && (
<span>· {dayjs(feed.lastFetchedAt).fromNow()}</span>
)}
</Space>
}
/>
</List.Item>
)}
/>
<Modal
title={editingFeed ? 'Edit Feed' : 'Add External Feed'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => {
setModalOpen(false);
setEditingFeed(null);
form.resetFields();
}}
okText={editingFeed ? 'Save' : 'Add'}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ refreshInterval: 'HOURLY' }}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Enter a name' }]}>
<Input placeholder="e.g. Work Calendar" />
</Form.Item>
<Form.Item
name="url"
label="ICS URL"
rules={[
{ required: true, message: 'Enter an ICS URL' },
{ type: 'url', message: 'Enter a valid URL' },
]}
>
<Input placeholder="https://example.com/calendar.ics" />
</Form.Item>
<Form.Item name="refreshInterval" label="Refresh Interval">
<Select options={INTERVAL_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,97 @@
import { useState } from 'react';
import { Button, Popover, Space, Tooltip, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SharedViewReactionGroup } from '@/types/api';
const EMOJI_PALETTE = ['👍', '❤️', '🎉', '😄', '🤔', '👀', '🔥', '⭐', '💪', '📅'];
interface CalendarReactionsProps {
viewId: string;
itemId: string;
reactions: SharedViewReactionGroup[];
currentUserId: string;
onUpdate: () => void;
}
export default function CalendarReactions({
viewId,
itemId,
reactions,
currentUserId,
onUpdate,
}: CalendarReactionsProps) {
const [paletteOpen, setPaletteOpen] = useState(false);
const toggleReaction = async (emoji: string) => {
try {
await api.post(`/calendar/shared/${viewId}/reactions`, { itemId, emoji });
onUpdate();
} catch {
message.error('Failed to update reaction');
}
setPaletteOpen(false);
};
return (
<Space size={4} wrap style={{ marginTop: 4 }}>
{reactions.map((r) => {
const hasReacted = r.users.some((u) => u.id === currentUserId);
const tooltip = r.users.map((u) => u.name || 'Someone').join(', ');
return (
<Tooltip key={r.emoji} title={tooltip}>
<Button
size="small"
type={hasReacted ? 'primary' : 'default'}
style={{
fontSize: 13,
padding: '0 6px',
height: 24,
borderRadius: 12,
opacity: hasReacted ? 1 : 0.7,
}}
onClick={() => toggleReaction(r.emoji)}
>
{r.emoji} {r.count}
</Button>
</Tooltip>
);
})}
<Popover
open={paletteOpen}
onOpenChange={setPaletteOpen}
trigger="click"
placement="bottom"
content={
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxWidth: 200 }}>
{EMOJI_PALETTE.map((emoji) => (
<Button
key={emoji}
size="small"
type="text"
style={{ fontSize: 18, width: 36, height: 36 }}
onClick={() => toggleReaction(emoji)}
>
{emoji}
</Button>
))}
</div>
}
>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
style={{
fontSize: 12,
height: 24,
width: 24,
borderRadius: 12,
opacity: 0.5,
}}
/>
</Popover>
</Space>
);
}

View File

@ -0,0 +1,204 @@
import { useState, useCallback } from 'react';
import {
List,
Button,
Typography,
Tag,
Space,
Modal,
Checkbox,
message,
Empty,
Skeleton,
Popconfirm,
} from 'antd';
import { UserAddOutlined, LogoutOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SharedCalendarMember } from '@/types/api';
const { Text } = Typography;
interface Friend {
id: string;
name: string | null;
email: string;
}
interface SharedViewMembersPanelProps {
viewId: string;
members: SharedCalendarMember[];
isOwner: boolean;
onInvite: () => void;
onLeave: () => void;
onRefresh: () => void;
}
export default function SharedViewMembersPanel({
viewId,
members,
isOwner,
onInvite: _onInvite,
onLeave,
onRefresh,
}: SharedViewMembersPanelProps) {
const [inviteModalOpen, setInviteModalOpen] = useState(false);
const [friends, setFriends] = useState<Friend[]>([]);
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
const [loadingFriends, setLoadingFriends] = useState(false);
const [inviting, setInviting] = useState(false);
const memberUserIds = new Set(members.map((m) => m.userId));
const fetchFriends = useCallback(async () => {
setLoadingFriends(true);
try {
const { data } = await api.get('/social/friends');
const accepted = (data.friends || data || [])
.filter((f: any) => f.status === 'accepted')
.map((f: any) => f.friend || f.user || f);
setFriends(accepted);
} catch {
// ignore
} finally {
setLoadingFriends(false);
}
}, []);
const openInviteModal = () => {
setInviteModalOpen(true);
setSelectedFriends([]);
fetchFriends();
};
const handleInvite = async () => {
if (selectedFriends.length === 0) return;
setInviting(true);
try {
await api.post(`/calendar/shared/${viewId}/invite`, { userIds: selectedFriends });
message.success(`Invited ${selectedFriends.length} friend(s)`);
setInviteModalOpen(false);
onRefresh();
} catch {
message.error('Failed to invite');
} finally {
setInviting(false);
}
};
const statusColor: Record<string, string> = {
ACCEPTED: 'green',
INVITED: 'gold',
DECLINED: 'red',
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14 }}>Members</Text>
<Button size="small" icon={<UserAddOutlined />} onClick={openInviteModal}>
Invite
</Button>
</div>
{members.length === 0 ? (
<Empty description="No members yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={members}
renderItem={(member) => (
<List.Item style={{ padding: '6px 0' }}>
<Space size={8}>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: member.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
color: '#fff',
flexShrink: 0,
}}
>
{(member.user.name || member.user.email)[0]?.toUpperCase()}
</div>
<div>
<Text style={{ fontSize: 13 }} ellipsis>
{member.user.name || member.user.email}
</Text>
<div>
<Tag
color={statusColor[member.status] || 'default'}
style={{ fontSize: 10, margin: 0 }}
>
{member.status}
</Tag>
</div>
</div>
</Space>
</List.Item>
)}
/>
)}
{!isOwner && (
<Popconfirm
title="Leave this shared calendar?"
onConfirm={onLeave}
okText="Leave"
okType="danger"
>
<Button
size="small"
danger
icon={<LogoutOutlined />}
style={{ marginTop: 12, width: '100%' }}
>
Leave Calendar
</Button>
</Popconfirm>
)}
<Modal
title="Invite Friends"
open={inviteModalOpen}
onCancel={() => setInviteModalOpen(false)}
onOk={handleInvite}
okText="Send Invites"
confirmLoading={inviting}
okButtonProps={{ disabled: selectedFriends.length === 0 }}
>
{loadingFriends ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : friends.length === 0 ? (
<Empty description="No friends to invite" />
) : (
<Checkbox.Group
value={selectedFriends}
onChange={(vals) => setSelectedFriends(vals as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{friends.map((friend) => {
const alreadyMember = memberUserIds.has(friend.id);
return (
<Checkbox key={friend.id} value={friend.id} disabled={alreadyMember}>
{friend.name || friend.email}
{alreadyMember && (
<Text type="secondary" style={{ marginLeft: 8, fontSize: 11 }}>
(already member)
</Text>
)}
</Checkbox>
);
})}
</Space>
</Checkbox.Group>
)}
</Modal>
</div>
);
}

View File

@ -0,0 +1,799 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import {
Segmented,
Tree,
Input,
Button,
Spin,
Modal,
Typography,
Dropdown,
List,
theme,
Result,
} from 'antd';
import type { TreeDataNode, MenuProps } from 'antd';
import {
FileAddOutlined,
FolderAddOutlined,
DeleteOutlined,
EditOutlined,
SearchOutlined,
ReloadOutlined,
FileMarkdownOutlined,
UploadOutlined,
EyeOutlined,
CodeOutlined,
FolderOutlined,
BuildOutlined,
EllipsisOutlined,
PlusOutlined,
CloseOutlined,
FileOutlined,
} from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } from '@/hooks/useDocsEditor';
import { MobileFormattingToolbar } from './MobileFormattingToolbar';
import type { InsertRequestType } from './MobileFormattingToolbar';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import type { Video as PickerVideo } from '@/components/media/VideoPickerModal';
import { PhotoPickerModal } from '@/components/media/PhotoPickerModal';
import type { Photo as PickerPhoto } from '@/components/media/PhotoPickerModal';
import { PhotoInsertModal } from '@/components/media/PhotoInsertModal';
import type { PhotoInsertResult } from '@/components/media/PhotoInsertModal';
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
import type { ProductInsertResult } from '@/components/payments/ProductInsertModal';
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
type MobileTab = 'files' | 'editor' | 'preview';
interface MobileDocsEditorProps {
editor: UseDocsEditorReturn;
}
// Flatten file tree into a searchable list of file paths
interface FlatFile { path: string; name: string; }
function flattenFiles(nodes: import('@/types/api').FileNode[]): FlatFile[] {
const out: FlatFile[] = [];
for (const n of nodes) {
if (n.isDirectory) {
if (n.children) out.push(...flattenFiles(n.children));
} else {
out.push({ path: n.path, name: n.name });
}
}
return out;
}
function fileNodeToTreeData(nodes: import('@/types/api').FileNode[]): TreeDataNode[] {
return nodes.map((node) => {
const displayName = !node.isDirectory && node.name.endsWith('.md')
? node.name.slice(0, -3)
: node.name;
const treeNode: TreeDataNode = {
key: node.path,
title: displayName,
isLeaf: !node.isDirectory,
};
if (node.isDirectory && node.children) {
treeNode.children = fileNodeToTreeData(node.children);
}
return treeNode;
});
}
const LINE_HEIGHT = 20;
const MONO_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
const FONT_SIZE = 13;
function LineNumberedEditor({
textareaRef,
value,
onChange,
token,
}: {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
token: ReturnType<typeof theme.useToken>['token'];
}) {
const gutterRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => value.split('\n').length, [value]);
// Sync gutter scroll with textarea scroll
const handleScroll = useCallback(() => {
if (gutterRef.current && textareaRef.current) {
gutterRef.current.scrollTop = textareaRef.current.scrollTop;
}
}, [textareaRef]);
// Attach scroll listener
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.addEventListener('scroll', handleScroll);
return () => ta.removeEventListener('scroll', handleScroll);
}, [textareaRef, handleScroll]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' }}>
{/* Line number gutter */}
<div
ref={gutterRef}
style={{
width: 36,
flexShrink: 0,
overflow: 'hidden',
paddingTop: 4,
background: token.colorBgLayout,
borderRight: `1px solid ${token.colorBorderSecondary}`,
userSelect: 'none',
fontFamily: MONO_FONT,
fontSize: FONT_SIZE - 2,
lineHeight: `${LINE_HEIGHT}px`,
color: token.colorTextQuaternary,
textAlign: 'right',
paddingRight: 6,
}}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} style={{ height: LINE_HEIGHT }}>{i + 1}</div>
))}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={onChange}
spellCheck={false}
style={{
flex: 1,
minHeight: 0,
height: '100%',
border: 'none',
outline: 'none',
resize: 'none',
padding: '4px 6px',
margin: 0,
fontFamily: MONO_FONT,
fontSize: FONT_SIZE,
lineHeight: `${LINE_HEIGHT}px`,
background: 'transparent',
color: token.colorText,
boxSizing: 'border-box',
overflow: 'auto',
whiteSpace: 'pre',
WebkitTextSizeAdjust: 'none',
}}
/>
</div>
);
}
export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
const { token } = theme.useToken();
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [activeTab, setActiveTab] = useState<MobileTab>('files');
const [searchOpen, setSearchOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Insert modal state
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
const [pendingPhotoVariant, setPendingPhotoVariant] = useState<PhotoInsertResult | null>(null);
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const {
fileTree,
filteredTree,
selectedFile,
fileContent,
dirty,
saving,
fileLoading,
loading,
fetchError,
filterQuery,
expandedKeys,
modalType,
modalInput,
contextPath,
config,
setFilterQuery,
setExpandedKeys,
setModalType,
setModalInput,
setContextPath,
fetchData,
loadFile,
saveFile,
onContentChange,
handleDelete,
handleModalOk,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
handleUploadFiles,
isDirectoryPath,
previewIframeRef,
fileInputRef,
contextHolder,
} = editor;
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
// Flat file list for search results
const allFiles = useMemo(() => flattenFiles(fileTree), [fileTree]);
const searchResults = useMemo(() => {
if (!filterQuery.trim() || filterQuery.length < 2) return [];
const q = filterQuery.toLowerCase();
return allFiles
.filter(f => f.path.toLowerCase().includes(q) || f.name.toLowerCase().includes(q))
.slice(0, 20);
}, [filterQuery, allFiles]);
// Helper: insert HTML at textarea cursor position
const insertHtml = useCallback((html: string) => {
const ta = textareaRef.current;
if (!ta) {
// No textarea — append to content
onContentChange(fileContent + '\n' + html + '\n');
return;
}
const { selectionStart, value } = ta;
const before = value.substring(0, selectionStart);
const after = value.substring(selectionStart);
onContentChange(before + '\n' + html + '\n' + after);
}, [onContentChange, fileContent]);
const handleTreeSelect = useCallback(async (keys: React.Key[]) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isDirectoryPath(path)) return;
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: 'Save changes before switching files?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await saveFile();
await loadFile(path);
setActiveTab('editor');
},
onCancel: async () => {
onContentChange(editor.fileContent);
await loadFile(path);
setActiveTab('editor');
},
});
return;
}
await loadFile(path);
setActiveTab('editor');
}, [dirty, saveFile, loadFile, isDirectoryPath, onContentChange, editor.fileContent]);
// Select file from search results
const handleSearchSelect = useCallback(async (path: string) => {
setSearchOpen(false);
setFilterQuery('');
await loadFile(path);
setActiveTab('editor');
}, [loadFile, setFilterQuery]);
const handleTabChange = useCallback((val: string | number) => {
const newTab = val as MobileTab;
if (activeTab === 'editor' && newTab === 'files' && dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: 'You have unsaved changes. Save before switching?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); setActiveTab(newTab); },
onCancel: () => setActiveTab(newTab),
});
return;
}
setActiveTab(newTab);
}, [activeTab, dirty, saveFile]);
const handleEditorChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onContentChange(e.target.value);
}, [onContentChange]);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadFiles(e.target.files);
e.target.value = '';
}
}, [handleUploadFiles]);
const toggleExpand = useCallback((key: string) => {
setExpandedKeys(prev =>
prev.includes(key)
? prev.filter(k => k !== key)
: [...prev, key]
);
}, [setExpandedKeys]);
const getContextMenuItems = useCallback((nodePath: string, isDir: boolean): MenuProps['items'] => {
const items: MenuProps['items'] = [];
if (isDir) {
items.push(
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFile'); } },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFolder'); } },
{ type: 'divider' },
);
}
items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
}, [setContextPath, setModalInput, setModalType, handleDelete]);
const addMenuItems: MenuProps['items'] = useMemo(() => [
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: handleNewFileRoot },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: handleNewFolderRoot },
{ key: 'upload', icon: <UploadOutlined />, label: 'Upload', onClick: () => fileInputRef.current?.click() },
{ type: 'divider' as const },
{ key: 'refresh', icon: <ReloadOutlined />, label: 'Refresh', onClick: refreshTree },
], [handleNewFileRoot, handleNewFolderRoot, fileInputRef, refreshTree]);
// --- Insert handlers (mirror desktop logic but use insertHtml instead of Monaco) ---
const handleInsertRequest = useCallback((type: InsertRequestType) => {
switch (type) {
case 'video-card': setVideoPickerOpen(true); break;
case 'photo-insert': setPhotoInsertOpen(true); break;
case 'donate-button': setDonateInsertOpen(true); break;
case 'product-card': setProductInsertOpen(true); break;
case 'ad-insert': setAdPickerOpen(true); break;
case 'scheduling-poll': setPollInsertOpen(true); break;
case 'pricing-table': {
const appUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
insertHtml(`<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 12px; margin: 16px 0;">\n <h2 style="color: #fff; margin: 12px 0;">Choose Your Plan</h2>\n <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Get access to exclusive content and features.</p>\n <a href="${appUrl}/pricing" style="display: inline-block; padding: 14px 36px; background: #722ed1; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 1.1rem;">View Plans</a>\n</div>`);
break;
}
}
}, [config, insertHtml]);
const handleVideoCardInsert = useCallback((video: PickerVideo) => {
const adminUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270" viewBox="0 0 480 270"><rect fill="#0d1b2a" width="480" height="270"/><circle cx="240" cy="135" r="32" fill="rgba(157,78,221,0.6)"/><polygon points="230,118 258,135 230,152" fill="#fff"/></svg>'
);
const html = generateVideoCardHtml({
id: video.id, title: video.title, durationSeconds: video.durationSeconds || 0,
quality: '', viewCount: 0, thumbnailUrl: placeholderThumb,
}, { baseUrl: adminUrl });
insertHtml(html);
setVideoPickerOpen(false);
}, [config, insertHtml]);
const handlePhotoInsert = useCallback((result: PhotoInsertResult) => {
if (result.variant === 'single-photo' || result.variant === 'photo-card') {
setPendingPhotoVariant(result);
setPhotoPickerOpen(true);
return;
}
const album = result.album;
if (!album) return;
let html = '';
if (result.variant === 'album-grid') {
const cols = result.options.columns || 3;
const max = result.options.maxPhotos || 12;
const title = result.options.showTitle !== false ? 'true' : 'false';
html = `<div class="photo-album-block" data-album-id="${album.id}" data-columns="${cols}" data-max-photos="${max}" data-show-title="${title}">Loading album...</div>`;
} else if (result.variant === 'album-carousel') {
const max = result.options.maxPhotos || 20;
const title = result.options.showTitle !== false ? 'true' : 'false';
const auto = result.options.autoPlay ? 'true' : 'false';
html = `<div class="photo-album-carousel" data-album-id="${album.id}" data-max-photos="${max}" data-show-title="${title}" data-auto-play="${auto}">Loading carousel...</div>`;
}
if (html) insertHtml(html);
}, [insertHtml]);
const handlePhotoSelected = useCallback((photo: PickerPhoto) => {
const variant = pendingPhotoVariant?.variant || 'photo-card';
if (variant === 'single-photo') {
const opts = pendingPhotoVariant?.options || {};
const html = `<div class="photo-block" data-photo-id="${photo.id}" data-size="${opts.size || 'large'}" data-caption="" data-link-to-gallery="${opts.linkToGallery !== false ? 'true' : 'false'}" data-alignment="${opts.alignment || 'center'}">Loading...</div>`;
insertHtml(html);
} else {
const adminUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect fill="#0d1b2a" width="480" height="320"/><circle cx="240" cy="160" r="32" fill="rgba(46,125,50,0.6)"/></svg>'
);
const html = generatePhotoCardHtml({
id: photo.id, title: photo.title || photo.originalFilename || 'Untitled Photo',
description: photo.description || undefined, showMetadata: true,
format: photo.format || undefined, width: photo.width || undefined,
height: photo.height || undefined, viewCount: photo.viewCount || 0,
thumbnailUrl: placeholderThumb,
}, { baseUrl: adminUrl });
insertHtml(html);
}
setPhotoPickerOpen(false);
setPendingPhotoVariant(null);
}, [config, pendingPhotoVariant, insertHtml]);
const handleDonateInsert = useCallback((result: DonateInsertResult) => {
if (result.variant === 'simple') {
insertHtml('<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #2d1b69, #1a1a2e); border-radius: 12px; margin: 16px 0;">\n <p style="font-size: 48px; margin: 0;">&#x2764;&#xFE0F;</p>\n <h2 style="color: #fff; margin: 12px 0;">Support Our Cause</h2>\n <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Your contribution helps us create lasting change.</p>\n <a href="/donate" style="display: inline-block; padding: 14px 36px; background: #eb2f96; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">Donate Now</a>\n</div>');
} else if (result.variant === 'set-amount') {
const cents = result.amount || 2500;
const dollars = (cents / 100).toFixed(0);
insertHtml(`<div data-amounts="${cents}" data-preselected="${cents}" style="text-align:center;padding:40px 20px;background:linear-gradient(135deg,#2d1b69,#1a1a2e);border-radius:12px;margin:16px 0;">\n <h2 style="color:#fff;">Donate $${dollars}</h2>\n <a href="/donate?amount=${cents}" style="display:inline-block;padding:14px 36px;background:#eb2f96;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Donate $${dollars}</a>\n</div>`);
} else {
const cfg = result.config;
const title = cfg?.donationPageTitle || 'Support Our Cause';
insertHtml(`<div style="text-align:center;padding:40px 20px;background:linear-gradient(135deg,#2d1b69,#1a1a2e);border-radius:12px;margin:16px 0;">\n <h2 style="color:#fff;">${title}</h2>\n <a href="/donate" style="display:inline-block;padding:14px 36px;background:#eb2f96;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Donate Now</a>\n</div>`);
}
}, [insertHtml]);
const handleProductInsert = useCallback((result: ProductInsertResult) => {
const p = result.product;
const priceStr = `$${(p.priceCAD / 100).toFixed(2)}`;
insertHtml(`<div data-product-id="${p.id}" style="text-align:center;padding:32px 20px;background:linear-gradient(135deg,#1a1a2e,#16213e);border-radius:12px;margin:16px 0;max-width:420px;margin-left:auto;margin-right:auto;">\n <h3 style="color:#fff;">${p.title}</h3>\n <p style="color:#fff;font-size:1.4rem;font-weight:700;">${priceStr}</p>\n <a href="/shop" style="display:inline-block;padding:14px 36px;background:#722ed1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Buy Now</a>\n</div>`);
}, [insertHtml]);
const handleAdInsert = useCallback((result: AdInsertResult) => {
const html = result.type === 'specific'
? `<div class="ad-specific-block" data-ad-id="${result.adId}" style="max-width:400px; margin:16px auto;">Loading ad...</div>`
: `<div class="ad-slot-block" data-placement="docs" data-variant="${result.variant || 'standard'}" style="max-width:400px; margin:16px auto;">Loading ad...</div>`;
insertHtml(html);
setAdPickerOpen(false);
}, [insertHtml]);
const handlePollInsert = useCallback((slug: string) => {
insertHtml(`<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">Loading poll...</div>`);
setPollInsertOpen(false);
}, [insertHtml]);
if (loading) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
}
if (fetchError) {
return (
<Result
status="error"
title="Cannot Load Editor"
subTitle="Failed to connect to the documentation services."
extra={<Button type="primary" onClick={fetchData}>Retry</Button>}
/>
);
}
const isMarkdownFile = selectedFile?.endsWith('.md');
const isImage = selectedFile ? isImageFile(selectedFile) : false;
const showSearchResults = searchOpen && filterQuery.trim().length >= 2;
return (
<>
{contextHolder}
<style>{`
.mobile-docs-tree .ant-tree-treenode {
padding: 0 !important;
margin: 0 !important;
min-height: 40px !important;
line-height: 40px !important;
border-radius: 0 !important;
width: 100% !important;
}
.mobile-docs-tree .ant-tree-treenode:active {
background: rgba(255,255,255,0.08) !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper {
padding: 0 4px !important;
min-height: 40px !important;
line-height: 40px !important;
border-radius: 0 !important;
background: transparent !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper:hover {
background: transparent !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
background: rgba(255,255,255,0.10) !important;
}
.mobile-docs-tree .ant-tree-switcher {
width: 24px !important;
height: 40px !important;
line-height: 40px !important;
}
.mobile-docs-tree .ant-tree-indent-unit {
width: 14px !important;
}
.mobile-docs-tree .ant-tree-list-holder-inner {
padding: 0 !important;
}
`}</style>
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 64px)' }}>
{/* Header */}
<div style={{
height: 40,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: 6,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
flexShrink: 0,
}}>
{searchOpen ? (
<>
<Input
prefix={<SearchOutlined style={{ color: token.colorTextQuaternary, fontSize: 12 }} />}
placeholder="Search files..."
allowClear
size="small"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
style={{ flex: 1 }}
autoFocus
/>
<Button
type="text"
size="small"
icon={<CloseOutlined style={{ fontSize: 12 }} />}
onClick={() => { setSearchOpen(false); setFilterQuery(''); }}
style={{ width: 28, height: 28, flexShrink: 0 }}
/>
</>
) : (
<>
<Segmented
size="small"
value={activeTab}
onChange={handleTabChange}
options={[
{ value: 'files', icon: <FolderOutlined /> },
{ value: 'editor', icon: <CodeOutlined /> },
{ value: 'preview', icon: <EyeOutlined /> },
]}
/>
<div style={{ flex: 1 }} />
<Button
type="text"
size="small"
icon={<SearchOutlined style={{ fontSize: 14 }} />}
onClick={() => setSearchOpen(true)}
style={{ width: 28, height: 28 }}
/>
{activeTab === 'files' && (
<Dropdown menu={{ items: addMenuItems }} trigger={['click']} placement="bottomRight">
<Button type="text" size="small" icon={<PlusOutlined style={{ fontSize: 14 }} />} style={{ width: 28, height: 28 }} />
</Dropdown>
)}
{isSuperAdmin && (
<Button type="text" size="small" icon={<BuildOutlined style={{ fontSize: 14 }} />} onClick={confirmAndBuild} loading={building} style={{ width: 28, height: 28 }} />
)}
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.pdf,.zip,.md"
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Search results overlay */}
{showSearchResults ? (
<div style={{ flex: 1, overflow: 'auto' }}>
{searchResults.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: token.colorTextTertiary }}>
No files matching "{filterQuery}"
</div>
) : (
<List
size="small"
dataSource={searchResults}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 12px', cursor: 'pointer' }}
onClick={() => handleSearchSelect(item.path)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<FileOutlined style={{ fontSize: 12, color: token.colorTextSecondary, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name.replace(/\.md$/, '')}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</div>
</div>
</div>
</List.Item>
)}
/>
)}
</div>
) : (
<>
{/* FILES TAB */}
{activeTab === 'files' && (
<div style={{ flex: 1, overflow: 'auto' }} className="mobile-docs-tree">
<Tree
treeData={treeData}
showIcon={false}
showLine={false}
motion={false}
selectedKeys={selectedFile ? [selectedFile] : []}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys)}
onSelect={(keys) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isDirectoryPath(path)) return;
handleTreeSelect(keys);
}}
blockNode
titleRender={(nodeData) => {
const nodePath = nodeData.key as string;
const isDir = isDirectoryPath(nodePath);
return (
<div style={{ display: 'flex', alignItems: 'center', minHeight: 40 }}>
<span
onClick={(e) => {
if (isDir) { e.stopPropagation(); toggleExpand(nodePath); }
}}
style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 14, lineHeight: '40px',
color: isDir ? token.colorTextSecondary : token.colorText,
cursor: 'pointer',
}}
>
{nodeData.title as string}
</span>
<Dropdown menu={{ items: getContextMenuItems(nodePath, isDir) }} trigger={['click']} placement="bottomRight">
<Button type="text" size="small" icon={<EllipsisOutlined />} onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0, width: 28, height: 40, opacity: 0.5 }} />
</Dropdown>
</div>
);
}}
/>
</div>
)}
{/* EDITOR TAB */}
{activeTab === 'editor' && (
<div style={{
display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0,
paddingBottom: isMarkdownFile ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 0,
}}>
{selectedFile && (
<div style={{
padding: '4px 10px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex', alignItems: 'center', gap: 6,
height: 28, flexShrink: 0,
}}>
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 11, flex: 1, color: token.colorTextSecondary }} ellipsis>
{selectedFile}
</Typography.Text>
{dirty && (
<span style={{ width: 6, height: 6, borderRadius: '50%', background: token.colorWarning, flexShrink: 0 }} />
)}
</div>
)}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{fileLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}><Spin /></div>
) : !selectedFile ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: token.colorTextTertiary }}>
<div style={{ textAlign: 'center', padding: 24 }}>
<FileMarkdownOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>Select a file from the Files tab</div>
</div>
</div>
) : isImage ? (
<div style={{ padding: 16, textAlign: 'center', overflow: 'auto' }}>
<img src={`/mkdocs-proxy/${selectedFile}`} alt={selectedFile} style={{ maxWidth: '100%', maxHeight: 400, objectFit: 'contain', borderRadius: 4 }} />
</div>
) : (
<LineNumberedEditor
textareaRef={textareaRef}
value={fileContent}
onChange={handleEditorChange}
token={token}
/>
)}
</div>
{isMarkdownFile && selectedFile && !isImage && !fileLoading && (
<MobileFormattingToolbar
textareaRef={textareaRef}
dirty={dirty}
saving={saving}
onContentChange={onContentChange}
onSave={saveFile}
onInsertRequest={handleInsertRequest}
/>
)}
</div>
)}
{/* PREVIEW TAB */}
{activeTab === 'preview' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{
padding: '4px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: `1px solid ${token.colorBorderSecondary}`, height: 28, flexShrink: 0,
}}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{selectedFile || 'Home'}</Typography.Text>
<Button type="text" size="small" icon={<ReloadOutlined style={{ fontSize: 12 }} />} onClick={() => previewIframeRef.current?.contentWindow?.location.reload()} style={{ width: 24, height: 24 }} />
</div>
<iframe ref={previewIframeRef} src="/mkdocs-proxy/" style={{ flex: 1, width: '100%', border: 'none' }} title="MkDocs Preview" />
</div>
)}
</>
)}
</div>
</div>
{/* File CRUD Modal */}
<Modal
title={modalType === 'newFile' ? 'New File' : modalType === 'newFolder' ? 'New Folder' : 'Rename'}
open={modalType !== null}
onOk={handleModalOk}
onCancel={() => setModalType(null)}
okText={modalType === 'rename' ? 'Rename' : 'Create'}
destroyOnHidden
>
<Input
placeholder={modalType === 'newFolder' ? 'Folder name' : 'File name (e.g. my-page.md)'}
value={modalInput}
onChange={(e) => setModalInput(e.target.value)}
onPressEnter={handleModalOk}
autoFocus
/>
{contextPath && (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{modalType === 'rename' ? `Renaming: ${contextPath}` : `Inside: ${contextPath}/`}
</Typography.Text>
)}
</Modal>
{/* Insert modals — same as desktop */}
<VideoPickerModal open={videoPickerOpen} onClose={() => setVideoPickerOpen(false)} onSelect={handleVideoCardInsert} title="Insert Video Card" />
<PhotoInsertModal open={photoInsertOpen} onClose={() => setPhotoInsertOpen(false)} onInsert={handlePhotoInsert} />
<PhotoPickerModal open={photoPickerOpen} onClose={() => { setPhotoPickerOpen(false); setPendingPhotoVariant(null); }} onSelect={handlePhotoSelected} title={pendingPhotoVariant?.variant === 'single-photo' ? 'Select Photo' : 'Select Photo for Card'} />
<DonateInsertModal open={donateInsertOpen} onClose={() => setDonateInsertOpen(false)} onInsert={handleDonateInsert} />
<ProductInsertModal open={productInsertOpen} onClose={() => setProductInsertOpen(false)} onInsert={handleProductInsert} />
<AdPickerModal open={adPickerOpen} onCancel={() => setAdPickerOpen(false)} onInsert={handleAdInsert} />
<PollInsertModal open={pollInsertOpen} onCancel={() => setPollInsertOpen(false)} onInsert={handlePollInsert} />
</>
);
}

View File

@ -0,0 +1,184 @@
import { useState, useCallback } from 'react';
import { Button, Drawer, List, theme } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
CodeOutlined,
LinkOutlined,
FontSizeOutlined,
EllipsisOutlined,
SaveOutlined,
} from '@ant-design/icons';
import {
insertAtCursor,
insertBlock,
cycleHeading,
applyResult,
type TextareaInsertResult,
} from '@/utils/textareaSnippets';
export type InsertRequestType = 'video-card' | 'photo-insert' | 'donate-button' | 'pricing-table' | 'product-card' | 'ad-insert' | 'scheduling-poll';
interface MobileFormattingToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
dirty: boolean;
saving: boolean;
onContentChange: (value: string) => void;
onSave: () => void;
onInsertRequest?: (type: InsertRequestType) => void;
}
interface SnippetDef {
id: string;
label: string;
group: string;
run?: (ta: HTMLTextAreaElement) => TextareaInsertResult;
insertType?: InsertRequestType;
}
const MORE_SNIPPETS: SnippetDef[] = [
// Formatting
{ id: 'strikethrough', label: 'Strikethrough', group: 'Formatting', run: (ta) => insertAtCursor(ta, '~~', '~~') },
{ id: 'highlight', label: 'Highlight', group: 'Formatting', run: (ta) => insertAtCursor(ta, '==', '==') },
{ id: 'kbd', label: 'Keyboard Key', group: 'Formatting', run: (ta) => insertAtCursor(ta, '++', '++') },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'Headings', run: (ta) => insertBlock(ta, '# $CURSOR') },
{ id: 'h2', label: 'Heading 2', group: 'Headings', run: (ta) => insertBlock(ta, '## $CURSOR') },
{ id: 'h3', label: 'Heading 3', group: 'Headings', run: (ta) => insertBlock(ta, '### $CURSOR') },
{ id: 'h4', label: 'Heading 4', group: 'Headings', run: (ta) => insertBlock(ta, '#### $CURSOR') },
// Admonitions
{ id: 'note', label: 'Note', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! note "Title"\n Content here') },
{ id: 'warning', label: 'Warning', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! warning "Title"\n Content here') },
{ id: 'tip', label: 'Tip', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! tip "Title"\n Content here') },
{ id: 'info', label: 'Info', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! info "Title"\n Content here') },
{ id: 'danger', label: 'Danger', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! danger "Title"\n Content here') },
{ id: 'success', label: 'Success', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! success "Title"\n Content here') },
{ id: 'collapsible', label: 'Collapsible', group: 'Admonitions', run: (ta) => insertBlock(ta, '???+ note "Title"\n Content here') },
// Code
{ id: 'code-block', label: 'Code Block', group: 'Code', run: (ta) => insertBlock(ta, '```python\n$CURSOR\n```') },
{ id: 'code-annotated', label: 'Annotated Code', group: 'Code', run: (ta) => insertBlock(ta, '```python\ncode # (1)!\n```\n\n1. Annotation') },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'Code', run: (ta) => insertBlock(ta, '```mermaid\ngraph LR\n A --> B\n```') },
// Insert — text snippets
{ id: 'image', label: 'Image', group: 'Insert', run: (ta) => insertBlock(ta, '![Alt text](image.png)') },
{ id: 'table', label: 'Table', group: 'Insert', run: (ta) => insertBlock(ta, '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |') },
{ id: 'tasklist', label: 'Task List', group: 'Insert', run: (ta) => insertBlock(ta, '- [ ] Task 1\n- [ ] Task 2\n- [x] Done') },
{ id: 'tabs', label: 'Tabs', group: 'Insert', run: (ta) => insertBlock(ta, '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content') },
{ id: 'button', label: 'Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button }') },
{ id: 'button-primary', label: 'Primary Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button .md-button--primary }') },
{ id: 'icon', label: 'Material Icon', group: 'Insert', run: (ta) => insertBlock(ta, ':material-icon-name:') },
{ id: 'math-block', label: 'Math Block', group: 'Insert', run: (ta) => insertBlock(ta, '$$\n$CURSOR\n$$') },
{ id: 'footnote', label: 'Footnote', group: 'Insert', run: (ta) => insertBlock(ta, '[^1]\n\n[^1]: Text') },
{ id: 'def-list', label: 'Definition List', group: 'Insert', run: (ta) => insertBlock(ta, 'Term\n: Definition') },
{ id: 'hr', label: 'Horizontal Rule', group: 'Insert', run: (ta) => insertBlock(ta, '---') },
// Insert — modal-based (open picker)
{ id: 'video-card', label: 'Video Card', group: 'Media & Widgets', insertType: 'video-card' },
{ id: 'photo-insert', label: 'Photo', group: 'Media & Widgets', insertType: 'photo-insert' },
{ id: 'donate-button', label: 'Donate Button', group: 'Media & Widgets', insertType: 'donate-button' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'Media & Widgets', insertType: 'pricing-table' },
{ id: 'product-card', label: 'Product Card', group: 'Media & Widgets', insertType: 'product-card' },
{ id: 'ad-insert', label: 'Ad', group: 'Media & Widgets', insertType: 'ad-insert' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'Media & Widgets', insertType: 'scheduling-poll' },
];
const GROUPS = [...new Set(MORE_SNIPPETS.map(s => s.group))];
export function MobileFormattingToolbar({
textareaRef,
dirty,
saving,
onContentChange,
onSave,
onInsertRequest,
}: MobileFormattingToolbarProps) {
const { token } = theme.useToken();
const [drawerOpen, setDrawerOpen] = useState(false);
const run = useCallback((fn: (ta: HTMLTextAreaElement) => TextareaInsertResult) => {
const ta = textareaRef.current;
if (!ta) return;
applyResult(ta, fn(ta), onContentChange);
}, [textareaRef, onContentChange]);
const btnStyle: React.CSSProperties = { minWidth: 44, height: 44 };
return (
<>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '6px 8px',
paddingBottom: `max(6px, env(safe-area-inset-bottom))`,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}
>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '**', '**'))} style={btnStyle} />
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '*', '*'))} style={btnStyle} />
<Button type="text" size="small" icon={<FontSizeOutlined />} onClick={() => run(cycleHeading)} style={btnStyle} />
<Button type="text" size="small" icon={<LinkOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '[', '](url)'))} style={btnStyle} />
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '`', '`'))} style={btnStyle} />
<Button type="text" size="small" icon={<EllipsisOutlined />} onClick={() => setDrawerOpen(true)} style={btnStyle} />
<div style={{ flex: 1 }} />
<Button
type={dirty ? 'primary' : 'default'}
size="small"
icon={<SaveOutlined />}
onClick={onSave}
loading={saving}
disabled={!dirty}
style={{ height: 44 }}
>
Save
</Button>
</div>
<Drawer
title="Insert Snippet"
placement="bottom"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
height="60%"
styles={{ body: { padding: 0 } }}
>
{GROUPS.map(group => (
<div key={group}>
<div style={{ padding: '10px 16px 2px', fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{group}
</div>
<List
size="small"
dataSource={MORE_SNIPPETS.filter(s => s.group === group)}
renderItem={(item) => (
<List.Item
style={{ padding: '10px 16px', cursor: 'pointer' }}
onClick={() => {
if (item.insertType) {
onInsertRequest?.(item.insertType);
setDrawerOpen(false);
} else if (item.run) {
run(item.run);
setDrawerOpen(false);
setTimeout(() => textareaRef.current?.focus(), 300);
}
}}
>
{item.label}
</List.Item>
)}
/>
</div>
))}
</Drawer>
</>
);
}

View File

@ -0,0 +1,526 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { message, Modal } from 'antd';
import { api } from '@/lib/api';
import type { FileNode, ServicesConfig } from '@/types/api';
// Tree cache constants
const TREE_CACHE_KEY = 'docs-tree-cache';
const TREE_CACHE_TIMESTAMP_KEY = 'docs-tree-cache-timestamp';
const TREE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
export function isImageFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
export function filePathToMkDocsUrl(filePath: string): string {
let url = filePath.replace(/\.md$/, '');
if (url.endsWith('/index') || url === 'index') {
url = url.replace(/\/?index$/, '');
}
return '/mkdocs-proxy/' + url + (url ? '/' : '');
}
function getCachedTree(): FileNode[] | null {
try {
const cached = localStorage.getItem(TREE_CACHE_KEY);
const timestamp = localStorage.getItem(TREE_CACHE_TIMESTAMP_KEY);
if (!cached || !timestamp) return null;
const age = Date.now() - parseInt(timestamp, 10);
if (age > TREE_CACHE_TTL) {
localStorage.removeItem(TREE_CACHE_KEY);
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
return null;
}
return JSON.parse(cached) as FileNode[];
} catch {
return null;
}
}
function setCachedTree(tree: FileNode[]): void {
try {
localStorage.setItem(TREE_CACHE_KEY, JSON.stringify(tree));
localStorage.setItem(TREE_CACHE_TIMESTAMP_KEY, Date.now().toString());
} catch { /* ignore */ }
}
function invalidateTreeCache(): void {
try {
localStorage.removeItem(TREE_CACHE_KEY);
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
} catch { /* ignore */ }
}
/** Collect all directory keys for expand-all */
export function collectAllDirKeys(nodes: FileNode[]): string[] {
const keys: string[] = [];
for (const node of nodes) {
if (node.isDirectory) {
keys.push(node.path);
if (node.children) keys.push(...collectAllDirKeys(node.children));
}
}
return keys;
}
/** Filter tree to only show nodes matching the query (+ their parent dirs) */
export function filterTree(nodes: FileNode[], query: string): FileNode[] {
const q = query.toLowerCase();
const filtered: FileNode[] = [];
for (const node of nodes) {
if (node.isDirectory) {
const childMatches = node.children ? filterTree(node.children, query) : [];
if (childMatches.length > 0 || node.name.toLowerCase().includes(q)) {
filtered.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
}
} else {
if (node.name.toLowerCase().includes(q)) {
filtered.push(node);
}
}
}
return filtered;
}
export interface UseDocsEditorReturn {
// State
fileTree: FileNode[];
config: ServicesConfig | null;
loading: boolean;
fetchError: boolean;
selectedFile: string | null;
fileContent: string;
dirty: boolean;
saving: boolean;
fileLoading: boolean;
filterQuery: string;
expandedKeys: React.Key[];
modalType: 'newFile' | 'newFolder' | 'rename' | null;
modalInput: string;
contextPath: string;
// Setters
setFilterQuery: (q: string) => void;
setExpandedKeys: React.Dispatch<React.SetStateAction<React.Key[]>>;
setModalType: (t: 'newFile' | 'newFolder' | 'rename' | null) => void;
setModalInput: (s: string) => void;
setContextPath: (s: string) => void;
setSelectedFile: (s: string | null) => void;
// Derived
filteredTree: FileNode[];
// Actions
fetchData: () => Promise<void>;
loadFile: (filePath: string) => Promise<void>;
saveFile: () => Promise<void>;
onContentChange: (value: string) => void;
handleDelete: (filePath: string) => void;
handleModalOk: () => Promise<void>;
handleNewFileRoot: () => void;
handleNewFolderRoot: () => void;
refreshTree: () => void;
handleUploadFiles: (files: FileList | File[]) => Promise<void>;
isDirectoryPath: (path: string) => boolean;
onTreeSelect: (keys: React.Key[]) => Promise<void>;
// Refs
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
// Message context
contextHolder: React.ReactElement;
}
export function useDocsEditor(): UseDocsEditorReturn {
const location = useLocation();
const [messageApi, contextHolder] = message.useMessage();
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [fileLoading, setFileLoading] = useState(false);
const [fileContentCache, setFileContentCache] = useState<Map<string, string>>(new Map());
const [filterQuery, setFilterQuery] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
// Modal state
const [modalType, setModalType] = useState<'newFile' | 'newFolder' | 'rename' | null>(null);
const [modalInput, setModalInput] = useState('');
const [contextPath, setContextPath] = useState<string>('');
const previewIframeRef = useRef<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch tree
const fetchTree = useCallback(async (showLoading = true, force = false) => {
try {
if (showLoading) setLoading(true);
setFetchError(false);
const url = force ? '/docs/files?force=true' : '/docs/files';
const res = await api.get<FileNode[]>(url);
setFileTree(res.data);
setCachedTree(res.data);
} catch {
setFetchError(true);
} finally {
if (showLoading) setLoading(false);
}
}, []);
const fetchConfig = useCallback(async () => {
try {
const res = await api.get<ServicesConfig>('/services/config');
setConfig(res.data);
} catch { /* ignore */ }
}, []);
const fetchData = useCallback(async () => {
const cached = getCachedTree();
if (cached) {
setFileTree(cached);
setLoading(false);
fetchTree(false);
} else {
setLoading(true);
setFetchError(false);
await fetchTree(true);
}
fetchConfig();
}, [fetchTree, fetchConfig]);
useEffect(() => { fetchData(); }, [fetchData]);
// Load file content
const loadFile = useCallback(async (filePath: string) => {
const cached = fileContentCache.get(filePath);
if (cached !== undefined) {
setFileContent(cached);
setOriginalContent(cached);
setSelectedFile(filePath);
setDirty(false);
if (previewIframeRef.current && filePath.endsWith('.md')) {
previewIframeRef.current.src = filePathToMkDocsUrl(filePath);
}
return;
}
setFileLoading(true);
try {
const res = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);
const content = res.data.content;
setFileContentCache(prev => new Map(prev).set(filePath, content));
setFileContent(content);
setOriginalContent(content);
setSelectedFile(filePath);
setDirty(false);
if (previewIframeRef.current && filePath.endsWith('.md')) {
previewIframeRef.current.src = filePathToMkDocsUrl(filePath);
}
} catch {
messageApi.error('Failed to load file');
} finally {
setFileLoading(false);
}
}, [fileContentCache, messageApi]);
// Handle navigation state — auto-select a file
useEffect(() => {
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile;
if (!selectFile || loading) return;
const parts = selectFile.split('/');
if (parts.length > 1) {
const parentKeys: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentKeys.push(parts.slice(0, i).join('/'));
}
setExpandedKeys(prev => {
const set = new Set(prev.map(String));
for (const k of parentKeys) set.add(k);
return Array.from(set);
});
}
loadFile(selectFile);
window.history.replaceState({}, '');
}, [location.state, loading, loadFile]);
// Save file
const saveFile = useCallback(async () => {
if (!selectedFile || !dirty) return;
setSaving(true);
try {
await api.put(`/docs/files/${selectedFile}`, { content: fileContent });
setOriginalContent(fileContent);
setDirty(false);
setFileContentCache(prev => new Map(prev).set(selectedFile, fileContent));
messageApi.success('Saved');
setTimeout(() => {
if (previewIframeRef.current) {
previewIframeRef.current.contentWindow?.location.reload();
}
}, 500);
} catch {
messageApi.error('Failed to save');
} finally {
setSaving(false);
}
}, [selectedFile, dirty, fileContent, messageApi]);
// Ctrl+S keyboard shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [saveFile]);
const onContentChange = useCallback((value: string) => {
setFileContent(value);
setDirty(value !== originalContent);
}, [originalContent]);
const refreshTree = useCallback(() => {
invalidateTreeCache();
fetchTree(false, true);
}, [fetchTree]);
const isDirectoryPath = useCallback((path: string): boolean => {
function find(nodes: FileNode[]): boolean {
for (const node of nodes) {
if (node.path === path) return node.isDirectory;
if (node.isDirectory && node.children && find(node.children)) return true;
}
return false;
}
return find(fileTree);
}, [fileTree]);
const handleDelete = useCallback((filePath: string) => {
Modal.confirm({
title: 'Delete',
content: `Are you sure you want to delete "${filePath}"?`,
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
try {
await api.delete(`/docs/files/${filePath}`);
messageApi.success('Deleted');
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
next.delete(filePath);
return next;
});
if (selectedFile === filePath) {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
setDirty(false);
}
fetchTree();
} catch {
messageApi.error('Failed to delete');
}
},
});
}, [selectedFile, messageApi, fetchTree]);
const handleModalOk = useCallback(async () => {
if (!modalInput.trim()) return;
try {
if (modalType === 'newFile') {
const name = modalInput.endsWith('.md') ? modalInput : `${modalInput}.md`;
const path = contextPath ? `${contextPath}/${name}` : name;
await api.post(`/docs/files/${path}`, { content: `# ${modalInput.replace(/\.md$/, '')}\n` });
messageApi.success('File created');
invalidateTreeCache();
await Promise.all([fetchTree(), loadFile(path)]);
} else if (modalType === 'newFolder') {
const path = contextPath ? `${contextPath}/${modalInput}` : modalInput;
await api.post(`/docs/files/${path}`, { isDirectory: true });
messageApi.success('Folder created');
invalidateTreeCache();
fetchTree();
} else if (modalType === 'rename') {
const parentDir = contextPath.includes('/') ? contextPath.substring(0, contextPath.lastIndexOf('/')) : '';
const newPath = parentDir ? `${parentDir}/${modalInput}` : modalInput;
await api.post('/docs/files/rename', { from: contextPath, to: newPath });
messageApi.success('Renamed');
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
const cached = next.get(contextPath);
next.delete(contextPath);
if (cached) next.set(newPath, cached);
return next;
});
if (selectedFile === contextPath) setSelectedFile(newPath);
fetchTree();
}
} catch {
messageApi.error('Operation failed');
}
setModalType(null);
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
const selectImageFile = useCallback((filePath: string) => {
setSelectedFile(filePath);
setFileContent('');
setOriginalContent('');
setDirty(false);
if (previewIframeRef.current) {
previewIframeRef.current.src = '/mkdocs-proxy/';
}
}, []);
const onTreeSelect = useCallback(async (keys: React.Key[]) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isImageFile(path)) {
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: `Save changes to ${selectedFile} before switching?`,
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); selectImageFile(path); },
onCancel: () => { setDirty(false); selectImageFile(path); },
});
return;
}
selectImageFile(path);
return;
}
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: `Save changes to ${selectedFile} before switching?`,
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); await loadFile(path); },
onCancel: () => { setDirty(false); loadFile(path); },
});
return;
}
await loadFile(path);
}, [dirty, selectedFile, saveFile, loadFile, selectImageFile]);
const handleUploadFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
let targetDir = '';
if (selectedFile) {
if (isDirectoryPath(selectedFile)) {
targetDir = selectedFile;
} else if (selectedFile.includes('/')) {
targetDir = selectedFile.substring(0, selectedFile.lastIndexOf('/'));
}
}
const hideLoading = messageApi.loading(`Uploading ${fileArray.length} file${fileArray.length > 1 ? 's' : ''}...`, 0);
let successCount = 0;
let lastMdPath: string | null = null;
for (const file of fileArray) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetDir);
const res = await api.post<{ success: boolean; path: string }>('/docs/upload', formData);
successCount++;
if (file.name.endsWith('.md')) {
lastMdPath = res.data.path;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Upload failed';
messageApi.error(`Failed to upload ${file.name}: ${msg}`);
}
}
hideLoading();
if (successCount > 0) {
messageApi.success(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
invalidateTreeCache();
await fetchTree(false, true);
if (lastMdPath) {
await loadFile(lastMdPath);
}
}
}, [selectedFile, isDirectoryPath, messageApi, fetchTree, loadFile]);
// Filtered tree
const filteredTree = useMemo(() => {
if (!filterQuery.trim()) return fileTree;
return filterTree(fileTree, filterQuery.trim());
}, [fileTree, filterQuery]);
// Sync expanded keys when filter changes
const expandedKeysForFilter = useMemo(() => {
if (filterQuery.trim()) return collectAllDirKeys(filteredTree);
return [];
}, [filterQuery, filteredTree]);
useEffect(() => {
setExpandedKeys(expandedKeysForFilter);
}, [expandedKeysForFilter]);
return {
fileTree,
config,
loading,
fetchError,
selectedFile,
fileContent,
dirty,
saving,
fileLoading,
filterQuery,
expandedKeys,
modalType,
modalInput,
contextPath,
setFilterQuery,
setExpandedKeys,
setModalType,
setModalInput,
setContextPath,
setSelectedFile,
filteredTree,
fetchData,
loadFile,
saveFile,
onContentChange,
handleDelete,
handleModalOk,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
handleUploadFiles,
isDirectoryPath,
onTreeSelect,
previewIframeRef,
fileInputRef,
contextHolder,
};
}

View File

@ -0,0 +1,289 @@
/**
* Shared navigation defaults single source of truth.
*
* Consumed by PublicNavBar, AppLayout, NavigationSettingsPage, and PublicLayout footer.
* Eliminates the three duplicate DEFAULT_NAV_ITEMS arrays that previously caused sync bugs.
*/
import React from 'react';
import {
HomeOutlined,
SendOutlined,
EnvironmentOutlined,
BarChartOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
LinkOutlined,
GlobalOutlined,
BookOutlined,
TagOutlined,
VideoCameraOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
WalletOutlined,
} from '@ant-design/icons';
import type { NavItem } from '@/types/api';
// ---------------------------------------------------------------------------
// Icon map — shared across all consumers
// ---------------------------------------------------------------------------
export const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: React.createElement(HomeOutlined),
SendOutlined: React.createElement(SendOutlined),
EnvironmentOutlined: React.createElement(EnvironmentOutlined),
BarChartOutlined: React.createElement(BarChartOutlined),
CalendarOutlined: React.createElement(CalendarOutlined),
ScheduleOutlined: React.createElement(ScheduleOutlined),
PlayCircleOutlined: React.createElement(PlayCircleOutlined),
HeartOutlined: React.createElement(HeartOutlined),
DollarOutlined: React.createElement(DollarOutlined),
ShoppingOutlined: React.createElement(ShoppingOutlined),
LinkOutlined: React.createElement(LinkOutlined),
GlobalOutlined: React.createElement(GlobalOutlined),
BookOutlined: React.createElement(BookOutlined),
TagOutlined: React.createElement(TagOutlined),
VideoCameraOutlined: React.createElement(VideoCameraOutlined),
FileTextOutlined: React.createElement(FileTextOutlined),
TrophyOutlined: React.createElement(TrophyOutlined),
AppstoreOutlined: React.createElement(AppstoreOutlined),
WalletOutlined: React.createElement(WalletOutlined),
};
// ---------------------------------------------------------------------------
// Default nav items — the canonical list with group nesting
// ---------------------------------------------------------------------------
export const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{
id: 'scheduling', label: 'Scheduling', path: '', icon: 'AppstoreOutlined', enabled: true, order: 3, type: 'group',
children: [
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 0, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'BarChartOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'tickets', label: 'Tickets', path: '/events/tickets', icon: 'TagOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableTicketedEvents' },
{ id: 'meet', label: 'Meet', path: '/meet', icon: 'VideoCameraOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeet' },
],
},
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{
id: 'commerce', label: 'Commerce', path: '', icon: 'WalletOutlined', enabled: true, order: 5, type: 'group', featureFlag: 'enablePayments',
children: [
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 1, type: 'builtin' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 2, type: 'builtin' },
],
},
{ id: 'wall-of-fame', label: 'Wall of Fame', path: '/wall-of-fame', icon: 'TrophyOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enableSocial' },
{ id: 'pages', label: 'Pages', path: '/pages', icon: 'FileTextOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enableLandingPages' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 8, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 9, type: 'builtin', external: true },
];
// ---------------------------------------------------------------------------
// Admin-specific overrides applied on top of shared defaults
// ---------------------------------------------------------------------------
export const ADMIN_NAV_OVERRIDES: Record<string, Partial<NavItem>> = {
home: { path: '/', external: true },
events: { label: 'Events', external: true },
};
// ---------------------------------------------------------------------------
// Feature flags
// ---------------------------------------------------------------------------
/** Flags that default to true (opt-out) — visible unless explicitly disabled */
const OPT_OUT_FLAGS = new Set([
'enableInfluence',
'enableMap',
'enableMediaFeatures',
'enableEvents',
]);
/** Build the feature flags record from settings */
export function buildFeatureFlags(settings: Record<string, any> | null | undefined): Record<string, boolean | undefined> {
if (!settings) return {};
return {
enableInfluence: settings.enableInfluence,
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
enableEvents: settings.enableEvents,
enableMeetingPlanner: settings.enableMeetingPlanner,
enableTicketedEvents: settings.enableTicketedEvents,
enableSocial: settings.enableSocial,
enableMeet: settings.enableMeet,
enableLandingPages: settings.enableLandingPages,
};
}
/** Check whether a single feature flag passes */
function flagPasses(flagName: string, flags: Record<string, boolean | undefined>): boolean {
if (OPT_OUT_FLAGS.has(flagName)) {
return flags[flagName] !== false;
}
return flags[flagName] === true;
}
// ---------------------------------------------------------------------------
// Merge: sync stored nav config with code-level defaults
// ---------------------------------------------------------------------------
/** Collect all IDs from items (top-level + children) */
function collectIds(items: NavItem[]): Set<string> {
const ids = new Set<string>();
for (const item of items) {
ids.add(item.id);
if (item.children) {
for (const child of item.children) ids.add(child.id);
}
}
return ids;
}
/**
* Merge stored nav config with code-level defaults.
* - Syncs icon/path for existing builtins (recursively for children).
* - Appends missing builtins/groups at end.
* - Adds missing children inside existing groups.
*/
export function mergeNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map<string, NavItem>();
for (const d of DEFAULT_NAV_ITEMS) {
if (d.type === 'builtin' || d.type === 'group') defaultMap.set(d.id, d);
if (d.children) {
for (const child of d.children) {
if (child.type === 'builtin') defaultMap.set(child.id, child);
}
}
}
const existingIds = collectIds(stored);
// Sync existing items
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
if (!def) return item;
if (item.type === 'builtin' && def.type === 'builtin') {
return { ...item, icon: def.icon, path: def.path };
}
if (item.type === 'group' && def.type === 'group' && def.children) {
// Sync existing children and append missing ones
const childMap = new Map(def.children.map(c => [c.id, c]));
const syncedChildren = (item.children || []).map(child => {
const childDef = childMap.get(child.id);
return (childDef && child.type === 'builtin') ? { ...child, icon: childDef.icon, path: childDef.path } : child;
});
const childIds = new Set(syncedChildren.map(c => c.id));
const missingChildren = def.children.filter(c => !childIds.has(c.id) && !existingIds.has(c.id));
const children = missingChildren.length > 0 ? [...syncedChildren, ...missingChildren] : syncedChildren;
return { ...item, icon: def.icon, children };
}
return item;
});
// Append missing top-level items (groups + builtins not already present anywhere)
const syncedIds = collectIds(synced);
const missing = DEFAULT_NAV_ITEMS.filter(d =>
(d.type === 'builtin' || d.type === 'group') && !syncedIds.has(d.id)
);
return missing.length > 0 ? [...synced, ...missing] : synced;
}
// ---------------------------------------------------------------------------
// Filter: apply feature flags and visibility rules
// ---------------------------------------------------------------------------
/**
* Filter nav items by feature flags and enabled state.
* Groups are visible when: (a) group's own featureFlag passes, AND (b) at least one child passes.
*/
export function filterNavItems(items: NavItem[], featureFlags: Record<string, boolean | undefined>): NavItem[] {
return items
.filter(item => item.enabled)
.filter(item => {
if (item.type === 'group') {
// Group's own feature flag must pass (if set)
if (item.featureFlag && !flagPasses(item.featureFlag, featureFlags)) return false;
// At least one child must be visible
const visibleChildren = (item.children || [])
.filter(c => c.enabled)
.filter(c => !c.featureFlag || flagPasses(c.featureFlag, featureFlags));
return visibleChildren.length > 0;
}
if (!item.featureFlag) return true;
return flagPasses(item.featureFlag, featureFlags);
})
.map(item => {
if (item.type !== 'group' || !item.children) return item;
// Filter children within visible groups
const filteredChildren = item.children
.filter(c => c.enabled)
.filter(c => !c.featureFlag || flagPasses(c.featureFlag, featureFlags))
.sort((a, b) => a.order - b.order);
return { ...item, children: filteredChildren };
})
.sort((a, b) => a.order - b.order);
}
// ---------------------------------------------------------------------------
// Flatten: convert groups to flat list (for footer, Gancio, MkDocs)
// ---------------------------------------------------------------------------
/** Flatten groups into their children. Groups themselves are removed; children promoted to top level. */
export function flattenNavItems(items: NavItem[]): NavItem[] {
const result: NavItem[] = [];
for (const item of items) {
if (item.type === 'group' && item.children) {
for (const child of item.children) {
result.push(child);
}
} else {
result.push(item);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Apply admin overrides to shared defaults
// ---------------------------------------------------------------------------
export function applyAdminOverrides(items: NavItem[]): NavItem[] {
return items.map(item => {
const override = ADMIN_NAV_OVERRIDES[item.id];
if (override) return { ...item, ...override };
if (item.type === 'group' && item.children) {
const children = item.children.map(child => {
const childOverride = ADMIN_NAV_OVERRIDES[child.id];
return childOverride ? { ...child, ...childOverride } : child;
});
return { ...item, children };
}
return item;
});
}
// ---------------------------------------------------------------------------
// Active route detection with group support
// ---------------------------------------------------------------------------
/** Check if a path matches any item or its children. Returns true if active. */
export function isItemActive(item: NavItem, currentPath: string): boolean {
if (item.type === 'group' && item.children) {
return item.children.some(child => child.path === currentPath || (child.path && currentPath.startsWith(child.path + '/')));
}
return item.path === currentPath || (item.path !== '' && currentPath.startsWith(item.path + '/'));
}

View File

@ -0,0 +1,239 @@
import { useState, useEffect } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Tag,
Space,
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AdminCalendarView } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
const ROLE_OPTIONS = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' },
{ label: 'Tickets', value: 'TICKETS' },
{ label: 'Polls', value: 'POLLS' },
{ label: 'Public Events', value: 'PUBLIC_EVENTS' },
];
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarPage() {
const navigate = useNavigate();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [views, setViews] = useState<AdminCalendarView[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingView, setEditingView] = useState<AdminCalendarView | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
setPageHeader({ title: 'Calendar Views', subtitle: 'Manage role-based shared calendar views' });
}, [setPageHeader]);
const fetchViews = async () => {
setLoading(true);
try {
const { data } = await api.get<{ views: AdminCalendarView[] }>('/admin/calendar/shared');
setViews(data.views);
} catch {
message.error('Failed to load calendar views');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchViews();
}, []);
const openCreate = () => {
setEditingView(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (view: AdminCalendarView) => {
setEditingView(view);
form.setFieldsValue({
name: view.name,
description: view.description,
autoIncludeRoles: view.autoIncludeRoles,
includedLayerTypes: view.includedLayerTypes,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
if (editingView) {
await api.patch(`/admin/calendar/shared/${editingView.id}`, values);
message.success('View updated');
} else {
await api.post('/admin/calendar/shared', values);
message.success('View created');
}
setModalOpen(false);
fetchViews();
} catch {
// validation or API error
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/calendar/shared/${id}`);
message.success('View deleted');
fetchViews();
} catch {
message.error('Failed to delete view');
}
};
const columns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Roles',
dataIndex: 'autoIncludeRoles',
key: 'roles',
render: (roles: string[]) => (
<Space size={4} wrap>
{roles.map((r) => (
<Tag key={r} color={ROLE_COLORS[r] || 'default'}>{r}</Tag>
))}
</Space>
),
},
{
title: 'Layer Types',
dataIndex: 'includedLayerTypes',
key: 'layerTypes',
render: (types: string[]) => (
<Space size={4} wrap>
{types.map((t) => (
<Tag key={t}>{t}</Tag>
))}
</Space>
),
},
{
title: 'Users',
dataIndex: 'userCount',
key: 'userCount',
width: 80,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 100,
render: (_: unknown, record: AdminCalendarView) => (
<Space size={4}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); openEdit(record); }}
/>
<Popconfirm
title="Delete this view?"
onConfirm={(e) => { e?.stopPropagation(); handleDelete(record.id); }}
onCancel={(e) => e?.stopPropagation()}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Create View
</Button>
</div>
<Table
dataSource={views}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
onRow={(record) => ({
onClick: () => navigate(`/app/scheduling/calendar-views/${record.id}`),
style: { cursor: 'pointer' },
})}
/>
<Modal
title={editingView ? 'Edit Calendar View' : 'Create Calendar View'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Name is required' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="autoIncludeRoles" label="Roles" initialValue={[]}>
<Select mode="multiple" options={ROLE_OPTIONS} placeholder="Select roles" />
</Form.Item>
<Form.Item name="includedLayerTypes" label="Layer Types" initialValue={[]}>
<Select mode="multiple" options={LAYER_TYPE_OPTIONS} placeholder="Select layer types" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,308 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
Tabs,
Alert,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
UserOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import type {
PersonalCalendarItem,
AdminCalendarView,
AdminCalendarUser,
AdminCalendarItem,
} from '@/types/api';
const { Title, Text } = Typography;
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [view, setView] = useState<AdminCalendarView | null>(null);
const [users, setUsers] = useState<AdminCalendarUser[]>([]);
const [items, setItems] = useState<AdminCalendarItem[]>([]);
const [truncated, setTruncated] = useState(false);
const [totalUsers, setTotalUsers] = useState(0);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!id) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const [viewsRes, itemsRes] = await Promise.all([
api.get<{ views: AdminCalendarView[] }>('/admin/calendar/shared'),
api.get<{
items: AdminCalendarItem[];
users: AdminCalendarUser[];
totalUsers: number;
truncated: boolean;
}>(`/admin/calendar/shared/${id}/items`, { params: { startDate, endDate } }),
]);
const found = viewsRes.data.views.find((v) => v.id === id);
if (found) setView(found);
setItems(itemsRes.data.items);
setUsers(itemsRes.data.users);
setTotalUsers(itemsRes.data.totalUsers);
setTruncated(itemsRes.data.truncated);
} catch {
navigate('/app/scheduling/calendar-views');
} finally {
setLoading(false);
}
}, [id, currentMonth, navigate]);
useEffect(() => {
setLoading(true);
fetchData();
}, [fetchData]);
const calendarItems: PersonalCalendarItem[] = items.map((item) => ({
id: item.id,
type: item.type as PersonalCalendarItem['type'],
layerId: item.layerId,
title: item.title,
date: item.date,
startTime: item.startTime,
endTime: item.endTime,
isAllDay: false,
location: item.location,
color: item.userColor,
itemType: item.itemType as PersonalCalendarItem['itemType'],
busyStatus: 'BUSY' as const,
showDetailsTo: 'EVERYONE' as const,
}));
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (!view) return null;
const usersPanel = (
<div>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
<UserOutlined /> Users ({totalUsers})
</Text>
{truncated && (
<Alert
type="warning"
message={`Showing ${users.length} of ${totalUsers} users`}
style={{ marginBottom: 12, fontSize: 12 }}
showIcon
/>
)}
<List
size="small"
dataSource={users}
renderItem={(u) => (
<List.Item style={{ padding: '6px 0' }}>
<Space size={8}>
<div
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: u.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 13 }} ellipsis>
{u.name || u.email}
</Text>
<Tag color={ROLE_COLORS[u.role] || 'default'} style={{ fontSize: 10, margin: 0 }}>
{u.role}
</Tag>
</Space>
</List.Item>
)}
/>
</div>
);
const dateDetailPanel = selectedDate && (
<div>
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 12 }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0', flexDirection: 'column', alignItems: 'stretch' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.userColor || item.color,
flexShrink: 0,
}}
/>
}
title={
<Space size={4}>
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
({item.userName})
</Text>
</Space>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
);
if (isMobile) {
return (
<div style={{ padding: 12 }}>
<Space style={{ marginBottom: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
/>
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
</Space>
{view.description && (
<Text type="secondary" style={{ display: 'block', marginBottom: 12, fontSize: 13 }}>
{view.description}
</Text>
)}
<Tabs
defaultActiveKey="calendar"
items={[
{
key: 'calendar',
label: 'Calendar',
children: (
<>
<PersonalCalendarView
items={calendarItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
{dateDetailPanel}
</>
),
},
{ key: 'users', label: 'Users', children: usersPanel },
]}
/>
</div>
);
}
return (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
/>
<CalendarOutlined style={{ fontSize: 18 }} />
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>
</Space>
{view.description && (
<Text type="secondary" style={{ marginLeft: 16, fontSize: 13 }}>
{view.description}
</Text>
)}
</div>
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 220, flexShrink: 0, paddingRight: 16 }}>
{usersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={calendarItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{selectedDate && (
<div
style={{
width: 280,
flexShrink: 0,
padding: '0 0 0 16px',
borderLeft: `1px solid ${token.colorBorderSecondary}`,
}}
>
{dateDetailPanel}
</div>
)}
</div>
</div>
);
}

View File

@ -66,6 +66,8 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import { useDocsEditor } from '@/hooks/useDocsEditor';
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
import type { FileNode, ServicesConfig } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
@ -546,6 +548,12 @@ function applySnippet(
ed.focus();
}
/** Wrapper component so useDocsEditor() hook only runs on mobile */
function MobileDocsEditorWrapper() {
const editor = useDocsEditor();
return <MobileDocsEditor editor={editor} />;
}
export default function DocsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const location = useLocation();
@ -1576,9 +1584,7 @@ export default function DocsPage() {
}, [handleUploadFiles]);
if (isMobile) {
return (
<Result status="info" title="Desktop Required" subTitle="The documentation editor requires a desktop browser with a larger screen." />
);
return <MobileDocsEditorWrapper />;
}
if (loading) {

View File

@ -1,12 +1,13 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Button, Space, Badge, Table, Modal, Form, Input, DatePicker,
Button, Space, Badge, Table, Form, Input, DatePicker, Drawer,
App, Popconfirm, Typography, Tag, Tooltip, Grid, Result,
} from 'antd';
import {
ReloadOutlined, PlusOutlined, VideoCameraOutlined,
CopyOutlined, DeleteOutlined, LoginOutlined, LinkOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
@ -28,6 +29,7 @@ export default function JitsiMeetPage() {
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [fastMeetLoading, setFastMeetLoading] = useState(false);
const [form] = Form.useForm();
const fetchStatus = useCallback(async () => {
@ -130,6 +132,32 @@ export default function JitsiMeetPage() {
}
}, [meetUrl, message]);
const handleFastMeeting = useCallback(async () => {
if (!meetUrl) {
message.error('Jitsi service not configured');
return;
}
setFastMeetLoading(true);
try {
const title = `Quick Meeting — ${dayjs().format('MMM D, h:mm A')}`;
const createRes = await api.post<Meeting>('/jitsi/meetings', { title });
const slug = createRes.data.slug;
const tokenRes = await api.post<{ token: string; jitsiRoom: string }>(`/jitsi/meetings/${slug}/token`);
const guestLink = `${window.location.origin}/meet/${slug}`;
await navigator.clipboard.writeText(guestLink);
message.success('Guest link copied — opening meeting...');
window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank');
fetchMeetings();
} catch {
message.error('Failed to create fast meeting');
} finally {
setFastMeetLoading(false);
}
}, [meetUrl, message, fetchMeetings]);
const headerActions = useMemo(() => (
<Space>
<Badge
@ -139,11 +167,14 @@ export default function JitsiMeetPage() {
<Button icon={<ReloadOutlined />} onClick={handleRefresh} size="small">
Refresh
</Button>
<Button icon={<ThunderboltOutlined />} onClick={handleFastMeeting} loading={fastMeetLoading} size="small">
Fast Meeting
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small">
New Meeting
</Button>
</Space>
), [online, handleRefresh]);
), [online, handleRefresh, handleFastMeeting, fastMeetLoading]);
useEffect(() => {
setPageHeader({ title: 'Video Meet', actions: headerActions });
@ -247,25 +278,46 @@ export default function JitsiMeetPage() {
},
];
const drawerWidth = 480;
return (
<>
<Table
dataSource={meetings}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
locale={{ emptyText: 'No meetings yet. Create one to get started.' }}
/>
<div
style={{
marginRight: createOpen ? drawerWidth : 0,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Table
dataSource={meetings}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No meetings yet. Create one to get started.' }}
/>
</div>
<Modal
<Drawer
title="Create Meeting"
open={createOpen}
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={creating}
okText="Create"
destroyOnClose
placement="right"
width={drawerWidth}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => { setCreateOpen(false); form.resetFields(); }}
extra={
<Space>
<Button onClick={() => { setCreateOpen(false); form.resetFields(); }} disabled={creating}>
Cancel
</Button>
<Button type="primary" loading={creating} onClick={() => form.submit()}>
Create
</Button>
</Space>
}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
@ -282,11 +334,11 @@ export default function JitsiMeetPage() {
<DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Form>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
<Paragraph type="secondary" style={{ fontSize: 12 }}>
A unique guest link will be generated. Share it with anyone they can join without an account.
Authenticated users join as moderators with full meeting controls.
</Paragraph>
</Modal>
</Drawer>
</>
);
}

View File

@ -241,7 +241,7 @@ export default function LandingPagesPage() {
settingsForm.setFieldsValue({
title: page.title,
description: page.description,
listed: (page as any).listed ?? false,
listed: page.listed ?? false,
mkdocsPath: page.mkdocsPath,
mkdocsExportMode: page.mkdocsExportMode,
mkdocsHideNav: page.mkdocsHideNav,
@ -284,7 +284,7 @@ export default function LandingPagesPage() {
render: (published: boolean, record: LandingPage) => (
<Space size={4}>
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
{(record as any).listed && <Tag color="blue">Listed</Tag>}
{record.listed && <Tag color="blue">Listed</Tag>}
</Space>
),
},

View File

@ -9,61 +9,30 @@ import {
Tag,
Modal,
Tooltip,
Select,
Badge,
message,
Spin,
Form,
} from 'antd';
import {
SaveOutlined,
HomeOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
LinkOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
DeleteOutlined,
PlusOutlined,
GlobalOutlined,
BookOutlined,
SendOutlined,
FolderOutlined,
FolderAddOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import type { AppOutletContext } from '@/components/AppLayout';
import type { NavItem } from '@/types/api';
const NAV_ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Events', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents', external: true },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
import {
DEFAULT_NAV_ITEMS,
ICON_MAP,
mergeNavDefaults,
} from '@/lib/nav-defaults';
export default function NavigationSettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -87,16 +56,7 @@ export default function NavigationSettingsPage() {
useEffect(() => {
if (settings?.navConfig?.items) {
// Merge missing builtin defaults and sync icons so code-level changes propagate
const stored = settings.navConfig.items;
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
const synced = stored.map((item: NavItem) => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map((i: NavItem) => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
setNavItems(missing.length > 0 ? [...synced, ...missing] : synced);
setNavItems(mergeNavDefaults(settings.navConfig.items));
}
}, [settings]);
@ -113,37 +73,89 @@ export default function NavigationSettingsPage() {
}
};
// --- Toggle enable/disable (works for top-level and children) ---
const toggleNavItem = (itemId: string, enabled: boolean) => {
setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, enabled } : item));
setNavItems(prev => prev.map(item => {
if (item.id === itemId) return { ...item, enabled };
if (item.children) {
const children = item.children.map(c => c.id === itemId ? { ...c, enabled } : c);
return { ...item, children };
}
return item;
}));
setDirty(true);
};
// --- Reorder: scoped to sibling context ---
const moveNavItem = (itemId: string, direction: 'up' | 'down') => {
setNavItems(prev => {
const items = [...prev].sort((a, b) => a.order - b.order);
const idx = items.findIndex(i => i.id === itemId);
if (idx < 0) return prev;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= items.length) return prev;
const tempOrder = items[idx]!.order;
items[idx] = { ...items[idx]!, order: items[swapIdx]!.order };
items[swapIdx] = { ...items[swapIdx]!, order: tempOrder };
items.sort((a, b) => a.order - b.order);
return items;
// Check if item is top-level
const topIdx = prev.findIndex(i => i.id === itemId);
if (topIdx >= 0) {
const sorted = [...prev].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex(i => i.id === itemId);
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sorted.length) return prev;
const tempOrder = sorted[idx]!.order;
sorted[idx] = { ...sorted[idx]!, order: sorted[swapIdx]!.order };
sorted[swapIdx] = { ...sorted[swapIdx]!, order: tempOrder };
return sorted.sort((a, b) => a.order - b.order);
}
// Check children
return prev.map(item => {
if (!item.children) return item;
const childIdx = item.children.findIndex(c => c.id === itemId);
if (childIdx < 0) return item;
const sorted = [...item.children].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex(c => c.id === itemId);
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sorted.length) return item;
const tempOrder = sorted[idx]!.order;
sorted[idx] = { ...sorted[idx]!, order: sorted[swapIdx]!.order };
sorted[swapIdx] = { ...sorted[swapIdx]!, order: tempOrder };
return { ...item, children: sorted.sort((a, b) => a.order - b.order) };
});
});
setDirty(true);
};
// --- Update a field on any item (top-level or child) ---
const updateNavItemField = (itemId: string, field: 'label' | 'path', value: string) => {
setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, [field]: value } : item));
setNavItems(prev => prev.map(item => {
if (item.id === itemId) return { ...item, [field]: value };
if (item.children) {
const children = item.children.map(c => c.id === itemId ? { ...c, [field]: value } : c);
return { ...item, children };
}
return item;
}));
setDirty(true);
};
// --- Delete an item ---
const deleteNavItem = (itemId: string) => {
setNavItems(prev => prev.filter(item => item.id !== itemId));
setNavItems(prev => {
// If it's a group, move children to top-level first
const group = prev.find(i => i.id === itemId && i.type === 'group');
if (group && group.children) {
const maxOrder = prev.reduce((max, i) => Math.max(max, i.order), -1);
const promotedChildren = group.children.map((c, idx) => ({ ...c, order: maxOrder + 1 + idx }));
return [...prev.filter(i => i.id !== itemId), ...promotedChildren];
}
// Remove from top-level
const withoutTop = prev.filter(i => i.id !== itemId);
if (withoutTop.length < prev.length) return withoutTop;
// Remove from children
return prev.map(item => {
if (!item.children) return item;
const filtered = item.children.filter(c => c.id !== itemId);
return filtered.length !== item.children.length ? { ...item, children: filtered } : item;
});
});
setDirty(true);
};
// --- Add custom link ---
const addCustomNavLink = () => {
if (!customLinkLabel.trim() || !customLinkPath.trim()) return;
const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1);
@ -163,6 +175,69 @@ export default function NavigationSettingsPage() {
setDirty(true);
};
// --- Add group ---
const addGroup = () => {
const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1);
setNavItems(prev => [...prev, {
id: `group-${Date.now()}`,
label: 'New Group',
path: '',
icon: 'FolderOutlined',
enabled: true,
order: maxOrder + 1,
type: 'group',
children: [],
}]);
setDirty(true);
};
// --- Move item to/from group ---
const moveItemToGroup = (itemId: string, targetGroupId: string | null) => {
setNavItems(prev => {
// Extract item from wherever it currently is
let extractedItem: NavItem | null = null;
let items = prev.map(item => {
if (item.id === itemId) {
extractedItem = item;
return null; // Mark for removal
}
if (item.children) {
const child = item.children.find(c => c.id === itemId);
if (child) {
extractedItem = child;
return { ...item, children: item.children.filter(c => c.id !== itemId) };
}
}
return item;
}).filter(Boolean) as NavItem[];
if (!extractedItem) return prev;
const extracted: NavItem = extractedItem;
if (targetGroupId === null) {
// Move to top level
const maxOrder = items.reduce((max, i) => Math.max(max, i.order), -1);
return [...items, { ...extracted, order: maxOrder + 1 }];
}
// Move into target group
return items.map(item => {
if (item.id === targetGroupId) {
const maxChildOrder = (item.children || []).reduce((max, c) => Math.max(max, c.order), -1);
return {
...item,
children: [...(item.children || []), { ...extracted, order: maxChildOrder + 1 }],
};
}
return item;
});
});
setDirty(true);
};
// Get all groups for the "Move to Group" dropdown
const groups = navItems.filter(i => i.type === 'group');
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
@ -171,105 +246,162 @@ export default function NavigationSettingsPage() {
);
}
// Helper to find which group an item belongs to (null = top-level)
const findParentGroupId = (itemId: string): string | null => {
for (const item of navItems) {
if (item.children?.some(c => c.id === itemId)) return item.id;
}
return null;
};
const renderItemRow = (item: NavItem, _idx: number, siblings: NavItem[], indent: boolean) => {
const isGroup = item.type === 'group';
const sorted = [...siblings].sort((a, b) => a.order - b.order);
const sortedIdx = sorted.findIndex(i => i.id === item.id);
const parentGroupId = indent ? findParentGroupId(item.id) : null;
return (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: indent
? '40px 32px 1fr 1fr auto auto 90px'
: '40px 32px 1fr 1.5fr auto auto 90px',
gap: 8,
alignItems: 'center',
padding: '8px 12px',
paddingLeft: indent ? 52 : 12,
background: isGroup
? 'rgba(100,150,255,0.06)'
: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
borderRadius: 6,
border: isGroup
? '1px solid rgba(100,150,255,0.15)'
: '1px solid rgba(255,255,255,0.08)',
borderLeft: indent ? '3px solid rgba(100,150,255,0.3)' : undefined,
opacity: item.enabled ? 1 : 0.5,
}}
>
<Switch
size="small"
checked={item.enabled}
onChange={(checked) => toggleNavItem(item.id, checked)}
/>
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{isGroup ? <FolderOutlined /> : (ICON_MAP[item.icon] || <LinkOutlined />)}
</span>
<Input
size="small"
value={item.label}
onChange={(e) => updateNavItemField(item.id, 'label', e.target.value)}
/>
{isGroup ? (
<Badge count={item.children?.length ?? 0} size="small" offset={[8, 0]} color="blue">
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontStyle: 'italic' }}>Group {item.children?.length ?? 0} items</span>
</Badge>
) : (
<Tooltip title={item.path.startsWith('$') ? 'Auto-resolved based on environment' : undefined}>
<Input
size="small"
value={item.path}
onChange={(e) => updateNavItemField(item.id, 'path', e.target.value)}
disabled={item.path.startsWith('$') || isGroup}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Tooltip>
)}
<Space size={2}>
<Tooltip title="Move up">
<Button
type="text"
size="small"
icon={<ArrowUpOutlined />}
onClick={() => moveNavItem(item.id, 'up')}
disabled={sortedIdx === 0}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
<Tooltip title="Move down">
<Button
type="text"
size="small"
icon={<ArrowDownOutlined />}
onClick={() => moveNavItem(item.id, 'down')}
disabled={sortedIdx === sorted.length - 1}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
{(item.type === 'custom' || item.type === 'group') && (
<Tooltip title={isGroup ? 'Delete group (children move to top level)' : 'Delete'}>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteNavItem(item.id)}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
)}
</Space>
{/* Move to Group dropdown — only for non-group items */}
{!isGroup ? (
<Select
size="small"
value={parentGroupId ?? '__top__'}
onChange={(val) => moveItemToGroup(item.id, val === '__top__' ? null : val)}
style={{ width: 90, fontSize: 11 }}
popupMatchSelectWidth={false}
>
<Select.Option value="__top__">(Top Level)</Select.Option>
{groups.map(g => (
<Select.Option key={g.id} value={g.id}>{g.label}</Select.Option>
))}
</Select>
) : (
<div style={{ textAlign: 'right' }}>
{item.featureFlag ? (
<Tooltip title={`Controlled by ${item.featureFlag}`}>
<Tag color="cyan" style={{ margin: 0, fontSize: 10 }}>
{item.featureFlag.replace('enable', '')}
</Tag>
</Tooltip>
) : (
<Tag color="geekblue" style={{ margin: 0, fontSize: 10 }}>group</Tag>
)}
</div>
)}
{!isGroup && (
<div style={{ display: 'none' }}>
{/* Placeholder — tag column handled by the Select above */}
</div>
)}
</div>
);
};
const sorted = [...navItems].sort((a, b) => a.order - b.order);
return (
<div style={{ maxWidth: 700 }}>
<div style={{ maxWidth: 800 }}>
<Alert
type="info"
message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site."
message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site. Groups appear as dropdowns on desktop and collapsible sections on mobile."
showIcon
style={{ marginBottom: 24 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{[...navItems].sort((a, b) => a.order - b.order).map((item, idx) => {
const sorted = [...navItems].sort((a, b) => a.order - b.order);
return (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '40px 32px 1fr 1.5fr auto 90px',
gap: 8,
alignItems: 'center',
padding: '8px 12px',
background: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
opacity: item.enabled ? 1 : 0.5,
}}
>
<Switch
size="small"
checked={item.enabled}
onChange={(checked) => toggleNavItem(item.id, checked)}
/>
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{NAV_ICON_MAP[item.icon] || <LinkOutlined />}
</span>
<Input
size="small"
value={item.label}
onChange={(e) => updateNavItemField(item.id, 'label', e.target.value)}
/>
<Tooltip title={item.path.startsWith('$') ? 'Auto-resolved based on environment' : undefined}>
<Input
size="small"
value={item.path}
onChange={(e) => updateNavItemField(item.id, 'path', e.target.value)}
disabled={item.path.startsWith('$')}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Tooltip>
<Space size={2}>
<Tooltip title="Move up">
<Button
type="text"
size="small"
icon={<ArrowUpOutlined />}
onClick={() => moveNavItem(item.id, 'up')}
disabled={idx === 0}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
<Tooltip title="Move down">
<Button
type="text"
size="small"
icon={<ArrowDownOutlined />}
onClick={() => moveNavItem(item.id, 'down')}
disabled={idx === sorted.length - 1}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
{item.type === 'custom' && (
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteNavItem(item.id)}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
)}
</Space>
<div style={{ textAlign: 'right' }}>
{item.featureFlag ? (
<Tooltip title={`Controlled by ${item.featureFlag}`}>
<Tag color="cyan" style={{ margin: 0, fontSize: 10 }}>
{item.featureFlag.replace('enable', '')}
</Tag>
</Tooltip>
) : (
<Tag color={item.type === 'builtin' ? 'blue' : 'purple'} style={{ margin: 0, fontSize: 10 }}>
{item.type}
</Tag>
)}
</div>
</div>
);
})}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
{sorted.map((item, idx) => (
<div key={item.id}>
{renderItemRow(item, idx, sorted, false)}
{/* Render children indented below their group */}
{item.type === 'group' && item.children && [...item.children].sort((a, b) => a.order - b.order).map((child, childIdx) =>
renderItemRow(child, childIdx, item.children!, true)
)}
</div>
))}
</div>
<Space>
@ -279,6 +411,12 @@ export default function NavigationSettingsPage() {
>
Add Custom Link
</Button>
<Button
icon={<FolderAddOutlined />}
onClick={addGroup}
>
Add Group
</Button>
</Space>
<div style={{ marginTop: 24 }}>

View File

@ -35,7 +35,7 @@ export default function CheckInScannerPage() {
const fetchEvent = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/api/ticketed-events/admin/${id}`);
const { data } = await api.get(`/ticketed-events/admin/${id}`);
setEvent(data);
} catch {
message.error('Failed to load event');
@ -45,7 +45,7 @@ export default function CheckInScannerPage() {
const fetchStats = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/api/ticketed-events/checkin/event/${id}/stats`);
const { data } = await api.get(`/ticketed-events/checkin/event/${id}/stats`);
setStats(data);
} catch { /* silent */ }
}, [id]);
@ -72,7 +72,7 @@ export default function CheckInScannerPage() {
} catch { /* not a URL, use raw text */ }
// Validate first
const { data: validation } = await api.post('/api/ticketed-events/checkin/validate', { token });
const { data: validation } = await api.post('/ticketed-events/checkin/validate', { token });
if (!validation.valid) {
setScanResult({
@ -88,7 +88,7 @@ export default function CheckInScannerPage() {
}
// Confirm check-in
const { data: result } = await api.post('/api/ticketed-events/checkin/confirm', { token });
const { data: result } = await api.post('/ticketed-events/checkin/confirm', { token });
setScanResult({
type: 'success',
title: 'Checked In!',
@ -151,7 +151,7 @@ export default function CheckInScannerPage() {
body.holderEmail = manualEmail.trim();
}
const { data } = await api.post('/api/ticketed-events/checkin/manual', body);
const { data } = await api.post('/ticketed-events/checkin/manual', body);
setScanResult({
type: 'success',
title: 'Checked In!',

View File

@ -30,8 +30,8 @@ export default function EventDetailPage() {
if (!id) return;
try {
const [eventRes, statsRes] = await Promise.all([
api.get(`/api/ticketed-events/admin/${id}`),
api.get(`/api/ticketed-events/admin/${id}/stats`),
api.get(`/ticketed-events/admin/${id}`),
api.get(`/ticketed-events/admin/${id}/stats`),
]);
setEvent(eventRes.data);
setStats(statsRes.data);
@ -49,7 +49,7 @@ export default function EventDetailPage() {
limit: ticketPagination.limit,
};
if (ticketSearch) params.search = ticketSearch;
const { data } = await api.get(`/api/ticketed-events/admin/${id}/tickets`, { params });
const { data } = await api.get(`/ticketed-events/admin/${id}/tickets`, { params });
setTickets(data.tickets);
setTicketPagination(p => ({ ...p, total: data.pagination.total }));
} catch {
@ -62,7 +62,7 @@ export default function EventDetailPage() {
const fetchCheckIns = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/api/ticketed-events/admin/${id}/checkins`, { params: { limit: 50 } });
const { data } = await api.get(`/ticketed-events/admin/${id}/checkins`, { params: { limit: 50 } });
setCheckIns(data.checkIns);
} catch { /* silent */ }
}, [id]);
@ -73,7 +73,7 @@ export default function EventDetailPage() {
const handleResend = async (ticketId: string) => {
try {
await api.post(`/api/ticketed-events/admin/${id}/resend-ticket/${ticketId}`);
await api.post(`/ticketed-events/admin/${id}/resend-ticket/${ticketId}`);
message.success('Ticket email resent');
} catch {
message.error('Failed to resend');
@ -82,7 +82,7 @@ export default function EventDetailPage() {
const handleCancelTicket = async (ticketId: string) => {
try {
await api.post(`/api/ticketed-events/admin/${id}/tickets/${ticketId}/cancel`);
await api.post(`/ticketed-events/admin/${id}/tickets/${ticketId}/cancel`);
message.success('Ticket cancelled');
fetchTickets();
fetchEvent();
@ -95,7 +95,7 @@ export default function EventDetailPage() {
const handleJoinAsModerator = async () => {
setJoiningMeeting(true);
try {
const { data } = await api.post(`/api/ticketed-events/admin/${id}/meeting-token`);
const { data } = await api.post(`/ticketed-events/admin/${id}/meeting-token`);
window.open(data.jitsiUrl, '_blank');
} catch {
message.error('Failed to generate meeting token');

View File

@ -88,7 +88,7 @@ export default function TicketedEventsPage() {
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get('/api/ticketed-events/admin', { params });
const { data } = await api.get('/ticketed-events/admin', { params });
setEvents(data.events);
setPagination(p => ({ ...p, total: data.pagination.total }));
} catch {
@ -100,7 +100,7 @@ export default function TicketedEventsPage() {
useEffect(() => { fetchEvents(); }, [fetchEvents]);
useEffect(() => {
api.get('/api/settings').then(({ data }) => setEnableMeet(!!data.enableMeet)).catch(() => {});
api.get('/settings').then(({ data }) => setEnableMeet(!!data.enableMeet)).catch(() => {});
}, []);
const handleCreate = () => {
@ -143,7 +143,7 @@ export default function TicketedEventsPage() {
const { tiers, ...eventData } = payload;
await api.put(`/api/ticketed-events/admin/${editingEvent.id}`, eventData);
} else {
await api.post('/api/ticketed-events/admin', payload);
await api.post('/ticketed-events/admin', payload);
}
message.success(editingEvent ? 'Event updated' : 'Event created');

View File

@ -141,11 +141,9 @@ export default function HomePage() {
<Button type="primary" size="large" icon={<SendOutlined />}>Browse Campaigns</Button>
</Link>
)}
{data.enabledModules.map && (
<Link to="/shifts">
<Button size="large" icon={<ScheduleOutlined />}>Volunteer</Button>
</Link>
)}
<Link to="/volunteer">
<Button size="large" icon={<ScheduleOutlined />}>Volunteer</Button>
</Link>
</Space>
</div>

View File

@ -0,0 +1,196 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
message,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import type { PersonalCalendarItem } from '@/types/api';
const { Title, Text } = Typography;
export default function FriendCalendarPage() {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [friendName, setFriendName] = useState<string>('');
const [items, setItems] = useState<PersonalCalendarItem[]>([]);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
if (!userId) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await api.get<{ items: PersonalCalendarItem[] }>(
`/calendar/shared/user/${userId}`,
{ params: { startDate, endDate } },
);
setItems(data.items);
} catch (err: any) {
if (err.response?.status === 403) {
message.error('This user has not shared their calendar');
}
} finally {
setLoading(false);
}
}, [userId, currentMonth]);
const fetchFriendName = useCallback(async () => {
if (!userId) return;
try {
const { data } = await api.get(`/social/profile/${userId}`);
setFriendName(data.user?.name || data.user?.email || 'Friend');
} catch {
setFriendName('Friend');
}
}, [userId]);
useEffect(() => {
setLoading(true);
Promise.all([fetchItems(), fetchFriendName()]).then(() => setLoading(false));
}, [fetchItems, fetchFriendName]);
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: '12px 0' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
const dateDetailPanel = selectedDate && (
<div
style={{
width: isMobile ? '100%' : 280,
flexShrink: 0,
padding: isMobile ? '12px 0' : '0 0 0 16px',
borderLeft: isMobile ? undefined : `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ fontSize: 15, marginBottom: 12, display: 'block' }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
}
title={
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
{!item.isAllDay && (
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
)}
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
);
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
/>
<CalendarOutlined style={{ fontSize: 18, marginRight: 8 }} />
<Title level={4} style={{ margin: 0 }}>
{friendName}&apos;s Calendar
</Title>
</div>
{isMobile ? (
<div>
<MobileDayView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
/>
{dateDetailPanel}
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{dateDetailPanel}
</div>
)}
</div>
</FeatureGate>
);
}

View File

@ -11,6 +11,9 @@ import {
message,
theme,
Modal,
Segmented,
Drawer,
Divider,
} from 'antd';
import {
CalendarOutlined,
@ -19,14 +22,18 @@ import {
EnvironmentOutlined,
DeleteOutlined,
EditOutlined,
SettingOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import CalendarItemModal, { type CalendarItemFormData } from '@/components/calendar/CalendarItemModal';
import CalendarFeedsPanel from '@/components/calendar/CalendarFeedsPanel';
import CalendarExportPanel from '@/components/calendar/CalendarExportPanel';
import type {
CalendarLayer,
PersonalCalendarItem,
@ -34,9 +41,10 @@ import type {
SeriesEditScope,
} from '@/types/api';
const { Title, Text } = Typography;
const { Text } = Typography;
export default function MyCalendarPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
@ -53,6 +61,7 @@ export default function MyCalendarPage() {
// Modal state
const [itemModalOpen, setItemModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
// Derived: enabled layer IDs for filtering
const enabledLayerIds = new Set(layers.filter((l) => l.isEnabled).map((l) => l.id));
@ -68,8 +77,8 @@ export default function MyCalendarPage() {
// Fetch layers
const fetchLayers = useCallback(async () => {
try {
const { data } = await api.get<CalendarLayer[]>('/calendar/layers');
setLayers(data);
const { data } = await api.get<{ layers: CalendarLayer[] }>('/calendar/layers');
setLayers(data.layers);
} catch {
// Layers may not exist yet
}
@ -361,17 +370,29 @@ export default function MyCalendarPage() {
marginBottom: 16,
}}
>
<Title level={4} style={{ margin: 0 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
My Calendar
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleAddItem(selectedDate ?? dayjs().format('YYYY-MM-DD'))}
>
{!isMobile && 'Add Event'}
</Button>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<Segmented
options={['My Calendar', 'Shared']}
value="My Calendar"
onChange={(val) => {
if (val === 'Shared') navigate('/volunteer/calendar/shared');
}}
/>
</Space>
<Space>
<Button
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleAddItem(selectedDate ?? dayjs().format('YYYY-MM-DD'))}
>
{!isMobile && 'Add Event'}
</Button>
</Space>
</div>
{isMobile ? (
@ -422,6 +443,21 @@ export default function MyCalendarPage() {
</div>
)}
{/* Settings drawer */}
<Drawer
title="Calendar Settings"
open={settingsOpen}
onClose={() => {
setSettingsOpen(false);
fetchItems();
}}
width={400}
>
<CalendarFeedsPanel />
<Divider />
<CalendarExportPanel layers={layers} />
</Drawer>
{/* Item create/edit modal */}
<CalendarItemModal
open={itemModalOpen}

View File

@ -48,7 +48,7 @@ export default function MyTicketsPage() {
const fetchTickets = async () => {
setLoading(true);
try {
const { data } = await api.get('/api/ticketed-events/my-tickets');
const { data } = await api.get('/ticketed-events/my-tickets');
setTickets(data.tickets);
} catch {
// silent

View File

@ -0,0 +1,347 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
Switch,
Tabs,
message,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import SharedViewMembersPanel from '@/components/calendar/SharedViewMembersPanel';
import AvailabilityFinder from '@/components/calendar/AvailabilityFinder';
import CalendarComments from '@/components/calendar/CalendarComments';
import CalendarReactions from '@/components/calendar/CalendarReactions';
import type {
SharedCalendarView,
SharedCalendarMember,
SharedCalendarItem,
SharedViewReactionGroup,
} from '@/types/api';
const { Title, Text } = Typography;
export default function SharedCalendarViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { user } = useAuthStore();
const currentUserId = user?.id || '';
const [view, setView] = useState<SharedCalendarView | null>(null);
const [members, setMembers] = useState<SharedCalendarMember[]>([]);
const [items, setItems] = useState<SharedCalendarItem[]>([]);
const [reactions, setReactions] = useState<Record<string, SharedViewReactionGroup[]>>({});
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showAvailability, setShowAvailability] = useState(false);
const fetchView = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get<SharedCalendarView>(`/calendar/shared/${id}`);
setView(data);
} catch {
message.error('Failed to load shared calendar');
navigate('/volunteer/calendar/shared');
}
}, [id, navigate]);
const fetchMembers = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get<{ members: SharedCalendarMember[] }>(`/calendar/shared/${id}/members`);
setMembers(data.members);
} catch {
// ignore
}
}, [id]);
const fetchItems = useCallback(async () => {
if (!id) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await api.get<{ items: SharedCalendarItem[] }>(`/calendar/shared/${id}/items`, {
params: { startDate, endDate },
});
setItems(data.items);
} catch {
// ignore
}
}, [id, currentMonth]);
const fetchReactions = useCallback(async () => {
if (!id || !selectedDate) return;
try {
const { data } = await api.get<Record<string, SharedViewReactionGroup[]>>(
`/calendar/shared/${id}/reactions`,
{ params: { date: selectedDate } },
);
setReactions(data);
} catch {
// ignore
}
}, [id, selectedDate]);
useEffect(() => {
const load = async () => {
setLoading(true);
await Promise.all([fetchView(), fetchMembers(), fetchItems()]);
setLoading(false);
};
load();
}, [fetchView, fetchMembers, fetchItems]);
useEffect(() => {
if (selectedDate) fetchReactions();
}, [fetchReactions, selectedDate]);
const isOwner = view?.ownerId === currentUserId;
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleLeave = async () => {
if (!id) return;
try {
await api.delete(`/calendar/shared/${id}/leave`);
message.success('Left shared calendar');
navigate('/volunteer/calendar/shared');
} catch {
message.error('Failed to leave');
}
};
// Noop for read-only calendar item click
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: '12px 0' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (!view) return null;
const dateDetailPanel = selectedDate && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong style={{ fontSize: 15 }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
</div>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0', flexDirection: 'column', alignItems: 'stretch' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.memberColor || item.color,
flexShrink: 0,
}}
/>
}
title={
<Space size={4}>
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
({item.memberName})
</Text>
</Space>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
{!item.isAllDay && (
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
)}
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
<CalendarReactions
viewId={id!}
itemId={item.id}
reactions={reactions[item.id] || []}
currentUserId={currentUserId}
onUpdate={fetchReactions}
/>
</List.Item>
)}
/>
)}
<div style={{ marginTop: 16 }}>
<CalendarComments
viewId={id!}
date={selectedDate}
currentUserId={currentUserId}
/>
</div>
</div>
);
const membersPanel = (
<SharedViewMembersPanel
viewId={id!}
members={members}
isOwner={isOwner}
onInvite={fetchMembers}
onLeave={handleLeave}
onRefresh={fetchMembers}
/>
);
const availabilityPanel = (
<AvailabilityFinder viewId={id!} />
);
if (isMobile) {
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<Space style={{ marginBottom: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/volunteer/calendar/shared')}
/>
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
</Space>
<Tabs
defaultActiveKey="calendar"
items={[
{
key: 'calendar',
label: 'Calendar',
children: (
<>
<MobileDayView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
/>
{dateDetailPanel}
</>
),
},
{ key: 'members', label: 'Members', children: membersPanel },
{ key: 'availability', label: 'Availability', children: availabilityPanel },
]}
/>
</div>
</FeatureGate>
);
}
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/volunteer/calendar/shared')}
/>
<CalendarOutlined style={{ fontSize: 18 }} />
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>
</Space>
<Space>
<Text style={{ fontSize: 12 }}>Availability</Text>
<Switch
size="small"
checked={showAvailability}
onChange={setShowAvailability}
/>
</Space>
</div>
{showAvailability ? (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
{membersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{availabilityPanel}
</div>
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 260, flexShrink: 0, paddingRight: 16 }}>
{membersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{selectedDate && (
<div
style={{
width: 280,
flexShrink: 0,
padding: '0 0 0 16px',
borderLeft: `1px solid ${token.colorBorderSecondary}`,
}}
>
{dateDetailPanel}
</div>
)}
</div>
)}
</div>
</FeatureGate>
);
}

View File

@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Modal,
Form,
Input,
Select,
Checkbox,
Space,
Tag,
Empty,
Skeleton,
Grid,
message,
Segmented,
} from 'antd';
import {
PlusOutlined,
CalendarOutlined,
TeamOutlined,
GlobalOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import type { SharedCalendarView, SharedViewScope } from '@/types/api';
const { Text, Paragraph } = Typography;
const LAYER_TYPE_OPTIONS = [
{ label: 'Personal Events', value: 'USER' },
{ label: 'Shifts', value: 'SYSTEM_SHIFTS' },
{ label: 'Ticketed Events', value: 'SYSTEM_EVENTS' },
{ label: 'Scheduling Polls', value: 'SYSTEM_POLLS' },
];
export default function SharedCalendarsPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [views, setViews] = useState<SharedCalendarView[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form] = Form.useForm();
const fetchViews = useCallback(async () => {
try {
const { data } = await api.get<{ views: SharedCalendarView[] }>('/calendar/shared');
setViews(data.views);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchViews();
}, [fetchViews]);
const handleCreate = async (values: any) => {
setCreating(true);
try {
await api.post('/calendar/shared', {
name: values.name,
description: values.description || null,
includedLayerTypes: values.includedLayerTypes || ['USER'],
shareScope: values.shareScope || 'MEMBERS',
});
message.success('Shared calendar created');
setCreateModalOpen(false);
form.resetFields();
await fetchViews();
} catch {
message.error('Failed to create shared calendar');
} finally {
setCreating(false);
}
};
const handleRespond = async (viewId: string, status: 'ACCEPTED' | 'DECLINED') => {
try {
await api.patch(`/calendar/shared/${viewId}/respond`, { status });
message.success(status === 'ACCEPTED' ? 'Invitation accepted' : 'Invitation declined');
await fetchViews();
} catch {
message.error('Failed to respond to invitation');
}
};
const scopeIcon = (scope: SharedViewScope) =>
scope === 'PUBLIC' ? <GlobalOutlined /> : <TeamOutlined />;
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<Segmented
options={['My Calendar', 'Shared']}
value="Shared"
onChange={(val) => {
if (val === 'My Calendar') navigate('/volunteer/calendar');
}}
/>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
{!isMobile && 'Create'}
</Button>
</div>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : views.length === 0 ? (
<Empty description="No shared calendars yet. Create one or wait for an invite." />
) : (
<Row gutter={[12, 12]}>
{views.map((view) => (
<Col key={view.id} xs={24} sm={12} lg={8}>
<Card
hoverable
onClick={() => {
if (view.myStatus === 'ACCEPTED' || view.ownerId === view.owner?.id) {
navigate(`/volunteer/calendar/shared/${view.id}`);
}
}}
style={{ height: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Text strong style={{ fontSize: 15 }}>{view.name}</Text>
<Tag icon={scopeIcon(view.shareScope)} style={{ margin: 0 }}>
{view.shareScope}
</Tag>
</div>
{view.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0, fontSize: 13 }}>
{view.description}
</Paragraph>
)}
<Space size={4}>
<TeamOutlined />
<Text type="secondary" style={{ fontSize: 12 }}>
{view._count?.members || 0} member{(view._count?.members || 0) !== 1 ? 's' : ''}
</Text>
{view.owner && (
<Text type="secondary" style={{ fontSize: 12 }}>
by {view.owner.name || view.owner.email}
</Text>
)}
</Space>
{view.myStatus === 'INVITED' && (
<Space style={{ marginTop: 4 }}>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRespond(view.id, 'ACCEPTED');
}}
>
Accept
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRespond(view.id, 'DECLINED');
}}
>
Decline
</Button>
</Space>
)}
{view.myStatus === 'DECLINED' && (
<Tag color="red" style={{ marginTop: 4 }}>Declined</Tag>
)}
</Space>
</Card>
</Col>
))}
</Row>
)}
<Modal
title="Create Shared Calendar"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={creating}
okText="Create"
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Please enter a name' }]}
>
<Input placeholder="e.g. Team Calendar" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} placeholder="Optional description" />
</Form.Item>
<Form.Item
name="includedLayerTypes"
label="Include Layer Types"
initialValue={['USER']}
>
<Checkbox.Group options={LAYER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="shareScope"
label="Visibility"
initialValue="MEMBERS"
>
<Select
options={[
{ value: 'MEMBERS', label: 'Members Only' },
{ value: 'PUBLIC', label: 'Public (anyone with link)' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
</FeatureGate>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Card, Typography, Statistic, Row, Col, Skeleton, Space, Tag, Alert } from 'antd';
import { TeamOutlined, ScheduleOutlined, EnvironmentOutlined, TrophyOutlined } from '@ant-design/icons';
import { Card, Typography, Statistic, Row, Col, Skeleton, Space, Tag, Alert, Button } from 'antd';
import { TeamOutlined, ScheduleOutlined, EnvironmentOutlined, TrophyOutlined, CalendarOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import UserAvatar from '@/components/social/UserAvatar';
@ -145,6 +145,11 @@ export default function SocialProfilePage() {
friendId={user.id}
isFriend={friendshipStatus.status === 'accepted'}
/>
{friendshipStatus.status === 'accepted' && (
<Link to={`/volunteer/calendar/friend/${user.id}`}>
<Button icon={<CalendarOutlined />}>View Calendar</Button>
</Link>
)}
</Space>
</Space>
</Card>

View File

@ -1,8 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button, Spin, Result, Grid } from 'antd';
import { ConfigProvider, App, Button, Spin, Result, theme } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
const FOOTER_HEIGHT = 44;
interface RCConfig {
enabled: boolean;
@ -17,8 +21,7 @@ interface RCAuthResponse {
}
export default function VolunteerChatPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { settings } = useSettingsStore();
const [online, setOnline] = useState<boolean | null>(null);
const [rcConfig, setRcConfig] = useState<RCConfig | null>(null);
@ -54,72 +57,118 @@ export default function VolunteerChatPage() {
fetchAndAuth();
}, [fetchAndAuth]);
// Retry postMessage pattern — RC's iframe listener may not be ready on first load
const retryTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const handleIframeLoad = useCallback(() => {
if (authToken && iframeRef.current?.contentWindow) {
retryTimers.current.forEach(clearTimeout);
retryTimers.current = [];
if (!authToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ externalCommand: 'login-with-token', token: authToken },
{ event: 'login-with-token', loginToken: authToken },
'*',
);
}
};
sendToken();
retryTimers.current.push(setTimeout(sendToken, 1000));
retryTimers.current.push(setTimeout(sendToken, 3000));
}, [authToken]);
if (isMobile) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Result
icon={<MessageOutlined style={{ fontSize: 48 }} />}
title="Desktop Recommended"
subTitle="Chat works best on a larger screen."
/>
</div>
);
}
useEffect(() => {
return () => retryTimers.current.forEach(clearTimeout);
}, []);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
const contentHeight = `calc(100dvh - ${FOOTER_HEIGHT}px)`;
if (!rcConfig?.enabled) {
return (
<div style={{ padding: 24 }}>
<Result status="info" title="Chat Not Available" subTitle="Team chat has not been enabled yet." />
</div>
);
}
const renderContent = () => {
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}>
<Spin size="large" />
</div>
);
}
if (!online || error) {
return (
<div style={{ padding: 24 }}>
<Result
status="error"
title="Chat Unavailable"
subTitle={error || 'Chat service is not running.'}
extra={<Button type="primary" onClick={fetchAndAuth}>Retry</Button>}
/>
</div>
);
}
if (!rcConfig?.enabled) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}>
<Result
icon={<MessageOutlined style={{ fontSize: 48 }} />}
status="info"
title="Chat Not Available"
subTitle="Team chat has not been enabled yet."
/>
</div>
);
}
const serviceUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
const iframeSrc = `${serviceUrl}/channel/general?layout=embedded`;
if (!online || error) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}>
<Result
status="error"
title="Chat Unavailable"
subTitle={error || 'Chat service is not running.'}
extra={<Button type="primary" onClick={fetchAndAuth}>Retry</Button>}
/>
</div>
);
}
const serviceUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
return (
<iframe
ref={iframeRef}
src={`${serviceUrl}/channel/general`}
onLoad={handleIframeLoad}
style={{
width: '100%',
height: contentHeight,
border: 'none',
display: 'block',
}}
title="Team Chat"
allow="microphone; camera"
/>
);
};
return (
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={handleIframeLoad}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
borderRadius: 8,
},
}}
title="Team Chat"
allow="microphone; camera"
/>
>
<App>
<div style={{ height: '100dvh', overflow: 'hidden', background: '#0d1b2a' }}>
{/* Chat content — RC's full UI provides its own header/nav */}
{renderContent()}
{/* Footer nav */}
<VolunteerFooterNav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1200,
}}
/>
</div>
</App>
</ConfigProvider>
);
}

View File

@ -103,7 +103,7 @@ export default function VolunteerMapPage() {
const sessionActive = mode === 'session' && !!session;
// Footer nav height for positioning (no session bar, controls integrated into bottom panel)
const FOOTER_HEIGHT = 56;
const FOOTER_HEIGHT = 44;
// ─── Initialize ──────────────────────────────────────────────────
useEffect(() => {
@ -418,7 +418,7 @@ export default function VolunteerMapPage() {
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}>
<div style={{ height: '100dvh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}>
<Spin size="large" />
</div>
);
@ -444,8 +444,8 @@ export default function VolunteerMapPage() {
position: 'relative',
width: '100vw',
height: drawerOpen
? `calc(100vh - ${FOOTER_HEIGHT}px - ${menuDrawerHeight}px)`
: '100vh',
? `calc(100dvh - ${FOOTER_HEIGHT}px - ${menuDrawerHeight}px)`
: '100dvh',
transition: 'height 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
}}
@ -587,7 +587,7 @@ export default function VolunteerMapPage() {
header: { padding: '12px 16px 0' },
body: {
padding: '12px 16px',
maxHeight: `calc(100vh - ${FOOTER_HEIGHT + 120}px - env(safe-area-inset-bottom))`,
maxHeight: `calc(100dvh - ${FOOTER_HEIGHT + 120}px - env(safe-area-inset-bottom))`,
overflow: 'auto',
},
}}

View File

@ -979,6 +979,7 @@ export interface LandingPage {
mkdocsHideToc: boolean;
mkdocsSkipExport: boolean;
published: boolean;
listed: boolean;
seoTitle: string | null;
seoDescription: string | null;
seoImage: string | null;
@ -1209,13 +1210,14 @@ export interface Meeting {
export interface NavItem {
id: string;
label: string;
path: string;
path: string; // Empty string '' for group items
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
type: 'builtin' | 'custom' | 'group';
featureFlag?: string;
external?: boolean;
children?: NavItem[]; // One level deep only (for groups)
}
export interface NavConfig {
@ -3121,3 +3123,73 @@ export interface AvailabilityResponse {
dates: Record<string, AvailabilityDay>;
}
// --- Calendar Feed Types ---
export type CalendarFeedStatus = 'OK' | 'ERROR' | 'PENDING';
export type CalendarFeedInterval = 'FIFTEEN_MIN' | 'HOURLY' | 'SIX_HOUR' | 'DAILY';
export interface CalendarFeed {
id: string;
userId: string;
name: string;
url: string;
layerId: string;
refreshInterval: CalendarFeedInterval;
lastFetchedAt: string | null;
lastStatus: CalendarFeedStatus;
lastError: string | null;
itemCount: number;
createdAt: string;
updatedAt: string;
}
// --- Admin Calendar View Types ---
export interface AdminCalendarView {
id: string;
name: string;
description: string | null;
autoIncludeRoles: string[];
includedLayerTypes: string[];
ownerId: string;
owner: { id: string; name: string | null; email: string };
userCount: number;
createdAt: string;
updatedAt: string;
}
export interface AdminCalendarUser {
id: string;
name: string | null;
email: string;
role: string;
color: string;
}
export interface AdminCalendarItem {
id: string;
type: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
color: string;
itemType: string;
layerId: string;
layerName: string;
layerColor: string;
userId: string;
userName: string;
userColor: string;
}
export interface CalendarExportToken {
id: string;
userId: string;
token: string;
includePersonal: boolean;
includeLayers: string[] | null;
createdAt: string;
}

View File

@ -0,0 +1,99 @@
/**
* Insert markdown snippets into a textarea at the current cursor position.
* Handles selection-aware insertion (wrapping selected text).
*/
export interface TextareaInsertResult {
newValue: string;
cursorPos: number;
}
export function insertAtCursor(
textarea: HTMLTextAreaElement,
before: string,
after: string,
): TextareaInsertResult {
const { selectionStart, selectionEnd, value } = textarea;
const selected = value.substring(selectionStart, selectionEnd);
if (selected) {
// Wrap selection
const inserted = before + selected + after;
const newValue = value.substring(0, selectionStart) + inserted + value.substring(selectionEnd);
return { newValue, cursorPos: selectionStart + inserted.length };
}
// No selection — insert markers and position cursor between them
const inserted = before + after;
const newValue = value.substring(0, selectionStart) + inserted + value.substring(selectionEnd);
return { newValue, cursorPos: selectionStart + before.length };
}
export function insertBlock(
textarea: HTMLTextAreaElement,
template: string,
): TextareaInsertResult {
const { selectionStart, selectionEnd, value } = textarea;
const selected = value.substring(selectionStart, selectionEnd);
let text = template.replace('$CURSOR', selected || '');
// If cursor is in the middle of a line, prepend newline
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const beforeCursor = value.substring(lineStart, selectionStart);
if (beforeCursor.trim().length > 0) {
text = '\n' + text;
}
const newValue = value.substring(0, selectionStart) + text + value.substring(selectionEnd);
const cursorMarker = text.indexOf(selected || '');
const cursorPos = selectionStart + (cursorMarker >= 0 ? cursorMarker + (selected || '').length : text.length);
return { newValue, cursorPos };
}
/**
* Cycle heading level at the current line.
* No heading -> # -> ## -> ### -> remove heading
*/
export function cycleHeading(textarea: HTMLTextAreaElement): TextareaInsertResult {
const { selectionStart, value } = textarea;
// Find the current line
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const lineEnd = value.indexOf('\n', selectionStart);
const line = value.substring(lineStart, lineEnd === -1 ? value.length : lineEnd);
const match = line.match(/^(#{1,3})\s/);
let newLine: string;
if (!match) {
newLine = '# ' + line;
} else if (match[1] === '#') {
newLine = '## ' + line.substring(2);
} else if (match[1] === '##') {
newLine = '### ' + line.substring(3);
} else {
// ### -> remove
newLine = line.substring(4);
}
const end = lineEnd === -1 ? value.length : lineEnd;
const newValue = value.substring(0, lineStart) + newLine + value.substring(end);
const cursorPos = lineStart + newLine.length;
return { newValue, cursorPos };
}
/**
* Apply a textarea snippet result: update value and set cursor position.
*/
export function applyResult(
textarea: HTMLTextAreaElement,
result: TextareaInsertResult,
onChange: (value: string) => void,
): void {
onChange(result.newValue);
// Use requestAnimationFrame so React has time to update the textarea value
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(result.cursorPos, result.cursorPos);
});
}

98
api/package-lock.json generated
View File

@ -26,11 +26,13 @@
"express-rate-limit": "^7.5.0",
"fastify": "^5.7.4",
"helmet": "^8.0.0",
"ical-generator": "^10.0.0",
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16",
"pg": "^8.18.0",
"proj4": "^2.20.2",
@ -1630,6 +1632,17 @@
"node": ">=18"
}
},
"node_modules/@js-temporal/polyfill": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
"integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
"dependencies": {
"jsbi": "^4.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@ -3822,6 +3835,53 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ical-generator": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-10.0.0.tgz",
"integrity": "sha512-YUQ7H4eZdLfYvx3zE/qN4AoG0qqwMZG37vLdWzysXFDn/YQEfctZ9tQuPSBncARKgv79d2smWf5Sh67k6xiZfg==",
"engines": {
"node": "20 || 22 || >=24"
},
"peerDependencies": {
"@touch4it/ical-timezones": ">=1.6.0",
"@types/luxon": ">= 1.26.0",
"@types/mocha": ">= 8.2.1",
"dayjs": ">= 1.10.0",
"luxon": ">= 1.26.0",
"moment": ">= 2.29.0",
"moment-timezone": ">= 0.5.33",
"rrule": ">= 2.6.8"
},
"peerDependenciesMeta": {
"@touch4it/ical-timezones": {
"optional": true
},
"@types/luxon": {
"optional": true
},
"@types/mocha": {
"optional": true
},
"@types/node": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-timezone": {
"optional": true
},
"rrule": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -3932,6 +3992,11 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jsbi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz",
"integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@ -4350,6 +4415,18 @@
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-ical": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.25.5.tgz",
"integrity": "sha512-hj1I+kV38EXdhMB9Sh9phtvdzeJML/HvYbiKqBqcET1O2JiFmJnvpEWISNLA5nUeCWQAaTqiDhZH4uwUTG2Vdg==",
"dependencies": {
"rrule-temporal": "^1.4.7",
"temporal-polyfill": "^0.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
@ -4967,6 +5044,14 @@
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"node_modules/rrule-temporal": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.4.7.tgz",
"integrity": "sha512-5qiq4dnzIiRsvLnHObNMaPQiHnYLXBkXGQORJkbtl8UO8d/Y5h5Pq5xniW8c5U2BMdPH6XBvBxufjxvDcCLKUA==",
"dependencies": {
"@js-temporal/polyfill": "^0.5.1"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -5331,6 +5416,19 @@
"bintrees": "1.0.2"
}
},
"node_modules/temporal-polyfill": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz",
"integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==",
"dependencies": {
"temporal-spec": "0.3.0"
}
},
"node_modules/temporal-spec": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz",
"integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ=="
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",

View File

@ -34,11 +34,13 @@
"express-rate-limit": "^7.5.0",
"fastify": "^5.7.4",
"helmet": "^8.0.0",
"ical-generator": "^10.0.0",
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16",
"pg": "^8.18.0",
"proj4": "^2.20.2",

View File

@ -0,0 +1,74 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { adminCalendarService } from './admin-calendar.service';
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
import { dateRangeQuerySchema } from './shared-calendar.schemas';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
// List admin calendar views
router.get('/', async (req, res, next) => {
try {
const views = await adminCalendarService.listAdminViews(req.user!.id);
res.json({ views });
} catch (error) {
next(error);
}
});
// Create admin calendar view
router.post('/', validate(createAdminViewSchema), async (req, res, next) => {
try {
const view = await adminCalendarService.createAdminView(req.user!.id, req.body);
res.status(201).json({ view });
} catch (error) {
next(error);
}
});
// Update admin calendar view
router.patch('/:id', validate(updateAdminViewSchema), async (req, res, next) => {
try {
const view = await adminCalendarService.updateAdminView(
req.user!.id,
req.params.id as string,
req.body,
);
res.json({ view });
} catch (error) {
next(error);
}
});
// Delete admin calendar view
router.delete('/:id', async (req, res, next) => {
try {
await adminCalendarService.deleteAdminView(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// Get merged items for admin calendar view
router.get('/:id/items', validate(dateRangeQuerySchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const result = await adminCalendarService.getAdminViewItems(
req.params.id as string,
req.user!.id,
startDate,
endDate,
);
res.json(result);
} catch (error) {
next(error);
}
});
export const adminCalendarRouter = router;

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
const VALID_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'USER', 'TEMP'] as const;
const VALID_LAYER_TYPES = ['SHIFTS', 'TICKETS', 'POLLS', 'PUBLIC_EVENTS'] as const;
export const createAdminViewSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
autoIncludeRoles: z.array(z.enum(VALID_ROLES)).min(1),
includedLayerTypes: z.array(z.enum(VALID_LAYER_TYPES)).min(1),
});
export const updateAdminViewSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().nullable().optional(),
autoIncludeRoles: z.array(z.enum(VALID_ROLES)).min(1).optional(),
includedLayerTypes: z.array(z.enum(VALID_LAYER_TYPES)).min(1).optional(),
});
export type CreateAdminViewInput = z.infer<typeof createAdminViewSchema>;
export type UpdateAdminViewInput = z.infer<typeof updateAdminViewSchema>;

View File

@ -0,0 +1,173 @@
import { CalendarLayerType, CalendarSystemType, Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { sharedCalendarService } from './shared-calendar.service';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
import type { CreateAdminViewInput, UpdateAdminViewInput } from './admin-calendar.schemas';
const MEMBER_COLORS = [
'#1890ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1',
'#13c2c2', '#faad14', '#f5222d', '#2f54eb', '#a0d911',
];
export const adminCalendarService = {
async listAdminViews(userId: string) {
const views = await prisma.sharedCalendarView.findMany({
where: { viewType: 'ROLE_BASED', ownerId: userId },
include: {
owner: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
});
// Count matching users for each view
const viewsWithCounts = await Promise.all(
views.map(async (view) => {
const roles = (view.autoIncludeRoles as string[]) || [];
const count = await prisma.user.count({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
});
return { ...view, userCount: count };
}),
);
return viewsWithCounts;
},
async createAdminView(userId: string, data: CreateAdminViewInput) {
const view = await prisma.sharedCalendarView.create({
data: {
name: data.name,
description: data.description,
ownerId: userId,
viewType: 'ROLE_BASED',
autoIncludeRoles: data.autoIncludeRoles as unknown as Prisma.InputJsonValue,
includedLayerTypes: data.includedLayerTypes as unknown as Prisma.InputJsonValue,
shareScope: 'MEMBERS',
},
include: {
owner: { select: { id: true, name: true, email: true } },
},
});
return view;
},
async updateAdminView(userId: string, viewId: string, data: UpdateAdminViewInput) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
return prisma.sharedCalendarView.update({
where: { id: viewId },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.autoIncludeRoles !== undefined && {
autoIncludeRoles: data.autoIncludeRoles as unknown as Prisma.InputJsonValue,
}),
...(data.includedLayerTypes !== undefined && {
includedLayerTypes: data.includedLayerTypes as unknown as Prisma.InputJsonValue,
}),
},
include: {
owner: { select: { id: true, name: true, email: true } },
},
});
},
async deleteAdminView(userId: string, viewId: string) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
await prisma.sharedCalendarView.delete({ where: { id: viewId } });
},
async getAdminViewItems(
viewId: string,
userId: string,
startDate: string,
endDate: string,
) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
const roles = (view.autoIncludeRoles as string[]) || [];
const includedLayerTypes = (view.includedLayerTypes as string[]) || [];
// Query users by role (live, no member rows)
const users = await prisma.user.findMany({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
orderBy: { name: 'asc' },
take: 50,
select: { id: true, name: true, email: true, role: true },
});
const totalUsers = await prisma.user.count({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
});
const start = new Date(startDate);
const end = new Date(endDate);
const allItems: any[] = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
const color = MEMBER_COLORS[i % MEMBER_COLORS.length];
// Get this user's system layers matching includedLayerTypes
const layers = await prisma.calendarLayer.findMany({
where: {
userId: user.id,
isEnabled: true,
layerType: CalendarLayerType.SYSTEM,
systemType: { in: includedLayerTypes as CalendarSystemType[] },
},
});
for (const layer of layers) {
try {
const items = await sharedCalendarService.getSystemLayerItems(
user.id,
layer,
start,
end,
);
for (const item of items) {
allItems.push({
...item,
userId: user.id,
userName: user.name || user.email,
userColor: color,
});
}
} catch (err) {
logger.debug(`Failed to fetch system layer items for user ${user.id}:`, err);
}
}
}
// Sort by date then time
allItems.sort((a, b) => {
const dc = a.date.localeCompare(b.date);
return dc !== 0 ? dc : a.startTime.localeCompare(b.startTime);
});
return {
items: allItems,
users: users.map((u, i) => ({
...u,
color: MEMBER_COLORS[i % MEMBER_COLORS.length],
})),
totalUsers,
truncated: totalUsers > 50,
};
},
};

View File

@ -0,0 +1,127 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { feedService } from './feed.service';
import { createFeedSchema, updateFeedSchema, createExportTokenSchema } from './feed.schemas';
const router = Router();
// =========================================================================
// Public routes (no auth)
// =========================================================================
// GET /api/calendar/feed/:token.ics — serve ICS export feed
router.get('/feed/:token.ics', async (req, res, next) => {
try {
const token = req.params.token as string;
const icsData = await feedService.getExportFeed(token);
if (!icsData) {
res.status(404).json({ error: { message: 'Feed not found', code: 'NOT_FOUND' } });
return;
}
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Content-Disposition', 'inline; filename="calendar.ics"');
res.send(icsData);
} catch (error) {
next(error);
}
});
// =========================================================================
// Authenticated routes
// =========================================================================
router.use(authenticate);
// GET /api/calendar/feeds — list user's feeds
router.get('/feeds', async (req, res, next) => {
try {
const feeds = await feedService.listFeeds(req.user!.id);
res.json({ feeds });
} catch (error) {
next(error);
}
});
// POST /api/calendar/feeds — create a new ICS feed subscription
router.post('/feeds', validate(createFeedSchema), async (req, res, next) => {
try {
const feed = await feedService.createFeed(req.user!.id, req.body);
res.status(201).json({ feed });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/feeds/:id — update feed settings
router.patch('/feeds/:id', validate(updateFeedSchema), async (req, res, next) => {
try {
const feed = await feedService.updateFeed(req.user!.id, req.params.id as string, req.body);
res.json({ feed });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/feeds/:id — delete a feed subscription
router.delete('/feeds/:id', async (req, res, next) => {
try {
await feedService.deleteFeed(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// POST /api/calendar/feeds/:id/refresh — force refresh a feed
router.post('/feeds/:id/refresh', async (req, res, next) => {
try {
const feed = await feedService.listFeeds(req.user!.id);
const owned = feed.find((f) => f.id === (req.params.id as string));
if (!owned) {
res.status(404).json({ error: { message: 'Feed not found', code: 'NOT_FOUND' } });
return;
}
await feedService.refreshFeed(req.params.id as string);
const updated = await feedService.listFeeds(req.user!.id);
const refreshed = updated.find((f) => f.id === (req.params.id as string));
res.json({ feed: refreshed });
} catch (error) {
next(error);
}
});
// GET /api/calendar/export/tokens — list export tokens
router.get('/export/tokens', async (req, res, next) => {
try {
const tokens = await feedService.listExportTokens(req.user!.id);
res.json({ tokens });
} catch (error) {
next(error);
}
});
// POST /api/calendar/export/tokens — create an export token
router.post('/export/tokens', validate(createExportTokenSchema), async (req, res, next) => {
try {
const token = await feedService.createExportToken(req.user!.id, req.body);
res.status(201).json({ token });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/export/tokens/:id — revoke an export token
router.delete('/export/tokens/:id', async (req, res, next) => {
try {
await feedService.revokeExportToken(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
export default router;

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const createFeedSchema = z.object({
name: z.string().min(1).max(100),
url: z.string().url(),
refreshInterval: z.enum(['FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY']).default('HOURLY'),
});
export const updateFeedSchema = z.object({
name: z.string().min(1).max(100).optional(),
url: z.string().url().optional(),
refreshInterval: z.enum(['FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY']).optional(),
});
export const createExportTokenSchema = z.object({
includePersonal: z.boolean(),
includeLayers: z.array(z.string()).optional(),
});
export type CreateFeedInput = z.infer<typeof createFeedSchema>;
export type UpdateFeedInput = z.infer<typeof updateFeedSchema>;
export type CreateExportTokenInput = z.infer<typeof createExportTokenSchema>;

View File

@ -0,0 +1,514 @@
import crypto from 'crypto';
import {
CalendarLayerType,
CalendarVisibility,
CalendarItemType,
CalendarItemSource,
CalendarFeedStatus,
CalendarFeedInterval,
Prisma,
} from '@prisma/client';
import ical, { ICalCalendarMethod } from 'ical-generator';
import nodeIcal from 'node-ical';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { CreateFeedInput, UpdateFeedInput, CreateExportTokenInput } from './feed.schemas';
const MAX_EVENTS_PER_FEED = 1000;
const FETCH_TIMEOUT_MS = 30_000;
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const MATERIALIZE_MONTHS = 3;
// Map CalendarFeedInterval to milliseconds
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
FIFTEEN_MIN: 15 * 60 * 1000,
HOURLY: 60 * 60 * 1000,
SIX_HOUR: 6 * 60 * 60 * 1000,
DAILY: 24 * 60 * 60 * 1000,
};
export const feedService = {
// =========================================================================
// Feed Management
// =========================================================================
async listFeeds(userId: string) {
return prisma.calendarFeed.findMany({
where: { userId },
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
orderBy: { createdAt: 'asc' },
});
},
async createFeed(userId: string, data: CreateFeedInput) {
// Validate URL is reachable
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(data.url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
});
clearTimeout(timeout);
if (!res.ok && res.status !== 405) {
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(400, 'Feed URL is not reachable', 'FEED_URL_UNREACHABLE');
}
// Create EXTERNAL layer + feed in a transaction
const result = await prisma.$transaction(async (tx) => {
const layer = await tx.calendarLayer.create({
data: {
userId,
name: data.name,
color: '#1890ff',
layerType: CalendarLayerType.EXTERNAL,
visibility: CalendarVisibility.PRIVATE,
},
});
const feed = await tx.calendarFeed.create({
data: {
userId,
name: data.name,
url: data.url,
layerId: layer.id,
refreshInterval: data.refreshInterval as CalendarFeedInterval,
},
});
return feed;
});
// Trigger initial fetch (fire-and-forget)
this.refreshFeed(result.id).catch((err) => {
logger.warn(`Initial feed refresh failed for ${result.id}:`, err);
});
return prisma.calendarFeed.findUnique({
where: { id: result.id },
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
});
},
async updateFeed(userId: string, feedId: string, data: UpdateFeedInput) {
const feed = await prisma.calendarFeed.findFirst({
where: { id: feedId, userId },
});
if (!feed) {
throw new AppError(404, 'Feed not found', 'NOT_FOUND');
}
const updateData: Prisma.CalendarFeedUncheckedUpdateInput = {};
if (data.name !== undefined) {
updateData.name = data.name;
// Also update layer name
await prisma.calendarLayer.update({
where: { id: feed.layerId },
data: { name: data.name },
});
}
if (data.url !== undefined) updateData.url = data.url;
if (data.refreshInterval !== undefined) {
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
}
return prisma.calendarFeed.update({
where: { id: feedId },
data: updateData,
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
});
},
async deleteFeed(userId: string, feedId: string) {
const feed = await prisma.calendarFeed.findFirst({
where: { id: feedId, userId },
});
if (!feed) {
throw new AppError(404, 'Feed not found', 'NOT_FOUND');
}
// Deleting the layer cascades to items and the feed record
await prisma.calendarLayer.delete({ where: { id: feed.layerId } });
},
async refreshFeed(feedId: string) {
const feed = await prisma.calendarFeed.findUnique({ where: { id: feedId } });
if (!feed) return;
try {
// Fetch the ICS data
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(feed.url, {
signal: controller.signal,
redirect: 'follow',
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Read body with size limit
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > FETCH_MAX_BYTES) {
reader.cancel();
throw new Error('Feed exceeds 5MB size limit');
}
chunks.push(value);
}
const icsText = Buffer.concat(chunks).toString('utf-8');
// Parse ICS
const parsed = nodeIcal.sync.parseICS(icsText);
// Extract VEVENT entries
const events: Array<{
uid: string;
title: string;
description: string | null;
date: Date;
startTime: string;
endTime: string;
isAllDay: boolean;
location: string | null;
}> = [];
const now = new Date();
const futureLimit = new Date(now);
futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);
for (const key of Object.keys(parsed)) {
const component = parsed[key];
if (!component || component.type !== 'VEVENT') continue;
if (events.length >= MAX_EVENTS_PER_FEED) break;
const vevent = component as nodeIcal.VEvent;
if (!vevent.uid || !vevent.start) continue;
const start = vevent.start instanceof Date ? vevent.start : new Date(vevent.start as unknown as string);
if (isNaN(start.getTime())) continue;
// Check if this is an all-day event (dateOnly property from node-ical)
const isAllDay = !!(vevent.start as { dateOnly?: boolean }).dateOnly;
let startTime = '00:00';
let endTime = '23:59';
if (!isAllDay) {
startTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
if (vevent.end) {
const end = vevent.end instanceof Date ? vevent.end : new Date(vevent.end as unknown as string);
if (!isNaN(end.getTime())) {
endTime = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
}
} else {
// Default 1 hour
const endDate = new Date(start);
endDate.setHours(endDate.getHours() + 1);
endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
}
}
// Handle RRULE: materialize recurring instances
if (vevent.rrule) {
try {
const occurrences = vevent.rrule.between(now, futureLimit, true);
for (const occ of occurrences) {
if (events.length >= MAX_EVENTS_PER_FEED) break;
const occDate = new Date(occ);
events.push({
uid: `${vevent.uid}_${occDate.toISOString().split('T')[0]}`,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: occDate,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
} catch {
// If rrule parsing fails, just add the single instance
events.push({
uid: vevent.uid,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: start,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
} else {
events.push({
uid: vevent.uid,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: start,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
}
// Upsert events and remove stale ones in a transaction
await prisma.$transaction(async (tx) => {
const sourceIds = events.map((e) => e.uid);
// Delete stale items (no longer in feed)
await tx.calendarItem.deleteMany({
where: {
layerId: feed.layerId,
userId: feed.userId,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: { notIn: sourceIds.length > 0 ? sourceIds : ['__none__'] },
},
});
// Upsert each event (no unique constraint on sourceId, so find+update/create)
for (const event of events) {
const existing = await tx.calendarItem.findFirst({
where: {
userId: feed.userId,
layerId: feed.layerId,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: event.uid,
},
});
if (existing) {
await tx.calendarItem.update({
where: { id: existing.id },
data: {
title: event.title,
description: event.description,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
location: event.location,
},
});
} else {
await tx.calendarItem.create({
data: {
userId: feed.userId,
layerId: feed.layerId,
title: event.title,
description: event.description,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
itemType: CalendarItemType.EVENT,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: event.uid,
location: event.location,
},
});
}
}
});
// Update feed status
await prisma.calendarFeed.update({
where: { id: feedId },
data: {
lastFetchedAt: new Date(),
lastStatus: CalendarFeedStatus.OK,
lastError: null,
itemCount: events.length,
},
});
logger.debug(`Feed ${feed.name} refreshed: ${events.length} events`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
await prisma.calendarFeed.update({
where: { id: feedId },
data: {
lastFetchedAt: new Date(),
lastStatus: CalendarFeedStatus.ERROR,
lastError: message.slice(0, 500),
},
});
logger.warn(`Feed ${feed.name} refresh failed: ${message}`);
}
},
// =========================================================================
// Export
// =========================================================================
async createExportToken(userId: string, data: CreateExportTokenInput) {
const token = crypto.randomBytes(32).toString('hex');
return prisma.calendarExportToken.create({
data: {
userId,
token,
includePersonal: data.includePersonal,
includeLayers: data.includeLayers
? (data.includeLayers as unknown as Prisma.InputJsonValue)
: undefined,
},
});
},
async revokeExportToken(userId: string, tokenId: string) {
const exportToken = await prisma.calendarExportToken.findFirst({
where: { id: tokenId, userId },
});
if (!exportToken) {
throw new AppError(404, 'Export token not found', 'NOT_FOUND');
}
await prisma.calendarExportToken.delete({ where: { id: tokenId } });
},
async listExportTokens(userId: string) {
return prisma.calendarExportToken.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
},
async getExportFeed(token: string): Promise<string | null> {
const exportToken = await prisma.calendarExportToken.findUnique({
where: { token },
include: { user: { select: { id: true, name: true } } },
});
if (!exportToken) return null;
const now = new Date();
const pastLimit = new Date(now);
pastLimit.setMonth(pastLimit.getMonth() - 1);
const futureLimit = new Date(now);
futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);
// Build layer filter
const layerWhere: Prisma.CalendarLayerWhereInput = {
userId: exportToken.userId,
isEnabled: true,
};
// If includeLayers is specified, filter to those layer IDs
const includeLayerIds = exportToken.includeLayers as string[] | null;
if (includeLayerIds && includeLayerIds.length > 0) {
layerWhere.id = { in: includeLayerIds };
}
// If not includePersonal, exclude USER layers
if (!exportToken.includePersonal) {
layerWhere.layerType = { not: CalendarLayerType.USER };
}
const layers = await prisma.calendarLayer.findMany({ where: layerWhere });
const layerIds = layers.map((l) => l.id);
if (layerIds.length === 0) {
// Return empty calendar
const cal = ical({ name: 'Changemaker Calendar' });
cal.method(ICalCalendarMethod.PUBLISH);
return cal.toString();
}
const items = await prisma.calendarItem.findMany({
where: {
userId: exportToken.userId,
layerId: { in: layerIds },
date: { gte: pastLimit, lte: futureLimit },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
const cal = ical({
name: `${exportToken.user.name || 'Changemaker'} Calendar`,
prodId: { company: 'Changemaker', product: 'Calendar', language: 'EN' },
});
cal.method(ICalCalendarMethod.PUBLISH);
for (const item of items) {
const dateStr = item.date.toISOString().split('T')[0];
const [startH, startM] = item.startTime.split(':').map(Number);
const [endH, endM] = item.endTime.split(':').map(Number);
const startDt = new Date(item.date);
startDt.setHours(startH, startM, 0, 0);
const endDt = new Date(item.date);
endDt.setHours(endH, endM, 0, 0);
// If end is before start (shouldn't happen), add 1 hour
if (endDt <= startDt) {
endDt.setHours(startDt.getHours() + 1);
}
const event = cal.createEvent({
id: item.sourceId || item.id,
start: startDt,
end: endDt,
summary: item.title,
description: item.description || undefined,
location: item.location || undefined,
allDay: item.isAllDay,
});
if (item.isAllDay) {
event.allDay(true);
}
}
return cal.toString();
},
// =========================================================================
// Queue helpers
// =========================================================================
/**
* Get feeds that are due for refresh based on their interval and lastFetchedAt.
*/
async getFeedsDueForRefresh(limit: number = 10) {
const now = new Date();
const feeds = await prisma.calendarFeed.findMany({
orderBy: { lastFetchedAt: 'asc' },
take: limit * 2, // fetch extra to filter
});
return feeds.filter((feed) => {
if (!feed.lastFetchedAt) return true; // never fetched
const intervalMs = INTERVAL_MS[feed.refreshInterval];
const nextDue = new Date(feed.lastFetchedAt.getTime() + intervalMs);
return now >= nextDue;
}).slice(0, limit);
},
};

View File

@ -0,0 +1,291 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { sharedCalendarService } from './shared-calendar.service';
import {
createSharedViewSchema,
updateSharedViewSchema,
inviteMembersSchema,
respondToInviteSchema,
createCommentSchema,
createReactionSchema,
shareItemSchema,
availabilityQuerySchema,
dateRangeQuerySchema,
} from './shared-calendar.schemas';
const router = Router();
// =========================================================================
// Public route (no auth)
// =========================================================================
router.get('/public/:shareToken', async (req, res, next) => {
try {
const { shareToken } = req.params as { shareToken: string };
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
if (!startDate || !endDate) {
res.status(400).json({ error: { message: 'startDate and endDate are required' } });
return;
}
const result = await sharedCalendarService.getPublicView(shareToken, startDate, endDate);
res.json(result);
} catch (error) {
next(error);
}
});
// =========================================================================
// Authenticated routes
// =========================================================================
router.use(authenticate);
// --- Shared View CRUD ---
router.get('/', async (req, res, next) => {
try {
const views = await sharedCalendarService.listSharedViews(req.user!.id);
res.json({ views });
} catch (error) {
next(error);
}
});
router.post('/', validate(createSharedViewSchema), async (req, res, next) => {
try {
const view = await sharedCalendarService.createSharedView(req.user!.id, req.body);
res.status(201).json({ view });
} catch (error) {
next(error);
}
});
router.patch('/:id', validate(updateSharedViewSchema), async (req, res, next) => {
try {
const view = await sharedCalendarService.updateSharedView(
req.user!.id,
req.params.id as string,
req.body,
);
res.json({ view });
} catch (error) {
next(error);
}
});
router.delete('/:id', async (req, res, next) => {
try {
await sharedCalendarService.deleteSharedView(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// --- Member Management ---
router.post('/:id/invite', validate(inviteMembersSchema), async (req, res, next) => {
try {
const results = await sharedCalendarService.inviteMembers(
req.user!.id,
req.params.id as string,
req.body.userIds,
);
res.json({ results });
} catch (error) {
next(error);
}
});
router.patch('/:id/respond', validate(respondToInviteSchema), async (req, res, next) => {
try {
const member = await sharedCalendarService.respondToInvite(
req.user!.id,
req.params.id as string,
req.body.status,
);
res.json({ member });
} catch (error) {
next(error);
}
});
router.delete('/:id/leave', async (req, res, next) => {
try {
await sharedCalendarService.leaveView(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
router.get('/:id/members', async (req, res, next) => {
try {
const members = await sharedCalendarService.getMembers(
req.params.id as string,
req.user!.id,
);
res.json({ members });
} catch (error) {
next(error);
}
});
// --- Merged Items ---
router.get('/:id/items', validate(dateRangeQuerySchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const items = await sharedCalendarService.getSharedViewItems(
req.params.id as string,
req.user!.id,
startDate,
endDate,
);
res.json({ items });
} catch (error) {
next(error);
}
});
// --- Comments ---
router.get('/:id/comments', async (req, res, next) => {
try {
const date = req.query.date as string;
if (!date) {
res.status(400).json({ error: { message: 'date query parameter is required' } });
return;
}
const comments = await sharedCalendarService.getComments(
req.params.id as string,
req.user!.id,
date,
);
res.json({ comments });
} catch (error) {
next(error);
}
});
router.post('/:id/comments', validate(createCommentSchema), async (req, res, next) => {
try {
const comment = await sharedCalendarService.createComment(
req.params.id as string,
req.user!.id,
req.body,
);
res.status(201).json({ comment });
} catch (error) {
next(error);
}
});
router.delete('/:id/comments/:commentId', async (req, res, next) => {
try {
await sharedCalendarService.deleteComment(
req.params.commentId as string,
req.user!.id,
);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// --- Reactions ---
router.post('/:id/reactions', validate(createReactionSchema), async (req, res, next) => {
try {
const reaction = await sharedCalendarService.addReaction(
req.params.id as string,
req.user!.id,
req.body,
);
res.status(201).json({ reaction });
} catch (error) {
next(error);
}
});
router.delete('/:id/reactions/:reactionId', async (req, res, next) => {
try {
await sharedCalendarService.removeReaction(
req.params.reactionId as string,
req.user!.id,
);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// --- Availability ---
router.get('/:id/availability', validate(availabilityQuerySchema, 'query'), async (req, res, next) => {
try {
const { start, end, dayStart, dayEnd, slotDuration } = req.query as unknown as {
start: string; end: string; dayStart: string; dayEnd: string; slotDuration: number;
};
const result = await sharedCalendarService.findAvailability(
req.params.id as string,
req.user!.id,
start,
end,
dayStart,
dayEnd,
Number(slotDuration),
);
res.json(result);
} catch (error) {
next(error);
}
});
// --- Share Token ---
router.post('/:id/share-token', async (req, res, next) => {
try {
const result = await sharedCalendarService.generateShareToken(
req.user!.id,
req.params.id as string,
);
res.json(result);
} catch (error) {
next(error);
}
});
// --- Item Sharing & Friend Calendar ---
router.post('/items/:id/share', validate(shareItemSchema), async (req, res, next) => {
try {
const results = await sharedCalendarService.shareItemWithFriends(
req.user!.id,
req.params.id as string,
req.body.friendIds,
req.body.message,
);
res.json({ results });
} catch (error) {
next(error);
}
});
router.get('/user/:userId', validate(dateRangeQuerySchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const items = await sharedCalendarService.getFriendPublicCalendar(
req.user!.id,
req.params.userId as string,
startDate,
endDate,
);
res.json({ items });
} catch (error) {
next(error);
}
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,7 @@ const ANT_ICON_TO_MATERIAL: Record<string, string> = {
EnvironmentOutlined: 'place',
ScheduleOutlined: 'schedule',
CalendarOutlined: 'event',
BarChartOutlined: 'bar_chart',
PlayCircleOutlined: 'play_circle',
HeartOutlined: 'favorite_border',
DollarOutlined: 'attach_money',
@ -59,6 +60,13 @@ const ANT_ICON_TO_MATERIAL: Record<string, string> = {
LinkOutlined: 'link',
GlobalOutlined: 'language',
BookOutlined: 'menu_book',
TagOutlined: 'sell',
VideoCameraOutlined: 'videocam',
FileTextOutlined: 'description',
TrophyOutlined: 'emoji_events',
FolderOutlined: 'folder',
AppstoreOutlined: 'apps',
WalletOutlined: 'account_balance_wallet',
};
/**
@ -80,9 +88,10 @@ interface NavConfigItem {
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
type: 'builtin' | 'custom' | 'group';
featureFlag?: string;
external?: boolean;
children?: NavConfigItem[];
}
/** Extended nav item with rendering hints (not persisted in schema) */
@ -91,6 +100,18 @@ interface RenderNavItem extends HeaderNavItem {
isAbsoluteHref?: boolean;
}
/** A group of nav items rendered as a dropdown on desktop / expandable section on mobile */
interface RenderNavGroup {
id: string;
label: string;
icon: string;
type: 'group';
children: RenderNavItem[];
}
/** A renderable item: either a single link or a group dropdown */
type RenderItem = RenderNavItem | RenderNavGroup;
class HeaderBuilderService {
/**
* Read the current header config from disk.
@ -204,7 +225,7 @@ class HeaderBuilderService {
var h = location.hostname;
var base;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port }} || 3000);
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port | default(0) }} || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
@ -403,23 +424,53 @@ class HeaderBuilderService {
* Regenerate main.html from the centralized navConfig stored in SiteSettings.
* Called when navConfig changes via the settings API.
*/
async regenerateFromNavConfig(navConfigItems: NavConfigItem[], settings?: { publicHeaderGradient?: string; publicColorBgBase?: string; publicColorBgContainer?: string }): Promise<void> {
async regenerateFromNavConfig(
navConfigItems: NavConfigItem[],
settings?: {
publicHeaderGradient?: string;
publicColorBgBase?: string;
publicColorBgContainer?: string;
// Feature flags
enableInfluence?: boolean | null;
enableMap?: boolean | null;
enableMediaFeatures?: boolean | null;
enablePayments?: boolean | null;
enableEvents?: boolean | null;
enableMeetingPlanner?: boolean | null;
enableTicketedEvents?: boolean | null;
enableSocial?: boolean | null;
enableMeet?: boolean | null;
enableLandingPages?: boolean | null;
},
): Promise<void> {
try {
const enabledItems = navConfigItems
.filter((item) => item.enabled)
.sort((a, b) => a.order - b.order);
// Opt-out flags: visible by default, hidden only when explicitly false
const OPT_OUT_FLAGS = new Set(['enableInfluence', 'enableMap', 'enableMediaFeatures', 'enableEvents']);
if (enabledItems.length === 0) {
// Write minimal passthrough
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
logger.info('Generated passthrough main.html (no nav items enabled)');
return;
}
const featureFlagMap: Record<string, boolean | null | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
enableMeetingPlanner: settings?.enableMeetingPlanner,
enableTicketedEvents: settings?.enableTicketedEvents,
enableSocial: settings?.enableSocial,
enableMeet: settings?.enableMeet,
enableLandingPages: settings?.enableLandingPages,
};
// Convert NavConfigItems to HeaderNavItems for rendering
// Resolve $token paths: MkDocs serves at the root domain, so use relative paths
const headerItems: RenderNavItem[] = enabledItems.map((item) => {
/** Check if an item passes its feature flag filter */
const passesFeatureFlag = (item: NavConfigItem): boolean => {
if (!item.featureFlag) return true;
if (OPT_OUT_FLAGS.has(item.featureFlag)) {
return featureFlagMap[item.featureFlag] !== false;
}
return featureFlagMap[item.featureFlag] === true;
};
/** Convert a NavConfigItem to a RenderNavItem */
const toRenderItem = (item: NavConfigItem): RenderNavItem => {
let resolvedPath = item.path;
if (item.path === '$landing') resolvedPath = '/';
else if (item.path === '$docs') resolvedPath = '/docs/';
@ -431,30 +482,66 @@ class HeaderBuilderService {
icon: toMaterialIcon(item.icon),
enabled: true,
order: item.order,
type: item.type,
// $token-resolved items use direct href (relative to MkDocs root), not data-path
type: item.type as 'builtin' | 'custom',
openInNewTab: item.path.startsWith('$') ? false : item.external,
isAbsoluteHref: item.path.startsWith('$'),
};
});
};
// Build group-aware RenderItem[] preserving dropdown structure
const renderItems: RenderItem[] = [];
const sortedTopLevel = [...navConfigItems].sort((a, b) => a.order - b.order);
for (const item of sortedTopLevel) {
if (!item.enabled) continue;
if (item.type === 'group' && item.children) {
// Check group-level feature flag
if (!passesFeatureFlag(item)) continue;
// Filter children individually
const enabledChildren = item.children
.filter((child) => child.enabled && passesFeatureFlag(child))
.sort((a, b) => a.order - b.order)
.map(toRenderItem);
if (enabledChildren.length === 0) continue;
// If only one child survives, render it as a flat item instead of a dropdown
if (enabledChildren.length === 1) {
renderItems.push(enabledChildren[0]);
} else {
renderItems.push({
id: item.id,
label: item.label,
icon: toMaterialIcon(item.icon),
type: 'group',
children: enabledChildren,
});
}
} else {
if (!passesFeatureFlag(item)) continue;
renderItems.push(toRenderItem(item));
}
}
if (renderItems.length === 0) {
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
logger.info('Generated passthrough main.html (no nav items enabled)');
return;
}
const backgroundColor = settings?.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
const colorBgBase = settings?.publicColorBgBase || '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer || '#1b2838';
const config = {
enabled: true,
items: headerItems,
style: {
backgroundColor,
textColor: '#ffffff',
hoverColor: 'rgba(255,255,255,0.15)',
height: '40px',
colorBgBase,
colorBgContainer,
},
};
const html = this.generateMainHtml(config);
const html = this.generateMainHtmlV2(renderItems, {
backgroundColor,
textColor: '#ffffff',
colorBgBase,
colorBgContainer,
});
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
logger.info('Regenerated main.html from navConfig');
} catch (err) {
@ -462,6 +549,500 @@ class HeaderBuilderService {
}
}
/**
* Generate main.html from a mixed RenderItem[] (groups + flat items).
* Groups render as CSS hover dropdowns on desktop and expandable sections on mobile.
*/
private generateMainHtmlV2(
items: RenderItem[],
style: { backgroundColor: string; textColor: string; colorBgBase: string; colorBgContainer: string },
): string {
const { backgroundColor, textColor, colorBgBase, colorBgContainer } = style;
const desktopLinks = items
.map((item) =>
item.type === 'group'
? this.renderNavDropdown(item as RenderNavGroup)
: this.renderNavLink(item as RenderNavItem),
)
.join('\n ');
const mobileLinks = items
.map((item) =>
item.type === 'group'
? this.renderMobileGroup(item as RenderNavGroup)
: this.renderMobileNavLink(item as RenderNavItem),
)
.join('\n ');
return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #}
{% extends "base.html" %}
{% block announce %}
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
<nav class="cm-header-nav" role="navigation" aria-label="Application">
<div class="cm-header-nav__brand">
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
</a>
</div>
<div class="cm-header-nav__links">
<div class="cm-header-nav__links-inner">
${desktopLinks}
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
<span class="material-icons-outlined">login</span>
<span class="cm-header-nav__label">Sign In</span>
</a>
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">person</span>
<span class="cm-header-nav__label">Admin</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
<span class="material-icons-outlined">menu</span>
</button>
</div>
</nav>
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
<div class="cm-header-nav__mobile-header">
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
<span class="material-icons-outlined">close</span>
</button>
</div>
<div class="cm-header-nav__mobile-links">
${mobileLinks}
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
<span class="material-icons-outlined">login</span>
<span>Sign In</span>
</a>
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">person</span>
<span style="flex:1">Admin</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
</div>
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
<script>
(function() {
var h = location.hostname;
var base;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port | default(0) }} || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
else { parts.unshift('app'); }
base = location.protocol + '//' + parts.join('.');
}
var links = document.querySelectorAll('[data-path]');
for (var i = 0; i < links.length; i++) {
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
}
// Highlight active nav link based on current path
var path = location.pathname;
var activeLink = null;
if (path.indexOf('/docs') === 0) activeLink = 'docs';
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
if (el.getAttribute('data-nav-id') === activeLink) {
el.classList.add('cm-header-nav__link--active');
}
});
// Hamburger toggle
var hamburger = document.querySelector('.cm-header-nav__hamburger');
var drawer = document.getElementById('cm-mobile-drawer');
var overlay = document.getElementById('cm-mobile-overlay');
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
// Mobile group expand/collapse toggles
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
trigger.addEventListener('click', function() {
var group = this.closest('.cm-header-nav__mobile-group');
var children = group.querySelector('.cm-header-nav__mobile-group-children');
var isExpanded = group.classList.contains('expanded');
if (isExpanded) {
group.classList.remove('expanded');
children.style.display = 'none';
} else {
group.classList.add('expanded');
children.style.display = 'block';
}
});
});
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
// Uses hidden iframe + postMessage to read auth state from the app's origin.
function showAdminMenu() {
var s1 = document.getElementById('cm-signin-link');
var s2 = document.getElementById('cm-mobile-signin-link');
var a1 = document.getElementById('cm-admin-dropdown');
var a2 = document.getElementById('cm-mobile-admin-group');
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = 'none';
if (a1) a1.style.display = '';
if (a2) a2.style.display = '';
}
// 1. Same-origin check (works when MkDocs served from same origin as app)
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
showAdminMenu();
}
}
} catch(e) {}
// 2. Cross-origin check via hidden iframe + postMessage
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = base + '/auth-check.html';
window.addEventListener('message', function(event) {
if (event.origin !== base) return;
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
showAdminMenu();
}
});
document.body.appendChild(iframe);
})();
</script>
<style>
.md-banner {
background: ${escapeHtml(backgroundColor)} !important;
color: ${escapeHtml(textColor)} !important;
padding: 0 !important;
overflow: visible !important;
border: none !important;
box-shadow: none !important;
}
.md-banner__inner {
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: ${escapeHtml(backgroundColor)};
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
position: relative;
z-index: 100;
box-sizing: border-box;
}
.cm-header-nav a {
color: rgba(255, 255, 255, 0.85) !important;
}
.cm-header-nav__brand-link {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none !important;
color: #fff !important;
}
.cm-header-nav__brand-text {
font-size: 18px;
font-weight: 600;
color: #fff !important;
}
.cm-header-nav__links {
display: flex;
align-items: center;
}
.cm-header-nav__links-inner {
display: flex;
align-items: center;
gap: 16px;
}
.cm-header-nav__link {
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
transition: color 0.2s, border-color 0.2s;
white-space: nowrap;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.cm-header-nav__link:hover {
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__link--active,
.cm-header-nav__link--active:hover {
color: #fff !important;
font-weight: 600;
border-bottom-color: #fff;
}
.cm-header-nav__link .material-icons-outlined {
font-size: 16px;
}
.cm-header-nav__hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
color: #fff;
}
.cm-header-nav__hamburger .material-icons-outlined {
font-size: 24px;
}
/* Desktop dropdown menus */
.cm-header-nav__dropdown {
position: relative;
display: inline-flex;
align-items: center;
}
.cm-header-nav__dropdown-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
font-size: 14px;
transition: transform 0.2s;
}
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
transform: rotate(180deg);
}
.cm-header-nav__dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: ${escapeHtml(colorBgContainer)};
border-radius: 8px;
padding: 6px 0;
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
z-index: 100;
margin-top: 4px;
}
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
display: block;
}
.cm-header-nav__dropdown-menu--right {
left: auto;
right: 0;
}
.cm-header-nav__dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s;
}
.cm-header-nav__dropdown-item:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__dropdown-item .material-icons-outlined {
font-size: 16px;
}
/* Mobile drawer */
.cm-header-nav__mobile-drawer {
position: fixed;
top: 0;
right: -280px;
width: 280px;
height: 100vh;
background: ${escapeHtml(colorBgBase)};
z-index: 10001;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
}
.cm-header-nav__mobile-drawer.open {
right: 0;
}
.cm-header-nav__mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255,255,255,0.1);
background: ${escapeHtml(colorBgContainer)};
}
.cm-header-nav__mobile-close {
background: none;
border: none;
cursor: pointer;
color: rgba(255,255,255,0.85);
padding: 4px;
}
.cm-header-nav__mobile-links {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 0;
}
.cm-header-nav__mobile-link {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
color: rgba(255,255,255,0.85) !important;
text-decoration: none !important;
font-size: 15px;
border-radius: 4px;
}
.cm-header-nav__mobile-link:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__mobile-link--active {
color: #fff !important;
font-weight: 600;
background: rgba(255,255,255,0.1);
}
.cm-header-nav__mobile-link .material-icons-outlined {
font-size: 18px;
}
/* Mobile group expand/collapse */
.cm-header-nav__mobile-group-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__mobile-chevron {
font-size: 14px !important;
transition: transform 0.2s;
}
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
transform: rotate(180deg);
}
.cm-header-nav__mobile-group-children {
display: none;
}
.cm-header-nav__mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
}
.cm-header-nav__mobile-overlay.open {
display: block;
}
@media (max-width: 768px) {
.cm-header-nav { padding: 0 16px; }
.cm-header-nav__links-inner { display: none; }
.cm-header-nav__hamburger { display: block; }
.cm-header-nav__dropdown-menu { display: none !important; }
}
</style>
{% endblock %}
`;
}
/**
* Render a desktop dropdown menu for a nav group.
* Uses pure CSS :hover no JavaScript needed for desktop.
*/
private renderNavDropdown(group: RenderNavGroup): string {
const iconHtml = group.icon
? `<span class="material-icons-outlined">${escapeHtml(group.icon)}</span>`
: '';
const childLinks = group.children
.map((child) => {
const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://');
const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : '';
const childIcon = child.icon
? `<span class="material-icons-outlined">${escapeHtml(child.icon)}</span>`
: '';
const href = isAbsolute
? `href="${escapeHtml(child.path)}"`
: `href="#" data-path="${escapeHtml(child.path)}"`;
return ` <a ${href} class="cm-header-nav__dropdown-item"${navId}${target}>${childIcon}<span>${escapeHtml(child.label)}</span></a>`;
})
.join('\n');
return `<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
${iconHtml}
<span class="cm-header-nav__label">${escapeHtml(group.label)}</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
${childLinks}
</div>
</div>`;
}
/**
* Render a mobile expandable group section.
* Uses JS click handler to toggle visibility.
*/
private renderMobileGroup(group: RenderNavGroup): string {
const iconHtml = group.icon
? `<span class="material-icons-outlined">${escapeHtml(group.icon)}</span>`
: '';
const childLinks = group.children
.map((child) => {
const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://');
const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : '';
const childIcon = child.icon
? `<span class="material-icons-outlined">${escapeHtml(child.icon)}</span>`
: '';
const href = isAbsolute
? `href="${escapeHtml(child.path)}"`
: `href="#" data-path="${escapeHtml(child.path)}"`;
return ` <a ${href} class="cm-header-nav__mobile-link"${navId}${target} style="padding-left:48px">${childIcon}<span>${escapeHtml(child.label)}</span></a>`;
})
.join('\n');
return `<div class="cm-header-nav__mobile-group" data-group-id="${escapeHtml(group.id)}">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
${iconHtml}
<span style="flex:1">${escapeHtml(group.label)}</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
${childLinks}
</div>
</div>`;
}
/**
* Render a single nav link element.
* Items with isAbsoluteHref use direct href (e.g. $token-resolved paths like / or /docs/).

View File

@ -16,6 +16,7 @@ export const createLandingPageSchema = z.object({
mkdocsHideToc: z.boolean().optional().default(true),
mkdocsSkipExport: z.boolean().optional().default(false),
published: z.boolean().optional().default(false),
listed: z.boolean().optional().default(false),
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
seoImage: z.string().optional(),
@ -34,6 +35,7 @@ export const updateLandingPageSchema = z.object({
mkdocsHideToc: z.boolean().optional(),
mkdocsSkipExport: z.boolean().optional(),
published: z.boolean().optional(),
listed: z.boolean().optional(),
seoTitle: z.string().nullable().optional(),
seoDescription: z.string().nullable().optional(),
seoImage: z.string().nullable().optional(),

View File

@ -23,6 +23,7 @@ const landingPageSelect = {
mkdocsHideToc: true,
mkdocsSkipExport: true,
published: true,
listed: true,
seoTitle: true,
seoDescription: true,
seoImage: true,

View File

@ -23,6 +23,7 @@ router.get('/', async (req, res, next) => {
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=3600');
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
res.send(buffer);
} catch (err) {
next(err);

View File

@ -116,7 +116,12 @@ router.put(
}
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
const headerTriggerFields = ['navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer'];
const headerTriggerFields = [
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',
'enableInfluence', 'enableMap', 'enableMediaFeatures', 'enablePayments',
'enableEvents', 'enableMeetingPlanner', 'enableTicketedEvents', 'enableSocial',
'enableMeet', 'enableLandingPages',
];
if (headerTriggerFields.some((f) => f in req.body)) {
if ('navConfig' in req.body) {
gancioSettingsSyncService.syncAll().catch(() => {});
@ -130,6 +135,16 @@ router.put(
publicHeaderGradient: settings.publicHeaderGradient ?? undefined,
publicColorBgBase: settings.publicColorBgBase ?? undefined,
publicColorBgContainer: settings.publicColorBgContainer ?? undefined,
enableInfluence: settings.enableInfluence,
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
enableEvents: settings.enableEvents,
enableMeetingPlanner: settings.enableMeetingPlanner,
enableTicketedEvents: settings.enableTicketedEvents,
enableSocial: settings.enableSocial,
enableMeet: settings.enableMeet,
enableLandingPages: settings.enableLandingPages,
},
).then(() => {
// Fire-and-forget docs build so the static site picks up the new header

View File

@ -86,7 +86,7 @@ export const updateSiteSettingsSchema = z.object({
provisionListmonk: z.boolean().optional(),
provisionListmonkTiming: z.enum(['lazy', 'eager']).optional(),
// Navigation configuration
// Navigation configuration (supports one level of nesting via groups)
navConfig: z.object({
items: z.array(z.object({
id: z.string(),
@ -95,9 +95,20 @@ export const updateSiteSettingsSchema = z.object({
icon: z.string(),
enabled: z.boolean(),
order: z.number(),
type: z.enum(['builtin', 'custom']),
type: z.enum(['builtin', 'custom', 'group']),
featureFlag: z.string().optional(),
external: z.boolean().optional(),
children: z.array(z.object({
id: z.string(),
label: z.string(),
path: z.string(),
icon: z.string(),
enabled: z.boolean(),
order: z.number(),
type: z.enum(['builtin', 'custom']),
featureFlag: z.string().optional(),
external: z.boolean().optional(),
})).optional(),
})),
}).optional(),

View File

@ -60,8 +60,8 @@ router.get('/:slug', async (req: Request, res: Response, next: NextFunction) =>
}
}
// Only show published events publicly
if (event.status !== 'PUBLISHED') {
// Only show published or completed events publicly
if (event.status !== 'PUBLISHED' && event.status !== 'COMPLETED') {
throw new AppError(404, 'Event not found', 'NOT_FOUND');
}

View File

@ -104,12 +104,16 @@ import { ogRouter } from './modules/og/og.routes';
import { socialRouter } from './modules/social/social.routes';
import { errorReportRouter } from './modules/reports/error-report.routes';
import calendarRoutes from './modules/calendar/calendar.routes';
import feedRoutes from './modules/calendar/feed.routes';
import sharedCalendarRoutes from './modules/calendar/shared-calendar.routes';
import { adminCalendarRouter } from './modules/calendar/admin-calendar.routes';
import { ticketedEventsPublicRouter } from './modules/ticketed-events/ticketed-events-public.routes';
import { ticketedEventsAdminRouter } from './modules/ticketed-events/ticketed-events-admin.routes';
import { checkinRouter } from './modules/ticketed-events/checkin.routes';
import { sseService } from './modules/social/sse.service';
import { presenceService } from './modules/social/presence.service';
import { upgradeService } from './modules/upgrade/upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
const app = express();
@ -273,6 +277,9 @@ app.use('/api/ticketed-events/admin', ticketedEventsAdminRouter); // Admin tic
app.use('/api/ticketed-events/checkin', checkinRouter); // Check-in scanner routes (auth required)
app.use('/api/ticketed-events', ticketedEventsPublicRouter); // Public ticketed event listing + checkout (no auth)
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
app.use('/api/admin/calendar/shared', adminCalendarRouter); // Admin calendar views (SUPER_ADMIN/MAP_ADMIN)
app.use('/api/calendar/shared', sharedCalendarRoutes); // Shared calendar views + collaboration (public share + auth)
app.use('/api/calendar', feedRoutes); // ICS feed subscriptions + export (public .ics + auth)
app.use('/api/calendar', calendarRoutes); // Personal calendar layers + items (auth required)
// --- API 404 Handler (catch unmatched /api/* routes) ---
@ -313,6 +320,7 @@ async function start() {
emailQueueService.startWorker();
notificationQueueService.startWorker();
geocodeQueueService.startWorker();
calendarFeedQueueService.startWorker();
startProxy();
// Load SMS config from DB (env fallback for empty fields)
@ -459,6 +467,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
await notificationQueueService.close();
await geocodeQueueService.close();
await smsQueueService.close();
await calendarFeedQueueService.close();
await prisma.$disconnect();
redis.disconnect();
process.exit(0);

View File

@ -0,0 +1,78 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { feedService } from '../modules/calendar/feed.service';
const QUEUE_NAME = 'calendar-feed-refresh';
const MAX_FEEDS_PER_CYCLE = 10;
class CalendarFeedQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue(QUEUE_NAME, {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
removeOnComplete: { age: 60 * 60, count: 100 },
removeOnFail: { age: 24 * 60 * 60 },
},
});
}
startWorker() {
// Add repeatable job: every 15 minutes
this.queue.add(
'refresh-cycle',
{},
{
repeat: { every: 15 * 60 * 1000 },
jobId: 'calendar-feed-refresh-cycle',
}
);
this.worker = new Worker(
QUEUE_NAME,
async (_job: Job) => {
const feeds = await feedService.getFeedsDueForRefresh(MAX_FEEDS_PER_CYCLE);
if (feeds.length === 0) return;
logger.debug(`Calendar feed refresh cycle: ${feeds.length} feeds due`);
for (const feed of feeds) {
try {
await feedService.refreshFeed(feed.id);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`Feed refresh failed for ${feed.name}: ${message}`);
}
}
},
{
connection: { url: env.REDIS_URL },
concurrency: 1,
}
);
this.worker.on('completed', (job) => {
logger.debug(`Calendar feed refresh job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Calendar feed refresh job ${job?.id} failed: ${err.message}`);
});
logger.info('Calendar feed queue worker started');
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Calendar feed queue closed');
}
}
export const calendarFeedQueueService = new CalendarFeedQueueService();

View File

@ -95,9 +95,10 @@ interface NavConfigItem {
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
type: 'builtin' | 'custom' | 'group';
featureFlag?: string;
external?: boolean;
children?: NavConfigItem[];
}
/** Map navConfig icon IDs to the SVG NAV_ICONS keys */
@ -106,12 +107,18 @@ const ICON_ID_TO_KEY: Record<string, string> = {
SendOutlined: 'Campaigns',
EnvironmentOutlined: 'Map',
CalendarOutlined: 'Shifts',
ScheduleOutlined: 'Shifts',
BarChartOutlined: 'Events',
PlayCircleOutlined: 'Gallery',
HeartOutlined: 'Donate',
DollarOutlined: 'Donate',
ShoppingOutlined: 'Donate',
GlobalOutlined: 'Website',
BookOutlined: 'Docs',
TagOutlined: 'Events',
VideoCameraOutlined: 'Events',
FileTextOutlined: 'Docs',
TrophyOutlined: 'Home',
};
function buildCustomJs(settings: {
@ -122,6 +129,12 @@ function buildCustomJs(settings: {
enableMap?: boolean | null;
enableMediaFeatures?: boolean | null;
enablePayments?: boolean | null;
enableEvents?: boolean | null;
enableMeetingPlanner?: boolean | null;
enableTicketedEvents?: boolean | null;
enableSocial?: boolean | null;
enableMeet?: boolean | null;
enableLandingPages?: boolean | null;
navConfig?: { items: NavConfigItem[] } | null;
}, appUrl: string, homeUrl: string): string {
const orgName = settings.organizationName || 'Changemaker Lite';
@ -139,9 +152,25 @@ function buildCustomJs(settings: {
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
enableEvents: settings.enableEvents,
enableMeetingPlanner: settings.enableMeetingPlanner,
enableTicketedEvents: settings.enableTicketedEvents,
enableSocial: settings.enableSocial,
enableMeet: settings.enableMeet,
enableLandingPages: settings.enableLandingPages,
};
const sorted = [...settings.navConfig.items]
// Flatten groups: Gancio uses a flat HTML nav bar, no dropdown support
const flatItems: NavConfigItem[] = [];
for (const item of settings.navConfig.items) {
if (item.type === 'group' && item.children) {
for (const child of item.children) flatItems.push(child);
} else {
flatItems.push(item);
}
}
const sorted = flatItems
.filter(item => item.enabled)
.filter(item => !item.featureFlag || featureFlagMap[item.featureFlag] !== false)
.sort((a, b) => a.order - b.order);

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -95,11 +95,14 @@
"assets/images/social/docs/features/user-provisioning.png": "a4eb3646ca519dab25a7a88be5c2f415d0881736",
"assets/images/social/docs/features/video-conferencing.png": "8f23fd22191ec7e59ca8b34e174a3aece810e877",
"assets/images/social/docs/features/whiteboard.png": "76ea579bf566914646e99472103c9e75c705f17e",
"assets/images/social/docs/getting-started/control-panel.png": "af6419a4b71dba2b6a7db061510da2779d043626",
"assets/images/social/docs/getting-started/environment-variables.png": "a2ac6ca4cb56f9697fc7fd25f9cca6f1aa83a58b",
"assets/images/social/docs/getting-started/features.png": "ae9016c9e67134485b7f7bcb9edbbf1545be1792",
"assets/images/social/docs/getting-started/first-steps.png": "6cb7de20e49639ba1df8f6ea5a95320ce22fb489",
"assets/images/social/docs/getting-started/index.png": "c38af587f205180bab6232e517438de6db89fc88",
"assets/images/social/docs/getting-started/installation.png": "36c629ba3ab9edab492ce68af59a029c0ad79e81",
"assets/images/social/docs/getting-started/installation.png": "280897d4c54ab9b7c7f137a2e681461bce9ff6f5",
"assets/images/social/docs/getting-started/services.png": "64f58bca6982d60b364d3a355c66ce0f84fd5a1b",
"assets/images/social/docs/getting-started/upgrades.png": "c814d3d85a2e40a9a71fc121c9c5707b93ebcba8",
"assets/images/social/docs/index.png": "473afed8e6ed44768b1a64ad90c4a8595667a8f3",
"assets/images/social/docs/phil.png": "ffe46a0052d8c23422f82d91e03a213118869539",
"assets/images/social/docs/services/index.png": "9fcf00324266a9f7b58c7b277da2127e8882aa47",
@ -140,6 +143,7 @@
"assets/images/social/services/postgresql.png": "831fb68dd3e01d9a017e59b100aaa8a455c8c112",
"assets/images/social/services/static-server.png": "f36d527c80adba4bcb7778784683f429acb4ce74",
"assets/images/social/test-2.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83",
"assets/images/social/test-page.png": "c9d5751a1f0a4c1341336bb7d00c9bc743d33ef4",
"assets/images/social/test.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83",
"assets/images/social/v1/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b",
"assets/images/social/v1/adv/index.png": "faa3ec092003114c031995ba6258c4d43f4262a4",

View File

@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 23,
"updated_at": "2026-03-03T14:22:46-07:00",
"updated_at": "2026-03-05T12:20:58-07: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-03-03T14:22:46-07:00"
"last_build_update": "2026-03-05T12:20:58-07:00"
}

View File

@ -4,13 +4,13 @@
"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": 73218,
"forks_count": 5806,
"open_issues_count": 5500,
"updated_at": "2026-03-03T21:40:58Z",
"stars_count": 74943,
"forks_count": 6013,
"open_issues_count": 5785,
"updated_at": "2026-03-07T19:48:10Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-02T16:38:30Z"
"last_build_update": "2026-03-07T00:12:45Z"
}

View File

@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 76454,
"forks_count": 6532,
"open_issues_count": 174,
"updated_at": "2026-03-03T21:35:43Z",
"stars_count": 76519,
"forks_count": 6539,
"open_issues_count": 169,
"updated_at": "2026-03-07T18:20:51Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-03T21:35:38Z"
"last_build_update": "2026-03-06T12:59:10Z"
}

View File

@ -4,13 +4,13 @@
"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": 28705,
"stars_count": 28761,
"forks_count": 1808,
"open_issues_count": 6,
"updated_at": "2026-03-03T21:17:50Z",
"open_issues_count": 1,
"updated_at": "2026-03-07T19:52:28Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",
"default_branch": "dev",
"last_build_update": "2026-03-03T12:22:06Z"
"last_build_update": "2026-03-07T15:45:18Z"
}

View File

@ -4,13 +4,13 @@
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
"html_url": "https://github.com/go-gitea/gitea",
"language": "Go",
"stars_count": 54043,
"forks_count": 6420,
"open_issues_count": 2841,
"updated_at": "2026-03-03T21:25:00Z",
"stars_count": 54164,
"forks_count": 6434,
"open_issues_count": 2847,
"updated_at": "2026-03-07T18:54:25Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-03T19:24:00Z"
"last_build_update": "2026-03-07T05:30:59Z"
}

View File

@ -4,13 +4,13 @@
"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": 19177,
"forks_count": 1945,
"open_issues_count": 115,
"updated_at": "2026-03-03T21:29:08Z",
"stars_count": 19208,
"forks_count": 1946,
"open_issues_count": 99,
"updated_at": "2026-03-07T18:41:21Z",
"created_at": "2019-06-26T05:08:39Z",
"clone_url": "https://github.com/knadh/listmonk.git",
"ssh_url": "git@github.com:knadh/listmonk.git",
"default_branch": "master",
"last_build_update": "2026-03-03T03:44:33Z"
"last_build_update": "2026-03-07T18:41:17Z"
}

View File

@ -4,13 +4,13 @@
"description": "Create & scan cute qr codes easily \ud83d\udc7e",
"html_url": "https://github.com/lyqht/mini-qr",
"language": "Vue",
"stars_count": 1883,
"forks_count": 240,
"stars_count": 1896,
"forks_count": 238,
"open_issues_count": 21,
"updated_at": "2026-03-03T15:21:16Z",
"updated_at": "2026-03-07T17:03:03Z",
"created_at": "2023-04-21T14:20:14Z",
"clone_url": "https://github.com/lyqht/mini-qr.git",
"ssh_url": "git@github.com:lyqht/mini-qr.git",
"default_branch": "main",
"last_build_update": "2026-03-02T11:52:10Z"
"last_build_update": "2026-03-05T13:18:42Z"
}

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": 177388,
"forks_count": 55378,
"open_issues_count": 1397,
"updated_at": "2026-03-03T21:42:29Z",
"stars_count": 178014,
"forks_count": 55521,
"open_issues_count": 1413,
"updated_at": "2026-03-07T19:43:47Z",
"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-03-03T21:30:53Z"
"last_build_update": "2026-03-07T18:51:26Z"
}

View File

@ -4,13 +4,13 @@
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
"html_url": "https://github.com/nocodb/nocodb",
"language": "TypeScript",
"stars_count": 62290,
"forks_count": 4650,
"open_issues_count": 621,
"updated_at": "2026-03-03T21:35:23Z",
"stars_count": 62371,
"forks_count": 4655,
"open_issues_count": 627,
"updated_at": "2026-03-07T19:49:07Z",
"created_at": "2017-10-29T18:51:48Z",
"clone_url": "https://github.com/nocodb/nocodb.git",
"ssh_url": "git@github.com:nocodb/nocodb.git",
"default_branch": "develop",
"last_build_update": "2026-03-03T15:07:07Z"
"last_build_update": "2026-03-07T10:48:26Z"
}

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": 163957,
"forks_count": 14745,
"open_issues_count": 2551,
"updated_at": "2026-03-03T21:37:48Z",
"stars_count": 164358,
"forks_count": 14823,
"open_issues_count": 2590,
"updated_at": "2026-03-07T19:18:40Z",
"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-03-03T21:23:42Z"
"last_build_update": "2026-03-07T03:18:54Z"
}

View File

@ -4,13 +4,13 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
"stars_count": 26161,
"forks_count": 4048,
"stars_count": 26199,
"forks_count": 4047,
"open_issues_count": 2,
"updated_at": "2026-03-03T19:59:27Z",
"updated_at": "2026-03-07T18:01:45Z",
"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",
"default_branch": "master",
"last_build_update": "2026-03-03T19:59:22Z"
"last_build_update": "2026-03-05T13:44:14Z"
}

View File

@ -0,0 +1,270 @@
---
title: Control Panel (CCP)
description: Multi-tenant management for provisioning and operating multiple Changemaker Lite instances.
icon: material/console
---
# Changemaker Control Panel (CCP)
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?"
If you're running a single Changemaker Lite instance, you don't need CCP. Skip this page and continue with [First Steps](first-steps.md).
---
## When to Use CCP
CCP is designed for:
- **Campaign organizations** managing instances for multiple chapters or regions
- **Hosting providers** offering Changemaker Lite as a managed service
- **Development teams** spinning up isolated test instances
CCP handles the entire instance lifecycle: provisioning, configuration, health monitoring, backups, and upgrades — all from a single dashboard.
---
## Architecture
CCP runs as 4 Docker containers alongside (but independent from) your CML instances:
```
┌──────────────────────────┐
│ CCP Admin GUI (5100) │ React + Vite + Ant Design
│ Dark theme, SPA │ Zustand auth store
└────────────┬─────────────┘
┌────────────▼─────────────┐
│ CCP API (5000) │ Express + TypeScript
│ JWT auth, RBAC │ Prisma ORM → PostgreSQL
│ Docker socket access │ Winston logger
└────────────┬─────────────┘
┌────────┼────────┐
▼ ▼ ▼
ccp-postgres ccp-redis Docker Socket
(port 5480) (port 6399)
```
| Service | Container | Port | Description |
|---------|-----------|------|-------------|
| CCP API | `ccp-api` | 5000 | Express API with Docker CLI access |
| CCP Admin | `ccp-admin` | 5100 | React admin GUI |
| CCP PostgreSQL | `ccp-postgres` | 5480 | CCP metadata database |
| CCP Redis | `ccp-redis` | 6399 | Rate limiting, caching |
Each managed CML instance gets its own isolated set of containers and PostgreSQL database, with ports allocated from non-overlapping ranges.
---
## Setup
### 1. Run the Setup Script
```bash
cd changemaker-control-panel
chmod +x setup.sh
./setup.sh
```
The setup script:
- Detects the installation directory and resolves absolute paths
- Creates `instances/` and `backups/` directories
- Copies `.env.example` to `.env` if not present
- Sets `INSTANCES_BASE_PATH`, `BACKUP_STORAGE_PATH`, and `CML_SOURCE_PATH`
- Generates random secrets for any placeholder values
### 2. Review Environment
Edit `.env` and verify the key settings:
| Variable | Default | Description |
|----------|---------|-------------|
| `JWT_ACCESS_SECRET` | Auto-generated | JWT signing key |
| `JWT_REFRESH_SECRET` | Auto-generated | Refresh token signing key |
| `ENCRYPTION_KEY` | Auto-generated | AES-256 key for instance secrets at rest |
| `INITIAL_ADMIN_EMAIL` | `admin@example.com` | Bootstrap admin email |
| `INITIAL_ADMIN_PASSWORD` | `ChangeMe2025!!` | Bootstrap admin password |
| `INSTANCES_BASE_PATH` | `./instances` | Where instance directories are created |
| `CML_SOURCE_PATH` | Auto-detected | Path to CML source repo for provisioning |
| `BACKUP_STORAGE_PATH` | `./backups` | Backup archive storage |
| `PANGOLIN_API_URL` | — | Pangolin API for tunnel management |
| `PANGOLIN_API_KEY` | — | Pangolin authentication |
| `PANGOLIN_ORG_ID` | — | Pangolin organization |
### 3. Start CCP
```bash
docker compose up -d
# Run database migrations and seed the admin user
docker compose exec ccp-api npx prisma migrate deploy
docker compose exec ccp-api npx prisma db seed
```
### 4. Log In
Open **http://localhost:5100** and sign in with the admin credentials from `.env`.
---
## Creating an Instance
The Create Instance wizard walks through 5 steps:
### Step 1: Basic Information
- **Instance name** — human-readable label (e.g., "Edmonton Chapter")
- **Slug** — URL-safe identifier (e.g., `edmonton`), used for directory names and compose project
- **Domain** — the domain this instance will serve (e.g., `edmonton.example.org`)
### Step 2: Features
Toggle which platform features to enable for this instance:
- Media Manager
- Listmonk newsletter sync
- Payments
- Rocket.Chat
- Gancio events
- Jitsi Meet
- SMS Campaigns
### Step 3: Email
Configure SMTP for the instance, or use MailHog for testing.
### Step 4: Tunnel
Optionally configure Pangolin tunnel credentials for public access.
### Step 5: Review
Review all settings, then click **Create** to start provisioning.
---
## Provisioning Flow
When you create an instance, CCP runs a **13-step async provisioning process**:
| Step | What Happens |
|------|-------------|
| 1 | Validate uniqueness (slug + domain) |
| 2 | Allocate 4 ports from ranges |
| 3 | Generate 14 secrets (passwords, JWT keys, encryption keys) |
| 4 | Create Instance record (status: PROVISIONING) |
| 5 | Create instance directory |
| 6 | Copy CML source code (rsync, excluding node_modules/.git/.env) |
| 7 | Decrypt secrets and build template context |
| 8 | Render 7 config files from Handlebars templates (docker-compose.yml, .env, nginx configs, Pangolin, Prometheus) |
| 9 | Copy static files (nginx.conf) |
| 10 | `docker compose pull` (non-fatal if images are cached) |
| 11 | `docker compose build` |
| 12 | Start infrastructure (PostgreSQL + Redis), wait for healthy |
| 13 | Start API (runs migrations + seed), then start all remaining services |
The admin GUI polls every 3 seconds during provisioning to show progress. When complete, the instance status changes to **RUNNING**.
---
## Port Allocation
CCP allocates ports from 4 non-overlapping ranges to prevent conflicts between instances:
| Range | Start | End | Purpose |
|-------|-------|-----|---------|
| API | 14000 | 14999 | Express API server |
| Admin | 13000 | 13999 | React admin GUI |
| PostgreSQL | 15400 | 15499 | Database |
| Nginx | 10000 | 10999 | Reverse proxy |
Each new instance receives one port from each range. Ports are tracked in the database and released when instances are deleted.
---
## Pages Overview
### Dashboard
At-a-glance fleet status:
- Total instances, running, healthy, degraded, stopped, error counts
- Instance cards with status indicators and quick actions
### Instance List
Searchable, filterable table of all instances with status, domain, health, and creation date.
### Instance Detail
5-tab view for each instance:
| Tab | Content |
|-----|---------|
| **Overview** | Status, domain, ports, features, health summary |
| **Services** | Per-container status grid with restart and log-view actions |
| **Logs** | Real-time log viewer with service filter, tail count, and time range |
| **Backups** | Backup list with create, download, and delete actions |
| **Tunnel** | Pangolin tunnel status and configuration |
### Backups
Cross-instance backup management:
- All backups in one table with instance filter
- Stats: total count, total size, last backup time
- "Backup All Running" bulk action
- Download and delete individual archives
### Audit Log
Filterable activity trail with 18 action types:
- Instance lifecycle: CREATE, UPDATE, DELETE, START, STOP, RESTART, UPGRADE
- Backups: CREATE, DELETE
- Tunnel: PANGOLIN_SETUP, PANGOLIN_SYNC
- Users: LOGIN, CREATE, UPDATE, DELETE
- Settings: UPDATE
Each entry includes timestamp, user, action, instance, IP address, and details (expandable JSON).
### Settings
CCP-level configuration:
- Port ranges
- Pangolin credentials
- Default feature flags for new instances
- Health check interval
- Backup retention period
---
## Roles
| Role | Capabilities |
|------|-------------|
| **SUPER_ADMIN** | Full access: create/delete instances, manage users, view secrets, delete backups |
| **OPERATOR** | Manage instances: create, start/stop/restart, backups, health checks |
| **VIEWER** | Read-only: view instances, logs, health, backups, audit log |
---
## Security
- **JWT authentication** with 15-minute access tokens and 7-day refresh tokens (atomic rotation)
- **AES-256-GCM encryption** for instance secrets stored in the database
- **Audit logging** on all operations with IP address capture
- **Role-based access control** on all API endpoints
- **Docker socket access** restricted to the CCP API container only
---
## Next Steps
- [Services Overview](services.md) — learn about the services CCP provisions for each instance
- [Updates & Upgrades](upgrades.md) — upgrading CML instances
- [Deployment](../deployment/index.md) — production setup with tunneling and SSL

View File

@ -33,6 +33,7 @@ Visit **Settings** (`/app/settings`) to:
- Choose theme colors for admin and public interfaces
- Enable feature modules (campaigns, map, media, payments, etc.)
- Configure email delivery (MailHog for testing, production SMTP for live use)
- Check the **System** tab to verify your installation and check for updates
---
@ -75,6 +76,8 @@ Share the shifts page link or generate QR codes for in-person events. Volunteers
## Next Steps
- [Services Overview](services.md) — complete catalog of all 30+ Docker services
- [Updates & Upgrades](upgrades.md) — keep your installation current
- [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 tunneling and SSL

View File

@ -16,132 +16,73 @@ This guide walks you through installing Changemaker Lite, running your first dep
- At least 2 GB RAM and 10 GB disk space
- A domain name (optional, but recommended for production)
## Installation
### 1. Clone the Repository
## Quick Start
```bash
git clone https://gitea.bnkops.com/admin/changemaker.lite
cd changemaker.lite
git checkout v2
```
### 2. Run the Configuration Wizard
The fastest way to get a working `.env` file is the interactive configuration wizard:
```bash
bash config.sh
docker compose up -d
```
The wizard walks you through each step:
| Step | What it does |
|------|-------------|
| **Prerequisites check** | Verifies Docker, Docker Compose, and OpenSSL are installed |
| **Domain** | Sets your root domain and updates all subdomain references (nginx, Gitea, n8n, MkDocs, etc.) |
| **Admin credentials** | Prompts for the initial super-admin email and password (enforces 12+ chars, uppercase, lowercase, digit) |
| **Secret generation** | Auto-generates 16 unique secrets &mdash; JWT keys, encryption key, database passwords, Redis password, API tokens |
| **SMTP** | Optionally configures production SMTP (defaults to MailHog for development) |
| **Feature flags** | Enable/disable Media Manager and Listmonk newsletter sync |
| **Pangolin tunnel** | Optionally configures tunnel credentials for public access |
| **CORS** | Auto-sets allowed origins based on your domain |
| **Homepage** | Generates `configs/homepage/services.yaml` with all service links for your domain |
| **Permissions** | Creates required directories and sets container-friendly permissions |
After completion you'll have a fully populated `.env` with no placeholder passwords remaining.
!!! tip "Already have a `.env`?"
If a `.env` file exists, the wizard offers to back it up before creating a fresh one, or update values in place.
??? example "What the wizard looks like"
```
██████╗██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗
██╔════╝██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝
██║ ███████║███████║██╔██╗ ██║██║ ███╗█████╗
██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝
╚██████╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝
███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗
████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗
██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝
██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗
██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
V2 Configuration Wizard
[INFO] This wizard will create your .env file, generate secure secrets,
[INFO] and prepare your system to run the full Changemaker Lite stack.
```
### 3. Manual Setup (Alternative)
If you prefer to configure things by hand:
```bash
cp .env.example .env
```
Then edit `.env` and at minimum set these values:
```bash
V2_POSTGRES_PASSWORD=<strong password>
REDIS_PASSWORD=<strong password>
JWT_ACCESS_SECRET=<openssl rand -hex 32>
JWT_REFRESH_SECRET=<openssl rand -hex 32>
ENCRYPTION_KEY=<openssl rand -hex 32>
INITIAL_ADMIN_PASSWORD=<12+ chars, mixed case + digit>
```
See [Environment Variables](environment-variables.md) for every available option.
### 4. Start Services
```bash
# Start core services
docker compose up -d v2-postgres redis api admin
# Run database migrations and seed the initial admin account
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed
```
### 5. Log In
Open **http://localhost:3000** and sign in with the admin email and password you configured.
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.
!!! warning "Change your password"
If you used the wizard's generated password, change it immediately from the admin dashboard.
## Optional Services
For the full setup walkthrough, see [Installation](installation.md).
Once the core is running, add more services as needed:
## Configuration Wizard
```bash
# Reverse proxy (required for subdomain routing)
docker compose up -d nginx
The `config.sh` wizard produces a fully populated `.env` file in **14 steps**:
# Video library
docker compose up -d media-api
| Step | What It Does |
|------|-------------|
| **1. Prerequisites** | Verifies Docker, Docker Compose, and OpenSSL |
| **2. Environment file** | Creates `.env` from `.env.example` (backs up existing) |
| **3. Domain** | Sets root domain + 14 derived variables, updates mkdocs.yml |
| **4. Admin credentials** | Email + password (enforces 12+ chars, mixed case, digit) |
| **5. Secrets** | Auto-generates 21 unique secrets (JWT, encryption, database, service passwords) |
| **6. Email** | MailHog (dev) or production SMTP, optionally shared with Listmonk |
| **7. Feature flags** | 9 toggles: Media, Listmonk, Payments, Chat, Events, Meet, SMS, Docs Comments, Bunker Ops |
| **8. Tunnel** | Pangolin credentials for secure public access |
| **9. CORS** | Auto-calculated allowed origins from domain |
| **10. Nginx** | Renders `.conf.template` files with domain substitution |
| **11. Homepage** | Generates `services.yaml` with 27 service entries |
| **12. Permissions** | Creates 12 directories with container-friendly permissions |
| **13. Upgrade watcher** | Installs systemd units for GUI-triggered upgrades (optional, requires sudo) |
| **14. Summary** | Displays configuration summary + next steps |
# Newsletters
docker compose up -d listmonk-app
See [Installation](installation.md) for detailed documentation of each step.
# Service dashboard
docker compose up -d homepage
## Services
# All services at once
docker compose up -d
Changemaker Lite includes **30+ Docker services** organized into 8 categories:
# Monitoring stack (Prometheus, Grafana, Alertmanager)
docker compose --profile monitoring up -d
```
| Category | Services | Startup |
|----------|----------|---------|
| **Core** | API, Admin, PostgreSQL, Redis, Nginx | `docker compose up -d v2-postgres redis api admin nginx` |
| **Media** | Fastify media API | `docker compose up -d media-api` |
| **Communication** | Rocket.Chat, Gancio, Jitsi Meet | Individual `docker compose up -d` commands |
| **Newsletter & Email** | Listmonk, MailHog | `docker compose up -d listmonk-app` |
| **Developer Tools** | Code Server, MkDocs, Gitea, NocoDB, n8n | Individual `docker compose up -d` commands |
| **Utilities** | Mini QR, Excalidraw, Vaultwarden, Homepage | `docker compose up -d mini-qr excalidraw vaultwarden homepage` |
| **Monitoring** | Prometheus, Grafana, Alertmanager, exporters | `docker compose --profile monitoring up -d` |
| **Infrastructure** | Newt tunnel, Docker socket proxy | Auto-starts with tunnel configuration |
See [Services Overview](services.md) for the complete catalog with ports, feature flags, and detailed descriptions.
## Next Steps
- [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

View File

@ -1,12 +1,12 @@
---
title: Installation
description: System requirements, installation methods, and initial service startup.
description: System requirements, configuration wizard walkthrough, and initial service startup.
icon: material/download
---
# Installation
Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose.
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.
---
@ -15,8 +15,8 @@ Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compo
- **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)
- At least **2 GB RAM** for core services, **4 GB** for the full stack
- A domain name (optional for development, recommended for production)
---
@ -31,31 +31,294 @@ git checkout v2
# Run the configuration wizard
bash config.sh
# Start core services
docker compose up -d v2-postgres redis api admin
# Run database migrations and seed
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed
# Start all services
docker compose up -d
```
Open **http://localhost:3000** and sign in with the admin credentials you configured.
Open **http://localhost:3000** and sign in with the admin credentials you configured. Database migrations and seeding run automatically on first startup.
!!! warning "Change your password"
If you used the wizard's generated password, change it immediately from the admin dashboard.
---
## Configuration Wizard
## Configuration Wizard (`config.sh`)
The `config.sh` wizard walks you through domain setup, admin credentials, secret generation, SMTP config, feature flags, and Pangolin tunnel setup. After completion you'll have a fully populated `.env` with no placeholder passwords.
The wizard performs **14 steps** to produce a fully configured `.env` file and prepare the system for startup. Each step is interactive with sensible defaults.
### Step 1: Prerequisites Check
Verifies that Docker, Docker Compose v2, and OpenSSL are installed. Exits immediately if any are missing, with links to installation guides.
### Step 2: Environment File Setup
- If no `.env` exists, copies `.env.example` as the starting point
- If `.env` already exists, offers to **back it up** (timestamped copy) and create a fresh one, or update values in place
### Step 3: Domain Configuration
Prompts for your root domain (default: `cmlite.org`) and derives **14 environment variables** from it:
| Variable | Example Value |
|----------|--------------|
| `DOMAIN` | `example.org` |
| `BASE_DOMAIN` | `https://example.org` |
| `GITEA_ROOT_URL` | `https://git.example.org` |
| `GITEA_DOMAIN` | `git.example.org` |
| `N8N_HOST` | `n8n.example.org` |
| `SMTP_FROM` | `noreply@example.org` |
| `INITIAL_ADMIN_EMAIL` | `admin@example.org` |
| `NC_ADMIN_EMAIL` | `admin@example.org` |
| `EXCALIDRAW_WS_URL` | `wss://draw.example.org` |
| `LISTMONK_SMTP_FROM` | `Changemaker Lite <noreply@example.org>` |
| `HOMEPAGE_VAR_BASE_URL` | `https://example.org` |
| `VAULTWARDEN_DOMAIN` | `https://vault.example.org` |
| `GANCIO_BASE_URL` | `https://events.example.org` |
| `TEST_EMAIL_RECIPIENT` | `admin@example.org` |
Also updates `mkdocs/mkdocs.yml` with the new `site_url` and `repo_url`, and asks whether this is a **production deployment** (sets `NODE_ENV=production`).
### Step 4: Admin Credentials
Prompts for the initial super-admin email and password. The password is validated against the security policy:
- Minimum **12 characters**
- At least one **uppercase** letter
- At least one **lowercase** letter
- At least one **digit**
- Requires password confirmation
### Step 5: Secret Generation
Auto-generates **21 unique secrets** — no placeholder passwords remain after this step:
| Category | Count | Secrets |
|----------|-------|---------|
| JWT & Encryption | 3 | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `ENCRYPTION_KEY` (64-char hex) |
| Database | 2 | `V2_POSTGRES_PASSWORD`, `REDIS_PASSWORD` (24-char alphanumeric) |
| Listmonk | 3 | `LISTMONK_DB_PASSWORD`, `LISTMONK_WEB_ADMIN_PASSWORD`, `LISTMONK_API_TOKEN` |
| NocoDB | 1 | `NC_ADMIN_PASSWORD` |
| Gitea | 2 | `GITEA_DB_PASSWD`, `GITEA_DB_ROOT_PASSWORD` |
| n8n | 2 | `N8N_ENCRYPTION_KEY`, `N8N_USER_PASSWORD` |
| Monitoring | 2 | `GRAFANA_ADMIN_PASSWORD`, `GOTIFY_ADMIN_PASSWORD` |
| Vaultwarden | 1 | `VAULTWARDEN_ADMIN_TOKEN` (64-char hex) |
| Rocket.Chat | 1 | `ROCKETCHAT_ADMIN_PASSWORD` |
| Gancio | 1 | `GANCIO_ADMIN_PASSWORD` |
| Jitsi Meet | 3 | `JITSI_APP_SECRET` (64-char hex), `JITSI_JICOFO_AUTH_PASSWORD`, `JITSI_JVB_AUTH_PASSWORD` |
### Step 6: Email Configuration
Choose between:
- **MailHog** (default) — captures all outgoing emails at `http://localhost:8025` for development
- **Production SMTP** — configures host, port, user, and password. Optionally shares credentials with Listmonk for newsletter delivery
### Step 7: Feature Flags
Enable or disable 9 optional platform features:
| Flag | Environment Variable | What It Enables |
|------|---------------------|-----------------|
| **Media Manager** | `ENABLE_MEDIA_FEATURES=true` | Video library, analytics, scheduled publishing |
| **Listmonk Sync** | `LISTMONK_SYNC_ENABLED=true` | Newsletter subscriber sync from platform participants |
| **Payments** | `ENABLE_PAYMENTS=true` | Stripe-based products, donations, and plans |
| **Rocket.Chat** | `ENABLE_CHAT=true` | Team communication platform |
| **Gancio Events** | `GANCIO_SYNC_ENABLED=true` | Shift-to-event sync with Gancio |
| **Jitsi Meet** | `ENABLE_MEET=true` | Video conferencing (also prompts for server public IP) |
| **SMS Campaigns** | `ENABLE_SMS=true` | Termux Android bridge for SMS (also prompts for API URL) |
| **Docs Comments** | `GITEA_COMMENTS_ENABLED=true` | Gitea-backed page comments on documentation |
| **Bunker Ops** | `BUNKER_OPS_ENABLED=true` | Fleet metrics push to central server (also prompts for remote write URL) |
### Step 8: Tunnel Configuration (Pangolin)
Optionally configures Pangolin tunnel credentials for secure public access:
- `PANGOLIN_API_URL` — API endpoint (default: `https://api.bnkserve.org/v1`)
- `PANGOLIN_API_KEY` — Authentication key
- `PANGOLIN_ORG_ID` — Organization identifier
Complete tunnel setup is done from the admin GUI at **Settings > Tunnel** after services are running.
### Step 9: CORS Origins
Automatically calculates allowed origins from your domain:
```
http://app.DOMAIN,https://app.DOMAIN,http://DOMAIN,https://DOMAIN,http://localhost:3000,http://localhost,http://localhost:4003
```
### Step 10: Nginx Config Generation
Renders all `.conf.template` files in `nginx/conf.d/` by substituting `${DOMAIN}` with your configured domain. This produces the nginx configuration files that handle subdomain routing.
### Step 11: Homepage Services YAML
Generates `configs/homepage/services.yaml` with **27 service entries** (both production and local development URLs) for the Homepage service dashboard.
### Step 12: Container Directory Permissions
Creates and sets permissions (775) on **12 directories** needed by containers:
| Directory | Purpose |
|-----------|---------|
| `configs/code-server/.config` | Code Server configuration |
| `configs/code-server/.local` | Code Server local data |
| `mkdocs/.cache` | MkDocs build cache |
| `mkdocs/site` | MkDocs built site output |
| `assets/uploads` | Listmonk uploads |
| `assets/images` | Shared images |
| `assets/icons` | Homepage icons |
| `media/local/inbox` | Media upload inbox |
| `media/local/thumbnails` | Video thumbnails |
| `media/public` | Public media files |
| `local-files` | n8n local files |
| `data` | NAR import data |
### Step 13: Upgrade Watcher (Optional)
Installs a **systemd path watcher** that enables the admin GUI's "Check for Updates" and "Start Upgrade" buttons. This step requires `sudo` and is optional — you can install it later or use the CLI upgrade script directly.
The watcher installs two systemd units:
- `changemaker-upgrade.path` — watches for `data/upgrade/trigger.json`
- `changemaker-upgrade.service` — runs `scripts/upgrade-watcher.sh` when triggered
### Step 14: Summary & Next Steps
Displays a configuration summary showing all choices made, then prints startup commands.
---
## Manual Setup
## What Gets Modified
If you prefer to configure by hand, copy `.env.example` to `.env` and set the required values. See [Environment Variables](environment-variables.md) for every option.
After the wizard completes, the following files have been created or modified:
| File | Action |
|------|--------|
| `.env` | Created (or updated) with all configuration values |
| `mkdocs/mkdocs.yml` | Updated `site_url` and `repo_url` with domain |
| `nginx/conf.d/*.conf` | Generated from `.conf.template` files |
| `configs/homepage/services.yaml` | Generated with all service URLs |
| 12 directories | Created with container-friendly permissions |
| systemd units (optional) | Installed to `/etc/systemd/system/` |
---
## Manual Setup (Alternative)
If you prefer to configure by hand instead of using the wizard:
```bash
cp .env.example .env
```
At minimum, set these required secrets:
```bash
# Generate cryptographic secrets
V2_POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 24)
REDIS_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 24)
JWT_ACCESS_SECRET=$(openssl rand -hex 32)
JWT_REFRESH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
```
Set your admin credentials (password must meet the 12+ char complexity requirement):
```bash
INITIAL_ADMIN_EMAIL=admin@yourdomain.org
INITIAL_ADMIN_PASSWORD=YourStrongPassword1
```
Then configure optional sections:
- **Domain**: Set `DOMAIN` and all derived variables (see Step 3 table above)
- **SMTP**: Set `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_TEST_MODE=false`
- **Feature flags**: Enable features as needed (see Step 7 table above)
- **Tunnel**: Set `PANGOLIN_API_URL`, `PANGOLIN_API_KEY`, `PANGOLIN_ORG_ID`
See [Environment Variables](environment-variables.md) for every available option.
---
## Full Stack Startup
After configuration, start the entire platform:
```bash
docker compose up -d
```
That's it. Docker handles the startup order automatically:
1. **PostgreSQL** and **Redis** start first (with healthchecks)
2. **API** waits for both to be healthy, then auto-runs database migrations and seeding
3. **Admin GUI** waits for the API
4. **Nginx**, media, communication, and all other services start in parallel
5. **Init containers** (nocodb-init, listmonk-init, etc.) run once and exit
Watch the startup progress:
```bash
docker compose logs -f api --tail 20
```
Once you see `Starting server on port 4000`, open **http://localhost:3000** and log in.
### Include Monitoring
The monitoring stack (Prometheus, Grafana, Alertmanager) uses a Docker Compose profile and isn't included by default:
```bash
docker compose --profile monitoring up -d
```
### Start Only Core Services
If you prefer a minimal startup (lower resource usage):
```bash
docker compose up -d v2-postgres redis api admin nginx
```
!!! note "Manual migrations"
The API container runs migrations and seeding automatically on startup via its
entrypoint script. You only need to run them manually if you're developing
locally without Docker:
```bash
cd api && npx prisma migrate deploy && npx prisma db seed
```
See [Services Overview](services.md) for the complete service catalog.
---
## Verifying Installation
After starting services, verify everything is healthy:
```bash
# Check running containers
docker compose ps
# API health check
curl -s http://localhost:4000/api/health | python3 -m json.tool
# View API logs
docker compose logs api --tail 20
# Check for containers in restart loops
docker compose ps | grep -i restarting
```
You should see the API return `{"status":"ok"}` and all started containers in a "running" state.
---
## Next Steps
- [First Steps](first-steps.md) — explore the dashboard and create your first campaign
- [Environment Variables](environment-variables.md) — complete configuration reference
- [Services Overview](services.md) — complete service catalog with ports and startup commands
- [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 up to date

View File

@ -0,0 +1,280 @@
---
title: Services Overview
description: Complete catalog of all Docker services, ports, and startup commands.
icon: material/docker
---
# Services Overview
Changemaker Lite runs as **30+ Docker containers** orchestrated by Docker Compose. This page catalogs every service, organized by category.
!!! tip "Quick reference"
Use `docker compose ps` to see which services are currently running, or `docker compose ps -a` to include stopped containers.
---
## Core (Required)
These services form the minimum viable platform. Start them first.
| Container | Port | Description |
|-----------|------|-------------|
| `changemaker-v2-api` | 4000 | Express.js REST API (Prisma ORM) |
| `changemaker-v2-admin` | 3000 | React admin GUI (Vite + Ant Design) |
| `changemaker-v2-postgres` | 5433 | PostgreSQL 16 — primary database |
| `redis-changemaker` | 6379 | Redis 7 — cache, rate limiting, job queues |
| `changemaker-v2-nginx` | 80 | Nginx reverse proxy — subdomain routing |
```bash
# Start core services only (minimal)
docker compose up -d v2-postgres redis api admin nginx
# Or start everything at once
docker compose up -d
```
The API container automatically runs database migrations and seeding on startup via its entrypoint script.
!!! note
Nginx is technically optional for local development (you can access services directly by port), but required for production subdomain routing.
---
## Media
| Container | Port | Description | Feature Flag |
|-----------|------|-------------|-------------|
| `changemaker-media-api` | 4100 | Fastify media API — video library, analytics, scheduling | `ENABLE_MEDIA_FEATURES=true` |
```bash
docker compose up -d media-api
```
The media API runs as a separate Fastify server sharing the same PostgreSQL database. It handles video upload (FFprobe metadata extraction), scheduled publishing via BullMQ, and GDPR-compliant view analytics.
---
## Communication
### Rocket.Chat (Team Chat)
| Container | Port | Description | Feature Flag |
|-----------|------|-------------|-------------|
| `rocketchat-changemaker` | 8891 | Rocket.Chat server | `ENABLE_CHAT=true` |
| `mongodb-changemaker` | — | MongoDB (Rocket.Chat data store) | — |
| `nats-changemaker` | — | NATS (Rocket.Chat message bus) | — |
```bash
docker compose up -d rocketchat mongodb nats
```
### Gancio (Events)
| Container | Port | Description | Feature Flag |
|-----------|------|-------------|-------------|
| `gancio-changemaker` | 8092 | Gancio event platform | `GANCIO_SYNC_ENABLED=true` |
| `gancio-init` | — | Init container — creates Gancio database | — |
```bash
docker compose up -d gancio
```
!!! info "Init containers"
`gancio-init` runs once on first start to create the Gancio database in PostgreSQL, then exits. This is normal — don't worry about seeing it in a "stopped" state.
### Jitsi Meet (Video Conferencing)
| Container | Port | Description | Feature Flag |
|-----------|------|-------------|-------------|
| `jitsi-web-changemaker` | 8893 | Jitsi web interface | `ENABLE_MEET=true` |
| `jitsi-prosody-changemaker` | — | XMPP server (Prosody) | — |
| `jitsi-jicofo-changemaker` | — | Jitsi conference focus | — |
| `jitsi-jvb-changemaker` | 10000/udp | Jitsi video bridge | — |
```bash
docker compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb
```
!!! warning "Firewall requirement"
Jitsi requires **UDP port 10000** open in your firewall for video/audio media traffic. Set `JVB_ADVERTISE_IP` in `.env` to your server's public IP address.
---
## Newsletter & Email
| Container | Port | Description | Feature Flag |
|-----------|------|-------------|-------------|
| `listmonk-app` | 9001 | Listmonk newsletter platform | `LISTMONK_SYNC_ENABLED=true` |
| `listmonk-db` | 5432 | PostgreSQL (Listmonk's own database) | — |
| `listmonk-init` | — | Init container — creates API user | — |
| `mailhog-changemaker` | 8025 | MailHog email capture (development) | `EMAIL_TEST_MODE=true` |
```bash
# Newsletter platform
docker compose up -d listmonk-app
# Email testing (captures all outgoing emails)
docker compose up -d mailhog
```
Listmonk has its own PostgreSQL instance separate from the main database. The `listmonk-init` container auto-creates the API user for platform integration.
---
## Developer Tools
| Container | Port | Description |
|-----------|------|-------------|
| `code-server-changemaker` | 8888 | VS Code in the browser |
| `mkdocs-changemaker` | 4003 | MkDocs live preview (hot reload) |
| `mkdocs-site-server-changemaker` | 4004 | MkDocs static site server |
| `gitea-changemaker` | 3030 | Gitea — self-hosted Git repository |
| `gitea-db` | — | PostgreSQL (Gitea's database) |
| `changemaker-v2-nocodb` | 8091 | NocoDB — read-only database browser |
| `nocodb-init` | — | Init container — registers database |
| `n8n-changemaker` | 5678 | n8n — workflow automation |
```bash
# Start individual tools
docker compose up -d code-server
docker compose up -d mkdocs mkdocs-site-server
docker compose up -d gitea
docker compose up -d nocodb
docker compose up -d n8n
```
!!! tip
`mkdocs` (port 4003) provides live editing with hot reload for documentation authors. `mkdocs-site-server` (port 4004) serves the built static site for production visitors.
---
## Utilities
| Container | Port | Description |
|-----------|------|-------------|
| `mini-qr` | 8089 | QR code PNG generator |
| `excalidraw-changemaker` | 8090 | Collaborative whiteboard |
| `vaultwarden-changemaker` | 8445 | Vaultwarden — Bitwarden-compatible password manager |
| `vaultwarden-init` | — | Init container — configures admin settings |
| `homepage-changemaker` | 3010 | Homepage — service dashboard |
```bash
docker compose up -d mini-qr excalidraw vaultwarden homepage
```
Mini QR is used internally by walk sheets and cut export pages to generate printable QR codes.
---
## Monitoring (Docker Profile)
Monitoring services are behind a Docker Compose profile and are **not started by default**.
| Container | Port | Description |
|-----------|------|-------------|
| `prometheus-changemaker` | 9090 | Prometheus — metrics collection |
| `grafana-changemaker` | 3005 | Grafana — monitoring dashboards |
| `alertmanager-changemaker` | 9093 | Alertmanager — alert routing |
| `cadvisor-changemaker` | 8086 | cAdvisor — container metrics |
| `node-exporter-changemaker` | 9100 | Node Exporter — host system metrics |
| `redis-exporter-changemaker` | 9121 | Redis Exporter — Redis metrics |
| `gotify-changemaker` | 8889 | Gotify — push notifications |
```bash
# Start the entire monitoring stack
docker compose --profile monitoring up -d
```
The monitoring stack includes 3 pre-configured Grafana dashboards and 12 custom `cm_*` Prometheus metrics. See [Monitoring](../admin/services/monitoring.md) for details.
---
## Infrastructure
| Container | Port | Description |
|-----------|------|-------------|
| `newt` | — | Pangolin tunnel connector (Newt) |
| `docker-socket-proxy` | — | Docker socket proxy for secure container access |
```bash
# Newt starts automatically if PANGOLIN_NEWT_ID and PANGOLIN_NEWT_SECRET are set
docker compose up -d newt
```
The Newt container connects to a Pangolin tunnel server for secure public access without opening inbound ports. See [Tunnel](../admin/services/tunnel.md) for setup.
---
## Subdomain Routing
When Nginx is running, services are accessible via subdomains. The root domain serves documentation only; all application routes are at `app.DOMAIN`.
| Subdomain | Target | Purpose |
|-----------|--------|---------|
| `app.DOMAIN` | Admin (3000) | All application routes (admin, public pages, campaigns, map, shifts, media gallery) |
| `api.DOMAIN` | Express API (4000) | REST API |
| `media.DOMAIN` | Fastify Media API (4100) | Media API |
| `DOMAIN` | MkDocs Static (4004) | Documentation / marketing site |
| `db.DOMAIN` | NocoDB (8091) | Database browser |
| `docs.DOMAIN` | MkDocs Live (4003) | Live documentation preview |
| `code.DOMAIN` | Code Server (8888) | Web IDE |
| `n8n.DOMAIN` | n8n (5678) | Workflow automation |
| `git.DOMAIN` | Gitea (3030) | Git hosting |
| `home.DOMAIN` | Homepage (3010) | Service dashboard |
| `grafana.DOMAIN` | Grafana (3005) | Metrics visualization |
| `listmonk.DOMAIN` | Listmonk (9001) | Newsletter platform |
| `qr.DOMAIN` | Mini QR (8089) | QR code generator |
| `draw.DOMAIN` | Excalidraw (8090) | Collaborative whiteboard |
| `vault.DOMAIN` | Vaultwarden (8445) | Password manager |
| `events.DOMAIN` | Gancio (8092) | Event platform |
| `chat.DOMAIN` | Rocket.Chat (8891) | Team chat |
| `meet.DOMAIN` | Jitsi Meet (8893) | Video conferencing |
| `mail.DOMAIN` | MailHog (8025) | Email capture (dev) |
---
## Init Containers
Several services use **init containers** — lightweight containers that run once on first startup to bootstrap databases or configuration, then exit with code 0. This pattern is borrowed from Kubernetes.
| Init Container | Purpose |
|----------------|---------|
| `listmonk-init` | Creates the Listmonk API user for platform integration |
| `gancio-init` | Creates the Gancio database in the shared PostgreSQL instance |
| `vaultwarden-init` | Configures Vaultwarden admin settings |
| `nocodb-init` | Registers the main database with NocoDB for browsing |
Seeing these containers in a "stopped" or "exited (0)" state is completely normal.
---
## Starting Everything
To start all services at once (excluding monitoring):
```bash
docker compose up -d
```
To start everything including monitoring:
```bash
docker compose up -d && docker compose --profile monitoring up -d
```
To see what's running:
```bash
docker compose ps
```
!!! warning
Starting all services at once requires at least **4 GB RAM**. For resource-constrained environments, start only the services you need.
---
## Next Steps
- [Installation](installation.md) — setup walkthrough and configuration wizard details
- [Environment Variables](environment-variables.md) — complete `.env` reference
- [First Steps](first-steps.md) — create your first campaign and volunteer shift

View File

@ -0,0 +1,285 @@
---
title: Updates & Upgrades
description: Keep Changemaker Lite up to date via the admin GUI or command line.
icon: material/update
---
# Updates & Upgrades
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:
1. **Admin GUI** — Check for updates and run upgrades from **Settings > System**
2. **CLI** — Run `./scripts/upgrade.sh` directly from the command line
Both methods execute the same 6-phase upgrade process.
---
## Prerequisites
### Upgrade Watcher (Required for GUI Method)
The admin GUI triggers upgrades via a **systemd path watcher** that monitors for trigger files. This must be installed on the host system.
**Install during initial setup:**
The `config.sh` wizard offers to install the watcher automatically (Step 13). If you skipped it, install manually:
```bash
# Edit the systemd units to set your project path and user
sed -e "s|__PROJECT_DIR__|$(pwd)|g" scripts/systemd/changemaker-upgrade.path > /tmp/changemaker-upgrade.path
sed -e "s|__PROJECT_DIR__|$(pwd)|g" -e "s|__USER__|$(whoami)|g" scripts/systemd/changemaker-upgrade.service > /tmp/changemaker-upgrade.service
# Install and enable
sudo cp /tmp/changemaker-upgrade.path /tmp/changemaker-upgrade.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now changemaker-upgrade.path
```
**Verify it's running:**
```bash
sudo systemctl status changemaker-upgrade.path
```
!!! note "How the watcher works"
The API container writes a `trigger.json` file to a shared `data/upgrade/` volume. The systemd path watcher detects the file and runs `scripts/upgrade-watcher.sh` on the host, which dispatches to the appropriate script (check or upgrade). Progress and results are communicated back via JSON files that the API reads.
---
## Method 1: Admin GUI
### Checking for Updates
1. Navigate to **Settings** (`/app/settings`)
2. Click the **System** tab
3. Click **Check for Updates**
The system fetches from the git remote and shows:
- Current commit hash and message
- Remote commit hash (if different)
- Number of commits behind
- Changelog of incoming changes
### Starting an Upgrade
1. Review the changelog to understand what's changing
2. Click **Start Upgrade**
3. Optionally configure:
- **Skip backup** — skip the database backup phase (not recommended)
- **Pull images** — also update third-party Docker images (PostgreSQL, Redis, etc.)
- **Dry run** — preview what would happen without making changes
4. Monitor the 6-phase progress indicator
The GUI polls for progress updates and displays the current phase, percentage, and status message in real time.
---
## The 6 Upgrade Phases
Both the GUI and CLI methods execute the same 6-phase process:
| Phase | % | Name | What Happens |
|-------|---|------|-------------|
| **1** | 5% | Pre-flight Checks | Verifies Docker, git, disk space (2 GB minimum), remote reachability, and clean working directory |
| **2** | 15% | Backup | Runs `scripts/backup.sh` (pg_dump + archive), backs up user-modifiable content, saves pre-upgrade commit hash |
| **3** | 30% | Code Update | Saves user paths, stashes local changes, `git pull`, pops stash with auto-conflict resolution, detects new `.env` variables |
| **4** | 50% | Container Rebuild | Rebuilds `api`, `admin`, `media-api`; conditionally rebuilds `nginx` and `code-server` if their configs changed; optionally pulls third-party images |
| **5** | 70% | Service Restart | Stops app containers, force-recreates LSIO containers, verifies Gancio config, starts infrastructure, waits for PostgreSQL, starts API (runs migrations), starts everything else, restarts Newt tunnel and monitoring if they were running |
| **6** | 90% | Verification | Health checks for API, Admin, Media API, Gancio, MkDocs; detects containers in restart loops |
---
## What Gets Preserved
The upgrade script automatically preserves **user-modifiable paths** that you may have customized:
| Path | What It Contains |
|------|-----------------|
| `mkdocs/docs/` | Your documentation content |
| `mkdocs/mkdocs.yml` | MkDocs configuration |
| `mkdocs/site/` | Built documentation site |
| `configs/` | Prometheus, Grafana, Alertmanager, Homepage configs |
| `nginx/conf.d/services.conf` | Custom nginx service proxies |
These files are saved before `git pull` and unconditionally restored afterward, even if the pull introduces changes to them. Your versions always win.
!!! tip
The `.env` file is never touched by `git pull` (it's in `.gitignore`). However, if new environment variables are added in `.env.example`, the upgrade script automatically appends them to your `.env` with their default values and warns you to review them.
---
## Method 2: CLI
Run the upgrade script directly:
```bash
./scripts/upgrade.sh
```
### Options
| Flag | Description |
|------|-------------|
| `--skip-backup` | Skip the backup phase (requires `--force`) |
| `--pull-services` | Also pull new third-party Docker images |
| `--dry-run` | Show what would happen without executing |
| `--force` | Continue past non-critical warnings |
| `--branch BRANCH` | Git branch to pull (default: current branch) |
| `--rollback` | Rollback to pre-upgrade commit |
| `--api-mode` | Write progress/result JSON for admin GUI (used internally) |
### Examples
```bash
# Standard upgrade
./scripts/upgrade.sh
# Preview changes without executing
./scripts/upgrade.sh --dry-run
# Full upgrade including third-party image updates
./scripts/upgrade.sh --pull-services
# Rollback to the last pre-upgrade state
./scripts/upgrade.sh --rollback
```
---
## Rollback
### Automatic Rollback
If the upgrade fails at any phase, the script prints detailed rollback instructions including the pre-upgrade commit hash. Use the `--rollback` flag:
```bash
./scripts/upgrade.sh --rollback
```
This:
1. Finds the latest backup archive
2. Extracts the pre-upgrade commit hash from `git-commit.txt` inside the archive
3. Checks out that commit
4. Rebuilds and restarts all containers
!!! warning
`--rollback` restores the **code** to the pre-upgrade state but does **not** automatically restore the database. If database migrations were applied during the failed upgrade, you may need to manually restore from the backup archive.
### Manual Rollback
```bash
# 1. Restore code
cd /path/to/changemaker.lite
git checkout <pre-upgrade-commit-hash>
# 2. Rebuild and restart
docker compose build api admin media-api
docker compose up -d
# 3. Database restore (if needed — destructive!)
ls -lt backups/changemaker-v2-backup-*.tar.gz | head -5
tar xzf backups/<backup>.tar.gz -C /tmp
gunzip -c /tmp/<backup>/v2-postgres.sql.gz | \
docker exec -i changemaker-v2-postgres psql -U changemaker -d changemaker_v2
```
---
## New Environment Variables
When upstream code adds new environment variables to `.env.example`, the upgrade script automatically:
1. Compares `.env.example` against your `.env`
2. Appends any missing variables with their default values
3. Warns you to review the new additions
```
[WARN] New env vars added to .env (review defaults):
NEW_FEATURE_FLAG
NEW_API_KEY
```
Always review new variables after an upgrade — some may need manual configuration.
---
## Update Checker
A separate lightweight script checks for available updates without performing any changes:
```bash
./scripts/upgrade-check.sh
```
This writes `data/upgrade/status.json` with:
- Current and remote commit hashes
- Number of commits behind
- Changelog (last 30 commits)
- Timestamp of last check
The admin GUI reads this file to display update availability.
---
## Troubleshooting
### Stale Progress Indicator
If the GUI shows an upgrade "in progress" but nothing is happening, the upgrade script may have crashed. The system automatically detects stale progress (no update for 10+ minutes) and treats it as not running.
To manually clear:
```bash
rm -f data/upgrade/progress.json
```
### Merge Conflicts
If `git pull` encounters merge conflicts in **user-modifiable paths** (docs, configs), the upgrade script auto-resolves by keeping your version. If conflicts occur in **project-owned files** (api/, admin/), the upgrade fails and asks you to resolve manually.
### Lock File
The upgrade script uses `.upgrade.lock` to prevent concurrent upgrades. If a previous upgrade crashed without cleaning up:
```bash
# Verify no upgrade is actually running
ps aux | grep upgrade.sh
# Remove stale lock
rm -f .upgrade.lock
```
### Health Check Failures
If Phase 6 health checks fail, services may still be starting. Wait 1-2 minutes and check manually:
```bash
# API health
curl -s http://localhost:4000/api/health
# Container status
docker compose ps
# Recent logs
docker compose logs api --tail 50
docker compose logs admin --tail 50
```
### Systemd Watcher Not Triggering
```bash
# Check watcher status
sudo systemctl status changemaker-upgrade.path
# Check service logs
sudo journalctl -u changemaker-upgrade.service --tail 20
# Re-enable if stopped
sudo systemctl enable --now changemaker-upgrade.path
```

View File

@ -11,19 +11,57 @@
</div>
<div class="cm-header-nav__links">
<div class="cm-header-nav__links-inner">
<a href="#" data-path="/" class="cm-header-nav__link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
<a href="#" data-path="/home" class="cm-header-nav__link" data-nav-id="home"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span class="cm-header-nav__label">Campaigns</span></a>
<a href="#" data-path="/shifts" class="cm-header-nav__link" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span class="cm-header-nav__label">Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span class="cm-header-nav__label">Events</span></a>
<a href="#" data-path="/map" class="cm-header-nav__link" data-nav-id="map"><span class="material-icons-outlined">place</span><span class="cm-header-nav__label">Map</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">apps</span>
<span class="cm-header-nav__label">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/shifts" class="cm-header-nav__dropdown-item" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__dropdown-item" data-nav-id="events"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__dropdown-item" data-nav-id="polls"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
<a href="#" data-path="/events/tickets" class="cm-header-nav__dropdown-item" data-nav-id="tickets"><span class="material-icons-outlined">sell</span><span>Tickets</span></a>
<a href="#" data-path="/meet" class="cm-header-nav__dropdown-item" data-nav-id="meet"><span class="material-icons-outlined">videocam</span><span>Meet</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span class="cm-header-nav__label">Gallery</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span class="cm-header-nav__label">Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span class="cm-header-nav__label">Donate</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">account_balance_wallet</span>
<span class="cm-header-nav__label">Commerce</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/pricing" class="cm-header-nav__dropdown-item" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__dropdown-item" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__dropdown-item" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span class="cm-header-nav__label">Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span class="cm-header-nav__label">Pages</span></a>
<a href="/" class="cm-header-nav__link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span class="cm-header-nav__label">Website</span></a>
<a href="/docs/" class="cm-header-nav__link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span class="cm-header-nav__label">Docs</span></a>
<a href="#" data-path="/app" class="cm-header-nav__link">
<span class="material-icons-outlined">dashboard</span>
<span class="cm-header-nav__label">Admin</span>
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
<span class="material-icons-outlined">login</span>
<span class="cm-header-nav__label">Sign In</span>
</a>
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">person</span>
<span class="cm-header-nav__label">Admin</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
<span class="material-icons-outlined">menu</span>
@ -38,19 +76,57 @@
</button>
</div>
<div class="cm-header-nav__mobile-links">
<a href="#" data-path="/" class="cm-header-nav__mobile-link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span>Home</span></a>
<a href="#" data-path="/home" class="cm-header-nav__mobile-link" data-nav-id="home"><span class="material-icons-outlined">home</span><span>Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__mobile-link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span>Campaigns</span></a>
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span>Events</span></a>
<a href="#" data-path="/map" class="cm-header-nav__mobile-link" data-nav-id="map"><span class="material-icons-outlined">place</span><span>Map</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="scheduling">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">apps</span>
<span style="flex:1">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts" style="padding-left:48px"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" style="padding-left:48px"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__mobile-link" data-nav-id="polls" style="padding-left:48px"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
<a href="#" data-path="/events/tickets" class="cm-header-nav__mobile-link" data-nav-id="tickets" style="padding-left:48px"><span class="material-icons-outlined">sell</span><span>Tickets</span></a>
<a href="#" data-path="/meet" class="cm-header-nav__mobile-link" data-nav-id="meet" style="padding-left:48px"><span class="material-icons-outlined">videocam</span><span>Meet</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__mobile-link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span>Gallery</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="commerce">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">account_balance_wallet</span>
<span style="flex:1">Commerce</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/pricing" class="cm-header-nav__mobile-link" data-nav-id="pricing" style="padding-left:48px"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop" style="padding-left:48px"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate" style="padding-left:48px"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__mobile-link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span>Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__mobile-link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span>Pages</span></a>
<a href="/" class="cm-header-nav__mobile-link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span>Website</span></a>
<a href="/docs/" class="cm-header-nav__mobile-link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span>Docs</span></a>
<a href="#" data-path="/app" class="cm-header-nav__mobile-link">
<span class="material-icons-outlined">dashboard</span>
<span>Admin</span>
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
<span class="material-icons-outlined">login</span>
<span>Sign In</span>
</a>
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">person</span>
<span style="flex:1">Admin</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
</div>
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
@ -59,7 +135,7 @@
var h = location.hostname;
var base;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port }} || 3000);
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port | default(0) }} || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
@ -89,26 +165,84 @@
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
// Mobile group expand/collapse toggles
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
trigger.addEventListener('click', function() {
var group = this.closest('.cm-header-nav__mobile-group');
var children = group.querySelector('.cm-header-nav__mobile-group-children');
var isExpanded = group.classList.contains('expanded');
if (isExpanded) {
group.classList.remove('expanded');
children.style.display = 'none';
} else {
group.classList.add('expanded');
children.style.display = 'block';
}
});
});
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
// Uses hidden iframe + postMessage to read auth state from the app's origin.
function showAdminMenu() {
var s1 = document.getElementById('cm-signin-link');
var s2 = document.getElementById('cm-mobile-signin-link');
var a1 = document.getElementById('cm-admin-dropdown');
var a2 = document.getElementById('cm-mobile-admin-group');
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = 'none';
if (a1) a1.style.display = '';
if (a2) a2.style.display = '';
}
// 1. Same-origin check (works when MkDocs served from same origin as app)
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
showAdminMenu();
}
}
} catch(e) {}
// 2. Cross-origin check via hidden iframe + postMessage
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = base + '/auth-check.html';
window.addEventListener('message', function(event) {
if (event.origin !== base) return;
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
showAdminMenu();
}
});
document.body.appendChild(iframe);
})();
</script>
<style>
.md-banner {
background: linear-gradient(158deg, rgb(0,31,156) 0%, rgb(0,68,204) 100%) !important;
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%) !important;
color: #ffffff !important;
padding: 0 !important;
overflow: visible !important;
border: none !important;
box-shadow: none !important;
}
.md-banner__inner {
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: linear-gradient(158deg, rgb(0,31,156) 0%, rgb(0,68,204) 100%);
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 10;
position: relative;
z-index: 100;
box-sizing: border-box;
}
.cm-header-nav a {
@ -171,6 +305,62 @@
.cm-header-nav__hamburger .material-icons-outlined {
font-size: 24px;
}
/* Desktop dropdown menus */
.cm-header-nav__dropdown {
position: relative;
display: inline-flex;
align-items: center;
}
.cm-header-nav__dropdown-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
font-size: 14px;
transition: transform 0.2s;
}
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
transform: rotate(180deg);
}
.cm-header-nav__dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: #1b2838;
border-radius: 8px;
padding: 6px 0;
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
z-index: 100;
margin-top: 4px;
}
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
display: block;
}
.cm-header-nav__dropdown-menu--right {
left: auto;
right: 0;
}
.cm-header-nav__dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s;
}
.cm-header-nav__dropdown-item:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__dropdown-item .material-icons-outlined {
font-size: 16px;
}
/* Mobile drawer */
.cm-header-nav__mobile-drawer {
position: fixed;
@ -231,6 +421,21 @@
.cm-header-nav__mobile-link .material-icons-outlined {
font-size: 18px;
}
/* Mobile group expand/collapse */
.cm-header-nav__mobile-group-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__mobile-chevron {
font-size: 14px !important;
transition: transform 0.2s;
}
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
transform: rotate(180deg);
}
.cm-header-nav__mobile-group-children {
display: none;
}
.cm-header-nav__mobile-overlay {
display: none;
position: fixed;
@ -248,6 +453,7 @@
.cm-header-nav { padding: 0 16px; }
.cm-header-nav__links-inner { display: none; }
.cm-header-nav__hamburger { display: block; }
.cm-header-nav__dropdown-menu { display: none !important; }
}
</style>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "main.html" %}
{% block content %}
<style>
* { box-sizing: border-box; } body {margin: 0;}#i25w{padding:10px;}
</style>
<body id="i7af"><div id="i25w">Insert your text here</div></body>
{% endblock %}

7
mkdocs/docs/test-page.md Normal file
View File

@ -0,0 +1,7 @@
---
template: test-page.html
hide:
- navigation
- toc
title: "Test Page"
---

View File

@ -163,8 +163,11 @@ nav:
- Getting Started:
- docs/getting-started/index.md
- Installation: docs/getting-started/installation.md
- Services Overview: docs/getting-started/services.md
- Environment Variables: docs/getting-started/environment-variables.md
- First Steps: docs/getting-started/first-steps.md
- Updates & Upgrades: docs/getting-started/upgrades.md
- Control Panel (CCP): docs/getting-started/control-panel.md
- Features at a Glance: docs/getting-started/features.md
- Admin Guide:
- docs/admin/index.md

View File

@ -168,19 +168,57 @@
</div>
<div class="cm-header-nav__links">
<div class="cm-header-nav__links-inner">
<a href="#" data-path="/" class="cm-header-nav__link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
<a href="#" data-path="/home" class="cm-header-nav__link" data-nav-id="home"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span class="cm-header-nav__label">Campaigns</span></a>
<a href="#" data-path="/shifts" class="cm-header-nav__link" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span class="cm-header-nav__label">Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span class="cm-header-nav__label">Events</span></a>
<a href="#" data-path="/map" class="cm-header-nav__link" data-nav-id="map"><span class="material-icons-outlined">place</span><span class="cm-header-nav__label">Map</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">apps</span>
<span class="cm-header-nav__label">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/shifts" class="cm-header-nav__dropdown-item" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__dropdown-item" data-nav-id="events"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__dropdown-item" data-nav-id="polls"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
<a href="#" data-path="/events/tickets" class="cm-header-nav__dropdown-item" data-nav-id="tickets"><span class="material-icons-outlined">sell</span><span>Tickets</span></a>
<a href="#" data-path="/meet" class="cm-header-nav__dropdown-item" data-nav-id="meet"><span class="material-icons-outlined">videocam</span><span>Meet</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span class="cm-header-nav__label">Gallery</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span class="cm-header-nav__label">Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span class="cm-header-nav__label">Donate</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">account_balance_wallet</span>
<span class="cm-header-nav__label">Commerce</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/pricing" class="cm-header-nav__dropdown-item" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__dropdown-item" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__dropdown-item" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span class="cm-header-nav__label">Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span class="cm-header-nav__label">Pages</span></a>
<a href="/" class="cm-header-nav__link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span class="cm-header-nav__label">Website</span></a>
<a href="/docs/" class="cm-header-nav__link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span class="cm-header-nav__label">Docs</span></a>
<a href="#" data-path="/app" class="cm-header-nav__link">
<span class="material-icons-outlined">dashboard</span>
<span class="cm-header-nav__label">Admin</span>
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
<span class="material-icons-outlined">login</span>
<span class="cm-header-nav__label">Sign In</span>
</a>
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">person</span>
<span class="cm-header-nav__label">Admin</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
<span class="material-icons-outlined">menu</span>
@ -195,19 +233,57 @@
</button>
</div>
<div class="cm-header-nav__mobile-links">
<a href="#" data-path="/" class="cm-header-nav__mobile-link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span>Home</span></a>
<a href="#" data-path="/home" class="cm-header-nav__mobile-link" data-nav-id="home"><span class="material-icons-outlined">home</span><span>Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__mobile-link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span>Campaigns</span></a>
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span>Events</span></a>
<a href="#" data-path="/map" class="cm-header-nav__mobile-link" data-nav-id="map"><span class="material-icons-outlined">place</span><span>Map</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="scheduling">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">apps</span>
<span style="flex:1">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts" style="padding-left:48px"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" style="padding-left:48px"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__mobile-link" data-nav-id="polls" style="padding-left:48px"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
<a href="#" data-path="/events/tickets" class="cm-header-nav__mobile-link" data-nav-id="tickets" style="padding-left:48px"><span class="material-icons-outlined">sell</span><span>Tickets</span></a>
<a href="#" data-path="/meet" class="cm-header-nav__mobile-link" data-nav-id="meet" style="padding-left:48px"><span class="material-icons-outlined">videocam</span><span>Meet</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__mobile-link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span>Gallery</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="commerce">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">account_balance_wallet</span>
<span style="flex:1">Commerce</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/pricing" class="cm-header-nav__mobile-link" data-nav-id="pricing" style="padding-left:48px"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop" style="padding-left:48px"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate" style="padding-left:48px"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__mobile-link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span>Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__mobile-link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span>Pages</span></a>
<a href="/" class="cm-header-nav__mobile-link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span>Website</span></a>
<a href="/docs/" class="cm-header-nav__mobile-link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span>Docs</span></a>
<a href="#" data-path="/app" class="cm-header-nav__mobile-link">
<span class="material-icons-outlined">dashboard</span>
<span>Admin</span>
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
<span class="material-icons-outlined">login</span>
<span>Sign In</span>
</a>
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">person</span>
<span style="flex:1">Admin</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
</div>
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
@ -246,26 +322,84 @@
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
// Mobile group expand/collapse toggles
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
trigger.addEventListener('click', function() {
var group = this.closest('.cm-header-nav__mobile-group');
var children = group.querySelector('.cm-header-nav__mobile-group-children');
var isExpanded = group.classList.contains('expanded');
if (isExpanded) {
group.classList.remove('expanded');
children.style.display = 'none';
} else {
group.classList.add('expanded');
children.style.display = 'block';
}
});
});
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
// Uses hidden iframe + postMessage to read auth state from the app's origin.
function showAdminMenu() {
var s1 = document.getElementById('cm-signin-link');
var s2 = document.getElementById('cm-mobile-signin-link');
var a1 = document.getElementById('cm-admin-dropdown');
var a2 = document.getElementById('cm-mobile-admin-group');
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = 'none';
if (a1) a1.style.display = '';
if (a2) a2.style.display = '';
}
// 1. Same-origin check (works when MkDocs served from same origin as app)
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
showAdminMenu();
}
}
} catch(e) {}
// 2. Cross-origin check via hidden iframe + postMessage
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = base + '/auth-check.html';
window.addEventListener('message', function(event) {
if (event.origin !== base) return;
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
showAdminMenu();
}
});
document.body.appendChild(iframe);
})();
</script>
<style>
.md-banner {
background: linear-gradient(158deg, rgb(0,31,156) 0%, rgb(0,68,204) 100%) !important;
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%) !important;
color: #ffffff !important;
padding: 0 !important;
overflow: visible !important;
border: none !important;
box-shadow: none !important;
}
.md-banner__inner {
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: linear-gradient(158deg, rgb(0,31,156) 0%, rgb(0,68,204) 100%);
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 10;
position: relative;
z-index: 100;
box-sizing: border-box;
}
.cm-header-nav a {
@ -328,6 +462,62 @@
.cm-header-nav__hamburger .material-icons-outlined {
font-size: 24px;
}
/* Desktop dropdown menus */
.cm-header-nav__dropdown {
position: relative;
display: inline-flex;
align-items: center;
}
.cm-header-nav__dropdown-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
font-size: 14px;
transition: transform 0.2s;
}
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
transform: rotate(180deg);
}
.cm-header-nav__dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: #1b2838;
border-radius: 8px;
padding: 6px 0;
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
z-index: 100;
margin-top: 4px;
}
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
display: block;
}
.cm-header-nav__dropdown-menu--right {
left: auto;
right: 0;
}
.cm-header-nav__dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s;
}
.cm-header-nav__dropdown-item:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__dropdown-item .material-icons-outlined {
font-size: 16px;
}
/* Mobile drawer */
.cm-header-nav__mobile-drawer {
position: fixed;
@ -388,6 +578,21 @@
.cm-header-nav__mobile-link .material-icons-outlined {
font-size: 18px;
}
/* Mobile group expand/collapse */
.cm-header-nav__mobile-group-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__mobile-chevron {
font-size: 14px !important;
transition: transform 0.2s;
}
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
transform: rotate(180deg);
}
.cm-header-nav__mobile-group-children {
display: none;
}
.cm-header-nav__mobile-overlay {
display: none;
position: fixed;
@ -405,6 +610,7 @@
.cm-header-nav { padding: 0 16px; }
.cm-header-nav__links-inner { display: none; }
.cm-header-nav__hamburger { display: block; }
.cm-header-nav__dropdown-menu { display: none !important; }
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 23,
"updated_at": "2026-03-03T14:22:46-07:00",
"updated_at": "2026-03-05T12:20:58-07: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-03-03T14:22:46-07:00"
"last_build_update": "2026-03-05T12:20:58-07:00"
}

View File

@ -4,13 +4,13 @@
"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": 73218,
"forks_count": 5806,
"open_issues_count": 5500,
"updated_at": "2026-03-03T21:40:58Z",
"stars_count": 74943,
"forks_count": 6013,
"open_issues_count": 5785,
"updated_at": "2026-03-07T19:48:10Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-02T16:38:30Z"
"last_build_update": "2026-03-07T00:12:45Z"
}

View File

@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 76454,
"forks_count": 6532,
"open_issues_count": 174,
"updated_at": "2026-03-03T21:35:43Z",
"stars_count": 76519,
"forks_count": 6539,
"open_issues_count": 169,
"updated_at": "2026-03-07T18:20:51Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-03T21:35:38Z"
"last_build_update": "2026-03-06T12:59:10Z"
}

View File

@ -4,13 +4,13 @@
"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": 28705,
"stars_count": 28761,
"forks_count": 1808,
"open_issues_count": 6,
"updated_at": "2026-03-03T21:17:50Z",
"open_issues_count": 1,
"updated_at": "2026-03-07T19:52:28Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",
"default_branch": "dev",
"last_build_update": "2026-03-03T12:22:06Z"
"last_build_update": "2026-03-07T15:45:18Z"
}

View File

@ -4,13 +4,13 @@
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
"html_url": "https://github.com/go-gitea/gitea",
"language": "Go",
"stars_count": 54043,
"forks_count": 6420,
"open_issues_count": 2841,
"updated_at": "2026-03-03T21:25:00Z",
"stars_count": 54164,
"forks_count": 6434,
"open_issues_count": 2847,
"updated_at": "2026-03-07T18:54:25Z",
"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",
"default_branch": "main",
"last_build_update": "2026-03-03T19:24:00Z"
"last_build_update": "2026-03-07T05:30:59Z"
}

View File

@ -4,13 +4,13 @@
"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": 19177,
"forks_count": 1945,
"open_issues_count": 115,
"updated_at": "2026-03-03T21:29:08Z",
"stars_count": 19208,
"forks_count": 1946,
"open_issues_count": 99,
"updated_at": "2026-03-07T18:41:21Z",
"created_at": "2019-06-26T05:08:39Z",
"clone_url": "https://github.com/knadh/listmonk.git",
"ssh_url": "git@github.com:knadh/listmonk.git",
"default_branch": "master",
"last_build_update": "2026-03-03T03:44:33Z"
"last_build_update": "2026-03-07T18:41:17Z"
}

View File

@ -4,13 +4,13 @@
"description": "Create & scan cute qr codes easily \ud83d\udc7e",
"html_url": "https://github.com/lyqht/mini-qr",
"language": "Vue",
"stars_count": 1883,
"forks_count": 240,
"stars_count": 1896,
"forks_count": 238,
"open_issues_count": 21,
"updated_at": "2026-03-03T15:21:16Z",
"updated_at": "2026-03-07T17:03:03Z",
"created_at": "2023-04-21T14:20:14Z",
"clone_url": "https://github.com/lyqht/mini-qr.git",
"ssh_url": "git@github.com:lyqht/mini-qr.git",
"default_branch": "main",
"last_build_update": "2026-03-02T11:52:10Z"
"last_build_update": "2026-03-05T13:18:42Z"
}

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": 177388,
"forks_count": 55378,
"open_issues_count": 1397,
"updated_at": "2026-03-03T21:42:29Z",
"stars_count": 178014,
"forks_count": 55521,
"open_issues_count": 1413,
"updated_at": "2026-03-07T19:43:47Z",
"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-03-03T21:30:53Z"
"last_build_update": "2026-03-07T18:51:26Z"
}

View File

@ -4,13 +4,13 @@
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
"html_url": "https://github.com/nocodb/nocodb",
"language": "TypeScript",
"stars_count": 62290,
"forks_count": 4650,
"open_issues_count": 621,
"updated_at": "2026-03-03T21:35:23Z",
"stars_count": 62371,
"forks_count": 4655,
"open_issues_count": 627,
"updated_at": "2026-03-07T19:49:07Z",
"created_at": "2017-10-29T18:51:48Z",
"clone_url": "https://github.com/nocodb/nocodb.git",
"ssh_url": "git@github.com:nocodb/nocodb.git",
"default_branch": "develop",
"last_build_update": "2026-03-03T15:07:07Z"
"last_build_update": "2026-03-07T10:48:26Z"
}

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