diff --git a/CLAUDE.md b/CLAUDE.md index c43d3219..a3693ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 30a347a2..dccd9742 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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={} /> + } /> } /> } /> } /> diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index e61bade0..afd0f446 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -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: , - label: 'Media Library', + icon: , + label: 'Media', children: [ - { key: '/app/media/library', icon: , label: 'Videos' }, + { key: '/app/media/library', icon: , label: 'Library' }, { key: '/app/media/analytics', icon: , label: 'Analytics' }, { key: '/app/media/curated', icon: , label: 'Curated' }, { key: '/app/media/moderation', icon: , label: 'Moderation' }, @@ -444,10 +444,10 @@ export default function AppLayout() { > )} {settings?.enableMediaFeatures !== false && ( - + } + icon={} onClick={() => navigate('/gallery')} > {!isMobile && 'Gallery'} diff --git a/admin/src/components/PublicLayout.tsx b/admin/src/components/PublicLayout.tsx index 595e997b..9a8c5694 100644 --- a/admin/src/components/PublicLayout.tsx +++ b/admin/src/components/PublicLayout.tsx @@ -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 ? ( - logout()} icon={} label="Logout" /> + <> + {isAdmin ? ( + } label="Admin" /> + ) : ( + } label="Volunteer Portal" /> + )} + logout()} icon={} label="Logout" /> + > ) : ( - setAuthModalOpen(true)} icon={} label="Sign In" /> + { setAuthModalContext('generic'); setAuthModalOpen(true); }} icon={} label="Sign In" /> )} )} @@ -350,21 +359,37 @@ export default function PublicLayout() { )} {isAuthenticated ? ( - { 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' }} - > - Logout - + <> + 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 ? : } + {isAdmin ? 'Admin Panel' : 'Volunteer Portal'} + + { 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' }} + > + Logout + + > ) : ( { 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' }} > Sign In @@ -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'} /> diff --git a/admin/src/components/canvass/CanvassTrendsCard.tsx b/admin/src/components/canvass/CanvassTrendsCard.tsx new file mode 100644 index 00000000..6d873542 --- /dev/null +++ b/admin/src/components/canvass/CanvassTrendsCard.tsx @@ -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(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 = { 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 ( + + setGranularity(v as 'day' | 'week')} + /> + + { + if (vals && vals[0] && vals[1]) { + setDateRange([vals[0], vals[1]]); + } + }} + allowClear={false} + style={{ width: isMobile ? '100%' : 220 }} + /> + + } + > + {loading ? ( + + + + ) : !hasData ? ( + + ) : ( + + + + + + + granularity === 'week' + ? dayjs(v).format('MMM D') + : dayjs(v).format('M/D') + } + /> + (percentMode ? `${v}%` : String(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) => ( + + ))} + + + + + + + {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 ( + + + + {VISIT_OUTCOME_LABELS[o]} + + + {count} ({pct}%) + + + ); + })} + + + + )} + + ); +} diff --git a/admin/src/components/canvass/VolunteerMapDrawer.tsx b/admin/src/components/canvass/VolunteerMapDrawer.tsx index cdc6499b..fd8cd2e1 100644 --- a/admin/src/components/canvass/VolunteerMapDrawer.tsx +++ b/admin/src/components/canvass/VolunteerMapDrawer.tsx @@ -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(null); const [assignments, setAssignments] = useState([]); @@ -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(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 ( - - - {/* Header with drag handle and close button */} - - {/* Drag handle at top center */} - + 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 ( + <> + + + {/* Header with drag handle and close button */} + + {/* Drag handle at top center */} + + + {/* Close button at top right */} + } + onClick={onClose} + style={{ + position: 'absolute', + top: -8, + right: -8, + color: 'rgba(255,255,255,0.6)', + }} + size="small" + /> + + + {/* Active session alert */} + {sessionActive && sessionCutName && ( + <> + + + Active Session: {sessionCutName} + + {sessionStartedAt && ( + + + + + )} + + } + type="info" + showIcon={false} + action={ + onEndSession && ( + } + onClick={onEndSession} + loading={endingSession} + > + End + + ) + } + style={{ marginBottom: 16 }} + /> + + > + )} + + + {user?.name || user?.email || 'Volunteer'} + + + {user?.email} + + + {/* Mini stats */} + {stats && ( + + + + + )} + + + + {/* Assignments (hidden when session active) */} + {!sessionActive && assignments.length > 0 && ( + <> + + My Assignments + + ( + } + onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }} + > + Start + , + ]} + > + {a.cutName}} + description={ + + {a.shiftTitle} · {Math.round(a.completionPercentage)}% + + } + /> + + )} + /> + + > + )} + + {/* Free session — pick a cut (hidden when session active) */} + {!sessionActive && ( + <> + + Start Session (Any Cut) + + + ({ label: c.name, value: c.id }))} + allowClear + /> + } + disabled={!freeCutId} + onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }} + > + Go + + + > + )} + + {/* Navigation links */} + } + block + style={{ textAlign: 'left', marginBottom: 4 }} + onClick={() => { navigate('/volunteer/activity'); onClose(); }} + > + My Activity + + + {/* Admin: Invite Volunteer button */} + {isAdmin && ( } - onClick={onClose} - style={{ - position: 'absolute', - top: -8, - right: -8, - color: 'rgba(255,255,255,0.6)', - }} - size="small" - /> + icon={} + block + style={{ textAlign: 'left', marginBottom: 4 }} + onClick={handleGenerateInvite} + loading={generatingInvite} + > + Invite Volunteer + + )} + + + + + } + block + style={{ textAlign: 'left' }} + onClick={handleLogout} + > + Logout + + - {/* Active session alert */} - {sessionActive && sessionCutName && ( - <> - - - Active Session: {sessionCutName} - - {sessionStartedAt && ( - - - - - )} - - } - type="info" - showIcon={false} - action={ - onEndSession && ( - } - onClick={onEndSession} - loading={endingSession} - > - End - - ) - } - style={{ marginBottom: 16 }} - /> - - > - )} - - - {user?.name || user?.email || 'Volunteer'} - - - {user?.email} - - - {/* Mini stats */} - {stats && ( - - - - - )} - - - - {/* Assignments (hidden when session active) */} - {!sessionActive && assignments.length > 0 && ( - <> - - My Assignments + {/* QR Code Invite Modal */} + { setQrModalOpen(false); setInviteUrl(null); setCopied(false); }} + footer={null} + title="Invite Volunteer" + centered + width={340} + zIndex={1200} + > + + + Show this QR code to a new volunteer. They'll scan it with their phone to get instant access. - + + + )} + + + Or copy this link to send via text: + + + : } type={copied ? 'default' : 'primary'}> + {copied ? 'Copied' : 'Copy'} + + } + onSearch={handleCopyUrl} + style={{ marginBottom: 8 }} size="small" - dataSource={assignments} - style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }} - renderItem={(a) => ( - } - onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }} - > - Start - , - ]} - > - {a.cutName}} - description={ - - {a.shiftTitle} · {Math.round(a.completionPercentage)}% - - } - /> - - )} /> - - > - )} - {/* Free session — pick a cut (hidden when session active) */} - {!sessionActive && ( - <> - - Start Session (Any Cut) + + Link expires in 30 minutes. Access lasts 24 hours. - - ({ label: c.name, value: c.id }))} - allowClear - /> - } - disabled={!freeCutId} - onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }} - > - Go - - - > - )} - - {/* Navigation links */} - } - block - style={{ textAlign: 'left', marginBottom: 4 }} - onClick={() => { navigate('/volunteer/activity'); onClose(); }} - > - My Activity - - - - - - } - block - style={{ textAlign: 'left' }} - onClick={handleLogout} - > - Logout - - - + + + > ); } diff --git a/admin/src/components/dashboard/ActivityFeedCard.tsx b/admin/src/components/dashboard/ActivityFeedCard.tsx index 43a093a4..0765af5d 100644 --- a/admin/src/components/dashboard/ActivityFeedCard.tsx +++ b/admin/src/components/dashboard/ActivityFeedCard.tsx @@ -18,11 +18,11 @@ dayjs.extend(relativeTime); const { Text } = Typography; const TYPE_CONFIG: Record = { - shift_signup: { color: '#eb2f96', icon: }, - response_submitted: { color: '#faad14', icon: }, - canvass_completed: { color: '#52c41a', icon: }, - email_sent: { color: '#1890ff', icon: }, - user_created: { color: '#722ed1', icon: }, + shift_signup: { color: '#eb2f96', icon: }, + response_submitted: { color: '#faad14', icon: }, + canvass_completed: { color: '#52c41a', icon: }, + email_sent: { color: '#1890ff', icon: }, + user_created: { color: '#722ed1', icon: }, }; const MODULE_OPTIONS = [ @@ -37,19 +37,19 @@ function ActivityRow({ item }: { item: ActivityItem }) { return ( - {config.icon} - {item.title} + {config.icon} + {item.title} {item.description} - + {dayjs(item.timestamp).fromNow(true)} @@ -77,7 +77,7 @@ export default function ActivityFeedCard() { setLoading(true); try { const res = await api.get('/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 ( Recent Activity} + title={Recent Activity} size="small" extra={ } - styles={{ body: { padding: '4px 12px 6px' } }} - style={{ height: '100%' }} + styles={{ body: { padding: '6px 14px 8px' } }} > {loading && items.length === 0 ? ( - + ) : items.length === 0 ? ( - No recent activity + No recent activity ) : ( <> - + {items.map(item => ( ))} {hasMore && ( - - + + Load more diff --git a/admin/src/components/dashboard/CampaignEffectivenessCard.tsx b/admin/src/components/dashboard/CampaignEffectivenessCard.tsx new file mode 100644 index 00000000..f046281c --- /dev/null +++ b/admin/src/components/dashboard/CampaignEffectivenessCard.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Campaign Effectiveness} + size="small" + extra={ + + navigate('/app/influence/effectiveness')} style={{ fontSize: 12, padding: 0 }}> + Details + + } onClick={fetchData} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !data ? ( + + ) : s ? ( + <> + + Emails Sent} + value={s.totalEmails} + valueStyle={{ fontSize: 18 }} + /> + Responses} + value={s.totalResponses} + valueStyle={{ fontSize: 18 }} + /> + Response Rate} + value={Math.round(s.avgResponseRate * 100)} + suffix="%" + valueStyle={{ fontSize: 18, color: s.avgResponseRate > 0.1 ? '#52c41a' : '#faad14' }} + /> + + {topCampaigns.length > 0 && ( + + Top Campaigns + {topCampaigns.map(c => ( + + + + {c.title} + + + + {c.emailTotal} emails + 0.1 ? 'green' : 'default'} style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}> + {Math.round(c.responseRate * 100)}% + + + + ))} + + )} + > + ) : ( + No campaign data + )} + + ); +} diff --git a/admin/src/components/dashboard/ChatNotifierCard.tsx b/admin/src/components/dashboard/ChatNotifierCard.tsx index fe599994..6ae194f5 100644 --- a/admin/src/components/dashboard/ChatNotifierCard.tsx +++ b/admin/src/components/dashboard/ChatNotifierCard.tsx @@ -31,28 +31,28 @@ function ChatRow({ message }: { message: ChatMessage }) { {message.isBot - ? - : + ? + : } - {message.username} + {message.username} #{message.channel} {cleanText} - + {dayjs(message.timestamp).fromNow(true)} @@ -96,24 +96,23 @@ export default function ChatNotifierCard() { return ( Team Chat} + title={Team Chat} size="small" extra={ - } onClick={fetchChat} /> + } onClick={fetchChat} /> } - styles={{ body: { padding: '4px 12px 6px' } }} - style={{ height: '100%' }} + styles={{ body: { padding: '6px 14px 8px' } }} > {loading && !result ? ( - + ) : result && result.messages.length > 0 ? ( - + {result.messages.map(msg => ( ))} ) : ( - No recent messages + No recent messages )} ); diff --git a/admin/src/components/dashboard/DocsAnalyticsCard.tsx b/admin/src/components/dashboard/DocsAnalyticsCard.tsx new file mode 100644 index 00000000..f5b538a9 --- /dev/null +++ b/admin/src/components/dashboard/DocsAnalyticsCard.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchAnalytics = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Docs Analytics} + size="small" + extra={ + + navigate('/app/docs/analytics')} style={{ fontSize: 12, padding: 0 }}> + Full Report + + } onClick={fetchAnalytics} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !data ? ( + + ) : data ? ( + <> + + Page Views} + value={data.totalViews} + valueStyle={{ fontSize: 18 }} + /> + Sessions} + value={data.uniqueSessions} + valueStyle={{ fontSize: 18 }} + /> + Avg/Day} + value={avgPerDay} + valueStyle={{ fontSize: 18 }} + /> + + {topPages.length > 0 && ( + + Top Pages (30d) + {topPages.map((page, i) => ( + + + + + {page.path} + + + {page.views} + + ))} + + )} + > + ) : ( + No analytics data + )} + + ); +} diff --git a/admin/src/components/dashboard/DonationSummaryCard.tsx b/admin/src/components/dashboard/DonationSummaryCard.tsx new file mode 100644 index 00000000..29b3f1d6 --- /dev/null +++ b/admin/src/components/dashboard/DonationSummaryCard.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Payments} + size="small" + extra={ + + navigate('/app/payments')} style={{ fontSize: 12, padding: 0 }}> + Manage + + } onClick={fetchData} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !data ? ( + + ) : data ? ( + <> + + Revenue} + value={formatCents(data.totalRevenue)} + valueStyle={{ fontSize: 18 }} + /> + MRR} + value={formatCents(data.mrr)} + valueStyle={{ fontSize: 18, color: data.mrr > 0 ? '#52c41a' : undefined }} + /> + Subscribers} + value={data.activeSubscribers} + valueStyle={{ fontSize: 18 }} + /> + Donations} + value={data.donations?.totalDonations || 0} + valueStyle={{ fontSize: 18 }} + /> + + {recent.length > 0 && ( + + Recent + {recent.map(d => ( + + + {d.isAnonymous ? 'Anonymous' : d.buyerName || d.buyerEmail} + + + {formatCents(d.amountCAD)} + + {d.status} + + + + ))} + + )} + > + ) : ( + No payment data + )} + + ); +} diff --git a/admin/src/components/dashboard/NewsletterStatsCard.tsx b/admin/src/components/dashboard/NewsletterStatsCard.tsx new file mode 100644 index 00000000..a36601d9 --- /dev/null +++ b/admin/src/components/dashboard/NewsletterStatsCard.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Newsletter} + size="small" + extra={ + + navigate('/app/listmonk')} style={{ fontSize: 12, padding: 0 }}> + Manage + + } onClick={fetchData} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !data ? ( + + ) : lists.length > 0 ? ( + <> + + + {totalSubscribers.toLocaleString()} + total across {lists.length} lists + + + {lists.slice(0, 6).map((list, i) => ( + + + + {list.name} + + + {list.subscriberCount.toLocaleString()} + + ))} + + > + ) : ( + No lists configured + )} + + ); +} diff --git a/admin/src/components/dashboard/RecentCommentsCard.tsx b/admin/src/components/dashboard/RecentCommentsCard.tsx new file mode 100644 index 00000000..f2904110 --- /dev/null +++ b/admin/src/components/dashboard/RecentCommentsCard.tsx @@ -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 = { + safe: 'green', + pending: 'orange', + flagged: 'red', + unsafe: 'red', +}; + +function CommentRow({ comment }: { comment: DashboardRecentComment }) { + const navigate = useNavigate(); + const videoLabel = comment.videoTitle || comment.videoFilename; + + return ( + navigate(`/gallery/watch/${comment.videoId}`)} + style={{ + padding: '5px 0', + borderBottom: '1px solid rgba(255,255,255,0.04)', + lineHeight: 1.4, + cursor: 'pointer', + }} + > + + {comment.authorName || 'Anonymous'} + + + {comment.content} + + + + {videoLabel} + + + {comment.safetyStatus && comment.safetyStatus !== 'safe' && ( + + {comment.safetyStatus} + + )} + + {dayjs(comment.createdAt).fromNow(true)} + + + ); +} + +export default function RecentCommentsCard() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchComments = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Recent Comments} + size="small" + extra={ + + {result && result.pendingCount > 0 && ( + {result.pendingCount} pending + )} + } onClick={fetchComments} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !result ? ( + + ) : result && result.comments.length > 0 ? ( + + {result.comments.map(comment => ( + + ))} + + ) : ( + No comments yet + )} + + ); +} diff --git a/admin/src/components/dashboard/RecentSignupsCard.tsx b/admin/src/components/dashboard/RecentSignupsCard.tsx new file mode 100644 index 00000000..55237118 --- /dev/null +++ b/admin/src/components/dashboard/RecentSignupsCard.tsx @@ -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 = { + AUTHENTICATED: 'blue', + PUBLIC: 'green', + ADMIN: 'purple', +}; + +function SignupRow({ signup }: { signup: DashboardRecentSignup }) { + const displayName = signup.userName || signup.userEmail; + + return ( + + + + + {displayName} + + + + + {signup.shiftTitle || 'Shift'} + + + + {signup.signupSource === 'AUTHENTICATED' ? 'Auth' : signup.signupSource === 'PUBLIC' ? 'Public' : signup.signupSource} + + + {dayjs(signup.signupDate).fromNow(true)} + + + ); +} + +export default function RecentSignupsCard() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchSignups = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Recent Signups} + size="small" + extra={ + + {result && result.total > 0 && ( + {result.total} (14d) + )} + } onClick={fetchSignups} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !result ? ( + + ) : result && result.signups.length > 0 ? ( + + {result.signups.map(signup => ( + + ))} + + ) : ( + No recent signups + )} + + ); +} diff --git a/admin/src/components/dashboard/SystemAlertsCard.tsx b/admin/src/components/dashboard/SystemAlertsCard.tsx new file mode 100644 index 00000000..ebe1b49a --- /dev/null +++ b/admin/src/components/dashboard/SystemAlertsCard.tsx @@ -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 = { + critical: 'red', + warning: 'orange', + info: 'blue', +}; + +function AlertRow({ alert }: { alert: AlertInfo }) { + return ( + + + {alert.severity} + + + {alert.name} + + + {alert.summary} + + + {dayjs(alert.startsAt).fromNow(true)} + + + ); +} + +export default function SystemAlertsCard() { + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + + + Alerts + {data && data.critical > 0 && ( + + {data.critical} critical + + )} + + } + size="small" + extra={ + + navigate('/app/observability')} style={{ fontSize: 12, padding: 0 }}> + Monitor + + } onClick={fetchData} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !data ? ( + + ) : data && data.alerts.length > 0 ? ( + + {data.alerts.slice(0, 6).map(alert => ( + + ))} + + ) : ( + + + All systems nominal + + )} + + ); +} diff --git a/admin/src/components/dashboard/TodayEventsCard.tsx b/admin/src/components/dashboard/TodayEventsCard.tsx index c8a7c601..cb1d7cbf 100644 --- a/admin/src/components/dashboard/TodayEventsCard.tsx +++ b/admin/src/components/dashboard/TodayEventsCard.tsx @@ -24,30 +24,30 @@ function EventRow({ event }: { event: TodayEvent }) { return ( - - + + {timeStr} - + {event.title} {event.placeName && ( - - + + {event.placeName} )} {event.tags.length > 0 && ( - + {event.tags[0]} )} @@ -81,19 +81,18 @@ export default function TodayEventsCard() { return ( Today's Events} + title={Today's Events} size="small" extra={ - {result && {result.total} event{result.total !== 1 ? 's' : ''}} - } onClick={fetchEvents} /> + {result && {result.total} event{result.total !== 1 ? 's' : ''}} + } onClick={fetchEvents} /> } - styles={{ body: { padding: '4px 12px 6px' } }} - style={{ height: '100%' }} + styles={{ body: { padding: '6px 14px 8px' } }} > {loading && !result ? ( - + ) : result && result.events.length > 0 ? ( <> {result.events.map(event => ( @@ -101,7 +100,7 @@ export default function TodayEventsCard() { ))} > ) : ( - No events scheduled today + No events scheduled today )} ); diff --git a/admin/src/components/dashboard/TopVideosCard.tsx b/admin/src/components/dashboard/TopVideosCard.tsx new file mode 100644 index 00000000..81953e40 --- /dev/null +++ b/admin/src/components/dashboard/TopVideosCard.tsx @@ -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 ( + navigate(`/gallery/watch/${video.id}`)} + style={{ + padding: '5px 0', + borderBottom: '1px solid rgba(255,255,255,0.04)', + lineHeight: 1.4, + cursor: 'pointer', + }} + > + + {rank} + + + + {displayTitle} + + + {!video.isPublished && ( + Draft + )} + + {formatDuration(video.durationSeconds)} + + + + + + {video.viewCount} + + + {video.commentCount > 0 && ( + + + + {video.commentCount} + + + )} + {video.upvoteCount > 0 && ( + + + + {video.upvoteCount} + + + )} + + + ); +} + +export default function TopVideosCard() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchVideos = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Top Videos} + size="small" + extra={ + } onClick={fetchVideos} /> + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !result ? ( + + ) : result && result.videos.length > 0 ? ( + + {result.videos.map((video, i) => ( + + ))} + + ) : ( + No videos yet + )} + + ); +} diff --git a/admin/src/components/dashboard/UpcomingShiftsCard.tsx b/admin/src/components/dashboard/UpcomingShiftsCard.tsx new file mode 100644 index 00000000..c6413858 --- /dev/null +++ b/admin/src/components/dashboard/UpcomingShiftsCard.tsx @@ -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 ( + navigate('/app/map/shifts')} + style={{ + padding: '5px 0', + borderBottom: '1px solid rgba(255,255,255,0.04)', + lineHeight: 1.4, + cursor: 'pointer', + }} + > + + {dayjs(shift.date).format('MMM D')} + + + + {shift.title} + + + + + {shift.startTime} + + + = 100 ? '#52c41a' : fillPct >= 50 ? '#faad14' : '#1890ff'} + showInfo={false} + /> + + {shift.cutName && ( + + {shift.cutName} + + )} + + ); +} + +export default function UpcomingShiftsCard() { + const navigate = useNavigate(); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchShifts = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 ( + Upcoming Shifts} + size="small" + extra={ + + {result && result.total > 5 && ( + navigate('/app/map/shifts')} style={{ fontSize: 12, padding: 0 }}> + +{result.total - 5} more + + )} + } onClick={fetchShifts} /> + + } + styles={{ body: { padding: '6px 14px 8px' } }} + > + {loading && !result ? ( + + ) : result && result.shifts.length > 0 ? ( + + {result.shifts.map(shift => ( + + ))} + + ) : ( + No upcoming shifts + )} + + ); +} diff --git a/admin/src/components/media/AlbumCard.tsx b/admin/src/components/media/AlbumCard.tsx new file mode 100644 index 00000000..d49dc774 --- /dev/null +++ b/admin/src/components/media/AlbumCard.tsx @@ -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 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 ( + onClick?.(album)} + cover={ + + {coverUrl ? ( + + ) : ( + + + + )} + + {/* Photo count badge */} + + {album.photoCount || album._count?.photos || 0} + + } + style={{ position: 'absolute', bottom: 6, right: 6 }} + /> + + {/* Publish badge */} + {album.isPublished && ( + + + + )} + + } + > + + {album.title} + + } + description={ + + {album.photoCount || album._count?.photos || 0} photos + {album.viewCount > 0 ? ` · ${album.viewCount} views` : ''} + + } + /> + + ); +} diff --git a/admin/src/components/media/AlbumDetailDrawer.tsx b/admin/src/components/media/AlbumDetailDrawer.tsx new file mode 100644 index 00000000..8da93488 --- /dev/null +++ b/admin/src/components/media/AlbumDetailDrawer.tsx @@ -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 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(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 ( + + + }>Delete Album + + + {!album?.isPublished && ( + } onClick={handlePublish}> + Publish Album + + )} + + + } + > + {/* Editable title/description */} + + {editing ? ( + + setTitle(e.target.value)} placeholder="Album title" /> + setDescription(e.target.value)} placeholder="Description" rows={2} /> + + Save + setEditing(false)}>Cancel + + + ) : ( + + + + {album?.title} + {album?.isPublished && Published} + + setEditing(true)}>Edit + + {album?.description && {album.description}} + + {photos.length} photos · {album?.viewCount || 0} views · {album?.upvoteCount || 0} upvotes + + + )} + + + {/* Photo list */} + {photos.length === 0 ? ( + + ) : ( + ( + } + onClick={() => handleSetCover(photo.id)} + type={album?.coverPhotoId === photo.id ? 'primary' : 'default'} + > + {album?.coverPhotoId === photo.id ? 'Cover' : 'Set Cover'} + , + handleRemovePhoto(photo.id)} + > + } /> + , + ]} + > + + ) : ( + + + + ) + } + title={ + + {photo.title || photo.originalFilename} + + } + description={ + + {photo.width}×{photo.height} · {photo.format?.toUpperCase()} + {photo.isPublished && Published} + + } + /> + + )} + /> + )} + + ); +} diff --git a/admin/src/components/media/CreateAlbumModal.tsx b/admin/src/components/media/CreateAlbumModal.tsx new file mode 100644 index 00000000..16b96746 --- /dev/null +++ b/admin/src/components/media/CreateAlbumModal.tsx @@ -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 ( + + + + + + + + + + {selectedPhotoIds.length > 0 && ( + + {selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album + + )} + + ); +} diff --git a/admin/src/components/media/EditPhotoModal.tsx b/admin/src/components/media/EditPhotoModal.tsx new file mode 100644 index 00000000..ffe15443 --- /dev/null +++ b/admin/src/components/media/EditPhotoModal.tsx @@ -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 ( + + Cancel + Save + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + {/* EXIF Info (read-only) */} + {(photo.cameraMake || photo.cameraModel || photo.takenAt) && ( + + {photo.cameraMake && ( + {photo.cameraMake} {photo.cameraModel || ''} + )} + {photo.focalLength && ( + {photo.focalLength} + )} + {photo.aperture && ( + {photo.aperture} + )} + {photo.shutterSpeed && ( + {photo.shutterSpeed} + )} + {photo.iso && ( + {photo.iso} + )} + {photo.takenAt && ( + {new Date(photo.takenAt).toLocaleString()} + )} + {photo.gpsLatitude && photo.gpsLongitude && ( + + {photo.gpsLatitude.toFixed(6)}, {photo.gpsLongitude.toFixed(6)} + + )} + + )} + + {/* Metadata */} + + + {photo.width}×{photo.height} ({photo.orientation}) + + + {photo.format?.toUpperCase()} + {photo.hasAlpha ? ' (alpha)' : ''} + + {photo.colorSpace && ( + {photo.colorSpace} + )} + {photo.dpi && ( + {photo.dpi} + )} + + {photo.originalFilename} + + + + ); +} diff --git a/admin/src/components/media/ExpandedAlbumCard.tsx b/admin/src/components/media/ExpandedAlbumCard.tsx new file mode 100644 index 00000000..0fb77d04 --- /dev/null +++ b/admin/src/components/media/ExpandedAlbumCard.tsx @@ -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(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([]); + 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 ( + + {/* Image carousel */} + + {loading ? ( + + ) : currentPhoto ? ( + <> + {imageLoading && ( + + + + )} + setImageLoading(false)} + style={{ + maxWidth: '100%', + maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)', + objectFit: 'contain', + }} + /> + + {/* Left arrow */} + {currentIndex > 0 && ( + } + 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 && ( + } + 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 */} + + {currentIndex + 1} / {photos.length} + + > + ) : ( + No photos in this album + )} + + + {/* Thumbnail strip */} + {photos.length > 1 && ( + + {photos.map((p, idx) => ( + { + 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', + }} + > + + + ))} + + )} + + {/* Bottom info bar */} + + } + onClick={collapseVideo} + size="small" + style={{ flexShrink: 0 }} + /> + + + {album.title} + + + + + Album + + + {album.photoCount} photos + + + + + {formatCount(album.viewCount)} + + + : } + onClick={handleUpvote} + loading={upvoting} + disabled={hasUpvoted} + size="small" + style={{ flexShrink: 0 }} + > + {formatCount(upvoteCount)} + + + + ); +} diff --git a/admin/src/components/media/ExpandedPhotoCard.tsx b/admin/src/components/media/ExpandedPhotoCard.tsx new file mode 100644 index 00000000..a5381b17 --- /dev/null +++ b/admin/src/components/media/ExpandedPhotoCard.tsx @@ -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(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 ( + + {/* Image section */} + setZoomed(!zoomed)} + > + {imageLoading && ( + + + + )} + 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 */} + + {zoomed ? : } + {zoomed ? 'Click to zoom out' : 'Click to zoom in'} + + + + {/* Bottom info bar */} + + {/* Close button */} + } + onClick={collapseVideo} + size="small" + style={{ flexShrink: 0 }} + /> + + {/* Title */} + + {title} + + + {/* Tags */} + + + Photo + + {photo.format && ( + {photo.format.toUpperCase()} + )} + {photo.width && photo.height && ( + {photo.width}×{photo.height} + )} + + + + {formatCount(photo.viewCount)} + {formatCount(photo.commentCount)} + + + {/* Upvote */} + : } + onClick={handleUpvote} + loading={upvoting} + disabled={hasUpvoted} + size="small" + style={{ flexShrink: 0 }} + > + {formatCount(upvoteCount)} + + + + ); +} diff --git a/admin/src/components/media/MediaBottomNav.tsx b/admin/src/components/media/MediaBottomNav.tsx index 72b298b2..36023282 100644 --- a/admin/src/components/media/MediaBottomNav.tsx +++ b/admin/src/components/media/MediaBottomNav.tsx @@ -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 && ( + } + onClick={() => navigate('/campaigns')} + style={{ color: token.colorTextSecondary, flexShrink: 0 }} + > + {!isShorts && 'Action'} + + )} } diff --git a/admin/src/components/media/MediaSidebar.tsx b/admin/src/components/media/MediaSidebar.tsx index 703a5060..c7a85439 100644 --- a/admin/src/components/media/MediaSidebar.tsx +++ b/admin/src/components/media/MediaSidebar.tsx @@ -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: , path: '/gallery' }, { key: 'shorts', label: 'Shorts', icon: , path: '/gallery/shorts' }, { key: 'videos', label: 'Videos', icon: , path: '/gallery/videos' }, -{ key: 'curated', label: 'Curated', icon: , path: '/gallery/curated' }, + { key: 'photos', label: 'Photos', icon: , path: '/gallery/photos' }, + { key: 'curated', label: 'Curated', icon: , path: '/gallery/curated' }, { key: 'playback', label: 'Playback', icon: , path: '/gallery/playback' }, ]; @@ -204,7 +206,7 @@ export default function MediaSidebar() { color: 'rgba(255,255,255,0.45)', }} > - Video Platform + Media Platform )} diff --git a/admin/src/components/media/PhotoCard.tsx b/admin/src/components/media/PhotoCard.tsx new file mode 100644 index 00000000..48aaac20 --- /dev/null +++ b/admin/src/components/media/PhotoCard.tsx @@ -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 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 = ( + + {onEdit && ( + + { e.stopPropagation(); onEdit(photo); }} + /> + + )} + {onPreview && ( + + { e.stopPropagation(); onPreview(photo); }} + /> + + )} + {onDelete && ( + + { e.stopPropagation(); onDelete(photo); }} + /> + + )} + + ); + + return ( + onClick?.(photo)} + cover={ + + {thumbnailUrl ? ( + + ) : ( + + + + )} + + {/* Selection checkbox */} + {onSelect && ( + { e.stopPropagation(); onSelect(photo.id); }} + onClick={(e) => e.stopPropagation()} + style={{ position: 'absolute', top: 6, left: 6, zIndex: 2 }} + /> + )} + + {/* Publish toggle pill */} + {onTogglePublish && ( + { + 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 ? ( + <> + + Published + > + ) : ( + <> + + Draft + > + )} + + )} + + {/* Album badge */} + {photo.album && ( + + {photo.album.title} + + )} + + {/* Format badge */} + {photo.format && ( + + {photo.format.toUpperCase()} + + )} + + {hoverActions} + + } + > + + {photo.title || photo.originalFilename || photo.filename} + + } + description={ + + {photo.width && photo.height ? `${photo.width}×${photo.height}` : ''} + {photo.fileSize ? ` · ${formatFileSize(photo.fileSize)}` : ''} + + } + /> + + + ); +} diff --git a/admin/src/components/media/PhotoViewerModal.tsx b/admin/src/components/media/PhotoViewerModal.tsx new file mode 100644 index 00000000..2d4070f4 --- /dev/null +++ b/admin/src/components/media/PhotoViewerModal.tsx @@ -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 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 ( + + + {/* Image */} + + + + + {/* Info panel */} + + + {photo.title || photo.originalFilename || photo.filename} + + + + {photo.format && {photo.format.toUpperCase()}} + {photo.width && photo.height && {photo.width}×{photo.height}} + {photo.orientation && {photo.orientation === 'H' ? 'Landscape' : photo.orientation === 'V' ? 'Portrait' : 'Square'}} + {photo.isPublished && Published} + + + {(photo.cameraMake || photo.cameraModel) && ( + + {(photo.cameraMake || photo.cameraModel) && ( + Camera>}> + {[photo.cameraMake, photo.cameraModel].filter(Boolean).join(' ')} + + )} + {photo.focalLength && ( + {photo.focalLength} + )} + {photo.aperture && ( + {photo.aperture} + )} + {photo.shutterSpeed && ( + {photo.shutterSpeed} + )} + {photo.iso && ( + {photo.iso} + )} + {photo.takenAt && ( + {new Date(photo.takenAt).toLocaleString()} + )} + + )} + + {photo.description && ( + {photo.description} + )} + + + + ); +} diff --git a/admin/src/components/media/PublicAlbumCard.tsx b/admin/src/components/media/PublicAlbumCard.tsx new file mode 100644 index 00000000..a1f70dc0 --- /dev/null +++ b/admin/src/components/media/PublicAlbumCard.tsx @@ -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) => { + const card = e.currentTarget; + card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`; + card.style.transform = 'translateY(-2px)'; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + const card = e.currentTarget; + card.style.boxShadow = 'none'; + card.style.transform = 'translateY(0)'; + }; + + return ( + + {/* Cover thumbnail */} + {album.coverThumbnailUrl && !thumbnailError ? ( + setThumbnailError(true)} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + ) : ( + + + + )} + + {/* Stacked photos effect overlay */} + + + {/* Expand overlay on hover */} + { e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }} + > + { e.currentTarget.style.transform = 'scale(1.1)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} + > + + + + + {/* Photo count badge */} + + + {album.photoCount} photos + + + {/* Category badge */} + {album.category && ( + + {album.category} + + )} + + {/* Album indicator */} + + Album + + + } + > + + + {album.title} + + + + + + {formatCount(album.upvoteCount)} + + + + {formatCount(album.viewCount)} + + + + {album.photoCount} + + + + + ); +} diff --git a/admin/src/components/media/PublicPhotoCard.tsx b/admin/src/components/media/PublicPhotoCard.tsx new file mode 100644 index 00000000..aaf1c4b1 --- /dev/null +++ b/admin/src/components/media/PublicPhotoCard.tsx @@ -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) => { + const card = e.currentTarget; + card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`; + card.style.transform = 'translateY(-2px)'; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + 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 ( + + {/* Thumbnail */} + {photo.thumbnailUrl && !thumbnailError ? ( + setThumbnailError(true)} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + ) : ( + + + + )} + + {/* Camera icon overlay on hover */} + { e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }} + > + { e.currentTarget.style.transform = 'scale(1.1)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} + > + + + + + {/* Format badge */} + {photo.format && ( + + {photo.format} + + )} + + {/* Category badge */} + {photo.category && ( + + {photo.category} + + )} + + {/* Dimensions badge */} + {photo.width && photo.height && ( + + {photo.width}×{photo.height} + + )} + + } + > + + + {title} + + + + + + {formatCount(photo.upvoteCount)} + + + + {formatCount(photo.viewCount)} + + + + {formatCount(photo.commentCount)} + + + + + ); +} diff --git a/admin/src/components/media/UploadPhotoDrawer.tsx b/admin/src/components/media/UploadPhotoDrawer.tsx new file mode 100644 index 00000000..fe798947 --- /dev/null +++ b/admin/src/components/media/UploadPhotoDrawer.tsx @@ -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([]); + const [albums, setAlbums] = useState([]); + const [fileList, setFileList] = useState([]); + + 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 ( + + Cancel + + Upload {fileList.length > 0 ? `(${fileList.length})` : ''} + + + } + > + + false} + fileList={fileList} + onChange={({ fileList: fl }) => setFileList(fl)} + style={{ marginBottom: 16 }} + > + + + + Click or drag photos here + + JPG, PNG, WebP, AVIF, GIF, TIFF, HEIC + + + + {fileList.length === 1 && ( + + + + )} + + + + + + + + + + + ({ + value: a.id, + label: `${a.title} (${a.photoCount} photos)`, + }))} + /> + + + + {uploading && } + + {results.length > 0 && ( + ( + + {item.success ? ( + } color="success">{item.filename} + ) : ( + } color="error"> + {item.filename}: {item.error} + + )} + + )} + /> + )} + + ); +} diff --git a/admin/src/contexts/ExpandedVideoContext.tsx b/admin/src/contexts/ExpandedVideoContext.tsx index c99c93b4..98514f19 100644 --- a/admin/src/contexts/ExpandedVideoContext.tsx +++ b/admin/src/contexts/ExpandedVideoContext.tsx @@ -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({ 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, }; diff --git a/admin/src/pages/CanvassDashboardPage.tsx b/admin/src/pages/CanvassDashboardPage.tsx index 90245615..4dc66626 100644 --- a/admin/src/pages/CanvassDashboardPage.tsx +++ b/admin/src/pages/CanvassDashboardPage.tsx @@ -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(); @@ -350,6 +351,11 @@ export default function CanvassDashboardPage() { )} + {/* Outcome Trends */} + + + + {/* Historical Routes Drawer */} * { 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() { {activeView === 'dashboard' && ( - {showInfluence && } onClick={() => navigate('/app/campaigns')} />} - {showMap && } onClick={() => navigate('/app/map')} />} - {showMedia && } onClick={() => navigate('/app/media/library')} />} - } onClick={() => navigate('/app/pages')} /> + {showInfluence && } onClick={() => navigate('/app/campaigns')} />} + {showMap && } onClick={() => navigate('/app/map')} />} + {showMedia && } onClick={() => navigate('/app/media/library')} />} + } onClick={() => navigate('/app/pages')} /> {isSuperAdmin && ( <> - } onClick={() => navigate('/app/observability')} /> - } onClick={() => navigate('/app/tunnel')} /> - } onClick={() => navigate('/app/services/nocodb')} /> - } onClick={() => navigate('/app/services/n8n')} /> - } onClick={() => navigate('/app/services/gitea')} /> - } onClick={() => navigate('/app/code')} /> - } onClick={() => navigate('/app/docs')} /> - } onClick={() => navigate('/app/services/miniqr')} /> - } onClick={() => navigate('/app/map/data-quality')} /> + } onClick={() => navigate('/app/observability')} /> + } onClick={() => navigate('/app/tunnel')} /> + } onClick={() => navigate('/app/services/nocodb')} /> + } onClick={() => navigate('/app/services/n8n')} /> + } onClick={() => navigate('/app/services/gitea')} /> + } onClick={() => navigate('/app/code')} /> + } onClick={() => navigate('/app/docs')} /> + } onClick={() => navigate('/app/services/miniqr')} /> + } onClick={() => navigate('/app/map/data-quality')} /> > )} - } onClick={handleRefresh} /> + } onClick={handleRefresh} /> )} {activeView === 'homepage' && ( - } onClick={handleRefresh} /> + } onClick={handleRefresh} /> )} @@ -400,14 +423,14 @@ export default function DashboardPage() { {/* === Status Bar (weather + stats + pending actions + connectivity) === */} {summary && ( - + {weather && ( - - {getWeatherIcon(weather.weatherCode, weather.isDay)} - {Math.round(weather.temperature)}°C - {weather.weatherDescription} + + {getWeatherIcon(weather.weatherCode, weather.isDay)} + {Math.round(weather.temperature)}°C + {weather.weatherDescription} )} {/* Quick stat chips */} @@ -480,110 +503,107 @@ export default function DashboardPage() { )} - {/* === Module Overview Row (3 columns) === */} - + {/* === Dashboard Cards (masonry layout for dense packing) === */} + {showInfluence && ( - + - - Influence - {summary && {summary.campaigns.active} active / {summary.campaigns.total}} + + Influence + {summary && {summary.campaigns.active} active / {summary.campaigns.total}} } size="small" - extra={ navigate('/app/campaigns')}>View} - style={{ height: '100%' }} + extra={ navigate('/app/campaigns')}>View} > {summary && ( - + {summary.campaigns.active} Active {summary.campaigns.draft} Draft {summary.campaigns.paused > 0 && {summary.campaigns.paused} Paused} - Responses: {summary.responses.total} + Responses: {summary.responses.total} {summary.responses.pending > 0 && {summary.responses.pending} pending} - Emails: {summary.emails.sent} sent + Emails: {summary.emails.sent} sent {summary.emails.failed > 0 && {summary.emails.failed} failed} {queue && ( - Queue: {queue.waiting} waiting + Queue: {queue.waiting} waiting {queue.paused && Paused} )} {summary.campaigns.total > 0 && screens.md && ( - - + + )} )} - + )} {showMap && ( - + - - Map - {summary && {summary.locations.total.toLocaleString()} locations} + + Map + {summary && {summary.locations.total.toLocaleString()} locations} } size="small" - extra={ navigate('/app/map')}>View} - style={{ height: '100%' }} + extra={ navigate('/app/map')}>View} > {summary && ( - + - Geocoded: + Geocoded: - {summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()} + {summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()} - Addresses: {summary.locations.addresses.toLocaleString()} - {summary.cuts.total} cuts + Addresses: {summary.locations.addresses.toLocaleString()} + {summary.cuts.total} cuts - Canvassing: {summary.canvass.totalVisits} visits + Canvassing: {summary.canvass.totalVisits} visits {summary.canvass.activeSessions > 0 && {summary.canvass.activeSessions} active} - Shifts: {summary.shifts.upcoming} upcoming - {summary.shifts.open} open + Shifts: {summary.shifts.upcoming} upcoming + {summary.shifts.open} open )} - + )} - + - - Users & Content - {summary && {summary.users.total} users} + + Users & Content + {summary && {summary.users.total} users} } size="small" - extra={ navigate('/app/users')}>Manage} - style={{ height: '100%' }} + extra={ navigate('/app/users')}>Manage} > {summary && ( - + {Object.entries(summary.users.byRole) .filter(([, count]) => count > 0) @@ -595,41 +615,90 @@ export default function DashboardPage() { {summary.users.suspended > 0 && Suspended: {summary.users.suspended}} - Pages: {summary.pages.published} published - / {summary.pages.total} + Pages: {summary.pages.published} published + / {summary.pages.total} - Templates: {summary.emailTemplates.total} - {showInfluence && Reps: {summary.representatives.totalCached}} + Templates: {summary.emailTemplates.total} + {showInfluence && Reps: {summary.representatives.totalCached}} {showMedia && ( - Videos: {summary.videos.published} published - / {summary.videos.total} + Videos: {summary.videos.published} published + / {summary.videos.total} )} )} - - + - {/* === Activity Feed + Events + Chat === */} - - + - - - - - - - - - - - - + + {showEvents && ( + + + + )} + + + + {showChat && ( + + + + )} + {showMap && ( + + + + )} + {showInfluence && ( + + + + )} + {showMap && ( + + + + )} + {showMedia && ( + + + + )} + {showMedia && ( + + + + )} + {showNewsletter && isSuperAdmin && ( + + + + )} + {showPayments && isSuperAdmin && ( + + + + )} + {isSuperAdmin && ( + + + + )} + + + {/* === Canvass Progress (full-width table) === */} + {showMap && ( + + + + + + )} {/* === System + Docker Section (SUPER_ADMIN only) === */} {isSuperAdmin && ( @@ -944,17 +1013,17 @@ function QuickStat({ icon, color, value, label, onClick }: { return ( - {icon} - {value} - {label} + {icon} + {value} + {label} ); } diff --git a/admin/src/pages/media/LibraryPage.tsx b/admin/src/pages/media/LibraryPage.tsx index e14615d9..783f6f00 100644 --- a/admin/src/pages/media/LibraryPage.tsx +++ b/admin/src/pages/media/LibraryPage.tsx @@ -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(); + const [mediaTab, setMediaTab] = useLocalStorage('libraryMediaTab', 'Videos'); + + // === Video state === const [videos, setVideos] = useState([]); - 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([]); - const [producers, setProducers] = useState([]); - const [viewMode, setViewMode] = useLocalStorage<'grid' | 'compact'>('mediaViewMode', 'grid'); + const [videoPagination, setVideoPagination] = useState({ page: 1, limit: 48, total: 0 }); const [selectedVideoIds, setSelectedVideoIds] = useState([]); - 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(null); const [analyticsVideoId, setAnalyticsVideoId] = useState(null); const [analyticsVideoTitle, setAnalyticsVideoTitle] = useState(''); @@ -62,58 +67,140 @@ export default function LibraryPage() { const [playlistVideoId, setPlaylistVideoId] = useState(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([]); + const [photoPagination, setPhotoPagination] = useState({ page: 1, limit: 48, total: 0 }); + const [selectedPhotoIds, setSelectedPhotoIds] = useState([]); + const [viewerPhoto, setViewerPhoto] = useState(null); + const [editPhoto, setEditPhoto] = useState(null); + const [uploadPhotoOpen, setUploadPhotoOpen] = useState(false); + const [createAlbumOpen, setCreateAlbumOpen] = useState(false); + const [formatFilter, setFormatFilter] = useState(); + const [photoFormats, setPhotoFormats] = useState([]); + + // === Album state === + const [albums, setAlbums] = useState([]); + const [albumPagination, setAlbumPagination] = useState({ page: 1, limit: 48, total: 0 }); + const [selectedAlbumId, setSelectedAlbumId] = useState(null); + + // === Shared state === + const [search, setSearch] = useState(''); + const [debouncedSearch] = useDebounce(search, 300); + const [orientation, setOrientation] = useState<'H' | 'V' | 'S' | undefined>(); + const [selectedProducers, setSelectedProducers] = useState([]); + const [producers, setProducers] = useState([]); + 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('/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('/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('/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('/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 ( + {/* Media Type Toggle */} + + , label: 'Videos' }, + { value: 'Photos', icon: , label: 'Photos' }, + { value: 'Albums', icon: , label: 'Albums' }, + ]} + /> + + {/* Toolbar */} } value={search} onChange={(e) => setSearch(e.target.value)} allowClear style={{ width: 200 }} /> - - ({ value: p, label: p }))} - value={selectedProducers} - onChange={setSelectedProducers} - style={{ width: 140 }} - maxTagCount={1} - /> - setShortsFilter(v === undefined ? undefined : v === 'true')} - allowClear - style={{ width: 110 }} - /> + + {/* Orientation filter (Videos + Photos) */} + {mediaTab !== 'Albums' && ( + + )} + + {/* Producer filter (Videos + Photos) */} + {mediaTab !== 'Albums' && ( + ({ value: p, label: p }))} + value={selectedProducers} + onChange={setSelectedProducers} + style={{ width: 140 }} + maxTagCount={1} + /> + )} + + {/* Video-specific: Shorts filter */} + {mediaTab === 'Videos' && ( + setShortsFilter(v === undefined ? undefined : v === 'true')} + allowClear + style={{ width: 110 }} + /> + )} + + {/* Photo-specific: Format filter */} + {mediaTab === 'Photos' && photoFormats.length > 0 && ( + ({ value: f, label: f.toUpperCase() }))} + value={formatFilter} + onChange={setFormatFilter} + allowClear + style={{ width: 100 }} + /> + )} + - } - onClick={() => setUploadModalOpen(true)} - > - Upload - - - } onClick={() => setFetchDrawerOpen(true)} /> - - - } onClick={handleScanShorts} loading={scanningShorts} /> - - - } onClick={() => setCalendarModalOpen(true)} /> - + + {/* Upload button */} + {mediaTab === 'Videos' && ( + <> + } onClick={() => setUploadVideoOpen(true)}>Upload + + } onClick={() => setFetchDrawerOpen(true)} /> + + + } onClick={handleScanShorts} loading={scanningShorts} /> + + + } onClick={() => setCalendarModalOpen(true)} /> + + > + )} + {mediaTab === 'Photos' && ( + <> + } onClick={() => setUploadPhotoOpen(true)}>Upload + {selectedPhotoIds.length >= 2 && ( + + } onClick={() => setCreateAlbumOpen(true)}>Album + + )} + > + )} + {mediaTab === 'Albums' && ( + } onClick={() => setCreateAlbumOpen(true)}> + New Album + + )} + : } onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')} /> - - {selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'} - + + {/* Select All (Videos + Photos) */} + {mediaTab === 'Videos' && ( + + {selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'} + + )} + {mediaTab === 'Photos' && ( + + {selectedPhotoIds.length === photos.length && photos.length > 0 ? 'Deselect' : 'Select All'} + + )} {/* Stats */} - {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`} - {/* Video Grid */} + {/* Content Grid */} {loading ? ( - ) : videos.length === 0 ? ( - ) : ( <> - - {videos.map((video) => ( - - setViewerVideo(v)} - onEdit={handleEdit} - onPreview={(v) => setViewerVideo(v)} - onAnalytics={handleAnalytics} - onSchedule={handleSchedule} - onDelete={handleDeleteSingle} - onAddToPlaylist={(v) => setPlaylistVideoId(v.id)} - onRefresh={fetchVideos} - onTogglePublish={handleTogglePublish} - /> - - ))} - + {/* === Videos Tab === */} + {mediaTab === 'Videos' && ( + videos.length === 0 ? ( + + ) : ( + + {videos.map((video) => ( + + 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} + /> + + ))} + + ) + )} - 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 ? ( + + ) : ( + + {photos.map((photo) => ( + + setViewerPhoto(p)} + onEdit={(p) => setEditPhoto(p)} + onPreview={(p) => setViewerPhoto(p)} + onDelete={handleDeleteSinglePhoto} + onTogglePublish={handleTogglePhotoPublish} + /> + + ))} + + ) + )} + + {/* === Albums Tab === */} + {mediaTab === 'Albums' && ( + albums.length === 0 ? ( + + ) : ( + + {albums.map((album) => ( + + setSelectedAlbumId(a.id)} + /> + + ))} + + ) + )} + + {/* Pagination */} + {activePagination.total > activePagination.limit && ( + { + 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 */} - , - onClick: () => setPublishModalOpen(true), - type: 'primary', - }, - { - key: 'access-level', - label: 'Access Level', - icon: , - onClick: () => setBulkAccessLevelOpen(true), - }, - { - key: 'add-to-playlist', - label: 'Add to Playlist', - icon: , - onClick: () => setBulkPlaylistOpen(true), - }, - { - key: 'delete', - label: 'Delete', - icon: , - onClick: () => setDeleteModalOpen(true), - danger: true, - }, - ]} - /> + {/* === Video Bulk Actions === */} + {mediaTab === 'Videos' && ( + , onClick: () => setPublishModalOpen(true), type: 'primary' }, + { key: 'access-level', label: 'Access Level', icon: , onClick: () => setBulkAccessLevelOpen(true) }, + { key: 'add-to-playlist', label: 'Add to Playlist', icon: , onClick: () => setBulkPlaylistOpen(true) }, + { key: 'delete', label: 'Delete', icon: , onClick: () => setDeleteModalOpen(true), danger: true }, + ]} + /> + )} - {/* Modals */} + {/* === Photo Bulk Actions === */} + {mediaTab === 'Photos' && selectedPhotoIds.length > 0 && ( + , onClick: handleBulkPhotoPublish, type: 'primary' }, + { key: 'create-album', label: 'Create Album', icon: , onClick: () => setCreateAlbumOpen(true) }, + { key: 'delete', label: 'Delete', icon: , onClick: handleBulkPhotoDelete, danger: true }, + ]} + /> + )} + + {/* === Video Modals === */} setUploadModalOpen(false)} - onSuccess={handleUploadSuccess} + open={uploadVideoOpen} + onClose={() => setUploadVideoOpen(false)} + onSuccess={() => { setUploadVideoOpen(false); fetchVideos(); fetchProducers(); }} /> { setPublishModalOpen(false); setSelectedVideoIds([]); fetchVideos(); }} onCancel={() => setPublishModalOpen(false)} /> 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(); }} /> setEditVideo(null)} - onSuccess={handleEditSuccess} + onSuccess={() => { setEditVideo(null); fetchVideos(); fetchProducers(); }} /> setBulkAccessLevelOpen(false)} - onSuccess={() => { - setBulkAccessLevelOpen(false); - setSelectedVideoIds([]); - fetchVideos(); - }} + onSuccess={() => { setBulkAccessLevelOpen(false); setSelectedVideoIds([]); fetchVideos(); }} /> setBulkPlaylistOpen(false)} + onSuccess={() => { setBulkPlaylistOpen(false); setSelectedVideoIds([]); }} + /> + + {/* === Photo Modals === */} + setUploadPhotoOpen(false)} + onSuccess={() => { setUploadPhotoOpen(false); fetchPhotos(); fetchPhotoFormats(); }} + /> + + setViewerPhoto(null)} + /> + + setEditPhoto(null)} + onSuccess={() => { setEditPhoto(null); fetchPhotos(); }} + /> + + setCreateAlbumOpen(false)} onSuccess={() => { - setBulkPlaylistOpen(false); - setSelectedVideoIds([]); + setCreateAlbumOpen(false); + setSelectedPhotoIds([]); + fetchPhotos(); + fetchAlbums(); }} + selectedPhotoIds={selectedPhotoIds} + /> + + setSelectedAlbumId(null)} + onRefresh={fetchAlbums} /> ); diff --git a/admin/src/pages/public/CampaignPage.tsx b/admin/src/pages/public/CampaignPage.tsx index 06c35e9e..e45b7c7f 100644 --- a/admin/src/pages/public/CampaignPage.tsx +++ b/admin/src/pages/public/CampaignPage.tsx @@ -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() { )} + {siteSettings?.enableMap !== false && ( + + } style={{ borderColor: '#52c41a', color: '#52c41a' }}> + Volunteer for a Shift + + + )} }> Start Your Own Campaign + {siteSettings?.enableMediaFeatures !== false && ( + + } style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}> + Watch Our Content + + + )} } onClick={handleCopyLink} diff --git a/admin/src/pages/public/MediaGalleryPage.tsx b/admin/src/pages/public/MediaGalleryPage.tsx index 44cc5796..2a0d097c 100644 --- a/admin/src/pages/public/MediaGalleryPage.tsx +++ b/admin/src/pages/public/MediaGalleryPage.tsx @@ -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([]); + + // Unified gallery state (photos mode or all mode) + const [galleryItems, setGalleryItems] = useState([]); + const [ads, setAds] = useState([]); const [loading, setLoading] = useState(true); const [pagination, setPagination] = useState({ @@ -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('/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 ; + } + + 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 ; + } + return ; + } + + if (item.type === 'album') { + const album = item.data as PublicAlbum; + if (expandedState.mediaType === 'album' && expandedState.mediaData?.id === album.id) { + return ; + } + return ; + } + + // Default: video + const video = item.data as Video; + if (expandedState.videoId === video.id) { + return ; + } + return ; + }; return ( - {/* Featured Playlists Carousel — only on main gallery page */} + {/* Featured Playlists Carousel — only on main gallery page (not photos tab) */} {!urlCategory && !search && } - {/* Loading State */} {loading && ( )} - {/* Empty State */} - {!loading && videos.length === 0 && ( + {!loading && totalItems === 0 && ( )} - {/* Video Grid (with ads interleaved) */} - {!loading && videos.length > 0 && ( + {!loading && totalItems > 0 && ( <> - {gridItems.map((item: GridItem) => - item.type === 'ad' ? ( - - ) : expandedState.videoId === item.data.id ? ( - - ) : ( - - ) - )} + {gridItems.map(renderGridItem)} - {/* Pagination — still based on video count only */} {pagination.total > pagination.limit && ( `Total ${total} videos`} + showTotal={(total) => `Total ${total} ${emptyLabel}`} /> )} diff --git a/admin/src/pages/public/MediaViewerPage.tsx b/admin/src/pages/public/MediaViewerPage.tsx index 22bb84d5..bed45323 100644 --- a/admin/src/pages/public/MediaViewerPage.tsx +++ b/admin/src/pages/public/MediaViewerPage.tsx @@ -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() { )} + {/* Cross-site CTA */} + {settings?.enableInfluence !== false && ( + <> + + + + Want to take action? + + + Join an active campaign and make your voice heard. + + + + }> + View Campaigns + + + + > + )} + {/* Comments section */} diff --git a/admin/src/pages/public/QuickJoinPage.tsx b/admin/src/pages/public/QuickJoinPage.tsx new file mode 100644 index 00000000..8d5896d7 --- /dev/null +++ b/admin/src/pages/public/QuickJoinPage.tsx @@ -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(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 ( + + + + } + onClick={() => window.location.href = '/campaigns'} + > + Return to Site + + } + /> + + + + ); + } + + return ( + + + + + + + + Join as Volunteer + + + {settings?.organizationName ?? 'Changemaker Lite'} + + + + {error && ( + + {error} + + )} + + + + } placeholder="Your email" autoFocus /> + + + + } placeholder="Your name (optional)" /> + + + + } placeholder="Phone (optional)" /> + + + + + Join & Start Canvassing + + + + + + You'll get temporary 24-hour access to the canvassing app. + + + } onClick={() => window.location.href = '/campaigns'}> + Browse Site + + + + + + + ); +} diff --git a/admin/src/pages/volunteer/VolunteerMapPage.tsx b/admin/src/pages/volunteer/VolunteerMapPage.tsx index 31c70f50..39b2f29c 100644 --- a/admin/src/pages/volunteer/VolunteerMapPage.tsx +++ b/admin/src/pages/volunteer/VolunteerMapPage.tsx @@ -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 */} diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index b24c3d2f..93120d8b 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -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; +} diff --git a/admin/src/types/canvass.ts b/admin/src/types/canvass.ts index 5b6eb855..4dc7b659 100644 --- a/admin/src/types/canvass.ts +++ b/admin/src/types/canvass.ts @@ -205,3 +205,15 @@ export interface VolunteerSummary { sessions: number; lastActive: string | null; } + +// --- Outcome Trends --- + +export type CanvassOutcomeTrendPoint = { date: string } & Partial>; + +export interface CanvassOutcomeTrendsData { + granularity: 'day' | 'week'; + dateFrom: string; + dateTo: string; + series: CanvassOutcomeTrendPoint[]; + totals: Partial>; +} diff --git a/admin/src/types/media.ts b/admin/src/types/media.ts index b497880d..e2b942be 100644 --- a/admin/src/types/media.ts +++ b/admin/src/types/media.ts @@ -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; +} diff --git a/admin/src/utils/galleryAdMerge.ts b/admin/src/utils/galleryAdMerge.ts index 95f05ed1..f9347cb6 100644 --- a/admin/src/utils/galleryAdMerge.ts +++ b/admin/src/utils/galleryAdMerge.ts @@ -1,7 +1,9 @@ import type { GalleryAd } from '@/types/gallery-ads'; -export type GridItem = +export type GridItem = | { type: 'video'; data: T } + | { type: 'photo'; data: any } + | { type: 'album'; data: any } | { type: 'ad'; data: GalleryAd }; /** @@ -68,3 +70,50 @@ export function mergeAdsIntoGrid( 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(); + const usedSlots = new Set(); + + 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; +} diff --git a/api/Dockerfile.media b/api/Dockerfile.media index 67af03eb..5e230d3c 100644 --- a/api/Dockerfile.media +++ b/api/Dockerfile.media @@ -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 diff --git a/api/package-lock.json b/api/package-lock.json index 3617f2d2..fc2dfb6c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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", diff --git a/api/package.json b/api/package.json index c5744358..5b62706f 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql b/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql new file mode 100644 index 00000000..883f9e65 --- /dev/null +++ b/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserCreatedVia" ADD VALUE IF NOT EXISTS 'QUICK_JOIN_INVITE'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index c0e841ea..e294446b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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") +} diff --git a/api/src/media-server.ts b/api/src/media-server.ts index bca70b9b..de16fd54 100644 --- a/api/src/media-server.ts +++ b/api/src/media-server.ts @@ -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'; diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts index 757daf07..da65c957 100644 --- a/api/src/middleware/rate-limit.ts +++ b/api/src/middleware/rate-limit.ts @@ -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, + 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 diff --git a/api/src/modules/auth/auth.service.ts b/api/src/modules/auth/auth.service.ts index e170ed5f..7f9b90f3 100644 --- a/api/src/modules/auth/auth.service.ts +++ b/api/src/modules/auth/auth.service.ts @@ -18,7 +18,7 @@ interface TokenPayload { roles: UserRole[]; } -interface TokenPair { +export interface TokenPair { accessToken: string; refreshToken: string; } diff --git a/api/src/modules/dashboard/dashboard.routes.ts b/api/src/modules/dashboard/dashboard.routes.ts index 07ab0d54..b0972463 100644 --- a/api/src/modules/dashboard/dashboard.routes.ts +++ b/api/src/modules/dashboard/dashboard.routes.ts @@ -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; diff --git a/api/src/modules/dashboard/dashboard.service.ts b/api/src/modules/dashboard/dashboard.service.ts index f4a44bd9..543b3a83 100644 --- a/api/src/modules/dashboard/dashboard.service.ts +++ b/api/src/modules/dashboard/dashboard.service.ts @@ -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 { 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 { } } +// --- 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/api/src/modules/map/canvass/canvass.routes.ts b/api/src/modules/map/canvass/canvass.routes.ts index 6d0e0c7c..050b98ab 100644 --- a/api/src/modules/map/canvass/canvass.routes.ts +++ b/api/src/modules/map/canvass/canvass.routes.ts @@ -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 }; diff --git a/api/src/modules/map/canvass/canvass.schemas.ts b/api/src/modules/map/canvass/canvass.schemas.ts index 6c45fe86..40723383 100644 --- a/api/src/modules/map/canvass/canvass.schemas.ts +++ b/api/src/modules/map/canvass/canvass.schemas.ts @@ -101,5 +101,12 @@ export type WalkingRouteInput = z.infer; export type ListMyVisitsInput = z.infer; export type AdminActivityInput = z.infer; export type AdminVisitsInput = z.infer; +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; export type VolunteerCreateLocationInput = z.infer; +export type OutcomeTrendsQueryInput = z.infer; diff --git a/api/src/modules/map/canvass/canvass.service.ts b/api/src/modules/map/canvass/canvass.service.ts index 24ff346e..48cf63fb 100644 --- a/api/src/modules/map/canvass/canvass.service.ts +++ b/api/src/modules/map/canvass/canvass.service.ts @@ -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>(); + const totals: Record = {}; + + 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) { diff --git a/api/src/modules/media/middleware/auth.ts b/api/src/modules/media/middleware/auth.ts index fdfbe3fa..66cfa1eb 100644 --- a/api/src/modules/media/middleware/auth.ts +++ b/api/src/modules/media/middleware/auth.ts @@ -33,16 +33,23 @@ export async function authenticate( reply: FastifyReply ): Promise { const authHeader = request.headers.authorization; + const queryToken = (request.query as Record)?.token; - if (!authHeader?.startsWith('Bearer ')) { + // Support both Authorization header and ?token= query param (for / 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 { const authHeader = request.headers.authorization; + const queryToken = (request.query as Record)?.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; diff --git a/api/src/modules/media/routes/photo-albums.routes.ts b/api/src/modules/media/routes/photo-albums.routes.ts new file mode 100644 index 00000000..1945281d --- /dev/null +++ b/api/src/modules/media/routes/photo-albums.routes.ts @@ -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' }; + } + ); +} diff --git a/api/src/modules/media/routes/photo-engagement.routes.ts b/api/src/modules/media/routes/photo-engagement.routes.ts new file mode 100644 index 00000000..88d60206 --- /dev/null +++ b/api/src/modules/media/routes/photo-engagement.routes.ts @@ -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' }; + } + ); +} diff --git a/api/src/modules/media/routes/photo-upload.routes.ts b/api/src/modules/media/routes/photo-upload.routes.ts new file mode 100644 index 00000000..766cff3b --- /dev/null +++ b/api/src/modules/media/routes/photo-upload.routes.ts @@ -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; + 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', + }); + } + } + ); +} diff --git a/api/src/modules/media/routes/photos-public.routes.ts b/api/src/modules/media/routes/photos-public.routes.ts new file mode 100644 index 00000000..3e520944 --- /dev/null +++ b/api/src/modules/media/routes/photos-public.routes.ts @@ -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, + }, + }; + } + ); +} diff --git a/api/src/modules/media/routes/photos.routes.ts b/api/src/modules/media/routes/photos.routes.ts new file mode 100644 index 00000000..e7d702d1 --- /dev/null +++ b/api/src/modules/media/routes/photos.routes.ts @@ -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 }; + } + ); +} diff --git a/api/src/modules/media/services/photo-processing.service.ts b/api/src/modules/media/services/photo-processing.service.ts new file mode 100644 index 00000000..1bb6fa2e --- /dev/null +++ b/api/src/modules/media/services/photo-processing.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/api/src/modules/volunteer-invite/volunteer-invite.routes.ts b/api/src/modules/volunteer-invite/volunteer-invite.routes.ts new file mode 100644 index 00000000..823d62ce --- /dev/null +++ b/api/src/modules/volunteer-invite/volunteer-invite.routes.ts @@ -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 }; diff --git a/api/src/modules/volunteer-invite/volunteer-invite.schemas.ts b/api/src/modules/volunteer-invite/volunteer-invite.schemas.ts new file mode 100644 index 00000000..2f640a8e --- /dev/null +++ b/api/src/modules/volunteer-invite/volunteer-invite.schemas.ts @@ -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; +export type RedeemInviteInput = z.infer; diff --git a/api/src/modules/volunteer-invite/volunteer-invite.service.ts b/api/src/modules/volunteer-invite/volunteer-invite.service.ts new file mode 100644 index 00000000..641f8266 --- /dev/null +++ b/api/src/modules/volunteer-invite/volunteer-invite.service.ts @@ -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 }; + }, +}; diff --git a/api/src/server.ts b/api/src/server.ts index c98fec6a..7dc30eac 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml index f72e93c9..6446ca2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0d8c30eb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "changemaker.lite", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{}
{photo.description}
+ +
Click or drag photos here
+ JPG, PNG, WebP, AVIF, GIF, TIFF, HEIC +