Add guided tour, media enhancements, error handling, and DevOps improvements

Major additions: onboarding tour system, correlation-id middleware, media
error handler, restore script, env validation script, Dockerignore files.
Updates across 70+ admin components for improved UX and error handling.

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-26 10:31:51 -06:00
parent 0c634e100f
commit 39d74e7b85
127 changed files with 3051 additions and 380 deletions

View File

@ -11,6 +11,21 @@
# - NEVER commit .env to version control
# ==============================================================================
# ==============================================================================
# MINIMUM VIABLE SETUP (required — change these before deploying)
# ==============================================================================
# 1. V2_POSTGRES_PASSWORD — database password (8+ chars)
# 2. REDIS_PASSWORD — cache password (8+ chars)
# 3. JWT_ACCESS_SECRET — openssl rand -hex 32
# 4. JWT_REFRESH_SECRET — openssl rand -hex 32 (different from above)
# 5. JWT_INVITE_SECRET — openssl rand -hex 32 (different from above)
# 6. ENCRYPTION_KEY — openssl rand -hex 32 (different from JWT secrets)
# 7. INITIAL_ADMIN_PASSWORD — 12+ chars, uppercase + lowercase + digit
# 8. DOMAIN — your deployment domain (default: cmlite.org)
#
# Everything below these 8 values works with defaults for development.
# ==============================================================================
# --- General ---
NODE_ENV=development
# Root domain serves MkDocs documentation site only
@ -106,6 +121,8 @@ LISTMONK_WEB_ADMIN_USER=admin
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# API user (auto-created by listmonk-init container, used by V2 API for sync)
# Generate token: openssl rand -hex 16
# NOTE: LISTMONK_ADMIN_USER/PASSWORD are what the V2 API uses to connect.
# They MUST match LISTMONK_API_USER/TOKEN (same credentials, different var names).
LISTMONK_API_USER=v2-api
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
LISTMONK_ADMIN_USER=v2-api

1
.gitignore vendored
View File

@ -69,3 +69,4 @@ core.*
# Control Panel runtime data (managed deployments + backups)
/changemaker-control-panel/instances/
/changemaker-control-panel/backups/
logs/

6
admin/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.git
*.log
.env
.env.*

View File

