A whole bunch of stuff agian lol I promise to track more closely when we get to more stable state - like end of feb

This commit is contained in:
bunker-admin 2026-02-19 09:41:27 -07:00
parent 1a1f12c45b
commit 435fb8150c
71 changed files with 8498 additions and 652 deletions

View File

@ -339,6 +339,33 @@ cd api && ./test-media-api.sh
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
```
### API Testing Credentials & Login
**Test admin account:** `admin@bnkops.ca` / `ChangeMe2025!` (SUPER_ADMIN role)
**Reliable login method (avoids shell `!` escaping issues):**
1. Write the JSON body to a file using the **Write tool** (NOT echo/printf — the `!` gets backslash-escaped by bash):
```
Write /tmp/login.json → {"email":"admin@bnkops.ca","password":"ChangeMe2025!"}
```
2. Use `curl -d @/tmp/login.json`:
```bash
curl -s -X POST http://localhost:4002/api/auth/login \
-H "Content-Type: application/json" -d @/tmp/login.json
```
3. Extract token and use for authenticated requests:
```bash
TOKEN=$(curl -s -X POST http://localhost:4002/api/auth/login \
-H "Content-Type: application/json" -d @/tmp/login.json \
| python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])")
curl -s http://localhost:4002/api/some-endpoint -H "Authorization: Bearer $TOKEN"
```
**Port mapping:** API container port 4000 → host port **4002**, Admin port 3000 → host port **3002**
**Important:** The `!` character in `ChangeMe2025!` triggers bash history expansion. NEVER pass this password directly in bash command strings. Always use the Write-tool-to-file approach above.
---
## Core Modules Reference

View File

@ -81,6 +81,7 @@ import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
import { ADMIN_ROLES } from '@/types/api';
import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage';
import VerifyEmailPage from '@/pages/VerifyEmailPage';
import ResetPasswordPage from '@/pages/ResetPasswordPage';
@ -248,6 +249,7 @@ export default function App() {
element={<NavigateToCutMap />}
/>
<Route path="/join" element={<QuickJoinPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />

View File

@ -28,7 +28,7 @@ import {
BranchesOutlined,
CloudServerOutlined,
QrcodeOutlined,
VideoCameraOutlined,
PlaySquareOutlined,
FolderOutlined,
HistoryOutlined,
LineChartOutlined,
@ -129,10 +129,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
if (settings?.enableMediaFeatures !== false) {
items.push({
key: 'media-submenu',
icon: <VideoCameraOutlined />,
label: 'Media Library',
icon: <PlaySquareOutlined />,
label: 'Media',
children: [
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Library' },
{ key: '/app/media/analytics', icon: <BarChartOutlined />, label: 'Analytics' },
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
@ -444,10 +444,10 @@ export default function AppLayout() {
</>
)}
{settings?.enableMediaFeatures !== false && (
<Tooltip title="Open Video Gallery">
<Tooltip title="Open Gallery">
<Button
type="text"
icon={<VideoCameraOutlined />}
icon={<PlaySquareOutlined />}
onClick={() => navigate('/gallery')}
>
{!isMobile && 'Gallery'}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme, Space, Grid, Drawer, Button } from 'antd';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined } from '@ant-design/icons';
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
@ -79,10 +79,12 @@ function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.
export default function PublicLayout() {
const { settings } = useSettingsStore();
const { isAuthenticated, logout } = useAuthStore();
const { isAuthenticated, logout, user } = useAuthStore();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const navigate = useNavigate();
const location = useLocation();
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalContext, setAuthModalContext] = useState<'generic' | 'campaign'>('generic');
const [drawerOpen, setDrawerOpen] = useState(false);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
@ -214,9 +216,16 @@ export default function PublicLayout() {
</>
)}
{isAuthenticated ? (
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
<>
{isAdmin ? (
<NavLink to="/app" icon={<AppstoreOutlined />} label="Admin" />
) : (
<NavLink to="/volunteer" icon={<TeamOutlined />} label="Volunteer Portal" />
)}
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
</>
) : (
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
<NavButton onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); }} icon={<LoginOutlined />} label="Sign In" />
)}
</Space>
)}
@ -350,21 +359,37 @@ export default function PublicLayout() {
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{isAuthenticated ? (
<span
role="button"
tabIndex={0}
onClick={() => { logout(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LogoutOutlined /> <span>Logout</span>
</span>
<>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
</Link>
<span
role="button"
tabIndex={0}
onClick={() => { logout(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LogoutOutlined /> <span>Logout</span>
</span>
</>
) : (
<span
role="button"
tabIndex={0}
onClick={() => { setAuthModalOpen(true); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalOpen(true); setDrawerOpen(false); } }}
onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LoginOutlined /> <span>Sign In</span>
@ -378,10 +403,12 @@ export default function PublicLayout() {
onCancel={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
navigate('/campaigns/create');
if (authModalContext === 'campaign') {
navigate('/campaigns/create');
}
}}
title="Sign in to Create a Campaign"
subtitle="Sign in or create an account to submit your own campaign"
title={authModalContext === 'campaign' ? 'Sign in to Create a Campaign' : 'Sign in to your account'}
subtitle={authModalContext === 'campaign' ? 'Sign in or create an account to submit your own campaign' : 'Sign in or create an account to get involved'}
/>
</Layout>
</ConfigProvider>

View File

@ -0,0 +1,223 @@
import { useEffect, useState, useMemo } from 'react';
import { Card, Col, Row, Segmented, Switch, DatePicker, Empty, Spin, Grid, App, Typography } from 'antd';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer,
} from 'recharts';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import MiniDonutChart from '@/components/dashboard/MiniDonutChart';
import type { VisitOutcome, CanvassOutcomeTrendsData } from '@/types/canvass';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
const { RangePicker } = DatePicker;
// Stacking order: positive first, neutral middle, negative last
const OUTCOME_ORDER: VisitOutcome[] = [
'SPOKE_WITH',
'LEFT_LITERATURE',
'NOT_HOME',
'COME_BACK_LATER',
'ALREADY_VOTED',
'REFUSED',
'MOVED',
];
export default function CanvassTrendsCard() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [data, setData] = useState<CanvassOutcomeTrendsData | null>(null);
const [loading, setLoading] = useState(true);
const [granularity, setGranularity] = useState<'day' | 'week'>('day');
const [percentMode, setPercentMode] = useState(false);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
dayjs().subtract(30, 'day'),
dayjs(),
]);
useEffect(() => {
let cancelled = false;
const fetchTrends = async () => {
setLoading(true);
try {
const res = await api.get('/map/canvass/trends', {
params: {
granularity,
dateFrom: dateRange[0].format('YYYY-MM-DD'),
dateTo: dateRange[1].format('YYYY-MM-DD'),
},
});
if (!cancelled) setData(res.data);
} catch {
if (!cancelled) message.error('Failed to load outcome trends');
} finally {
if (!cancelled) setLoading(false);
}
};
fetchTrends();
return () => { cancelled = true; };
}, [granularity, dateRange, message]);
// Transform series for percentage mode
const chartData = useMemo(() => {
if (!data?.series?.length) return [];
if (!percentMode) return data.series;
return data.series.map((point) => {
const total = OUTCOME_ORDER.reduce((sum, o) => sum + ((point[o] as number) || 0), 0);
if (total === 0) return point;
const pct: Record<string, unknown> = { date: point.date };
for (const o of OUTCOME_ORDER) {
pct[o] = Math.round((((point[o] as number) || 0) / total) * 100);
}
return pct;
});
}, [data, percentMode]);
// Donut data from totals
const donutData = useMemo(() => {
if (!data?.totals) return [];
return OUTCOME_ORDER
.filter((o) => (data.totals[o] || 0) > 0)
.map((o) => ({
name: VISIT_OUTCOME_LABELS[o],
value: data.totals[o] || 0,
color: VISIT_OUTCOME_COLORS[o],
}));
}, [data]);
const totalVisits = useMemo(() => {
if (!data?.totals) return 0;
return Object.values(data.totals).reduce((s, v) => s + (v || 0), 0);
}, [data]);
const hasData = chartData.length > 0;
return (
<Card
title="Outcome Trends"
size="small"
extra={
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Segmented
size="small"
options={[
{ label: 'Day', value: 'day' },
{ label: 'Week', value: 'week' },
]}
value={granularity}
onChange={(v) => setGranularity(v as 'day' | 'week')}
/>
<Switch
size="small"
checkedChildren="%"
unCheckedChildren="#"
checked={percentMode}
onChange={setPercentMode}
/>
<RangePicker
size="small"
value={dateRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
setDateRange([vals[0], vals[1]]);
}
}}
allowClear={false}
style={{ width: isMobile ? '100%' : 220 }}
/>
</div>
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : !hasData ? (
<Empty description="No visit data for this period" />
) : (
<Row gutter={16}>
<Col xs={24} md={16}>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis
dataKey="date"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
tickFormatter={(v: string) =>
granularity === 'week'
? dayjs(v).format('MMM D')
: dayjs(v).format('M/D')
}
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v: number) => (percentMode ? `${v}%` : String(v))}
/>
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 6 }}
labelFormatter={(v) => dayjs(String(v)).format('ddd, MMM D')}
formatter={(value, name) => [
percentMode ? `${value}%` : value,
VISIT_OUTCOME_LABELS[name as VisitOutcome] || name,
]}
/>
{OUTCOME_ORDER.map((outcome) => (
<Area
key={outcome}
type="monotone"
dataKey={outcome}
stackId="1"
stroke={VISIT_OUTCOME_COLORS[outcome]}
fill={VISIT_OUTCOME_COLORS[outcome]}
fillOpacity={0.6}
/>
))}
</AreaChart>
</ResponsiveContainer>
</Col>
<Col xs={24} md={8}>
<MiniDonutChart data={donutData} height={140} innerRadius={36} outerRadius={56} />
<div style={{ marginTop: 8 }}>
{OUTCOME_ORDER.filter((o) => (data?.totals[o] || 0) > 0).map((o) => {
const count = data?.totals[o] || 0;
const pct = totalVisits > 0 ? Math.round((count / totalVisits) * 100) : 0;
return (
<div
key={o}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
fontSize: 12,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: VISIT_OUTCOME_COLORS[o],
flexShrink: 0,
}}
/>
<Typography.Text style={{ flex: 1, fontSize: 12 }} ellipsis>
{VISIT_OUTCOME_LABELS[o]}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{count} ({pct}%)
</Typography.Text>
</div>
);
})}
</div>
</Col>
</Row>
)}
</Card>
);
}

View File

@ -1,6 +1,9 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert } from 'antd';
import {
Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert,
Modal, Input, App,
} from 'antd';
import {
HistoryOutlined,
LogoutOutlined,
@ -9,6 +12,9 @@ import {
StopOutlined,
ClockCircleOutlined,
CloseOutlined,
QrcodeOutlined,
CopyOutlined,
CheckOutlined,
} from '@ant-design/icons';
import SessionTimer from './SessionTimer';
import { api } from '@/lib/api';
@ -27,6 +33,9 @@ interface VolunteerMapDrawerProps {
sessionStartedAt?: string;
onEndSession?: () => void;
endingSession?: boolean;
activeCutId?: string;
activeShiftId?: string;
isAdmin?: boolean;
}
export default function VolunteerMapDrawer({
@ -40,8 +49,12 @@ export default function VolunteerMapDrawer({
sessionStartedAt,
onEndSession,
endingSession = false,
activeCutId,
activeShiftId,
isAdmin = false,
}: VolunteerMapDrawerProps) {
const navigate = useNavigate();
const { message } = App.useApp();
const { user, logout } = useAuthStore();
const [stats, setStats] = useState<MyCanvassStats | null>(null);
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
@ -49,6 +62,12 @@ export default function VolunteerMapDrawer({
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// QR invite modal state
const [qrModalOpen, setQrModalOpen] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [generatingInvite, setGeneratingInvite] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!open) return;
// Load stats and assignments when drawer opens
@ -61,204 +80,308 @@ export default function VolunteerMapDrawer({
navigate('/login', { replace: true });
};
return (
<Drawer
placement="bottom"
open={open}
onClose={onClose}
height="auto"
closable={false}
mask={false}
maskClosable={false}
zIndex={1150}
styles={{
wrapper: {
bottom: 0, // Sits at bottom, footer will push up
},
body: {
padding: isMobile ? '12px' : '16px',
maxHeight: '60vh',
overflowY: 'auto',
},
header: { display: 'none' },
}}
>
<div ref={drawerBodyRef} style={{ width: '100%' }}>
{/* Header with drag handle and close button */}
<div style={{ position: 'relative', marginBottom: 16 }}>
{/* Drag handle at top center */}
<div
style={{
width: 40,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 2,
margin: '0 auto',
}}
/>
const handleGenerateInvite = async () => {
setGeneratingInvite(true);
try {
const { data } = await api.post('/volunteer-invite/generate', {
cutId: activeCutId || undefined,
shiftId: activeShiftId || undefined,
});
// Build the join URL using current origin
const joinUrl = `${window.location.origin}/join?token=${data.token}`;
setInviteUrl(joinUrl);
setQrModalOpen(true);
} catch {
message.error('Failed to generate invite link');
} finally {
setGeneratingInvite(false);
}
};
{/* Close button at top right */}
const handleCopyUrl = async () => {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
message.error('Failed to copy');
}
};
// Build QR code image URL using existing /api/qr endpoint
const qrImageUrl = inviteUrl
? `/api/qr?text=${encodeURIComponent(inviteUrl)}&size=280`
: null;
return (
<>
<Drawer
placement="bottom"
open={open}
onClose={onClose}
height="auto"
closable={false}
mask={false}
maskClosable={false}
zIndex={1150}
styles={{
wrapper: {
bottom: 0, // Sits at bottom, footer will push up
},
body: {
padding: isMobile ? '12px' : '16px',
maxHeight: '60vh',
overflowY: 'auto',
},
header: { display: 'none' },
}}
>
<div ref={drawerBodyRef} style={{ width: '100%' }}>
{/* Header with drag handle and close button */}
<div style={{ position: 'relative', marginBottom: 16 }}>
{/* Drag handle at top center */}
<div
style={{
width: 40,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 2,
margin: '0 auto',
}}
/>
{/* Close button at top right */}
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
style={{
position: 'absolute',
top: -8,
right: -8,
color: 'rgba(255,255,255,0.6)',
}}
size="small"
/>
</div>
{/* Active session alert */}
{sessionActive && sessionCutName && (
<>
<Alert
message={
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong style={{ fontSize: 13 }}>
Active Session: {sessionCutName}
</Typography.Text>
{sessionStartedAt && (
<Space size={4}>
<ClockCircleOutlined style={{ fontSize: 12 }} />
<SessionTimer startedAt={sessionStartedAt} />
</Space>
)}
</Space>
}
type="info"
showIcon={false}
action={
onEndSession && (
<Button
danger
size="small"
icon={<StopOutlined />}
onClick={onEndSession}
loading={endingSession}
>
End
</Button>
)
}
style={{ marginBottom: 16 }}
/>
<Divider style={{ margin: '0 0 16px 0' }} />
</>
)}
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
{user?.name || user?.email || 'Volunteer'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
{user?.email}
</Typography.Text>
{/* Mini stats */}
{stats && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
</Space>
)}
<Divider style={{ margin: '8px 0' }} />
{/* Assignments (hidden when session active) */}
{!sessionActive && assignments.length > 0 && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
My Assignments
</Typography.Text>
<List
size="small"
dataSource={assignments}
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
renderItem={(a) => (
<List.Item
style={{ padding: '6px 0' }}
actions={[
<Button
key="start"
type="primary"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
>
Start
</Button>,
]}
>
<List.Item.Meta
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
description={
<span style={{ fontSize: 11 }}>
{a.shiftTitle} &middot; {Math.round(a.completionPercentage)}%
</span>
}
/>
</List.Item>
)}
/>
<Divider style={{ margin: '8px 0' }} />
</>
)}
{/* Free session — pick a cut (hidden when session active) */}
{!sessionActive && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
Start Session (Any Cut)
</Typography.Text>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder="Select a cut..."
style={{ flex: 1 }}
value={freeCutId}
onChange={setFreeCutId}
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
allowClear
/>
<Button
type="primary"
icon={<AimOutlined />}
disabled={!freeCutId}
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
>
Go
</Button>
</Space.Compact>
</>
)}
{/* Navigation links */}
<Button
type="text"
icon={<HistoryOutlined />}
block
style={{ textAlign: 'left', marginBottom: 4 }}
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
>
My Activity
</Button>
{/* Admin: Invite Volunteer button */}
{isAdmin && (
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
style={{
position: 'absolute',
top: -8,
right: -8,
color: 'rgba(255,255,255,0.6)',
}}
size="small"
/>
icon={<QrcodeOutlined />}
block
style={{ textAlign: 'left', marginBottom: 4 }}
onClick={handleGenerateInvite}
loading={generatingInvite}
>
Invite Volunteer
</Button>
)}
<div style={{ flex: 1 }} />
<Divider style={{ margin: '8px 0' }} />
<Button
type="text"
danger
icon={<LogoutOutlined />}
block
style={{ textAlign: 'left' }}
onClick={handleLogout}
>
Logout
</Button>
</div>
</Drawer>
{/* Active session alert */}
{sessionActive && sessionCutName && (
<>
<Alert
message={
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong style={{ fontSize: 13 }}>
Active Session: {sessionCutName}
</Typography.Text>
{sessionStartedAt && (
<Space size={4}>
<ClockCircleOutlined style={{ fontSize: 12 }} />
<SessionTimer startedAt={sessionStartedAt} />
</Space>
)}
</Space>
}
type="info"
showIcon={false}
action={
onEndSession && (
<Button
danger
size="small"
icon={<StopOutlined />}
onClick={onEndSession}
loading={endingSession}
>
End
</Button>
)
}
style={{ marginBottom: 16 }}
/>
<Divider style={{ margin: '0 0 16px 0' }} />
</>
)}
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
{user?.name || user?.email || 'Volunteer'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
{user?.email}
</Typography.Text>
{/* Mini stats */}
{stats && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
</Space>
)}
<Divider style={{ margin: '8px 0' }} />
{/* Assignments (hidden when session active) */}
{!sessionActive && assignments.length > 0 && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
My Assignments
{/* QR Code Invite Modal */}
<Modal
open={qrModalOpen}
onCancel={() => { setQrModalOpen(false); setInviteUrl(null); setCopied(false); }}
footer={null}
title="Invite Volunteer"
centered
width={340}
zIndex={1200}
>
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
Show this QR code to a new volunteer. They'll scan it with their phone to get instant access.
</Typography.Text>
<List
{qrImageUrl && (
<div style={{ marginBottom: 16 }}>
<img
src={qrImageUrl}
alt="Invite QR Code"
style={{
width: 280,
height: 280,
borderRadius: 8,
background: '#fff',
padding: 8,
}}
/>
</div>
)}
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 11 }}>
Or copy this link to send via text:
</Typography.Text>
<Input.Search
value={inviteUrl || ''}
readOnly
enterButton={
<Button icon={copied ? <CheckOutlined /> : <CopyOutlined />} type={copied ? 'default' : 'primary'}>
{copied ? 'Copied' : 'Copy'}
</Button>
}
onSearch={handleCopyUrl}
style={{ marginBottom: 8 }}
size="small"
dataSource={assignments}
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
renderItem={(a) => (
<List.Item
style={{ padding: '6px 0' }}
actions={[
<Button
key="start"
type="primary"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
>
Start
</Button>,
]}
>
<List.Item.Meta
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
description={
<span style={{ fontSize: 11 }}>
{a.shiftTitle} &middot; {Math.round(a.completionPercentage)}%
</span>
}
/>
</List.Item>
)}
/>
<Divider style={{ margin: '8px 0' }} />
</>
)}
{/* Free session — pick a cut (hidden when session active) */}
{!sessionActive && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
Start Session (Any Cut)
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 8 }}>
Link expires in 30 minutes. Access lasts 24 hours.
</Typography.Text>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder="Select a cut..."
style={{ flex: 1 }}
value={freeCutId}
onChange={setFreeCutId}
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
allowClear
/>
<Button
type="primary"
icon={<AimOutlined />}
disabled={!freeCutId}
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
>
Go
</Button>
</Space.Compact>
</>
)}
{/* Navigation links */}
<Button
type="text"
icon={<HistoryOutlined />}
block
style={{ textAlign: 'left', marginBottom: 4 }}
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
>
My Activity
</Button>
<div style={{ flex: 1 }} />
<Divider style={{ margin: '8px 0' }} />
<Button
type="text"
danger
icon={<LogoutOutlined />}
block
style={{ textAlign: 'left' }}
onClick={handleLogout}
>
Logout
</Button>
</div>
</Drawer>
</div>
</Modal>
</>
);
}

View File

@ -18,11 +18,11 @@ dayjs.extend(relativeTime);
const { Text } = Typography;
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 10 }} /> },
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 10 }} /> },
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 10 }} /> },
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 10 }} /> },
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 10 }} /> },
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 13 }} /> },
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 13 }} /> },
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 13 }} /> },
};
const MODULE_OPTIONS = [
@ -37,19 +37,19 @@ function ActivityRow({ item }: { item: ActivityItem }) {
return (
<Flex
align="center"
gap={6}
gap={8}
style={{
padding: '3px 0',
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
lineHeight: 1.4,
}}
>
<span style={{ color: config.color, flexShrink: 0, width: 14, textAlign: 'center' }}>{config.icon}</span>
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{item.title}</Text>
<span style={{ color: config.color, flexShrink: 0, width: 18, textAlign: 'center' }}>{config.icon}</span>
<Text strong style={{ fontSize: 13, flexShrink: 0 }}>{item.title}</Text>
<Text
type="secondary"
style={{
fontSize: 11,
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: 'hidden',
@ -59,7 +59,7 @@ function ActivityRow({ item }: { item: ActivityItem }) {
>
{item.description}
</Text>
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(item.timestamp).fromNow(true)}
</Text>
</Flex>
@ -77,7 +77,7 @@ export default function ActivityFeedCard() {
setLoading(true);
try {
const res = await api.get<ActivityFeedResult>('/dashboard/activity', {
params: { page: p, limit: 10, module: mod },
params: { page: p, limit: 15, module: mod },
});
if (append) {
setItems(prev => [...prev, ...res.data.items]);
@ -107,7 +107,7 @@ export default function ActivityFeedCard() {
return (
<Card
title={<span style={{ fontSize: 13 }}><HistoryOutlined style={{ marginRight: 5 }} />Recent Activity</span>}
title={<span style={{ fontSize: 14 }}><HistoryOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Activity</span>}
size="small"
extra={
<Segmented
@ -117,23 +117,22 @@ export default function ActivityFeedCard() {
options={MODULE_OPTIONS}
/>
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && items.length === 0 ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : items.length === 0 ? (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent activity</Text>
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent activity</Text>
) : (
<>
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
<div>
{items.map(item => (
<ActivityRow key={item.id} item={item} />
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', paddingTop: 2 }}>
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 11 }}>
<div style={{ textAlign: 'center', paddingTop: 4 }}>
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 13 }}>
Load more
</Button>
</div>

View File

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Statistic, Tag, Tooltip } from 'antd';
import {
FundOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { CampaignOverviewStats } from '@/types/api';
const { Text } = Typography;
export default function CampaignEffectivenessCard() {
const navigate = useNavigate();
const [data, setData] = useState<CampaignOverviewStats | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<CampaignOverviewStats>('/influence/effectiveness/overview');
setData(res.data);
setHasError(false);
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
const s = data?.summary;
const topCampaigns = data?.campaigns?.slice(0, 3) || [];
return (
<Card
title={<span style={{ fontSize: 14 }}><FundOutlined style={{ marginRight: 6, fontSize: 15 }} />Campaign Effectiveness</span>}
size="small"
extra={
<Flex align="center" gap={6}>
<Button type="link" size="small" onClick={() => navigate('/app/influence/effectiveness')} style={{ fontSize: 12, padding: 0 }}>
Details
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : s ? (
<>
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
<Statistic
title={<Text style={{ fontSize: 11 }}>Emails Sent</Text>}
value={s.totalEmails}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Responses</Text>}
value={s.totalResponses}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Response Rate</Text>}
value={Math.round(s.avgResponseRate * 100)}
suffix="%"
valueStyle={{ fontSize: 18, color: s.avgResponseRate > 0.1 ? '#52c41a' : '#faad14' }}
/>
</Flex>
{topCampaigns.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Top Campaigns</Text>
{topCampaigns.map(c => (
<Flex key={c.campaignId} justify="space-between" align="center" style={{ padding: '2px 0' }}>
<Tooltip title={c.title}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '60%' }}>
{c.title}
</Text>
</Tooltip>
<Flex gap={6} style={{ flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 11 }}>{c.emailTotal} emails</Text>
<Tag color={c.responseRate > 0.1 ? 'green' : 'default'} style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}>
{Math.round(c.responseRate * 100)}%
</Tag>
</Flex>
</Flex>
))}
</div>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No campaign data</Text>
)}
</Card>
);
}

View File

@ -31,28 +31,28 @@ function ChatRow({ message }: { message: ChatMessage }) {
<Tooltip title={dayjs(message.timestamp).format('MMM D, h:mm A')} placement="left">
<Flex
align="center"
gap={4}
gap={6}
style={{
padding: '3px 0',
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
lineHeight: 1.4,
}}
>
{message.isBot
? <RobotOutlined style={{ fontSize: 10, color: '#1890ff', flexShrink: 0 }} />
: <UserOutlined style={{ fontSize: 10, color: '#666', flexShrink: 0 }} />
? <RobotOutlined style={{ fontSize: 13, color: '#1890ff', flexShrink: 0 }} />
: <UserOutlined style={{ fontSize: 13, color: '#666', flexShrink: 0 }} />
}
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{message.username}</Text>
<Text strong style={{ fontSize: 13, flexShrink: 0 }}>{message.username}</Text>
<Tag
color={CHANNEL_COLORS[message.channel] || 'default'}
style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}
style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
#{message.channel}
</Tag>
<Text
type="secondary"
style={{
fontSize: 11,
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: 'hidden',
@ -62,7 +62,7 @@ function ChatRow({ message }: { message: ChatMessage }) {
>
{cleanText}
</Text>
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(message.timestamp).fromNow(true)}
</Text>
</Flex>
@ -96,24 +96,23 @@ export default function ChatNotifierCard() {
return (
<Card
title={<span style={{ fontSize: 13 }}><MessageOutlined style={{ marginRight: 5 }} />Team Chat</span>}
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Team Chat</span>}
size="small"
extra={
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchChat} />
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchChat} />
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.messages.length > 0 ? (
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
<div>
{result.messages.map(msg => (
<ChatRow key={msg.id} message={msg} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent messages</Text>
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
)}
</Card>
);

View File

@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Statistic, Tooltip } from 'antd';
import {
BookOutlined,
ReloadOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { DashboardDocsAnalytics } from '@/types/api';
const { Text } = Typography;
export default function DocsAnalyticsCard() {
const navigate = useNavigate();
const [data, setData] = useState<DashboardDocsAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchAnalytics = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<DashboardDocsAnalytics>('/docs-analytics/summary', {
params: { days: 30 },
});
setData(res.data);
setHasError(false);
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAnalytics();
const interval = setInterval(fetchAnalytics, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchAnalytics]);
// Hide if the endpoint isn't available
if (hasError && !data) return null;
const avgPerDay = data ? Math.round(data.totalViews / 30) : 0;
const topPages = data?.topPages?.slice(0, 5) || [];
return (
<Card
title={<span style={{ fontSize: 14 }}><BookOutlined style={{ marginRight: 6, fontSize: 15 }} />Docs Analytics</span>}
size="small"
extra={
<Flex align="center" gap={6}>
<Button type="link" size="small" onClick={() => navigate('/app/docs/analytics')} style={{ fontSize: 12, padding: 0 }}>
Full Report
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchAnalytics} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data ? (
<>
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
<Statistic
title={<Text style={{ fontSize: 11 }}>Page Views</Text>}
value={data.totalViews}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Sessions</Text>}
value={data.uniqueSessions}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Avg/Day</Text>}
value={avgPerDay}
valueStyle={{ fontSize: 18 }}
/>
</Flex>
{topPages.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Top Pages (30d)</Text>
{topPages.map((page, i) => (
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
<Tooltip title={page.path}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '75%' }}>
<FileTextOutlined style={{ marginRight: 4, fontSize: 10, color: '#1890ff' }} />
{page.path}
</Text>
</Tooltip>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{page.views}</Text>
</Flex>
))}
</div>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No analytics data</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,117 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Statistic, Tag } from 'antd';
import {
DollarOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { PaymentDashboardStats } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
export default function DonationSummaryCard() {
const navigate = useNavigate();
const [data, setData] = useState<PaymentDashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<PaymentDashboardStats>('/payments/admin/dashboard');
setData(res.data);
setHasError(false);
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
const recent = data?.donations?.recentDonations?.slice(0, 4) || [];
return (
<Card
title={<span style={{ fontSize: 14 }}><DollarOutlined style={{ marginRight: 6, fontSize: 15 }} />Payments</span>}
size="small"
extra={
<Flex align="center" gap={6}>
<Button type="link" size="small" onClick={() => navigate('/app/payments')} style={{ fontSize: 12, padding: 0 }}>
Manage
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data ? (
<>
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
<Statistic
title={<Text style={{ fontSize: 11 }}>Revenue</Text>}
value={formatCents(data.totalRevenue)}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>MRR</Text>}
value={formatCents(data.mrr)}
valueStyle={{ fontSize: 18, color: data.mrr > 0 ? '#52c41a' : undefined }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Subscribers</Text>}
value={data.activeSubscribers}
valueStyle={{ fontSize: 18 }}
/>
<Statistic
title={<Text style={{ fontSize: 11 }}>Donations</Text>}
value={data.donations?.totalDonations || 0}
valueStyle={{ fontSize: 18 }}
/>
</Flex>
{recent.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Recent</Text>
{recent.map(d => (
<Flex key={d.id} justify="space-between" align="center" style={{ padding: '2px 0' }}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '55%' }}>
{d.isAnonymous ? 'Anonymous' : d.buyerName || d.buyerEmail}
</Text>
<Flex gap={4} align="center" style={{ flexShrink: 0 }}>
<Text strong style={{ fontSize: 12 }}>{formatCents(d.amountCAD)}</Text>
<Tag
color={d.status === 'COMPLETED' ? 'green' : d.status === 'PENDING' ? 'orange' : 'red'}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}
>
{d.status}
</Tag>
</Flex>
</Flex>
))}
</div>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No payment data</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,85 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tooltip } from 'antd';
import {
MailOutlined,
ReloadOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { ListmonkStats } from '@/types/api';
const { Text } = Typography;
export default function NewsletterStatsCard() {
const navigate = useNavigate();
const [data, setData] = useState<ListmonkStats | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ListmonkStats>('/listmonk/stats');
setData(res.data);
setHasError(false);
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
const lists = data?.lists || [];
const totalSubscribers = lists.reduce((sum, l) => sum + l.subscriberCount, 0);
return (
<Card
title={<span style={{ fontSize: 14 }}><MailOutlined style={{ marginRight: 6, fontSize: 15 }} />Newsletter</span>}
size="small"
extra={
<Flex align="center" gap={6}>
<Button type="link" size="small" onClick={() => navigate('/app/listmonk')} style={{ fontSize: 12, padding: 0 }}>
Manage
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : lists.length > 0 ? (
<>
<Flex align="center" gap={8} style={{ marginBottom: 8 }}>
<TeamOutlined style={{ fontSize: 16, color: '#1890ff' }} />
<Text strong style={{ fontSize: 18 }}>{totalSubscribers.toLocaleString()}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>total across {lists.length} lists</Text>
</Flex>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
{lists.slice(0, 6).map((list, i) => (
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
<Tooltip title={list.name}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{list.name}
</Text>
</Tooltip>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{list.subscriberCount.toLocaleString()}</Text>
</Flex>
))}
</div>
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No lists configured</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
MessageOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { DashboardRecentCommentsResult, DashboardRecentComment } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const SAFETY_COLORS: Record<string, string> = {
safe: 'green',
pending: 'orange',
flagged: 'red',
unsafe: 'red',
};
function CommentRow({ comment }: { comment: DashboardRecentComment }) {
const navigate = useNavigate();
const videoLabel = comment.videoTitle || comment.videoFilename;
return (
<Flex
align="center"
gap={8}
onClick={() => navigate(`/gallery/watch/${comment.videoId}`)}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
cursor: 'pointer',
}}
>
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{comment.authorName || 'Anonymous'}
</Text>
<Text
type="secondary"
style={{
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{comment.content}
</Text>
<Tooltip title={videoLabel}>
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{videoLabel}
</Tag>
</Tooltip>
{comment.safetyStatus && comment.safetyStatus !== 'safe' && (
<Tag
color={SAFETY_COLORS[comment.safetyStatus] || 'default'}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
{comment.safetyStatus}
</Tag>
)}
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(comment.createdAt).fromNow(true)}
</Text>
</Flex>
);
}
export default function RecentCommentsCard() {
const [result, setResult] = useState<DashboardRecentCommentsResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchComments = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<DashboardRecentCommentsResult>('/dashboard/recent-comments');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchComments();
const interval = setInterval(fetchComments, 3 * 60_000);
return () => clearInterval(interval);
}, [fetchComments]);
if (result && !result.enabled) return null;
return (
<Card
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Comments</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{result && result.pendingCount > 0 && (
<Tag color="orange" style={{ margin: 0, fontSize: 11 }}>{result.pendingCount} pending</Tag>
)}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchComments} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.comments.length > 0 ? (
<div>
{result.comments.map(comment => (
<CommentRow key={comment.id} comment={comment} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No comments yet</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
UserAddOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { DashboardRecentSignupsResult, DashboardRecentSignup } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const SOURCE_COLORS: Record<string, string> = {
AUTHENTICATED: 'blue',
PUBLIC: 'green',
ADMIN: 'purple',
};
function SignupRow({ signup }: { signup: DashboardRecentSignup }) {
const displayName = signup.userName || signup.userEmail;
return (
<Flex
align="center"
gap={8}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
}}
>
<UserAddOutlined style={{ fontSize: 12, color: '#52c41a', flexShrink: 0 }} />
<Tooltip title={displayName}>
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</Text>
</Tooltip>
<Tooltip title={signup.shiftTitle}>
<Text
type="secondary"
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{signup.shiftTitle || 'Shift'}
</Text>
</Tooltip>
<Tag
color={SOURCE_COLORS[signup.signupSource] || 'default'}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
{signup.signupSource === 'AUTHENTICATED' ? 'Auth' : signup.signupSource === 'PUBLIC' ? 'Public' : signup.signupSource}
</Tag>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(signup.signupDate).fromNow(true)}
</Text>
</Flex>
);
}
export default function RecentSignupsCard() {
const [result, setResult] = useState<DashboardRecentSignupsResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchSignups = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<DashboardRecentSignupsResult>('/dashboard/recent-signups');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSignups();
const interval = setInterval(fetchSignups, 3 * 60_000);
return () => clearInterval(interval);
}, [fetchSignups]);
return (
<Card
title={<span style={{ fontSize: 14 }}><UserAddOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Signups</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{result && result.total > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>{result.total} (14d)</Text>
)}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchSignups} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.signups.length > 0 ? (
<div>
{result.signups.map(signup => (
<SignupRow key={signup.id} signup={signup} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent signups</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag } from 'antd';
import {
AlertOutlined,
ReloadOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { AlertsResponse, AlertInfo } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const SEVERITY_COLORS: Record<string, string> = {
critical: 'red',
warning: 'orange',
info: 'blue',
};
function AlertRow({ alert }: { alert: AlertInfo }) {
return (
<Flex
align="center"
gap={8}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
}}
>
<Tag
color={SEVERITY_COLORS[alert.severity] || 'default'}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
{alert.severity}
</Tag>
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{alert.name}
</Text>
<Text
type="secondary"
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{alert.summary}
</Text>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(alert.startsAt).fromNow(true)}
</Text>
</Flex>
);
}
export default function SystemAlertsCard() {
const navigate = useNavigate();
const [data, setData] = useState<AlertsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AlertsResponse>('/observability/alerts');
setData(res.data);
setHasError(false);
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
return (
<Card
title={
<span style={{ fontSize: 14 }}>
<AlertOutlined style={{ marginRight: 6, fontSize: 15 }} />
Alerts
{data && data.critical > 0 && (
<Tag color="red" style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', lineHeight: '18px' }}>
{data.critical} critical
</Tag>
)}
</span>
}
size="small"
extra={
<Flex align="center" gap={6}>
<Button type="link" size="small" onClick={() => navigate('/app/observability')} style={{ fontSize: 12, padding: 0 }}>
Monitor
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data && data.alerts.length > 0 ? (
<div>
{data.alerts.slice(0, 6).map(alert => (
<AlertRow key={alert.id} alert={alert} />
))}
</div>
) : (
<Flex align="center" gap={8} style={{ padding: '8px 0' }}>
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 16 }} />
<Text type="secondary" style={{ fontSize: 13 }}>All systems nominal</Text>
</Flex>
)}
</Card>
);
}

View File

@ -24,30 +24,30 @@ function EventRow({ event }: { event: TodayEvent }) {
return (
<Flex
align="center"
gap={6}
gap={8}
style={{
padding: '3px 0',
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
lineHeight: 1.4,
}}
>
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, width: 82, whiteSpace: 'nowrap' }}>
<ClockCircleOutlined style={{ marginRight: 3, fontSize: 9 }} />
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, width: 90, whiteSpace: 'nowrap' }}>
<ClockCircleOutlined style={{ marginRight: 4, fontSize: 11 }} />
{timeStr}
</Text>
<Text strong style={{ fontSize: 11, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Text strong style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{event.title}
</Text>
{event.placeName && (
<Tooltip title={event.placeName}>
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<EnvironmentOutlined style={{ marginRight: 2, fontSize: 9 }} />
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<EnvironmentOutlined style={{ marginRight: 3, fontSize: 11 }} />
{event.placeName}
</Text>
</Tooltip>
)}
{event.tags.length > 0 && (
<Tag style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}>
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}>
{event.tags[0]}
</Tag>
)}
@ -81,19 +81,18 @@ export default function TodayEventsCard() {
return (
<Card
title={<span style={{ fontSize: 13 }}><CalendarOutlined style={{ marginRight: 5 }} />Today's Events</span>}
title={<span style={{ fontSize: 14 }}><CalendarOutlined style={{ marginRight: 6, fontSize: 15 }} />Today's Events</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{result && <Text type="secondary" style={{ fontSize: 10 }}>{result.total} event{result.total !== 1 ? 's' : ''}</Text>}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchEvents} />
{result && <Text type="secondary" style={{ fontSize: 12 }}>{result.total} event{result.total !== 1 ? 's' : ''}</Text>}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchEvents} />
</Flex>
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.events.length > 0 ? (
<>
{result.events.map(event => (
@ -101,7 +100,7 @@ export default function TodayEventsCard() {
))}
</>
) : (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No events scheduled today</Text>
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No events scheduled today</Text>
)}
</Card>
);

View File

@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
VideoCameraOutlined,
ReloadOutlined,
EyeOutlined,
MessageOutlined,
LikeOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { DashboardTopVideosResult, DashboardTopVideo } from '@/types/api';
const { Text } = Typography;
function formatDuration(seconds: number | null): string {
if (!seconds) return '--:--';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function VideoRow({ video, rank }: { video: DashboardTopVideo; rank: number }) {
const navigate = useNavigate();
const displayTitle = video.title || video.filename;
return (
<Flex
align="center"
gap={8}
onClick={() => navigate(`/gallery/watch/${video.id}`)}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
cursor: 'pointer',
}}
>
<Text type="secondary" style={{ fontSize: 13, flexShrink: 0, width: 18, textAlign: 'center' }}>
{rank}
</Text>
<Tooltip title={displayTitle}>
<Text
strong
style={{
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayTitle}
</Text>
</Tooltip>
{!video.isPublished && (
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}>Draft</Tag>
)}
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
{formatDuration(video.durationSeconds)}
</Text>
<Flex gap={6} style={{ flexShrink: 0 }}>
<Tooltip title="Views">
<Text type="secondary" style={{ fontSize: 11 }}>
<EyeOutlined style={{ marginRight: 2, fontSize: 10 }} />
{video.viewCount}
</Text>
</Tooltip>
{video.commentCount > 0 && (
<Tooltip title="Comments">
<Text type="secondary" style={{ fontSize: 11 }}>
<MessageOutlined style={{ marginRight: 2, fontSize: 10 }} />
{video.commentCount}
</Text>
</Tooltip>
)}
{video.upvoteCount > 0 && (
<Tooltip title="Upvotes">
<Text type="secondary" style={{ fontSize: 11 }}>
<LikeOutlined style={{ marginRight: 2, fontSize: 10 }} />
{video.upvoteCount}
</Text>
</Tooltip>
)}
</Flex>
</Flex>
);
}
export default function TopVideosCard() {
const [result, setResult] = useState<DashboardTopVideosResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchVideos = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<DashboardTopVideosResult>('/dashboard/top-videos');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchVideos();
const interval = setInterval(fetchVideos, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchVideos]);
if (result && !result.enabled) return null;
return (
<Card
title={<span style={{ fontSize: 14 }}><VideoCameraOutlined style={{ marginRight: 6, fontSize: 15 }} />Top Videos</span>}
size="small"
extra={
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchVideos} />
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.videos.length > 0 ? (
<div>
{result.videos.map((video, i) => (
<VideoRow key={video.id} video={video} rank={i + 1} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No videos yet</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,117 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip, Progress } from 'antd';
import {
CalendarOutlined,
ReloadOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { DashboardUpcomingShiftsResult, DashboardUpcomingShift } from '@/types/api';
const { Text } = Typography;
function ShiftRow({ shift }: { shift: DashboardUpcomingShift }) {
const navigate = useNavigate();
const fillPct = shift.maxVolunteers > 0
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100) : 0;
return (
<Flex
align="center"
gap={8}
onClick={() => navigate('/app/map/shifts')}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
cursor: 'pointer',
}}
>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, width: 50, whiteSpace: 'nowrap' }}>
{dayjs(shift.date).format('MMM D')}
</Text>
<Tooltip title={shift.title}>
<Text
strong
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{shift.title}
</Text>
</Tooltip>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
<ClockCircleOutlined style={{ marginRight: 2, fontSize: 10 }} />
{shift.startTime}
</Text>
<Tooltip title={`${shift.currentVolunteers}/${shift.maxVolunteers} volunteers`}>
<Progress
percent={fillPct}
size="small"
style={{ width: 50, flexShrink: 0 }}
strokeColor={fillPct >= 100 ? '#52c41a' : fillPct >= 50 ? '#faad14' : '#1890ff'}
showInfo={false}
/>
</Tooltip>
{shift.cutName && (
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, maxWidth: 70, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shift.cutName}
</Tag>
)}
</Flex>
);
}
export default function UpcomingShiftsCard() {
const navigate = useNavigate();
const [result, setResult] = useState<DashboardUpcomingShiftsResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchShifts = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<DashboardUpcomingShiftsResult>('/dashboard/upcoming-shifts');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchShifts();
const interval = setInterval(fetchShifts, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchShifts]);
return (
<Card
title={<span style={{ fontSize: 14 }}><CalendarOutlined style={{ marginRight: 6, fontSize: 15 }} />Upcoming Shifts</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{result && result.total > 5 && (
<Button type="link" size="small" onClick={() => navigate('/app/map/shifts')} style={{ fontSize: 12, padding: 0 }}>
+{result.total - 5} more
</Button>
)}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchShifts} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.shifts.length > 0 ? (
<div>
{result.shifts.map(shift => (
<ShiftRow key={shift.id} shift={shift} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No upcoming shifts</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,102 @@
import { Card, Tag, Badge } from 'antd';
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import type { PhotoAlbum } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumCardProps {
album: PhotoAlbum;
onClick?: (album: PhotoAlbum) => void;
}
export default function AlbumCard({ album, onClick }: AlbumCardProps) {
const coverUrl = album.coverThumbnailUrl;
return (
<Card
hoverable
size="small"
styles={{ body: { padding: 8 } }}
onClick={() => onClick?.(album)}
cover={
<div
style={{
position: 'relative',
paddingTop: '75%',
backgroundColor: '#1a1a1a',
overflow: 'hidden',
}}
>
{coverUrl ? (
<img
src={getAuthenticatedUrl(coverUrl)}
alt={album.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
) : (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<FolderOpenOutlined style={{ fontSize: 32, color: '#555' }} />
</div>
)}
{/* Photo count badge */}
<Badge
count={
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>
<PictureOutlined /> {album.photoCount || album._count?.photos || 0}
</Tag>
}
style={{ position: 'absolute', bottom: 6, right: 6 }}
/>
{/* Publish badge */}
{album.isPublished && (
<Tag
color="green"
style={{ position: 'absolute', top: 6, right: 6, margin: 0 }}
>
<GlobalOutlined />
</Tag>
)}
</div>
}
>
<Card.Meta
title={
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{album.title}
</div>
}
description={
<div style={{ fontSize: 11, color: '#999' }}>
{album.photoCount || album._count?.photos || 0} photos
{album.viewCount > 0 ? ` · ${album.viewCount} views` : ''}
</div>
}
/>
</Card>
);
}

View File

@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty } from 'antd';
import {
DeleteOutlined,
PictureOutlined,
CrownOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api';
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumDetailDrawerProps {
albumId: number | null;
open: boolean;
onClose: () => void;
onRefresh: () => void;
}
export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) {
const [album, setAlbum] = useState<PhotoAlbum | null>(null);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (albumId && open) {
fetchAlbum();
}
}, [albumId, open]);
const fetchAlbum = async () => {
if (!albumId) return;
setLoading(true);
try {
const { data } = await mediaApi.get(`/albums/${albumId}`);
setAlbum(data);
setTitle(data.title);
setDescription(data.description || '');
} catch {
message.error('Failed to load album');
} finally {
setLoading(false);
}
};
const handleSaveMetadata = async () => {
if (!albumId) return;
try {
await mediaApi.patch(`/albums/${albumId}`, { title, description });
message.success('Album updated');
setEditing(false);
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to update album');
}
};
const handleSetCover = async (photoId: number) => {
if (!albumId) return;
try {
await mediaApi.put(`/albums/${albumId}/cover`, { photoId });
message.success('Cover photo set');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to set cover photo');
}
};
const handleRemovePhoto = async (photoId: number) => {
if (!albumId) return;
try {
await mediaApi.delete(`/albums/${albumId}/photos/${photoId}`);
message.success('Photo removed from album');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to remove photo');
}
};
const handlePublish = async () => {
if (!albumId) return;
try {
await mediaApi.post(`/albums/${albumId}/publish`);
message.success('Album and photos published');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to publish album');
}
};
const handleDeleteAlbum = async () => {
if (!albumId) return;
try {
await mediaApi.delete(`/albums/${albumId}`);
message.success('Album deleted (photos preserved)');
onClose();
onRefresh();
} catch {
message.error('Failed to delete album');
}
};
const photos = album?.photos || [];
return (
<Drawer
title={album?.title || 'Album Detail'}
open={open}
onClose={onClose}
width={600}
loading={loading}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Popconfirm title="Delete album? Photos will be preserved." onConfirm={handleDeleteAlbum}>
<Button danger icon={<DeleteOutlined />}>Delete Album</Button>
</Popconfirm>
<Space>
{!album?.isPublished && (
<Button type="primary" icon={<GlobalOutlined />} onClick={handlePublish}>
Publish Album
</Button>
)}
</Space>
</div>
}
>
{/* Editable title/description */}
<div style={{ marginBottom: 16 }}>
{editing ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Album title" />
<Input.TextArea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description" rows={2} />
<Space>
<Button type="primary" size="small" onClick={handleSaveMetadata}>Save</Button>
<Button size="small" onClick={() => setEditing(false)}>Cancel</Button>
</Space>
</Space>
) : (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<strong>{album?.title}</strong>
{album?.isPublished && <Tag color="green" style={{ marginLeft: 8 }}>Published</Tag>}
</div>
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
</div>
{album?.description && <div style={{ color: '#999', marginTop: 4 }}>{album.description}</div>}
<div style={{ color: '#666', marginTop: 4, fontSize: 12 }}>
{photos.length} photos · {album?.viewCount || 0} views · {album?.upvoteCount || 0} upvotes
</div>
</div>
)}
</div>
{/* Photo list */}
{photos.length === 0 ? (
<Empty description="No photos in this album" />
) : (
<List
dataSource={photos}
renderItem={(photo: PhotoAlbumItem) => (
<List.Item
key={photo.id}
actions={[
<Button
key="cover"
size="small"
icon={<CrownOutlined />}
onClick={() => handleSetCover(photo.id)}
type={album?.coverPhotoId === photo.id ? 'primary' : 'default'}
>
{album?.coverPhotoId === photo.id ? 'Cover' : 'Set Cover'}
</Button>,
<Popconfirm
key="remove"
title="Remove from album?"
onConfirm={() => handleRemovePhoto(photo.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
photo.thumbnailUrl ? (
<Image
src={getAuthenticatedUrl(photo.thumbnailUrl)}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
/>
) : (
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PictureOutlined style={{ color: '#555' }} />
</div>
)
}
title={
<span style={{ fontSize: 13 }}>
{photo.title || photo.originalFilename}
</span>
}
description={
<span style={{ fontSize: 11 }}>
{photo.width}×{photo.height} · {photo.format?.toUpperCase()}
{photo.isPublished && <Tag color="green" style={{ marginLeft: 4, fontSize: 10 }}>Published</Tag>}
</span>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import { Modal, Form, Input, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
interface CreateAlbumModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
selectedPhotoIds?: number[];
}
export default function CreateAlbumModal({
open,
onClose,
onSuccess,
selectedPhotoIds = [],
}: CreateAlbumModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleCreate = async () => {
try {
const values = await form.validateFields();
setLoading(true);
await mediaApi.post('/albums', {
title: values.title,
description: values.description,
photoIds: selectedPhotoIds.length > 0 ? selectedPhotoIds : undefined,
});
message.success('Album created');
form.resetFields();
onSuccess();
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message);
}
} finally {
setLoading(false);
}
};
return (
<Modal
title="Create Album"
open={open}
onOk={handleCreate}
onCancel={onClose}
confirmLoading={loading}
okText="Create"
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
<Input placeholder="Album title" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Optional description" />
</Form.Item>
</Form>
{selectedPhotoIds.length > 0 && (
<div style={{ color: '#999', fontSize: 12 }}>
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
</div>
)}
</Modal>
);
}

View File

@ -0,0 +1,149 @@
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';
interface EditPhotoModalProps {
photo: Photo | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function EditPhotoModal({ photo, open, onClose, onSuccess }: EditPhotoModalProps) {
const [form] = Form.useForm();
useEffect(() => {
if (photo && open) {
form.setFieldsValue({
title: photo.title,
description: photo.description,
producer: photo.producer,
creator: photo.creator,
category: photo.category,
tags: photo.tags || [],
accessLevel: photo.accessLevel || 'free',
});
}
}, [photo, open]);
const handleSave = async () => {
if (!photo) return;
const values = form.getFieldsValue();
try {
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');
}
};
if (!photo) return null;
return (
<Drawer
title="Edit Photo"
open={open}
onClose={onClose}
width={480}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={onClose}>Cancel</Button>
<Button type="primary" onClick={handleSave}>Save</Button>
</div>
}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title">
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="producer" label="Producer">
<Input />
</Form.Item>
<Form.Item name="creator" label="Creator">
<Input />
</Form.Item>
<Form.Item name="category" label="Category">
<Input placeholder="e.g. photos, events, portraits" />
</Form.Item>
<Form.Item name="tags" label="Tags">
<Select mode="tags" placeholder="Add tags" />
</Form.Item>
<Form.Item name="accessLevel" label="Access Level">
<Select
options={[
{ value: 'free', label: 'Free' },
{ value: 'member', label: 'Member' },
{ value: 'premium', label: 'Premium' },
]}
/>
</Form.Item>
</Form>
{/* EXIF Info (read-only) */}
{(photo.cameraMake || photo.cameraModel || photo.takenAt) && (
<Descriptions
title="EXIF Data"
size="small"
column={1}
bordered
style={{ marginTop: 16 }}
>
{photo.cameraMake && (
<Descriptions.Item label="Camera">{photo.cameraMake} {photo.cameraModel || ''}</Descriptions.Item>
)}
{photo.focalLength && (
<Descriptions.Item label="Focal Length">{photo.focalLength}</Descriptions.Item>
)}
{photo.aperture && (
<Descriptions.Item label="Aperture">{photo.aperture}</Descriptions.Item>
)}
{photo.shutterSpeed && (
<Descriptions.Item label="Shutter">{photo.shutterSpeed}</Descriptions.Item>
)}
{photo.iso && (
<Descriptions.Item label="ISO">{photo.iso}</Descriptions.Item>
)}
{photo.takenAt && (
<Descriptions.Item label="Taken At">{new Date(photo.takenAt).toLocaleString()}</Descriptions.Item>
)}
{photo.gpsLatitude && photo.gpsLongitude && (
<Descriptions.Item label="GPS">
{photo.gpsLatitude.toFixed(6)}, {photo.gpsLongitude.toFixed(6)}
</Descriptions.Item>
)}
</Descriptions>
)}
{/* Metadata */}
<Descriptions
title="File Info"
size="small"
column={1}
bordered
style={{ marginTop: 16 }}
>
<Descriptions.Item label="Dimensions">
{photo.width}×{photo.height} ({photo.orientation})
</Descriptions.Item>
<Descriptions.Item label="Format">
{photo.format?.toUpperCase()}
{photo.hasAlpha ? ' (alpha)' : ''}
</Descriptions.Item>
{photo.colorSpace && (
<Descriptions.Item label="Color Space">{photo.colorSpace}</Descriptions.Item>
)}
{photo.dpi && (
<Descriptions.Item label="DPI">{photo.dpi}</Descriptions.Item>
)}
<Descriptions.Item label="Original">
{photo.originalFilename}
</Descriptions.Item>
</Descriptions>
</Drawer>
);
}

View File

@ -0,0 +1,347 @@
import { useRef, useState, useEffect } from 'react';
import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd';
import {
CloseOutlined,
LikeOutlined,
LikeFilled,
EyeOutlined,
LeftOutlined,
RightOutlined,
PictureOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicAlbum } from '@/types/media';
const { useBreakpoint } = Grid;
interface AlbumPhoto {
id: number;
title: string | null;
width: number | null;
height: number | null;
format: string | null;
thumbnailUrl: string;
imageUrl: string;
}
interface ExpandedAlbumCardProps {
album: PublicAlbum;
}
export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { collapseVideo } = useExpandedVideo();
const containerRef = useRef<HTMLDivElement>(null);
const [hasUpvoted, setHasUpvoted] = useState(false);
const [upvoteCount, setUpvoteCount] = useState(album.upvoteCount);
const [upvoting, setUpvoting] = useState(false);
const [isExpanding, setIsExpanding] = useState(true);
const [photos, setPhotos] = useState<AlbumPhoto[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [imageLoading, setImageLoading] = useState(true);
const pad = isMobile ? 8 : 12;
// Fetch album photos
useEffect(() => {
const fetchPhotos = async () => {
try {
const { data } = await mediaPublicApi.get(`/public/albums/${album.id}`);
setPhotos(data.photos || []);
} catch {
// Silent fail
} finally {
setLoading(false);
}
};
fetchPhotos();
}, [album.id]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') collapseVideo();
if (e.key === 'ArrowLeft') setCurrentIndex(prev => Math.max(0, prev - 1));
if (e.key === 'ArrowRight') setCurrentIndex(prev => Math.min(photos.length - 1, prev + 1));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [collapseVideo, photos.length]);
// Expand animation
useEffect(() => {
const timer = requestAnimationFrame(() => {
requestAnimationFrame(() => setIsExpanding(false));
});
return () => cancelAnimationFrame(timer);
}, []);
// Scroll into view
useEffect(() => {
const timer = setTimeout(() => {
containerRef.current?.scrollIntoView({
behavior: isMobile ? 'auto' : 'smooth',
block: 'nearest',
});
}, 350);
return () => clearTimeout(timer);
}, [isMobile]);
const handleUpvote = async () => {
if (upvoting || hasUpvoted) return;
try {
setUpvoting(true);
// Albums don't have a direct upvote — upvote the current photo instead
if (photos[currentIndex]) {
await mediaPublicApi.post(`/photos/${photos[currentIndex].id}/upvote`);
}
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
if (error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {
setUpvoting(false);
}
};
const formatCount = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
const currentPhoto = photos[currentIndex];
return (
<div
ref={containerRef}
style={{
gridColumn: '1 / -1',
background: token.colorBgContainer,
borderRadius: 0,
overflow: 'hidden',
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
maxHeight: isExpanding ? 0 : 3000,
opacity: isExpanding ? 0 : 1,
marginLeft: -pad,
marginRight: -pad,
width: `calc(100% + ${pad * 2}px)`,
}}
>
{/* Image carousel */}
<div
style={{
position: 'relative',
width: '100%',
maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 300,
}}
>
{loading ? (
<Spin size="large" />
) : currentPhoto ? (
<>
{imageLoading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)}
<img
key={currentPhoto.id}
src={currentPhoto.imageUrl}
alt={currentPhoto.title || `Photo ${currentIndex + 1}`}
onLoad={() => setImageLoading(false)}
style={{
maxWidth: '100%',
maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)',
objectFit: 'contain',
}}
/>
{/* Left arrow */}
{currentIndex > 0 && (
<Button
type="text"
icon={<LeftOutlined />}
onClick={(e) => {
e.stopPropagation();
setImageLoading(true);
setCurrentIndex(prev => prev - 1);
}}
style={{
position: 'absolute',
left: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
borderRadius: '50%',
width: 40,
height: 40,
}}
/>
)}
{/* Right arrow */}
{currentIndex < photos.length - 1 && (
<Button
type="text"
icon={<RightOutlined />}
onClick={(e) => {
e.stopPropagation();
setImageLoading(true);
setCurrentIndex(prev => prev + 1);
}}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
borderRadius: '50%',
width: 40,
height: 40,
}}
/>
)}
{/* Photo counter */}
<div
style={{
position: 'absolute',
top: 12,
right: 12,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px 12px',
borderRadius: 16,
fontSize: 13,
fontWeight: 500,
}}
>
{currentIndex + 1} / {photos.length}
</div>
</>
) : (
<div style={{ color: '#666', fontSize: 16 }}>No photos in this album</div>
)}
</div>
{/* Thumbnail strip */}
{photos.length > 1 && (
<div
style={{
display: 'flex',
gap: 4,
padding: '8px 12px',
overflowX: 'auto',
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorder}`,
}}
>
{photos.map((p, idx) => (
<div
key={p.id}
onClick={() => {
setImageLoading(true);
setCurrentIndex(idx);
}}
style={{
width: 48,
height: 36,
flexShrink: 0,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
border: idx === currentIndex
? `2px solid ${token.colorPrimary}`
: '2px solid transparent',
opacity: idx === currentIndex ? 1 : 0.6,
transition: 'all 0.2s ease',
}}
>
<img
src={p.thumbnailUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
))}
</div>
)}
{/* Bottom info bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: isMobile ? '6px 12px' : '6px 16px',
borderTop: `1px solid ${token.colorBorder}`,
flexWrap: 'wrap',
}}
>
<Button
type="text"
icon={<CloseOutlined />}
onClick={collapseVideo}
size="small"
style={{ flexShrink: 0 }}
/>
<div
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: isMobile ? 12 : 14,
fontWeight: 500,
color: token.colorText,
}}
>
{album.title}
</div>
<Space size={8} style={{ flexShrink: 0 }}>
<Tag style={{ margin: 0, fontSize: 10 }}>
<AppstoreOutlined /> Album
</Tag>
<Tag style={{ margin: 0, fontSize: 10 }}>
<PictureOutlined /> {album.photoCount} photos
</Tag>
</Space>
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
<span><EyeOutlined /> {formatCount(album.viewCount)}</span>
</Space>
<Button
type={hasUpvoted ? 'primary' : 'text'}
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
onClick={handleUpvote}
loading={upvoting}
disabled={hasUpvoted}
size="small"
style={{ flexShrink: 0 }}
>
{formatCount(upvoteCount)}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,238 @@
import { useRef, useState, useEffect } from 'react';
import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd';
import {
CloseOutlined,
LikeOutlined,
LikeFilled,
EyeOutlined,
CommentOutlined,
ZoomInOutlined,
ZoomOutOutlined,
CameraOutlined,
} from '@ant-design/icons';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicPhoto } from '@/types/media';
const { useBreakpoint } = Grid;
interface ExpandedPhotoCardProps {
photo: PublicPhoto;
}
export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { collapseVideo } = useExpandedVideo();
const containerRef = useRef<HTMLDivElement>(null);
const [hasUpvoted, setHasUpvoted] = useState(false);
const [upvoteCount, setUpvoteCount] = useState(photo.upvoteCount);
const [upvoting, setUpvoting] = useState(false);
const [isExpanding, setIsExpanding] = useState(true);
const [zoomed, setZoomed] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const pad = isMobile ? 8 : 12;
const title = photo.title || 'Untitled Photo';
// Use large image URL
const imageUrl = photo.imageUrl || photo.thumbnailUrl;
// Handle keyboard
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') collapseVideo();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [collapseVideo]);
// Expand animation
useEffect(() => {
const timer = requestAnimationFrame(() => {
requestAnimationFrame(() => setIsExpanding(false));
});
return () => cancelAnimationFrame(timer);
}, []);
// Scroll into view
useEffect(() => {
const timer = setTimeout(() => {
containerRef.current?.scrollIntoView({
behavior: isMobile ? 'auto' : 'smooth',
block: 'nearest',
});
}, 350);
return () => clearTimeout(timer);
}, [isMobile]);
// Track view
useEffect(() => {
mediaPublicApi.post('/photos/' + photo.id + '/view').catch(() => {});
}, [photo.id]);
const handleUpvote = async () => {
if (upvoting || hasUpvoted) return;
try {
setUpvoting(true);
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
if (error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {
setUpvoting(false);
}
};
const formatCount = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div
ref={containerRef}
style={{
gridColumn: '1 / -1',
background: token.colorBgContainer,
borderRadius: 0,
overflow: 'hidden',
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
maxHeight: isExpanding ? 0 : 3000,
opacity: isExpanding ? 0 : 1,
marginLeft: -pad,
marginRight: -pad,
width: `calc(100% + ${pad * 2}px)`,
}}
>
{/* Image section */}
<div
style={{
position: 'relative',
width: '100%',
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
cursor: zoomed ? 'zoom-out' : 'zoom-in',
}}
onClick={() => setZoomed(!zoomed)}
>
{imageLoading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)}
<img
src={imageUrl}
alt={title}
onLoad={() => setImageLoading(false)}
style={{
maxWidth: zoomed ? 'none' : '100%',
maxHeight: zoomed ? 'none' : isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
width: zoomed ? 'auto' : undefined,
objectFit: 'contain',
transition: 'transform 0.3s ease',
transform: zoomed ? 'scale(1.5)' : 'scale(1)',
}}
/>
{/* Zoom indicator */}
<div
style={{
position: 'absolute',
bottom: 12,
right: 12,
background: 'rgba(0,0,0,0.6)',
borderRadius: 4,
padding: '4px 8px',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{zoomed ? <ZoomOutOutlined /> : <ZoomInOutlined />}
{zoomed ? 'Click to zoom out' : 'Click to zoom in'}
</div>
</div>
{/* Bottom info bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: isMobile ? '6px 12px' : '6px 16px',
borderTop: `1px solid ${token.colorBorder}`,
flexWrap: 'wrap',
}}
>
{/* Close button */}
<Button
type="text"
icon={<CloseOutlined />}
onClick={collapseVideo}
size="small"
style={{ flexShrink: 0 }}
/>
{/* Title */}
<div
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: isMobile ? 12 : 14,
fontWeight: 500,
color: token.colorText,
}}
>
{title}
</div>
{/* Tags */}
<Space size={8} style={{ flexShrink: 0 }}>
<Tag style={{ margin: 0, fontSize: 10 }}>
<CameraOutlined /> Photo
</Tag>
{photo.format && (
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>{photo.format.toUpperCase()}</Tag>
)}
{photo.width && photo.height && (
<Tag style={{ margin: 0, fontSize: 10 }}>{photo.width}&times;{photo.height}</Tag>
)}
</Space>
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
<span><EyeOutlined /> {formatCount(photo.viewCount)}</span>
<span><CommentOutlined /> {formatCount(photo.commentCount)}</span>
</Space>
{/* Upvote */}
<Button
type={hasUpvoted ? 'primary' : 'text'}
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
onClick={handleUpvote}
loading={upvoting}
disabled={hasUpvoted}
size="small"
style={{ flexShrink: 0 }}
>
{formatCount(upvoteCount)}
</Button>
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
import { Input, Select, theme, Grid } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { Input, Select, Button, theme, Grid } from 'antd';
import { SearchOutlined, SendOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
const { useBreakpoint } = Grid;
@ -12,6 +13,7 @@ export default function MediaBottomNav() {
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { settings } = useSettingsStore();
// Initialize from URL params
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
@ -75,6 +77,17 @@ export default function MediaBottomNav() {
zIndex: 1000,
}}
>
{isMobile && settings?.enableInfluence !== false && (
<Button
type="text"
size="small"
icon={<SendOutlined />}
onClick={() => navigate('/campaigns')}
style={{ color: token.colorTextSecondary, flexShrink: 0 }}
>
{!isShorts && 'Action'}
</Button>
)}
<Input
placeholder="Search videos..."
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}

