Tonne of updates to things like social systems, calendars, and the documentation system (making it mobile friendly and fixing up navigation)
@ -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
|
||||
|
||||
28
admin/public/auth-check.html
Normal 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>
|
||||
@ -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={
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
252
admin/src/components/calendar/AvailabilityFinder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
admin/src/components/calendar/CalendarComments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
admin/src/components/calendar/CalendarExportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
admin/src/components/calendar/CalendarFeedsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
admin/src/components/calendar/CalendarReactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
admin/src/components/calendar/SharedViewMembersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
799
admin/src/components/docs/MobileDocsEditor.tsx
Normal 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;">❤️</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
admin/src/components/docs/MobileFormattingToolbar.tsx
Normal 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, '') },
|
||||
{ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
526
admin/src/hooks/useDocsEditor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
289
admin/src/lib/nav-defaults.ts
Normal 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 + '/'));
|
||||
}
|
||||
239
admin/src/pages/AdminCalendarPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
admin/src/pages/AdminCalendarViewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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!',
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
196
admin/src/pages/volunteer/FriendCalendarPage.tsx
Normal 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}'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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
347
admin/src/pages/volunteer/SharedCalendarViewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
admin/src/pages/volunteer/SharedCalendarsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
99
admin/src/utils/textareaSnippets.ts
Normal 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
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
74
api/src/modules/calendar/admin-calendar.routes.ts
Normal 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;
|
||||
21
api/src/modules/calendar/admin-calendar.schemas.ts
Normal 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>;
|
||||
173
api/src/modules/calendar/admin-calendar.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
127
api/src/modules/calendar/feed.routes.ts
Normal 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;
|
||||
22
api/src/modules/calendar/feed.schemas.ts
Normal 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>;
|
||||
514
api/src/modules/calendar/feed.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
291
api/src/modules/calendar/shared-calendar.routes.ts
Normal 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;
|
||||
1084
api/src/modules/calendar/shared-calendar.service.ts
Normal 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/).
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -23,6 +23,7 @@ const landingPageSelect = {
|
||||
mkdocsHideToc: true,
|
||||
mkdocsSkipExport: true,
|
||||
published: true,
|
||||
listed: true,
|
||||
seoTitle: true,
|
||||
seoDescription: true,
|
||||
seoImage: true,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
78
api/src/services/calendar-feed-queue.service.ts
Normal 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();
|
||||
@ -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);
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
mkdocs/.cache/plugin/social/assets/images/social/test-page.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
270
mkdocs/docs/docs/getting-started/control-panel.md
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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 — 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
|
||||
|
||||
@ -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
|
||||
|
||||
280
mkdocs/docs/docs/getting-started/services.md
Normal 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
|
||||
285
mkdocs/docs/docs/getting-started/upgrades.md
Normal 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
|
||||
```
|
||||
@ -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 %}
|
||||
|
||||
7
mkdocs/docs/overrides/test-page.html
Normal 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
@ -0,0 +1,7 @@
|
||||
---
|
||||
template: test-page.html
|
||||
hide:
|
||||
- navigation
|
||||
- toc
|
||||
title: "Test Page"
|
||||
---
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
mkdocs/site/assets/images/social/test-page.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||