@ -54,6 +54,7 @@ import {
TrophyOutlined,
FlagOutlined,
UserAddOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { api } from '@/lib/api';
@ -83,6 +84,10 @@ import {
} from '@/lib/nav-defaults';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { useTourStore } from '@/stores/tour.store';
import { AdminTour } from './tour/AdminTour';
import { TourHub } from './tour/TourHub';
import { TourTriggerButton } from './tour/TourTriggerButton';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
import RocketChatWidget from './chat/RocketChatWidget';
@ -431,6 +436,14 @@ export default function AppLayout() {
};
const userMenuItems: MenuProps['items'] = [
{
key: 'tour',
icon: <QuestionCircleOutlined />,
label: 'Learning Tours',
onClick: () => {
useTourStore.getState().openHub();
},
},
{
key: 'logout',
icon: <LogoutOutlined />,
@ -578,6 +591,7 @@ export default function AppLayout() {
trigger={null}
collapsible
collapsed={collapsed}
data-tour="sidebar"
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
>
{sidebarMenu}
@ -615,6 +629,7 @@ export default function AppLayout() {
<Button
type="text"
icon={<SearchOutlined />}
data-tour="search-button"
onClick={() => useCommandPaletteStore.getState().open()}
/>
</Tooltip>
@ -685,7 +700,7 @@ export default function AppLayout() {
</Button>
</Tooltip>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
<Button type="text" icon={<UserOutlined />} data-tour="user-menu">
{!isMobile && !collapsed && (
<Text style={{ marginLeft: 8 }}>
{user?.name || user?.email || 'User'}
@ -708,6 +723,9 @@ export default function AppLayout() {
</Content>
</Layout>
</Layout>
<AdminTour />
<TourHub />
<TourTriggerButton />
<RocketChatWidget />
</>
);

View File

@ -344,6 +344,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
overflow: 'hidden',
flexShrink: 0,
}}
>
@ -374,7 +375,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
/>
</Space>
) : (
<Space size={navCollapsed ? 8 : 16}>
<Space size={navCollapsed ? 8 : 16} style={{ flexWrap: 'nowrap', overflow: 'hidden' }}>
{visibleNavItems.map(renderDesktopLink)}
{overflowMenuItems.length > 0 && (
<Dropdown

View File

@ -16,6 +16,7 @@ import { PlusOutlined, CheckCircleOutlined, VideoCameraOutlined, CopyOutlined }
import axios from 'axios';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { Text } = Typography;
@ -79,9 +80,8 @@ export default function EventSubmissionForm({ initialDate, onSuccess, gancioUrl,
setSuccess(true);
form.resetFields();
onSuccess?.();
} catch (err: any) {
const msg = err.response?.data?.error?.message || err.response?.data?.error || 'Failed to submit event';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit event'));
} finally {
setSubmitting(false);
}

View File

@ -240,7 +240,12 @@ export default function CommandPalette() {
if (flatItem.type === 'command') {
const cmd = flatItem.item;
addRecent(cmd.id);
navigate(cmd.path, { state: cmd.navigationState });
// Special handling for non-navigation actions
if (cmd.id === 'action-learning-tours') {
import('@/stores/tour.store').then(({ useTourStore }) => useTourStore.getState().openHub());
} else {
navigate(cmd.path, { state: cmd.navigationState });
}
} else {
navigate(flatItem.item.path, { state: flatItem.item.navigationState });
}

View File

@ -790,6 +790,18 @@ export const commandRegistry: CommandItem[] = [
requiredRoles: ['SUPER_ADMIN'],
},
// ── Help & Tours ────────────────────────────────────────
{
id: 'action-learning-tours',
title: 'Learning Tours',
description: 'Browse interactive tutorials for each section of the admin',
group: 'Actions',
path: '',
icon: 'QuestionCircleOutlined',
keywords: ['tour', 'help', 'learn', 'tutorial', 'guide', 'onboarding', 'walkthrough'],
category: 'action',
},
// ── Quick actions ─────────────────────────────────────
{
id: 'action-create-campaign',

View File

@ -39,6 +39,7 @@ import type {
AreaImportProgress,
AreaImportSourceStatus,
} from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Title } = Typography;
@ -232,8 +233,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
try {
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
setPreview(data);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
} catch (err: unknown) {
setPreviewError(getErrorMessage(err, 'Preview failed'));
} finally {
setPreviewLoading(false);
}
@ -259,8 +260,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
// Ignore polling errors
}
}, 2000);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
} catch (err: unknown) {
setPreviewError(getErrorMessage(err, 'Failed to start import'));
setImporting(false);
}
};

View File

@ -4,6 +4,7 @@ import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistSummary } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -139,8 +140,8 @@ export default function AddToPlaylistModal({
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
]);
setSelections((prev) => ({ ...prev, [data.id]: true }));
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');

View File

@ -1,6 +1,7 @@
import { Modal, Select, message } from 'antd';
import { useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface BulkAccessLevelModalProps {
open: boolean;
@ -28,8 +29,8 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
});
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
onSuccess();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to update access levels');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to update access levels'));
} finally {
setLoading(false);
}

View File

@ -3,6 +3,7 @@ import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message
import { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -62,8 +63,8 @@ export default function BulkAddToPlaylistModal({
try {
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
added++;
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
skipped++;
} else {
throw error;
@ -98,8 +99,8 @@ export default function BulkAddToPlaylistModal({
setNewName('');
setShowCreate(false);
message.success(`Created "${data.name}"`);
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');

View File

@ -16,6 +16,7 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import axios from 'axios';
dayjs.extend(relativeTime);
@ -105,8 +106,8 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
setComments((prev) => [response.data.comment, ...prev]);
setCommentText('');
message.success('Comment posted!');
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('Please log in to comment');
} else {
message.error('Failed to post comment');

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Modal, Form, Input, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
interface CreateAlbumModalProps {
open: boolean;
@ -30,8 +31,8 @@ export default function CreateAlbumModal({
message.success('Album created');
form.resetFields();
onSuccess();
} catch (error: any) {
if (error.response?.data?.message) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.data?.message) {
message.error(error.response.data.message);
}
} finally {

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
interface CreatePlaylistModalProps {
open: boolean;
@ -31,10 +32,10 @@ export default function CreatePlaylistModal({
form.resetFields();
onCreated?.(data);
onClose();
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!error.errorFields) {
} else if (!(error instanceof Object && 'errorFields' in error)) {
message.error('Failed to create playlist');
}
} finally {

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
import { mediaApi } from '@/lib/media-api';
import type { Photo } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface EditPhotoModalProps {
photo: Photo | null;
@ -34,8 +35,8 @@ export default function EditPhotoModal({ photo, open, onClose, onSuccess }: Edit
await mediaApi.patch(`/photos/${photo.id}`, values);
message.success('Photo updated');
onSuccess();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to update photo');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to update photo'));
}
};

View File

@ -4,6 +4,7 @@ import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistVideoItem } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -71,10 +72,10 @@ export default function EditPlaylistModal({
message.success('Playlist updated');
onUpdated?.();
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!error.errorFields) {
} else if (!(error instanceof Object && 'errorFields' in error)) {
message.error('Failed to update playlist');
}
} finally {

View File

@ -3,6 +3,7 @@ import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
import axios from 'axios';
interface EditVideoDrawerProps {
video: Video | null;
@ -87,8 +88,8 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
message.success('Video updated successfully');
onSuccess?.();
onClose();
} catch (error: any) {
if (error.response?.data?.message) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.data?.message) {
message.error(error.response.data.message);
}
// form validation errors are shown inline

View File

@ -13,6 +13,7 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicAlbum } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -103,8 +104,8 @@ export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
}
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {

View File

@ -13,6 +13,7 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicPhoto } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -80,8 +81,8 @@ export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {

View File

@ -18,6 +18,7 @@ import ReactionButtons from './ReactionButtons';
import AddToPlaylistModal from './AddToPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -117,9 +118,9 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
await mediaPublicApi.post(`/public/${video.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
} catch (error: unknown) {
console.error('Upvote failed:', error);
if (error.response?.status === 401) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
alert('Please log in to upvote videos');
}
} finally {

View File

@ -26,6 +26,7 @@ import {
ExpandOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { Text } = Typography;
@ -260,8 +261,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
// Immediately expand the new job
setExpandedJobId(data.jobId);
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to submit fetch job');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit fetch job'));
} finally {
setSubmitting(false);
}
@ -272,8 +273,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
message.success('Job cancelled');
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to cancel job');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to cancel job'));
}
};

View File

@ -20,6 +20,7 @@ import {
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
const { Text } = Typography;
const { TextArea } = Input;
@ -302,12 +303,12 @@ export default function LiveChat({
setCommentInput('');
// Note: New comment will appear via SSE broadcast
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to submit comment:', err);
if (err.response?.status === 429) {
if (axios.isAxiosError(err) && err.response?.status === 429) {
alert('Rate limit exceeded. Please wait a minute before commenting again.');
} else if (err.response?.status === 401) {
} else if (axios.isAxiosError(err) && err.response?.status === 401) {
alert('Please log in to comment.');
if (onRequestLogin) {
onRequestLogin();

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Modal, Select, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface PublishModalProps {
open: boolean;
@ -28,8 +29,8 @@ export default function PublishModal({ open, videoIds, onSuccess, onCancel }: Pu
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
onSuccess();
setCategory('videos');
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to publish videos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to publish videos'));
} finally {
setLoading(false);
}

View File

@ -9,6 +9,7 @@ import {
import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface QuickAnalyticsModalProps {
videoId: number;
@ -41,9 +42,9 @@ export default function QuickAnalyticsModal({
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally {
setLoading(false);
}

View File

@ -3,6 +3,7 @@ import { Space, Button, message, theme } from 'antd';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
import axios from 'axios';
interface ReactionButtonsProps {
videoId: number;
@ -63,8 +64,8 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
}, 2000);
message.success(`${emoji} reaction added!`);
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('Please log in to add reactions');
} else {
message.error('Failed to add reaction');

View File

@ -4,6 +4,7 @@ import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined }
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface ScheduleEvent {
jobId: string;
@ -55,9 +56,9 @@ export default function ScheduleCalendarDrawer({
params: { limit: 100 },
});
setSchedules(response.data.schedules || []);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch schedules:', error);
setError(error.response?.data?.message || 'Failed to load schedules. Please try again.');
setError(getErrorMessage(error, 'Failed to load schedules. Please try again.'));
} finally {
setLoading(false);
}
@ -69,8 +70,8 @@ export default function ScheduleCalendarDrawer({
message.success(`${action} schedule cancelled`);
fetchSchedules();
onRefresh?.();
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
} catch (error: unknown) {
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
}
};

View File

@ -6,6 +6,7 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -100,8 +101,8 @@ export default function SchedulePublishModal({
onSuccess?.();
onClose();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to schedule video');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to schedule video'));
} finally {
setLoading(false);
}
@ -116,8 +117,8 @@ export default function SchedulePublishModal({
message.success(`${action} schedule cancelled`);
onSuccess?.();
onClose();
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
} catch (error: unknown) {
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
} finally {
setLoading(false);
}

View File

@ -3,6 +3,7 @@ import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, T
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PhotoAlbum } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload;
@ -75,11 +76,11 @@ export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }:
headers: { 'Content-Type': 'multipart/form-data' },
});
uploadResults.push({ filename: file.name, success: true });
} catch (error: any) {
} catch (error: unknown) {
uploadResults.push({
filename: file.name,
success: false,
error: error.response?.data?.message || 'Upload failed',
error: getErrorMessage(error, 'Upload failed'),
});
}

View File

@ -16,6 +16,7 @@ import {
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload;
const { Text } = Typography;
@ -117,11 +118,11 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
filename: file.name,
success: true,
});
} catch (error: any) {
} catch (error: unknown) {
uploadResults.push({
filename: file.name,
success: false,
error: error.response?.data?.message || 'Upload failed',
error: getErrorMessage(error, 'Upload failed'),
});
}

View File

@ -12,6 +12,7 @@ import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
import AnalyticsChart from './AnalyticsChart';
import ViewersTable from './ViewersTable';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface VideoAnalyticsModalProps {
videoId: number | null;
@ -46,9 +47,9 @@ export default function VideoAnalyticsModal({
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally {
setLoading(false);
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Modal, Button, Typography, Spin, Result, message, Space } from 'antd';
import { VideoCameraOutlined, CopyOutlined, LoginOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Paragraph } = Typography;
@ -57,8 +58,8 @@ export default function VideoCallModal({ open, onClose, personName }: VideoCallM
`/jitsi/meetings/${meeting.slug}/token`,
);
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank');
} catch (err: any) {
message.error(err.response?.data?.error || 'Failed to get moderator token');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to get moderator token'));
} finally {
setJoining(false);
}

View File

@ -3,6 +3,7 @@ import { Input, Button, Typography, message, Space } from 'antd';
import { MailOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
@ -25,9 +26,8 @@ export default function NewsletterSignup() {
await axios.post('/api/newsletter/subscribe', { email: email.trim() });
setSuccess(true);
setEmail('');
} catch (err: any) {
const msg = err?.response?.data?.error?.message || 'Failed to subscribe';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to subscribe'));
} finally {
setLoading(false);
}

View File

@ -19,6 +19,7 @@ import {
import { Link } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
@ -59,9 +60,8 @@ export default function ShiftSignupModal({ shift, open, onClose, onSuccess }: Sh
onClose();
form.resetFields();
onSuccess?.();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to sign up';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to sign up'));
} finally {
setSubmitting(false);
}

View File

@ -5,6 +5,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { getErrorMessage } from '../../utils/getErrorMessage';
const apiBase = '/api';
@ -210,8 +211,8 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S
setSubmitMsg({ type: 'success', text: hasVoted ? 'Votes updated!' : 'Votes submitted!' });
setHasVoted(true);
fetchPoll();
} catch (err: any) {
setSubmitMsg({ type: 'error', text: err.response?.data?.error?.message || 'Failed to submit votes' });
} catch (err: unknown) {
setSubmitMsg({ type: 'error', text: getErrorMessage(err, 'Failed to submit votes') });
} finally {
setSubmitting(false);
}

View File

@ -12,6 +12,7 @@ import type { MenuProps } from 'antd';
import { useSocialStore } from '@/stores/social.store';
import { api } from '@/lib/api';
import type { FriendshipStatusResponse } from '@/types/social';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface FriendButtonProps {
userId: string;
@ -50,8 +51,8 @@ export default function FriendButton({ userId, status: statusProp, onStatusChang
if (!statusProp) {
api.get(`/social/friends/status/${userId}`).then(({ data }) => setFetchedStatus(data)).catch(() => {});
}
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Action failed');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Action failed'));
} finally {
setLoading(false);
}

View File

@ -5,6 +5,7 @@ import { UserAddOutlined, CloseOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useSocialStore } from '@/stores/social.store';
import UserAvatar from './UserAvatar';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface Suggestion {
userId: string;
@ -45,8 +46,8 @@ export default function FriendSuggestions({ limit = 5 }: FriendSuggestionsProps)
await sendFriendRequest(userId);
message.success('Friend request sent');
setSuggestions((prev) => prev.filter((s) => s.userId !== userId));
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send request');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to send request'));
} finally {
setActionLoading(null);
}

View File

@ -4,6 +4,7 @@ import { MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useSettingsStore } from '@/stores/settings.store';
import { useChatWidgetStore } from '@/stores/chat-widget.store';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface Props {
friendId: string;
@ -34,9 +35,8 @@ export default function MessageButton({ friendId, isFriend, size = 'middle' }: P
// Open chat panel for the DM room
openChannel(data.roomId);
message.success('Opening direct message...');
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to open direct message';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to open direct message'));
} finally {
setLoading(false);
}

View File

@ -131,6 +131,7 @@ export default function NotificationBell() {
type="text"
size="small"
icon={<BellOutlined style={{ color: '#fff', fontSize: 16 }} />}
style={{ minWidth: 32, minHeight: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
/>
</Badge>
</Dropdown>

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Button, message, Tooltip } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface PokeButtonProps {
userId: string;
@ -44,8 +45,8 @@ export default function PokeButton({ userId, isFriend, size = 'small' }: PokeBut
await api.post('/social/pokes', { userId });
message.success('Poke sent!');
setCooldownSeconds(24 * 60 * 60);
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send poke');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to send poke'));
} finally {
setLoading(false);
}

View File

@ -4,6 +4,7 @@ import { VideoCameraOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { mediaApi } from '@/lib/media-api';
import type { FriendListItem } from '@/types/social';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface RecommendVideoModalProps {
open: boolean;
@ -91,8 +92,8 @@ export default function RecommendVideoModal({
});
message.success('Recommendation sent!');
onClose();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send recommendation');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to send recommendation'));
} finally {
setSending(false);
}

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Card, Input, Button, List, Space, Typography, Tag, App } from 'antd';
import { PlusOutlined, TeamOutlined, CrownOutlined, LoginOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface TeamInfo {
id: string;
@ -39,8 +40,8 @@ export default function TeamJoinCard({
message.success('Team created');
setTeamName('');
onTeamCreated();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to create team');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to create team'));
} finally {
setCreating(false);
}
@ -52,8 +53,8 @@ export default function TeamJoinCard({
await api.post(`/social/challenges/${challengeId}/teams/${teamId}/join`);
message.success('Joined team');
onTeamJoined();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to join team');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to join team'));
} finally {
setJoiningId(null);
}

View File

@ -0,0 +1,99 @@
import { useMemo, useEffect, useState } from 'react';
import { Tour, Grid } from 'antd';
import { useLocation } from 'react-router-dom';
import { useTourStore } from '../../stores/tour.store';
import { useAuthStore } from '../../stores/auth.store';
import { useSettingsStore } from '../../stores/settings.store';
import { tourRegistry } from './tourRegistry';
import type { TourStepContext } from './types';
/**
* Layout-level tour runner.
* Handles sections with route=null (e.g., getting-started) that target
* AppLayout elements (sidebar, header, user menu).
*
* Page-specific tours are handled by <PageTour> on each page.
*/
export function AdminTour() {
const { sections, activeSectionId, activeStep, launchSection, setStep, completeActiveSection, pauseActiveSection } = useTourStore();
const { user } = useAuthStore();
const settings = useSettingsStore((s) => s.settings);
const location = useLocation();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [open, setOpen] = useState(false);
// Find the active layout-level section (route === null)
const activeSection = useMemo(() => {
if (!activeSectionId) return null;
const section = tourRegistry.find((s) => s.id === activeSectionId);
return section?.route === null ? section : null;
}, [activeSectionId]);
// Build steps for the active layout-level section
const ctx: TourStepContext | null = useMemo(
() => settings ? { settings, isMobile } : null,
[settings, isMobile]
);
const steps = useMemo(
() => (activeSection && ctx ? activeSection.buildSteps(ctx) : []),
[activeSection, ctx]
);
// Open tour when a layout-level section is activated
useEffect(() => {
if (activeSection && steps.length > 0) {
const timer = setTimeout(() => setOpen(true), 300);
return () => clearTimeout(timer);
}
setOpen(false);
}, [activeSection, steps.length]);
// Auto-launch getting-started for new SUPER_ADMIN users on dashboard
// Only auto-launch if the section has NEVER been started (not in sections map at all)
useEffect(() => {
const gettingStarted = sections['getting-started'];
if (
!gettingStarted &&
!activeSectionId &&
user?.role === 'SUPER_ADMIN' &&
location.pathname === '/app' &&
ctx
) {
const gsDef = tourRegistry.find((s) => s.id === 'getting-started');
if (gsDef) {
const gsSteps = gsDef.buildSteps(ctx);
if (gsSteps.length > 0) {
const timer = setTimeout(() => launchSection('getting-started', gsSteps.length), 500);
return () => clearTimeout(timer);
}
}
}
}, [sections, activeSectionId, user, location.pathname, ctx, launchSection]);
if (!activeSection || steps.length === 0) return null;
const isLastStep = activeStep >= steps.length - 1;
return (
<Tour
open={open}
current={activeStep}
steps={steps}
onChange={(step) => setStep(step)}
onClose={() => {
setOpen(false);
if (isLastStep) {
completeActiveSection();
} else {
pauseActiveSection();
}
}}
onFinish={() => {
setOpen(false);
completeActiveSection();
}}
/>
);
}

View File

@ -0,0 +1,74 @@
import { useMemo, useEffect, useState } from 'react';
import { Tour, Grid } from 'antd';
import { useTourStore } from '../../stores/tour.store';
import { useSettingsStore } from '../../stores/settings.store';
import { tourRegistry } from './tourRegistry';
import type { TourSectionId, TourStepContext } from './types';
interface PageTourProps {
sectionId: TourSectionId;
}
/**
* Page-level tour runner. Include this on any page that has a tour section.
* Only renders the Tour overlay when this page's section is the active one.
*/
export function PageTour({ sectionId }: PageTourProps) {
const { activeSectionId, activeStep, setStep, completeActiveSection, pauseActiveSection } = useTourStore();
const settings = useSettingsStore((s) => s.settings);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [open, setOpen] = useState(false);
const isActive = activeSectionId === sectionId;
const section = useMemo(
() => tourRegistry.find((s) => s.id === sectionId),
[sectionId]
);
const ctx: TourStepContext | null = useMemo(
() => settings ? { settings, isMobile } : null,
[settings, isMobile]
);
const steps = useMemo(
() => (section && ctx ? section.buildSteps(ctx) : []),
[section, ctx]
);
// Open tour when this section becomes active, with delay for DOM readiness
useEffect(() => {
if (isActive && steps.length > 0) {
const timer = setTimeout(() => setOpen(true), 400);
return () => clearTimeout(timer);
}
setOpen(false);
}, [isActive, steps.length]);
if (!isActive || steps.length === 0) return null;
const isLastStep = activeStep >= steps.length - 1;
return (
<Tour
open={open}
current={activeStep}
steps={steps}
onChange={(step) => setStep(step)}
onClose={() => {
setOpen(false);
// If closed on the last step, treat as completed
if (isLastStep) {
completeActiveSection();
} else {
pauseActiveSection();
}
}}
onFinish={() => {
setOpen(false);
completeActiveSection();
}}
/>
);
}

View File

@ -0,0 +1,140 @@
import { useMemo, useCallback } from 'react';
import { Drawer, Progress, Typography, Collapse, Button, Grid, theme } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useTourStore } from '../../stores/tour.store';
import { useSettingsStore } from '../../stores/settings.store';
import { getAvailableSections } from './tourRegistry';
import { TourSectionCard } from './TourSectionCard';
import type { TourCategory, TourSection } from './types';
const { Title, Text } = Typography;
const categoryLabels: Record<TourCategory, string> = {
essentials: 'Essentials',
features: 'Features',
};
export function TourHub() {
const { hubOpen, closeHub, sections, launchSection, resetSection, resetAllSections } = useTourStore();
const settings = useSettingsStore((s) => s.settings);
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const availableSections = useMemo(
() => (settings ? getAvailableSections(settings as unknown as Record<string, unknown>) : []),
[settings]
);
const completedCount = useMemo(
() => availableSections.filter((s) => sections[s.id]?.completed).length,
[availableSections, sections]
);
const totalCount = availableSections.length;
const overallPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
// Group sections by category
const grouped = useMemo(() => {
const groups: Record<string, TourSection[]> = {};
for (const section of availableSections) {
const cat = section.category;
if (!groups[cat]) groups[cat] = [];
groups[cat]!.push(section);
}
return groups;
}, [availableSections]);
const handleLaunch = useCallback(
(section: TourSection) => {
if (!settings) return;
const ctx = { settings, isMobile };
const steps = section.buildSteps(ctx);
if (steps.length === 0) return;
launchSection(section.id, steps.length);
closeHub();
if (section.route) {
navigate(section.route);
}
},
[settings, isMobile, launchSection, closeHub, navigate]
);
const handleReset = useCallback(
(sectionId: string) => {
resetSection(sectionId as TourSection['id']);
},
[resetSection]
);
return (
<Drawer
title={null}
open={hubOpen}
onClose={closeHub}
width={isMobile ? '100%' : 400}
styles={{ body: { padding: '16px 20px' } }}
>
<div style={{ marginBottom: 20 }}>
<Title level={4} style={{ marginBottom: 4 }}>Learning Tours</Title>
<Text type="secondary">
Interactive tutorials to help you learn each part of the platform.
</Text>
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{completedCount} of {totalCount} completed
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{overallPercent}%
</Text>
</div>
<Progress
percent={overallPercent}
showInfo={false}
status={completedCount === totalCount ? 'success' : 'active'}
/>
</div>
</div>
<Collapse
defaultActiveKey={['essentials', 'features']}
ghost
items={Object.entries(grouped).map(([category, sects]) => ({
key: category,
label: (
<Text strong style={{ fontSize: 14 }}>
{categoryLabels[category as TourCategory] || category}
</Text>
),
children: sects.map((section) => (
<TourSectionCard
key={section.id}
section={section}
progress={sections[section.id] ?? null}
onLaunch={() => handleLaunch(section)}
onReset={() => handleReset(section.id)}
/>
)),
}))}
/>
{completedCount > 0 && (
<div style={{ textAlign: 'center', marginTop: 16, paddingTop: 16, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Button
type="link"
danger
size="small"
onClick={resetAllSections}
>
Reset All Progress
</Button>
</div>
)}
</Drawer>
);
}

View File

@ -0,0 +1,125 @@
import { Button, Progress, Space, Typography, theme } from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
RocketOutlined,
DashboardOutlined,
SettingOutlined,
SendOutlined,
EnvironmentOutlined,
PlaySquareOutlined,
TeamOutlined,
} from '@ant-design/icons';
import type { TourSection, SectionProgress } from './types';
const { Text } = Typography;
const iconMap: Record<string, React.ReactNode> = {
RocketOutlined: <RocketOutlined />,
DashboardOutlined: <DashboardOutlined />,
SettingOutlined: <SettingOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
PlaySquareOutlined: <PlaySquareOutlined />,
TeamOutlined: <TeamOutlined />,
};
interface TourSectionCardProps {
section: TourSection;
progress: SectionProgress | null;
onLaunch: () => void;
onReset: () => void;
}
export function TourSectionCard({ section, progress, onLaunch, onReset }: TourSectionCardProps) {
const { token } = theme.useToken();
const isCompleted = progress?.completed ?? false;
const isInProgress = progress && !progress.completed && progress.currentStep > 0;
const percent = progress
? isCompleted
? 100
: Math.round((progress.currentStep / Math.max(progress.totalSteps, 1)) * 100)
: 0;
return (
<div
style={{
padding: '12px 16px',
borderRadius: token.borderRadius,
border: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
marginBottom: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
fontSize: 20,
color: isCompleted ? token.colorSuccess : token.colorPrimary,
marginTop: 2,
flexShrink: 0,
}}
>
{isCompleted ? <CheckCircleOutlined /> : (iconMap[section.icon] || <RocketOutlined />)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong>{section.title}</Text>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{section.estimatedMinutes} min
</Text>
</div>
<Text type="secondary" style={{ fontSize: 13, display: 'block', marginTop: 2 }}>
{section.description}
</Text>
{(isInProgress || isCompleted) && (
<Progress
percent={percent}
size="small"
status={isCompleted ? 'success' : 'active'}
style={{ marginTop: 8, marginBottom: 0 }}
showInfo={false}
/>
)}
<Space style={{ marginTop: 8 }}>
{isCompleted ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={(e) => { e.stopPropagation(); onReset(); }}
>
Restart
</Button>
) : isInProgress ? (
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
onClick={onLaunch}
>
Resume (Step {(progress?.currentStep ?? 0) + 1}/{progress?.totalSteps ?? 0})
</Button>
) : (
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
onClick={onLaunch}
>
Start
</Button>
)}
</Space>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { Badge, Button, Tooltip } from 'antd';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useTourStore } from '../../stores/tour.store';
/**
* Floating help button (bottom-right corner) that opens the Tour Hub.
* Shows a badge dot when there's a tour section in progress.
*/
export function TourTriggerButton() {
const { openHub, sections, activeSectionId } = useTourStore();
// Check if any section is in-progress (started but not completed)
const hasInProgress = Object.values(sections).some(
(s) => !s.completed && s.currentStep > 0
);
// Don't show the button if a tour is currently active (overlay is showing)
if (activeSectionId) return null;
return (
<div
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 999,
}}
>
<Tooltip title="Learning Tours" placement="left">
<Badge dot={hasInProgress} offset={[-4, 4]}>
<Button
type="primary"
shape="circle"
size="large"
icon={<QuestionCircleOutlined />}
onClick={openHub}
style={{ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)' }}
/>
</Badge>
</Tooltip>
</div>
);
}

View File

@ -0,0 +1,58 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function campaignsSteps(ctx: TourStepContext): TourStepProps[] {
if (ctx.settings.enableInfluence === false) {
return [];
}
return [
{
title: 'Advocacy Campaigns',
description:
'Campaigns let your supporters write letters to elected representatives. You create the campaign, set talking points, and supporters send personalized emails.',
target: null,
},
{
title: 'Campaign List',
description:
'This table shows all your campaigns. Filter by status — Draft, Active, Closed, or Archived — and search by title.',
target: domTarget('[data-tour-campaigns-table]'),
placement: 'bottom',
},
{
title: 'Create a Campaign',
description:
'Click here to create a new campaign. You will set a title, choose the government level (federal, provincial, municipal), write talking points, and draft a letter template.',
target: domTarget('[data-tour-campaigns-create]'),
placement: 'bottom',
},
{
title: 'Campaign Status',
description:
'Campaigns move through stages: Draft (not visible to public), Active (accepting submissions), Closed (read-only), and Archived.',
target: domTarget('[data-tour-campaigns-status]'),
placement: 'right',
},
{
title: 'Campaign Actions',
description:
'Each campaign has actions: edit details, view submitted emails, preview the public page, and copy the shareable link.',
target: domTarget('[data-tour-campaigns-actions]'),
placement: 'left',
},
{
title: 'Response Moderation',
description:
'Submitted letters appear in the Responses section for moderation. You can approve, edit, or reject submissions before they are sent to representatives.',
target: null,
},
{
title: 'Email Queue',
description:
'Approved letters queue up in Outgoing Emails. Monitor delivery status and retry failed sends from there.',
target: null,
},
];
}

View File

@ -0,0 +1,48 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function dashboardSteps(_ctx: TourStepContext): TourStepProps[] {
return [
{
title: 'Your Command Center',
description:
'This is your operational hub. Real-time metrics, service health, and recent activity — everything you need at a glance.',
target: null,
},
{
title: 'Key Metrics',
description:
'These cards show your most important numbers: active campaigns, map locations, registered users. Click any card to jump to that section.',
target: domTarget('[data-tour-dashboard-stats]'),
placement: 'bottom',
},
{
title: 'Service Health',
description:
'This grid shows whether each service is running. Green means healthy. If something goes red, check the logs for details.',
target: domTarget('[data-tour-dashboard-services]'),
placement: 'bottom',
},
{
title: 'Activity Feed',
description:
'The activity feed shows recent actions across the platform — new users, campaign submissions, shift signups. Stay aware of what is happening.',
target: domTarget('[data-tour-dashboard-activity]'),
placement: 'left',
},
{
title: 'Quick Actions',
description:
'Use these shortcuts to jump to common tasks like creating campaigns, uploading media, or managing users.',
target: domTarget('[data-tour-dashboard-actions]'),
placement: 'bottom',
},
{
title: 'Explore More',
description:
'That covers the dashboard basics. Open the Learning Tours menu anytime to explore tutorials for specific features like Campaigns, Map, or Media.',
target: null,
},
];
}

View File

@ -0,0 +1,79 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function gettingStartedSteps(ctx: TourStepContext): TourStepProps[] {
const steps: TourStepProps[] = [];
// Step 1 (conditional): Change Password — only when org name is still default
if (ctx.settings.organizationName === 'Changemaker Lite') {
steps.push({
title: 'Change Your Password',
description:
'Welcome! Your first step should be to change your default admin password. Click your user menu, then go to Users to update it.',
target: domTarget('[data-tour="user-menu"]'),
placement: 'bottomRight',
});
}
// Step 2: Welcome & Dashboard (centered, no target)
steps.push({
title: 'Welcome to Your Dashboard',
description:
'This is your Dashboard — it shows key metrics about your organization: active campaigns, map locations, volunteers, and system health.',
target: null,
});
// Step 3: Sidebar Navigation
if (!ctx.isMobile) {
steps.push({
title: 'Sidebar Navigation',
description:
'The sidebar is your main navigation. Sections expand to reveal sub-pages. You can collapse it with the toggle button.',
target: domTarget('[data-tour="sidebar"]'),
placement: 'right',
});
}
// Step 4: Settings
steps.push({
title: 'Settings',
description:
'Settings is where you configure your organization name, domain, email, theme, and feature modules. Visit this first after setup.',
target: ctx.isMobile ? null : domTarget('[data-menu-id*="/app/settings"]'),
placement: 'right',
});
// Step 5 (conditional): Campaigns
if (ctx.settings.enableInfluence !== false) {
steps.push({
title: 'Advocacy Campaigns',
description:
'Advocacy Campaigns let you organize letter-writing and email campaigns to elected representatives.',
target: ctx.isMobile ? null : domTarget('[data-menu-id*="influence"]'),
placement: 'right',
});
}
// Step 6 (conditional): Map & Canvassing
if (ctx.settings.enableMap !== false) {
steps.push({
title: 'Map & Canvassing',
description:
'The Map module manages geographic locations, canvassing areas, and volunteer shifts for field operations.',
target: ctx.isMobile ? null : domTarget('[data-menu-id*="map"]'),
placement: 'right',
});
}
// Step 7: Getting Help
steps.push({
title: 'Getting Help',
description:
'Use the search shortcut (Ctrl+K) to quickly find any page. Click the floating help button to explore more tutorials for each section.',
target: domTarget('[data-tour="search-button"]'),
placement: 'bottom',
});
return steps;
}

View File

@ -0,0 +1,51 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function mapLocationsSteps(ctx: TourStepContext): TourStepProps[] {
if (ctx.settings.enableMap === false) {
return [];
}
return [
{
title: 'Map Module',
description:
'The Map module manages geographic locations for door-to-door canvassing, event planning, and supporter tracking.',
target: null,
},
{
title: 'Locations Table',
description:
'Each row is an address with data: support level, contact info, building type, and notes from canvassers. Click a row to see full details.',
target: domTarget('[data-tour-map-table]'),
placement: 'bottom',
},
{
title: 'Add Locations',
description:
'Add locations one at a time, or use CSV import to bulk-upload hundreds of addresses. Geocoding runs automatically to place them on the map.',
target: domTarget('[data-tour-map-add]'),
placement: 'bottom',
},
{
title: 'Search & Filter',
description:
'Filter locations by support level, area, tags, or address text. Use the map view toggle to see locations plotted geographically.',
target: domTarget('[data-tour-map-filters]'),
placement: 'bottom',
},
{
title: 'Canvass Areas',
description:
'Organize locations into geographic areas called \'cuts\' for canvassing routes. Areas can be drawn on the map and assigned to volunteers for shift-based door-knocking.',
target: null,
},
{
title: 'Data Quality',
description:
'The Data Quality dashboard (under Map menu) flags duplicates, missing geocodes, and data issues so you can keep your location database clean.',
target: null,
},
];
}

View File

@ -0,0 +1,51 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function mediaLibrarySteps(ctx: TourStepContext): TourStepProps[] {
if (!ctx.settings.enableMediaFeatures) {
return [];
}
return [
{
title: 'Media Library',
description:
'The Media Library manages all your organization\'s videos and photos. Content can be published to the public gallery or kept private for internal use.',
target: null,
},
{
title: 'Media Tabs',
description:
'Switch between Videos, Photos, and Albums. Each type has its own upload and management workflow.',
target: domTarget('[data-tour-media-tabs]'),
placement: 'bottom',
},
{
title: 'Upload Content',
description:
'Click Upload to add new videos or photos. Videos are analyzed automatically for duration, resolution, and quality metadata.',
target: domTarget('[data-tour-media-upload]'),
placement: 'bottom',
},
{
title: 'Search & Filter',
description:
'Search by title, filter by category or publication status. Use bulk select to manage multiple items at once.',
target: domTarget('[data-tour-media-filters]'),
placement: 'bottom',
},
{
title: 'Publishing',
description:
'Media starts as a draft. Publish it to make it visible in the public gallery. You can also schedule a future publication date.',
target: null,
},
{
title: 'Analytics',
description:
'Track views, engagement, and audience data from the Analytics dashboard. All tracking is GDPR-compliant and privacy-respecting.',
target: null,
},
];
}

View File

@ -0,0 +1,55 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function settingsSteps(_ctx: TourStepContext): TourStepProps[] {
return [
{
title: 'Settings Overview',
description:
'Settings is where you configure everything about your platform. The tabs across the top organize settings by category.',
target: null,
},
{
title: 'Organization',
description:
'Click this tab to set your organization\'s name, logo, and footer text. The name appears in the sidebar, browser title, and all public pages.',
target: domTarget('[data-node-key="organization"]'),
placement: 'bottom',
},
{
title: 'Theme & Colors',
description:
'Click this tab to customize colors for both the admin panel and public-facing pages. Pick your brand color and changes apply instantly.',
target: domTarget('[data-node-key="theme"]'),
placement: 'bottom',
},
{
title: 'Email Configuration',
description:
'Click this tab to configure SMTP settings for sending advocacy emails and notifications. Test your connection before going live.',
target: domTarget('[data-node-key="email"]'),
placement: 'bottom',
},
{
title: 'Feature Toggles',
description:
'Click this tab to enable or disable entire modules. Disabled features hide their menu items and routes. Tailor the platform to your needs.',
target: domTarget('[data-node-key="features"]'),
placement: 'bottom',
},
{
title: 'Notifications',
description:
'Click this tab to control which events trigger admin notifications and configure alert preferences.',
target: domTarget('[data-node-key="notifications"]'),
placement: 'bottom',
},
{
title: 'What to Do Next',
description:
'Start by setting your organization name and enabling the features you need. You can always come back to adjust these later.',
target: null,
},
];
}

View File

@ -0,0 +1,47 @@
import type { TourStepProps } from 'antd';
import type { TourStepContext } from '../types';
import { domTarget } from '../types';
export function userManagementSteps(_ctx: TourStepContext): TourStepProps[] {
return [
{
title: 'User Management',
description:
'This page manages everyone who can log into your platform — administrators, content editors, and volunteers.',
target: null,
},
{
title: 'User Table',
description:
'The table shows all registered users with their roles, status, and last login. Use the search bar and filters to find specific users.',
target: domTarget('[data-tour-users-table]'),
placement: 'bottom',
},
{
title: 'Create a User',
description:
'Create new users here. Set their name, email, role, and initial password. Users receive a welcome email if email is configured.',
target: domTarget('[data-tour-users-create]'),
placement: 'bottom',
},
{
title: 'Roles',
description:
'Roles control access: SUPER_ADMIN has full control. Module-specific admins (INFLUENCE_ADMIN, MAP_ADMIN, MEDIA_ADMIN) can only manage their area. USER and TEMP roles are for volunteers.',
target: null,
},
{
title: 'User Status',
description:
'Users can be Active, Pending (awaiting approval), Suspended, or Expired. Control access without deleting accounts.',
target: domTarget('[data-tour-users-status]'),
placement: 'right',
},
{
title: 'Service Accounts',
description:
'When enabled in Settings, new users are automatically provisioned accounts in connected services like Gitea, Vaultwarden, and Listmonk.',
target: null,
},
];
}

View File

@ -0,0 +1,108 @@
import type { TourSection } from './types';
import { gettingStartedSteps } from './sections/getting-started';
import { dashboardSteps } from './sections/dashboard';
import { settingsSteps } from './sections/settings';
import { campaignsSteps } from './sections/campaigns';
import { mapLocationsSteps } from './sections/map-locations';
import { mediaLibrarySteps } from './sections/media-library';
import { userManagementSteps } from './sections/user-management';
export const tourRegistry: TourSection[] = [
{
id: 'getting-started',
title: 'Getting Started',
description: 'First-time setup: change your password, explore the sidebar, and configure your organization.',
icon: 'RocketOutlined',
route: null,
featureFlag: null,
order: 0,
category: 'essentials',
estimatedMinutes: 2,
buildSteps: gettingStartedSteps,
},
{
id: 'dashboard',
title: 'Dashboard Overview',
description: 'Learn to read your metrics, service health, and activity feed.',
icon: 'DashboardOutlined',
route: '/app',
featureFlag: null,
order: 1,
category: 'essentials',
estimatedMinutes: 3,
buildSteps: dashboardSteps,
},
{
id: 'settings',
title: 'Settings & Configuration',
description: 'Configure your organization, theme, email, and feature toggles.',
icon: 'SettingOutlined',
route: '/app/settings',
featureFlag: null,
order: 2,
category: 'essentials',
estimatedMinutes: 4,
buildSteps: settingsSteps,
},
{
id: 'campaigns',
title: 'Advocacy Campaigns',
description: 'Create letter-writing campaigns to elected representatives.',
icon: 'SendOutlined',
route: '/app/campaigns',
featureFlag: 'enableInfluence',
order: 10,
category: 'features',
estimatedMinutes: 4,
buildSteps: campaignsSteps,
},
{
id: 'map-locations',
title: 'Map & Locations',
description: 'Manage geographic locations, import data, and track supporters.',
icon: 'EnvironmentOutlined',
route: '/app/map',
featureFlag: 'enableMap',
order: 11,
category: 'features',
estimatedMinutes: 3,
buildSteps: mapLocationsSteps,
},
{
id: 'media-library',
title: 'Media Library',
description: 'Upload, organize, and publish videos and photos.',
icon: 'PlaySquareOutlined',
route: '/app/media/library',
featureFlag: 'enableMediaFeatures',
order: 12,
category: 'features',
estimatedMinutes: 3,
buildSteps: mediaLibrarySteps,
},
{
id: 'user-management',
title: 'User Management',
description: 'Create accounts, assign roles, and manage access.',
icon: 'TeamOutlined',
route: '/app/users',
featureFlag: null,
order: 13,
category: 'features',
estimatedMinutes: 3,
buildSteps: userManagementSteps,
},
];
/**
* Get sections available for the current user/settings context.
* Filters by feature flags role filtering is done at the component level.
*/
export function getAvailableSections(
settings: Record<string, unknown>
): TourSection[] {
return tourRegistry.filter((section) => {
if (section.featureFlag && !settings[section.featureFlag]) return false;
return true;
}).sort((a, b) => a.order - b.order);
}

View File

@ -0,0 +1,57 @@
import type { TourStepProps } from 'antd';
import type { SiteSettings } from '@/types/api';
/** Unique identifier for each tour section */
export type TourSectionId =
| 'getting-started'
| 'dashboard'
| 'settings'
| 'campaigns'
| 'map-locations'
| 'media-library'
| 'user-management';
/** Context passed to step builder functions */
export interface TourStepContext {
settings: SiteSettings;
isMobile: boolean;
}
/** Category for grouping sections in the Tour Hub */
export type TourCategory = 'essentials' | 'features';
/** Registry entry defining a tour section */
export interface TourSection {
id: TourSectionId;
title: string;
description: string;
icon: string;
/** Route the user must be on for this tour to run. null = layout-level (AppLayout). */
route: string | null;
/** Feature flag key that must be truthy. null = always available. */
featureFlag: keyof SiteSettings | null;
/** Sort order in the hub UI */
order: number;
category: TourCategory;
/** Estimated time to complete in minutes */
estimatedMinutes: number;
/** Function that builds steps dynamically */
buildSteps: (ctx: TourStepContext) => TourStepProps[];
}
/** Per-section progress as stored in Zustand */
export interface SectionProgress {
currentStep: number;
totalSteps: number;
completed: boolean;
lastActiveAt: string | null;
}
/**
* Helper to create a target function that satisfies antd's Tour target type.
* antd expects `(() => HTMLElement) | (() => null)` but querySelector returns
* `HTMLElement | null`. We cast through unknown to bridge the union gap.
*/
export function domTarget(selector: string): (() => HTMLElement) | (() => null) {
return (() => document.querySelector(selector)) as (() => HTMLElement) | (() => null);
}

View File

@ -0,0 +1,50 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Grid } from 'antd';
import { useTourStore } from '../../stores/tour.store';
import { useSettingsStore } from '../../stores/settings.store';
import { tourRegistry } from './tourRegistry';
import type { TourSectionId } from './types';
/**
* Hook to launch a specific tour section from any component.
* Handles navigation to the section's route if needed.
*/
export function useTourLauncher(sectionId: TourSectionId) {
const navigate = useNavigate();
const { sections, launchSection } = useTourStore();
const settings = useSettingsStore((s) => s.settings);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const section = useMemo(
() => tourRegistry.find((s) => s.id === sectionId),
[sectionId]
);
const available = useMemo(() => {
if (!section || !settings) return false;
if (section.featureFlag && !settings[section.featureFlag]) return false;
return true;
}, [section, settings]);
const progress = sections[sectionId] ?? null;
const inProgress = progress !== null && !progress.completed && progress.currentStep > 0;
const completed = progress?.completed ?? false;
const launch = useCallback(() => {
if (!section || !settings) return;
const ctx = { settings, isMobile };
const steps = section.buildSteps(ctx);
if (steps.length === 0) return;
launchSection(sectionId, steps.length);
// Navigate to the section's page if it has a route
if (section.route) {
navigate(section.route);
}
}, [section, settings, isMobile, sectionId, launchSection, navigate]);
return { available, launch, inProgress, completed, progress };
}

View File

@ -55,6 +55,7 @@ import QrCodeModal from '@/components/QrCodeModal';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import type { Video } from '@/components/media/VideoPickerModal';
import { useSettingsStore } from '@/stores/settings.store';
import { PageTour } from '@/components/tour/PageTour';
const { TextArea } = Input;
@ -270,6 +271,7 @@ export default function CampaignsPage() {
title: 'Status',
dataIndex: 'status',
key: 'status',
onHeaderCell: () => ({ 'data-tour-campaigns-status': true } as Record<string, unknown>),
render: (status: CampaignStatus) => (
<Tag color={statusColors[status]}>{status}</Tag>
),
@ -310,6 +312,7 @@ export default function CampaignsPage() {
{
title: 'Actions',
key: 'actions',
onHeaderCell: () => ({ 'data-tour-campaigns-actions': true } as Record<string, unknown>),
render: (_: unknown, record: Campaign) => (
<Space>
{record.status === 'ACTIVE' && (
@ -556,28 +559,30 @@ export default function CampaignsPage() {
/>
</Col>
<Col flex="auto" style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)} data-tour-campaigns-create>
Create Campaign
</Button>
</Col>
</Row>
<Table<Campaign>
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
/>
<div data-tour-campaigns-table>
<Table<Campaign>
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
/>
</div>
</div>
{/* Create Drawer */}
@ -683,6 +688,7 @@ export default function CampaignsPage() {
}}
title="Select Cover Video"
/>
<PageTour sectionId="campaigns" />
</>
);
}

View File

@ -55,6 +55,7 @@ import LatencyBandsChart from '@/components/dashboard/LatencyBandsChart';
import ContainerPopover from '@/components/dashboard/ContainerPopover';
import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
import ActivityFeedCard from '@/components/dashboard/ActivityFeedCard';
import { PageTour } from '@/components/tour/PageTour';
import TodayEventsCard from '@/components/dashboard/TodayEventsCard';
import ChatNotifierCard from '@/components/dashboard/ChatNotifierCard';
import TopVideosCard from '@/components/dashboard/TopVideosCard';
@ -336,7 +337,7 @@ export default function DashboardPage() {
)}
</Flex>
{activeView === 'dashboard' && (
<Flex gap={4} wrap="wrap" justify="flex-end">
<Flex gap={4} wrap="wrap" justify="flex-end" data-tour-dashboard-actions>
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
@ -426,7 +427,7 @@ export default function DashboardPage() {
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
{summary && (
<Card size="small" style={{ marginBottom: 16 }} styles={{ body: { padding: '10px 16px' } }}>
<Card size="small" style={{ marginBottom: 16 }} styles={{ body: { padding: '10px 16px' } }} data-tour-dashboard-stats>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex gap={0} wrap="wrap" align="center">
{weather && (
@ -666,7 +667,7 @@ export default function DashboardPage() {
</Card>
</div>
<div className="db-mi">
<div className="db-mi" data-tour-dashboard-activity>
<ActivityFeedCard />
</div>
{showEvents && (
@ -829,6 +830,7 @@ export default function DashboardPage() {
title={<><ContainerOutlined style={{ marginRight: 6 }} />Infrastructure</>}
size="small"
style={{ height: '100%' }}
data-tour-dashboard-services
extra={
<Flex gap={8} align="center">
{health && (
@ -1000,6 +1002,7 @@ export default function DashboardPage() {
)}
</>}
<PageTour sectionId="dashboard" />
</div>
);
}

View File

@ -59,6 +59,7 @@ import {
MobileOutlined,
DesktopOutlined,
CalendarOutlined,
ClearOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -574,6 +575,40 @@ export default function DocsPage() {
const isMobile = !screens.md;
const { token } = theme.useToken();
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [resetting, setResetting] = useState(false);
const confirmAndReset = useCallback(() => {
if (!isSuperAdmin) return;
Modal.confirm({
title: 'Reset Documentation Site',
content: (
<div>
<p>This will reset all documentation content to a baseline template.</p>
<p><strong>Preserved:</strong> header config, analytics tracking, hooks, assets, stylesheets, blog.</p>
<p><strong>Deleted:</strong> all custom content pages.</p>
<p>A backup will be created automatically.</p>
</div>
),
okText: 'Reset Site',
okButtonProps: { danger: true },
onOk: async () => {
setResetting(true);
try {
const { data } = await api.post('/docs/reset');
message.success(`Site reset complete. ${data.filesReset} files reset, ${data.filesPreserved} preserved.`);
// Refresh file tree
const treeRes = await api.get('/docs/files');
setFileTree(treeRes.data.tree || []);
setSelectedFile(null);
setFileContent('');
} catch {
message.error('Failed to reset documentation site');
} finally {
setResetting(false);
}
},
});
}, [isSuperAdmin]);
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
const [config, setConfig] = useState<ServicesConfig | null>(null);
@ -1531,10 +1566,13 @@ export default function DocsPage() {
<Tooltip title="Build static site">
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
</Tooltip>
<Tooltip title="Reset site to baseline">
<Button type="text" danger icon={<ClearOutlined />} onClick={confirmAndReset} loading={resetting} size="middle" />
</Tooltip>
</>
)}
</Space>
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild]);
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild, resetting, confirmAndReset]);
// Inject header
useEffect(() => {

View File

@ -3,6 +3,8 @@ import { useParams, Navigate } from 'react-router-dom';
import { Spin, Result } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { api } from '@/lib/api';
import axios from 'axios';
import { getErrorMessage } from '@/utils/getErrorMessage';
/**
* Jitsi TOKEN_AUTH_URL bridge page.
@ -35,11 +37,11 @@ export default function JitsiAuthPage() {
const protocol = window.location.protocol;
window.location.replace(`${protocol}//${meetHost}/${room}?jwt=${data.token}`);
} catch (err: any) {
} catch (err: unknown) {
if (cancelled) return;
const status = err?.response?.status || 500;
const message = err?.response?.data?.error || 'Failed to generate meeting token';
setError({ status, message });
const status = axios.isAxiosError(err) ? err.response?.status || 500 : 500;
const msg = getErrorMessage(err, 'Failed to generate meeting token');
setError({ status, message: msg });
}
}

View File

@ -79,7 +79,9 @@ import {
SUPPORT_LEVEL_COLORS,
} from '@/types/api';
import AdminMapView from '@/components/map/AdminMapView';
import { PageTour } from '@/components/tour/PageTour';
import AreaImportWizard from '@/components/map/AreaImportWizard';
import axios from 'axios';
const { Text } = Typography;
const { TextArea } = Input;
@ -284,8 +286,8 @@ export default function LocationsPage() {
}
setAllLocations(data);
} catch (err: any) {
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
} catch (err: unknown) {
if (!axios.isCancel(err) && !(err instanceof DOMException && err.name === 'AbortError')) {
message.error('Failed to load map locations');
}
} finally {
@ -1145,7 +1147,7 @@ export default function LocationsPage() {
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>Import CSV</Button>
<Button icon={<AimOutlined />} onClick={handleGeocodeMissing} loading={geocodingMissing}>Geocode Missing</Button>
<Button icon={<TableOutlined />} onClick={() => setBulkGeocodeDrawerOpen(true)}>Bulk Re-Geocode</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateDrawerOpen(true)}>Add Location</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateDrawerOpen(true)} data-tour-map-add>Add Location</Button>
</Space>
</Row>
@ -1159,7 +1161,7 @@ export default function LocationsPage() {
children: (
<>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} data-tour-map-filters>
<Col xs={24} sm={14} md={12}>
<Input
placeholder="Search address, postal code"
@ -1198,36 +1200,38 @@ export default function LocationsPage() {
)}
</Row>
<Table<Location>
columns={columns}
dataSource={locations}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
expandable={{
expandedRowRender,
rowExpandable: (record) => (record.totalUnits ?? 0) > 1 || (record.addresses && record.addresses.length > 0) || false,
}}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} locations`,
}}
onChange={handleTableChange}
locale={{ emptyText: (debouncedSearch || confidenceFilter)
? 'No locations match your filters.'
: <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No locations yet.</div>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>Import CSV</Button>
</div>
}}
/>
<div data-tour-map-table>
<Table<Location>
columns={columns}
dataSource={locations}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
expandable={{
expandedRowRender,
rowExpandable: (record) => (record.totalUnits ?? 0) > 1 || (record.addresses && record.addresses.length > 0) || false,
}}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} locations`,
}}
onChange={handleTableChange}
locale={{ emptyText: (debouncedSearch || confidenceFilter)
? 'No locations match your filters.'
: <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No locations yet.</div>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>Import CSV</Button>
</div>
}}
/>
</div>
</>
),
},
@ -2045,6 +2049,7 @@ export default function LocationsPage() {
</>
) : null}
</Drawer>
<PageTour sectionId="map-locations" />
</>
);
}