View File

@ -5,6 +5,7 @@ import {
HomeOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
PictureOutlined,
StarOutlined,
PlayCircleOutlined,
TeamOutlined,
@ -125,7 +126,8 @@ export default function MediaSidebar() {
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'photos', label: 'Photos', icon: <PictureOutlined />, path: '/gallery/photos' },
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'playback', label: 'Playback', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
];
@ -204,7 +206,7 @@ export default function MediaSidebar() {
color: 'rgba(255,255,255,0.45)',
}}
>
Video Platform
Media Platform
</Text>
</Space>
)}

View File

@ -0,0 +1,247 @@
import { Card, Checkbox, Tag, Tooltip } from 'antd';
import {
EditOutlined,
DeleteOutlined,
EyeOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
FolderOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoCardProps {
photo: Photo;
selected?: boolean;
onSelect?: (id: number) => void;
onClick?: (photo: Photo) => void;
onEdit?: (photo: Photo) => void;
onPreview?: (photo: Photo) => void;
onDelete?: (photo: Photo) => void;
onTogglePublish?: (photo: Photo) => void;
}
function formatFileSize(sizeStr: string | null): string {
if (!sizeStr) return '';
const bytes = parseInt(sizeStr);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function PhotoCard({
photo,
selected,
onSelect,
onClick,
onEdit,
onPreview,
onDelete,
onTogglePublish,
}: PhotoCardProps) {
const thumbnailUrl = photo.thumbnailUrl;
const hoverActions = (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
opacity: 0,
transition: 'opacity 0.2s',
}}
className="photo-card-hover"
>
{onEdit && (
<Tooltip title="Edit">
<EditOutlined
style={{ color: '#fff', fontSize: 18, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); onEdit(photo); }}
/>
</Tooltip>
)}
{onPreview && (
<Tooltip title="Preview">
<EyeOutlined
style={{ color: '#fff', fontSize: 18, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); onPreview(photo); }}
/>
</Tooltip>
)}
{onDelete && (
<Tooltip title="Delete">
<DeleteOutlined
style={{ color: '#ff4d4f', fontSize: 18, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); onDelete(photo); }}
/>
</Tooltip>
)}
</div>
);
return (
<Card
hoverable
size="small"
style={{ position: 'relative', overflow: 'hidden' }}
styles={{ body: { padding: 8 } }}
onClick={() => onClick?.(photo)}
cover={
<div
style={{
position: 'relative',
paddingTop: '75%', // 4:3 aspect ratio
backgroundColor: '#1a1a1a',
overflow: 'hidden',
}}
>
{thumbnailUrl ? (
<img
src={getAuthenticatedUrl(thumbnailUrl)}
alt={photo.title || photo.filename}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
) : (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<PictureOutlined style={{ fontSize: 32, color: '#555' }} />
</div>
)}
{/* Selection checkbox */}
{onSelect && (
<Checkbox
checked={selected}
onChange={(e) => { e.stopPropagation(); onSelect(photo.id); }}
onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 6, left: 6, zIndex: 2 }}
/>
)}
{/* Publish toggle pill */}
{onTogglePublish && (
<div
onClick={(e) => {
e.stopPropagation();
onTogglePublish(photo);
}}
style={{
position: 'absolute',
top: 8,
right: 8,
cursor: 'pointer',
background: photo.isPublished
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
color: '#fff',
padding: '4px 10px',
borderRadius: 20,
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 5,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
transition: 'all 0.2s ease',
zIndex: 11,
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
}}
title={photo.isPublished ? 'Click to unpublish' : 'Click to publish'}
role="button"
aria-label={photo.isPublished ? 'Unpublish photo' : 'Publish photo'}
>
{photo.isPublished ? (
<>
<CheckCircleOutlined style={{ fontSize: 13 }} />
<span>Published</span>
</>
) : (
<>
<ClockCircleOutlined style={{ fontSize: 13 }} />
<span>Draft</span>
</>
)}
</div>
)}
{/* Album badge */}
{photo.album && (
<Tag
color="blue"
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0 }}
>
<FolderOutlined /> {photo.album.title}
</Tag>
)}
{/* Format badge */}
{photo.format && (
<Tag
style={{ position: 'absolute', bottom: 6, right: 6, margin: 0, fontSize: 10 }}
>
{photo.format.toUpperCase()}
</Tag>
)}
{hoverActions}
</div>
}
>
<Card.Meta
title={
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{photo.title || photo.originalFilename || photo.filename}
</div>
}
description={
<div style={{ fontSize: 11, color: '#999' }}>
{photo.width && photo.height ? `${photo.width}×${photo.height}` : ''}
{photo.fileSize ? ` · ${formatFileSize(photo.fileSize)}` : ''}
</div>
}
/>
<style>{`
.photo-card-hover { opacity: 0 !important; }
.ant-card:hover .photo-card-hover { opacity: 1 !important; }
`}</style>
</Card>
);
}

