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:
parent
0c634e100f
commit
39d74e7b85
17
.env.example
17
.env.example
@ -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
1
.gitignore
vendored
@ -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
6
admin/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
99
admin/src/components/tour/AdminTour.tsx
Normal file
99
admin/src/components/tour/AdminTour.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
admin/src/components/tour/PageTour.tsx
Normal file
74
admin/src/components/tour/PageTour.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
140
admin/src/components/tour/TourHub.tsx
Normal file
140
admin/src/components/tour/TourHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
admin/src/components/tour/TourSectionCard.tsx
Normal file
125
admin/src/components/tour/TourSectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
admin/src/components/tour/TourTriggerButton.tsx
Normal file
43
admin/src/components/tour/TourTriggerButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
admin/src/components/tour/sections/campaigns.ts
Normal file
58
admin/src/components/tour/sections/campaigns.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
48
admin/src/components/tour/sections/dashboard.ts
Normal file
48
admin/src/components/tour/sections/dashboard.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
79
admin/src/components/tour/sections/getting-started.ts
Normal file
79
admin/src/components/tour/sections/getting-started.ts
Normal 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;
|
||||
}
|
||||
51
admin/src/components/tour/sections/map-locations.ts
Normal file
51
admin/src/components/tour/sections/map-locations.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
51
admin/src/components/tour/sections/media-library.ts
Normal file
51
admin/src/components/tour/sections/media-library.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
55
admin/src/components/tour/sections/settings.ts
Normal file
55
admin/src/components/tour/sections/settings.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
47
admin/src/components/tour/sections/user-management.ts
Normal file
47
admin/src/components/tour/sections/user-management.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
108
admin/src/components/tour/tourRegistry.ts
Normal file
108
admin/src/components/tour/tourRegistry.ts
Normal 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);
|
||||
}
|
||||
57
admin/src/components/tour/types.ts
Normal file
57
admin/src/components/tour/types.ts
Normal 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);
|
||||
}
|
||||
50
admin/src/components/tour/useTourLauncher.ts
Normal file
50
admin/src/components/tour/useTourLauncher.ts
Normal 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 };
|
||||
}
|
||||
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
167
admin/src/stores/tour.store.ts
Normal file
167
admin/src/stores/tour.store.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
);
|
||||
15
admin/src/utils/getErrorMessage.ts
Normal file
15
admin/src/utils/getErrorMessage.ts
Normal 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
42
api/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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...');
|
||||
|
||||
16
api/src/middleware/correlation-id.ts
Normal file
16
api/src/middleware/correlation-id.ts
Normal 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();
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
404
api/src/modules/docs/docs-reset.service.ts
Normal file
404
api/src/modules/docs/docs-reset.service.ts
Normal 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,
|
||||
};
|
||||
@ -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)
|
||||
|
||||
20
api/src/modules/media/middleware/error-handler.ts
Normal file
20
api/src/modules/media/middleware/error-handler.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user