View File

@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Card, Typography, Form, Input, Button, Result } from 'antd';
import { LockOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text } = Typography;
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
@ -32,9 +33,8 @@ export default function ResetPasswordPage() {
password: values.password,
});
setState('success');
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to reset password';
setErrorMessage(msg);
} catch (err: unknown) {
setErrorMessage(getErrorMessage(err, 'Failed to reset password'));
setState('error');
} finally {
setSubmitting(false);

View File

@ -58,6 +58,7 @@ import {
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
import { PageTour } from '@/components/tour/PageTour';
import type { AppOutletContext } from '@/components/AppLayout';
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult, UpgradeHistoryResponse } from '@/types/api';
@ -172,7 +173,7 @@ export default function SettingsPage() {
label: 'Organization',
icon: <SettingOutlined />,
children: (
<div style={{ maxWidth: 600 }}>
<div style={{ maxWidth: 600 }} data-tour-settings-org>
<Form.Item label="Organization Name" name="organizationName">
<Input placeholder="Changemaker Lite" />
</Form.Item>
@ -198,7 +199,7 @@ export default function SettingsPage() {
key: 'theme',
label: 'Theme Colors',
children: (
<div style={{ maxWidth: 600 }}>
<div style={{ maxWidth: 600 }} data-tour-settings-theme>
<Text strong style={{ fontSize: 15 }}>Admin Theme</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="Primary Color" name="adminColorPrimary">
@ -249,7 +250,7 @@ export default function SettingsPage() {
const isMailhog = (settings?.smtpActiveProvider || 'mailhog') === 'mailhog';
return (
<div style={{ maxWidth: 600 }}>
<div style={{ maxWidth: 600 }} data-tour-settings-email>
{/* Current Configuration Summary */}
{eff && (
<Card size="small" style={{ marginBottom: 24 }}>
@ -450,7 +451,7 @@ export default function SettingsPage() {
key: 'features',
label: 'Feature Toggles',
children: (
<div>
<div data-tour-settings-features>
<Alert
type="info"
message="Disabling a module hides it from navigation but does not delete data."
@ -672,7 +673,7 @@ export default function SettingsPage() {
key: 'system',
label: 'System',
icon: <CloudSyncOutlined />,
children: <SystemUpgradeTab />,
children: <div data-tour-settings-domains><SystemUpgradeTab /></div>,
},
];
@ -684,6 +685,7 @@ export default function SettingsPage() {
Save Settings
</Button>
</div>
<PageTour sectionId="settings" />
</Form>
);
}

View File

@ -62,6 +62,7 @@ import type {
} from '@/types/api';
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
import EditModeModal from '@/components/shifts/EditModeModal';
import { getErrorMessage } from '@/utils/getErrorMessage';
import ShiftsCalendar from '@/components/shifts/ShiftsCalendar';
const { Text } = Typography;
@ -1100,8 +1101,8 @@ export default function ShiftsPage() {
message.success('Video briefing created');
fetchShifts();
setEditingShift({ ...editingShift, meeting: data, meetingId: data.id });
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to create video briefing');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to create video briefing'));
}
}}
>

View File

@ -68,6 +68,7 @@ import type {
ServicesConfig,
} from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
import { PageTour } from '@/components/tour/PageTour';
const roleColors: Record<UserRole, string> = {
@ -449,6 +450,7 @@ export default function UsersPage() {
title: 'Status',
dataIndex: 'status',
key: 'status',
onHeaderCell: () => ({ 'data-tour-users-status': true } as Record<string, unknown>),
render: (status: UserStatus) => (
<Tag color={statusColors[status]}>{status.replace(/_/g, ' ')}</Tag>
),
@ -535,6 +537,7 @@ export default function UsersPage() {
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateDrawerOpen(true)}
data-tour-users-create
>
Create User
</Button>
@ -726,22 +729,24 @@ export default function UsersPage() {
}]}
/>
<Table<User>
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} users`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No users found' }}
/>
<div data-tour-users-table>
<Table<User>
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} users`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No users found' }}
/>
</div>
</div>
{/* Create Drawer */}
@ -1141,6 +1146,7 @@ export default function UsersPage() {
rows={3}
/>
</Modal>
<PageTour sectionId="user-management" />
</>
);
}

View File

@ -23,6 +23,7 @@ import {
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import axios from 'axios';
const { TextArea } = Input;
@ -147,8 +148,8 @@ export default function ImpactStoriesPage() {
}
setModalOpen(false);
fetchStories(pagination.current, pagination.pageSize);
} catch (err: any) {
if (err?.response?.data?.error?.message) {
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
}

View File

@ -10,6 +10,7 @@ import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
import FeatureGate from '@/components/FeatureGate';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface DailyData {
date: string;
@ -85,8 +86,8 @@ export default function AdAnalyticsDashboardPage() {
params: { days },
});
setData(result);
} catch (err: any) {
setError(err?.response?.data?.error || 'Failed to load analytics');
} catch (err: unknown) {
setError(getErrorMessage(err, 'Failed to load analytics'));
} finally {
setLoading(false);
}

View File

@ -11,6 +11,7 @@ import { useOutletContext } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
import type { AnalyticsOverviewResponse, TopVideosResponse } from '@/types/media';
import type { AppOutletContext } from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
export default function AnalyticsDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -41,9 +42,9 @@ export default function AnalyticsDashboardPage() {
setError(null);
const response = await mediaApi.get('/videos/analytics/overview');
setOverview(response.data);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch analytics overview:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally {
setLoading(false);
}
@ -55,7 +56,7 @@ export default function AnalyticsDashboardPage() {
params: { metric: topMetric, limit: 10 },
});
setTopVideos(response.data);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch top videos:', error);
}
};

View File

@ -35,6 +35,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@ -231,8 +232,8 @@ export default function CommentModerationPage() {
message.success('Word added to filter');
setNewWord('');
fetchWordFilters();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to add word');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to add word'));
}
};