View File

@ -0,0 +1,103 @@
import { Modal, Descriptions, Tag } from 'antd';
import { CameraOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoViewerModalProps {
photo: Photo | null;
open: boolean;
onClose: () => void;
}
export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) {
if (!photo) return null;
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={900}
centered
styles={{ body: { padding: 0 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Image */}
<div
style={{
background: '#000',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
maxHeight: '60vh',
overflow: 'hidden',
}}
>
<img
src={getAuthenticatedUrl(adminImageUrl)}
alt={photo.title || photo.filename}
style={{
maxWidth: '100%',
maxHeight: '60vh',
objectFit: 'contain',
}}
/>
</div>
{/* Info panel */}
<div style={{ padding: '0 24px 24px' }}>
<h3 style={{ margin: '0 0 8px' }}>
{photo.title || photo.originalFilename || photo.filename}
</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{photo.format && <Tag>{photo.format.toUpperCase()}</Tag>}
{photo.width && photo.height && <Tag>{photo.width}×{photo.height}</Tag>}
{photo.orientation && <Tag>{photo.orientation === 'H' ? 'Landscape' : photo.orientation === 'V' ? 'Portrait' : 'Square'}</Tag>}
{photo.isPublished && <Tag color="green">Published</Tag>}
</div>
{(photo.cameraMake || photo.cameraModel) && (
<Descriptions size="small" column={2} bordered style={{ marginBottom: 12 }}>
{(photo.cameraMake || photo.cameraModel) && (
<Descriptions.Item label={<><CameraOutlined /> Camera</>}>
{[photo.cameraMake, photo.cameraModel].filter(Boolean).join(' ')}
</Descriptions.Item>
)}
{photo.focalLength && (
<Descriptions.Item label="Focal">{photo.focalLength}</Descriptions.Item>
)}
{photo.aperture && (
<Descriptions.Item label="Aperture">{photo.aperture}</Descriptions.Item>
)}
{photo.shutterSpeed && (
<Descriptions.Item label="Shutter">{photo.shutterSpeed}</Descriptions.Item>
)}
{photo.iso && (
<Descriptions.Item label="ISO">{photo.iso}</Descriptions.Item>
)}
{photo.takenAt && (
<Descriptions.Item label="Date">{new Date(photo.takenAt).toLocaleString()}</Descriptions.Item>
)}
</Descriptions>
)}
{photo.description && (
<p style={{ color: '#999', fontSize: 13 }}>{photo.description}</p>
)}
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,229 @@
import { useState } from 'react';
import { Card, Tag, Space, Typography, theme } from 'antd';
import { PictureOutlined, LikeOutlined, EyeOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { hexToRgba } from '@/utils/color';
import type { PublicAlbum } from '@/types/media';
interface PublicAlbumCardProps {
album: PublicAlbum;
}
export default function PublicAlbumCard({ album }: PublicAlbumCardProps) {
const { token } = theme.useToken();
const { expandMedia } = useExpandedVideo();
const [thumbnailError, setThumbnailError] = useState(false);
const formatCount = (count: number | undefined | null) => {
if (!count && count !== 0) return '0';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
const handleCardClick = () => {
expandMedia(album.id, 'album', album);
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
const card = e.currentTarget;
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
card.style.transform = 'translateY(-2px)';
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
const card = e.currentTarget;
card.style.boxShadow = 'none';
card.style.transform = 'translateY(0)';
};
return (
<Card
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${token.colorBorderSecondary}`,
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
styles={{ body: { padding: 12 } }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleCardClick}
cover={
<div
style={{
position: 'relative',
paddingTop: '75%', // 4:3 aspect
background: '#000',
overflow: 'hidden',
}}
>
{/* Cover thumbnail */}
{album.coverThumbnailUrl && !thumbnailError ? (
<img
src={album.coverThumbnailUrl}
alt={album.title}
onError={() => setThumbnailError(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 48,
color: '#666',
}}
>
<AppstoreOutlined />
</div>
)}
{/* Stacked photos effect overlay */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%)',
}}
/>
{/* Expand overlay on hover */}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.3)',
opacity: 0,
transition: 'opacity 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: hexToRgba(token.colorPrimary, 0.9),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
>
<AppstoreOutlined style={{ fontSize: 28, color: '#fff' }} />
</div>
</div>
{/* Photo count badge */}
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<PictureOutlined style={{ fontSize: 11 }} />
{album.photoCount} photos
</div>
{/* Category badge */}
{album.category && (
<Tag
style={{
position: 'absolute',
top: 8,
right: 8,
margin: 0,
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
>
{album.category}
</Tag>
)}
{/* Album indicator */}
<Tag
style={{
position: 'absolute',
top: 8,
left: 8,
margin: 0,
background: token.colorPrimary,
border: 'none',
color: '#fff',
fontWeight: 600,
}}
>
Album
</Tag>
</div>
}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text
strong
style={{
fontSize: 14,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
}}
title={album.title}
>
{album.title}
</Typography.Text>
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
<Space size={4}>
<LikeOutlined />
<span>{formatCount(album.upvoteCount)}</span>
</Space>
<Space size={4}>
<EyeOutlined />
<span>{formatCount(album.viewCount)}</span>
</Space>
<Space size={4}>
<PictureOutlined />
<span>{album.photoCount}</span>
</Space>
</Space>
</Space>
</Card>
);
}

View File

@ -0,0 +1,226 @@
import { useState } from 'react';
import { Card, Tag, Space, Typography, theme } from 'antd';
import { CameraOutlined, LikeOutlined, EyeOutlined, CommentOutlined, PictureOutlined } from '@ant-design/icons';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { hexToRgba } from '@/utils/color';
import type { PublicPhoto } from '@/types/media';
interface PublicPhotoCardProps {
photo: PublicPhoto;
}
export default function PublicPhotoCard({ photo }: PublicPhotoCardProps) {
const { token } = theme.useToken();
const { expandMedia } = useExpandedVideo();
const [thumbnailError, setThumbnailError] = useState(false);
const formatCount = (count: number | undefined | null) => {
if (!count && count !== 0) return '0';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
const title = photo.title || 'Untitled Photo';
const handleCardClick = () => {
expandMedia(photo.id, 'photo', photo);
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
const card = e.currentTarget;
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
card.style.transform = 'translateY(-2px)';
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
const card = e.currentTarget;
card.style.boxShadow = 'none';
card.style.transform = 'translateY(0)';
};
// Choose aspect ratio based on orientation
const aspectPadding = photo.orientation === 'V' ? '133.33%' : photo.orientation === 'S' ? '100%' : '75%'; // 3:4, 1:1, or 4:3
return (
<Card
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${token.colorBorderSecondary}`,
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
styles={{ body: { padding: 12 } }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleCardClick}
cover={
<div
style={{
position: 'relative',
paddingTop: aspectPadding,
background: '#000',
overflow: 'hidden',
}}
>
{/* Thumbnail */}
{photo.thumbnailUrl && !thumbnailError ? (
<img
src={photo.thumbnailUrl}
alt={title}
onError={() => setThumbnailError(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 48,
color: '#666',
}}
>
<PictureOutlined />
</div>
)}
{/* Camera icon overlay on hover */}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.3)',
opacity: 0,
transition: 'opacity 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: hexToRgba(token.colorPrimary, 0.9),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
>
<CameraOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
</div>
{/* Format badge */}
{photo.format && (
<Tag
style={{
position: 'absolute',
top: 8,
left: 8,
margin: 0,
background: token.colorPrimary,
border: 'none',
color: '#fff',
fontWeight: 600,
textTransform: 'uppercase',
}}
>
{photo.format}
</Tag>
)}
{/* Category badge */}
{photo.category && (
<Tag
style={{
position: 'absolute',
top: 8,
right: 8,
margin: 0,
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
>
{photo.category}
</Tag>
)}
{/* Dimensions badge */}
{photo.width && photo.height && (
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
}}
>
{photo.width}&times;{photo.height}
</div>
)}
</div>
}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text
strong
style={{
fontSize: 14,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
}}
title={title}
>
{title}
</Typography.Text>
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
<Space size={4}>
<LikeOutlined />
<span>{formatCount(photo.upvoteCount)}</span>
</Space>
<Space size={4}>
<EyeOutlined />
<span>{formatCount(photo.viewCount)}</span>
</Space>
<Space size={4}>
<CommentOutlined />
<span>{formatCount(photo.commentCount)}</span>
</Space>
</Space>
</Space>
</Card>
);
}

View File

@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, Tag } from 'antd';
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PhotoAlbum } from '@/types/media';
const { Dragger } = Upload;
interface UploadPhotoDrawerProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
albumId?: number; // Pre-select album
}
interface UploadResult {
filename: string;
success: boolean;
error?: string;
}
const ACCEPTED_TYPES = '.jpg,.jpeg,.png,.webp,.avif,.gif,.tiff,.tif,.heic,.heif';
export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }: UploadPhotoDrawerProps) {
const [form] = Form.useForm();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<UploadResult[]>([]);
const [albums, setAlbums] = useState<PhotoAlbum[]>([]);
const [fileList, setFileList] = useState<any[]>([]);
useEffect(() => {
if (open) {
fetchAlbums();
setResults([]);
setProgress(0);
setFileList([]);
form.resetFields();
if (albumId) form.setFieldsValue({ albumId });
}
}, [open, albumId]);
const fetchAlbums = async () => {
try {
const { data } = await mediaApi.get('/albums', { params: { limit: 200 } });
setAlbums(data.albums || []);
} catch {
// Ignore
}
};
const handleUpload = async () => {
if (fileList.length === 0) {
message.warning('Select at least one photo');
return;
}
const values = form.getFieldsValue();
setUploading(true);
setResults([]);
const uploadResults: UploadResult[] = [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i].originFileObj || fileList[i];
const formData = new FormData();
formData.append('file', file);
if (values.title && fileList.length === 1) formData.append('title', values.title);
if (values.producer) formData.append('producer', values.producer);
if (values.creator) formData.append('creator', values.creator);
if (values.albumId) formData.append('albumId', String(values.albumId));
try {
await mediaApi.post('/photos/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
uploadResults.push({ filename: file.name, success: true });
} catch (error: any) {
uploadResults.push({
filename: file.name,
success: false,
error: error.response?.data?.message || 'Upload failed',
});
}
setProgress(Math.round(((i + 1) / fileList.length) * 100));
setResults([...uploadResults]);
}
setUploading(false);
const successCount = uploadResults.filter(r => r.success).length;
if (successCount > 0) {
message.success(`Uploaded ${successCount} photo${successCount > 1 ? 's' : ''}`);
onSuccess();
}
if (successCount < fileList.length) {
message.error(`${fileList.length - successCount} upload(s) failed`);
}
};
return (
<Drawer
title="Upload Photos"
open={open}
onClose={onClose}
width={480}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={onClose}>Cancel</Button>
<Button
type="primary"
onClick={handleUpload}
loading={uploading}
disabled={fileList.length === 0}
>
Upload {fileList.length > 0 ? `(${fileList.length})` : ''}
</Button>
</div>
}
>
<Form form={form} layout="vertical">
<Dragger
accept={ACCEPTED_TYPES}
multiple
beforeUpload={() => false}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
style={{ marginBottom: 16 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Click or drag photos here</p>
<p className="ant-upload-hint">
JPG, PNG, WebP, AVIF, GIF, TIFF, HEIC
</p>
</Dragger>
{fileList.length === 1 && (
<Form.Item name="title" label="Title">
<Input placeholder="Photo title" />
</Form.Item>
)}
<Form.Item name="producer" label="Producer">
<Input placeholder="Producer name" />
</Form.Item>
<Form.Item name="creator" label="Creator">
<Input placeholder="Creator name" />
</Form.Item>
<Form.Item name="albumId" label="Add to Album">
<Select
placeholder="No album"
allowClear
options={albums.map(a => ({
value: a.id,
label: `${a.title} (${a.photoCount} photos)`,
}))}
/>
</Form.Item>
</Form>
{uploading && <Progress percent={progress} style={{ marginBottom: 16 }} />}
{results.length > 0 && (
<List
size="small"
dataSource={results}
renderItem={(item) => (
<List.Item>
{item.success ? (
<Tag icon={<CheckCircleOutlined />} color="success">{item.filename}</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">
{item.filename}: {item.error}
</Tag>
)}
</List.Item>
)}
/>
)}
</Drawer>
);
}

View File

@ -17,14 +17,19 @@ export interface VideoData {
createdAt: string;
}
export type ExpandedMediaType = 'video' | 'photo' | 'album';
interface ExpandedVideoState {
videoId: number | null;
video: VideoData | null;
mediaType: ExpandedMediaType;
mediaData: any; // For photo/album expansion
}
interface ExpandedVideoContextValue {
state: ExpandedVideoState;
expandVideo: (id: number, video: VideoData) => void;
expandMedia: (id: number, type: ExpandedMediaType, data: any) => void;
collapseVideo: () => void;
}
@ -48,10 +53,12 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
const [state, setState] = useState<ExpandedVideoState>({
videoId: null,
video: null,
mediaType: 'video',
mediaData: null,
});
const expandVideo = useCallback((id: number, video: VideoData) => {
setState({ videoId: id, video });
setState({ videoId: id, video, mediaType: 'video', mediaData: video });
// Update URL with ?expanded=id (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
@ -59,8 +66,21 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate]);
const expandMedia = useCallback((id: number, type: ExpandedMediaType, data: any) => {
setState({
videoId: type === 'video' ? id : null,
video: type === 'video' ? data : null,
mediaType: type,
mediaData: data,
});
const newParams = new URLSearchParams(window.location.search);
newParams.set('expanded', `${type}-${id}`);
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate]);
const collapseVideo = useCallback(() => {
setState({ videoId: null, video: null });
setState({ videoId: null, video: null, mediaType: 'video', mediaData: null });
// Remove URL param (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
@ -71,6 +91,7 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
const value: ExpandedVideoContextValue = {
state,
expandVideo,
expandMedia,
collapseVideo,
};

View File

@ -29,6 +29,7 @@ import AdminLiveMap from '@/components/canvass/AdminLiveMap';
import HistoricalRoutesDrawer from '@/components/canvass/HistoricalRoutesDrawer';
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
import CanvassTrendsCard from '@/components/canvass/CanvassTrendsCard';
export default function CanvassDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -350,6 +351,11 @@ export default function CanvassDashboardPage() {
</Row>
)}
{/* Outcome Trends */}
<div style={{ marginTop: 16, padding: isMobile ? '0' : undefined }}>
<CanvassTrendsCard />
</div>
{/* Historical Routes Drawer */}
<HistoricalRoutesDrawer
open={historyOpen}

View File

@ -53,6 +53,16 @@ import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
import ActivityFeedCard from '@/components/dashboard/ActivityFeedCard';
import TodayEventsCard from '@/components/dashboard/TodayEventsCard';
import ChatNotifierCard from '@/components/dashboard/ChatNotifierCard';
import TopVideosCard from '@/components/dashboard/TopVideosCard';
import RecentCommentsCard from '@/components/dashboard/RecentCommentsCard';
import DocsAnalyticsCard from '@/components/dashboard/DocsAnalyticsCard';
import UpcomingShiftsCard from '@/components/dashboard/UpcomingShiftsCard';
import CampaignEffectivenessCard from '@/components/dashboard/CampaignEffectivenessCard';
import RecentSignupsCard from '@/components/dashboard/RecentSignupsCard';
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
import DonationSummaryCard from '@/components/dashboard/DonationSummaryCard';
import SystemAlertsCard from '@/components/dashboard/SystemAlertsCard';
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
import { buildServiceUrl } from '@/lib/service-url';
import type {
DashboardSummary, QueueStats, ServicesStatus, ServicesConfig,
@ -78,6 +88,8 @@ if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID))
.svc-badge-online .ant-badge-status-dot {
animation: dashboard-pulse 2s infinite;
}
.db-mi { min-width: 0; display: flex; flex-direction: column; }
.db-mi > * { flex: 1; }
`;
document.head.appendChild(style);
}
@ -234,6 +246,17 @@ export default function DashboardPage() {
const showInfluence = settings?.enableInfluence !== false;
const showMap = settings?.enableMap !== false;
const showMedia = settings?.enableMediaFeatures !== false;
const showEvents = settings?.enableEvents !== false;
const showChat = settings?.enableChat !== false;
const showNewsletter = settings?.enableNewsletter !== false;
const showPayments = settings?.enablePayments !== false;
// Grid span helper: 12-col on lg, 2-col on md, 1-col on xs
const gs = (lg: number, md?: number): React.CSSProperties | undefined => {
if (screens.lg) return { gridColumn: `span ${lg}` };
if (screens.md && md) return { gridColumn: `span ${md}` };
return undefined;
};
const geocodePct = summary && summary.locations.total > 0
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
@ -311,28 +334,28 @@ export default function DashboardPage() {
</Flex>
{activeView === 'dashboard' && (
<Flex gap={4} wrap="wrap" justify="flex-end">
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
{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>}
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
{isSuperAdmin && (
<>
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
</>
)}
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 18 }} />} onClick={handleRefresh} /></Tooltip>
</Flex>
)}
{activeView === 'homepage' && (
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 18 }} />} onClick={handleRefresh} /></Tooltip>
)}
</Flex>
</Card>
@ -400,14 +423,14 @@ export default function DashboardPage() {
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
{summary && (
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 16px' } }}>
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '10px 16px' } }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex gap={0} wrap="wrap" align="center">
{weather && (
<Flex align="center" gap={6} style={{ padding: '0 12px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 20 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
<Text strong style={{ fontSize: 14 }}>{Math.round(weather.temperature)}°C</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{weather.weatherDescription}</Text>
<Flex align="center" gap={6} style={{ padding: '0 14px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 24 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
<Text strong style={{ fontSize: 16 }}>{Math.round(weather.temperature)}°C</Text>
<Text type="secondary" style={{ fontSize: 13 }}>{weather.weatherDescription}</Text>
</Flex>
)}
{/* Quick stat chips */}
@ -480,110 +503,107 @@ export default function DashboardPage() {
</Card>
)}
{/* === Module Overview Row (3 columns) === */}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
{/* === Dashboard Cards (masonry layout for dense packing) === */}
<div style={{ display: 'grid', gridTemplateColumns: screens.lg ? 'repeat(12, 1fr)' : screens.md ? 'repeat(2, 1fr)' : '1fr', gap: 12, gridAutoFlow: 'dense' }}>
{showInfluence && (
<Col xs={24} lg={8}>
<div className="db-mi" style={gs(4)}>
<Card
title={
<Flex align="center" gap={6}>
<SendOutlined />
<span>Influence</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.campaigns.active} active / {summary.campaigns.total}</Text>}
<SendOutlined style={{ fontSize: 16 }} />
<span style={{ fontSize: 14 }}>Influence</span>
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.campaigns.active} active / {summary.campaigns.total}</Text>}
</Flex>
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/campaigns')}>View</Button>}
style={{ height: '100%' }}
extra={<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>}
>
{summary && (
<Flex gap={8} align="flex-start">
<Space direction="vertical" style={{ width: '100%', flex: 1 }} size={4}>
<Space direction="vertical" style={{ width: '100%', flex: 1 }} size={6}>
<Flex gap={4} wrap="wrap">
<Tag color="green">{summary.campaigns.active} Active</Tag>
<Tag>{summary.campaigns.draft} Draft</Tag>
{summary.campaigns.paused > 0 && <Tag color="orange">{summary.campaigns.paused} Paused</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Responses: <Text strong>{summary.responses.total}</Text></Text>
<Text style={{ fontSize: 13 }}>Responses: <Text strong>{summary.responses.total}</Text></Text>
{summary.responses.pending > 0 && <Tag color="orange" style={{ margin: 0 }}>{summary.responses.pending} pending</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Emails: <Text strong>{summary.emails.sent}</Text> sent</Text>
<Text style={{ fontSize: 13 }}>Emails: <Text strong>{summary.emails.sent}</Text> sent</Text>
{summary.emails.failed > 0 && <Text type="danger">{summary.emails.failed} failed</Text>}
</Flex>
{queue && (
<Flex justify="space-between">
<Text>Queue: <Text strong>{queue.waiting}</Text> waiting</Text>
<Text style={{ fontSize: 13 }}>Queue: <Text strong>{queue.waiting}</Text> waiting</Text>
{queue.paused && <Tag color="red" style={{ margin: 0 }}>Paused</Tag>}
</Flex>
)}
</Space>
{summary.campaigns.total > 0 && screens.md && (
<div style={{ width: 80, flexShrink: 0 }}>
<MiniDonutChart data={campaignDonutData} height={80} innerRadius={20} outerRadius={34} />
<div style={{ width: 90, flexShrink: 0 }}>
<MiniDonutChart data={campaignDonutData} height={90} innerRadius={22} outerRadius={38} />
</div>
)}
</Flex>
)}
</Card>
</Col>
</div>
)}
{showMap && (
<Col xs={24} lg={8}>
<div className="db-mi" style={gs(4)}>
<Card
title={
<Flex align="center" gap={6}>
<CompassOutlined />
<span>Map</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.locations.total.toLocaleString()} locations</Text>}
<CompassOutlined style={{ fontSize: 16 }} />
<span style={{ fontSize: 14 }}>Map</span>
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.locations.total.toLocaleString()} locations</Text>}
</Flex>
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/map')}>View</Button>}
style={{ height: '100%' }}
extra={<Button type="link" onClick={() => navigate('/app/map')}>View</Button>}
>
{summary && (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Flex align="center" gap={8}>
<Text>Geocoded:</Text>
<Text style={{ fontSize: 13 }}>Geocoded:</Text>
<Progress percent={geocodePct} size="small" style={{ flex: 1 }} />
<Text type="secondary" style={{ fontSize: 12 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
</Flex>
<Flex justify="space-between">
<Text>Addresses: <Text strong>{summary.locations.addresses.toLocaleString()}</Text></Text>
<Text type="secondary"><ScissorOutlined /> {summary.cuts.total} cuts</Text>
<Text style={{ fontSize: 13 }}>Addresses: <Text strong>{summary.locations.addresses.toLocaleString()}</Text></Text>
<Text type="secondary" style={{ fontSize: 13 }}><ScissorOutlined /> {summary.cuts.total} cuts</Text>
</Flex>
<Flex justify="space-between">
<Text>Canvassing: <Text strong>{summary.canvass.totalVisits}</Text> visits</Text>
<Text style={{ fontSize: 13 }}>Canvassing: <Text strong>{summary.canvass.totalVisits}</Text> visits</Text>
{summary.canvass.activeSessions > 0 && <Tag color="green" style={{ margin: 0 }}>{summary.canvass.activeSessions} active</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Shifts: <Text strong>{summary.shifts.upcoming}</Text> upcoming</Text>
<Text type="secondary">{summary.shifts.open} open</Text>
<Text style={{ fontSize: 13 }}>Shifts: <Text strong>{summary.shifts.upcoming}</Text> upcoming</Text>
<Text type="secondary" style={{ fontSize: 13 }}>{summary.shifts.open} open</Text>
</Flex>
</Space>
)}
</Card>
</Col>
</div>
)}
<Col xs={24} lg={8}>
<div className="db-mi" style={gs(4)}>
<Card
title={
<Flex align="center" gap={6}>
<TeamOutlined />
<span>Users & Content</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.users.total} users</Text>}
<TeamOutlined style={{ fontSize: 16 }} />
<span style={{ fontSize: 14 }}>Users & Content</span>
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.users.total} users</Text>}
</Flex>
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/users')}>Manage</Button>}
style={{ height: '100%' }}
extra={<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>}
>
{summary && (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Flex gap={4} wrap="wrap">
{Object.entries(summary.users.byRole)
.filter(([, count]) => count > 0)
@ -595,41 +615,90 @@ export default function DashboardPage() {
{summary.users.suspended > 0 && <Tag color="volcano" style={{ margin: 0 }}>Suspended: {summary.users.suspended}</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Pages: <Text strong>{summary.pages.published}</Text> published</Text>
<Text type="secondary">/ {summary.pages.total}</Text>
<Text style={{ fontSize: 13 }}>Pages: <Text strong>{summary.pages.published}</Text> published</Text>
<Text type="secondary" style={{ fontSize: 13 }}>/ {summary.pages.total}</Text>
</Flex>
<Flex justify="space-between">
<Text>Templates: <Text strong>{summary.emailTemplates.total}</Text></Text>
{showInfluence && <Text type="secondary">Reps: {summary.representatives.totalCached}</Text>}
<Text style={{ fontSize: 13 }}>Templates: <Text strong>{summary.emailTemplates.total}</Text></Text>
{showInfluence && <Text type="secondary" style={{ fontSize: 13 }}>Reps: {summary.representatives.totalCached}</Text>}
</Flex>
{showMedia && (
<Flex justify="space-between">
<Text>Videos: <Text strong>{summary.videos.published}</Text> published</Text>
<Text type="secondary">/ {summary.videos.total}</Text>
<Text style={{ fontSize: 13 }}>Videos: <Text strong>{summary.videos.published}</Text> published</Text>
<Text type="secondary" style={{ fontSize: 13 }}>/ {summary.videos.total}</Text>
</Flex>
)}
</Space>
)}
</Card>
</Col>
</Row>
</div>
{/* === Activity Feed + Events + Chat === */}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={24} lg={12}>
<div className="db-mi" style={gs(5, 2)}>
<ActivityFeedCard />
</Col>
<Col xs={24} lg={12}>
<Row gutter={[12, 12]}>
<Col xs={24}>
<TodayEventsCard />
</Col>
<Col xs={24}>
<ChatNotifierCard />
</Col>
</Row>
</Col>
</Row>
</div>
{showEvents && (
<div className="db-mi" style={gs(3)}>
<TodayEventsCard />
</div>
)}
<div className="db-mi" style={gs(4)}>
<DocsAnalyticsCard />
</div>
{showChat && (
<div className="db-mi" style={gs(5, 2)}>
<ChatNotifierCard />
</div>
)}
{showMap && (
<div className="db-mi" style={gs(4)}>
<UpcomingShiftsCard />
</div>
)}
{showInfluence && (
<div className="db-mi" style={gs(3)}>
<CampaignEffectivenessCard />
</div>
)}
{showMap && (
<div className="db-mi" style={gs(3)}>
<RecentSignupsCard />
</div>
)}
{showMedia && (
<div className="db-mi" style={gs(5)}>
<TopVideosCard />
</div>
)}
{showMedia && (
<div className="db-mi" style={gs(4)}>
<RecentCommentsCard />
</div>
)}
{showNewsletter && isSuperAdmin && (
<div className="db-mi" style={gs(4)}>
<NewsletterStatsCard />
</div>
)}
{showPayments && isSuperAdmin && (
<div className="db-mi" style={gs(3)}>
<DonationSummaryCard />
</div>
)}
{isSuperAdmin && (
<div className="db-mi" style={gs(5)}>
<SystemAlertsCard />
</div>
)}
</div>
{/* === Canvass Progress (full-width table) === */}
{showMap && (
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={24}>
<CutCampaignAnalyticsCard />
</Col>
</Row>
)}
{/* === System + Docker Section (SUPER_ADMIN only) === */}
{isSuperAdmin && (
@ -944,17 +1013,17 @@ function QuickStat({ icon, color, value, label, onClick }: {
return (
<Flex
align="center"
gap={5}
gap={6}
onClick={onClick}
style={{
padding: '2px 10px',
padding: '2px 12px',
cursor: 'pointer',
borderRight: '1px solid rgba(255,255,255,0.06)',
}}
>
<span style={{ color, fontSize: 14 }}>{icon}</span>
<Text strong style={{ fontSize: 14 }}>{value}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
<span style={{ color, fontSize: 16 }}>{icon}</span>
<Text strong style={{ fontSize: 16 }}>{value}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>{label}</Text>
</Flex>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal } from 'antd';
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal, Segmented } from 'antd';
import {
SearchOutlined,
GlobalOutlined,
@ -12,19 +12,30 @@ import {
ThunderboltOutlined,
OrderedListOutlined,
LockOutlined,
PictureOutlined,
VideoCameraOutlined,
FolderOutlined,
FolderAddOutlined,
} from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
import { useDebounce } from '@/hooks/useDebounce';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import type { Video, VideosListResponse, VideosListParams } from '@/types/media';
import type { Video, VideosListResponse, VideosListParams, Photo, PhotosListResponse, PhotoAlbum } from '@/types/media';
import type { AppOutletContext } from '@/types/api';
import VideoCard from '@/components/media/VideoCard';
import PhotoCard from '@/components/media/PhotoCard';
import AlbumCard from '@/components/media/AlbumCard';
import BulkActionsBar from '@/components/media/BulkActionsBar';
import PublishModal from '@/components/media/PublishModal';
import DeleteConfirmModal from '@/components/media/DeleteConfirmModal';
import UploadVideoDrawer from '@/components/media/UploadVideoDrawer';
import UploadPhotoDrawer from '@/components/media/UploadPhotoDrawer';
import VideoViewerModal from '@/components/media/VideoViewerModal';
import PhotoViewerModal from '@/components/media/PhotoViewerModal';
import EditPhotoModal from '@/components/media/EditPhotoModal';
import AlbumDetailDrawer from '@/components/media/AlbumDetailDrawer';
import CreateAlbumModal from '@/components/media/CreateAlbumModal';
import QuickAnalyticsModal from '@/components/media/QuickAnalyticsModal';
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
import ScheduleCalendarDrawer from '@/components/media/ScheduleCalendarDrawer';
@ -34,22 +45,16 @@ import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
type MediaTab = 'Videos' | 'Photos' | 'Albums';
export default function LibraryPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [mediaTab, setMediaTab] = useLocalStorage<MediaTab>('libraryMediaTab', 'Videos');
// === Video state ===
const [videos, setVideos] = useState<Video[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 48, total: 0 });
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const [orientation, setOrientation] = useState<'H' | 'V' | undefined>();
const [selectedProducers, setSelectedProducers] = useState<string[]>([]);
const [producers, setProducers] = useState<string[]>([]);
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'compact'>('mediaViewMode', 'grid');
const [videoPagination, setVideoPagination] = useState({ page: 1, limit: 48, total: 0 });
const [selectedVideoIds, setSelectedVideoIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [publishModalOpen, setPublishModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [viewerVideo, setViewerVideo] = useState<Video | null>(null);
const [analyticsVideoId, setAnalyticsVideoId] = useState<number | null>(null);
const [analyticsVideoTitle, setAnalyticsVideoTitle] = useState<string>('');
@ -62,58 +67,140 @@ export default function LibraryPage() {
const [playlistVideoId, setPlaylistVideoId] = useState<number | null>(null);
const [bulkPlaylistOpen, setBulkPlaylistOpen] = useState(false);
const [bulkAccessLevelOpen, setBulkAccessLevelOpen] = useState(false);
const [publishModalOpen, setPublishModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [uploadVideoOpen, setUploadVideoOpen] = useState(false);
// === Photo state ===
const [photos, setPhotos] = useState<Photo[]>([]);
const [photoPagination, setPhotoPagination] = useState({ page: 1, limit: 48, total: 0 });
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
const [viewerPhoto, setViewerPhoto] = useState<Photo | null>(null);
const [editPhoto, setEditPhoto] = useState<Photo | null>(null);
const [uploadPhotoOpen, setUploadPhotoOpen] = useState(false);
const [createAlbumOpen, setCreateAlbumOpen] = useState(false);
const [formatFilter, setFormatFilter] = useState<string | undefined>();
const [photoFormats, setPhotoFormats] = useState<string[]>([]);
// === Album state ===
const [albums, setAlbums] = useState<PhotoAlbum[]>([]);
const [albumPagination, setAlbumPagination] = useState({ page: 1, limit: 48, total: 0 });
const [selectedAlbumId, setSelectedAlbumId] = useState<number | null>(null);
// === Shared state ===
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const [orientation, setOrientation] = useState<'H' | 'V' | 'S' | undefined>();
const [selectedProducers, setSelectedProducers] = useState<string[]>([]);
const [producers, setProducers] = useState<string[]>([]);
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'compact'>('mediaViewMode', 'grid');
const [loading, setLoading] = useState(false);
useEffect(() => {
setPageHeader({
title: 'Video Library',
subtitle: 'Manage your media collection',
title: 'Media Library',
subtitle: 'Manage videos, photos, and albums',
});
}, [setPageHeader]);
useEffect(() => {
fetchProducers();
fetchPhotoFormats();
}, []);
// Fetch data when tab or filters change
useEffect(() => {
fetchVideos();
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
if (mediaTab === 'Videos') fetchVideos();
else if (mediaTab === 'Photos') fetchPhotos();
else fetchAlbums();
}, [mediaTab, debouncedSearch, orientation, selectedProducers, shortsFilter, formatFilter,
videoPagination.page, videoPagination.limit,
photoPagination.page, photoPagination.limit,
albumPagination.page, albumPagination.limit]);
const fetchProducers = async () => {
try {
const { data } = await mediaApi.get<string[]>('/videos/producers');
setProducers(data);
} catch (error: any) {
console.error('Failed to load producers:', error);
}
} catch { /* ignore */ }
};
const fetchPhotoFormats = async () => {
try {
const { data } = await mediaApi.get<string[]>('/photos/formats');
setPhotoFormats(data);
} catch { /* ignore */ }
};
// === Video fetching ===
const fetchVideos = useCallback(async () => {
setLoading(true);
try {
const params: VideosListParams = {
search: debouncedSearch || undefined,
orientation,
orientation: orientation === 'S' ? undefined : orientation as 'H' | 'V' | undefined,
producers: selectedProducers.length > 0 ? selectedProducers : undefined,
isShort: shortsFilter,
offset: (pagination.page - 1) * pagination.limit,
limit: pagination.limit,
offset: (videoPagination.page - 1) * videoPagination.limit,
limit: videoPagination.limit,
};
const { data } = await mediaApi.get<VideosListResponse>('/videos', { params });
setVideos(data.videos);
setPagination((prev) => ({ ...prev, total: data.total }));
setVideoPagination((prev) => ({ ...prev, total: data.total }));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to load videos');
} finally {
setLoading(false);
}
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, videoPagination.page, videoPagination.limit]);
// === Photo fetching ===
const fetchPhotos = useCallback(async () => {
setLoading(true);
try {
const params: any = {
search: debouncedSearch || undefined,
orientation,
producer: selectedProducers[0] || undefined,
format: formatFilter,
offset: (photoPagination.page - 1) * photoPagination.limit,
limit: photoPagination.limit,
};
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');
} finally {
setLoading(false);
}
}, [debouncedSearch, orientation, selectedProducers, formatFilter, photoPagination.page, photoPagination.limit]);
// === Album fetching ===
const fetchAlbums = useCallback(async () => {
setLoading(true);
try {
const params: any = {
search: debouncedSearch || undefined,
offset: (albumPagination.page - 1) * albumPagination.limit,
limit: albumPagination.limit,
};
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');
} finally {
setLoading(false);
}
}, [debouncedSearch, albumPagination.page, albumPagination.limit]);
// === Video handlers ===
const handleScanShorts = async () => {
setScanningShorts(true);
try {
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>(
'/shorts/scan'
);
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) {
@ -123,37 +210,22 @@ export default function LibraryPage() {
}
};
const handleSelect = (id: number) => {
const handleVideoSelect = (id: number) => {
setSelectedVideoIds((prev) =>
prev.includes(id) ? prev.filter((videoId) => videoId !== id) : [...prev, id]
prev.includes(id) ? prev.filter((vid) => vid !== id) : [...prev, id]
);
};
const handleSelectAll = () => {
if (selectedVideoIds.length === videos.length) {
setSelectedVideoIds([]);
} else {
setSelectedVideoIds(videos.map((v) => v.id));
}
const handleVideoSelectAll = () => {
if (selectedVideoIds.length === videos.length) setSelectedVideoIds([]);
else setSelectedVideoIds(videos.map((v) => v.id));
};
const handlePublishSuccess = () => {
setPublishModalOpen(false);
setSelectedVideoIds([]);
fetchVideos();
};
const handleUploadSuccess = () => {
setUploadModalOpen(false);
fetchVideos();
fetchProducers();
};
const handleDelete = async () => {
const handleVideoDelete = async () => {
setDeleting(true);
try {
await Promise.all(selectedVideoIds.map((id) => mediaApi.delete(`/videos/${id}`)));
message.success(`Successfully deleted ${selectedVideoIds.length} video(s)`);
message.success(`Deleted ${selectedVideoIds.length} video(s)`);
setDeleteModalOpen(false);
setSelectedVideoIds([]);
fetchVideos();
@ -164,7 +236,7 @@ export default function LibraryPage() {
}
};
const handleDeleteSingle = (video: Video) => {
const handleDeleteSingleVideo = (video: Video) => {
Modal.confirm({
title: 'Delete this video?',
content: video.title || video.filename,
@ -173,7 +245,7 @@ export default function LibraryPage() {
onOk: async () => {
try {
await mediaApi.delete(`/videos/${video.id}`);
message.success('Video deleted successfully');
message.success('Video deleted');
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete video');
@ -182,225 +254,387 @@ export default function LibraryPage() {
});
};
const handleEdit = (video: Video) => {
setEditVideo(video);
};
const handleEditSuccess = () => {
setEditVideo(null);
fetchVideos();
fetchProducers();
};
const handleAnalytics = (video: Video) => {
setAnalyticsVideoId(video.id);
setAnalyticsVideoTitle(video.title || video.filename);
};
const handleSchedule = (video: Video) => {
setScheduleVideo(video);
};
const handleScheduleSuccess = () => {
setScheduleVideo(null);
fetchVideos();
};
const handleTogglePublish = async (video: Video) => {
const handleToggleVideoPublish = async (video: Video) => {
try {
if (video.isPublished) {
// Unpublish
await mediaApi.post(`/videos/${video.id}/unpublish`);
message.success(`"${video.title || video.filename}" unpublished`);
} else {
// Publish to default 'videos' category
await mediaApi.post(`/videos/${video.id}/publish`, {
category: 'videos',
});
await mediaApi.post(`/videos/${video.id}/publish`, { category: 'videos' });
message.success(`"${video.title || video.filename}" published`);
}
fetchVideos();
} catch (error: any) {
console.error('Failed to toggle publish:', error);
message.error(error.response?.data?.message || 'Failed to toggle publish state');
message.error(error.response?.data?.message || 'Failed to toggle publish');
}
};
// === Photo handlers ===
const handlePhotoSelect = (id: number) => {
setSelectedPhotoIds((prev) =>
prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id]
);
};
const handlePhotoSelectAll = () => {
if (selectedPhotoIds.length === photos.length) setSelectedPhotoIds([]);
else setSelectedPhotoIds(photos.map((p) => p.id));
};
const handleTogglePhotoPublish = async (photo: Photo) => {
try {
if (photo.isPublished) {
await mediaApi.post(`/photos/${photo.id}/unpublish`);
message.success('Photo unpublished');
} else {
await mediaApi.post(`/photos/${photo.id}/publish`);
message.success('Photo published');
}
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to toggle publish');
}
};
const handleDeleteSinglePhoto = (photo: Photo) => {
Modal.confirm({
title: 'Delete this photo?',
content: photo.title || photo.originalFilename || photo.filename,
okText: 'Delete',
okType: 'danger',
onOk: async () => {
try {
await mediaApi.delete(`/photos/${photo.id}`);
message.success('Photo deleted');
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete photo');
}
},
});
};
const handleBulkPhotoPublish = async () => {
try {
await mediaApi.post('/photos/bulk-publish', { ids: selectedPhotoIds });
message.success(`Published ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to publish photos');
}
};
const handleBulkPhotoDelete = async () => {
try {
await mediaApi.post('/photos/bulk-delete', { ids: selectedPhotoIds });
message.success(`Deleted ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete photos');
}
};
// Tab change clears selections
const handleTabChange = (tab: string | number) => {
setMediaTab(tab as MediaTab);
setSelectedVideoIds([]);
setSelectedPhotoIds([]);
setSearch('');
};
const colSpan = viewMode === 'grid' ? { xs: 12, sm: 8, md: 6, lg: 4 } : { xs: 24, sm: 12, md: 8 };
// Current pagination for active tab
const activePagination = mediaTab === 'Videos' ? videoPagination : mediaTab === 'Photos' ? photoPagination : albumPagination;
return (
<div>
{/* Media Type Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Segmented
value={mediaTab}
onChange={handleTabChange}
options={[
{ value: 'Videos', icon: <VideoCameraOutlined />, label: 'Videos' },
{ value: 'Photos', icon: <PictureOutlined />, label: 'Photos' },
{ value: 'Albums', icon: <FolderOutlined />, label: 'Albums' },
]}
/>
</div>
{/* Toolbar */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<Input
placeholder="Search videos..."
placeholder={`Search ${mediaTab.toLowerCase()}...`}
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ width: 200 }}
/>
<Select
placeholder="Orientation"
options={[
{ value: 'H', label: 'Horizontal' },
{ value: 'V', label: 'Vertical' },
]}
value={orientation}
onChange={setOrientation}
allowClear
style={{ width: 120 }}
/>
<Select
mode="multiple"
placeholder="Producer"
options={producers.map((p) => ({ value: p, label: p }))}
value={selectedProducers}
onChange={setSelectedProducers}
style={{ width: 140 }}
maxTagCount={1}
/>
<Select
placeholder="Shorts"
options={[
{ value: 'true', label: 'Shorts' },
{ value: 'false', label: 'Non-Shorts' },
]}
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
allowClear
style={{ width: 110 }}
/>
{/* Orientation filter (Videos + Photos) */}
{mediaTab !== 'Albums' && (
<Select
placeholder="Orientation"
options={[
{ value: 'H', label: 'Horizontal' },
{ value: 'V', label: 'Vertical' },
...(mediaTab === 'Photos' ? [{ value: 'S', label: 'Square' }] : []),
]}
value={orientation}
onChange={setOrientation}
allowClear
style={{ width: 120 }}
/>
)}
{/* Producer filter (Videos + Photos) */}
{mediaTab !== 'Albums' && (
<Select
mode="multiple"
placeholder="Producer"
options={producers.map((p) => ({ value: p, label: p }))}
value={selectedProducers}
onChange={setSelectedProducers}
style={{ width: 140 }}
maxTagCount={1}
/>
)}
{/* Video-specific: Shorts filter */}
{mediaTab === 'Videos' && (
<Select
placeholder="Shorts"
options={[
{ value: 'true', label: 'Shorts' },
{ value: 'false', label: 'Non-Shorts' },
]}
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
allowClear
style={{ width: 110 }}
/>
)}
{/* Photo-specific: Format filter */}
{mediaTab === 'Photos' && photoFormats.length > 0 && (
<Select
placeholder="Format"
options={photoFormats.map((f) => ({ value: f, label: f.toUpperCase() }))}
value={formatFilter}
onChange={setFormatFilter}
allowClear
style={{ width: 100 }}
/>
)}
<div style={{ flex: 1 }} />
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setUploadModalOpen(true)}
>
Upload
</Button>
<Tooltip title="Fetch from URL">
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
</Tooltip>
<Tooltip title="Scan Shorts">
<Button icon={<ThunderboltOutlined />} onClick={handleScanShorts} loading={scanningShorts} />
</Tooltip>
<Tooltip title="Schedule Calendar">
<Button icon={<CalendarOutlined />} onClick={() => setCalendarModalOpen(true)} />
</Tooltip>
{/* Upload button */}
{mediaTab === 'Videos' && (
<>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadVideoOpen(true)}>Upload</Button>
<Tooltip title="Fetch from URL">
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
</Tooltip>
<Tooltip title="Scan Shorts">
<Button icon={<ThunderboltOutlined />} onClick={handleScanShorts} loading={scanningShorts} />
</Tooltip>
<Tooltip title="Schedule Calendar">
<Button icon={<CalendarOutlined />} onClick={() => setCalendarModalOpen(true)} />
</Tooltip>
</>
)}
{mediaTab === 'Photos' && (
<>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadPhotoOpen(true)}>Upload</Button>
{selectedPhotoIds.length >= 2 && (
<Tooltip title="Create Album from Selected">
<Button icon={<FolderAddOutlined />} onClick={() => setCreateAlbumOpen(true)}>Album</Button>
</Tooltip>
)}
</>
)}
{mediaTab === 'Albums' && (
<Button type="primary" icon={<FolderAddOutlined />} onClick={() => setCreateAlbumOpen(true)}>
New Album
</Button>
)}
<Tooltip title={viewMode === 'grid' ? 'Switch to Compact' : 'Switch to Grid'}>
<Button
icon={viewMode === 'grid' ? <AppstoreOutlined /> : <BarsOutlined />}
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
/>
</Tooltip>
<Button size="small" type="text" onClick={handleSelectAll}>
{selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'}
</Button>
{/* Select All (Videos + Photos) */}
{mediaTab === 'Videos' && (
<Button size="small" type="text" onClick={handleVideoSelectAll}>
{selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'}
</Button>
)}
{mediaTab === 'Photos' && (
<Button size="small" type="text" onClick={handlePhotoSelectAll}>
{selectedPhotoIds.length === photos.length && photos.length > 0 ? 'Deselect' : 'Select All'}
</Button>
)}
</div>
{/* Stats */}
<div style={{ marginBottom: 8, color: '#999', fontSize: 13 }}>
{pagination.total} video{pagination.total !== 1 ? 's' : ''}
{selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
{activePagination.total} {mediaTab.toLowerCase().replace(/s$/, '')}{activePagination.total !== 1 ? 's' : ''}
{mediaTab === 'Videos' && selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
{mediaTab === 'Photos' && selectedPhotoIds.length > 0 && ` · ${selectedPhotoIds.length} selected`}
</div>
{/* Video Grid */}
{/* Content Grid */}
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : videos.length === 0 ? (
<Empty description="No videos found" />
) : (
<>
<Row gutter={[16, 16]}>
{videos.map((video) => (
<Col {...colSpan} key={video.id}>
<VideoCard
video={video}
selected={selectedVideoIds.includes(video.id)}
onSelect={handleSelect}
onClick={(v) => setViewerVideo(v)}
onEdit={handleEdit}
onPreview={(v) => setViewerVideo(v)}
onAnalytics={handleAnalytics}
onSchedule={handleSchedule}
onDelete={handleDeleteSingle}
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
onRefresh={fetchVideos}
onTogglePublish={handleTogglePublish}
/>
</Col>
))}
</Row>
{/* === Videos Tab === */}
{mediaTab === 'Videos' && (
videos.length === 0 ? (
<Empty description="No videos found" />
) : (
<Row gutter={[16, 16]}>
{videos.map((video) => (
<Col {...colSpan} key={video.id}>
<VideoCard
video={video}
selected={selectedVideoIds.includes(video.id)}
onSelect={handleVideoSelect}
onClick={(v) => setViewerVideo(v)}
onEdit={(v) => setEditVideo(v)}
onPreview={(v) => setViewerVideo(v)}
onAnalytics={(v) => { setAnalyticsVideoId(v.id); setAnalyticsVideoTitle(v.title || v.filename); }}
onSchedule={(v) => setScheduleVideo(v)}
onDelete={handleDeleteSingleVideo}
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
onRefresh={fetchVideos}
onTogglePublish={handleToggleVideoPublish}
/>
</Col>
))}
</Row>
)
)}
<Pagination
current={pagination.page}
pageSize={pagination.limit}
total={pagination.total}
onChange={(page) => setPagination((prev) => ({ ...prev, page }))}
style={{ marginTop: 24, textAlign: 'center' }}
showSizeChanger
onShowSizeChange={(_, size) =>
setPagination((prev) => ({ ...prev, limit: size, page: 1 }))
}
pageSizeOptions={[24, 48, 96, 144]}
/>
{/* === Photos Tab === */}
{mediaTab === 'Photos' && (
photos.length === 0 ? (
<Empty description="No photos found" />
) : (
<Row gutter={[16, 16]}>
{photos.map((photo) => (
<Col {...colSpan} key={photo.id}>
<PhotoCard
photo={photo}
selected={selectedPhotoIds.includes(photo.id)}
onSelect={handlePhotoSelect}
onClick={(p) => setViewerPhoto(p)}
onEdit={(p) => setEditPhoto(p)}
onPreview={(p) => setViewerPhoto(p)}
onDelete={handleDeleteSinglePhoto}
onTogglePublish={handleTogglePhotoPublish}
/>
</Col>
))}
</Row>
)
)}
{/* === Albums Tab === */}
{mediaTab === 'Albums' && (
albums.length === 0 ? (
<Empty description="No albums found" />
) : (
<Row gutter={[16, 16]}>
{albums.map((album) => (
<Col {...colSpan} key={album.id}>
<AlbumCard
album={album}
onClick={(a) => setSelectedAlbumId(a.id)}
/>
</Col>
))}
</Row>
)
)}
{/* Pagination */}
{activePagination.total > activePagination.limit && (
<Pagination
current={activePagination.page}
pageSize={activePagination.limit}
total={activePagination.total}
onChange={(page) => {
if (mediaTab === 'Videos') setVideoPagination((prev) => ({ ...prev, page }));
else if (mediaTab === 'Photos') setPhotoPagination((prev) => ({ ...prev, page }));
else setAlbumPagination((prev) => ({ ...prev, page }));
}}
style={{ marginTop: 24, textAlign: 'center' }}
showSizeChanger
onShowSizeChange={(_, size) => {
if (mediaTab === 'Videos') setVideoPagination((prev) => ({ ...prev, limit: size, page: 1 }));
else if (mediaTab === 'Photos') setPhotoPagination((prev) => ({ ...prev, limit: size, page: 1 }));
else setAlbumPagination((prev) => ({ ...prev, limit: size, page: 1 }));
}}
pageSizeOptions={[24, 48, 96, 144]}
/>
)}
</>
)}
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedVideoIds.length}
actions={[
{
key: 'publish',
label: 'Publish',
icon: <GlobalOutlined />,
onClick: () => setPublishModalOpen(true),
type: 'primary',
},
{
key: 'access-level',
label: 'Access Level',
icon: <LockOutlined />,
onClick: () => setBulkAccessLevelOpen(true),
},
{
key: 'add-to-playlist',
label: 'Add to Playlist',
icon: <OrderedListOutlined />,
onClick: () => setBulkPlaylistOpen(true),
},
{
key: 'delete',
label: 'Delete',
icon: <DeleteOutlined />,
onClick: () => setDeleteModalOpen(true),
danger: true,
},
]}
/>
{/* === Video Bulk Actions === */}
{mediaTab === 'Videos' && (
<BulkActionsBar
selectedCount={selectedVideoIds.length}
actions={[
{ key: 'publish', label: 'Publish', icon: <GlobalOutlined />, onClick: () => setPublishModalOpen(true), type: 'primary' },
{ key: 'access-level', label: 'Access Level', icon: <LockOutlined />, onClick: () => setBulkAccessLevelOpen(true) },
{ key: 'add-to-playlist', label: 'Add to Playlist', icon: <OrderedListOutlined />, onClick: () => setBulkPlaylistOpen(true) },
{ key: 'delete', label: 'Delete', icon: <DeleteOutlined />, onClick: () => setDeleteModalOpen(true), danger: true },
]}
/>
)}
{/* Modals */}
{/* === Photo Bulk Actions === */}
{mediaTab === 'Photos' && selectedPhotoIds.length > 0 && (
<BulkActionsBar
selectedCount={selectedPhotoIds.length}
actions={[
{ key: 'publish', label: 'Publish', icon: <GlobalOutlined />, onClick: handleBulkPhotoPublish, type: 'primary' },
{ key: 'create-album', label: 'Create Album', icon: <FolderAddOutlined />, onClick: () => setCreateAlbumOpen(true) },
{ key: 'delete', label: 'Delete', icon: <DeleteOutlined />, onClick: handleBulkPhotoDelete, danger: true },
]}
/>
)}
{/* === Video Modals === */}
<UploadVideoDrawer
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onSuccess={handleUploadSuccess}
open={uploadVideoOpen}
onClose={() => setUploadVideoOpen(false)}
onSuccess={() => { setUploadVideoOpen(false); fetchVideos(); fetchProducers(); }}
/>
<PublishModal
open={publishModalOpen}
videoIds={selectedVideoIds}
onSuccess={handlePublishSuccess}
onSuccess={() => { setPublishModalOpen(false); setSelectedVideoIds([]); fetchVideos(); }}
onCancel={() => setPublishModalOpen(false)}
/>
<DeleteConfirmModal
open={deleteModalOpen}
count={selectedVideoIds.length}
onConfirm={handleDelete}
onConfirm={handleVideoDelete}
onCancel={() => setDeleteModalOpen(false)}
loading={deleting}
/>
@ -424,7 +658,7 @@ export default function LibraryPage() {
video={scheduleVideo}
open={!!scheduleVideo}
onClose={() => setScheduleVideo(null)}
onSuccess={handleScheduleSuccess}
onSuccess={() => { setScheduleVideo(null); fetchVideos(); }}
/>
<ScheduleCalendarDrawer
@ -437,7 +671,7 @@ export default function LibraryPage() {
video={editVideo}
open={!!editVideo}
onClose={() => setEditVideo(null)}
onSuccess={handleEditSuccess}
onSuccess={() => { setEditVideo(null); fetchVideos(); fetchProducers(); }}
/>
<FetchVideosDrawer
@ -458,21 +692,53 @@ export default function LibraryPage() {
open={bulkAccessLevelOpen}
videoIds={selectedVideoIds}
onClose={() => setBulkAccessLevelOpen(false)}
onSuccess={() => {
setBulkAccessLevelOpen(false);
setSelectedVideoIds([]);
fetchVideos();
}}
onSuccess={() => { setBulkAccessLevelOpen(false); setSelectedVideoIds([]); fetchVideos(); }}
/>
<BulkAddToPlaylistModal
videoIds={selectedVideoIds}
open={bulkPlaylistOpen}
onClose={() => setBulkPlaylistOpen(false)}
onSuccess={() => { setBulkPlaylistOpen(false); setSelectedVideoIds([]); }}
/>
{/* === Photo Modals === */}
<UploadPhotoDrawer
open={uploadPhotoOpen}
onClose={() => setUploadPhotoOpen(false)}
onSuccess={() => { setUploadPhotoOpen(false); fetchPhotos(); fetchPhotoFormats(); }}
/>
<PhotoViewerModal
photo={viewerPhoto}
open={!!viewerPhoto}
onClose={() => setViewerPhoto(null)}
/>
<EditPhotoModal
photo={editPhoto}
open={!!editPhoto}
onClose={() => setEditPhoto(null)}
onSuccess={() => { setEditPhoto(null); fetchPhotos(); }}
/>
<CreateAlbumModal
open={createAlbumOpen}
onClose={() => setCreateAlbumOpen(false)}
onSuccess={() => {
setBulkPlaylistOpen(false);
setSelectedVideoIds([]);
setCreateAlbumOpen(false);
setSelectedPhotoIds([]);
fetchPhotos();
fetchAlbums();
}}
selectedPhotoIds={selectedPhotoIds}
/>
<AlbumDetailDrawer
albumId={selectedAlbumId}
open={!!selectedAlbumId}
onClose={() => setSelectedAlbumId(null)}
onRefresh={fetchAlbums}
/>
</div>
);

View File

@ -26,6 +26,8 @@ import {
ArrowRightOutlined,
RocketOutlined,
ShareAltOutlined,
CalendarOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
@ -603,11 +605,25 @@ export default function CampaignPage() {
</Button>
</Link>
)}
{siteSettings?.enableMap !== false && (
<Link to="/shifts">
<Button icon={<CalendarOutlined />} style={{ borderColor: '#52c41a', color: '#52c41a' }}>
Volunteer for a Shift
</Button>
</Link>
)}
<Link to="/campaigns/create">
<Button type="primary" icon={<RocketOutlined />}>
Start Your Own Campaign
</Button>
</Link>
{siteSettings?.enableMediaFeatures !== false && (
<Link to="/gallery">
<Button icon={<PlayCircleOutlined />} style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}>
Watch Our Content
</Button>
</Link>
)}
<Button
icon={<ShareAltOutlined />}
onClick={handleCopyLink}

View File

@ -7,14 +7,19 @@ import {
} from 'antd';
import axios from 'axios';
import PublicVideoCard from '@/components/media/PublicVideoCard';
import PublicPhotoCard from '@/components/media/PublicPhotoCard';
import PublicAlbumCard from '@/components/media/PublicAlbumCard';
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
import ExpandedPhotoCard from '@/components/media/ExpandedPhotoCard';
import ExpandedAlbumCard from '@/components/media/ExpandedAlbumCard';
import GalleryAdCard from '@/components/media/GalleryAdCard';
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useParams, useSearchParams } from 'react-router-dom';
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mergeAdsIntoGrid, type GridItem } from '@/utils/galleryAdMerge';
import { mergeAdsIntoGrid, mergeAdsIntoMediaGrid, type GridItem } from '@/utils/galleryAdMerge';
import type { GalleryAd } from '@/types/gallery-ads';
import type { PublicPhoto, PublicAlbum } from '@/types/media';
const { useBreakpoint } = Grid;
@ -41,18 +46,31 @@ interface PaginationInfo {
hasMore: boolean;
}
// Unified gallery item from /public/gallery endpoint
interface GalleryApiItem {
type: 'video' | 'photo' | 'album';
data: any;
}
function MediaGalleryContent() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { category: urlCategory } = useParams<{ category?: string }>();
const [searchParams] = useSearchParams();
const { state: expandedState, expandVideo } = useExpandedVideo();
const { state: expandedState, expandVideo, expandMedia } = useExpandedVideo();
// Read search/sort from URL params (set by MediaBottomNav)
const search = searchParams.get('search') || '';
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
// Determine if we're in photos mode
const isPhotosMode = urlCategory === 'photos';
// Video-only state (existing behavior)
const [videos, setVideos] = useState<Video[]>([]);
// Unified gallery state (photos mode or all mode)
const [galleryItems, setGalleryItems] = useState<GalleryApiItem[]>([]);
const [ads, setAds] = useState<GalleryAd[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationInfo>({
@ -63,38 +81,49 @@ function MediaGalleryContent() {
});
const [currentPage, setCurrentPage] = useState(1);
// Fetch videos
const fetchVideos = async () => {
// Fetch content based on mode
const fetchContent = async () => {
try {
setLoading(true);
const offset = (currentPage - 1) * pagination.limit;
const params: any = {
limit: pagination.limit,
offset,
sort,
};
if (isPhotosMode) {
// Fetch from unified gallery endpoint with photo filter
const params: any = {
limit: pagination.limit,
offset,
sort,
mediaType: 'photo',
};
if (search) params.search = search;
if (urlCategory) {
params.category = urlCategory;
const response = await mediaPublicApi.get('/public/gallery', { params });
setGalleryItems(response.data.items || []);
setPagination(response.data.pagination);
setVideos([]);
} else {
// Existing video-only fetch
const params: any = {
limit: pagination.limit,
offset,
sort,
};
if (urlCategory) params.category = urlCategory;
if (search) params.search = search;
const response = await mediaPublicApi.get('/public', { params });
setVideos(response.data.videos);
setPagination(response.data.pagination);
setGalleryItems([]);
}
if (search) {
params.search = search;
}
const response = await mediaPublicApi.get('/public', { params });
setVideos(response.data.videos);
setPagination(response.data.pagination);
} catch (error) {
console.error('Failed to fetch videos:', error);
console.error('Failed to fetch content:', error);
} finally {
setLoading(false);
}
};
// Fetch ads (from Express API, non-critical)
// Fetch ads
const fetchAds = async () => {
try {
const { data } = await axios.get<GalleryAd[]>('/api/gallery-ads', {
@ -102,70 +131,123 @@ function MediaGalleryContent() {
});
setAds(data);
} catch {
// Silent fail — ads are supplementary
// Silent fail
}
};
// Fetch on filter changes (search/sort come from URL params via MediaBottomNav)
useEffect(() => {
setCurrentPage(1);
}, [search, sort]);
}, [search, sort, urlCategory]);
useEffect(() => {
fetchVideos();
fetchContent();
fetchAds();
}, [urlCategory, search, sort, currentPage]);
// Handle URL ?expanded=123 parameter on initial load only
// (e.g., shared link or page refresh — not triggered by collapse)
// Handle URL ?expanded=photo-123 or ?expanded=album-5 or ?expanded=123 on initial load
const hasRestoredRef = useRef(false);
useEffect(() => {
if (hasRestoredRef.current) return;
const expandedId = searchParams.get('expanded');
if (expandedId && videos.length > 0) {
const videoId = parseInt(expandedId, 10);
const video = videos.find(v => v.id === videoId);
if (video) {
hasRestoredRef.current = true;
expandVideo(videoId, video);
const expandedParam = searchParams.get('expanded');
if (!expandedParam) return;
if (isPhotosMode && galleryItems.length > 0) {
// Parse expanded param: "photo-123", "album-5"
const match = expandedParam.match(/^(photo|album)-(\d+)$/);
if (match) {
const mediaType = match[1]! as 'photo' | 'album';
const id = parseInt(match[2]!, 10);
const item = galleryItems.find(gi => gi.type === mediaType && gi.data.id === id);
if (item) {
hasRestoredRef.current = true;
expandMedia(id, mediaType, item.data);
}
}
} else if (!isPhotosMode && videos.length > 0) {
const videoId = parseInt(expandedParam, 10);
if (!isNaN(videoId)) {
const video = videos.find(v => v.id === videoId);
if (video) {
hasRestoredRef.current = true;
expandVideo(videoId, video);
}
}
}
}, [videos]); // Only re-check when videos load
}, [videos, galleryItems]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Merge ads into video grid
const gridItems = mergeAdsIntoGrid(videos, ads);
// Build grid items based on mode
const gridItems: GridItem[] = isPhotosMode
? mergeAdsIntoMediaGrid(
galleryItems.map(gi => ({
type: gi.type as 'video' | 'photo' | 'album',
data: gi.data,
})),
ads
)
: mergeAdsIntoGrid(videos, ads);
const totalItems = isPhotosMode ? galleryItems.length : videos.length;
const emptyLabel = isPhotosMode ? 'photos' : 'videos';
// Render a single grid item based on its type and expanded state
const renderGridItem = (item: GridItem) => {
if (item.type === 'ad') {
return <GalleryAdCard key={`ad-${item.data.id}`} ad={item.data} />;
}
if (item.type === 'photo') {
const photo = item.data as PublicPhoto;
// Check if this photo is expanded
if (expandedState.mediaType === 'photo' && expandedState.mediaData?.id === photo.id) {
return <ExpandedPhotoCard key={`expanded-photo-${photo.id}`} photo={photo} />;
}
return <PublicPhotoCard key={`photo-${photo.id}`} photo={photo} />;
}
if (item.type === 'album') {
const album = item.data as PublicAlbum;
if (expandedState.mediaType === 'album' && expandedState.mediaData?.id === album.id) {
return <ExpandedAlbumCard key={`expanded-album-${album.id}`} album={album} />;
}
return <PublicAlbumCard key={`album-${album.id}`} album={album} />;
}
// Default: video
const video = item.data as Video;
if (expandedState.videoId === video.id) {
return <ExpandedVideoCard key={`expanded-${video.id}`} video={video} />;
}
return <PublicVideoCard key={video.id} video={video} />;
};
return (
<div>
{/* Featured Playlists Carousel — only on main gallery page */}
{/* Featured Playlists Carousel — only on main gallery page (not photos tab) */}
{!urlCategory && !search && <FeaturedPlaylistCarousel />}
{/* Loading State */}
{loading && (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
)}
{/* Empty State */}
{!loading && videos.length === 0 && (
{!loading && totalItems === 0 && (
<Empty
description={
search
? `No videos found for "${search}"`
: 'No videos available'
? `No ${emptyLabel} found for "${search}"`
: `No ${emptyLabel} available`
}
style={{ padding: 60 }}
/>
)}
{/* Video Grid (with ads interleaved) */}
{!loading && videos.length > 0 && (
{!loading && totalItems > 0 && (
<>
<div
style={{
@ -177,18 +259,9 @@ function MediaGalleryContent() {
gap: 16,
}}
>
{gridItems.map((item: GridItem<Video>) =>
item.type === 'ad' ? (
<GalleryAdCard key={`ad-${item.data.id}`} ad={item.data} />
) : expandedState.videoId === item.data.id ? (
<ExpandedVideoCard key={`expanded-${item.data.id}`} video={item.data} />
) : (
<PublicVideoCard key={item.data.id} video={item.data} />
)
)}
{gridItems.map(renderGridItem)}
</div>
{/* Pagination — still based on video count only */}
{pagination.total > pagination.limit && (
<div style={{ marginTop: 32, textAlign: 'center' }}>
<Pagination
@ -197,7 +270,7 @@ function MediaGalleryContent() {
total={pagination.total}
onChange={handlePageChange}
showSizeChanger={false}
showTotal={(total) => `Total ${total} videos`}
showTotal={(total) => `Total ${total} ${emptyLabel}`}
/>
</div>
)}

View File

@ -18,6 +18,7 @@ import {
EyeOutlined,
ArrowLeftOutlined,
PlusOutlined,
SendOutlined,
} from '@ant-design/icons';
import VideoPlayer from '@/components/media/VideoPlayer';
import ReactionButtons from '@/components/media/ReactionButtons';
@ -26,6 +27,8 @@ import PublicVideoCard from '@/components/media/PublicVideoCard';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
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';
const { Title, Text } = Typography;
@ -59,6 +62,7 @@ export default function MediaViewerPage() {
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
const currentTime = 0; // TODO: Track video playback time
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { settings } = useSettingsStore();
const videoId = parseInt(id || '0', 10);
@ -314,6 +318,39 @@ export default function MediaViewerPage() {
</div>
)}
{/* Cross-site CTA */}
{settings?.enableInfluence !== false && (
<>
<Divider />
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'linear-gradient(135deg, rgba(0,90,156,0.15) 0%, rgba(82,196,26,0.1) 100%)',
borderRadius: 8,
border: '1px solid rgba(0,90,156,0.25)',
flexWrap: 'wrap',
gap: 12,
}}
>
<div>
<Text strong>Want to take action?</Text>
<br />
<Text type="secondary" style={{ fontSize: 13 }}>
Join an active campaign and make your voice heard.
</Text>
</div>
<Link to="/campaigns">
<Button type="primary" icon={<SendOutlined />}>
View Campaigns
</Button>
</Link>
</div>
</>
)}
<Divider />
{/* Comments section */}

View File

@ -0,0 +1,193 @@
import { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ConfigProvider, App, theme, Typography, Form, Input, Button, Card, Result, Grid,
} from 'antd';
import { MailOutlined, UserOutlined, PhoneOutlined, TeamOutlined, HomeOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
const { Title, Text } = Typography;
export default function QuickJoinPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setTokens, fetchMe } = useAuthStore();
const { settings } = useSettingsStore();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const token = searchParams.get('token');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (values: { email: string; name?: string; phone?: string }) => {
if (!token) return;
setLoading(true);
setError(null);
try {
const { data } = await axios.post('/api/volunteer-invite/redeem', {
token,
email: values.email,
name: values.name || undefined,
phone: values.phone || undefined,
});
// Store tokens and fetch user profile
setTokens(data.accessToken, data.refreshToken);
await fetchMe();
// Redirect to volunteer map with cut/shift context
const params = new URLSearchParams();
if (data.cutId) params.set('cutId', data.cutId);
if (data.shiftId) params.set('shiftId', data.shiftId);
const query = params.toString();
navigate(`/volunteer${query ? `?${query}` : ''}`, { replace: true });
} catch (err: unknown) {
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } } } })
?.response?.data?.error;
if (resp?.code === 'QUICK_JOIN_RATE_LIMIT_EXCEEDED') {
setError('Too many attempts. Please try again later.');
} else if (resp?.code === 'INVALID_INVITE_TOKEN' || resp?.code === 'INVALID_TOKEN_TYPE') {
setError('This invite link has expired or is invalid. Please ask for a new one.');
} else if (resp?.code === 'ACCOUNT_INACTIVE') {
setError(resp.message || 'Your account is not active.');
} else {
setError(resp?.message || 'Something went wrong. Please try again.');
}
} finally {
setLoading(false);
}
};
// Missing or empty token
if (!token) {
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
borderRadius: 8,
},
}}
>
<App>
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Result
status="warning"
title="Invalid Invite Link"
subTitle="This link is missing the invite token. Please scan the QR code again or ask the organizer for a new link."
extra={
<Button
type="primary"
icon={<HomeOutlined />}
onClick={() => window.location.href = '/campaigns'}
>
Return to Site
</Button>
}
/>
</div>
</App>
</ConfigProvider>
);
}
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
borderRadius: 8,
},
}}
>
<App>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? 16 : 24,
background: settings?.publicColorBgBase ?? '#0d1b2a',
}}
>
<Card
style={{ maxWidth: 400, width: '100%' }}
styles={{ body: { padding: isMobile ? 20 : 24 } }}
>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<TeamOutlined style={{ fontSize: 40, color: settings?.publicColorPrimary ?? '#3498db', marginBottom: 12 }} />
<Title level={4} style={{ margin: 0 }}>
Join as Volunteer
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
{settings?.organizationName ?? 'Changemaker Lite'}
</Text>
</div>
{error && (
<div
style={{
background: 'rgba(255, 77, 79, 0.1)',
border: '1px solid rgba(255, 77, 79, 0.3)',
borderRadius: 6,
padding: '8px 12px',
marginBottom: 16,
fontSize: 13,
color: '#ff4d4f',
}}
>
{error}
</div>
)}
<Form onFinish={handleSubmit} layout="vertical" size="large" requiredMark={false}>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Your email" autoFocus />
</Form.Item>
<Form.Item name="name">
<Input prefix={<UserOutlined />} placeholder="Your name (optional)" />
</Form.Item>
<Form.Item name="phone">
<Input prefix={<PhoneOutlined />} placeholder="Phone (optional)" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block loading={loading}>
Join & Start Canvassing
</Button>
</Form.Item>
</Form>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 11 }}>
You'll get temporary 24-hour access to the canvassing app.
</Text>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button type="link" size="small" icon={<HomeOutlined />} onClick={() => window.location.href = '/campaigns'}>
Browse Site
</Button>
</div>
</Card>
</div>
</App>
</ConfigProvider>
);
}

View File

@ -547,6 +547,9 @@ export default function VolunteerMapPage() {
sessionStartedAt={session?.startedAt}
onEndSession={handleEndSession}
endingSession={endingSession}
activeCutId={activeCutId ?? undefined}
activeShiftId={session?.shiftId ?? undefined}
isAdmin={isAdmin}
/>
{/* Bottom sheet — visit recording */}

View File

@ -17,7 +17,7 @@ export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER'
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD' | 'SELF_REGISTRATION';
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD' | 'SELF_REGISTRATION' | 'QUICK_JOIN_INVITE';
export interface User {
id: string;
@ -2012,3 +2012,87 @@ export interface ActivityTrendsData {
dateTo: string;
series: Array<{ date: string; emails: number; responses: number }>;
}
// --- Dashboard Top Videos ---
export interface DashboardTopVideo {
id: number;
title: string | null;
filename: string;
viewCount: number;
commentCount: number;
upvoteCount: number;
durationSeconds: number | null;
isPublished: boolean;
}
export interface DashboardTopVideosResult {
enabled: boolean;
videos: DashboardTopVideo[];
}
// --- Dashboard Recent Comments ---
export interface DashboardRecentComment {
id: number;
content: string;
videoId: number;
videoTitle: string | null;
videoFilename: string;
authorName: string | null;
safetyStatus: string | null;
createdAt: string;
}
export interface DashboardRecentCommentsResult {
enabled: boolean;
comments: DashboardRecentComment[];
pendingCount: number;
}
// --- Dashboard Docs Analytics ---
export interface DashboardDocsAnalytics {
totalViews: number;
uniqueSessions: number;
topPages: Array<{ path: string; views: number }>;
viewsByDay: Array<{ date: string; views: number }>;
topReferrers?: Array<{ referrer: string; views: number }>;
}
// --- Dashboard Upcoming Shifts ---
export interface DashboardUpcomingShift {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: string;
cutName: string | null;
}
export interface DashboardUpcomingShiftsResult {
shifts: DashboardUpcomingShift[];
total: number;
}
// --- Dashboard Recent Signups ---
export interface DashboardRecentSignup {
id: string;
userName: string | null;
userEmail: string;
shiftTitle: string | null;
shiftDate: string | null;
signupDate: string;
signupSource: string;
}
export interface DashboardRecentSignupsResult {
signups: DashboardRecentSignup[];
total: number;
}

View File

@ -205,3 +205,15 @@ export interface VolunteerSummary {
sessions: number;
lastActive: string | null;
}
// --- Outcome Trends ---
export type CanvassOutcomeTrendPoint = { date: string } & Partial<Record<VisitOutcome, number>>;
export interface CanvassOutcomeTrendsData {
granularity: 'day' | 'week';
dateFrom: string;
dateTo: string;
series: CanvassOutcomeTrendPoint[];
totals: Partial<Record<VisitOutcome, number>>;
}

View File

@ -219,3 +219,138 @@ export interface UpdatePlaylistBody {
export interface ReorderPlaylistVideosBody {
items: Array<{ mediaId: number; position: number }>;
}
// ============================================================================
// PHOTO GALLERY
// ============================================================================
export interface Photo {
id: number;
path: string;
filename: string;
originalFilename: string | null;
title: string | null;
description: string | null;
producer: string | null;
creator: string | null;
tags: string[] | null;
width: number | null;
height: number | null;
orientation: 'H' | 'V' | 'S' | null;
fileSize: string | null; // BigInt serialized as string
format: string | null;
colorSpace: string | null;
hasAlpha: boolean | null;
dpi: number | null;
cameraMake: string | null;
cameraModel: string | null;
focalLength: string | null;
aperture: string | null;
shutterSpeed: string | null;
iso: number | null;
takenAt: string | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
thumbnailPath: string | null;
thumbnailUrl: string | null;
isPublished: boolean;
publishedAt: string | null;
category: string | null;
accessLevel: string;
position: number | null;
isLocked: boolean;
viewCount: number;
upvoteCount: number;
commentCount: number;
albumId: number | null;
albumPosition: number | null;
uploaderId: string | null;
createdAt: string;
album?: { id: number; title: string } | null;
uploader?: { id: string; name: string | null; email: string } | null;
}
export interface PhotoAlbum {
id: number;
title: string;
description: string | null;
coverPhotoId: number | null;
isPublished: boolean;
publishedAt: string | null;
category: string | null;
accessLevel: string;
position: number | null;
isLocked: boolean;
viewCount: number;
upvoteCount: number;
photoCount: number;
creatorId: string | null;
createdAt: string;
updatedAt: string;
coverPhoto?: { id: number; thumbnailPath: string | null } | null;
coverThumbnailUrl?: string | null;
creator?: { id: string; name: string | null; email: string } | null;
photos?: PhotoAlbumItem[];
_count?: { photos: number };
}
export interface PhotoAlbumItem {
id: number;
title: string | null;
originalFilename: string | null;
thumbnailPath: string | null;
thumbnailUrl: string | null;
width: number | null;
height: number | null;
orientation: string | null;
format: string | null;
fileSize: string | null;
albumPosition: number | null;
isPublished: boolean;
createdAt: string;
}
export interface PhotosListResponse {
photos: Photo[];
total: number;
limit: number;
offset: number;
}
export interface PublicPhoto {
id: number;
title: string | null;
width: number | null;
height: number | null;
orientation: string | null;
format: string | null;
producer: string | null;
category: string | null;
publishedAt: string | null;
viewCount: number;
upvoteCount: number;
commentCount: number;
thumbnailUrl: string;
imageUrl: string;
albumId: number | null;
}
export interface PublicAlbum {
id: number;
title: string;
description: string | null;
category: string | null;
photoCount: number;
viewCount: number;
upvoteCount: number;
publishedAt: string | null;
coverThumbnailUrl: string | null;
coverPhoto?: { id: number; width: number | null; height: number | null } | null;
}
export type GalleryItemType = 'video' | 'photo' | 'album';
export interface GalleryItem {
type: GalleryItemType;
data: any;
}

View File

@ -1,7 +1,9 @@
import type { GalleryAd } from '@/types/gallery-ads';
export type GridItem<T> =
export type GridItem<T = any> =
| { type: 'video'; data: T }
| { type: 'photo'; data: any }
| { type: 'album'; data: any }
| { type: 'ad'; data: GalleryAd };
/**
@ -68,3 +70,50 @@ export function mergeAdsIntoGrid<T extends { id: number }>(
return result;
}
/**
* Merge ads into a unified media grid (videos + photos + albums).
*/
export function mergeAdsIntoMediaGrid(
items: Array<{ type: 'video' | 'photo' | 'album'; data: any }>,
ads: GalleryAd[]
): GridItem[] {
if (ads.length === 0) {
return items;
}
const sortedAds = [...ads].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
const slotAssignments = new Map<number, GalleryAd>();
const usedSlots = new Set<number>();
for (const ad of sortedAds) {
const freq = ad.frequency;
for (let i = freq - 1; i < items.length; i += freq) {
let targetSlot = i;
while (usedSlots.has(targetSlot) && targetSlot < items.length + ads.length) {
targetSlot++;
}
if (!usedSlots.has(targetSlot)) {
slotAssignments.set(targetSlot, ad);
usedSlots.add(targetSlot);
break;
}
}
}
const result: GridItem[] = [];
let itemIdx = 0;
for (let pos = 0; itemIdx < items.length || slotAssignments.has(pos); pos++) {
const adForSlot = slotAssignments.get(pos);
if (adForSlot) {
result.push({ type: 'ad', data: adForSlot });
}
if (itemIdx < items.length) {
result.push(items[itemIdx]!);
itemIdx++;
}
}
return result;
}

View File

@ -4,12 +4,14 @@
FROM node:20-alpine AS base
WORKDIR /app
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install dependencies
# Two-step: skip scripts to avoid sharp source build, then install prebuilt musl binary
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm ci --omit=dev --ignore-scripts && \
npm install --no-save @img/sharp-linuxmusl-x64
# Copy Prisma schema and generate client (needed for auth middleware)
COPY prisma ./prisma
@ -35,8 +37,8 @@ RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Copy built files and node_modules
COPY --from=build /app/dist ./dist

509
api/package-lock.json generated
View File

@ -21,6 +21,7 @@
"csv-stringify": "^6.6.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.1",
"exif-reader": "^2.0.3",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"fastify": "^5.7.4",
@ -29,12 +30,14 @@
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"node-addon-api": "^8.5.0",
"nodemailer": "^6.9.16",
"pg": "^8.18.0",
"proj4": "^2.20.2",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",
@ -81,6 +84,15 @@
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
"dev": true
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@ -1165,6 +1177,446 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
@ -2134,7 +2586,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"optional": true,
"engines": {
"node": ">=8"
}
@ -2930,6 +3381,11 @@
"node": ">= 0.6"
}
},
"node_modules/exif-reader": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/exif-reader/-/exif-reader-2.0.3.tgz",
"integrity": "sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A=="
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@ -3866,6 +4322,14 @@
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@ -4636,6 +5100,49 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",

View File

@ -29,6 +29,7 @@
"csv-stringify": "^6.6.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.1",
"exif-reader": "^2.0.3",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"fastify": "^5.7.4",
@ -37,12 +38,14 @@
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"node-addon-api": "^8.5.0",
"nodemailer": "^6.9.16",
"pg": "^8.18.0",
"proj4": "^2.20.2",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "UserCreatedVia" ADD VALUE IF NOT EXISTS 'QUICK_JOIN_INVITE';

View File

@ -33,6 +33,7 @@ enum UserCreatedVia {
PUBLIC_SHIFT_SIGNUP
STANDARD
SELF_REGISTRATION
QUICK_JOIN_INVITE
}
model User {
@ -134,6 +135,11 @@ model User {
notifications Notification[] @relation("UserNotifications")
notificationPreferences NotificationPreferences? @relation("NotificationPreferences")
// Photo gallery relations
photosUploaded Photo[] @relation("PhotoUploader")
albumsCreated PhotoAlbum[] @relation("AlbumCreator")
photoComments PhotoComment[] @relation("PhotoCommentUser")
@@map("users")
}
@ -1616,6 +1622,11 @@ model Session {
adClicks AdClick[]
userFinishes UserFinish[]
// Photo gallery relations
photoUpvotes PhotoUpvote[] @relation("SessionPhotoUpvotes")
photoComments PhotoComment[] @relation("SessionPhotoComments")
photoReactions PhotoReaction[] @relation("SessionPhotoReactions")
@@index([userId], map: "idx_sessions_user_id")
@@index([country], map: "idx_sessions_country")
@@map("sessions")
@ -3473,3 +3484,188 @@ model DocsPageView {
@@index([path, createdAt])
@@map("docs_page_views")
}
// ============================================================================
// PHOTO GALLERY
// ============================================================================
model Photo {
id Int @id @default(autoincrement())
path String @unique // Full path to original file
filename String // UUID filename on disk
originalFilename String? @map("original_filename") // Original upload filename
title String?
description String? @db.Text
producer String?
creator String?
tags Json? // String array
// Image metadata (from sharp)
width Int?
height Int?
orientation String? // H / V / S (horizontal/vertical/square)
fileSize BigInt? @map("file_size")
format String? // jpeg, png, webp, avif, gif, tiff, heic
colorSpace String? @map("color_space") // srgb, display-p3, etc.
hasAlpha Boolean? @default(false) @map("has_alpha")
dpi Int?
// EXIF data
cameraMake String? @map("camera_make")
cameraModel String? @map("camera_model")
focalLength String? @map("focal_length")
aperture String?
shutterSpeed String? @map("shutter_speed")
iso Int?
takenAt DateTime? @map("taken_at")
gpsLatitude Float? @map("gps_latitude") @db.Real
gpsLongitude Float? @map("gps_longitude") @db.Real
// Processed variants
thumbnailPath String? @map("thumbnail_path")
mediumPath String? @map("medium_path")
largePath String? @map("large_path")
webpPath String? @map("webp_path")
// Publishing (mirrors Video)
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String?
accessLevel String @default("free") @map("access_level")
position Int? @default(0)
isLocked Boolean @default(false) @map("is_locked")
scheduledPublishAt DateTime? @map("scheduled_publish_at")
scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at")
// Engagement counters
viewCount Int @default(0) @map("view_count")
upvoteCount Int @default(0) @map("upvote_count")
commentCount Int @default(0) @map("comment_count")
// Album membership
albumId Int? @map("album_id")
albumPosition Int? @default(0) @map("album_position")
// Tracking
uploaderId String? @map("uploader_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
album PhotoAlbum? @relation("AlbumPhotos", fields: [albumId], references: [id], onDelete: SetNull)
uploader User? @relation("PhotoUploader", fields: [uploaderId], references: [id])
upvotes PhotoUpvote[]
comments PhotoComment[]
views PhotoView[]
reactions PhotoReaction[]
coverForAlbum PhotoAlbum? @relation("AlbumCover")
@@index([orientation], map: "idx_photos_orientation")
@@index([producer], map: "idx_photos_producer")
@@index([isPublished, isLocked], map: "idx_photos_published_locked")
@@index([category, isPublished], map: "idx_photos_category_published")
@@index([albumId, albumPosition], map: "idx_photos_album_position")
@@index([createdAt], map: "idx_photos_created_at")
@@index([uploaderId], map: "idx_photos_uploader")
@@map("photos")
}
model PhotoAlbum {
id Int @id @default(autoincrement())
title String
description String? @db.Text
coverPhotoId Int? @unique @map("cover_photo_id")
// Publishing
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String?
accessLevel String @default("free") @map("access_level")
position Int? @default(0)
isLocked Boolean @default(false) @map("is_locked")
// Aggregate counters
viewCount Int @default(0) @map("view_count")
upvoteCount Int @default(0) @map("upvote_count")
photoCount Int @default(0) @map("photo_count")
// Scheduling
scheduledPublishAt DateTime? @map("scheduled_publish_at")
scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at")
// Tracking
creatorId String? @map("creator_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
photos Photo[] @relation("AlbumPhotos")
coverPhoto Photo? @relation("AlbumCover", fields: [coverPhotoId], references: [id], onDelete: SetNull)
creator User? @relation("AlbumCreator", fields: [creatorId], references: [id])
@@index([isPublished], map: "idx_photo_albums_published")
@@index([creatorId], map: "idx_photo_albums_creator")
@@map("photo_albums")
}
model PhotoUpvote {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoUpvotes", fields: [sessionId], references: [id])
@@unique([photoId, sessionId], map: "idx_photo_upvotes_unique")
@@index([photoId], map: "idx_photo_upvotes_photo")
@@map("photo_upvotes")
}
model PhotoComment {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
userId String? @map("user_id")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
safetyStatus String @default("approved") @map("safety_status")
isHidden Boolean @default(false) @map("is_hidden")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoComments", fields: [sessionId], references: [id])
user User? @relation("PhotoCommentUser", fields: [userId], references: [id])
@@index([photoId, createdAt], map: "idx_photo_comments_photo_date")
@@index([sessionId], map: "idx_photo_comments_session")
@@map("photo_comments")
}
model PhotoView {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String? @map("session_id")
userId String? @map("user_id")
ipAddressHash String? @map("ip_address_hash")
viewedAt DateTime @default(now()) @map("viewed_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
@@index([photoId, viewedAt], map: "idx_photo_views_photo_date")
@@index([sessionId], map: "idx_photo_views_session")
@@map("photo_views")
}
model PhotoReaction {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
reactionType String @map("reaction_type") // like, love, laugh, wow, sad, angry
createdAt DateTime @default(now()) @map("created_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoReactions", fields: [sessionId], references: [id])
@@unique([photoId, sessionId, reactionType], map: "idx_photo_reactions_unique")
@@index([photoId], map: "idx_photo_reactions_photo")
@@map("photo_reactions")
}

View File

@ -25,6 +25,11 @@ import { fetchRoutes } from './modules/media/routes/fetch.routes';
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
import { photosRoutes } from './modules/media/routes/photos.routes';
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';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@ -141,6 +146,13 @@ const start = async () => {
await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' });
// Photo gallery routes
await fastify.register(photosRoutes, { prefix: '/api/photos' });
await fastify.register(photoUploadRoutes, { prefix: '/api/photos' });
await fastify.register(photoAlbumsRoutes, { prefix: '/api/albums' });
await fastify.register(photosPublicRoutes, { prefix: '/api' });
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
const port = env.MEDIA_API_PORT;
const host = '0.0.0.0';

View File

@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({
},
});
export const quickJoinRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:quick-join:',
}),
message: {
error: {
message: 'Too many join attempts, please try again later',
code: 'QUICK_JOIN_RATE_LIMIT_EXCEEDED',
},
},
});
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks

View File

@ -18,7 +18,7 @@ interface TokenPayload {
roles: UserRole[];
}
interface TokenPair {
export interface TokenPair {
accessToken: string;
refreshToken: string;
}

View File

@ -13,6 +13,10 @@ import {
getConnectivity,
getTodayEvents,
getChatSummary,
getTopVideos,
getRecentComments,
getUpcomingShifts,
getRecentSignups,
} from './dashboard.service';
const router = Router();
@ -168,4 +172,44 @@ router.get('/chat-summary', async (_req: Request, res: Response, next: NextFunct
}
});
// GET /api/dashboard/upcoming-shifts — next 5 shifts
router.get('/upcoming-shifts', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getUpcomingShifts();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/recent-signups — latest 8 shift signups
router.get('/recent-signups', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getRecentSignups();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/top-videos — top 5 videos by view count (if media enabled)
router.get('/top-videos', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getTopVideos();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/recent-comments — latest 8 visible comments (if media enabled)
router.get('/recent-comments', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getRecentComments();
res.json(result);
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@ -10,6 +10,7 @@ import { isServiceOnline } from '../../utils/health-check';
import { listmonkClient } from '../../services/listmonk.client';
import { gancioClient } from '../../services/gancio.client';
import { rocketchatClient } from '../../services/rocketchat.client';
import { emailService } from '../../services/email.service';
import { logger } from '../../utils/logger';
// --- Types ---
@ -418,7 +419,7 @@ export interface ConnectivityStatus {
export async function getConnectivity(): Promise<ConnectivityStatus> {
const [smtp, listmonk, rocketchat, gancio] = await Promise.all([
isServiceOnline(`${env.SMTP_HOST}`, 3000).catch(() => false),
emailService.testConnection().catch(() => false),
listmonkClient.checkHealth().catch(() => false),
isServiceOnline(env.ROCKETCHAT_URL || '', 3000).catch(() => false),
gancioClient.isAvailable().catch(() => false),
@ -860,6 +861,243 @@ export async function getTodayEvents(): Promise<TodayEventsResult> {
}
}
// --- Upcoming Shifts ---
export interface UpcomingShiftItem {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: string;
cutName: string | null;
}
export interface UpcomingShiftsResult {
shifts: UpcomingShiftItem[];
total: number;
}
export async function getUpcomingShifts(): Promise<UpcomingShiftsResult> {
try {
const now = new Date();
const [shifts, total] = await Promise.all([
prisma.shift.findMany({
where: { date: { gte: now }, status: { not: 'CANCELLED' } },
orderBy: { date: 'asc' },
take: 5,
select: {
id: true,
title: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
cut: { select: { name: true } },
},
}),
prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }),
]);
return {
shifts: shifts.map(s => ({
id: s.id,
title: s.title,
date: s.date.toISOString(),
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
maxVolunteers: s.maxVolunteers,
currentVolunteers: s.currentVolunteers,
status: s.status,
cutName: s.cut?.name || null,
})),
total,
};
} catch (err) {
logger.debug('Failed to fetch upcoming shifts', err);
return { shifts: [], total: 0 };
}
}
// --- Recent Shift Signups ---
export interface RecentSignupItem {
id: string;
userName: string | null;
userEmail: string;
shiftTitle: string | null;
shiftDate: string | null;
signupDate: string;
signupSource: string;
}
export interface RecentSignupsResult {
signups: RecentSignupItem[];
total: number;
}
export async function getRecentSignups(): Promise<RecentSignupsResult> {
try {
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); // last 14 days
const [signups, total] = await Promise.all([
prisma.shiftSignup.findMany({
where: { signupDate: { gte: since }, status: 'CONFIRMED' },
orderBy: { signupDate: 'desc' },
take: 8,
select: {
id: true,
userName: true,
userEmail: true,
shiftTitle: true,
signupDate: true,
signupSource: true,
shift: { select: { date: true } },
},
}),
prisma.shiftSignup.count({ where: { signupDate: { gte: since }, status: 'CONFIRMED' } }),
]);
return {
signups: signups.map(s => ({
id: s.id,
userName: s.userName,
userEmail: s.userEmail,
shiftTitle: s.shiftTitle,
shiftDate: s.shift.date.toISOString(),
signupDate: s.signupDate.toISOString(),
signupSource: s.signupSource,
})),
total,
};
} catch (err) {
logger.debug('Failed to fetch recent signups', err);
return { signups: [], total: 0 };
}
}
// --- Top Videos (Media) ---
export interface TopVideoItem {
id: number;
title: string | null;
filename: string;
viewCount: number;
commentCount: number;
upvoteCount: number;
durationSeconds: number | null;
isPublished: boolean;
}
export interface TopVideosResult {
enabled: boolean;
videos: TopVideoItem[];
}
export async function getTopVideos(): Promise<TopVideosResult> {
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
return { enabled: false, videos: [] };
}
try {
const videos = await prisma.video.findMany({
select: {
id: true,
title: true,
filename: true,
viewCount: true,
commentCount: true,
upvoteCount: true,
durationSeconds: true,
isPublished: true,
},
orderBy: { viewCount: 'desc' },
take: 5,
});
return {
enabled: true,
videos: videos.map(v => ({
id: v.id,
title: v.title,
filename: v.filename,
viewCount: v.viewCount,
commentCount: v.commentCount,
upvoteCount: v.upvoteCount,
durationSeconds: v.durationSeconds,
isPublished: v.isPublished,
})),
};
} catch (err) {
logger.debug('Failed to fetch top videos', err);
return { enabled: true, videos: [] };
}
}
// --- Recent Comments (Media) ---
export interface RecentCommentItem {
id: number;
content: string;
videoId: number;
videoTitle: string | null;
videoFilename: string;
authorName: string | null;
safetyStatus: string | null;
createdAt: string;
}
export interface RecentCommentsResult {
enabled: boolean;
comments: RecentCommentItem[];
pendingCount: number;
}
export async function getRecentComments(): Promise<RecentCommentsResult> {
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
return { enabled: false, comments: [], pendingCount: 0 };
}
try {
const [comments, pendingCount] = await Promise.all([
prisma.comment.findMany({
where: { isHidden: { not: true } },
include: {
user: { select: { name: true, email: true } },
media: { select: { id: true, title: true, filename: true } },
},
orderBy: { createdAt: 'desc' },
take: 8,
}),
prisma.comment.count({ where: { safetyStatus: 'pending' } }),
]);
return {
enabled: true,
comments: comments.map(c => ({
id: c.id,
content: c.content.slice(0, 200),
videoId: c.media.id,
videoTitle: c.media.title,
videoFilename: c.media.filename,
authorName: c.user?.name || c.user?.email || null,
safetyStatus: c.safetyStatus,
createdAt: c.createdAt.toISOString(),
})),
pendingCount,
};
} catch (err) {
logger.debug('Failed to fetch recent comments', err);
return { enabled: true, comments: [], pendingCount: 0 };
}
}
// --- Chat Summary from Rocket.Chat ---
export interface ChatMessage {

View File

@ -11,6 +11,7 @@ import {
adminVisitsSchema,
volunteerUpdateLocationSchema,
volunteerCreateLocationSchema,
outcomeTrendsQuerySchema,
} from './canvass.schemas';
import { reverseGeocodeSchema, geocodeAddressSchema } from '../locations/locations.schemas';
import { locationsService } from '../locations/locations.service';
@ -365,4 +366,18 @@ adminRouter.get(
},
);
// GET /api/map/canvass/trends
adminRouter.get(
'/trends',
validate(outcomeTrendsQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await canvassService.getOutcomeTrends(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { volunteerRouter as canvassVolunteerRouter, adminRouter as canvassAdminRouter };

View File

@ -101,5 +101,12 @@ export type WalkingRouteInput = z.infer<typeof walkingRouteSchema>;
export type ListMyVisitsInput = z.infer<typeof listMyVisitsSchema>;
export type AdminActivityInput = z.infer<typeof adminActivitySchema>;
export type AdminVisitsInput = z.infer<typeof adminVisitsSchema>;
export const outcomeTrendsQuerySchema = z.object({
granularity: z.enum(['day', 'week']).default('day'),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
});
export type VolunteerUpdateLocationInput = z.infer<typeof volunteerUpdateLocationSchema>;
export type VolunteerCreateLocationInput = z.infer<typeof volunteerCreateLocationSchema>;
export type OutcomeTrendsQueryInput = z.infer<typeof outcomeTrendsQuerySchema>;

View File

@ -21,6 +21,7 @@ import type {
AdminActivityInput,
AdminVisitsInput,
VolunteerUpdateLocationInput,
OutcomeTrendsQueryInput,
} from './canvass.schemas';
const ADDRESS_SELECT = {
@ -995,6 +996,56 @@ export const canvassService = {
};
},
async getOutcomeTrends(filters: OutcomeTrendsQueryInput) {
const { granularity } = filters;
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date();
const dateFrom = filters.dateFrom
? new Date(filters.dateFrom)
: new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000);
// Ensure dateTo covers end of day
const dateToEnd = new Date(dateTo);
dateToEnd.setHours(23, 59, 59, 999);
const rows = await prisma.$queryRaw<
{ period: Date; outcome: string; count: number }[]
>`
SELECT DATE_TRUNC(${granularity}, "visitedAt") as period,
outcome::text as outcome,
COUNT(*)::int as count
FROM canvass_visits
WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd}
GROUP BY period, outcome
ORDER BY period ASC
`;
// Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }]
const pivotMap = new Map<string, Record<string, number>>();
const totals: Record<string, number> = {};
for (const row of rows) {
const dateStr = row.period.toISOString().split('T')[0];
if (!pivotMap.has(dateStr)) {
pivotMap.set(dateStr, {});
}
pivotMap.get(dateStr)![row.outcome] = row.count;
totals[row.outcome] = (totals[row.outcome] || 0) + row.count;
}
const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({
date,
...outcomes,
}));
return {
granularity,
dateFrom: dateFrom.toISOString().split('T')[0],
dateTo: dateTo.toISOString().split('T')[0],
series,
totals,
};
},
// ─── Helpers ───────────────────────────────────────────────────────
async recalculateCutCompletion(cutId: string) {

View File

@ -33,16 +33,23 @@ export async function authenticate(
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
if (!authHeader?.startsWith('Bearer ')) {
// Support both Authorization header and ?token= query param (for <img>/<video> src)
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
}
if (!token) {
return reply.status(401).send({
error: 'Authentication required',
code: 'AUTH_REQUIRED'
});
}
const token = authHeader.substring(7);
// Verify JWT with V2 access secret
let payload: TokenPayload;
try {
@ -133,12 +140,18 @@ export async function optionalAuth(
_reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
if (!authHeader?.startsWith('Bearer ')) {
return;
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
}
const token = authHeader.substring(7);
if (!token) {
return;
}
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;

View File

@ -0,0 +1,364 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
/**
* Photo album CRUD routes (prefix: /api/albums)
*/
interface CreateAlbumBody {
title: string;
description?: string;
photoIds?: number[];
}
interface UpdateAlbumBody {
title?: string;
description?: string;
category?: string;
accessLevel?: string;
}
interface AddPhotosBody {
photoIds: number[];
}
interface ReorderBody {
photoIds: number[];
}
interface SetCoverBody {
photoId: number;
}
export async function photoAlbumsRoutes(fastify: FastifyInstance) {
// GET /api/albums - List albums
fastify.get<{ Querystring: { limit?: string; offset?: string; search?: string } }>(
'/',
{ preHandler: requireAdminRole },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
const offset = parseInt(request.query.offset || '0');
const search = request.query.search;
const where: any = {};
if (search) {
where.title = { contains: search, mode: 'insensitive' };
}
const [albums, total] = await Promise.all([
prisma.photoAlbum.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
coverPhoto: {
select: { id: true, thumbnailPath: true },
},
creator: {
select: { id: true, name: true, email: true },
},
_count: { select: { photos: true } },
},
}),
prisma.photoAlbum.count({ where }),
]);
return {
albums: albums.map(a => ({
...a,
photoCount: a._count.photos,
coverThumbnailUrl: a.coverPhoto?.thumbnailPath
? `/media/photos/${a.coverPhoto.id}/thumbnail`
: null,
})),
total,
limit,
offset,
};
}
);
// POST /api/albums - Create album
fastify.post<{ Body: CreateAlbumBody }>(
'/',
{ preHandler: requireAdminRole },
async (request, reply) => {
const { title, description, photoIds } = request.body;
if (!title?.trim()) {
return reply.code(400).send({ message: 'Title is required' });
}
const album = await prisma.photoAlbum.create({
data: {
title: title.trim(),
description: description?.trim() || null,
creatorId: request.user?.id || null,
photoCount: photoIds?.length || 0,
},
});
// Move photos into album if provided
if (photoIds?.length) {
for (let i = 0; i < photoIds.length; i++) {
await prisma.photo.update({
where: { id: photoIds[i] },
data: { albumId: album.id, albumPosition: i },
});
}
// Set first photo as cover
await prisma.photoAlbum.update({
where: { id: album.id },
data: { coverPhotoId: photoIds[0] },
});
}
return reply.code(201).send({ album });
}
);
// GET /api/albums/:id - Album detail with photos
fastify.get<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findUnique({
where: { id },
include: {
coverPhoto: { select: { id: true, thumbnailPath: true } },
creator: { select: { id: true, name: true, email: true } },
photos: {
orderBy: { albumPosition: 'asc' },
select: {
id: true,
title: true,
originalFilename: true,
thumbnailPath: true,
width: true,
height: true,
orientation: true,
format: true,
fileSize: true,
albumPosition: true,
isPublished: true,
createdAt: true,
},
},
},
});
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
return {
...album,
photos: album.photos.map(p => ({
...p,
fileSize: p.fileSize?.toString() ?? null,
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
})),
};
}
);
// PATCH /api/albums/:id - Update album metadata
fastify.patch<{ Params: { id: string }; Body: UpdateAlbumBody }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const { title, description, category, accessLevel } = request.body;
const album = await prisma.photoAlbum.findUnique({ where: { id } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
const updated = await prisma.photoAlbum.update({
where: { id },
data: {
...(title !== undefined && { title: title.trim() }),
...(description !== undefined && { description: description?.trim() || null }),
...(category !== undefined && { category }),
...(accessLevel !== undefined && { accessLevel }),
},
});
return updated;
}
);
// DELETE /api/albums/:id - Delete album (photos become orphaned, NOT deleted)
fastify.delete<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findUnique({ where: { id } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
// Remove album reference from photos (orphan them)
await prisma.photo.updateMany({
where: { albumId: id },
data: { albumId: null, albumPosition: null },
});
await prisma.photoAlbum.delete({ where: { id } });
return { message: 'Album deleted, photos preserved' };
}
);
// POST /api/albums/:id/photos - Add photos to album
fastify.post<{ Params: { id: string }; Body: AddPhotosBody }>(
'/:id/photos',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoIds } = request.body;
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
// Find current max position
const maxPos = await prisma.photo.aggregate({
where: { albumId },
_max: { albumPosition: true },
});
let nextPos = (maxPos._max.albumPosition ?? -1) + 1;
for (const photoId of photoIds) {
await prisma.photo.update({
where: { id: photoId },
data: { albumId, albumPosition: nextPos++ },
});
}
// Update photo count
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
// Set cover if album has none
if (!album.coverPhotoId && photoIds.length > 0) {
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: photoIds[0] },
});
}
return { message: `Added ${photoIds.length} photos to album`, photoCount: count };
}
);
// DELETE /api/albums/:id/photos/:photoId - Remove photo from album
fastify.delete<{ Params: { id: string; photoId: string } }>(
'/:id/photos/:photoId',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const photoId = parseInt(request.params.photoId as string);
await prisma.photo.update({
where: { id: photoId },
data: { albumId: null, albumPosition: null },
});
// Clear cover if it was the removed photo
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
if (album?.coverPhotoId === photoId) {
// Set next photo as cover or null
const nextPhoto = await prisma.photo.findFirst({
where: { albumId },
orderBy: { albumPosition: 'asc' },
});
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: nextPhoto?.id ?? null },
});
}
// Update count
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
return { message: 'Photo removed from album', photoCount: count };
}
);
// PUT /api/albums/:id/reorder - Reorder photos in album
fastify.put<{ Params: { id: string }; Body: ReorderBody }>(
'/:id/reorder',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoIds } = request.body;
// Update positions
for (let i = 0; i < photoIds.length; i++) {
await prisma.photo.update({
where: { id: photoIds[i] },
data: { albumPosition: i },
});
}
return { message: 'Photos reordered' };
}
);
// PUT /api/albums/:id/cover - Set cover photo
fastify.put<{ Params: { id: string }; Body: SetCoverBody }>(
'/:id/cover',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoId } = request.body;
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: photoId },
});
return { message: 'Cover photo set' };
}
);
// POST /api/albums/:id/publish - Publish album + all its photos
fastify.post<{ Params: { id: string } }>(
'/:id/publish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const now = new Date();
await Promise.all([
prisma.photoAlbum.update({
where: { id: albumId },
data: { isPublished: true, publishedAt: now },
}),
prisma.photo.updateMany({
where: { albumId },
data: { isPublished: true, publishedAt: now },
}),
]);
return { message: 'Album and photos published' };
}
);
}

View File

@ -0,0 +1,253 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { createHash } from 'crypto';
import { logger } from '../../../utils/logger';
/**
* Photo engagement routes upvotes, comments, reactions, views (prefix: /api)
*/
interface UpvoteParams {
id: string;
}
interface CommentBody {
content: string;
sessionId: string;
}
interface ReactionBody {
sessionId: string;
reactionType: string;
}
interface ViewBody {
photoId: number;
sessionId?: string;
}
const VALID_REACTIONS = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'];
export async function photoEngagementRoutes(fastify: FastifyInstance) {
// POST /api/photos/:id/upvote - Toggle upvote on
fastify.post<{ Params: UpvoteParams; Body: { sessionId: string } }>(
'/photos/:id/upvote',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { sessionId } = request.body;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
// Check existing
const existing = await prisma.photoUpvote.findFirst({
where: { photoId, sessionId },
});
if (existing) {
return reply.code(409).send({ message: 'Already upvoted' });
}
await prisma.photoUpvote.create({
data: { photoId, sessionId },
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { upvoteCount: { increment: 1 } },
});
return { message: 'Upvoted', upvoted: true };
}
);
// DELETE /api/photos/:id/upvote - Remove upvote
fastify.delete<{ Params: UpvoteParams; Body: { sessionId: string } }>(
'/photos/:id/upvote',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const sessionId = (request.body as any)?.sessionId || (request.query as any)?.sessionId;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
const existing = await prisma.photoUpvote.findFirst({
where: { photoId, sessionId },
});
if (!existing) {
return reply.code(404).send({ message: 'No upvote found' });
}
await prisma.photoUpvote.delete({ where: { id: existing.id } });
await prisma.photo.update({
where: { id: photoId },
data: { upvoteCount: { decrement: 1 } },
});
return { message: 'Upvote removed', upvoted: false };
}
);
// GET /api/photos/:id/comments - Get comments
fastify.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
'/photos/:id/comments',
{ preHandler: optionalAuth },
async (request) => {
const photoId = parseInt(request.params.id as string);
const limit = Math.min(parseInt(request.query.limit || '50'), 200);
const offset = parseInt(request.query.offset || '0');
const [comments, total] = await Promise.all([
prisma.photoComment.findMany({
where: { photoId, isHidden: false, safetyStatus: 'approved' },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
select: {
id: true,
content: true,
createdAt: true,
user: {
select: { id: true, name: true },
},
},
}),
prisma.photoComment.count({
where: { photoId, isHidden: false, safetyStatus: 'approved' },
}),
]);
return { comments, total, limit, offset };
}
);
// POST /api/photos/:id/comments - Add comment
fastify.post<{ Params: { id: string }; Body: CommentBody }>(
'/photos/:id/comments',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { content, sessionId } = request.body;
if (!content?.trim()) {
return reply.code(400).send({ message: 'Content is required' });
}
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
const comment = await prisma.photoComment.create({
data: {
photoId,
sessionId,
userId: request.user?.id || null,
content: content.trim().slice(0, 2000), // Max 2000 chars
},
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { commentCount: { increment: 1 } },
});
return reply.code(201).send({ comment });
}
);
// POST /api/photos/:id/reactions - Add reaction
fastify.post<{ Params: { id: string }; Body: ReactionBody }>(
'/photos/:id/reactions',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { sessionId, reactionType } = request.body;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
if (!VALID_REACTIONS.includes(reactionType)) {
return reply.code(400).send({ message: `Invalid reaction. Must be: ${VALID_REACTIONS.join(', ')}` });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
// Upsert reaction (one per session per type)
await prisma.photoReaction.upsert({
where: {
photoId_sessionId_reactionType: {
photoId,
sessionId,
reactionType,
},
},
create: { photoId, sessionId, reactionType },
update: {},
});
return { message: 'Reaction added' };
}
);
// POST /api/track/photo-view - Record photo view
fastify.post<{ Body: ViewBody }>(
'/track/photo-view',
{ preHandler: optionalAuth },
async (request, reply) => {
const { photoId, sessionId } = request.body;
if (!photoId) {
return reply.code(400).send({ message: 'photoId is required' });
}
// Hash IP for privacy
const ipRaw = request.ip || request.headers['x-forwarded-for'] || '';
const ipStr = Array.isArray(ipRaw) ? ipRaw[0] : ipRaw;
const ipHash = createHash('sha256').update(ipStr).digest('hex').slice(0, 16);
await prisma.photoView.create({
data: {
photoId,
sessionId: sessionId || null,
userId: request.user?.id || null,
ipAddressHash: ipHash,
},
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { viewCount: { increment: 1 } },
});
return { message: 'View recorded' };
}
);
}

View File

@ -0,0 +1,269 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import { unlink, mkdir } from 'fs/promises';
import { join, extname } from 'path';
import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import {
ALLOWED_IMAGE_EXTENSIONS,
validateImage,
extractPhotoMetadata,
generateVariants,
getPhotoInboxDir,
} from '../services/photo-processing.service';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
/**
* Photo upload routes (prefix: /api/photos)
*/
export async function photoUploadRoutes(fastify: FastifyInstance) {
// POST /api/photos/upload - Single photo upload
fastify.post(
'/upload',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
let tempFilePath: string | null = null;
try {
const data = await request.file();
if (!data) {
return reply.code(400).send({ message: 'No file uploaded' });
}
// Validate file extension
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
return reply.code(400).send({
message: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
});
}
// Generate unique filename and ensure inbox dir exists
const filename = `${randomUUID()}${ext}`;
const inboxDir = getPhotoInboxDir();
await mkdir(inboxDir, { recursive: true });
const filePath = join(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk
logger.info(`Uploading photo to ${filePath}`);
await pipeline(data.file, createWriteStream(filePath));
// Extract metadata fields from form data
const metadataFields = data.fields as Record<string, { value: string }>;
const title = metadataFields.title?.value;
const producer = metadataFields.producer?.value;
const creator = metadataFields.creator?.value;
const albumIdStr = metadataFields.albumId?.value;
const albumId = albumIdStr ? parseInt(albumIdStr) : null;
// Validate image
logger.info(`Validating image: ${filePath}`);
await validateImage(filePath);
// Extract metadata
logger.info(`Extracting photo metadata: ${filePath}`);
const metadata = await extractPhotoMetadata(filePath);
// Determine album position if adding to album
let albumPosition: number | null = null;
if (albumId) {
const maxPos = await prisma.photo.aggregate({
where: { albumId },
_max: { albumPosition: true },
});
albumPosition = (maxPos._max.albumPosition ?? -1) + 1;
}
// Insert into database
const photo = await prisma.photo.create({
data: {
path: filePath,
filename,
originalFilename: data.filename,
title: title || data.filename,
producer: producer || null,
creator: creator || null,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
fileSize: BigInt(metadata.fileSize),
format: metadata.format,
colorSpace: metadata.colorSpace,
hasAlpha: metadata.hasAlpha,
dpi: metadata.dpi,
cameraMake: metadata.cameraMake,
cameraModel: metadata.cameraModel,
focalLength: metadata.focalLength,
aperture: metadata.aperture,
shutterSpeed: metadata.shutterSpeed,
iso: metadata.iso,
takenAt: metadata.takenAt,
gpsLatitude: metadata.gpsLatitude,
gpsLongitude: metadata.gpsLongitude,
albumId,
albumPosition,
uploaderId: request.user?.id || null,
},
});
logger.info(`Photo uploaded: ${photo.id}`);
// Generate image variants (thumbnail, medium, large, webp)
try {
const variants = await generateVariants(filePath, photo.id);
await prisma.photo.update({
where: { id: photo.id },
data: {
thumbnailPath: variants.thumbnailPath,
mediumPath: variants.mediumPath,
largePath: variants.largePath,
webpPath: variants.webpPath,
},
});
logger.info(`Generated variants for photo ${photo.id}`);
} catch (variantError) {
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
}
// Update album photo count if applicable
if (albumId) {
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
}
return reply.code(201).send({
message: 'Photo uploaded successfully',
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
});
} catch (error) {
if (tempFilePath) {
try { await unlink(tempFilePath); } catch { /* ignore */ }
}
logger.error('Photo upload failed:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Upload failed',
});
}
}
);
// POST /api/photos/upload/batch - Batch photo upload
fastify.post(
'/upload/batch',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const files = request.files();
const results: Array<{ filename: string; success: boolean; error?: string; photo?: any }> = [];
for await (const file of files) {
let tempFilePath: string | null = null;
try {
const ext = extname(file.filename).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
results.push({
filename: file.filename,
success: false,
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
});
continue;
}
const filename = `${randomUUID()}${ext}`;
const inboxDir = getPhotoInboxDir();
await mkdir(inboxDir, { recursive: true });
const filePath = join(inboxDir, filename);
tempFilePath = filePath;
await pipeline(file.file, createWriteStream(filePath));
await validateImage(filePath);
const metadata = await extractPhotoMetadata(filePath);
const photo = await prisma.photo.create({
data: {
path: filePath,
filename,
originalFilename: file.filename,
title: file.filename,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
fileSize: BigInt(metadata.fileSize),
format: metadata.format,
colorSpace: metadata.colorSpace,
hasAlpha: metadata.hasAlpha,
dpi: metadata.dpi,
cameraMake: metadata.cameraMake,
cameraModel: metadata.cameraModel,
focalLength: metadata.focalLength,
aperture: metadata.aperture,
shutterSpeed: metadata.shutterSpeed,
iso: metadata.iso,
takenAt: metadata.takenAt,
gpsLatitude: metadata.gpsLatitude,
gpsLongitude: metadata.gpsLongitude,
uploaderId: request.user?.id || null,
},
});
// Generate variants
try {
const variants = await generateVariants(filePath, photo.id);
await prisma.photo.update({
where: { id: photo.id },
data: {
thumbnailPath: variants.thumbnailPath,
mediumPath: variants.mediumPath,
largePath: variants.largePath,
webpPath: variants.webpPath,
},
});
} catch (variantError) {
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
}
results.push({
filename: file.filename,
success: true,
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
});
logger.info(`Batch upload: ${file.filename} -> photo ${photo.id}`);
} catch (error) {
if (tempFilePath) {
try { await unlink(tempFilePath); } catch { /* ignore */ }
}
logger.error(`Batch upload failed for ${file.filename}:`, error);
results.push({
filename: file.filename,
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
});
}
}
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
return reply.code(207).send({
message: `Batch upload: ${successCount} succeeded, ${failCount} failed`,
results,
});
} catch (error) {
logger.error('Batch photo upload failed:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Batch upload failed',
});
}
}
);
}

View File

@ -0,0 +1,473 @@
import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs';
import { access } from 'fs/promises';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { logger } from '../../../utils/logger';
/**
* Public photo/album/gallery endpoints (prefix: /api)
*/
interface PublicPhotosQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular' | 'oldest';
category?: string;
}
interface UnifiedGalleryQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular';
category?: string;
mediaType?: 'all' | 'video' | 'photo';
}
interface ImageQuery {
size?: 'thumb' | 'medium' | 'large';
}
export async function photosPublicRoutes(fastify: FastifyInstance) {
// GET /api/public/photos - Published photos (paginated)
fastify.get<{ Querystring: PublicPhotosQuery }>(
'/public/photos',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const category = request.query.category;
const where: any = {
isPublished: true,
isLocked: false,
};
if (category) where.category = category;
let orderBy: any = { publishedAt: 'desc' };
if (sort === 'oldest') orderBy = { publishedAt: 'asc' };
if (sort === 'popular') orderBy = { viewCount: 'desc' };
const [photos, total] = await Promise.all([
prisma.photo.findMany({
where,
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
category: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
albumId: true,
createdAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
prisma.photo.count({ where }),
]);
return {
photos: photos.map(p => ({
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
})),
pagination: { total, limit, offset, hasMore: offset + limit < total },
};
}
);
// GET /api/public/photos/:id - Single published photo
fastify.get<{ Params: { id: string } }>(
'/public/photos/:id',
{ preHandler: optionalAuth },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: {
id: true,
title: true,
description: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
creator: true,
category: true,
cameraMake: true,
cameraModel: true,
focalLength: true,
aperture: true,
shutterSpeed: true,
iso: true,
takenAt: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
albumId: true,
createdAt: true,
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
return {
...photo,
thumbnailUrl: `/media/public/photos/${photo.id}/thumbnail`,
imageUrl: `/media/public/photos/${photo.id}/image`,
};
}
);
// GET /api/public/photos/:id/image - Serve optimized image
fastify.get<{ Params: { id: string }; Querystring: ImageQuery }>(
'/public/photos/:id/image',
async (request, reply) => {
const id = parseInt(request.params.id as string);
const size = request.query.size || 'large';
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: {
thumbnailPath: true,
mediumPath: true,
largePath: true,
webpPath: true,
format: true,
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Check if client accepts WebP
const acceptsWebP = request.headers.accept?.includes('image/webp');
// Pick the right variant
let filePath: string | null = null;
let contentType = 'image/jpeg';
if (acceptsWebP && photo.webpPath) {
filePath = photo.webpPath;
contentType = 'image/webp';
} else {
switch (size) {
case 'thumb':
filePath = photo.thumbnailPath;
break;
case 'medium':
filePath = photo.mediumPath;
break;
case 'large':
default:
filePath = photo.largePath;
break;
}
}
if (!filePath || filePath.includes('..')) {
return reply.code(404).send({ message: 'Image variant not found' });
}
try {
await access(filePath);
} catch {
return reply.code(404).send({ message: 'Image file not found' });
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(filePath));
}
);
// GET /api/public/photos/:id/thumbnail - Serve thumbnail
fastify.get<{ Params: { id: string } }>(
'/public/photos/:id/thumbnail',
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: { thumbnailPath: true },
});
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
try {
await access(photo.thumbnailPath);
} catch {
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(photo.thumbnailPath));
}
);
// GET /api/public/albums - Published albums
fastify.get<{ Querystring: PublicPhotosQuery }>(
'/public/albums',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const category = request.query.category;
const where: any = { isPublished: true, isLocked: false };
if (category) where.category = category;
const [albums, total] = await Promise.all([
prisma.photoAlbum.findMany({
where,
select: {
id: true,
title: true,
description: true,
category: true,
photoCount: true,
viewCount: true,
upvoteCount: true,
publishedAt: true,
coverPhoto: {
select: { id: true, thumbnailPath: true, width: true, height: true },
},
},
orderBy: { publishedAt: 'desc' },
take: limit,
skip: offset,
}),
prisma.photoAlbum.count({ where }),
]);
return {
albums: albums.map(a => ({
...a,
coverThumbnailUrl: a.coverPhoto
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
: null,
})),
pagination: { total, limit, offset, hasMore: offset + limit < total },
};
}
);
// GET /api/public/albums/:id - Published album with photos
fastify.get<{ Params: { id: string } }>(
'/public/albums/:id',
{ preHandler: optionalAuth },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findFirst({
where: { id, isPublished: true, isLocked: false },
include: {
coverPhoto: { select: { id: true, thumbnailPath: true } },
photos: {
where: { isPublished: true, isLocked: false },
orderBy: { albumPosition: 'asc' },
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
},
},
},
});
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
return {
...album,
photos: album.photos.map(p => ({
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
})),
};
}
);
// GET /api/public/gallery - Unified feed (videos + photos + albums)
fastify.get<{ Querystring: UnifiedGalleryQuery }>(
'/public/gallery',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const category = request.query.category;
const mediaType = request.query.mediaType || 'all';
const orderByDate = sort === 'popular' ? undefined : 'desc';
const items: Array<{ type: 'video' | 'photo' | 'album'; data: any; publishedAt: Date }> = [];
// Fetch videos if needed
if (mediaType === 'all' || mediaType === 'video') {
const videoWhere: any = { isPublished: true, isLocked: false };
if (category) videoWhere.category = category;
const videos = await prisma.video.findMany({
where: videoWhere,
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
width: true,
height: true,
orientation: true,
quality: true,
producer: true,
thumbnailPath: true,
publishedAt: true,
category: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
isShort: true,
createdAt: true,
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset, // Over-fetch for merge
});
for (const v of videos) {
items.push({
type: 'video',
data: {
...v,
duration: v.durationSeconds,
thumbnailUrl: v.thumbnailPath ? `/media/videos/${v.id}/thumbnail` : null,
videoUrl: `/media/videos/${v.id}/stream`,
},
publishedAt: v.publishedAt || v.createdAt,
});
}
}
// Fetch photos (non-album) if needed
if (mediaType === 'all' || mediaType === 'photo') {
const photoWhere: any = { isPublished: true, isLocked: false, albumId: null };
if (category) photoWhere.category = category;
const photos = await prisma.photo.findMany({
where: photoWhere,
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
category: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
createdAt: true,
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset,
});
for (const p of photos) {
items.push({
type: 'photo',
data: {
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
},
publishedAt: p.publishedAt || p.createdAt,
});
}
// Fetch albums
const albumWhere: any = { isPublished: true, isLocked: false };
if (category) albumWhere.category = category;
const albums = await prisma.photoAlbum.findMany({
where: albumWhere,
select: {
id: true,
title: true,
description: true,
category: true,
photoCount: true,
viewCount: true,
upvoteCount: true,
publishedAt: true,
createdAt: true,
coverPhoto: {
select: { id: true, thumbnailPath: true, width: true, height: true },
},
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset,
});
for (const a of albums) {
items.push({
type: 'album',
data: {
...a,
coverThumbnailUrl: a.coverPhoto
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
: null,
},
publishedAt: a.publishedAt || a.createdAt,
});
}
}
// Sort merged results
if (sort === 'popular') {
items.sort((a, b) => (b.data.viewCount || 0) - (a.data.viewCount || 0));
} else {
items.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
}
// Apply offset and limit
const paged = items.slice(offset, offset + limit);
return {
items: paged.map(({ type, data }) => ({ type, data })),
pagination: {
total: items.length,
limit,
offset,
hasMore: offset + limit < items.length,
},
};
}
);
}

View File

@ -0,0 +1,388 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { unlink } from 'fs/promises';
/**
* Admin photo CRUD routes (prefix: /api/photos)
*/
interface PhotosQuery {
limit?: string;
offset?: string;
search?: string;
format?: string;
orientation?: 'H' | 'V' | 'S';
producer?: string;
albumId?: string;
isPublished?: string;
}
interface PhotoUpdateBody {
title?: string;
description?: string;
producer?: string;
creator?: string;
tags?: string[];
category?: string;
accessLevel?: string;
}
interface BulkIdsBody {
ids: number[];
}
export async function photosRoutes(fastify: FastifyInstance) {
// GET /api/photos - List photos (admin, paginated)
fastify.get<{ Querystring: PhotosQuery }>(
'/',
{ preHandler: requireAdminRole },
async (request, reply) => {
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
const offset = parseInt(request.query.offset || '0');
const { search, format, orientation, producer, albumId, isPublished } = request.query;
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ originalFilename: { contains: search, mode: 'insensitive' } },
{ producer: { contains: search, mode: 'insensitive' } },
];
}
if (format) where.format = format;
if (orientation) where.orientation = orientation;
if (producer) where.producer = producer;
if (albumId) where.albumId = parseInt(albumId);
if (isPublished !== undefined) where.isPublished = isPublished === 'true';
const [photos, total] = await Promise.all([
prisma.photo.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
album: { select: { id: true, title: true } },
},
}),
prisma.photo.count({ where }),
]);
return {
photos: photos.map(p => ({
...p,
fileSize: p.fileSize?.toString() ?? null,
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
})),
total,
limit,
offset,
};
}
);
// GET /api/photos/producers - Distinct producers list
fastify.get(
'/producers',
{ preHandler: requireAdminRole },
async () => {
const results = await prisma.photo.findMany({
where: { producer: { not: null } },
select: { producer: true },
distinct: ['producer'],
});
return results.map(r => r.producer).filter(Boolean);
}
);
// GET /api/photos/formats - Distinct formats list
fastify.get(
'/formats',
{ preHandler: requireAdminRole },
async () => {
const results = await prisma.photo.findMany({
where: { format: { not: null } },
select: { format: true },
distinct: ['format'],
});
return results.map(r => r.format).filter(Boolean);
}
);
// GET /api/photos/:id - Single photo detail
fastify.get<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({
where: { id },
include: {
album: { select: { id: true, title: true } },
uploader: { select: { id: true, name: true, email: true } },
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
return {
...photo,
fileSize: photo.fileSize?.toString() ?? null,
thumbnailUrl: photo.thumbnailPath ? `/media/photos/${photo.id}/thumbnail` : null,
};
}
);
// GET /api/photos/:id/thumbnail - Serve thumbnail image (admin)
fastify.get<{ Params: { id: string } }>(
'/:id/thumbnail',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({
where: { id },
select: { thumbnailPath: true },
});
if (!photo?.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
if (photo.thumbnailPath.includes('..')) {
return reply.code(403).send({ message: 'Access denied' });
}
const { createReadStream } = await import('fs');
const { access } = await import('fs/promises');
try {
await access(photo.thumbnailPath);
} catch {
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(createReadStream(photo.thumbnailPath));
}
);
// GET /api/photos/:id/image - Serve full image (admin, size: thumb/medium/large)
fastify.get<{ Params: { id: string }; Querystring: { size?: string } }>(
'/:id/image',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const size = request.query.size || 'large';
const photo = await prisma.photo.findUnique({
where: { id },
select: { thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true, path: true, format: true },
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Pick variant based on size param
let filePath: string | null = null;
let contentType = 'image/jpeg';
switch (size) {
case 'thumb':
filePath = photo.thumbnailPath;
break;
case 'medium':
filePath = photo.mediumPath;
break;
case 'large':
default:
filePath = photo.largePath || photo.mediumPath;
break;
}
if (!filePath || filePath.includes('..')) {
return reply.code(404).send({ message: 'Image variant not found' });
}
const { createReadStream } = await import('fs');
const { access } = await import('fs/promises');
try {
await access(filePath);
} catch {
return reply.code(404).send({ message: 'Image file not found' });
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(createReadStream(filePath));
}
);
// PATCH /api/photos/:id - Update photo metadata
fastify.patch<{ Params: { id: string }; Body: PhotoUpdateBody }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const { title, description, producer, creator, tags, category, accessLevel } = request.body;
const photo = await prisma.photo.findUnique({ where: { id } });
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
const updated = await prisma.photo.update({
where: { id },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(producer !== undefined && { producer }),
...(creator !== undefined && { creator }),
...(tags !== undefined && { tags: tags as any }),
...(category !== undefined && { category }),
...(accessLevel !== undefined && { accessLevel }),
},
});
return { ...updated, fileSize: updated.fileSize?.toString() ?? null };
}
);
// DELETE /api/photos/:id - Delete photo + variant files
fastify.delete<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({ where: { id } });
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Delete variant files
const filesToDelete = [
photo.path,
photo.thumbnailPath,
photo.mediumPath,
photo.largePath,
photo.webpPath,
].filter(Boolean) as string[];
for (const filePath of filesToDelete) {
try {
await unlink(filePath);
} catch {
// File may already be gone
}
}
// If this was a cover photo for an album, clear the cover
if (photo.albumId) {
await prisma.photoAlbum.updateMany({
where: { coverPhotoId: id },
data: { coverPhotoId: null },
});
}
await prisma.photo.delete({ where: { id } });
return { message: 'Photo deleted' };
}
);
// POST /api/photos/:id/publish
fastify.post<{ Params: { id: string } }>(
'/:id/publish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.update({
where: { id },
data: { isPublished: true, publishedAt: new Date() },
});
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
}
);
// POST /api/photos/:id/unpublish
fastify.post<{ Params: { id: string } }>(
'/:id/unpublish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.update({
where: { id },
data: { isPublished: false },
});
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
}
);
// POST /api/photos/bulk-publish
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-publish',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
const result = await prisma.photo.updateMany({
where: { id: { in: ids } },
data: { isPublished: true, publishedAt: new Date() },
});
return { updated: result.count };
}
);
// POST /api/photos/bulk-unpublish
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-unpublish',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
const result = await prisma.photo.updateMany({
where: { id: { in: ids } },
data: { isPublished: false },
});
return { updated: result.count };
}
);
// POST /api/photos/bulk-delete
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-delete',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
// Get file paths before deleting
const photos = await prisma.photo.findMany({
where: { id: { in: ids } },
select: { path: true, thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true },
});
// Delete files
for (const photo of photos) {
const paths = [photo.path, photo.thumbnailPath, photo.mediumPath, photo.largePath, photo.webpPath].filter(Boolean) as string[];
for (const p of paths) {
try { await unlink(p); } catch { /* ignore */ }
}
}
// Clear album covers referencing these photos
await prisma.photoAlbum.updateMany({
where: { coverPhotoId: { in: ids } },
data: { coverPhotoId: null },
});
const result = await prisma.photo.deleteMany({ where: { id: { in: ids } } });
return { deleted: result.count };
}
);
}

View File

@ -0,0 +1,282 @@
import sharp from 'sharp';
import { stat, mkdir } from 'fs/promises';
import { join, extname } from 'path';
import { logger } from '../../../utils/logger';
// Supported image extensions
export const ALLOWED_IMAGE_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tiff', '.tif', '.heic', '.heif',
];
// Variant output directories (relative to /media/local/photos/)
const PHOTOS_BASE = '/media/local/photos';
const DIRS = {
inbox: join(PHOTOS_BASE, 'inbox'),
thumbnails: join(PHOTOS_BASE, 'thumbnails'),
medium: join(PHOTOS_BASE, 'medium'),
large: join(PHOTOS_BASE, 'large'),
webp: join(PHOTOS_BASE, 'webp'),
};
export interface PhotoMetadata {
width: number;
height: number;
orientation: 'H' | 'V' | 'S';
fileSize: number;
format: string;
colorSpace: string | null;
hasAlpha: boolean;
dpi: number | null;
// EXIF
cameraMake: string | null;
cameraModel: string | null;
focalLength: string | null;
aperture: string | null;
shutterSpeed: string | null;
iso: number | null;
takenAt: Date | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
}
export interface PhotoVariants {
thumbnailPath: string;
mediumPath: string;
largePath: string;
webpPath: string;
}
/**
* Validate that a file is a real image (not just a renamed binary).
* Returns metadata if valid, throws if not.
*/
export async function validateImage(filePath: string): Promise<sharp.Metadata> {
try {
const metadata = await sharp(filePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Image has no dimensions');
}
return metadata;
} catch (error) {
throw new Error(
`Invalid image file: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
}
/**
* Extract metadata from an image file including EXIF data.
*/
export async function extractPhotoMetadata(filePath: string): Promise<PhotoMetadata> {
const metadata = await sharp(filePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Image has no dimensions');
}
const fileStat = await stat(filePath);
// Determine orientation
let orientation: 'H' | 'V' | 'S' = 'H';
if (metadata.width === metadata.height) {
orientation = 'S';
} else if (metadata.height > metadata.width) {
orientation = 'V';
}
// Parse EXIF data
const exif = metadata.exif ? parseExif(metadata) : null;
return {
width: metadata.width,
height: metadata.height,
orientation,
fileSize: fileStat.size,
format: metadata.format || extname(filePath).slice(1).toLowerCase(),
colorSpace: metadata.space || null,
hasAlpha: metadata.hasAlpha || false,
dpi: metadata.density || null,
cameraMake: exif?.make || null,
cameraModel: exif?.model || null,
focalLength: exif?.focalLength || null,
aperture: exif?.aperture || null,
shutterSpeed: exif?.shutterSpeed || null,
iso: exif?.iso || null,
takenAt: exif?.takenAt || null,
gpsLatitude: exif?.gpsLatitude || null,
gpsLongitude: exif?.gpsLongitude || null,
};
}
interface ParsedExif {
make: string | null;
model: string | null;
focalLength: string | null;
aperture: string | null;
shutterSpeed: string | null;
iso: number | null;
takenAt: Date | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
}
/**
* Parse EXIF data from sharp metadata.
* sharp exposes raw EXIF buffer; we parse key IFD tags manually.
*/
function parseExif(metadata: sharp.Metadata): ParsedExif {
const result: ParsedExif = {
make: null,
model: null,
focalLength: null,
aperture: null,
shutterSpeed: null,
iso: null,
takenAt: null,
gpsLatitude: null,
gpsLongitude: null,
};
// sharp provides some EXIF-derived fields directly on metadata
// For deeper EXIF, we'd need exif-reader, but sharp gives us basics
// We'll use the raw exif buffer if available
try {
if (metadata.exif) {
// Use dynamic import for exif-reader if available, otherwise skip
// For now, rely on sharp's built-in metadata fields
// sharp exposes: orientation, density (DPI)
// Additional EXIF requires the exif-reader package
// Try to parse raw EXIF with built-in support
const exifBuffer = metadata.exif;
if (exifBuffer && exifBuffer.length > 0) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const exifReader = require('exif-reader');
const parsed = exifReader(exifBuffer);
if (parsed.Image) {
result.make = parsed.Image.Make || null;
result.model = parsed.Image.Model || null;
}
if (parsed.Photo || parsed.Exif) {
const photo = parsed.Photo || parsed.Exif || {};
if (photo.FocalLength) result.focalLength = `${photo.FocalLength}mm`;
if (photo.FNumber) result.aperture = `f/${photo.FNumber}`;
if (photo.ExposureTime) {
result.shutterSpeed = photo.ExposureTime < 1
? `1/${Math.round(1 / photo.ExposureTime)}s`
: `${photo.ExposureTime}s`;
}
if (photo.ISOSpeedRatings) {
result.iso = Array.isArray(photo.ISOSpeedRatings)
? photo.ISOSpeedRatings[0]
: photo.ISOSpeedRatings;
}
if (photo.DateTimeOriginal) {
result.takenAt = new Date(photo.DateTimeOriginal);
}
}
if (parsed.GPSInfo || parsed.GPS) {
const gps = parsed.GPSInfo || parsed.GPS || {};
if (gps.GPSLatitude && gps.GPSLatitudeRef) {
result.gpsLatitude = dmsToDecimal(gps.GPSLatitude, gps.GPSLatitudeRef);
}
if (gps.GPSLongitude && gps.GPSLongitudeRef) {
result.gpsLongitude = dmsToDecimal(gps.GPSLongitude, gps.GPSLongitudeRef);
}
}
} catch {
// exif-reader not available or parse failed — that's fine
logger.debug('EXIF parsing skipped (exif-reader not available or parse error)');
}
}
}
} catch (error) {
logger.debug('EXIF extraction failed', { error });
}
return result;
}
/**
* Convert DMS (degrees/minutes/seconds) array to decimal degrees.
*/
function dmsToDecimal(dms: number[], ref: string): number {
if (!dms || dms.length < 3) return 0;
let decimal = dms[0] + dms[1] / 60 + dms[2] / 3600;
if (ref === 'S' || ref === 'W') decimal = -decimal;
return Math.round(decimal * 1_000_000) / 1_000_000;
}
/**
* Ensure all photo variant directories exist.
*/
export async function ensurePhotoDirs(): Promise<void> {
for (const dir of Object.values(DIRS)) {
await mkdir(dir, { recursive: true });
}
}
/**
* Generate all image variants (thumbnail, medium, large, webp).
* Auto-orients using EXIF data, strips GPS from output variants.
*/
export async function generateVariants(
sourcePath: string,
photoId: number
): Promise<PhotoVariants> {
await ensurePhotoDirs();
const thumbPath = join(DIRS.thumbnails, `${photoId}_thumb.jpg`);
const mediumPath = join(DIRS.medium, `${photoId}_medium.jpg`);
const largePath = join(DIRS.large, `${photoId}_large.jpg`);
const webpPath = join(DIRS.webp, `${photoId}.webp`);
// Base pipeline: auto-orient (apply EXIF rotation) and strip metadata (privacy)
const basePipeline = () => sharp(sourcePath).rotate().withMetadata({ orientation: undefined });
// Generate all variants in parallel
await Promise.all([
// Thumbnail: 320px longest edge, JPEG q80
basePipeline()
.resize(320, 320, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumbPath),
// Medium: 800px longest edge, JPEG q85
basePipeline()
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(mediumPath),
// Large: 1600px longest edge, JPEG q90
basePipeline()
.resize(1600, 1600, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 90 })
.toFile(largePath),
// WebP: 1200px longest edge, WebP q82
basePipeline()
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(webpPath),
]);
logger.info(`Generated 4 variants for photo ${photoId}`);
return {
thumbnailPath: thumbPath,
mediumPath,
largePath,
webpPath,
};
}
/**
* Get the inbox directory path for photo uploads.
*/
export function getPhotoInboxDir(): string {
return DIRS.inbox;
}