View File

@ -48,6 +48,8 @@ import GalleryAdCard from '@/components/media/GalleryAdCard';
import PhotoPickerModal from '@/components/media/PhotoPickerModal';
import type { Photo } from '@/components/media/PhotoPickerModal';
import dayjs from 'dayjs';
import axios from 'axios';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@ -200,10 +202,10 @@ export default function GalleryAdsPage() {
setDrawerOpen(false);
fetchAds();
} catch (err: any) {
if (err?.response?.data?.error) {
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.error) {
message.error(err.response.data.error);
} else if (!err?.errorFields) {
} else if (!(err instanceof Object && 'errorFields' in err)) {
message.error('Failed to save ad');
}
} finally {
@ -216,8 +218,8 @@ export default function GalleryAdsPage() {
await api.delete(`/gallery-ads/admin/${id}`);
message.success('Ad deleted');
fetchAds();
} catch (err: any) {
message.error(err?.response?.data?.error || 'Failed to delete ad');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to delete ad'));
}
};

View File

@ -44,6 +44,8 @@ import FetchVideosDrawer from '@/components/media/FetchVideosDrawer';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
import { getErrorMessage } from '@/utils/getErrorMessage';
import { PageTour } from '@/components/tour/PageTour';
type MediaTab = 'Videos' | 'Photos' | 'Albums';
@ -150,8 +152,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get<VideosListResponse>('/videos', { params });
setVideos(data.videos);
setVideoPagination((prev) => ({ ...prev, total: data.total }));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to load videos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to load videos'));
} finally {
setLoading(false);
}
@ -172,8 +174,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get<PhotosListResponse>('/photos', { params });
setPhotos(data.photos);
setPhotoPagination((prev) => ({ ...prev, total: data.total }));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to load photos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to load photos'));
} finally {
setLoading(false);
}
@ -191,8 +193,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get('/albums', { params });
setAlbums(data.albums || []);
setAlbumPagination((prev) => ({ ...prev, total: data.total }));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to load albums');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to load albums'));
} finally {
setLoading(false);
}
@ -205,8 +207,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>('/shorts/scan');
message.success(`Classified ${data.classified} shorts, declassified ${data.declassified}. Total shorts: ${data.totalShorts}`);
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to scan shorts');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to scan shorts'));
} finally {
setScanningShorts(false);
}
@ -231,8 +233,8 @@ export default function LibraryPage() {
setDeleteModalOpen(false);
setSelectedVideoIds([]);
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete videos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to delete videos'));
} finally {
setDeleting(false);
}
@ -249,8 +251,8 @@ export default function LibraryPage() {
await mediaApi.delete(`/videos/${video.id}`);
message.success('Video deleted');
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete video');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to delete video'));
}
},
});
@ -266,8 +268,8 @@ export default function LibraryPage() {
message.success(`"${video.title || video.filename}" published`);
}
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to toggle publish');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to toggle publish'));
}
};
@ -293,8 +295,8 @@ export default function LibraryPage() {
message.success('Photo published');
}
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to toggle publish');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to toggle publish'));
}
};
@ -309,8 +311,8 @@ export default function LibraryPage() {
await mediaApi.delete(`/photos/${photo.id}`);
message.success('Photo deleted');
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete photo');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to delete photo'));
}
},
});
@ -322,8 +324,8 @@ export default function LibraryPage() {
message.success(`Published ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to publish photos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to publish photos'));
}
};
@ -333,8 +335,8 @@ export default function LibraryPage() {
message.success(`Deleted ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete photos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to delete photos'));
}
};
@ -354,7 +356,7 @@ export default function LibraryPage() {
return (
<div>
{/* Media Type Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }} data-tour-media-tabs>
<Segmented
value={mediaTab}
onChange={handleTabChange}
@ -367,7 +369,7 @@ export default function LibraryPage() {
</div>
{/* Toolbar */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }} data-tour-media-filters>
<Input
placeholder={`Search ${mediaTab.toLowerCase()}...`}
prefix={<SearchOutlined />}
@ -438,7 +440,7 @@ export default function LibraryPage() {
{/* Upload button */}
{mediaTab === 'Videos' && (
<>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadVideoOpen(true)}>Upload</Button>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadVideoOpen(true)} data-tour-media-upload>Upload</Button>
<Tooltip title="Fetch from URL">
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
</Tooltip>
@ -742,6 +744,7 @@ export default function LibraryPage() {
onClose={() => setSelectedAlbumId(null)}
onRefresh={fetchAlbums}
/>
<PageTour sectionId="media-library" />
</div>
);
}

View File

@ -6,6 +6,7 @@ import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { Job, JobsListResponse } from '@/types/media';
import type { AppOutletContext } from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
export default function MediaJobsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -30,7 +31,7 @@ export default function MediaJobsPage() {
try {
const { data } = await mediaApi.get<JobsListResponse>('/jobs');
setJobs(data.jobs);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to load jobs:', error);
// Don't show error message on polling failures to avoid spam
}
@ -41,8 +42,8 @@ export default function MediaJobsPage() {
await mediaApi.post(`/jobs/${jobId}/cancel`);
message.success('Job cancelled');
fetchJobs();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to cancel job');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to cancel job'));
}
};
@ -54,8 +55,8 @@ export default function MediaJobsPage() {
setSelectedJob(null);
}
fetchJobs();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete job');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to delete job'));
}
};
@ -65,8 +66,8 @@ export default function MediaJobsPage() {
await mediaApi.delete('/jobs/completed');
message.success('Completed jobs cleaned up');
fetchJobs();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to clean up jobs');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to clean up jobs'));
} finally {
setLoading(false);
}

View File

@ -31,6 +31,7 @@ import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
import axios from 'axios';
const { Text } = Typography;
@ -159,8 +160,8 @@ export default function PlaylistManagementPage() {
message.success('Playlist featured');
fetchAll();
fetchFeatured();
} catch (error: any) {
if (error.response?.status === 409) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.warning('Already featured');
} else {
message.error('Failed to feature playlist');

View File

@ -30,6 +30,7 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { Link } from 'react-router-dom';
import axios from 'axios';
const { Title, Text } = Typography;
@ -98,11 +99,11 @@ export default function MediaViewerPage() {
});
}
}
} catch (error: any) {
if (error.response?.status === 404) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
message.error('Video not found');
navigate('/gallery');
} else if (error.response?.status === 401) {
} else if (axios.isAxiosError(error) && error.response?.status === 401) {
Modal.confirm({
title: 'Login Required',
content: 'This video is locked. Please log in to watch.',
@ -193,8 +194,8 @@ export default function MediaViewerPage() {
}
message.success(response.data.upvoted ? 'Upvoted!' : 'Upvote removed');
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.warning('Please log in to upvote');
} else {
message.error('Failed to upvote');

View File

@ -7,6 +7,7 @@ import {
SaveOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@ -160,9 +161,8 @@ export default function MySettingsPage() {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
const msg = err?.response?.data?.message || 'Failed to change password';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to change password'));
} finally {
setChangingPassword(false);
}

View File

@ -27,6 +27,7 @@ import PublicNavBar from '@/components/PublicNavBar';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistDetail } from '@/types/media';
import axios from 'axios';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@ -81,8 +82,8 @@ export default function PlaylistViewerPage() {
} else if (data.videos?.length > 0) {
setCurrentVideoId(data.videos[0]!.mediaId);
}
} catch (error: any) {
if (error.response?.status === 404) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
message.error('Playlist not found');
navigate('/gallery/curated');
} else {

View File

@ -44,6 +44,7 @@ import {
} from '@/types/api';
import dayjs from 'dayjs';
import { useSettingsStore } from '@/stores/settings.store';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@ -184,9 +185,8 @@ export default function ResponseWallPage() {
form.resetFields();
fetchResponses();
fetchStats();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to submit response';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit response'));
} finally {
setSubmitting(false);
}

View File

@ -42,6 +42,7 @@ import {
VOTE_VALUE_LABELS,
} from '@/types/api';
import { useAuthStore } from '@/stores/auth.store';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@ -153,8 +154,8 @@ export default function SchedulingPollPage() {
setHasVoted(true);
setVoteSuccess(true);
fetchPoll();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to submit votes');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit votes'));
} finally {
setSubmitting(false);
}