View File

@ -0,0 +1,48 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { quickJoinRateLimit } from '../../middleware/rate-limit';
import { volunteerInviteService } from './volunteer-invite.service';
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
const router = Router();
// POST /api/volunteer-invite/generate — Admin-only: create a signed invite token
router.post(
'/generate',
authenticate,
requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'),
validate(generateInviteSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { cutId, shiftId } = req.body;
const token = volunteerInviteService.generateInviteToken(req.user!.id, cutId, shiftId);
res.json({ token });
} catch (err) {
next(err);
}
},
);
// POST /api/volunteer-invite/redeem — Public: redeem an invite token
router.post(
'/redeem',
quickJoinRateLimit,
validate(redeemInviteSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await volunteerInviteService.redeemInvite(req.body);
res.json({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
cutId: result.cutId,
shiftId: result.shiftId,
});
} catch (err) {
next(err);
}
},
);
export { router as volunteerInviteRouter };

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
export const generateInviteSchema = z.object({
cutId: z.string().optional(),
shiftId: z.string().optional(),
});
export const redeemInviteSchema = z.object({
token: z.string().min(1),
email: z.string().email(),
name: z.string().max(200).optional(),
phone: z.string().max(50).optional(),
});
export type GenerateInviteInput = z.infer<typeof generateInviteSchema>;
export type RedeemInviteInput = z.infer<typeof redeemInviteSchema>;