View File

@ -27,6 +27,7 @@ import { useNavigate } from 'react-router-dom';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import LiveChat from '@/components/media/LiveChat';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -465,8 +466,8 @@ export default function ShortsPage() {
}
message.success(data.upvoted ? 'Upvoted!' : 'Upvote removed');
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Log in to upvote');
} else {
message.error('Failed to upvote');

View File

@ -4,6 +4,7 @@ import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useDebounce } from '@/hooks/useDebounce';
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@ -83,9 +84,8 @@ export default function NewConversationModal({ open, onClose, onCreated }: Props
message.success('Message queued');
onCreated(data);
handleReset();
} catch (err: any) {
const errMsg = err.response?.data?.error || 'Failed to send message';
message.error(errMsg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to send message'));
} finally {
setSending(false);
}

View File

@ -28,6 +28,8 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import type { PaginationMeta } from '@/types/api';
import axios from 'axios';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { RangePicker } = DatePicker;
@ -128,8 +130,8 @@ export default function ChallengesAdminPage() {
}
setModalOpen(false);
load();
} catch (err: any) {
if (err.response?.data?.error?.message) {
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
} finally {
@ -147,8 +149,8 @@ export default function ChallengesAdminPage() {
message.success(`Challenge ${action}d`);
}
load();
} catch (err: any) {
message.error(err.response?.data?.error?.message || `Failed to ${action}`);
} catch (err: unknown) {
message.error(getErrorMessage(err, `Failed to ${action}`));
}
};