View File

@ -0,0 +1,106 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { UserCreatedVia, UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { authService } from '../auth/auth.service';
import { AppError } from '../../middleware/error-handler';
import type { RedeemInviteInput } from './volunteer-invite.schemas';
interface InviteTokenPayload {
type: 'volunteer_invite';
adminUserId: string;
cutId?: string;
shiftId?: string;
}
export const volunteerInviteService = {
/**
* Generate a signed invite token (JWT, 30 min expiry).
* Contains the inviting admin's ID and optional cut/shift context.
*/
generateInviteToken(adminUserId: string, cutId?: string, shiftId?: string): string {
const payload: InviteTokenPayload = {
type: 'volunteer_invite',
adminUserId,
...(cutId && { cutId }),
...(shiftId && { shiftId }),
};
return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' });
},
/**
* Redeem an invite token: verify it, create (or reactivate) a TEMP user,
* and return a JWT token pair for immediate login.
*/
async redeemInvite(input: RedeemInviteInput) {
// 1. Verify and decode the invite token
let payload: InviteTokenPayload;
try {
const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET);
payload = decoded as InviteTokenPayload;
} catch {
throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN');
}
// 2. Validate token type to prevent JWT confusion attacks
if (payload.type !== 'volunteer_invite') {
throw new AppError(400, 'Invalid invite token', 'INVALID_TOKEN_TYPE');
}
const email = input.email.toLowerCase().trim();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h
// 3. Check for existing user
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
// Active non-TEMP user — just generate new token pair (re-login)
if (existingUser.status === UserStatus.ACTIVE && existingUser.role !== UserRole.TEMP) {
const tokens = await authService.generateTokenPair(existingUser);
return { tokens, user: existingUser, cutId: payload.cutId, shiftId: payload.shiftId };
}
// Expired or inactive TEMP user — reactivate with extended expiry
if (existingUser.role === UserRole.TEMP) {
const reactivated = await prisma.user.update({
where: { id: existingUser.id },
data: {
status: UserStatus.ACTIVE,
expiresAt,
name: input.name || existingUser.name,
phone: input.phone || existingUser.phone,
},
});
const tokens = await authService.generateTokenPair(reactivated);
return { tokens, user: reactivated, cutId: payload.cutId, shiftId: payload.shiftId };
}
// Suspended/inactive non-TEMP user — block
throw new AppError(403, 'Account is not active. Please contact an administrator.', 'ACCOUNT_INACTIVE');
}
// 4. Create new TEMP user with random password (never shown to user)
const randomPassword = crypto.randomBytes(16).toString('hex');
const hashedPassword = await bcrypt.hash(randomPassword, 10);
const newUser = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: input.name || null,
phone: input.phone || null,
role: UserRole.TEMP,
roles: JSON.stringify([UserRole.TEMP]),
status: UserStatus.ACTIVE,
createdVia: UserCreatedVia.QUICK_JOIN_INVITE,
expiresAt,
},
});
const tokens = await authService.generateTokenPair(newUser);
return { tokens, user: newUser, cutId: payload.cutId, shiftId: payload.shiftId };
},
};

View File

@ -64,6 +64,7 @@ import { galleryAdsPublicRouter } from './modules/gallery-ads/gallery-ads-public
import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.routes';
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes';
import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
import { volunteerInviteRouter } from './modules/volunteer-invite/volunteer-invite.routes';
import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service';
const app = express();
@ -197,6 +198,7 @@ app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)
app.use('/api/docs-analytics', docsAnalyticsAdminRouter); // Admin docs analytics (ADMIN roles)
app.use('/api/volunteer-invite', volunteerInviteRouter); // Quick join invite (admin generate + public redeem)
// --- Error Handler (must be last) ---
app.use(errorHandler);

View File

@ -127,6 +127,7 @@ services:
- ${MEDIA_ROOT:-./media}:/media:ro
- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
depends_on:
v2-postgres:

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "changemaker.lite",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

1
package.json Normal file
View File

@ -0,0 +1 @@
{}