View File

@ -10,6 +10,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@ -99,8 +100,8 @@ export default function SpotlightAdminPage() {
setNominateOpen(false);
nominateForm.resetFields();
fetchSpotlights(1);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to nominate');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to nominate'));
}
};
@ -109,8 +110,8 @@ export default function SpotlightAdminPage() {
await api.post(`/social/spotlight/admin/${id}/approve`);
message.success('Spotlight approved');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to approve');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to approve'));
}
};
@ -123,8 +124,8 @@ export default function SpotlightAdminPage() {
setFeatureOpen(false);
setFeatureMonth(null);
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to feature');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to feature'));
}
};
@ -133,8 +134,8 @@ export default function SpotlightAdminPage() {
await api.post(`/social/spotlight/admin/${id}/archive`);
message.success('Spotlight archived');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to archive');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to archive'));
}
};
@ -145,8 +146,8 @@ export default function SpotlightAdminPage() {
message.success('Spotlight updated');
setEditOpen(false);
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to update');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to update'));
}
};
@ -155,8 +156,8 @@ export default function SpotlightAdminPage() {
await api.delete(`/social/spotlight/admin/${id}`);
message.success('Spotlight deleted');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to delete');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to delete'));
}
};

View File

@ -12,6 +12,7 @@ import { useAuthStore } from '@/stores/auth.store';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import ChallengeLeaderboard from '@/components/social/ChallengeLeaderboard';
import TeamJoinCard from '@/components/social/TeamJoinCard';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface ChallengeDetail {
id: string;
@ -77,8 +78,8 @@ export default function ChallengeDetailPage() {
await api.post(`/social/challenges/${id}/teams/${myTeam.id}/leave`);
message.success('Left team');
load();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to leave team');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to leave team'));
} finally {
setLeaving(false);
}

View File

@ -24,6 +24,7 @@ import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
import type { PersonalCalendarItem } from '@/types/api';
import axios from 'axios';
const { Title, Text } = Typography;
@ -50,8 +51,8 @@ export default function FriendCalendarPage() {
{ params: { startDate, endDate } },
);
setItems(data.items);
} catch (err: any) {
if (err.response?.status === 403) {
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 403) {
message.error('This user has not shared their calendar');
}
} finally {

View File

@ -9,6 +9,7 @@ import type { SocialGroupDetail } from '@/types/social';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import dayjs from 'dayjs';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text } = Typography;
@ -37,8 +38,8 @@ export default function GroupDetailPage() {
try {
const { data } = await api.get<SocialGroupDetail>(`/social/groups/${id}`);
setGroup(data);
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Failed to load group');
} catch (err: unknown) {
setError(getErrorMessage(err, 'Failed to load group'));
} finally {
setLoading(false);
}
@ -56,8 +57,8 @@ export default function GroupDetailPage() {
const { data } = await api.post<{ token: string; room: string; slug: string }>(`/social/groups/${id}/call/token`);
window.open(`/meet/${data.slug}`, '_blank');
fetchGroup();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to start call');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to start call'));
} finally {
setCallLoading(false);
}
@ -68,8 +69,8 @@ export default function GroupDetailPage() {
try {
const { data } = await api.post<{ token: string; room: string; slug: string }>(`/social/groups/${id}/call/token`);
window.open(`/meet/${data.slug}`, '_blank');
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to join call');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to join call'));
} finally {
setCallLoading(false);
}
@ -86,8 +87,8 @@ export default function GroupDetailPage() {
await api.post(`/social/groups/${id}/call/end`);
message.success('Call ended');
fetchGroup();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to end call');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to end call'));
}
},
});

View File

@ -10,6 +10,7 @@ import PokeButton from '@/components/social/PokeButton';
import MessageButton from '@/components/social/MessageButton';
import type { SocialProfile, SocialProfileMe, AchievementWithProgress } from '@/types/social';
import dayjs from 'dayjs';
import { getErrorMessage } from '@/utils/getErrorMessage';
export default function SocialProfilePage() {
const { userId } = useParams<{ userId: string }>();
@ -41,8 +42,8 @@ export default function SocialProfilePage() {
setProfileOther(profileRes.data);
setAchievements(achRes.data.achievements || []);
}
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Failed to load profile');
} catch (err: unknown) {
setError(getErrorMessage(err, 'Failed to load profile'));
} finally {
setLoading(false);
}

View File

@ -29,6 +29,7 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { useCanvassStore } from '@/stores/canvass.store';
import FriendsAttendingBadge from '@/components/social/FriendsAttendingBadge';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@ -132,9 +133,8 @@ export default function VolunteerShiftsPage() {
await api.post(`/map/shifts/volunteer/${shift.id}/signup`);
message.success('Signed up successfully!');
fetchUpcoming();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to sign up';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to sign up'));
} finally {
setActionLoading(null);
}
@ -155,9 +155,8 @@ export default function VolunteerShiftsPage() {
message.success('Signup cancelled');
if (tab === 'upcoming') fetchUpcoming();
else fetchSignups();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to cancel signup';
message.error(msg);
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to cancel signup'));
} finally {
setActionLoading(null);
}

View File

@ -0,0 +1,167 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { TourSectionId, SectionProgress } from '@/components/tour/types';
interface TourState {
/** Schema version for migration */
storeVersion: number;
/** Legacy flag — kept for backward compat */
tourCompleted: boolean;
/** Per-section progress map */
sections: Record<string, SectionProgress>;
/** Currently active section (ephemeral, not persisted) */
activeSectionId: TourSectionId | null;
/** Current step within active section (ephemeral) */
activeStep: number;
/** Tour Hub drawer open state (ephemeral) */
hubOpen: boolean;
}
interface TourActions {
openHub: () => void;
closeHub: () => void;
launchSection: (sectionId: TourSectionId, totalSteps: number) => void;
setStep: (step: number) => void;
completeActiveSection: () => void;
pauseActiveSection: () => void;
resetSection: (sectionId: TourSectionId) => void;
resetAllSections: () => void;
/** Legacy compat */
completeTour: () => void;
resetTour: () => void;
}
export const useTourStore = create<TourState & TourActions>()(
persist(
(set, get) => ({
storeVersion: 2,
tourCompleted: false,
sections: {},
activeSectionId: null,
activeStep: 0,
hubOpen: false,
openHub: () => set({ hubOpen: true }),
closeHub: () => set({ hubOpen: false }),
launchSection: (sectionId, totalSteps) => {
const existing = get().sections[sectionId];
const startStep = existing && !existing.completed ? existing.currentStep : 0;
set({
activeSectionId: sectionId,
activeStep: startStep,
hubOpen: false,
sections: {
...get().sections,
[sectionId]: {
currentStep: startStep,
totalSteps,
completed: false,
lastActiveAt: new Date().toISOString(),
},
},
});
},
setStep: (step) => {
const { activeSectionId, sections } = get();
if (!activeSectionId) return;
const existing = sections[activeSectionId] || { currentStep: 0, totalSteps: 0, completed: false, lastActiveAt: null };
set({
activeStep: step,
sections: {
...sections,
[activeSectionId]: {
...existing,
currentStep: step,
lastActiveAt: new Date().toISOString(),
},
},
});
},
completeActiveSection: () => {
const { activeSectionId, sections } = get();
if (!activeSectionId) return;
const existing = sections[activeSectionId] || { currentStep: 0, totalSteps: 0, completed: false, lastActiveAt: null };
set({
activeSectionId: null,
activeStep: 0,
sections: {
...sections,
[activeSectionId]: {
...existing,
completed: true,
lastActiveAt: new Date().toISOString(),
},
},
});
},
pauseActiveSection: () => {
set({ activeSectionId: null, activeStep: 0 });
},
resetSection: (sectionId) => {
const { sections } = get();
const updated = { ...sections };
delete updated[sectionId];
set({ sections: updated });
},
resetAllSections: () => {
set({ sections: {}, tourCompleted: false });
},
// Legacy compat — maps to getting-started section
completeTour: () => {
set({
tourCompleted: true,
sections: {
...get().sections,
'getting-started': {
currentStep: 0,
totalSteps: 7,
completed: true,
lastActiveAt: new Date().toISOString(),
},
},
});
},
resetTour: () => {
const { sections } = get();
const updated = { ...sections };
delete updated['getting-started'];
set({ tourCompleted: false, sections: updated });
},
}),
{
name: 'cml-tour',
version: 2,
migrate: (persisted: unknown, version: number) => {
if (version < 2) {
// Old shape: { tourCompleted: boolean, tourVersion: number }
const old = persisted as { tourCompleted?: boolean } | null;
const wasCompleted = old?.tourCompleted === true;
return {
storeVersion: 2,
tourCompleted: wasCompleted,
sections: wasCompleted
? { 'getting-started': { currentStep: 0, totalSteps: 7, completed: true, lastActiveAt: null } }
: {},
activeSectionId: null,
activeStep: 0,
hubOpen: false,
};
}
return persisted as TourState & TourActions;
},
partialize: (state) => ({
storeVersion: state.storeVersion,
tourCompleted: state.tourCompleted,
sections: state.sections,
}) as unknown as TourState & TourActions,
}
)
);

View File

@ -0,0 +1,15 @@
import axios from 'axios';
/**
* Safely extract an error message from an unknown error value.
* Handles Axios errors (API responses), standard Error objects, and fallback.
*/
export function getErrorMessage(err: unknown, fallback: string): string {
if (axios.isAxiosError(err)) {
return err.response?.data?.message || err.response?.data?.error?.message || fallback;
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}

42
api/package-lock.json generated
View File

@ -43,6 +43,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
@ -3653,6 +3654,14 @@
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -4391,6 +4400,14 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -4535,6 +4552,14 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -5693,6 +5718,23 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -51,6 +51,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",

View File

@ -58,6 +58,10 @@ async function main() {
});
if (admin) {
console.log(` Found existing admin user: ${admin.email}`);
} else {
console.error('❌ FATAL: No SUPER_ADMIN user exists and none could be created.');
console.error(' Fix INITIAL_ADMIN_PASSWORD in .env (12+ chars, uppercase, lowercase, digit)');
process.exit(2);
}
}

View File

@ -11,6 +11,12 @@ const envSchema = z.object({
ADMIN_URL: z.string().default('http://localhost:3000'),
DOMAIN: z.string().default('cmlite.org'),
// Logging
LOG_DIR: z.string().default('/app/logs'),
// Security
CSP_ENABLED: z.string().default('false'),
// Bunker Ops (Fleet Management)
INSTANCE_LABEL: z.string().default(''),
BUNKER_OPS_ENABLED: z.string().default('false'),

View File

@ -30,6 +30,7 @@ import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes';
import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes';
import { photosPublicRoutes } from './modules/media/routes/photos-public.routes';
import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes';
import { mediaErrorHandler } from './modules/media/middleware/error-handler';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@ -45,6 +46,8 @@ const fastify = Fastify({
trustProxy: true,
});
fastify.setErrorHandler(mediaErrorHandler);
// Graceful shutdown handler
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');

View File

@ -0,0 +1,16 @@
import { randomUUID } from 'crypto';
import { Request, Response, NextFunction } from 'express';
const CORRELATION_HEADER = 'x-request-id';
/**
* Middleware that assigns a unique correlation ID to each request.
* Uses the incoming x-request-id header if present, otherwise generates a new UUID.
* Sets the correlation ID on both the request object and response header.
*/
export function correlationId(req: Request, res: Response, next: NextFunction) {
const id = (req.headers[CORRELATION_HEADER] as string) || randomUUID();
req.correlationId = id;
res.setHeader(CORRELATION_HEADER, id);
next();
}

View File

@ -16,7 +16,7 @@ export class AppError extends Error {
export function errorHandler(
err: Error,
_req: Request,
req: Request,
res: Response,
_next: NextFunction
) {
@ -47,7 +47,13 @@ export function errorHandler(
return;
}
logger.error('Unhandled error:', err);
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
correlationId: req.correlationId,
path: req.path,
method: req.method,
});
res.status(500).json({
error: {

View File

@ -0,0 +1,404 @@
import { cp, rm, readdir, mkdir, writeFile, stat } from 'fs/promises';
import path from 'path';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { docsFilesService } from './docs-files.service';
import { mkdocsConfigService } from './mkdocs-config.service';
const PRESERVED_DIRS = [
'hooks',
'assets',
'javascripts',
'overrides',
'stylesheets',
'blog',
'comments',
'partials',
'includes',
];
const BASELINE_INDEX_MD = `# Welcome to Your MkDocs Site
This site has been reset to baseline configuration.
## Getting Started
- Edit \`docs/index.md\` to change this page
- Add new pages in the \`docs/\` directory
- Configure navigation in \`mkdocs.yml\`
- Customize the theme in \`docs/overrides/\`
## Features Preserved
Your custom code has been preserved in:
- \`hooks/\` - Custom build hooks
- \`assets/\` - Images and static files
- \`javascripts/\` - Custom JavaScript
- \`overrides/\` - Theme overrides
- \`stylesheets/\` - Custom CSS
- \`blog/\` - Blog content
## Next Steps
1. Start adding your content
2. Configure the navigation
3. Customize the appearance
4. Deploy your site
---
*Built with MkDocs Material*
`;
const BASELINE_GETTING_STARTED_MD = `# Getting Started
Welcome to your fresh MkDocs Material site!
## Adding Content
Create new markdown files in the \`docs/\` directory:
\`\`\`bash
docs/
\u251c\u2500\u2500 index.md # Homepage
\u251c\u2500\u2500 getting-started.md # This page
\u251c\u2500\u2500 page1.md # Your content
\u2514\u2500\u2500 page2.md # More content
\`\`\`
## Configuring Navigation
Edit \`mkdocs.yml\` to add navigation:
\`\`\`yaml
nav:
- Home: index.md
- Getting Started: getting-started.md
- Your Section:
- Page 1: page1.md
- Page 2: page2.md
\`\`\`
## Using the Blog
The blog plugin is already configured. Add posts in \`docs/blog/posts/\`:
\`\`\`markdown
---
date: 2024-01-01
categories:
- News
---
# Your Blog Post Title
Post content here...
\`\`\`
## Customization
- Theme overrides: \`docs/overrides/\`
- Custom CSS: \`docs/stylesheets/\`
- Custom JS: \`docs/javascripts/\`
`;
const HOME_HTML = `{% extends "main.html" %}
{% block extrahead %}
{{ super() }}
<link rel="stylesheet" href="{{ 'stylesheets/home.css' | url }}">
{% endblock %}
{% block content %}
<div class="home-container">
<div class="home-hero">
{% if config.theme.logo %}
<img src="{{ config.theme.logo | url }}" alt="Logo" class="home-logo">
{% endif %}
<h1 class="home-title">Let's get started!</h1>
<p class="home-subtitle">
Your MkDocs Material site is ready for customization.
</p>
<div class="home-actions">
<a href="{{ 'getting-started/' | url }}" class="home-button home-button-primary">
Get Started
</a>
<a href="https://squidfunk.github.io/mkdocs-material/" class="home-button home-button-secondary">
Documentation
</a>
</div>
</div>
<div class="home-features">
<div class="feature-card">
<div class="feature-icon">📝</div>
<h3>Write Content</h3>
<p>Create pages with Markdown</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Customize Theme</h3>
<p>Make it your own</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚀</div>
<h3>Deploy</h3>
<p>Share with the world</p>
</div>
</div>
</div>
{% endblock %}
`;
const HOME_CSS = `/* Simple home page styles */
.home-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.home-hero {
text-align: center;
padding: 4rem 0;
}
.home-logo {
width: 120px;
height: 120px;
margin-bottom: 2rem;
}
.home-title {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem 0;
color: var(--md-primary-fg-color);
}
.home-subtitle {
font-size: 1.25rem;
color: var(--md-default-fg-color--light);
margin: 0 0 2rem 0;
}
.home-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.home-button {
display: inline-block;
padding: 0.75rem 2rem;
border-radius: 0.25rem;
text-decoration: none;
font-weight: 500;
transition: all 0.2s;
}
.home-button-primary {
background: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
}
.home-button-primary:hover {
background: var(--md-primary-fg-color--dark);
transform: translateY(-2px);
}
.home-button-secondary {
border: 2px solid var(--md-primary-fg-color);
color: var(--md-primary-fg-color);
}
.home-button-secondary:hover {
background: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
}
.home-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 4rem;
}
.feature-card {
text-align: center;
padding: 2rem;
border-radius: 0.5rem;
background: var(--md-code-bg-color);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.feature-card h3 {
margin: 0 0 0.5rem 0;
color: var(--md-default-fg-color);
}
.feature-card p {
margin: 0;
color: var(--md-default-fg-color--light);
}
/* Dark mode support */
[data-md-color-scheme="slate"] .home-logo {
filter: brightness(0.9);
}
[data-md-color-scheme="slate"] .feature-card {
background: var(--md-default-fg-color--lightest);
}
/* Mobile responsive */
@media (max-width: 768px) {
.home-title {
font-size: 2rem;
}
.home-subtitle {
font-size: 1rem;
}
.home-actions {
flex-direction: column;
align-items: center;
}
.home-button {
width: 100%;
max-width: 300px;
text-align: center;
}
}
`;
interface ResetResult {
success: boolean;
backupPath: string;
filesReset: number;
filesPreserved: number;
}
async function resetToBaseline(): Promise<ResetResult> {
const docsDir = path.resolve(env.MKDOCS_DOCS_PATH);
const mkdocsRoot = path.dirname(env.MKDOCS_CONFIG_PATH);
const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '_').slice(0, 15);
const backupDir = path.join(mkdocsRoot, 'backups', `docs_backup_${timestamp}`);
const tempDir = path.join('/tmp', `mkdocs-reset-${process.pid}`);
logger.info('Starting docs reset to baseline', { docsDir, backupDir });
// Step a: Create timestamped backup
await mkdir(backupDir, { recursive: true });
await cp(docsDir, path.join(backupDir, 'docs'), { recursive: true });
await cp(env.MKDOCS_CONFIG_PATH, path.join(backupDir, 'mkdocs.yml'));
logger.info('Backup created', { backupDir });
// Step b: Save preserved directories to temp
await mkdir(tempDir, { recursive: true });
let filesPreserved = 0;
for (const dir of PRESERVED_DIRS) {
const srcDir = path.join(docsDir, dir);
try {
const info = await stat(srcDir);
if (info.isDirectory()) {
await cp(srcDir, path.join(tempDir, dir), { recursive: true });
filesPreserved++;
logger.info(`Preserved directory: ${dir}`);
}
} catch {
// Directory doesn't exist, skip
}
}
// Step c: Clear the docs directory
const entries = await readdir(docsDir);
for (const entry of entries) {
await rm(path.join(docsDir, entry), { recursive: true, force: true });
}
logger.info('Docs directory cleared');
// Step d: Write baseline content
let filesReset = 0;
await writeFile(path.join(docsDir, 'index.md'), BASELINE_INDEX_MD, 'utf-8');
filesReset++;
await writeFile(path.join(docsDir, 'getting-started.md'), BASELINE_GETTING_STARTED_MD, 'utf-8');
filesReset++;
// Step e: Restore preserved directories from temp
for (const dir of PRESERVED_DIRS) {
const tempSrcDir = path.join(tempDir, dir);
try {
const info = await stat(tempSrcDir);
if (info.isDirectory()) {
await cp(tempSrcDir, path.join(docsDir, dir), { recursive: true });
logger.info(`Restored directory: ${dir}`);
}
} catch {
// Wasn't preserved, create empty directory
await mkdir(path.join(docsDir, dir), { recursive: true });
}
}
// Step f: Create home.html and home.css templates if they don't exist in preserved overrides/stylesheets
const homeHtmlPath = path.join(docsDir, 'overrides', 'home.html');
try {
await stat(homeHtmlPath);
logger.info('Existing home.html preserved');
} catch {
await mkdir(path.join(docsDir, 'overrides'), { recursive: true });
await writeFile(homeHtmlPath, HOME_HTML, 'utf-8');
filesReset++;
logger.info('Created baseline home.html');
}
const homeCssPath = path.join(docsDir, 'stylesheets', 'home.css');
try {
await stat(homeCssPath);
logger.info('Existing home.css preserved');
} catch {
await mkdir(path.join(docsDir, 'stylesheets'), { recursive: true });
await writeFile(homeCssPath, HOME_CSS, 'utf-8');
filesReset++;
logger.info('Created baseline home.css');
}
// Clean up temp directory
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
// Step g: Invalidate docs file tree cache
await docsFilesService.invalidateTreeCache();
// Step h: Trigger a build
const buildResult = await mkdocsConfigService.triggerBuild();
if (!buildResult.success) {
logger.warn('MkDocs build after reset did not succeed', { output: buildResult.output });
}
logger.info('Docs reset to baseline complete', { filesReset, filesPreserved, backupDir });
return {
success: true,
backupPath: backupDir,
filesReset,
filesPreserved,
};
}
export const docsResetService = {
resetToBaseline,
};

View File

@ -14,6 +14,7 @@ import { docsCollabService } from './docs-collab.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
import { docsResetService } from './docs-reset.service';
const router = Router();
router.use(authenticate);
@ -114,6 +115,21 @@ router.post(
},
);
// POST /api/docs/reset — reset docs content to baseline
router.post(
'/reset',
requireRole('SUPER_ADMIN'),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await docsResetService.resetToBaseline();
res.json(result);
} catch (err) {
logger.error('Docs reset failed', err);
next(err);
}
},
);
// --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config (content editors only)

View File

@ -0,0 +1,20 @@
import { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { logger } from '../../../utils/logger';
export function mediaErrorHandler(
error: FastifyError | Error,
request: FastifyRequest,
reply: FastifyReply
) {
logger.error('Media API error', {
error: error.message,
stack: error.stack,
url: request.url,
method: request.method,
});
const statusCode = 'statusCode' in error ? error.statusCode ?? 500 : 500;
reply.status(statusCode).send({
message: statusCode === 500 ? 'Internal server error' : error.message,
});
}

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