A whole bunch of stuff agian lol I promise to track more closely when we get to more stable state - like end of feb
This commit is contained in:
parent
1a1f12c45b
commit
435fb8150c
27
CLAUDE.md
27
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
|
||||
|
||||
@ -81,6 +81,7 @@ import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
||||
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
||||
import { ADMIN_ROLES } from '@/types/api';
|
||||
import { isAdmin } from '@/utils/roles';
|
||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
||||
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
||||
|
||||
@ -248,6 +249,7 @@ export default function App() {
|
||||
element={<NavigateToCutMap />}
|
||||
/>
|
||||
|
||||
<Route path="/join" element={<QuickJoinPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
@ -28,7 +28,7 @@ import {
|
||||
BranchesOutlined,
|
||||
CloudServerOutlined,
|
||||
QrcodeOutlined,
|
||||
VideoCameraOutlined,
|
||||
PlaySquareOutlined,
|
||||
FolderOutlined,
|
||||
HistoryOutlined,
|
||||
LineChartOutlined,
|
||||
@ -129,10 +129,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
if (settings?.enableMediaFeatures !== false) {
|
||||
items.push({
|
||||
key: 'media-submenu',
|
||||
icon: <VideoCameraOutlined />,
|
||||
label: 'Media Library',
|
||||
icon: <PlaySquareOutlined />,
|
||||
label: 'Media',
|
||||
children: [
|
||||
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
|
||||
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Library' },
|
||||
{ key: '/app/media/analytics', icon: <BarChartOutlined />, label: 'Analytics' },
|
||||
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
|
||||
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
|
||||
@ -444,10 +444,10 @@ export default function AppLayout() {
|
||||
</>
|
||||
)}
|
||||
{settings?.enableMediaFeatures !== false && (
|
||||
<Tooltip title="Open Video Gallery">
|
||||
<Tooltip title="Open Gallery">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<VideoCameraOutlined />}
|
||||
icon={<PlaySquareOutlined />}
|
||||
onClick={() => navigate('/gallery')}
|
||||
>
|
||||
{!isMobile && 'Gallery'}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ConfigProvider, Layout, Typography, theme, Space, Grid, Drawer, Button } from 'antd';
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import AuthModal from '@/components/AuthModal';
|
||||
@ -79,10 +79,12 @@ function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.
|
||||
|
||||
export default function PublicLayout() {
|
||||
const { settings } = useSettingsStore();
|
||||
const { isAuthenticated, logout } = useAuthStore();
|
||||
const { isAuthenticated, logout, user } = useAuthStore();
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authModalContext, setAuthModalContext] = useState<'generic' | 'campaign'>('generic');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
@ -214,9 +216,16 @@ export default function PublicLayout() {
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
|
||||
<>
|
||||
{isAdmin ? (
|
||||
<NavLink to="/app" icon={<AppstoreOutlined />} label="Admin" />
|
||||
) : (
|
||||
<NavLink to="/volunteer" icon={<TeamOutlined />} label="Volunteer Portal" />
|
||||
)}
|
||||
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
|
||||
</>
|
||||
) : (
|
||||
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
|
||||
<NavButton onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); }} icon={<LoginOutlined />} label="Sign In" />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
@ -350,21 +359,37 @@ export default function PublicLayout() {
|
||||
)}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
|
||||
{isAuthenticated ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { logout(); setDrawerOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
|
||||
>
|
||||
<LogoutOutlined /> <span>Logout</span>
|
||||
</span>
|
||||
<>
|
||||
<Link
|
||||
to={isAdmin ? '/app' : '/volunteer'}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '12px 24px',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
textDecoration: 'none', fontSize: 15,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
|
||||
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
|
||||
</Link>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { logout(); setDrawerOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
|
||||
>
|
||||
<LogoutOutlined /> <span>Logout</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { setAuthModalOpen(true); setDrawerOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalOpen(true); setDrawerOpen(false); } }}
|
||||
onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); } }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
|
||||
>
|
||||
<LoginOutlined /> <span>Sign In</span>
|
||||
@ -378,10 +403,12 @@ export default function PublicLayout() {
|
||||
onCancel={() => setAuthModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setAuthModalOpen(false);
|
||||
navigate('/campaigns/create');
|
||||
if (authModalContext === 'campaign') {
|
||||
navigate('/campaigns/create');
|
||||
}
|
||||
}}
|
||||
title="Sign in to Create a Campaign"
|
||||
subtitle="Sign in or create an account to submit your own campaign"
|
||||
title={authModalContext === 'campaign' ? 'Sign in to Create a Campaign' : 'Sign in to your account'}
|
||||
subtitle={authModalContext === 'campaign' ? 'Sign in or create an account to submit your own campaign' : 'Sign in or create an account to get involved'}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
|
||||
223
admin/src/components/canvass/CanvassTrendsCard.tsx
Normal file
223
admin/src/components/canvass/CanvassTrendsCard.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Card, Col, Row, Segmented, Switch, DatePicker, Empty, Spin, Grid, App, Typography } from 'antd';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import MiniDonutChart from '@/components/dashboard/MiniDonutChart';
|
||||
import type { VisitOutcome, CanvassOutcomeTrendsData } from '@/types/canvass';
|
||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Stacking order: positive first, neutral middle, negative last
|
||||
const OUTCOME_ORDER: VisitOutcome[] = [
|
||||
'SPOKE_WITH',
|
||||
'LEFT_LITERATURE',
|
||||
'NOT_HOME',
|
||||
'COME_BACK_LATER',
|
||||
'ALREADY_VOTED',
|
||||
'REFUSED',
|
||||
'MOVED',
|
||||
];
|
||||
|
||||
export default function CanvassTrendsCard() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [data, setData] = useState<CanvassOutcomeTrendsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [granularity, setGranularity] = useState<'day' | 'week'>('day');
|
||||
const [percentMode, setPercentMode] = useState(false);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
|
||||
dayjs().subtract(30, 'day'),
|
||||
dayjs(),
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchTrends = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/map/canvass/trends', {
|
||||
params: {
|
||||
granularity,
|
||||
dateFrom: dateRange[0].format('YYYY-MM-DD'),
|
||||
dateTo: dateRange[1].format('YYYY-MM-DD'),
|
||||
},
|
||||
});
|
||||
if (!cancelled) setData(res.data);
|
||||
} catch {
|
||||
if (!cancelled) message.error('Failed to load outcome trends');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTrends();
|
||||
return () => { cancelled = true; };
|
||||
}, [granularity, dateRange, message]);
|
||||
|
||||
// Transform series for percentage mode
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.series?.length) return [];
|
||||
if (!percentMode) return data.series;
|
||||
|
||||
return data.series.map((point) => {
|
||||
const total = OUTCOME_ORDER.reduce((sum, o) => sum + ((point[o] as number) || 0), 0);
|
||||
if (total === 0) return point;
|
||||
const pct: Record<string, unknown> = { date: point.date };
|
||||
for (const o of OUTCOME_ORDER) {
|
||||
pct[o] = Math.round((((point[o] as number) || 0) / total) * 100);
|
||||
}
|
||||
return pct;
|
||||
});
|
||||
}, [data, percentMode]);
|
||||
|
||||
// Donut data from totals
|
||||
const donutData = useMemo(() => {
|
||||
if (!data?.totals) return [];
|
||||
return OUTCOME_ORDER
|
||||
.filter((o) => (data.totals[o] || 0) > 0)
|
||||
.map((o) => ({
|
||||
name: VISIT_OUTCOME_LABELS[o],
|
||||
value: data.totals[o] || 0,
|
||||
color: VISIT_OUTCOME_COLORS[o],
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const totalVisits = useMemo(() => {
|
||||
if (!data?.totals) return 0;
|
||||
return Object.values(data.totals).reduce((s, v) => s + (v || 0), 0);
|
||||
}, [data]);
|
||||
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Outcome Trends"
|
||||
size="small"
|
||||
extra={
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
options={[
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Week', value: 'week' },
|
||||
]}
|
||||
value={granularity}
|
||||
onChange={(v) => setGranularity(v as 'day' | 'week')}
|
||||
/>
|
||||
<Switch
|
||||
size="small"
|
||||
checkedChildren="%"
|
||||
unCheckedChildren="#"
|
||||
checked={percentMode}
|
||||
onChange={setPercentMode}
|
||||
/>
|
||||
<RangePicker
|
||||
size="small"
|
||||
value={dateRange}
|
||||
onChange={(vals) => {
|
||||
if (vals && vals[0] && vals[1]) {
|
||||
setDateRange([vals[0], vals[1]]);
|
||||
}
|
||||
}}
|
||||
allowClear={false}
|
||||
style={{ width: isMobile ? '100%' : 220 }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<Empty description="No visit data for this period" />
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={16}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10 }}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(v: string) =>
|
||||
granularity === 'week'
|
||||
? dayjs(v).format('MMM D')
|
||||
: dayjs(v).format('M/D')
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v: number) => (percentMode ? `${v}%` : String(v))}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12, borderRadius: 6 }}
|
||||
labelFormatter={(v) => dayjs(String(v)).format('ddd, MMM D')}
|
||||
formatter={(value, name) => [
|
||||
percentMode ? `${value}%` : value,
|
||||
VISIT_OUTCOME_LABELS[name as VisitOutcome] || name,
|
||||
]}
|
||||
/>
|
||||
{OUTCOME_ORDER.map((outcome) => (
|
||||
<Area
|
||||
key={outcome}
|
||||
type="monotone"
|
||||
dataKey={outcome}
|
||||
stackId="1"
|
||||
stroke={VISIT_OUTCOME_COLORS[outcome]}
|
||||
fill={VISIT_OUTCOME_COLORS[outcome]}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<MiniDonutChart data={donutData} height={140} innerRadius={36} outerRadius={56} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{OUTCOME_ORDER.filter((o) => (data?.totals[o] || 0) > 0).map((o) => {
|
||||
const count = data?.totals[o] || 0;
|
||||
const pct = totalVisits > 0 ? Math.round((count / totalVisits) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
key={o}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 2,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: VISIT_OUTCOME_COLORS[o],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text style={{ flex: 1, fontSize: 12 }} ellipsis>
|
||||
{VISIT_OUTCOME_LABELS[o]}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{count} ({pct}%)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert } from 'antd';
|
||||
import {
|
||||
Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert,
|
||||
Modal, Input, App,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined,
|
||||
LogoutOutlined,
|
||||
@ -9,6 +12,9 @@ import {
|
||||
StopOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
CheckOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import SessionTimer from './SessionTimer';
|
||||
import { api } from '@/lib/api';
|
||||
@ -27,6 +33,9 @@ interface VolunteerMapDrawerProps {
|
||||
sessionStartedAt?: string;
|
||||
onEndSession?: () => void;
|
||||
endingSession?: boolean;
|
||||
activeCutId?: string;
|
||||
activeShiftId?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export default function VolunteerMapDrawer({
|
||||
@ -40,8 +49,12 @@ export default function VolunteerMapDrawer({
|
||||
sessionStartedAt,
|
||||
onEndSession,
|
||||
endingSession = false,
|
||||
activeCutId,
|
||||
activeShiftId,
|
||||
isAdmin = false,
|
||||
}: VolunteerMapDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [stats, setStats] = useState<MyCanvassStats | null>(null);
|
||||
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
|
||||
@ -49,6 +62,12 @@ export default function VolunteerMapDrawer({
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
// QR invite modal state
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false);
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [generatingInvite, setGeneratingInvite] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Load stats and assignments when drawer opens
|
||||
@ -61,204 +80,308 @@ export default function VolunteerMapDrawer({
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
height="auto"
|
||||
closable={false}
|
||||
mask={false}
|
||||
maskClosable={false}
|
||||
zIndex={1150}
|
||||
styles={{
|
||||
wrapper: {
|
||||
bottom: 0, // Sits at bottom, footer will push up
|
||||
},
|
||||
body: {
|
||||
padding: isMobile ? '12px' : '16px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<div ref={drawerBodyRef} style={{ width: '100%' }}>
|
||||
{/* Header with drag handle and close button */}
|
||||
<div style={{ position: 'relative', marginBottom: 16 }}>
|
||||
{/* Drag handle at top center */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 4,
|
||||
background: 'rgba(255,255,255,0.3)',
|
||||
borderRadius: 2,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
const handleGenerateInvite = async () => {
|
||||
setGeneratingInvite(true);
|
||||
try {
|
||||
const { data } = await api.post('/volunteer-invite/generate', {
|
||||
cutId: activeCutId || undefined,
|
||||
shiftId: activeShiftId || undefined,
|
||||
});
|
||||
// Build the join URL using current origin
|
||||
const joinUrl = `${window.location.origin}/join?token=${data.token}`;
|
||||
setInviteUrl(joinUrl);
|
||||
setQrModalOpen(true);
|
||||
} catch {
|
||||
message.error('Failed to generate invite link');
|
||||
} finally {
|
||||
setGeneratingInvite(false);
|
||||
}
|
||||
};
|
||||
|
||||
{/* Close button at top right */}
|
||||
const handleCopyUrl = async () => {
|
||||
if (!inviteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
message.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
// Build QR code image URL using existing /api/qr endpoint
|
||||
const qrImageUrl = inviteUrl
|
||||
? `/api/qr?text=${encodeURIComponent(inviteUrl)}&size=280`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
placement="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
height="auto"
|
||||
closable={false}
|
||||
mask={false}
|
||||
maskClosable={false}
|
||||
zIndex={1150}
|
||||
styles={{
|
||||
wrapper: {
|
||||
bottom: 0, // Sits at bottom, footer will push up
|
||||
},
|
||||
body: {
|
||||
padding: isMobile ? '12px' : '16px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<div ref={drawerBodyRef} style={{ width: '100%' }}>
|
||||
{/* Header with drag handle and close button */}
|
||||
<div style={{ position: 'relative', marginBottom: 16 }}>
|
||||
{/* Drag handle at top center */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 4,
|
||||
background: 'rgba(255,255,255,0.3)',
|
||||
borderRadius: 2,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close button at top right */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active session alert */}
|
||||
{sessionActive && sessionCutName && (
|
||||
<>
|
||||
<Alert
|
||||
message={
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
Active Session: {sessionCutName}
|
||||
</Typography.Text>
|
||||
{sessionStartedAt && (
|
||||
<Space size={4}>
|
||||
<ClockCircleOutlined style={{ fontSize: 12 }} />
|
||||
<SessionTimer startedAt={sessionStartedAt} />
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
action={
|
||||
onEndSession && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
onClick={onEndSession}
|
||||
loading={endingSession}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Divider style={{ margin: '0 0 16px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
|
||||
{user?.name || user?.email || 'Volunteer'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
|
||||
{user?.email}
|
||||
</Typography.Text>
|
||||
|
||||
{/* Mini stats */}
|
||||
{stats && (
|
||||
<Space size="large" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
|
||||
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* Assignments (hidden when session active) */}
|
||||
{!sessionActive && assignments.length > 0 && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
My Assignments
|
||||
</Typography.Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={assignments}
|
||||
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
|
||||
renderItem={(a) => (
|
||||
<List.Item
|
||||
style={{ padding: '6px 0' }}
|
||||
actions={[
|
||||
<Button
|
||||
key="start"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
|
||||
>
|
||||
Start
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
|
||||
description={
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{a.shiftTitle} · {Math.round(a.completionPercentage)}%
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Free session — pick a cut (hidden when session active) */}
|
||||
{!sessionActive && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
Start Session (Any Cut)
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="Select a cut..."
|
||||
style={{ flex: 1 }}
|
||||
value={freeCutId}
|
||||
onChange={setFreeCutId}
|
||||
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AimOutlined />}
|
||||
disabled={!freeCutId}
|
||||
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Navigation links */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left', marginBottom: 4 }}
|
||||
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
|
||||
>
|
||||
My Activity
|
||||
</Button>
|
||||
|
||||
{/* Admin: Invite Volunteer button */}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
icon={<QrcodeOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left', marginBottom: 4 }}
|
||||
onClick={handleGenerateInvite}
|
||||
loading={generatingInvite}
|
||||
>
|
||||
Invite Volunteer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<LogoutOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left' }}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
{/* Active session alert */}
|
||||
{sessionActive && sessionCutName && (
|
||||
<>
|
||||
<Alert
|
||||
message={
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
Active Session: {sessionCutName}
|
||||
</Typography.Text>
|
||||
{sessionStartedAt && (
|
||||
<Space size={4}>
|
||||
<ClockCircleOutlined style={{ fontSize: 12 }} />
|
||||
<SessionTimer startedAt={sessionStartedAt} />
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
action={
|
||||
onEndSession && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
onClick={onEndSession}
|
||||
loading={endingSession}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Divider style={{ margin: '0 0 16px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
|
||||
{user?.name || user?.email || 'Volunteer'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
|
||||
{user?.email}
|
||||
</Typography.Text>
|
||||
|
||||
{/* Mini stats */}
|
||||
{stats && (
|
||||
<Space size="large" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
|
||||
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* Assignments (hidden when session active) */}
|
||||
{!sessionActive && assignments.length > 0 && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
My Assignments
|
||||
{/* QR Code Invite Modal */}
|
||||
<Modal
|
||||
open={qrModalOpen}
|
||||
onCancel={() => { setQrModalOpen(false); setInviteUrl(null); setCopied(false); }}
|
||||
footer={null}
|
||||
title="Invite Volunteer"
|
||||
centered
|
||||
width={340}
|
||||
zIndex={1200}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||||
Show this QR code to a new volunteer. They'll scan it with their phone to get instant access.
|
||||
</Typography.Text>
|
||||
<List
|
||||
|
||||
{qrImageUrl && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<img
|
||||
src={qrImageUrl}
|
||||
alt="Invite QR Code"
|
||||
style={{
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 11 }}>
|
||||
Or copy this link to send via text:
|
||||
</Typography.Text>
|
||||
|
||||
<Input.Search
|
||||
value={inviteUrl || ''}
|
||||
readOnly
|
||||
enterButton={
|
||||
<Button icon={copied ? <CheckOutlined /> : <CopyOutlined />} type={copied ? 'default' : 'primary'}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
}
|
||||
onSearch={handleCopyUrl}
|
||||
style={{ marginBottom: 8 }}
|
||||
size="small"
|
||||
dataSource={assignments}
|
||||
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
|
||||
renderItem={(a) => (
|
||||
<List.Item
|
||||
style={{ padding: '6px 0' }}
|
||||
actions={[
|
||||
<Button
|
||||
key="start"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
|
||||
>
|
||||
Start
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
|
||||
description={
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{a.shiftTitle} · {Math.round(a.completionPercentage)}%
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Free session — pick a cut (hidden when session active) */}
|
||||
{!sessionActive && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
Start Session (Any Cut)
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 8 }}>
|
||||
Link expires in 30 minutes. Access lasts 24 hours.
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="Select a cut..."
|
||||
style={{ flex: 1 }}
|
||||
value={freeCutId}
|
||||
onChange={setFreeCutId}
|
||||
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AimOutlined />}
|
||||
disabled={!freeCutId}
|
||||
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Navigation links */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left', marginBottom: 4 }}
|
||||
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
|
||||
>
|
||||
My Activity
|
||||
</Button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<LogoutOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left' }}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,11 +18,11 @@ dayjs.extend(relativeTime);
|
||||
const { Text } = Typography;
|
||||
|
||||
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
|
||||
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 10 }} /> },
|
||||
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 10 }} /> },
|
||||
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 10 }} /> },
|
||||
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 10 }} /> },
|
||||
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 10 }} /> },
|
||||
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 13 }} /> },
|
||||
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 13 }} /> },
|
||||
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
|
||||
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },
|
||||
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 13 }} /> },
|
||||
};
|
||||
|
||||
const MODULE_OPTIONS = [
|
||||
@ -37,19 +37,19 @@ function ActivityRow({ item }: { item: ActivityItem }) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={6}
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.3,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: config.color, flexShrink: 0, width: 14, textAlign: 'center' }}>{config.icon}</span>
|
||||
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{item.title}</Text>
|
||||
<span style={{ color: config.color, flexShrink: 0, width: 18, textAlign: 'center' }}>{config.icon}</span>
|
||||
<Text strong style={{ fontSize: 13, flexShrink: 0 }}>{item.title}</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
@ -59,7 +59,7 @@ function ActivityRow({ item }: { item: ActivityItem }) {
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(item.timestamp).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
@ -77,7 +77,7 @@ export default function ActivityFeedCard() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<ActivityFeedResult>('/dashboard/activity', {
|
||||
params: { page: p, limit: 10, module: mod },
|
||||
params: { page: p, limit: 15, module: mod },
|
||||
});
|
||||
if (append) {
|
||||
setItems(prev => [...prev, ...res.data.items]);
|
||||
@ -107,7 +107,7 @@ export default function ActivityFeedCard() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 13 }}><HistoryOutlined style={{ marginRight: 5 }} />Recent Activity</span>}
|
||||
title={<span style={{ fontSize: 14 }}><HistoryOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Activity</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Segmented
|
||||
@ -117,23 +117,22 @@ export default function ActivityFeedCard() {
|
||||
options={MODULE_OPTIONS}
|
||||
/>
|
||||
}
|
||||
styles={{ body: { padding: '4px 12px 6px' } }}
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && items.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : items.length === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent activity</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent activity</Text>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<ActivityRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', paddingTop: 2 }}>
|
||||
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 11 }}>
|
||||
<div style={{ textAlign: 'center', paddingTop: 4 }}>
|
||||
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 13 }}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
105
admin/src/components/dashboard/CampaignEffectivenessCard.tsx
Normal file
105
admin/src/components/dashboard/CampaignEffectivenessCard.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Statistic, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
FundOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CampaignOverviewStats } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function CampaignEffectivenessCard() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<CampaignOverviewStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<CampaignOverviewStats>('/influence/effectiveness/overview');
|
||||
setData(res.data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (hasError && !data) return null;
|
||||
|
||||
const s = data?.summary;
|
||||
const topCampaigns = data?.campaigns?.slice(0, 3) || [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><FundOutlined style={{ marginRight: 6, fontSize: 15 }} />Campaign Effectiveness</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/influence/effectiveness')} style={{ fontSize: 12, padding: 0 }}>
|
||||
Details
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : s ? (
|
||||
<>
|
||||
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Emails Sent</Text>}
|
||||
value={s.totalEmails}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Responses</Text>}
|
||||
value={s.totalResponses}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Response Rate</Text>}
|
||||
value={Math.round(s.avgResponseRate * 100)}
|
||||
suffix="%"
|
||||
valueStyle={{ fontSize: 18, color: s.avgResponseRate > 0.1 ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Flex>
|
||||
{topCampaigns.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Top Campaigns</Text>
|
||||
{topCampaigns.map(c => (
|
||||
<Flex key={c.campaignId} justify="space-between" align="center" style={{ padding: '2px 0' }}>
|
||||
<Tooltip title={c.title}>
|
||||
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '60%' }}>
|
||||
{c.title}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Flex gap={6} style={{ flexShrink: 0 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{c.emailTotal} emails</Text>
|
||||
<Tag color={c.responseRate > 0.1 ? 'green' : 'default'} style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}>
|
||||
{Math.round(c.responseRate * 100)}%
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No campaign data</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -31,28 +31,28 @@ function ChatRow({ message }: { message: ChatMessage }) {
|
||||
<Tooltip title={dayjs(message.timestamp).format('MMM D, h:mm A')} placement="left">
|
||||
<Flex
|
||||
align="center"
|
||||
gap={4}
|
||||
gap={6}
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.3,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{message.isBot
|
||||
? <RobotOutlined style={{ fontSize: 10, color: '#1890ff', flexShrink: 0 }} />
|
||||
: <UserOutlined style={{ fontSize: 10, color: '#666', flexShrink: 0 }} />
|
||||
? <RobotOutlined style={{ fontSize: 13, color: '#1890ff', flexShrink: 0 }} />
|
||||
: <UserOutlined style={{ fontSize: 13, color: '#666', flexShrink: 0 }} />
|
||||
}
|
||||
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{message.username}</Text>
|
||||
<Text strong style={{ fontSize: 13, flexShrink: 0 }}>{message.username}</Text>
|
||||
<Tag
|
||||
color={CHANNEL_COLORS[message.channel] || 'default'}
|
||||
style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}
|
||||
style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
|
||||
>
|
||||
#{message.channel}
|
||||
</Tag>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
@ -62,7 +62,7 @@ function ChatRow({ message }: { message: ChatMessage }) {
|
||||
>
|
||||
{cleanText}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(message.timestamp).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
@ -96,24 +96,23 @@ export default function ChatNotifierCard() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 13 }}><MessageOutlined style={{ marginRight: 5 }} />Team Chat</span>}
|
||||
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Team Chat</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchChat} />
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchChat} />
|
||||
}
|
||||
styles={{ body: { padding: '4px 12px 6px' } }}
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.messages.length > 0 ? (
|
||||
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
<div>
|
||||
{result.messages.map(msg => (
|
||||
<ChatRow key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent messages</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
104
admin/src/components/dashboard/DocsAnalyticsCard.tsx
Normal file
104
admin/src/components/dashboard/DocsAnalyticsCard.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Statistic, Tooltip } from 'antd';
|
||||
import {
|
||||
BookOutlined,
|
||||
ReloadOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardDocsAnalytics } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function DocsAnalyticsCard() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<DashboardDocsAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<DashboardDocsAnalytics>('/docs-analytics/summary', {
|
||||
params: { days: 30 },
|
||||
});
|
||||
setData(res.data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
const interval = setInterval(fetchAnalytics, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
// Hide if the endpoint isn't available
|
||||
if (hasError && !data) return null;
|
||||
|
||||
const avgPerDay = data ? Math.round(data.totalViews / 30) : 0;
|
||||
const topPages = data?.topPages?.slice(0, 5) || [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><BookOutlined style={{ marginRight: 6, fontSize: 15 }} />Docs Analytics</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/docs/analytics')} style={{ fontSize: 12, padding: 0 }}>
|
||||
Full Report
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchAnalytics} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : data ? (
|
||||
<>
|
||||
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Page Views</Text>}
|
||||
value={data.totalViews}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Sessions</Text>}
|
||||
value={data.uniqueSessions}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Avg/Day</Text>}
|
||||
value={avgPerDay}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Flex>
|
||||
{topPages.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Top Pages (30d)</Text>
|
||||
{topPages.map((page, i) => (
|
||||
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
|
||||
<Tooltip title={page.path}>
|
||||
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '75%' }}>
|
||||
<FileTextOutlined style={{ marginRight: 4, fontSize: 10, color: '#1890ff' }} />
|
||||
{page.path}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{page.views}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No analytics data</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
admin/src/components/dashboard/DonationSummaryCard.tsx
Normal file
117
admin/src/components/dashboard/DonationSummaryCard.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Statistic, Tag } from 'antd';
|
||||
import {
|
||||
DollarOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { api } from '@/lib/api';
|
||||
import type { PaymentDashboardStats } from '@/types/api';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function DonationSummaryCard() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<PaymentDashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<PaymentDashboardStats>('/payments/admin/dashboard');
|
||||
setData(res.data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (hasError && !data) return null;
|
||||
|
||||
const recent = data?.donations?.recentDonations?.slice(0, 4) || [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><DollarOutlined style={{ marginRight: 6, fontSize: 15 }} />Payments</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/payments')} style={{ fontSize: 12, padding: 0 }}>
|
||||
Manage
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : data ? (
|
||||
<>
|
||||
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Revenue</Text>}
|
||||
value={formatCents(data.totalRevenue)}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>MRR</Text>}
|
||||
value={formatCents(data.mrr)}
|
||||
valueStyle={{ fontSize: 18, color: data.mrr > 0 ? '#52c41a' : undefined }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Subscribers</Text>}
|
||||
value={data.activeSubscribers}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>Donations</Text>}
|
||||
value={data.donations?.totalDonations || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Flex>
|
||||
{recent.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Recent</Text>
|
||||
{recent.map(d => (
|
||||
<Flex key={d.id} justify="space-between" align="center" style={{ padding: '2px 0' }}>
|
||||
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '55%' }}>
|
||||
{d.isAnonymous ? 'Anonymous' : d.buyerName || d.buyerEmail}
|
||||
</Text>
|
||||
<Flex gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Text strong style={{ fontSize: 12 }}>{formatCents(d.amountCAD)}</Text>
|
||||
<Tag
|
||||
color={d.status === 'COMPLETED' ? 'green' : d.status === 'PENDING' ? 'orange' : 'red'}
|
||||
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}
|
||||
>
|
||||
{d.status}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No payment data</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
85
admin/src/components/dashboard/NewsletterStatsCard.tsx
Normal file
85
admin/src/components/dashboard/NewsletterStatsCard.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tooltip } from 'antd';
|
||||
import {
|
||||
MailOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ListmonkStats } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function NewsletterStatsCard() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ListmonkStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<ListmonkStats>('/listmonk/stats');
|
||||
setData(res.data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (hasError && !data) return null;
|
||||
|
||||
const lists = data?.lists || [];
|
||||
const totalSubscribers = lists.reduce((sum, l) => sum + l.subscriberCount, 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><MailOutlined style={{ marginRight: 6, fontSize: 15 }} />Newsletter</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/listmonk')} style={{ fontSize: 12, padding: 0 }}>
|
||||
Manage
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : lists.length > 0 ? (
|
||||
<>
|
||||
<Flex align="center" gap={8} style={{ marginBottom: 8 }}>
|
||||
<TeamOutlined style={{ fontSize: 16, color: '#1890ff' }} />
|
||||
<Text strong style={{ fontSize: 18 }}>{totalSubscribers.toLocaleString()}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>total across {lists.length} lists</Text>
|
||||
</Flex>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
|
||||
{lists.slice(0, 6).map((list, i) => (
|
||||
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
|
||||
<Tooltip title={list.name}>
|
||||
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
|
||||
{list.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{list.subscriberCount.toLocaleString()}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No lists configured</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
127
admin/src/components/dashboard/RecentCommentsCard.tsx
Normal file
127
admin/src/components/dashboard/RecentCommentsCard.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
MessageOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardRecentCommentsResult, DashboardRecentComment } from '@/types/api';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SAFETY_COLORS: Record<string, string> = {
|
||||
safe: 'green',
|
||||
pending: 'orange',
|
||||
flagged: 'red',
|
||||
unsafe: 'red',
|
||||
};
|
||||
|
||||
function CommentRow({ comment }: { comment: DashboardRecentComment }) {
|
||||
const navigate = useNavigate();
|
||||
const videoLabel = comment.videoTitle || comment.videoFilename;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
onClick={() => navigate(`/gallery/watch/${comment.videoId}`)}
|
||||
style={{
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{comment.authorName || 'Anonymous'}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{comment.content}
|
||||
</Text>
|
||||
<Tooltip title={videoLabel}>
|
||||
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{videoLabel}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
{comment.safetyStatus && comment.safetyStatus !== 'safe' && (
|
||||
<Tag
|
||||
color={SAFETY_COLORS[comment.safetyStatus] || 'default'}
|
||||
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
|
||||
>
|
||||
{comment.safetyStatus}
|
||||
</Tag>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(comment.createdAt).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecentCommentsCard() {
|
||||
const [result, setResult] = useState<DashboardRecentCommentsResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<DashboardRecentCommentsResult>('/dashboard/recent-comments');
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// non-critical widget
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
const interval = setInterval(fetchComments, 3 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchComments]);
|
||||
|
||||
if (result && !result.enabled) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Comments</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
{result && result.pendingCount > 0 && (
|
||||
<Tag color="orange" style={{ margin: 0, fontSize: 11 }}>{result.pendingCount} pending</Tag>
|
||||
)}
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchComments} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.comments.length > 0 ? (
|
||||
<div>
|
||||
{result.comments.map(comment => (
|
||||
<CommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No comments yet</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
111
admin/src/components/dashboard/RecentSignupsCard.tsx
Normal file
111
admin/src/components/dashboard/RecentSignupsCard.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
UserAddOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardRecentSignupsResult, DashboardRecentSignup } from '@/types/api';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
AUTHENTICATED: 'blue',
|
||||
PUBLIC: 'green',
|
||||
ADMIN: 'purple',
|
||||
};
|
||||
|
||||
function SignupRow({ signup }: { signup: DashboardRecentSignup }) {
|
||||
const displayName = signup.userName || signup.userEmail;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<UserAddOutlined style={{ fontSize: 12, color: '#52c41a', flexShrink: 0 }} />
|
||||
<Tooltip title={displayName}>
|
||||
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Tooltip title={signup.shiftTitle}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{signup.shiftTitle || 'Shift'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Tag
|
||||
color={SOURCE_COLORS[signup.signupSource] || 'default'}
|
||||
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
|
||||
>
|
||||
{signup.signupSource === 'AUTHENTICATED' ? 'Auth' : signup.signupSource === 'PUBLIC' ? 'Public' : signup.signupSource}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(signup.signupDate).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecentSignupsCard() {
|
||||
const [result, setResult] = useState<DashboardRecentSignupsResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSignups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<DashboardRecentSignupsResult>('/dashboard/recent-signups');
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// non-critical widget
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSignups();
|
||||
const interval = setInterval(fetchSignups, 3 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchSignups]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><UserAddOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Signups</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
{result && result.total > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{result.total} (14d)</Text>
|
||||
)}
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchSignups} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.signups.length > 0 ? (
|
||||
<div>
|
||||
{result.signups.map(signup => (
|
||||
<SignupRow key={signup.id} signup={signup} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent signups</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
admin/src/components/dashboard/SystemAlertsCard.tsx
Normal file
124
admin/src/components/dashboard/SystemAlertsCard.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tag } from 'antd';
|
||||
import {
|
||||
AlertOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AlertsResponse, AlertInfo } from '@/types/api';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: 'red',
|
||||
warning: 'orange',
|
||||
info: 'blue',
|
||||
};
|
||||
|
||||
function AlertRow({ alert }: { alert: AlertInfo }) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
color={SEVERITY_COLORS[alert.severity] || 'default'}
|
||||
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
|
||||
>
|
||||
{alert.severity}
|
||||
</Tag>
|
||||
<Text strong style={{ fontSize: 13, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{alert.name}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{alert.summary}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(alert.startsAt).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemAlertsCard() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<AlertsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AlertsResponse>('/observability/alerts');
|
||||
setData(res.data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (hasError && !data) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 14 }}>
|
||||
<AlertOutlined style={{ marginRight: 6, fontSize: 15 }} />
|
||||
Alerts
|
||||
{data && data.critical > 0 && (
|
||||
<Tag color="red" style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', lineHeight: '18px' }}>
|
||||
{data.critical} critical
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/observability')} style={{ fontSize: 12, padding: 0 }}>
|
||||
Monitor
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : data && data.alerts.length > 0 ? (
|
||||
<div>
|
||||
{data.alerts.slice(0, 6).map(alert => (
|
||||
<AlertRow key={alert.id} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Flex align="center" gap={8} style={{ padding: '8px 0' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 16 }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>All systems nominal</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -24,30 +24,30 @@ function EventRow({ event }: { event: TodayEvent }) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={6}
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.3,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, width: 82, whiteSpace: 'nowrap' }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 3, fontSize: 9 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, width: 90, whiteSpace: 'nowrap' }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 4, fontSize: 11 }} />
|
||||
{timeStr}
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 11, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Text strong style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.placeName && (
|
||||
<Tooltip title={event.placeName}>
|
||||
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 2, fontSize: 9 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 3, fontSize: 11 }} />
|
||||
{event.placeName}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
{event.tags.length > 0 && (
|
||||
<Tag style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}>
|
||||
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}>
|
||||
{event.tags[0]}
|
||||
</Tag>
|
||||
)}
|
||||
@ -81,19 +81,18 @@ export default function TodayEventsCard() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 13 }}><CalendarOutlined style={{ marginRight: 5 }} />Today's Events</span>}
|
||||
title={<span style={{ fontSize: 14 }}><CalendarOutlined style={{ marginRight: 6, fontSize: 15 }} />Today's Events</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
{result && <Text type="secondary" style={{ fontSize: 10 }}>{result.total} event{result.total !== 1 ? 's' : ''}</Text>}
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchEvents} />
|
||||
{result && <Text type="secondary" style={{ fontSize: 12 }}>{result.total} event{result.total !== 1 ? 's' : ''}</Text>}
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchEvents} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '4px 12px 6px' } }}
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.events.length > 0 ? (
|
||||
<>
|
||||
{result.events.map(event => (
|
||||
@ -101,7 +100,7 @@ export default function TodayEventsCard() {
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No events scheduled today</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No events scheduled today</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
137
admin/src/components/dashboard/TopVideosCard.tsx
Normal file
137
admin/src/components/dashboard/TopVideosCard.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
MessageOutlined,
|
||||
LikeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardTopVideosResult, DashboardTopVideo } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '--:--';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function VideoRow({ video, rank }: { video: DashboardTopVideo; rank: number }) {
|
||||
const navigate = useNavigate();
|
||||
const displayTitle = video.title || video.filename;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
onClick={() => navigate(`/gallery/watch/${video.id}`)}
|
||||
style={{
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 13, flexShrink: 0, width: 18, textAlign: 'center' }}>
|
||||
{rank}
|
||||
</Text>
|
||||
<Tooltip title={displayTitle}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{!video.isPublished && (
|
||||
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px' }}>Draft</Tag>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
|
||||
{formatDuration(video.durationSeconds)}
|
||||
</Text>
|
||||
<Flex gap={6} style={{ flexShrink: 0 }}>
|
||||
<Tooltip title="Views">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<EyeOutlined style={{ marginRight: 2, fontSize: 10 }} />
|
||||
{video.viewCount}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{video.commentCount > 0 && (
|
||||
<Tooltip title="Comments">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<MessageOutlined style={{ marginRight: 2, fontSize: 10 }} />
|
||||
{video.commentCount}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
{video.upvoteCount > 0 && (
|
||||
<Tooltip title="Upvotes">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<LikeOutlined style={{ marginRight: 2, fontSize: 10 }} />
|
||||
{video.upvoteCount}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopVideosCard() {
|
||||
const [result, setResult] = useState<DashboardTopVideosResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchVideos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<DashboardTopVideosResult>('/dashboard/top-videos');
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// non-critical widget
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
const interval = setInterval(fetchVideos, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchVideos]);
|
||||
|
||||
if (result && !result.enabled) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><VideoCameraOutlined style={{ marginRight: 6, fontSize: 15 }} />Top Videos</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchVideos} />
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.videos.length > 0 ? (
|
||||
<div>
|
||||
{result.videos.map((video, i) => (
|
||||
<VideoRow key={video.id} video={video} rank={i + 1} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No videos yet</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
admin/src/components/dashboard/UpcomingShiftsCard.tsx
Normal file
117
admin/src/components/dashboard/UpcomingShiftsCard.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip, Progress } from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardUpcomingShiftsResult, DashboardUpcomingShift } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function ShiftRow({ shift }: { shift: DashboardUpcomingShift }) {
|
||||
const navigate = useNavigate();
|
||||
const fillPct = shift.maxVolunteers > 0
|
||||
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
onClick={() => navigate('/app/map/shifts')}
|
||||
style={{
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, width: 50, whiteSpace: 'nowrap' }}>
|
||||
{dayjs(shift.date).format('MMM D')}
|
||||
</Text>
|
||||
<Tooltip title={shift.title}>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{shift.title}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 2, fontSize: 10 }} />
|
||||
{shift.startTime}
|
||||
</Text>
|
||||
<Tooltip title={`${shift.currentVolunteers}/${shift.maxVolunteers} volunteers`}>
|
||||
<Progress
|
||||
percent={fillPct}
|
||||
size="small"
|
||||
style={{ width: 50, flexShrink: 0 }}
|
||||
strokeColor={fillPct >= 100 ? '#52c41a' : fillPct >= 50 ? '#faad14' : '#1890ff'}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Tooltip>
|
||||
{shift.cutName && (
|
||||
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, maxWidth: 70, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{shift.cutName}
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpcomingShiftsCard() {
|
||||
const navigate = useNavigate();
|
||||
const [result, setResult] = useState<DashboardUpcomingShiftsResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchShifts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<DashboardUpcomingShiftsResult>('/dashboard/upcoming-shifts');
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// non-critical widget
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShifts();
|
||||
const interval = setInterval(fetchShifts, 5 * 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchShifts]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 14 }}><CalendarOutlined style={{ marginRight: 6, fontSize: 15 }} />Upcoming Shifts</span>}
|
||||
size="small"
|
||||
extra={
|
||||
<Flex align="center" gap={6}>
|
||||
{result && result.total > 5 && (
|
||||
<Button type="link" size="small" onClick={() => navigate('/app/map/shifts')} style={{ fontSize: 12, padding: 0 }}>
|
||||
+{result.total - 5} more
|
||||
</Button>
|
||||
)}
|
||||
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchShifts} />
|
||||
</Flex>
|
||||
}
|
||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||
>
|
||||
{loading && !result ? (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||
) : result && result.shifts.length > 0 ? (
|
||||
<div>
|
||||
{result.shifts.map(shift => (
|
||||
<ShiftRow key={shift.id} shift={shift} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No upcoming shifts</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
102
admin/src/components/media/AlbumCard.tsx
Normal file
102
admin/src/components/media/AlbumCard.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { Card, Tag, Badge } from 'antd';
|
||||
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
import type { PhotoAlbum } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
}
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: PhotoAlbum;
|
||||
onClick?: (album: PhotoAlbum) => void;
|
||||
}
|
||||
|
||||
export default function AlbumCard({ album, onClick }: AlbumCardProps) {
|
||||
const coverUrl = album.coverThumbnailUrl;
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 8 } }}
|
||||
onClick={() => onClick?.(album)}
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingTop: '75%',
|
||||
backgroundColor: '#1a1a1a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={getAuthenticatedUrl(coverUrl)}
|
||||
alt={album.title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<FolderOpenOutlined style={{ fontSize: 32, color: '#555' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo count badge */}
|
||||
<Badge
|
||||
count={
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>
|
||||
<PictureOutlined /> {album.photoCount || album._count?.photos || 0}
|
||||
</Tag>
|
||||
}
|
||||
style={{ position: 'absolute', bottom: 6, right: 6 }}
|
||||
/>
|
||||
|
||||
{/* Publish badge */}
|
||||
{album.isPublished && (
|
||||
<Tag
|
||||
color="green"
|
||||
style={{ position: 'absolute', top: 6, right: 6, margin: 0 }}
|
||||
>
|
||||
<GlobalOutlined />
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{album.title}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
{album.photoCount || album._count?.photos || 0} photos
|
||||
{album.viewCount > 0 ? ` · ${album.viewCount} views` : ''}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
232
admin/src/components/media/AlbumDetailDrawer.tsx
Normal file
232
admin/src/components/media/AlbumDetailDrawer.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PictureOutlined,
|
||||
CrownOutlined,
|
||||
GlobalOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
}
|
||||
|
||||
interface AlbumDetailDrawerProps {
|
||||
albumId: number | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) {
|
||||
const [album, setAlbum] = useState<PhotoAlbum | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (albumId && open) {
|
||||
fetchAlbum();
|
||||
}
|
||||
}, [albumId, open]);
|
||||
|
||||
const fetchAlbum = async () => {
|
||||
if (!albumId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await mediaApi.get(`/albums/${albumId}`);
|
||||
setAlbum(data);
|
||||
setTitle(data.title);
|
||||
setDescription(data.description || '');
|
||||
} catch {
|
||||
message.error('Failed to load album');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!albumId) return;
|
||||
try {
|
||||
await mediaApi.patch(`/albums/${albumId}`, { title, description });
|
||||
message.success('Album updated');
|
||||
setEditing(false);
|
||||
fetchAlbum();
|
||||
onRefresh();
|
||||
} catch {
|
||||
message.error('Failed to update album');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetCover = async (photoId: number) => {
|
||||
if (!albumId) return;
|
||||
try {
|
||||
await mediaApi.put(`/albums/${albumId}/cover`, { photoId });
|
||||
message.success('Cover photo set');
|
||||
fetchAlbum();
|
||||
onRefresh();
|
||||
} catch {
|
||||
message.error('Failed to set cover photo');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = async (photoId: number) => {
|
||||
if (!albumId) return;
|
||||
try {
|
||||
await mediaApi.delete(`/albums/${albumId}/photos/${photoId}`);
|
||||
message.success('Photo removed from album');
|
||||
fetchAlbum();
|
||||
onRefresh();
|
||||
} catch {
|
||||
message.error('Failed to remove photo');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!albumId) return;
|
||||
try {
|
||||
await mediaApi.post(`/albums/${albumId}/publish`);
|
||||
message.success('Album and photos published');
|
||||
fetchAlbum();
|
||||
onRefresh();
|
||||
} catch {
|
||||
message.error('Failed to publish album');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlbum = async () => {
|
||||
if (!albumId) return;
|
||||
try {
|
||||
await mediaApi.delete(`/albums/${albumId}`);
|
||||
message.success('Album deleted (photos preserved)');
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch {
|
||||
message.error('Failed to delete album');
|
||||
}
|
||||
};
|
||||
|
||||
const photos = album?.photos || [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={album?.title || 'Album Detail'}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={600}
|
||||
loading={loading}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Popconfirm title="Delete album? Photos will be preserved." onConfirm={handleDeleteAlbum}>
|
||||
<Button danger icon={<DeleteOutlined />}>Delete Album</Button>
|
||||
</Popconfirm>
|
||||
<Space>
|
||||
{!album?.isPublished && (
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={handlePublish}>
|
||||
Publish Album
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Editable title/description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{editing ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Album title" />
|
||||
<Input.TextArea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description" rows={2} />
|
||||
<Space>
|
||||
<Button type="primary" size="small" onClick={handleSaveMetadata}>Save</Button>
|
||||
<Button size="small" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<strong>{album?.title}</strong>
|
||||
{album?.isPublished && <Tag color="green" style={{ marginLeft: 8 }}>Published</Tag>}
|
||||
</div>
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
</div>
|
||||
{album?.description && <div style={{ color: '#999', marginTop: 4 }}>{album.description}</div>}
|
||||
<div style={{ color: '#666', marginTop: 4, fontSize: 12 }}>
|
||||
{photos.length} photos · {album?.viewCount || 0} views · {album?.upvoteCount || 0} upvotes
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo list */}
|
||||
{photos.length === 0 ? (
|
||||
<Empty description="No photos in this album" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={photos}
|
||||
renderItem={(photo: PhotoAlbumItem) => (
|
||||
<List.Item
|
||||
key={photo.id}
|
||||
actions={[
|
||||
<Button
|
||||
key="cover"
|
||||
size="small"
|
||||
icon={<CrownOutlined />}
|
||||
onClick={() => handleSetCover(photo.id)}
|
||||
type={album?.coverPhotoId === photo.id ? 'primary' : 'default'}
|
||||
>
|
||||
{album?.coverPhotoId === photo.id ? 'Cover' : 'Set Cover'}
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="remove"
|
||||
title="Remove from album?"
|
||||
onConfirm={() => handleRemovePhoto(photo.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
photo.thumbnailUrl ? (
|
||||
<Image
|
||||
src={getAuthenticatedUrl(photo.thumbnailUrl)}
|
||||
width={60}
|
||||
height={45}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PictureOutlined style={{ color: '#555' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{photo.title || photo.originalFilename}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{photo.width}×{photo.height} · {photo.format?.toUpperCase()}
|
||||
{photo.isPublished && <Tag color="green" style={{ marginLeft: 4, fontSize: 10 }}>Published</Tag>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
66
admin/src/components/media/CreateAlbumModal.tsx
Normal file
66
admin/src/components/media/CreateAlbumModal.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
|
||||
interface CreateAlbumModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
selectedPhotoIds?: number[];
|
||||
}
|
||||
|
||||
export default function CreateAlbumModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
selectedPhotoIds = [],
|
||||
}: CreateAlbumModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
await mediaApi.post('/albums', {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
photoIds: selectedPhotoIds.length > 0 ? selectedPhotoIds : undefined,
|
||||
});
|
||||
message.success('Album created');
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create Album"
|
||||
open={open}
|
||||
onOk={handleCreate}
|
||||
onCancel={onClose}
|
||||
confirmLoading={loading}
|
||||
okText="Create"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
|
||||
<Input placeholder="Album title" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={3} placeholder="Optional description" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{selectedPhotoIds.length > 0 && (
|
||||
<div style={{ color: '#999', fontSize: 12 }}>
|
||||
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
149
admin/src/components/media/EditPhotoModal.tsx
Normal file
149
admin/src/components/media/EditPhotoModal.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import type { Photo } from '@/types/media';
|
||||
|
||||
interface EditPhotoModalProps {
|
||||
photo: Photo | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function EditPhotoModal({ photo, open, onClose, onSuccess }: EditPhotoModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (photo && open) {
|
||||
form.setFieldsValue({
|
||||
title: photo.title,
|
||||
description: photo.description,
|
||||
producer: photo.producer,
|
||||
creator: photo.creator,
|
||||
category: photo.category,
|
||||
tags: photo.tags || [],
|
||||
accessLevel: photo.accessLevel || 'free',
|
||||
});
|
||||
}
|
||||
}, [photo, open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!photo) return;
|
||||
const values = form.getFieldsValue();
|
||||
try {
|
||||
await mediaApi.patch(`/photos/${photo.id}`, values);
|
||||
message.success('Photo updated');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to update photo');
|
||||
}
|
||||
};
|
||||
|
||||
if (!photo) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Edit Photo"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button type="primary" onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="Title">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="producer" label="Producer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="creator" label="Creator">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="Category">
|
||||
<Input placeholder="e.g. photos, events, portraits" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="Tags">
|
||||
<Select mode="tags" placeholder="Add tags" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessLevel" label="Access Level">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'premium', label: 'Premium' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* EXIF Info (read-only) */}
|
||||
{(photo.cameraMake || photo.cameraModel || photo.takenAt) && (
|
||||
<Descriptions
|
||||
title="EXIF Data"
|
||||
size="small"
|
||||
column={1}
|
||||
bordered
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{photo.cameraMake && (
|
||||
<Descriptions.Item label="Camera">{photo.cameraMake} {photo.cameraModel || ''}</Descriptions.Item>
|
||||
)}
|
||||
{photo.focalLength && (
|
||||
<Descriptions.Item label="Focal Length">{photo.focalLength}</Descriptions.Item>
|
||||
)}
|
||||
{photo.aperture && (
|
||||
<Descriptions.Item label="Aperture">{photo.aperture}</Descriptions.Item>
|
||||
)}
|
||||
{photo.shutterSpeed && (
|
||||
<Descriptions.Item label="Shutter">{photo.shutterSpeed}</Descriptions.Item>
|
||||
)}
|
||||
{photo.iso && (
|
||||
<Descriptions.Item label="ISO">{photo.iso}</Descriptions.Item>
|
||||
)}
|
||||
{photo.takenAt && (
|
||||
<Descriptions.Item label="Taken At">{new Date(photo.takenAt).toLocaleString()}</Descriptions.Item>
|
||||
)}
|
||||
{photo.gpsLatitude && photo.gpsLongitude && (
|
||||
<Descriptions.Item label="GPS">
|
||||
{photo.gpsLatitude.toFixed(6)}, {photo.gpsLongitude.toFixed(6)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<Descriptions
|
||||
title="File Info"
|
||||
size="small"
|
||||
column={1}
|
||||
bordered
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Descriptions.Item label="Dimensions">
|
||||
{photo.width}×{photo.height} ({photo.orientation})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Format">
|
||||
{photo.format?.toUpperCase()}
|
||||
{photo.hasAlpha ? ' (alpha)' : ''}
|
||||
</Descriptions.Item>
|
||||
{photo.colorSpace && (
|
||||
<Descriptions.Item label="Color Space">{photo.colorSpace}</Descriptions.Item>
|
||||
)}
|
||||
{photo.dpi && (
|
||||
<Descriptions.Item label="DPI">{photo.dpi}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Original">
|
||||
{photo.originalFilename}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
347
admin/src/components/media/ExpandedAlbumCard.tsx
Normal file
347
admin/src/components/media/ExpandedAlbumCard.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd';
|
||||
import {
|
||||
CloseOutlined,
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
EyeOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
PictureOutlined,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||
import type { PublicAlbum } from '@/types/media';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface AlbumPhoto {
|
||||
id: number;
|
||||
title: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
format: string | null;
|
||||
thumbnailUrl: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface ExpandedAlbumCardProps {
|
||||
album: PublicAlbum;
|
||||
}
|
||||
|
||||
export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
|
||||
const { token } = theme.useToken();
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { collapseVideo } = useExpandedVideo();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasUpvoted, setHasUpvoted] = useState(false);
|
||||
const [upvoteCount, setUpvoteCount] = useState(album.upvoteCount);
|
||||
const [upvoting, setUpvoting] = useState(false);
|
||||
const [isExpanding, setIsExpanding] = useState(true);
|
||||
const [photos, setPhotos] = useState<AlbumPhoto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const pad = isMobile ? 8 : 12;
|
||||
|
||||
// Fetch album photos
|
||||
useEffect(() => {
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
const { data } = await mediaPublicApi.get(`/public/albums/${album.id}`);
|
||||
setPhotos(data.photos || []);
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPhotos();
|
||||
}, [album.id]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') collapseVideo();
|
||||
if (e.key === 'ArrowLeft') setCurrentIndex(prev => Math.max(0, prev - 1));
|
||||
if (e.key === 'ArrowRight') setCurrentIndex(prev => Math.min(photos.length - 1, prev + 1));
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [collapseVideo, photos.length]);
|
||||
|
||||
// Expand animation
|
||||
useEffect(() => {
|
||||
const timer = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setIsExpanding(false));
|
||||
});
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, []);
|
||||
|
||||
// Scroll into view
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: isMobile ? 'auto' : 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMobile]);
|
||||
|
||||
const handleUpvote = async () => {
|
||||
if (upvoting || hasUpvoted) return;
|
||||
try {
|
||||
setUpvoting(true);
|
||||
// Albums don't have a direct upvote — upvote the current photo instead
|
||||
if (photos[currentIndex]) {
|
||||
await mediaPublicApi.post(`/photos/${photos[currentIndex].id}/upvote`);
|
||||
}
|
||||
setHasUpvoted(true);
|
||||
setUpvoteCount(prev => prev + 1);
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401) {
|
||||
message.info('Please log in to upvote');
|
||||
}
|
||||
} finally {
|
||||
setUpvoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCount = (count: number) => {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
|
||||
maxHeight: isExpanding ? 0 : 3000,
|
||||
opacity: isExpanding ? 0 : 1,
|
||||
marginLeft: -pad,
|
||||
marginRight: -pad,
|
||||
width: `calc(100% + ${pad * 2}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Image carousel */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)',
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Spin size="large" />
|
||||
) : currentPhoto ? (
|
||||
<>
|
||||
{imageLoading && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={currentPhoto.id}
|
||||
src={currentPhoto.imageUrl}
|
||||
alt={currentPhoto.title || `Photo ${currentIndex + 1}`}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Left arrow */}
|
||||
{currentIndex > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageLoading(true);
|
||||
setCurrentIndex(prev => prev - 1);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{currentIndex < photos.length - 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageLoading(true);
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo counter */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
color: '#fff',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 16,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#666', fontSize: 16 }}>No photos in this album</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{photos.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '8px 12px',
|
||||
overflowX: 'auto',
|
||||
background: token.colorBgElevated,
|
||||
borderTop: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
{photos.map((p, idx) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setImageLoading(true);
|
||||
setCurrentIndex(idx);
|
||||
}}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
border: idx === currentIndex
|
||||
? `2px solid ${token.colorPrimary}`
|
||||
: '2px solid transparent',
|
||||
opacity: idx === currentIndex ? 1 : 0.6,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={p.thumbnailUrl}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom info bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: isMobile ? '6px 12px' : '6px 16px',
|
||||
borderTop: `1px solid ${token.colorBorder}`,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={collapseVideo}
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
fontWeight: 500,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
|
||||
<Space size={8} style={{ flexShrink: 0 }}>
|
||||
<Tag style={{ margin: 0, fontSize: 10 }}>
|
||||
<AppstoreOutlined /> Album
|
||||
</Tag>
|
||||
<Tag style={{ margin: 0, fontSize: 10 }}>
|
||||
<PictureOutlined /> {album.photoCount} photos
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
|
||||
<span><EyeOutlined /> {formatCount(album.viewCount)}</span>
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
type={hasUpvoted ? 'primary' : 'text'}
|
||||
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
|
||||
onClick={handleUpvote}
|
||||
loading={upvoting}
|
||||
disabled={hasUpvoted}
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{formatCount(upvoteCount)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
admin/src/components/media/ExpandedPhotoCard.tsx
Normal file
238
admin/src/components/media/ExpandedPhotoCard.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd';
|
||||
import {
|
||||
CloseOutlined,
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
EyeOutlined,
|
||||
CommentOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
CameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||
import type { PublicPhoto } from '@/types/media';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface ExpandedPhotoCardProps {
|
||||
photo: PublicPhoto;
|
||||
}
|
||||
|
||||
export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
|
||||
const { token } = theme.useToken();
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { collapseVideo } = useExpandedVideo();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasUpvoted, setHasUpvoted] = useState(false);
|
||||
const [upvoteCount, setUpvoteCount] = useState(photo.upvoteCount);
|
||||
const [upvoting, setUpvoting] = useState(false);
|
||||
const [isExpanding, setIsExpanding] = useState(true);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const pad = isMobile ? 8 : 12;
|
||||
const title = photo.title || 'Untitled Photo';
|
||||
|
||||
// Use large image URL
|
||||
const imageUrl = photo.imageUrl || photo.thumbnailUrl;
|
||||
|
||||
// Handle keyboard
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') collapseVideo();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [collapseVideo]);
|
||||
|
||||
// Expand animation
|
||||
useEffect(() => {
|
||||
const timer = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setIsExpanding(false));
|
||||
});
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, []);
|
||||
|
||||
// Scroll into view
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: isMobile ? 'auto' : 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMobile]);
|
||||
|
||||
// Track view
|
||||
useEffect(() => {
|
||||
mediaPublicApi.post('/photos/' + photo.id + '/view').catch(() => {});
|
||||
}, [photo.id]);
|
||||
|
||||
const handleUpvote = async () => {
|
||||
if (upvoting || hasUpvoted) return;
|
||||
try {
|
||||
setUpvoting(true);
|
||||
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
|
||||
setHasUpvoted(true);
|
||||
setUpvoteCount(prev => prev + 1);
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401) {
|
||||
message.info('Please log in to upvote');
|
||||
}
|
||||
} finally {
|
||||
setUpvoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCount = (count: number) => {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
|
||||
maxHeight: isExpanding ? 0 : 3000,
|
||||
opacity: isExpanding ? 0 : 1,
|
||||
marginLeft: -pad,
|
||||
marginRight: -pad,
|
||||
width: `calc(100% + ${pad * 2}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Image section */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
cursor: zoomed ? 'zoom-out' : 'zoom-in',
|
||||
}}
|
||||
onClick={() => setZoomed(!zoomed)}
|
||||
>
|
||||
{imageLoading && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
style={{
|
||||
maxWidth: zoomed ? 'none' : '100%',
|
||||
maxHeight: zoomed ? 'none' : isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
|
||||
width: zoomed ? 'auto' : undefined,
|
||||
objectFit: 'contain',
|
||||
transition: 'transform 0.3s ease',
|
||||
transform: zoomed ? 'scale(1.5)' : 'scale(1)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zoom indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 4,
|
||||
padding: '4px 8px',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{zoomed ? <ZoomOutOutlined /> : <ZoomInOutlined />}
|
||||
{zoomed ? 'Click to zoom out' : 'Click to zoom in'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom info bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: isMobile ? '6px 12px' : '6px 16px',
|
||||
borderTop: `1px solid ${token.colorBorder}`,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={collapseVideo}
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
fontWeight: 500,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<Space size={8} style={{ flexShrink: 0 }}>
|
||||
<Tag style={{ margin: 0, fontSize: 10 }}>
|
||||
<CameraOutlined /> Photo
|
||||
</Tag>
|
||||
{photo.format && (
|
||||
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>{photo.format.toUpperCase()}</Tag>
|
||||
)}
|
||||
{photo.width && photo.height && (
|
||||
<Tag style={{ margin: 0, fontSize: 10 }}>{photo.width}×{photo.height}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
|
||||
<span><EyeOutlined /> {formatCount(photo.viewCount)}</span>
|
||||
<span><CommentOutlined /> {formatCount(photo.commentCount)}</span>
|
||||
</Space>
|
||||
|
||||
{/* Upvote */}
|
||||
<Button
|
||||
type={hasUpvoted ? 'primary' : 'text'}
|
||||
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
|
||||
onClick={handleUpvote}
|
||||
loading={upvoting}
|
||||
disabled={hasUpvoted}
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{formatCount(upvoteCount)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Input, Select, theme, Grid } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Input, Select, Button, theme, Grid } from 'antd';
|
||||
import { SearchOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@ -12,6 +13,7 @@ export default function MediaBottomNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
// Initialize from URL params
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
|
||||
@ -75,6 +77,17 @@ export default function MediaBottomNav() {
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{isMobile && settings?.enableInfluence !== false && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => navigate('/campaigns')}
|
||||
style={{ color: token.colorTextSecondary, flexShrink: 0 }}
|
||||
>
|
||||
{!isShorts && 'Action'}
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search videos..."
|
||||
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
HomeOutlined,
|
||||
ThunderboltOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
StarOutlined,
|
||||
PlayCircleOutlined,
|
||||
TeamOutlined,
|
||||
@ -125,7 +126,8 @@ export default function MediaSidebar() {
|
||||
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
||||
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
|
||||
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
|
||||
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
|
||||
{ key: 'photos', label: 'Photos', icon: <PictureOutlined />, path: '/gallery/photos' },
|
||||
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
|
||||
{ key: 'playback', label: 'Playback', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
|
||||
];
|
||||
|
||||
@ -204,7 +206,7 @@ export default function MediaSidebar() {
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
}}
|
||||
>
|
||||
Video Platform
|
||||
Media Platform
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
247
admin/src/components/media/PhotoCard.tsx
Normal file
247
admin/src/components/media/PhotoCard.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { Card, Checkbox, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
FolderOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
import type { Photo } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
}
|
||||
|
||||
interface PhotoCardProps {
|
||||
photo: Photo;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: number) => void;
|
||||
onClick?: (photo: Photo) => void;
|
||||
onEdit?: (photo: Photo) => void;
|
||||
onPreview?: (photo: Photo) => void;
|
||||
onDelete?: (photo: Photo) => void;
|
||||
onTogglePublish?: (photo: Photo) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(sizeStr: string | null): string {
|
||||
if (!sizeStr) return '';
|
||||
const bytes = parseInt(sizeStr);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function PhotoCard({
|
||||
photo,
|
||||
selected,
|
||||
onSelect,
|
||||
onClick,
|
||||
onEdit,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onTogglePublish,
|
||||
}: PhotoCardProps) {
|
||||
const thumbnailUrl = photo.thumbnailUrl;
|
||||
|
||||
const hoverActions = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
className="photo-card-hover"
|
||||
>
|
||||
{onEdit && (
|
||||
<Tooltip title="Edit">
|
||||
<EditOutlined
|
||||
style={{ color: '#fff', fontSize: 18, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(photo); }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onPreview && (
|
||||
<Tooltip title="Preview">
|
||||
<EyeOutlined
|
||||
style={{ color: '#fff', fontSize: 18, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); onPreview(photo); }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Tooltip title="Delete">
|
||||
<DeleteOutlined
|
||||
style={{ color: '#ff4d4f', fontSize: 18, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(photo); }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
style={{ position: 'relative', overflow: 'hidden' }}
|
||||
styles={{ body: { padding: 8 } }}
|
||||
onClick={() => onClick?.(photo)}
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingTop: '75%', // 4:3 aspect ratio
|
||||
backgroundColor: '#1a1a1a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={getAuthenticatedUrl(thumbnailUrl)}
|
||||
alt={photo.title || photo.filename}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 32, color: '#555' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection checkbox */}
|
||||
{onSelect && (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={(e) => { e.stopPropagation(); onSelect(photo.id); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 6, left: 6, zIndex: 2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Publish toggle pill */}
|
||||
{onTogglePublish && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePublish(photo);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
cursor: 'pointer',
|
||||
background: photo.isPublished
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
|
||||
color: '#fff',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 20,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'all 0.2s ease',
|
||||
zIndex: 11,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
title={photo.isPublished ? 'Click to unpublish' : 'Click to publish'}
|
||||
role="button"
|
||||
aria-label={photo.isPublished ? 'Unpublish photo' : 'Publish photo'}
|
||||
>
|
||||
{photo.isPublished ? (
|
||||
<>
|
||||
<CheckCircleOutlined style={{ fontSize: 13 }} />
|
||||
<span>Published</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClockCircleOutlined style={{ fontSize: 13 }} />
|
||||
<span>Draft</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Album badge */}
|
||||
{photo.album && (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0 }}
|
||||
>
|
||||
<FolderOutlined /> {photo.album.title}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Format badge */}
|
||||
{photo.format && (
|
||||
<Tag
|
||||
style={{ position: 'absolute', bottom: 6, right: 6, margin: 0, fontSize: 10 }}
|
||||
>
|
||||
{photo.format.toUpperCase()}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{hoverActions}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{photo.title || photo.originalFilename || photo.filename}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
{photo.width && photo.height ? `${photo.width}×${photo.height}` : ''}
|
||||
{photo.fileSize ? ` · ${formatFileSize(photo.fileSize)}` : ''}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<style>{`
|
||||
.photo-card-hover { opacity: 0 !important; }
|
||||
.ant-card:hover .photo-card-hover { opacity: 1 !important; }
|
||||
`}</style>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
admin/src/components/media/PhotoViewerModal.tsx
Normal file
103
admin/src/components/media/PhotoViewerModal.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Modal, Descriptions, Tag } from 'antd';
|
||||
import { CameraOutlined } from '@ant-design/icons';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
import type { Photo } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
}
|
||||
|
||||
interface PhotoViewerModalProps {
|
||||
photo: Photo | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) {
|
||||
if (!photo) return null;
|
||||
|
||||
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Image */}
|
||||
<div
|
||||
style={{
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
maxHeight: '60vh',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getAuthenticatedUrl(adminImageUrl)}
|
||||
alt={photo.title || photo.filename}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '60vh',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
<div style={{ padding: '0 24px 24px' }}>
|
||||
<h3 style={{ margin: '0 0 8px' }}>
|
||||
{photo.title || photo.originalFilename || photo.filename}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{photo.format && <Tag>{photo.format.toUpperCase()}</Tag>}
|
||||
{photo.width && photo.height && <Tag>{photo.width}×{photo.height}</Tag>}
|
||||
{photo.orientation && <Tag>{photo.orientation === 'H' ? 'Landscape' : photo.orientation === 'V' ? 'Portrait' : 'Square'}</Tag>}
|
||||
{photo.isPublished && <Tag color="green">Published</Tag>}
|
||||
</div>
|
||||
|
||||
{(photo.cameraMake || photo.cameraModel) && (
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 12 }}>
|
||||
{(photo.cameraMake || photo.cameraModel) && (
|
||||
<Descriptions.Item label={<><CameraOutlined /> Camera</>}>
|
||||
{[photo.cameraMake, photo.cameraModel].filter(Boolean).join(' ')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{photo.focalLength && (
|
||||
<Descriptions.Item label="Focal">{photo.focalLength}</Descriptions.Item>
|
||||
)}
|
||||
{photo.aperture && (
|
||||
<Descriptions.Item label="Aperture">{photo.aperture}</Descriptions.Item>
|
||||
)}
|
||||
{photo.shutterSpeed && (
|
||||
<Descriptions.Item label="Shutter">{photo.shutterSpeed}</Descriptions.Item>
|
||||
)}
|
||||
{photo.iso && (
|
||||
<Descriptions.Item label="ISO">{photo.iso}</Descriptions.Item>
|
||||
)}
|
||||
{photo.takenAt && (
|
||||
<Descriptions.Item label="Date">{new Date(photo.takenAt).toLocaleString()}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{photo.description && (
|
||||
<p style={{ color: '#999', fontSize: 13 }}>{photo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
229
admin/src/components/media/PublicAlbumCard.tsx
Normal file
229
admin/src/components/media/PublicAlbumCard.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Tag, Space, Typography, theme } from 'antd';
|
||||
import { PictureOutlined, LikeOutlined, EyeOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||
import { hexToRgba } from '@/utils/color';
|
||||
import type { PublicAlbum } from '@/types/media';
|
||||
|
||||
interface PublicAlbumCardProps {
|
||||
album: PublicAlbum;
|
||||
}
|
||||
|
||||
export default function PublicAlbumCard({ album }: PublicAlbumCardProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { expandMedia } = useExpandedVideo();
|
||||
const [thumbnailError, setThumbnailError] = useState(false);
|
||||
|
||||
const formatCount = (count: number | undefined | null) => {
|
||||
if (!count && count !== 0) return '0';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
expandMedia(album.id, 'album', album);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = e.currentTarget;
|
||||
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = e.currentTarget;
|
||||
card.style.boxShadow = 'none';
|
||||
card.style.transform = 'translateY(0)';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleCardClick}
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingTop: '75%', // 4:3 aspect
|
||||
background: '#000',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Cover thumbnail */}
|
||||
{album.coverThumbnailUrl && !thumbnailError ? (
|
||||
<img
|
||||
src={album.coverThumbnailUrl}
|
||||
alt={album.title}
|
||||
onError={() => setThumbnailError(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 48,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked photos effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Expand overlay on hover */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
background: hexToRgba(token.colorPrimary, 0.9),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<AppstoreOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo count badge */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 11 }} />
|
||||
{album.photoCount} photos
|
||||
</div>
|
||||
|
||||
{/* Category badge */}
|
||||
{album.category && (
|
||||
<Tag
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
margin: 0,
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{album.category}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Album indicator */}
|
||||
<Tag
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
margin: 0,
|
||||
background: token.colorPrimary,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Album
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: token.colorText,
|
||||
}}
|
||||
title={album.title}
|
||||
>
|
||||
{album.title}
|
||||
</Typography.Text>
|
||||
|
||||
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
<Space size={4}>
|
||||
<LikeOutlined />
|
||||
<span>{formatCount(album.upvoteCount)}</span>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<EyeOutlined />
|
||||
<span>{formatCount(album.viewCount)}</span>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<PictureOutlined />
|
||||
<span>{album.photoCount}</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
226
admin/src/components/media/PublicPhotoCard.tsx
Normal file
226
admin/src/components/media/PublicPhotoCard.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Tag, Space, Typography, theme } from 'antd';
|
||||
import { CameraOutlined, LikeOutlined, EyeOutlined, CommentOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||
import { hexToRgba } from '@/utils/color';
|
||||
import type { PublicPhoto } from '@/types/media';
|
||||
|
||||
interface PublicPhotoCardProps {
|
||||
photo: PublicPhoto;
|
||||
}
|
||||
|
||||
export default function PublicPhotoCard({ photo }: PublicPhotoCardProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { expandMedia } = useExpandedVideo();
|
||||
const [thumbnailError, setThumbnailError] = useState(false);
|
||||
|
||||
const formatCount = (count: number | undefined | null) => {
|
||||
if (!count && count !== 0) return '0';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const title = photo.title || 'Untitled Photo';
|
||||
|
||||
const handleCardClick = () => {
|
||||
expandMedia(photo.id, 'photo', photo);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = e.currentTarget;
|
||||
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = e.currentTarget;
|
||||
card.style.boxShadow = 'none';
|
||||
card.style.transform = 'translateY(0)';
|
||||
};
|
||||
|
||||
// Choose aspect ratio based on orientation
|
||||
const aspectPadding = photo.orientation === 'V' ? '133.33%' : photo.orientation === 'S' ? '100%' : '75%'; // 3:4, 1:1, or 4:3
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleCardClick}
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingTop: aspectPadding,
|
||||
background: '#000',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{photo.thumbnailUrl && !thumbnailError ? (
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={title}
|
||||
onError={() => setThumbnailError(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 48,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera icon overlay on hover */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
background: hexToRgba(token.colorPrimary, 0.9),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<CameraOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
{photo.format && (
|
||||
<Tag
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
margin: 0,
|
||||
background: token.colorPrimary,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{photo.format}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{photo.category && (
|
||||
<Tag
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
margin: 0,
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{photo.category}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Dimensions badge */}
|
||||
{photo.width && photo.height && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{photo.width}×{photo.height}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: token.colorText,
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
|
||||
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
<Space size={4}>
|
||||
<LikeOutlined />
|
||||
<span>{formatCount(photo.upvoteCount)}</span>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<EyeOutlined />
|
||||
<span>{formatCount(photo.viewCount)}</span>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<CommentOutlined />
|
||||
<span>{formatCount(photo.commentCount)}</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
186
admin/src/components/media/UploadPhotoDrawer.tsx
Normal file
186
admin/src/components/media/UploadPhotoDrawer.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, Tag } from 'antd';
|
||||
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import type { PhotoAlbum } from '@/types/media';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface UploadPhotoDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
albumId?: number; // Pre-select album
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
filename: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = '.jpg,.jpeg,.png,.webp,.avif,.gif,.tiff,.tif,.heic,.heif';
|
||||
|
||||
export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }: UploadPhotoDrawerProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [results, setResults] = useState<UploadResult[]>([]);
|
||||
const [albums, setAlbums] = useState<PhotoAlbum[]>([]);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchAlbums();
|
||||
setResults([]);
|
||||
setProgress(0);
|
||||
setFileList([]);
|
||||
form.resetFields();
|
||||
if (albumId) form.setFieldsValue({ albumId });
|
||||
}
|
||||
}, [open, albumId]);
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
try {
|
||||
const { data } = await mediaApi.get('/albums', { params: { limit: 200 } });
|
||||
setAlbums(data.albums || []);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.warning('Select at least one photo');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
setUploading(true);
|
||||
setResults([]);
|
||||
|
||||
const uploadResults: UploadResult[] = [];
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i].originFileObj || fileList[i];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (values.title && fileList.length === 1) formData.append('title', values.title);
|
||||
if (values.producer) formData.append('producer', values.producer);
|
||||
if (values.creator) formData.append('creator', values.creator);
|
||||
if (values.albumId) formData.append('albumId', String(values.albumId));
|
||||
|
||||
try {
|
||||
await mediaApi.post('/photos/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
uploadResults.push({ filename: file.name, success: true });
|
||||
} catch (error: any) {
|
||||
uploadResults.push({
|
||||
filename: file.name,
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Upload failed',
|
||||
});
|
||||
}
|
||||
|
||||
setProgress(Math.round(((i + 1) / fileList.length) * 100));
|
||||
setResults([...uploadResults]);
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
const successCount = uploadResults.filter(r => r.success).length;
|
||||
if (successCount > 0) {
|
||||
message.success(`Uploaded ${successCount} photo${successCount > 1 ? 's' : ''}`);
|
||||
onSuccess();
|
||||
}
|
||||
if (successCount < fileList.length) {
|
||||
message.error(`${fileList.length - successCount} upload(s) failed`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Upload Photos"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
loading={uploading}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
Upload {fileList.length > 0 ? `(${fileList.length})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Dragger
|
||||
accept={ACCEPTED_TYPES}
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
fileList={fileList}
|
||||
onChange={({ fileList: fl }) => setFileList(fl)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag photos here</p>
|
||||
<p className="ant-upload-hint">
|
||||
JPG, PNG, WebP, AVIF, GIF, TIFF, HEIC
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{fileList.length === 1 && (
|
||||
<Form.Item name="title" label="Title">
|
||||
<Input placeholder="Photo title" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="producer" label="Producer">
|
||||
<Input placeholder="Producer name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="creator" label="Creator">
|
||||
<Input placeholder="Creator name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="albumId" label="Add to Album">
|
||||
<Select
|
||||
placeholder="No album"
|
||||
allowClear
|
||||
options={albums.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.title} (${a.photoCount} photos)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{uploading && <Progress percent={progress} style={{ marginBottom: 16 }} />}
|
||||
|
||||
{results.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={results}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
{item.success ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">{item.filename}</Tag>
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
{item.filename}: {item.error}
|
||||
</Tag>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -17,14 +17,19 @@ export interface VideoData {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ExpandedMediaType = 'video' | 'photo' | 'album';
|
||||
|
||||
interface ExpandedVideoState {
|
||||
videoId: number | null;
|
||||
video: VideoData | null;
|
||||
mediaType: ExpandedMediaType;
|
||||
mediaData: any; // For photo/album expansion
|
||||
}
|
||||
|
||||
interface ExpandedVideoContextValue {
|
||||
state: ExpandedVideoState;
|
||||
expandVideo: (id: number, video: VideoData) => void;
|
||||
expandMedia: (id: number, type: ExpandedMediaType, data: any) => void;
|
||||
collapseVideo: () => void;
|
||||
}
|
||||
|
||||
@ -48,10 +53,12 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
|
||||
const [state, setState] = useState<ExpandedVideoState>({
|
||||
videoId: null,
|
||||
video: null,
|
||||
mediaType: 'video',
|
||||
mediaData: null,
|
||||
});
|
||||
|
||||
const expandVideo = useCallback((id: number, video: VideoData) => {
|
||||
setState({ videoId: id, video });
|
||||
setState({ videoId: id, video, mediaType: 'video', mediaData: video });
|
||||
|
||||
// Update URL with ?expanded=id (read current params at call time)
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
@ -59,8 +66,21 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
|
||||
navigate({ search: newParams.toString() }, { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const expandMedia = useCallback((id: number, type: ExpandedMediaType, data: any) => {
|
||||
setState({
|
||||
videoId: type === 'video' ? id : null,
|
||||
video: type === 'video' ? data : null,
|
||||
mediaType: type,
|
||||
mediaData: data,
|
||||
});
|
||||
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
newParams.set('expanded', `${type}-${id}`);
|
||||
navigate({ search: newParams.toString() }, { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const collapseVideo = useCallback(() => {
|
||||
setState({ videoId: null, video: null });
|
||||
setState({ videoId: null, video: null, mediaType: 'video', mediaData: null });
|
||||
|
||||
// Remove URL param (read current params at call time)
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
@ -71,6 +91,7 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
|
||||
const value: ExpandedVideoContextValue = {
|
||||
state,
|
||||
expandVideo,
|
||||
expandMedia,
|
||||
collapseVideo,
|
||||
};
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import AdminLiveMap from '@/components/canvass/AdminLiveMap';
|
||||
import HistoricalRoutesDrawer from '@/components/canvass/HistoricalRoutesDrawer';
|
||||
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
|
||||
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
|
||||
import CanvassTrendsCard from '@/components/canvass/CanvassTrendsCard';
|
||||
|
||||
export default function CanvassDashboardPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
@ -350,6 +351,11 @@ export default function CanvassDashboardPage() {
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Outcome Trends */}
|
||||
<div style={{ marginTop: 16, padding: isMobile ? '0' : undefined }}>
|
||||
<CanvassTrendsCard />
|
||||
</div>
|
||||
|
||||
{/* Historical Routes Drawer */}
|
||||
<HistoricalRoutesDrawer
|
||||
open={historyOpen}
|
||||
|
||||
@ -53,6 +53,16 @@ import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
|
||||
import ActivityFeedCard from '@/components/dashboard/ActivityFeedCard';
|
||||
import TodayEventsCard from '@/components/dashboard/TodayEventsCard';
|
||||
import ChatNotifierCard from '@/components/dashboard/ChatNotifierCard';
|
||||
import TopVideosCard from '@/components/dashboard/TopVideosCard';
|
||||
import RecentCommentsCard from '@/components/dashboard/RecentCommentsCard';
|
||||
import DocsAnalyticsCard from '@/components/dashboard/DocsAnalyticsCard';
|
||||
import UpcomingShiftsCard from '@/components/dashboard/UpcomingShiftsCard';
|
||||
import CampaignEffectivenessCard from '@/components/dashboard/CampaignEffectivenessCard';
|
||||
import RecentSignupsCard from '@/components/dashboard/RecentSignupsCard';
|
||||
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
|
||||
import DonationSummaryCard from '@/components/dashboard/DonationSummaryCard';
|
||||
import SystemAlertsCard from '@/components/dashboard/SystemAlertsCard';
|
||||
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
import type {
|
||||
DashboardSummary, QueueStats, ServicesStatus, ServicesConfig,
|
||||
@ -78,6 +88,8 @@ if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID))
|
||||
.svc-badge-online .ant-badge-status-dot {
|
||||
animation: dashboard-pulse 2s infinite;
|
||||
}
|
||||
.db-mi { min-width: 0; display: flex; flex-direction: column; }
|
||||
.db-mi > * { flex: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@ -234,6 +246,17 @@ export default function DashboardPage() {
|
||||
const showInfluence = settings?.enableInfluence !== false;
|
||||
const showMap = settings?.enableMap !== false;
|
||||
const showMedia = settings?.enableMediaFeatures !== false;
|
||||
const showEvents = settings?.enableEvents !== false;
|
||||
const showChat = settings?.enableChat !== false;
|
||||
const showNewsletter = settings?.enableNewsletter !== false;
|
||||
const showPayments = settings?.enablePayments !== false;
|
||||
|
||||
// Grid span helper: 12-col on lg, 2-col on md, 1-col on xs
|
||||
const gs = (lg: number, md?: number): React.CSSProperties | undefined => {
|
||||
if (screens.lg) return { gridColumn: `span ${lg}` };
|
||||
if (screens.md && md) return { gridColumn: `span ${md}` };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const geocodePct = summary && summary.locations.total > 0
|
||||
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
|
||||
@ -311,28 +334,28 @@ export default function DashboardPage() {
|
||||
</Flex>
|
||||
{activeView === 'dashboard' && (
|
||||
<Flex gap={4} wrap="wrap" justify="flex-end">
|
||||
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
|
||||
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
|
||||
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
|
||||
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
|
||||
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
|
||||
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
|
||||
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
|
||||
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
|
||||
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
|
||||
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
|
||||
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
|
||||
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
|
||||
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
|
||||
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
|
||||
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
|
||||
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
|
||||
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
|
||||
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
|
||||
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
|
||||
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
|
||||
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
|
||||
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
|
||||
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
|
||||
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
|
||||
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
|
||||
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 18 }} />} onClick={handleRefresh} /></Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
{activeView === 'homepage' && (
|
||||
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
|
||||
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 18 }} />} onClick={handleRefresh} /></Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
@ -400,14 +423,14 @@ export default function DashboardPage() {
|
||||
|
||||
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 16px' } }}>
|
||||
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '10px 16px' } }}>
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
|
||||
<Flex gap={0} wrap="wrap" align="center">
|
||||
{weather && (
|
||||
<Flex align="center" gap={6} style={{ padding: '0 12px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 20 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
|
||||
<Text strong style={{ fontSize: 14 }}>{Math.round(weather.temperature)}°C</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{weather.weatherDescription}</Text>
|
||||
<Flex align="center" gap={6} style={{ padding: '0 14px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 24 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
|
||||
<Text strong style={{ fontSize: 16 }}>{Math.round(weather.temperature)}°C</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{weather.weatherDescription}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{/* Quick stat chips */}
|
||||
@ -480,110 +503,107 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* === Module Overview Row (3 columns) === */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
{/* === Dashboard Cards (masonry layout for dense packing) === */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: screens.lg ? 'repeat(12, 1fr)' : screens.md ? 'repeat(2, 1fr)' : '1fr', gap: 12, gridAutoFlow: 'dense' }}>
|
||||
{showInfluence && (
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={6}>
|
||||
<SendOutlined />
|
||||
<span>Influence</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.campaigns.active} active / {summary.campaigns.total}</Text>}
|
||||
<SendOutlined style={{ fontSize: 16 }} />
|
||||
<span style={{ fontSize: 14 }}>Influence</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.campaigns.active} active / {summary.campaigns.total}</Text>}
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" size="small" onClick={() => navigate('/app/campaigns')}>View</Button>}
|
||||
style={{ height: '100%' }}
|
||||
extra={<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>}
|
||||
>
|
||||
{summary && (
|
||||
<Flex gap={8} align="flex-start">
|
||||
<Space direction="vertical" style={{ width: '100%', flex: 1 }} size={4}>
|
||||
<Space direction="vertical" style={{ width: '100%', flex: 1 }} size={6}>
|
||||
<Flex gap={4} wrap="wrap">
|
||||
<Tag color="green">{summary.campaigns.active} Active</Tag>
|
||||
<Tag>{summary.campaigns.draft} Draft</Tag>
|
||||
{summary.campaigns.paused > 0 && <Tag color="orange">{summary.campaigns.paused} Paused</Tag>}
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Responses: <Text strong>{summary.responses.total}</Text></Text>
|
||||
<Text style={{ fontSize: 13 }}>Responses: <Text strong>{summary.responses.total}</Text></Text>
|
||||
{summary.responses.pending > 0 && <Tag color="orange" style={{ margin: 0 }}>{summary.responses.pending} pending</Tag>}
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Emails: <Text strong>{summary.emails.sent}</Text> sent</Text>
|
||||
<Text style={{ fontSize: 13 }}>Emails: <Text strong>{summary.emails.sent}</Text> sent</Text>
|
||||
{summary.emails.failed > 0 && <Text type="danger">{summary.emails.failed} failed</Text>}
|
||||
</Flex>
|
||||
{queue && (
|
||||
<Flex justify="space-between">
|
||||
<Text>Queue: <Text strong>{queue.waiting}</Text> waiting</Text>
|
||||
<Text style={{ fontSize: 13 }}>Queue: <Text strong>{queue.waiting}</Text> waiting</Text>
|
||||
{queue.paused && <Tag color="red" style={{ margin: 0 }}>Paused</Tag>}
|
||||
</Flex>
|
||||
)}
|
||||
</Space>
|
||||
{summary.campaigns.total > 0 && screens.md && (
|
||||
<div style={{ width: 80, flexShrink: 0 }}>
|
||||
<MiniDonutChart data={campaignDonutData} height={80} innerRadius={20} outerRadius={34} />
|
||||
<div style={{ width: 90, flexShrink: 0 }}>
|
||||
<MiniDonutChart data={campaignDonutData} height={90} innerRadius={22} outerRadius={38} />
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMap && (
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={6}>
|
||||
<CompassOutlined />
|
||||
<span>Map</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.locations.total.toLocaleString()} locations</Text>}
|
||||
<CompassOutlined style={{ fontSize: 16 }} />
|
||||
<span style={{ fontSize: 14 }}>Map</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.locations.total.toLocaleString()} locations</Text>}
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" size="small" onClick={() => navigate('/app/map')}>View</Button>}
|
||||
style={{ height: '100%' }}
|
||||
extra={<Button type="link" onClick={() => navigate('/app/map')}>View</Button>}
|
||||
>
|
||||
{summary && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
<Flex align="center" gap={8}>
|
||||
<Text>Geocoded:</Text>
|
||||
<Text style={{ fontSize: 13 }}>Geocoded:</Text>
|
||||
<Progress percent={geocodePct} size="small" style={{ flex: 1 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Addresses: <Text strong>{summary.locations.addresses.toLocaleString()}</Text></Text>
|
||||
<Text type="secondary"><ScissorOutlined /> {summary.cuts.total} cuts</Text>
|
||||
<Text style={{ fontSize: 13 }}>Addresses: <Text strong>{summary.locations.addresses.toLocaleString()}</Text></Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}><ScissorOutlined /> {summary.cuts.total} cuts</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Canvassing: <Text strong>{summary.canvass.totalVisits}</Text> visits</Text>
|
||||
<Text style={{ fontSize: 13 }}>Canvassing: <Text strong>{summary.canvass.totalVisits}</Text> visits</Text>
|
||||
{summary.canvass.activeSessions > 0 && <Tag color="green" style={{ margin: 0 }}>{summary.canvass.activeSessions} active</Tag>}
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Shifts: <Text strong>{summary.shifts.upcoming}</Text> upcoming</Text>
|
||||
<Text type="secondary">{summary.shifts.open} open</Text>
|
||||
<Text style={{ fontSize: 13 }}>Shifts: <Text strong>{summary.shifts.upcoming}</Text> upcoming</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{summary.shifts.open} open</Text>
|
||||
</Flex>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={6}>
|
||||
<TeamOutlined />
|
||||
<span>Users & Content</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.users.total} users</Text>}
|
||||
<TeamOutlined style={{ fontSize: 16 }} />
|
||||
<span style={{ fontSize: 14 }}>Users & Content</span>
|
||||
{summary && <Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{summary.users.total} users</Text>}
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" size="small" onClick={() => navigate('/app/users')}>Manage</Button>}
|
||||
style={{ height: '100%' }}
|
||||
extra={<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>}
|
||||
>
|
||||
{summary && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
<Flex gap={4} wrap="wrap">
|
||||
{Object.entries(summary.users.byRole)
|
||||
.filter(([, count]) => count > 0)
|
||||
@ -595,41 +615,90 @@ export default function DashboardPage() {
|
||||
{summary.users.suspended > 0 && <Tag color="volcano" style={{ margin: 0 }}>Suspended: {summary.users.suspended}</Tag>}
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Pages: <Text strong>{summary.pages.published}</Text> published</Text>
|
||||
<Text type="secondary">/ {summary.pages.total}</Text>
|
||||
<Text style={{ fontSize: 13 }}>Pages: <Text strong>{summary.pages.published}</Text> published</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>/ {summary.pages.total}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text>Templates: <Text strong>{summary.emailTemplates.total}</Text></Text>
|
||||
{showInfluence && <Text type="secondary">Reps: {summary.representatives.totalCached}</Text>}
|
||||
<Text style={{ fontSize: 13 }}>Templates: <Text strong>{summary.emailTemplates.total}</Text></Text>
|
||||
{showInfluence && <Text type="secondary" style={{ fontSize: 13 }}>Reps: {summary.representatives.totalCached}</Text>}
|
||||
</Flex>
|
||||
{showMedia && (
|
||||
<Flex justify="space-between">
|
||||
<Text>Videos: <Text strong>{summary.videos.published}</Text> published</Text>
|
||||
<Text type="secondary">/ {summary.videos.total}</Text>
|
||||
<Text style={{ fontSize: 13 }}>Videos: <Text strong>{summary.videos.published}</Text> published</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>/ {summary.videos.total}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* === Activity Feed + Events + Chat === */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="db-mi" style={gs(5, 2)}>
|
||||
<ActivityFeedCard />
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24}>
|
||||
<TodayEventsCard />
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<ChatNotifierCard />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
{showEvents && (
|
||||
<div className="db-mi" style={gs(3)}>
|
||||
<TodayEventsCard />
|
||||
</div>
|
||||
)}
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<DocsAnalyticsCard />
|
||||
</div>
|
||||
{showChat && (
|
||||
<div className="db-mi" style={gs(5, 2)}>
|
||||
<ChatNotifierCard />
|
||||
</div>
|
||||
)}
|
||||
{showMap && (
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<UpcomingShiftsCard />
|
||||
</div>
|
||||
)}
|
||||
{showInfluence && (
|
||||
<div className="db-mi" style={gs(3)}>
|
||||
<CampaignEffectivenessCard />
|
||||
</div>
|
||||
)}
|
||||
{showMap && (
|
||||
<div className="db-mi" style={gs(3)}>
|
||||
<RecentSignupsCard />
|
||||
</div>
|
||||
)}
|
||||
{showMedia && (
|
||||
<div className="db-mi" style={gs(5)}>
|
||||
<TopVideosCard />
|
||||
</div>
|
||||
)}
|
||||
{showMedia && (
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<RecentCommentsCard />
|
||||
</div>
|
||||
)}
|
||||
{showNewsletter && isSuperAdmin && (
|
||||
<div className="db-mi" style={gs(4)}>
|
||||
<NewsletterStatsCard />
|
||||
</div>
|
||||
)}
|
||||
{showPayments && isSuperAdmin && (
|
||||
<div className="db-mi" style={gs(3)}>
|
||||
<DonationSummaryCard />
|
||||
</div>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<div className="db-mi" style={gs(5)}>
|
||||
<SystemAlertsCard />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Canvass Progress (full-width table) === */}
|
||||
{showMap && (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24}>
|
||||
<CutCampaignAnalyticsCard />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* === System + Docker Section (SUPER_ADMIN only) === */}
|
||||
{isSuperAdmin && (
|
||||
@ -944,17 +1013,17 @@ function QuickStat({ icon, color, value, label, onClick }: {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={5}
|
||||
gap={6}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
padding: '2px 12px',
|
||||
cursor: 'pointer',
|
||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color, fontSize: 14 }}>{icon}</span>
|
||||
<Text strong style={{ fontSize: 14 }}>{value}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
|
||||
<span style={{ color, fontSize: 16 }}>{icon}</span>
|
||||
<Text strong style={{ fontSize: 16 }}>{value}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{label}</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal } from 'antd';
|
||||
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal, Segmented } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
GlobalOutlined,
|
||||
@ -12,19 +12,30 @@ import {
|
||||
ThunderboltOutlined,
|
||||
OrderedListOutlined,
|
||||
LockOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
FolderOutlined,
|
||||
FolderAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import type { Video, VideosListResponse, VideosListParams } from '@/types/media';
|
||||
import type { Video, VideosListResponse, VideosListParams, Photo, PhotosListResponse, PhotoAlbum } from '@/types/media';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import VideoCard from '@/components/media/VideoCard';
|
||||
import PhotoCard from '@/components/media/PhotoCard';
|
||||
import AlbumCard from '@/components/media/AlbumCard';
|
||||
import BulkActionsBar from '@/components/media/BulkActionsBar';
|
||||
import PublishModal from '@/components/media/PublishModal';
|
||||
import DeleteConfirmModal from '@/components/media/DeleteConfirmModal';
|
||||
import UploadVideoDrawer from '@/components/media/UploadVideoDrawer';
|
||||
import UploadPhotoDrawer from '@/components/media/UploadPhotoDrawer';
|
||||
import VideoViewerModal from '@/components/media/VideoViewerModal';
|
||||
import PhotoViewerModal from '@/components/media/PhotoViewerModal';
|
||||
import EditPhotoModal from '@/components/media/EditPhotoModal';
|
||||
import AlbumDetailDrawer from '@/components/media/AlbumDetailDrawer';
|
||||
import CreateAlbumModal from '@/components/media/CreateAlbumModal';
|
||||
import QuickAnalyticsModal from '@/components/media/QuickAnalyticsModal';
|
||||
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
|
||||
import ScheduleCalendarDrawer from '@/components/media/ScheduleCalendarDrawer';
|
||||
@ -34,22 +45,16 @@ import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
|
||||
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
|
||||
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
|
||||
|
||||
type MediaTab = 'Videos' | 'Photos' | 'Albums';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const [mediaTab, setMediaTab] = useLocalStorage<MediaTab>('libraryMediaTab', 'Videos');
|
||||
|
||||
// === Video state ===
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 48, total: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
const [orientation, setOrientation] = useState<'H' | 'V' | undefined>();
|
||||
const [selectedProducers, setSelectedProducers] = useState<string[]>([]);
|
||||
const [producers, setProducers] = useState<string[]>([]);
|
||||
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'compact'>('mediaViewMode', 'grid');
|
||||
const [videoPagination, setVideoPagination] = useState({ page: 1, limit: 48, total: 0 });
|
||||
const [selectedVideoIds, setSelectedVideoIds] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [viewerVideo, setViewerVideo] = useState<Video | null>(null);
|
||||
const [analyticsVideoId, setAnalyticsVideoId] = useState<number | null>(null);
|
||||
const [analyticsVideoTitle, setAnalyticsVideoTitle] = useState<string>('');
|
||||
@ -62,58 +67,140 @@ export default function LibraryPage() {
|
||||
const [playlistVideoId, setPlaylistVideoId] = useState<number | null>(null);
|
||||
const [bulkPlaylistOpen, setBulkPlaylistOpen] = useState(false);
|
||||
const [bulkAccessLevelOpen, setBulkAccessLevelOpen] = useState(false);
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [uploadVideoOpen, setUploadVideoOpen] = useState(false);
|
||||
|
||||
// === Photo state ===
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [photoPagination, setPhotoPagination] = useState({ page: 1, limit: 48, total: 0 });
|
||||
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
|
||||
const [viewerPhoto, setViewerPhoto] = useState<Photo | null>(null);
|
||||
const [editPhoto, setEditPhoto] = useState<Photo | null>(null);
|
||||
const [uploadPhotoOpen, setUploadPhotoOpen] = useState(false);
|
||||
const [createAlbumOpen, setCreateAlbumOpen] = useState(false);
|
||||
const [formatFilter, setFormatFilter] = useState<string | undefined>();
|
||||
const [photoFormats, setPhotoFormats] = useState<string[]>([]);
|
||||
|
||||
// === Album state ===
|
||||
const [albums, setAlbums] = useState<PhotoAlbum[]>([]);
|
||||
const [albumPagination, setAlbumPagination] = useState({ page: 1, limit: 48, total: 0 });
|
||||
const [selectedAlbumId, setSelectedAlbumId] = useState<number | null>(null);
|
||||
|
||||
// === Shared state ===
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
const [orientation, setOrientation] = useState<'H' | 'V' | 'S' | undefined>();
|
||||
const [selectedProducers, setSelectedProducers] = useState<string[]>([]);
|
||||
const [producers, setProducers] = useState<string[]>([]);
|
||||
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'compact'>('mediaViewMode', 'grid');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({
|
||||
title: 'Video Library',
|
||||
subtitle: 'Manage your media collection',
|
||||
title: 'Media Library',
|
||||
subtitle: 'Manage videos, photos, and albums',
|
||||
});
|
||||
}, [setPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducers();
|
||||
fetchPhotoFormats();
|
||||
}, []);
|
||||
|
||||
// Fetch data when tab or filters change
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
|
||||
if (mediaTab === 'Videos') fetchVideos();
|
||||
else if (mediaTab === 'Photos') fetchPhotos();
|
||||
else fetchAlbums();
|
||||
}, [mediaTab, debouncedSearch, orientation, selectedProducers, shortsFilter, formatFilter,
|
||||
videoPagination.page, videoPagination.limit,
|
||||
photoPagination.page, photoPagination.limit,
|
||||
albumPagination.page, albumPagination.limit]);
|
||||
|
||||
const fetchProducers = async () => {
|
||||
try {
|
||||
const { data } = await mediaApi.get<string[]>('/videos/producers');
|
||||
setProducers(data);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load producers:', error);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const fetchPhotoFormats = async () => {
|
||||
try {
|
||||
const { data } = await mediaApi.get<string[]>('/photos/formats');
|
||||
setPhotoFormats(data);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// === Video fetching ===
|
||||
const fetchVideos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: VideosListParams = {
|
||||
search: debouncedSearch || undefined,
|
||||
orientation,
|
||||
orientation: orientation === 'S' ? undefined : orientation as 'H' | 'V' | undefined,
|
||||
producers: selectedProducers.length > 0 ? selectedProducers : undefined,
|
||||
isShort: shortsFilter,
|
||||
offset: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit,
|
||||
offset: (videoPagination.page - 1) * videoPagination.limit,
|
||||
limit: videoPagination.limit,
|
||||
};
|
||||
const { data } = await mediaApi.get<VideosListResponse>('/videos', { params });
|
||||
setVideos(data.videos);
|
||||
setPagination((prev) => ({ ...prev, total: data.total }));
|
||||
setVideoPagination((prev) => ({ ...prev, total: data.total }));
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to load videos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
|
||||
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, videoPagination.page, videoPagination.limit]);
|
||||
|
||||
// === Photo fetching ===
|
||||
const fetchPhotos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
search: debouncedSearch || undefined,
|
||||
orientation,
|
||||
producer: selectedProducers[0] || undefined,
|
||||
format: formatFilter,
|
||||
offset: (photoPagination.page - 1) * photoPagination.limit,
|
||||
limit: photoPagination.limit,
|
||||
};
|
||||
const { data } = await mediaApi.get<PhotosListResponse>('/photos', { params });
|
||||
setPhotos(data.photos);
|
||||
setPhotoPagination((prev) => ({ ...prev, total: data.total }));
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to load photos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch, orientation, selectedProducers, formatFilter, photoPagination.page, photoPagination.limit]);
|
||||
|
||||
// === Album fetching ===
|
||||
const fetchAlbums = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
search: debouncedSearch || undefined,
|
||||
offset: (albumPagination.page - 1) * albumPagination.limit,
|
||||
limit: albumPagination.limit,
|
||||
};
|
||||
const { data } = await mediaApi.get('/albums', { params });
|
||||
setAlbums(data.albums || []);
|
||||
setAlbumPagination((prev) => ({ ...prev, total: data.total }));
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to load albums');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch, albumPagination.page, albumPagination.limit]);
|
||||
|
||||
// === Video handlers ===
|
||||
const handleScanShorts = async () => {
|
||||
setScanningShorts(true);
|
||||
try {
|
||||
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>(
|
||||
'/shorts/scan'
|
||||
);
|
||||
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>('/shorts/scan');
|
||||
message.success(`Classified ${data.classified} shorts, declassified ${data.declassified}. Total shorts: ${data.totalShorts}`);
|
||||
fetchVideos();
|
||||
} catch (error: any) {
|
||||
@ -123,37 +210,22 @@ export default function LibraryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
const handleVideoSelect = (id: number) => {
|
||||
setSelectedVideoIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((videoId) => videoId !== id) : [...prev, id]
|
||||
prev.includes(id) ? prev.filter((vid) => vid !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedVideoIds.length === videos.length) {
|
||||
setSelectedVideoIds([]);
|
||||
} else {
|
||||
setSelectedVideoIds(videos.map((v) => v.id));
|
||||
}
|
||||
const handleVideoSelectAll = () => {
|
||||
if (selectedVideoIds.length === videos.length) setSelectedVideoIds([]);
|
||||
else setSelectedVideoIds(videos.map((v) => v.id));
|
||||
};
|
||||
|
||||
const handlePublishSuccess = () => {
|
||||
setPublishModalOpen(false);
|
||||
setSelectedVideoIds([]);
|
||||
fetchVideos();
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setUploadModalOpen(false);
|
||||
fetchVideos();
|
||||
fetchProducers();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleVideoDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await Promise.all(selectedVideoIds.map((id) => mediaApi.delete(`/videos/${id}`)));
|
||||
message.success(`Successfully deleted ${selectedVideoIds.length} video(s)`);
|
||||
message.success(`Deleted ${selectedVideoIds.length} video(s)`);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedVideoIds([]);
|
||||
fetchVideos();
|
||||
@ -164,7 +236,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingle = (video: Video) => {
|
||||
const handleDeleteSingleVideo = (video: Video) => {
|
||||
Modal.confirm({
|
||||
title: 'Delete this video?',
|
||||
content: video.title || video.filename,
|
||||
@ -173,7 +245,7 @@ export default function LibraryPage() {
|
||||
onOk: async () => {
|
||||
try {
|
||||
await mediaApi.delete(`/videos/${video.id}`);
|
||||
message.success('Video deleted successfully');
|
||||
message.success('Video deleted');
|
||||
fetchVideos();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to delete video');
|
||||
@ -182,225 +254,387 @@ export default function LibraryPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (video: Video) => {
|
||||
setEditVideo(video);
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setEditVideo(null);
|
||||
fetchVideos();
|
||||
fetchProducers();
|
||||
};
|
||||
|
||||
const handleAnalytics = (video: Video) => {
|
||||
setAnalyticsVideoId(video.id);
|
||||
setAnalyticsVideoTitle(video.title || video.filename);
|
||||
};
|
||||
|
||||
const handleSchedule = (video: Video) => {
|
||||
setScheduleVideo(video);
|
||||
};
|
||||
|
||||
const handleScheduleSuccess = () => {
|
||||
setScheduleVideo(null);
|
||||
fetchVideos();
|
||||
};
|
||||
|
||||
const handleTogglePublish = async (video: Video) => {
|
||||
const handleToggleVideoPublish = async (video: Video) => {
|
||||
try {
|
||||
if (video.isPublished) {
|
||||
// Unpublish
|
||||
await mediaApi.post(`/videos/${video.id}/unpublish`);
|
||||
message.success(`"${video.title || video.filename}" unpublished`);
|
||||
} else {
|
||||
// Publish to default 'videos' category
|
||||
await mediaApi.post(`/videos/${video.id}/publish`, {
|
||||
category: 'videos',
|
||||
});
|
||||
await mediaApi.post(`/videos/${video.id}/publish`, { category: 'videos' });
|
||||
message.success(`"${video.title || video.filename}" published`);
|
||||
}
|
||||
fetchVideos();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to toggle publish:', error);
|
||||
message.error(error.response?.data?.message || 'Failed to toggle publish state');
|
||||
message.error(error.response?.data?.message || 'Failed to toggle publish');
|
||||
}
|
||||
};
|
||||
|
||||
// === Photo handlers ===
|
||||
const handlePhotoSelect = (id: number) => {
|
||||
setSelectedPhotoIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handlePhotoSelectAll = () => {
|
||||
if (selectedPhotoIds.length === photos.length) setSelectedPhotoIds([]);
|
||||
else setSelectedPhotoIds(photos.map((p) => p.id));
|
||||
};
|
||||
|
||||
const handleTogglePhotoPublish = async (photo: Photo) => {
|
||||
try {
|
||||
if (photo.isPublished) {
|
||||
await mediaApi.post(`/photos/${photo.id}/unpublish`);
|
||||
message.success('Photo unpublished');
|
||||
} else {
|
||||
await mediaApi.post(`/photos/${photo.id}/publish`);
|
||||
message.success('Photo published');
|
||||
}
|
||||
fetchPhotos();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to toggle publish');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSinglePhoto = (photo: Photo) => {
|
||||
Modal.confirm({
|
||||
title: 'Delete this photo?',
|
||||
content: photo.title || photo.originalFilename || photo.filename,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await mediaApi.delete(`/photos/${photo.id}`);
|
||||
message.success('Photo deleted');
|
||||
fetchPhotos();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to delete photo');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkPhotoPublish = async () => {
|
||||
try {
|
||||
await mediaApi.post('/photos/bulk-publish', { ids: selectedPhotoIds });
|
||||
message.success(`Published ${selectedPhotoIds.length} photos`);
|
||||
setSelectedPhotoIds([]);
|
||||
fetchPhotos();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to publish photos');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPhotoDelete = async () => {
|
||||
try {
|
||||
await mediaApi.post('/photos/bulk-delete', { ids: selectedPhotoIds });
|
||||
message.success(`Deleted ${selectedPhotoIds.length} photos`);
|
||||
setSelectedPhotoIds([]);
|
||||
fetchPhotos();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || 'Failed to delete photos');
|
||||
}
|
||||
};
|
||||
|
||||
// Tab change clears selections
|
||||
const handleTabChange = (tab: string | number) => {
|
||||
setMediaTab(tab as MediaTab);
|
||||
setSelectedVideoIds([]);
|
||||
setSelectedPhotoIds([]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const colSpan = viewMode === 'grid' ? { xs: 12, sm: 8, md: 6, lg: 4 } : { xs: 24, sm: 12, md: 8 };
|
||||
|
||||
// Current pagination for active tab
|
||||
const activePagination = mediaTab === 'Videos' ? videoPagination : mediaTab === 'Photos' ? photoPagination : albumPagination;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Media Type Toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<Segmented
|
||||
value={mediaTab}
|
||||
onChange={handleTabChange}
|
||||
options={[
|
||||
{ value: 'Videos', icon: <VideoCameraOutlined />, label: 'Videos' },
|
||||
{ value: 'Photos', icon: <PictureOutlined />, label: 'Photos' },
|
||||
{ value: 'Albums', icon: <FolderOutlined />, label: 'Albums' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="Search videos..."
|
||||
placeholder={`Search ${mediaTab.toLowerCase()}...`}
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Orientation"
|
||||
options={[
|
||||
{ value: 'H', label: 'Horizontal' },
|
||||
{ value: 'V', label: 'Vertical' },
|
||||
]}
|
||||
value={orientation}
|
||||
onChange={setOrientation}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Producer"
|
||||
options={producers.map((p) => ({ value: p, label: p }))}
|
||||
value={selectedProducers}
|
||||
onChange={setSelectedProducers}
|
||||
style={{ width: 140 }}
|
||||
maxTagCount={1}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Shorts"
|
||||
options={[
|
||||
{ value: 'true', label: 'Shorts' },
|
||||
{ value: 'false', label: 'Non-Shorts' },
|
||||
]}
|
||||
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
|
||||
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
|
||||
{/* Orientation filter (Videos + Photos) */}
|
||||
{mediaTab !== 'Albums' && (
|
||||
<Select
|
||||
placeholder="Orientation"
|
||||
options={[
|
||||
{ value: 'H', label: 'Horizontal' },
|
||||
{ value: 'V', label: 'Vertical' },
|
||||
...(mediaTab === 'Photos' ? [{ value: 'S', label: 'Square' }] : []),
|
||||
]}
|
||||
value={orientation}
|
||||
onChange={setOrientation}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Producer filter (Videos + Photos) */}
|
||||
{mediaTab !== 'Albums' && (
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Producer"
|
||||
options={producers.map((p) => ({ value: p, label: p }))}
|
||||
value={selectedProducers}
|
||||
onChange={setSelectedProducers}
|
||||
style={{ width: 140 }}
|
||||
maxTagCount={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video-specific: Shorts filter */}
|
||||
{mediaTab === 'Videos' && (
|
||||
<Select
|
||||
placeholder="Shorts"
|
||||
options={[
|
||||
{ value: 'true', label: 'Shorts' },
|
||||
{ value: 'false', label: 'Non-Shorts' },
|
||||
]}
|
||||
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
|
||||
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo-specific: Format filter */}
|
||||
{mediaTab === 'Photos' && photoFormats.length > 0 && (
|
||||
<Select
|
||||
placeholder="Format"
|
||||
options={photoFormats.map((f) => ({ value: f, label: f.toUpperCase() }))}
|
||||
value={formatFilter}
|
||||
onChange={setFormatFilter}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Tooltip title="Fetch from URL">
|
||||
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Scan Shorts">
|
||||
<Button icon={<ThunderboltOutlined />} onClick={handleScanShorts} loading={scanningShorts} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Schedule Calendar">
|
||||
<Button icon={<CalendarOutlined />} onClick={() => setCalendarModalOpen(true)} />
|
||||
</Tooltip>
|
||||
|
||||
{/* Upload button */}
|
||||
{mediaTab === 'Videos' && (
|
||||
<>
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadVideoOpen(true)}>Upload</Button>
|
||||
<Tooltip title="Fetch from URL">
|
||||
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Scan Shorts">
|
||||
<Button icon={<ThunderboltOutlined />} onClick={handleScanShorts} loading={scanningShorts} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Schedule Calendar">
|
||||
<Button icon={<CalendarOutlined />} onClick={() => setCalendarModalOpen(true)} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{mediaTab === 'Photos' && (
|
||||
<>
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadPhotoOpen(true)}>Upload</Button>
|
||||
{selectedPhotoIds.length >= 2 && (
|
||||
<Tooltip title="Create Album from Selected">
|
||||
<Button icon={<FolderAddOutlined />} onClick={() => setCreateAlbumOpen(true)}>Album</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mediaTab === 'Albums' && (
|
||||
<Button type="primary" icon={<FolderAddOutlined />} onClick={() => setCreateAlbumOpen(true)}>
|
||||
New Album
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tooltip title={viewMode === 'grid' ? 'Switch to Compact' : 'Switch to Grid'}>
|
||||
<Button
|
||||
icon={viewMode === 'grid' ? <AppstoreOutlined /> : <BarsOutlined />}
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button size="small" type="text" onClick={handleSelectAll}>
|
||||
{selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'}
|
||||
</Button>
|
||||
|
||||
{/* Select All (Videos + Photos) */}
|
||||
{mediaTab === 'Videos' && (
|
||||
<Button size="small" type="text" onClick={handleVideoSelectAll}>
|
||||
{selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'}
|
||||
</Button>
|
||||
)}
|
||||
{mediaTab === 'Photos' && (
|
||||
<Button size="small" type="text" onClick={handlePhotoSelectAll}>
|
||||
{selectedPhotoIds.length === photos.length && photos.length > 0 ? 'Deselect' : 'Select All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ marginBottom: 8, color: '#999', fontSize: 13 }}>
|
||||
{pagination.total} video{pagination.total !== 1 ? 's' : ''}
|
||||
{selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
|
||||
{activePagination.total} {mediaTab.toLowerCase().replace(/s$/, '')}{activePagination.total !== 1 ? 's' : ''}
|
||||
{mediaTab === 'Videos' && selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
|
||||
{mediaTab === 'Photos' && selectedPhotoIds.length > 0 && ` · ${selectedPhotoIds.length} selected`}
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
{/* Content Grid */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<Empty description="No videos found" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{videos.map((video) => (
|
||||
<Col {...colSpan} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
selected={selectedVideoIds.includes(video.id)}
|
||||
onSelect={handleSelect}
|
||||
onClick={(v) => setViewerVideo(v)}
|
||||
onEdit={handleEdit}
|
||||
onPreview={(v) => setViewerVideo(v)}
|
||||
onAnalytics={handleAnalytics}
|
||||
onSchedule={handleSchedule}
|
||||
onDelete={handleDeleteSingle}
|
||||
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
|
||||
onRefresh={fetchVideos}
|
||||
onTogglePublish={handleTogglePublish}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{/* === Videos Tab === */}
|
||||
{mediaTab === 'Videos' && (
|
||||
videos.length === 0 ? (
|
||||
<Empty description="No videos found" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{videos.map((video) => (
|
||||
<Col {...colSpan} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
selected={selectedVideoIds.includes(video.id)}
|
||||
onSelect={handleVideoSelect}
|
||||
onClick={(v) => setViewerVideo(v)}
|
||||
onEdit={(v) => setEditVideo(v)}
|
||||
onPreview={(v) => setViewerVideo(v)}
|
||||
onAnalytics={(v) => { setAnalyticsVideoId(v.id); setAnalyticsVideoTitle(v.title || v.filename); }}
|
||||
onSchedule={(v) => setScheduleVideo(v)}
|
||||
onDelete={handleDeleteSingleVideo}
|
||||
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
|
||||
onRefresh={fetchVideos}
|
||||
onTogglePublish={handleToggleVideoPublish}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
current={pagination.page}
|
||||
pageSize={pagination.limit}
|
||||
total={pagination.total}
|
||||
onChange={(page) => setPagination((prev) => ({ ...prev, page }))}
|
||||
style={{ marginTop: 24, textAlign: 'center' }}
|
||||
showSizeChanger
|
||||
onShowSizeChange={(_, size) =>
|
||||
setPagination((prev) => ({ ...prev, limit: size, page: 1 }))
|
||||
}
|
||||
pageSizeOptions={[24, 48, 96, 144]}
|
||||
/>
|
||||
{/* === Photos Tab === */}
|
||||
{mediaTab === 'Photos' && (
|
||||
photos.length === 0 ? (
|
||||
<Empty description="No photos found" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{photos.map((photo) => (
|
||||
<Col {...colSpan} key={photo.id}>
|
||||
<PhotoCard
|
||||
photo={photo}
|
||||
selected={selectedPhotoIds.includes(photo.id)}
|
||||
onSelect={handlePhotoSelect}
|
||||
onClick={(p) => setViewerPhoto(p)}
|
||||
onEdit={(p) => setEditPhoto(p)}
|
||||
onPreview={(p) => setViewerPhoto(p)}
|
||||
onDelete={handleDeleteSinglePhoto}
|
||||
onTogglePublish={handleTogglePhotoPublish}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* === Albums Tab === */}
|
||||
{mediaTab === 'Albums' && (
|
||||
albums.length === 0 ? (
|
||||
<Empty description="No albums found" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{albums.map((album) => (
|
||||
<Col {...colSpan} key={album.id}>
|
||||
<AlbumCard
|
||||
album={album}
|
||||
onClick={(a) => setSelectedAlbumId(a.id)}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{activePagination.total > activePagination.limit && (
|
||||
<Pagination
|
||||
current={activePagination.page}
|
||||
pageSize={activePagination.limit}
|
||||
total={activePagination.total}
|
||||
onChange={(page) => {
|
||||
if (mediaTab === 'Videos') setVideoPagination((prev) => ({ ...prev, page }));
|
||||
else if (mediaTab === 'Photos') setPhotoPagination((prev) => ({ ...prev, page }));
|
||||
else setAlbumPagination((prev) => ({ ...prev, page }));
|
||||
}}
|
||||
style={{ marginTop: 24, textAlign: 'center' }}
|
||||
showSizeChanger
|
||||
onShowSizeChange={(_, size) => {
|
||||
if (mediaTab === 'Videos') setVideoPagination((prev) => ({ ...prev, limit: size, page: 1 }));
|
||||
else if (mediaTab === 'Photos') setPhotoPagination((prev) => ({ ...prev, limit: size, page: 1 }));
|
||||
else setAlbumPagination((prev) => ({ ...prev, limit: size, page: 1 }));
|
||||
}}
|
||||
pageSizeOptions={[24, 48, 96, 144]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bulk Actions Bar */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedVideoIds.length}
|
||||
actions={[
|
||||
{
|
||||
key: 'publish',
|
||||
label: 'Publish',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => setPublishModalOpen(true),
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'access-level',
|
||||
label: 'Access Level',
|
||||
icon: <LockOutlined />,
|
||||
onClick: () => setBulkAccessLevelOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'add-to-playlist',
|
||||
label: 'Add to Playlist',
|
||||
icon: <OrderedListOutlined />,
|
||||
onClick: () => setBulkPlaylistOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: () => setDeleteModalOpen(true),
|
||||
danger: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* === Video Bulk Actions === */}
|
||||
{mediaTab === 'Videos' && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedVideoIds.length}
|
||||
actions={[
|
||||
{ key: 'publish', label: 'Publish', icon: <GlobalOutlined />, onClick: () => setPublishModalOpen(true), type: 'primary' },
|
||||
{ key: 'access-level', label: 'Access Level', icon: <LockOutlined />, onClick: () => setBulkAccessLevelOpen(true) },
|
||||
{ key: 'add-to-playlist', label: 'Add to Playlist', icon: <OrderedListOutlined />, onClick: () => setBulkPlaylistOpen(true) },
|
||||
{ key: 'delete', label: 'Delete', icon: <DeleteOutlined />, onClick: () => setDeleteModalOpen(true), danger: true },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{/* === Photo Bulk Actions === */}
|
||||
{mediaTab === 'Photos' && selectedPhotoIds.length > 0 && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedPhotoIds.length}
|
||||
actions={[
|
||||
{ key: 'publish', label: 'Publish', icon: <GlobalOutlined />, onClick: handleBulkPhotoPublish, type: 'primary' },
|
||||
{ key: 'create-album', label: 'Create Album', icon: <FolderAddOutlined />, onClick: () => setCreateAlbumOpen(true) },
|
||||
{ key: 'delete', label: 'Delete', icon: <DeleteOutlined />, onClick: handleBulkPhotoDelete, danger: true },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Video Modals === */}
|
||||
<UploadVideoDrawer
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onSuccess={handleUploadSuccess}
|
||||
open={uploadVideoOpen}
|
||||
onClose={() => setUploadVideoOpen(false)}
|
||||
onSuccess={() => { setUploadVideoOpen(false); fetchVideos(); fetchProducers(); }}
|
||||
/>
|
||||
|
||||
<PublishModal
|
||||
open={publishModalOpen}
|
||||
videoIds={selectedVideoIds}
|
||||
onSuccess={handlePublishSuccess}
|
||||
onSuccess={() => { setPublishModalOpen(false); setSelectedVideoIds([]); fetchVideos(); }}
|
||||
onCancel={() => setPublishModalOpen(false)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmModal
|
||||
open={deleteModalOpen}
|
||||
count={selectedVideoIds.length}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={handleVideoDelete}
|
||||
onCancel={() => setDeleteModalOpen(false)}
|
||||
loading={deleting}
|
||||
/>
|
||||
@ -424,7 +658,7 @@ export default function LibraryPage() {
|
||||
video={scheduleVideo}
|
||||
open={!!scheduleVideo}
|
||||
onClose={() => setScheduleVideo(null)}
|
||||
onSuccess={handleScheduleSuccess}
|
||||
onSuccess={() => { setScheduleVideo(null); fetchVideos(); }}
|
||||
/>
|
||||
|
||||
<ScheduleCalendarDrawer
|
||||
@ -437,7 +671,7 @@ export default function LibraryPage() {
|
||||
video={editVideo}
|
||||
open={!!editVideo}
|
||||
onClose={() => setEditVideo(null)}
|
||||
onSuccess={handleEditSuccess}
|
||||
onSuccess={() => { setEditVideo(null); fetchVideos(); fetchProducers(); }}
|
||||
/>
|
||||
|
||||
<FetchVideosDrawer
|
||||
@ -458,21 +692,53 @@ export default function LibraryPage() {
|
||||
open={bulkAccessLevelOpen}
|
||||
videoIds={selectedVideoIds}
|
||||
onClose={() => setBulkAccessLevelOpen(false)}
|
||||
onSuccess={() => {
|
||||
setBulkAccessLevelOpen(false);
|
||||
setSelectedVideoIds([]);
|
||||
fetchVideos();
|
||||
}}
|
||||
onSuccess={() => { setBulkAccessLevelOpen(false); setSelectedVideoIds([]); fetchVideos(); }}
|
||||
/>
|
||||
|
||||
<BulkAddToPlaylistModal
|
||||
videoIds={selectedVideoIds}
|
||||
open={bulkPlaylistOpen}
|
||||
onClose={() => setBulkPlaylistOpen(false)}
|
||||
onSuccess={() => { setBulkPlaylistOpen(false); setSelectedVideoIds([]); }}
|
||||
/>
|
||||
|
||||
{/* === Photo Modals === */}
|
||||
<UploadPhotoDrawer
|
||||
open={uploadPhotoOpen}
|
||||
onClose={() => setUploadPhotoOpen(false)}
|
||||
onSuccess={() => { setUploadPhotoOpen(false); fetchPhotos(); fetchPhotoFormats(); }}
|
||||
/>
|
||||
|
||||
<PhotoViewerModal
|
||||
photo={viewerPhoto}
|
||||
open={!!viewerPhoto}
|
||||
onClose={() => setViewerPhoto(null)}
|
||||
/>
|
||||
|
||||
<EditPhotoModal
|
||||
photo={editPhoto}
|
||||
open={!!editPhoto}
|
||||
onClose={() => setEditPhoto(null)}
|
||||
onSuccess={() => { setEditPhoto(null); fetchPhotos(); }}
|
||||
/>
|
||||
|
||||
<CreateAlbumModal
|
||||
open={createAlbumOpen}
|
||||
onClose={() => setCreateAlbumOpen(false)}
|
||||
onSuccess={() => {
|
||||
setBulkPlaylistOpen(false);
|
||||
setSelectedVideoIds([]);
|
||||
setCreateAlbumOpen(false);
|
||||
setSelectedPhotoIds([]);
|
||||
fetchPhotos();
|
||||
fetchAlbums();
|
||||
}}
|
||||
selectedPhotoIds={selectedPhotoIds}
|
||||
/>
|
||||
|
||||
<AlbumDetailDrawer
|
||||
albumId={selectedAlbumId}
|
||||
open={!!selectedAlbumId}
|
||||
onClose={() => setSelectedAlbumId(null)}
|
||||
onRefresh={fetchAlbums}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -26,6 +26,8 @@ import {
|
||||
ArrowRightOutlined,
|
||||
RocketOutlined,
|
||||
ShareAltOutlined,
|
||||
CalendarOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
@ -603,11 +605,25 @@ export default function CampaignPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{siteSettings?.enableMap !== false && (
|
||||
<Link to="/shifts">
|
||||
<Button icon={<CalendarOutlined />} style={{ borderColor: '#52c41a', color: '#52c41a' }}>
|
||||
Volunteer for a Shift
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/campaigns/create">
|
||||
<Button type="primary" icon={<RocketOutlined />}>
|
||||
Start Your Own Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
{siteSettings?.enableMediaFeatures !== false && (
|
||||
<Link to="/gallery">
|
||||
<Button icon={<PlayCircleOutlined />} style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}>
|
||||
Watch Our Content
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleCopyLink}
|
||||
|
||||
@ -7,14 +7,19 @@ import {
|
||||
} from 'antd';
|
||||
import axios from 'axios';
|
||||
import PublicVideoCard from '@/components/media/PublicVideoCard';
|
||||
import PublicPhotoCard from '@/components/media/PublicPhotoCard';
|
||||
import PublicAlbumCard from '@/components/media/PublicAlbumCard';
|
||||
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
|
||||
import ExpandedPhotoCard from '@/components/media/ExpandedPhotoCard';
|
||||
import ExpandedAlbumCard from '@/components/media/ExpandedAlbumCard';
|
||||
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
||||
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
|
||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||
import { mergeAdsIntoGrid, type GridItem } from '@/utils/galleryAdMerge';
|
||||
import { mergeAdsIntoGrid, mergeAdsIntoMediaGrid, type GridItem } from '@/utils/galleryAdMerge';
|
||||
import type { GalleryAd } from '@/types/gallery-ads';
|
||||
import type { PublicPhoto, PublicAlbum } from '@/types/media';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@ -41,18 +46,31 @@ interface PaginationInfo {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Unified gallery item from /public/gallery endpoint
|
||||
interface GalleryApiItem {
|
||||
type: 'video' | 'photo' | 'album';
|
||||
data: any;
|
||||
}
|
||||
|
||||
function MediaGalleryContent() {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { category: urlCategory } = useParams<{ category?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { state: expandedState, expandVideo } = useExpandedVideo();
|
||||
const { state: expandedState, expandVideo, expandMedia } = useExpandedVideo();
|
||||
|
||||
// Read search/sort from URL params (set by MediaBottomNav)
|
||||
const search = searchParams.get('search') || '';
|
||||
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
|
||||
|
||||
// Determine if we're in photos mode
|
||||
const isPhotosMode = urlCategory === 'photos';
|
||||
|
||||
// Video-only state (existing behavior)
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
|
||||
// Unified gallery state (photos mode or all mode)
|
||||
const [galleryItems, setGalleryItems] = useState<GalleryApiItem[]>([]);
|
||||
|
||||
const [ads, setAds] = useState<GalleryAd[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
@ -63,38 +81,49 @@ function MediaGalleryContent() {
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Fetch videos
|
||||
const fetchVideos = async () => {
|
||||
// Fetch content based on mode
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const offset = (currentPage - 1) * pagination.limit;
|
||||
|
||||
const params: any = {
|
||||
limit: pagination.limit,
|
||||
offset,
|
||||
sort,
|
||||
};
|
||||
if (isPhotosMode) {
|
||||
// Fetch from unified gallery endpoint with photo filter
|
||||
const params: any = {
|
||||
limit: pagination.limit,
|
||||
offset,
|
||||
sort,
|
||||
mediaType: 'photo',
|
||||
};
|
||||
if (search) params.search = search;
|
||||
|
||||
if (urlCategory) {
|
||||
params.category = urlCategory;
|
||||
const response = await mediaPublicApi.get('/public/gallery', { params });
|
||||
setGalleryItems(response.data.items || []);
|
||||
setPagination(response.data.pagination);
|
||||
setVideos([]);
|
||||
} else {
|
||||
// Existing video-only fetch
|
||||
const params: any = {
|
||||
limit: pagination.limit,
|
||||
offset,
|
||||
sort,
|
||||
};
|
||||
if (urlCategory) params.category = urlCategory;
|
||||
if (search) params.search = search;
|
||||
|
||||
const response = await mediaPublicApi.get('/public', { params });
|
||||
setVideos(response.data.videos);
|
||||
setPagination(response.data.pagination);
|
||||
setGalleryItems([]);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
const response = await mediaPublicApi.get('/public', { params });
|
||||
|
||||
setVideos(response.data.videos);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch videos:', error);
|
||||
console.error('Failed to fetch content:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch ads (from Express API, non-critical)
|
||||
// Fetch ads
|
||||
const fetchAds = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<GalleryAd[]>('/api/gallery-ads', {
|
||||
@ -102,70 +131,123 @@ function MediaGalleryContent() {
|
||||
});
|
||||
setAds(data);
|
||||
} catch {
|
||||
// Silent fail — ads are supplementary
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch on filter changes (search/sort come from URL params via MediaBottomNav)
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, sort]);
|
||||
}, [search, sort, urlCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
fetchContent();
|
||||
fetchAds();
|
||||
}, [urlCategory, search, sort, currentPage]);
|
||||
|
||||
// Handle URL ?expanded=123 parameter on initial load only
|
||||
// (e.g., shared link or page refresh — not triggered by collapse)
|
||||
// Handle URL ?expanded=photo-123 or ?expanded=album-5 or ?expanded=123 on initial load
|
||||
const hasRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasRestoredRef.current) return;
|
||||
const expandedId = searchParams.get('expanded');
|
||||
if (expandedId && videos.length > 0) {
|
||||
const videoId = parseInt(expandedId, 10);
|
||||
const video = videos.find(v => v.id === videoId);
|
||||
if (video) {
|
||||
hasRestoredRef.current = true;
|
||||
expandVideo(videoId, video);
|
||||
const expandedParam = searchParams.get('expanded');
|
||||
if (!expandedParam) return;
|
||||
|
||||
if (isPhotosMode && galleryItems.length > 0) {
|
||||
// Parse expanded param: "photo-123", "album-5"
|
||||
const match = expandedParam.match(/^(photo|album)-(\d+)$/);
|
||||
if (match) {
|
||||
const mediaType = match[1]! as 'photo' | 'album';
|
||||
const id = parseInt(match[2]!, 10);
|
||||
const item = galleryItems.find(gi => gi.type === mediaType && gi.data.id === id);
|
||||
if (item) {
|
||||
hasRestoredRef.current = true;
|
||||
expandMedia(id, mediaType, item.data);
|
||||
}
|
||||
}
|
||||
} else if (!isPhotosMode && videos.length > 0) {
|
||||
const videoId = parseInt(expandedParam, 10);
|
||||
if (!isNaN(videoId)) {
|
||||
const video = videos.find(v => v.id === videoId);
|
||||
if (video) {
|
||||
hasRestoredRef.current = true;
|
||||
expandVideo(videoId, video);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [videos]); // Only re-check when videos load
|
||||
}, [videos, galleryItems]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Merge ads into video grid
|
||||
const gridItems = mergeAdsIntoGrid(videos, ads);
|
||||
// Build grid items based on mode
|
||||
const gridItems: GridItem[] = isPhotosMode
|
||||
? mergeAdsIntoMediaGrid(
|
||||
galleryItems.map(gi => ({
|
||||
type: gi.type as 'video' | 'photo' | 'album',
|
||||
data: gi.data,
|
||||
})),
|
||||
ads
|
||||
)
|
||||
: mergeAdsIntoGrid(videos, ads);
|
||||
|
||||
const totalItems = isPhotosMode ? galleryItems.length : videos.length;
|
||||
const emptyLabel = isPhotosMode ? 'photos' : 'videos';
|
||||
|
||||
// Render a single grid item based on its type and expanded state
|
||||
const renderGridItem = (item: GridItem) => {
|
||||
if (item.type === 'ad') {
|
||||
return <GalleryAdCard key={`ad-${item.data.id}`} ad={item.data} />;
|
||||
}
|
||||
|
||||
if (item.type === 'photo') {
|
||||
const photo = item.data as PublicPhoto;
|
||||
// Check if this photo is expanded
|
||||
if (expandedState.mediaType === 'photo' && expandedState.mediaData?.id === photo.id) {
|
||||
return <ExpandedPhotoCard key={`expanded-photo-${photo.id}`} photo={photo} />;
|
||||
}
|
||||
return <PublicPhotoCard key={`photo-${photo.id}`} photo={photo} />;
|
||||
}
|
||||
|
||||
if (item.type === 'album') {
|
||||
const album = item.data as PublicAlbum;
|
||||
if (expandedState.mediaType === 'album' && expandedState.mediaData?.id === album.id) {
|
||||
return <ExpandedAlbumCard key={`expanded-album-${album.id}`} album={album} />;
|
||||
}
|
||||
return <PublicAlbumCard key={`album-${album.id}`} album={album} />;
|
||||
}
|
||||
|
||||
// Default: video
|
||||
const video = item.data as Video;
|
||||
if (expandedState.videoId === video.id) {
|
||||
return <ExpandedVideoCard key={`expanded-${video.id}`} video={video} />;
|
||||
}
|
||||
return <PublicVideoCard key={video.id} video={video} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Featured Playlists Carousel — only on main gallery page */}
|
||||
{/* Featured Playlists Carousel — only on main gallery page (not photos tab) */}
|
||||
{!urlCategory && !search && <FeaturedPlaylistCarousel />}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && videos.length === 0 && (
|
||||
{!loading && totalItems === 0 && (
|
||||
<Empty
|
||||
description={
|
||||
search
|
||||
? `No videos found for "${search}"`
|
||||
: 'No videos available'
|
||||
? `No ${emptyLabel} found for "${search}"`
|
||||
: `No ${emptyLabel} available`
|
||||
}
|
||||
style={{ padding: 60 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Grid (with ads interleaved) */}
|
||||
{!loading && videos.length > 0 && (
|
||||
{!loading && totalItems > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
@ -177,18 +259,9 @@ function MediaGalleryContent() {
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{gridItems.map((item: GridItem<Video>) =>
|
||||
item.type === 'ad' ? (
|
||||
<GalleryAdCard key={`ad-${item.data.id}`} ad={item.data} />
|
||||
) : expandedState.videoId === item.data.id ? (
|
||||
<ExpandedVideoCard key={`expanded-${item.data.id}`} video={item.data} />
|
||||
) : (
|
||||
<PublicVideoCard key={item.data.id} video={item.data} />
|
||||
)
|
||||
)}
|
||||
{gridItems.map(renderGridItem)}
|
||||
</div>
|
||||
|
||||
{/* Pagination — still based on video count only */}
|
||||
{pagination.total > pagination.limit && (
|
||||
<div style={{ marginTop: 32, textAlign: 'center' }}>
|
||||
<Pagination
|
||||
@ -197,7 +270,7 @@ function MediaGalleryContent() {
|
||||
total={pagination.total}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `Total ${total} videos`}
|
||||
showTotal={(total) => `Total ${total} ${emptyLabel}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
EyeOutlined,
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import VideoPlayer from '@/components/media/VideoPlayer';
|
||||
import ReactionButtons from '@/components/media/ReactionButtons';
|
||||
@ -26,6 +27,8 @@ import PublicVideoCard from '@/components/media/PublicVideoCard';
|
||||
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
|
||||
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@ -59,6 +62,7 @@ export default function MediaViewerPage() {
|
||||
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
|
||||
const currentTime = 0; // TODO: Track video playback time
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const videoId = parseInt(id || '0', 10);
|
||||
|
||||
@ -314,6 +318,39 @@ export default function MediaViewerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cross-site CTA */}
|
||||
{settings?.enableInfluence !== false && (
|
||||
<>
|
||||
<Divider />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
background: 'linear-gradient(135deg, rgba(0,90,156,0.15) 0%, rgba(82,196,26,0.1) 100%)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(0,90,156,0.25)',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>Want to take action?</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
Join an active campaign and make your voice heard.
|
||||
</Text>
|
||||
</div>
|
||||
<Link to="/campaigns">
|
||||
<Button type="primary" icon={<SendOutlined />}>
|
||||
View Campaigns
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Comments section */}
|
||||
|
||||
193
admin/src/pages/public/QuickJoinPage.tsx
Normal file
193
admin/src/pages/public/QuickJoinPage.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ConfigProvider, App, theme, Typography, Form, Input, Button, Card, Result, Grid,
|
||||
} from 'antd';
|
||||
import { MailOutlined, UserOutlined, PhoneOutlined, TeamOutlined, HomeOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function QuickJoinPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { setTokens, fetchMe } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const token = searchParams.get('token');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (values: { email: string; name?: string; phone?: string }) => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data } = await axios.post('/api/volunteer-invite/redeem', {
|
||||
token,
|
||||
email: values.email,
|
||||
name: values.name || undefined,
|
||||
phone: values.phone || undefined,
|
||||
});
|
||||
|
||||
// Store tokens and fetch user profile
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
await fetchMe();
|
||||
|
||||
// Redirect to volunteer map with cut/shift context
|
||||
const params = new URLSearchParams();
|
||||
if (data.cutId) params.set('cutId', data.cutId);
|
||||
if (data.shiftId) params.set('shiftId', data.shiftId);
|
||||
const query = params.toString();
|
||||
navigate(`/volunteer${query ? `?${query}` : ''}`, { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } } } })
|
||||
?.response?.data?.error;
|
||||
if (resp?.code === 'QUICK_JOIN_RATE_LIMIT_EXCEEDED') {
|
||||
setError('Too many attempts. Please try again later.');
|
||||
} else if (resp?.code === 'INVALID_INVITE_TOKEN' || resp?.code === 'INVALID_TOKEN_TYPE') {
|
||||
setError('This invite link has expired or is invalid. Please ask for a new one.');
|
||||
} else if (resp?.code === 'ACCOUNT_INACTIVE') {
|
||||
setError(resp.message || 'Your account is not active.');
|
||||
} else {
|
||||
setError(resp?.message || 'Something went wrong. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Missing or empty token
|
||||
if (!token) {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
|
||||
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
|
||||
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<Result
|
||||
status="warning"
|
||||
title="Invalid Invite Link"
|
||||
subTitle="This link is missing the invite token. Please scan the QR code again or ask the organizer for a new link."
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<HomeOutlined />}
|
||||
onClick={() => window.location.href = '/campaigns'}
|
||||
>
|
||||
Return to Site
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
|
||||
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
|
||||
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: isMobile ? 16 : 24,
|
||||
background: settings?.publicColorBgBase ?? '#0d1b2a',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{ maxWidth: 400, width: '100%' }}
|
||||
styles={{ body: { padding: isMobile ? 20 : 24 } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<TeamOutlined style={{ fontSize: 40, color: settings?.publicColorPrimary ?? '#3498db', marginBottom: 12 }} />
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Join as Volunteer
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{settings?.organizationName ?? 'Changemaker Lite'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 77, 79, 0.1)',
|
||||
border: '1px solid rgba(255, 77, 79, 0.3)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
color: '#ff4d4f',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form onFinish={handleSubmit} layout="vertical" size="large" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: 'Email is required' },
|
||||
{ type: 'email', message: 'Enter a valid email' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} placeholder="Your email" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="name">
|
||||
<Input prefix={<UserOutlined />} placeholder="Your name (optional)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="phone">
|
||||
<Input prefix={<PhoneOutlined />} placeholder="Phone (optional)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
Join & Start Canvassing
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 11 }}>
|
||||
You'll get temporary 24-hour access to the canvassing app.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Button type="link" size="small" icon={<HomeOutlined />} onClick={() => window.location.href = '/campaigns'}>
|
||||
Browse Site
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -205,3 +205,15 @@ export interface VolunteerSummary {
|
||||
sessions: number;
|
||||
lastActive: string | null;
|
||||
}
|
||||
|
||||
// --- Outcome Trends ---
|
||||
|
||||
export type CanvassOutcomeTrendPoint = { date: string } & Partial<Record<VisitOutcome, number>>;
|
||||
|
||||
export interface CanvassOutcomeTrendsData {
|
||||
granularity: 'day' | 'week';
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
series: CanvassOutcomeTrendPoint[];
|
||||
totals: Partial<Record<VisitOutcome, number>>;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { GalleryAd } from '@/types/gallery-ads';
|
||||
|
||||
export type GridItem<T> =
|
||||
export type GridItem<T = any> =
|
||||
| { type: 'video'; data: T }
|
||||
| { type: 'photo'; data: any }
|
||||
| { type: 'album'; data: any }
|
||||
| { type: 'ad'; data: GalleryAd };
|
||||
|
||||
/**
|
||||
@ -68,3 +70,50 @@ export function mergeAdsIntoGrid<T extends { id: number }>(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge ads into a unified media grid (videos + photos + albums).
|
||||
*/
|
||||
export function mergeAdsIntoMediaGrid(
|
||||
items: Array<{ type: 'video' | 'photo' | 'album'; data: any }>,
|
||||
ads: GalleryAd[]
|
||||
): GridItem[] {
|
||||
if (ads.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const sortedAds = [...ads].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
const slotAssignments = new Map<number, GalleryAd>();
|
||||
const usedSlots = new Set<number>();
|
||||
|
||||
for (const ad of sortedAds) {
|
||||
const freq = ad.frequency;
|
||||
for (let i = freq - 1; i < items.length; i += freq) {
|
||||
let targetSlot = i;
|
||||
while (usedSlots.has(targetSlot) && targetSlot < items.length + ads.length) {
|
||||
targetSlot++;
|
||||
}
|
||||
if (!usedSlots.has(targetSlot)) {
|
||||
slotAssignments.set(targetSlot, ad);
|
||||
usedSlots.add(targetSlot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: GridItem[] = [];
|
||||
let itemIdx = 0;
|
||||
|
||||
for (let pos = 0; itemIdx < items.length || slotAssignments.has(pos); pos++) {
|
||||
const adForSlot = slotAssignments.get(pos);
|
||||
if (adForSlot) {
|
||||
result.push({ type: 'ad', data: adForSlot });
|
||||
}
|
||||
if (itemIdx < items.length) {
|
||||
result.push(items[itemIdx]!);
|
||||
itemIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -4,12 +4,14 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
|
||||
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
|
||||
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||
|
||||
# Install dependencies
|
||||
# Two-step: skip scripts to avoid sharp source build, then install prebuilt musl binary
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm install --no-save @img/sharp-linuxmusl-x64
|
||||
|
||||
# Copy Prisma schema and generate client (needed for auth middleware)
|
||||
COPY prisma ./prisma
|
||||
@ -35,8 +37,8 @@ RUN npm run build
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
|
||||
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
|
||||
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||
|
||||
# Copy built files and node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
509
api/package-lock.json
generated
509
api/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "UserCreatedVia" ADD VALUE IF NOT EXISTS 'QUICK_JOIN_INVITE';
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
export const quickJoinRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:quick-join:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many join attempts, please try again later',
|
||||
code: 'QUICK_JOIN_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const authRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10, // Reduced from 20 to prevent brute force attacks
|
||||
|
||||
@ -18,7 +18,7 @@ interface TokenPayload {
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
interface TokenPair {
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -10,6 +10,7 @@ import { isServiceOnline } from '../../utils/health-check';
|
||||
import { listmonkClient } from '../../services/listmonk.client';
|
||||
import { gancioClient } from '../../services/gancio.client';
|
||||
import { rocketchatClient } from '../../services/rocketchat.client';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// --- Types ---
|
||||
@ -418,7 +419,7 @@ export interface ConnectivityStatus {
|
||||
|
||||
export async function getConnectivity(): Promise<ConnectivityStatus> {
|
||||
const [smtp, listmonk, rocketchat, gancio] = await Promise.all([
|
||||
isServiceOnline(`${env.SMTP_HOST}`, 3000).catch(() => false),
|
||||
emailService.testConnection().catch(() => false),
|
||||
listmonkClient.checkHealth().catch(() => false),
|
||||
isServiceOnline(env.ROCKETCHAT_URL || '', 3000).catch(() => false),
|
||||
gancioClient.isAvailable().catch(() => false),
|
||||
@ -860,6 +861,243 @@ export async function getTodayEvents(): Promise<TodayEventsResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Upcoming Shifts ---
|
||||
|
||||
export interface UpcomingShiftItem {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
maxVolunteers: number;
|
||||
currentVolunteers: number;
|
||||
status: string;
|
||||
cutName: string | null;
|
||||
}
|
||||
|
||||
export interface UpcomingShiftsResult {
|
||||
shifts: UpcomingShiftItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getUpcomingShifts(): Promise<UpcomingShiftsResult> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [shifts, total] = await Promise.all([
|
||||
prisma.shift.findMany({
|
||||
where: { date: { gte: now }, status: { not: 'CANCELLED' } },
|
||||
orderBy: { date: 'asc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
location: true,
|
||||
maxVolunteers: true,
|
||||
currentVolunteers: true,
|
||||
status: true,
|
||||
cut: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
shifts: shifts.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date.toISOString(),
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location,
|
||||
maxVolunteers: s.maxVolunteers,
|
||||
currentVolunteers: s.currentVolunteers,
|
||||
status: s.status,
|
||||
cutName: s.cut?.name || null,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch upcoming shifts', err);
|
||||
return { shifts: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recent Shift Signups ---
|
||||
|
||||
export interface RecentSignupItem {
|
||||
id: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
shiftTitle: string | null;
|
||||
shiftDate: string | null;
|
||||
signupDate: string;
|
||||
signupSource: string;
|
||||
}
|
||||
|
||||
export interface RecentSignupsResult {
|
||||
signups: RecentSignupItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getRecentSignups(): Promise<RecentSignupsResult> {
|
||||
try {
|
||||
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); // last 14 days
|
||||
const [signups, total] = await Promise.all([
|
||||
prisma.shiftSignup.findMany({
|
||||
where: { signupDate: { gte: since }, status: 'CONFIRMED' },
|
||||
orderBy: { signupDate: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
userName: true,
|
||||
userEmail: true,
|
||||
shiftTitle: true,
|
||||
signupDate: true,
|
||||
signupSource: true,
|
||||
shift: { select: { date: true } },
|
||||
},
|
||||
}),
|
||||
prisma.shiftSignup.count({ where: { signupDate: { gte: since }, status: 'CONFIRMED' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
signups: signups.map(s => ({
|
||||
id: s.id,
|
||||
userName: s.userName,
|
||||
userEmail: s.userEmail,
|
||||
shiftTitle: s.shiftTitle,
|
||||
shiftDate: s.shift.date.toISOString(),
|
||||
signupDate: s.signupDate.toISOString(),
|
||||
signupSource: s.signupSource,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch recent signups', err);
|
||||
return { signups: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Top Videos (Media) ---
|
||||
|
||||
export interface TopVideoItem {
|
||||
id: number;
|
||||
title: string | null;
|
||||
filename: string;
|
||||
viewCount: number;
|
||||
commentCount: number;
|
||||
upvoteCount: number;
|
||||
durationSeconds: number | null;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export interface TopVideosResult {
|
||||
enabled: boolean;
|
||||
videos: TopVideoItem[];
|
||||
}
|
||||
|
||||
export async function getTopVideos(): Promise<TopVideosResult> {
|
||||
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
|
||||
return { enabled: false, videos: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const videos = await prisma.video.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
viewCount: true,
|
||||
commentCount: true,
|
||||
upvoteCount: true,
|
||||
durationSeconds: true,
|
||||
isPublished: true,
|
||||
},
|
||||
orderBy: { viewCount: 'desc' },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
videos: videos.map(v => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
filename: v.filename,
|
||||
viewCount: v.viewCount,
|
||||
commentCount: v.commentCount,
|
||||
upvoteCount: v.upvoteCount,
|
||||
durationSeconds: v.durationSeconds,
|
||||
isPublished: v.isPublished,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch top videos', err);
|
||||
return { enabled: true, videos: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recent Comments (Media) ---
|
||||
|
||||
export interface RecentCommentItem {
|
||||
id: number;
|
||||
content: string;
|
||||
videoId: number;
|
||||
videoTitle: string | null;
|
||||
videoFilename: string;
|
||||
authorName: string | null;
|
||||
safetyStatus: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecentCommentsResult {
|
||||
enabled: boolean;
|
||||
comments: RecentCommentItem[];
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export async function getRecentComments(): Promise<RecentCommentsResult> {
|
||||
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
|
||||
return { enabled: false, comments: [], pendingCount: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const [comments, pendingCount] = await Promise.all([
|
||||
prisma.comment.findMany({
|
||||
where: { isHidden: { not: true } },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
media: { select: { id: true, title: true, filename: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.comment.count({ where: { safetyStatus: 'pending' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
comments: comments.map(c => ({
|
||||
id: c.id,
|
||||
content: c.content.slice(0, 200),
|
||||
videoId: c.media.id,
|
||||
videoTitle: c.media.title,
|
||||
videoFilename: c.media.filename,
|
||||
authorName: c.user?.name || c.user?.email || null,
|
||||
safetyStatus: c.safetyStatus,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
pendingCount,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch recent comments', err);
|
||||
return { enabled: true, comments: [], pendingCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat Summary from Rocket.Chat ---
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -101,5 +101,12 @@ export type WalkingRouteInput = z.infer<typeof walkingRouteSchema>;
|
||||
export type ListMyVisitsInput = z.infer<typeof listMyVisitsSchema>;
|
||||
export type AdminActivityInput = z.infer<typeof adminActivitySchema>;
|
||||
export type AdminVisitsInput = z.infer<typeof adminVisitsSchema>;
|
||||
export const outcomeTrendsQuerySchema = z.object({
|
||||
granularity: z.enum(['day', 'week']).default('day'),
|
||||
dateFrom: z.string().optional(),
|
||||
dateTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export type VolunteerUpdateLocationInput = z.infer<typeof volunteerUpdateLocationSchema>;
|
||||
export type VolunteerCreateLocationInput = z.infer<typeof volunteerCreateLocationSchema>;
|
||||
export type OutcomeTrendsQueryInput = z.infer<typeof outcomeTrendsQuerySchema>;
|
||||
|
||||
@ -21,6 +21,7 @@ import type {
|
||||
AdminActivityInput,
|
||||
AdminVisitsInput,
|
||||
VolunteerUpdateLocationInput,
|
||||
OutcomeTrendsQueryInput,
|
||||
} from './canvass.schemas';
|
||||
|
||||
const ADDRESS_SELECT = {
|
||||
@ -995,6 +996,56 @@ export const canvassService = {
|
||||
};
|
||||
},
|
||||
|
||||
async getOutcomeTrends(filters: OutcomeTrendsQueryInput) {
|
||||
const { granularity } = filters;
|
||||
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date();
|
||||
const dateFrom = filters.dateFrom
|
||||
? new Date(filters.dateFrom)
|
||||
: new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Ensure dateTo covers end of day
|
||||
const dateToEnd = new Date(dateTo);
|
||||
dateToEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ period: Date; outcome: string; count: number }[]
|
||||
>`
|
||||
SELECT DATE_TRUNC(${granularity}, "visitedAt") as period,
|
||||
outcome::text as outcome,
|
||||
COUNT(*)::int as count
|
||||
FROM canvass_visits
|
||||
WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd}
|
||||
GROUP BY period, outcome
|
||||
ORDER BY period ASC
|
||||
`;
|
||||
|
||||
// Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }]
|
||||
const pivotMap = new Map<string, Record<string, number>>();
|
||||
const totals: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const dateStr = row.period.toISOString().split('T')[0];
|
||||
if (!pivotMap.has(dateStr)) {
|
||||
pivotMap.set(dateStr, {});
|
||||
}
|
||||
pivotMap.get(dateStr)![row.outcome] = row.count;
|
||||
totals[row.outcome] = (totals[row.outcome] || 0) + row.count;
|
||||
}
|
||||
|
||||
const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({
|
||||
date,
|
||||
...outcomes,
|
||||
}));
|
||||
|
||||
return {
|
||||
granularity,
|
||||
dateFrom: dateFrom.toISOString().split('T')[0],
|
||||
dateTo: dateTo.toISOString().split('T')[0],
|
||||
series,
|
||||
totals,
|
||||
};
|
||||
},
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async recalculateCutCompletion(cutId: string) {
|
||||
|
||||
@ -33,16 +33,23 @@ export async function authenticate(
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
const queryToken = (request.query as Record<string, string>)?.token;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
// Support both Authorization header and ?token= query param (for <img>/<video> src)
|
||||
let token: string | null = null;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (queryToken) {
|
||||
token = queryToken;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return reply.status(401).send({
|
||||
error: 'Authentication required',
|
||||
code: 'AUTH_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Verify JWT with V2 access secret
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
@ -133,12 +140,18 @@ export async function optionalAuth(
|
||||
_reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
const queryToken = (request.query as Record<string, string>)?.token;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return;
|
||||
let token: string | null = null;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (queryToken) {
|
||||
token = queryToken;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
||||
|
||||
364
api/src/modules/media/routes/photo-albums.routes.ts
Normal file
364
api/src/modules/media/routes/photo-albums.routes.ts
Normal file
@ -0,0 +1,364 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
|
||||
/**
|
||||
* Photo album CRUD routes (prefix: /api/albums)
|
||||
*/
|
||||
|
||||
interface CreateAlbumBody {
|
||||
title: string;
|
||||
description?: string;
|
||||
photoIds?: number[];
|
||||
}
|
||||
|
||||
interface UpdateAlbumBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
accessLevel?: string;
|
||||
}
|
||||
|
||||
interface AddPhotosBody {
|
||||
photoIds: number[];
|
||||
}
|
||||
|
||||
interface ReorderBody {
|
||||
photoIds: number[];
|
||||
}
|
||||
|
||||
interface SetCoverBody {
|
||||
photoId: number;
|
||||
}
|
||||
|
||||
export async function photoAlbumsRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/albums - List albums
|
||||
fastify.get<{ Querystring: { limit?: string; offset?: string; search?: string } }>(
|
||||
'/',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.title = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const [albums, total] = await Promise.all([
|
||||
prisma.photoAlbum.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
coverPhoto: {
|
||||
select: { id: true, thumbnailPath: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
_count: { select: { photos: true } },
|
||||
},
|
||||
}),
|
||||
prisma.photoAlbum.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
albums: albums.map(a => ({
|
||||
...a,
|
||||
photoCount: a._count.photos,
|
||||
coverThumbnailUrl: a.coverPhoto?.thumbnailPath
|
||||
? `/media/photos/${a.coverPhoto.id}/thumbnail`
|
||||
: null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/albums - Create album
|
||||
fastify.post<{ Body: CreateAlbumBody }>(
|
||||
'/',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const { title, description, photoIds } = request.body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return reply.code(400).send({ message: 'Title is required' });
|
||||
}
|
||||
|
||||
const album = await prisma.photoAlbum.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
creatorId: request.user?.id || null,
|
||||
photoCount: photoIds?.length || 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Move photos into album if provided
|
||||
if (photoIds?.length) {
|
||||
for (let i = 0; i < photoIds.length; i++) {
|
||||
await prisma.photo.update({
|
||||
where: { id: photoIds[i] },
|
||||
data: { albumId: album.id, albumPosition: i },
|
||||
});
|
||||
}
|
||||
|
||||
// Set first photo as cover
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: album.id },
|
||||
data: { coverPhotoId: photoIds[0] },
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({ album });
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/albums/:id - Album detail with photos
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const album = await prisma.photoAlbum.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
coverPhoto: { select: { id: true, thumbnailPath: true } },
|
||||
creator: { select: { id: true, name: true, email: true } },
|
||||
photos: {
|
||||
orderBy: { albumPosition: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
originalFilename: true,
|
||||
thumbnailPath: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
format: true,
|
||||
fileSize: true,
|
||||
albumPosition: true,
|
||||
isPublished: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!album) {
|
||||
return reply.code(404).send({ message: 'Album not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
...album,
|
||||
photos: album.photos.map(p => ({
|
||||
...p,
|
||||
fileSize: p.fileSize?.toString() ?? null,
|
||||
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/albums/:id - Update album metadata
|
||||
fastify.patch<{ Params: { id: string }; Body: UpdateAlbumBody }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const { title, description, category, accessLevel } = request.body;
|
||||
|
||||
const album = await prisma.photoAlbum.findUnique({ where: { id } });
|
||||
if (!album) {
|
||||
return reply.code(404).send({ message: 'Album not found' });
|
||||
}
|
||||
|
||||
const updated = await prisma.photoAlbum.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title !== undefined && { title: title.trim() }),
|
||||
...(description !== undefined && { description: description?.trim() || null }),
|
||||
...(category !== undefined && { category }),
|
||||
...(accessLevel !== undefined && { accessLevel }),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/albums/:id - Delete album (photos become orphaned, NOT deleted)
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const album = await prisma.photoAlbum.findUnique({ where: { id } });
|
||||
if (!album) {
|
||||
return reply.code(404).send({ message: 'Album not found' });
|
||||
}
|
||||
|
||||
// Remove album reference from photos (orphan them)
|
||||
await prisma.photo.updateMany({
|
||||
where: { albumId: id },
|
||||
data: { albumId: null, albumPosition: null },
|
||||
});
|
||||
|
||||
await prisma.photoAlbum.delete({ where: { id } });
|
||||
|
||||
return { message: 'Album deleted, photos preserved' };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/albums/:id/photos - Add photos to album
|
||||
fastify.post<{ Params: { id: string }; Body: AddPhotosBody }>(
|
||||
'/:id/photos',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const albumId = parseInt(request.params.id as string);
|
||||
const { photoIds } = request.body;
|
||||
|
||||
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
|
||||
if (!album) {
|
||||
return reply.code(404).send({ message: 'Album not found' });
|
||||
}
|
||||
|
||||
// Find current max position
|
||||
const maxPos = await prisma.photo.aggregate({
|
||||
where: { albumId },
|
||||
_max: { albumPosition: true },
|
||||
});
|
||||
let nextPos = (maxPos._max.albumPosition ?? -1) + 1;
|
||||
|
||||
for (const photoId of photoIds) {
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { albumId, albumPosition: nextPos++ },
|
||||
});
|
||||
}
|
||||
|
||||
// Update photo count
|
||||
const count = await prisma.photo.count({ where: { albumId } });
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { photoCount: count },
|
||||
});
|
||||
|
||||
// Set cover if album has none
|
||||
if (!album.coverPhotoId && photoIds.length > 0) {
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { coverPhotoId: photoIds[0] },
|
||||
});
|
||||
}
|
||||
|
||||
return { message: `Added ${photoIds.length} photos to album`, photoCount: count };
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/albums/:id/photos/:photoId - Remove photo from album
|
||||
fastify.delete<{ Params: { id: string; photoId: string } }>(
|
||||
'/:id/photos/:photoId',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const albumId = parseInt(request.params.id as string);
|
||||
const photoId = parseInt(request.params.photoId as string);
|
||||
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { albumId: null, albumPosition: null },
|
||||
});
|
||||
|
||||
// Clear cover if it was the removed photo
|
||||
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
|
||||
if (album?.coverPhotoId === photoId) {
|
||||
// Set next photo as cover or null
|
||||
const nextPhoto = await prisma.photo.findFirst({
|
||||
where: { albumId },
|
||||
orderBy: { albumPosition: 'asc' },
|
||||
});
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { coverPhotoId: nextPhoto?.id ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
// Update count
|
||||
const count = await prisma.photo.count({ where: { albumId } });
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { photoCount: count },
|
||||
});
|
||||
|
||||
return { message: 'Photo removed from album', photoCount: count };
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/albums/:id/reorder - Reorder photos in album
|
||||
fastify.put<{ Params: { id: string }; Body: ReorderBody }>(
|
||||
'/:id/reorder',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const albumId = parseInt(request.params.id as string);
|
||||
const { photoIds } = request.body;
|
||||
|
||||
// Update positions
|
||||
for (let i = 0; i < photoIds.length; i++) {
|
||||
await prisma.photo.update({
|
||||
where: { id: photoIds[i] },
|
||||
data: { albumPosition: i },
|
||||
});
|
||||
}
|
||||
|
||||
return { message: 'Photos reordered' };
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/albums/:id/cover - Set cover photo
|
||||
fastify.put<{ Params: { id: string }; Body: SetCoverBody }>(
|
||||
'/:id/cover',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const albumId = parseInt(request.params.id as string);
|
||||
const { photoId } = request.body;
|
||||
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { coverPhotoId: photoId },
|
||||
});
|
||||
|
||||
return { message: 'Cover photo set' };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/albums/:id/publish - Publish album + all its photos
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/:id/publish',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const albumId = parseInt(request.params.id as string);
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all([
|
||||
prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { isPublished: true, publishedAt: now },
|
||||
}),
|
||||
prisma.photo.updateMany({
|
||||
where: { albumId },
|
||||
data: { isPublished: true, publishedAt: now },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { message: 'Album and photos published' };
|
||||
}
|
||||
);
|
||||
}
|
||||
253
api/src/modules/media/routes/photo-engagement.routes.ts
Normal file
253
api/src/modules/media/routes/photo-engagement.routes.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { createHash } from 'crypto';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* Photo engagement routes — upvotes, comments, reactions, views (prefix: /api)
|
||||
*/
|
||||
|
||||
interface UpvoteParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CommentBody {
|
||||
content: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface ReactionBody {
|
||||
sessionId: string;
|
||||
reactionType: string;
|
||||
}
|
||||
|
||||
interface ViewBody {
|
||||
photoId: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const VALID_REACTIONS = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'];
|
||||
|
||||
export async function photoEngagementRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/photos/:id/upvote - Toggle upvote on
|
||||
fastify.post<{ Params: UpvoteParams; Body: { sessionId: string } }>(
|
||||
'/photos/:id/upvote',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const photoId = parseInt(request.params.id as string);
|
||||
const { sessionId } = request.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.code(400).send({ message: 'sessionId is required' });
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
await prisma.session.upsert({
|
||||
where: { id: sessionId },
|
||||
create: { id: sessionId, userId: request.user?.id || null },
|
||||
update: { lastSeenAt: new Date() },
|
||||
});
|
||||
|
||||
// Check existing
|
||||
const existing = await prisma.photoUpvote.findFirst({
|
||||
where: { photoId, sessionId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return reply.code(409).send({ message: 'Already upvoted' });
|
||||
}
|
||||
|
||||
await prisma.photoUpvote.create({
|
||||
data: { photoId, sessionId },
|
||||
});
|
||||
|
||||
// Increment counter
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { upvoteCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return { message: 'Upvoted', upvoted: true };
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/photos/:id/upvote - Remove upvote
|
||||
fastify.delete<{ Params: UpvoteParams; Body: { sessionId: string } }>(
|
||||
'/photos/:id/upvote',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const photoId = parseInt(request.params.id as string);
|
||||
const sessionId = (request.body as any)?.sessionId || (request.query as any)?.sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.code(400).send({ message: 'sessionId is required' });
|
||||
}
|
||||
|
||||
const existing = await prisma.photoUpvote.findFirst({
|
||||
where: { photoId, sessionId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ message: 'No upvote found' });
|
||||
}
|
||||
|
||||
await prisma.photoUpvote.delete({ where: { id: existing.id } });
|
||||
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { upvoteCount: { decrement: 1 } },
|
||||
});
|
||||
|
||||
return { message: 'Upvote removed', upvoted: false };
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/:id/comments - Get comments
|
||||
fastify.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
|
||||
'/photos/:id/comments',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request) => {
|
||||
const photoId = parseInt(request.params.id as string);
|
||||
const limit = Math.min(parseInt(request.query.limit || '50'), 200);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
|
||||
const [comments, total] = await Promise.all([
|
||||
prisma.photoComment.findMany({
|
||||
where: { photoId, isHidden: false, safetyStatus: 'approved' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.photoComment.count({
|
||||
where: { photoId, isHidden: false, safetyStatus: 'approved' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { comments, total, limit, offset };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/:id/comments - Add comment
|
||||
fastify.post<{ Params: { id: string }; Body: CommentBody }>(
|
||||
'/photos/:id/comments',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const photoId = parseInt(request.params.id as string);
|
||||
const { content, sessionId } = request.body;
|
||||
|
||||
if (!content?.trim()) {
|
||||
return reply.code(400).send({ message: 'Content is required' });
|
||||
}
|
||||
if (!sessionId) {
|
||||
return reply.code(400).send({ message: 'sessionId is required' });
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
await prisma.session.upsert({
|
||||
where: { id: sessionId },
|
||||
create: { id: sessionId, userId: request.user?.id || null },
|
||||
update: { lastSeenAt: new Date() },
|
||||
});
|
||||
|
||||
const comment = await prisma.photoComment.create({
|
||||
data: {
|
||||
photoId,
|
||||
sessionId,
|
||||
userId: request.user?.id || null,
|
||||
content: content.trim().slice(0, 2000), // Max 2000 chars
|
||||
},
|
||||
});
|
||||
|
||||
// Increment counter
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { commentCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return reply.code(201).send({ comment });
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/:id/reactions - Add reaction
|
||||
fastify.post<{ Params: { id: string }; Body: ReactionBody }>(
|
||||
'/photos/:id/reactions',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const photoId = parseInt(request.params.id as string);
|
||||
const { sessionId, reactionType } = request.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.code(400).send({ message: 'sessionId is required' });
|
||||
}
|
||||
if (!VALID_REACTIONS.includes(reactionType)) {
|
||||
return reply.code(400).send({ message: `Invalid reaction. Must be: ${VALID_REACTIONS.join(', ')}` });
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
await prisma.session.upsert({
|
||||
where: { id: sessionId },
|
||||
create: { id: sessionId, userId: request.user?.id || null },
|
||||
update: { lastSeenAt: new Date() },
|
||||
});
|
||||
|
||||
// Upsert reaction (one per session per type)
|
||||
await prisma.photoReaction.upsert({
|
||||
where: {
|
||||
photoId_sessionId_reactionType: {
|
||||
photoId,
|
||||
sessionId,
|
||||
reactionType,
|
||||
},
|
||||
},
|
||||
create: { photoId, sessionId, reactionType },
|
||||
update: {},
|
||||
});
|
||||
|
||||
return { message: 'Reaction added' };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/track/photo-view - Record photo view
|
||||
fastify.post<{ Body: ViewBody }>(
|
||||
'/track/photo-view',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const { photoId, sessionId } = request.body;
|
||||
|
||||
if (!photoId) {
|
||||
return reply.code(400).send({ message: 'photoId is required' });
|
||||
}
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipRaw = request.ip || request.headers['x-forwarded-for'] || '';
|
||||
const ipStr = Array.isArray(ipRaw) ? ipRaw[0] : ipRaw;
|
||||
const ipHash = createHash('sha256').update(ipStr).digest('hex').slice(0, 16);
|
||||
|
||||
await prisma.photoView.create({
|
||||
data: {
|
||||
photoId,
|
||||
sessionId: sessionId || null,
|
||||
userId: request.user?.id || null,
|
||||
ipAddressHash: ipHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Increment counter
|
||||
await prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return { message: 'View recorded' };
|
||||
}
|
||||
);
|
||||
}
|
||||
269
api/src/modules/media/routes/photo-upload.routes.ts
Normal file
269
api/src/modules/media/routes/photo-upload.routes.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { unlink, mkdir } from 'fs/promises';
|
||||
import { join, extname } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { prisma } from '../../../config/database';
|
||||
import {
|
||||
ALLOWED_IMAGE_EXTENSIONS,
|
||||
validateImage,
|
||||
extractPhotoMetadata,
|
||||
generateVariants,
|
||||
getPhotoInboxDir,
|
||||
} from '../services/photo-processing.service';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* Photo upload routes (prefix: /api/photos)
|
||||
*/
|
||||
export async function photoUploadRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/photos/upload - Single photo upload
|
||||
fastify.post(
|
||||
'/upload',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let tempFilePath: string | null = null;
|
||||
|
||||
try {
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.code(400).send({ message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const ext = extname(data.filename).toLowerCase();
|
||||
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
|
||||
return reply.code(400).send({
|
||||
message: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique filename and ensure inbox dir exists
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const inboxDir = getPhotoInboxDir();
|
||||
await mkdir(inboxDir, { recursive: true });
|
||||
|
||||
const filePath = join(inboxDir, filename);
|
||||
tempFilePath = filePath;
|
||||
|
||||
// Stream file to disk
|
||||
logger.info(`Uploading photo to ${filePath}`);
|
||||
await pipeline(data.file, createWriteStream(filePath));
|
||||
|
||||
// Extract metadata fields from form data
|
||||
const metadataFields = data.fields as Record<string, { value: string }>;
|
||||
const title = metadataFields.title?.value;
|
||||
const producer = metadataFields.producer?.value;
|
||||
const creator = metadataFields.creator?.value;
|
||||
const albumIdStr = metadataFields.albumId?.value;
|
||||
const albumId = albumIdStr ? parseInt(albumIdStr) : null;
|
||||
|
||||
// Validate image
|
||||
logger.info(`Validating image: ${filePath}`);
|
||||
await validateImage(filePath);
|
||||
|
||||
// Extract metadata
|
||||
logger.info(`Extracting photo metadata: ${filePath}`);
|
||||
const metadata = await extractPhotoMetadata(filePath);
|
||||
|
||||
// Determine album position if adding to album
|
||||
let albumPosition: number | null = null;
|
||||
if (albumId) {
|
||||
const maxPos = await prisma.photo.aggregate({
|
||||
where: { albumId },
|
||||
_max: { albumPosition: true },
|
||||
});
|
||||
albumPosition = (maxPos._max.albumPosition ?? -1) + 1;
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
const photo = await prisma.photo.create({
|
||||
data: {
|
||||
path: filePath,
|
||||
filename,
|
||||
originalFilename: data.filename,
|
||||
title: title || data.filename,
|
||||
producer: producer || null,
|
||||
creator: creator || null,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
orientation: metadata.orientation,
|
||||
fileSize: BigInt(metadata.fileSize),
|
||||
format: metadata.format,
|
||||
colorSpace: metadata.colorSpace,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
dpi: metadata.dpi,
|
||||
cameraMake: metadata.cameraMake,
|
||||
cameraModel: metadata.cameraModel,
|
||||
focalLength: metadata.focalLength,
|
||||
aperture: metadata.aperture,
|
||||
shutterSpeed: metadata.shutterSpeed,
|
||||
iso: metadata.iso,
|
||||
takenAt: metadata.takenAt,
|
||||
gpsLatitude: metadata.gpsLatitude,
|
||||
gpsLongitude: metadata.gpsLongitude,
|
||||
albumId,
|
||||
albumPosition,
|
||||
uploaderId: request.user?.id || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Photo uploaded: ${photo.id}`);
|
||||
|
||||
// Generate image variants (thumbnail, medium, large, webp)
|
||||
try {
|
||||
const variants = await generateVariants(filePath, photo.id);
|
||||
await prisma.photo.update({
|
||||
where: { id: photo.id },
|
||||
data: {
|
||||
thumbnailPath: variants.thumbnailPath,
|
||||
mediumPath: variants.mediumPath,
|
||||
largePath: variants.largePath,
|
||||
webpPath: variants.webpPath,
|
||||
},
|
||||
});
|
||||
logger.info(`Generated variants for photo ${photo.id}`);
|
||||
} catch (variantError) {
|
||||
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
|
||||
}
|
||||
|
||||
// Update album photo count if applicable
|
||||
if (albumId) {
|
||||
const count = await prisma.photo.count({ where: { albumId } });
|
||||
await prisma.photoAlbum.update({
|
||||
where: { id: albumId },
|
||||
data: { photoCount: count },
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
message: 'Photo uploaded successfully',
|
||||
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
|
||||
});
|
||||
} catch (error) {
|
||||
if (tempFilePath) {
|
||||
try { await unlink(tempFilePath); } catch { /* ignore */ }
|
||||
}
|
||||
logger.error('Photo upload failed:', error);
|
||||
return reply.code(500).send({
|
||||
message: error instanceof Error ? error.message : 'Upload failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/upload/batch - Batch photo upload
|
||||
fastify.post(
|
||||
'/upload/batch',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const files = request.files();
|
||||
const results: Array<{ filename: string; success: boolean; error?: string; photo?: any }> = [];
|
||||
|
||||
for await (const file of files) {
|
||||
let tempFilePath: string | null = null;
|
||||
|
||||
try {
|
||||
const ext = extname(file.filename).toLowerCase();
|
||||
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
|
||||
results.push({
|
||||
filename: file.filename,
|
||||
success: false,
|
||||
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const inboxDir = getPhotoInboxDir();
|
||||
await mkdir(inboxDir, { recursive: true });
|
||||
|
||||
const filePath = join(inboxDir, filename);
|
||||
tempFilePath = filePath;
|
||||
|
||||
await pipeline(file.file, createWriteStream(filePath));
|
||||
await validateImage(filePath);
|
||||
const metadata = await extractPhotoMetadata(filePath);
|
||||
|
||||
const photo = await prisma.photo.create({
|
||||
data: {
|
||||
path: filePath,
|
||||
filename,
|
||||
originalFilename: file.filename,
|
||||
title: file.filename,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
orientation: metadata.orientation,
|
||||
fileSize: BigInt(metadata.fileSize),
|
||||
format: metadata.format,
|
||||
colorSpace: metadata.colorSpace,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
dpi: metadata.dpi,
|
||||
cameraMake: metadata.cameraMake,
|
||||
cameraModel: metadata.cameraModel,
|
||||
focalLength: metadata.focalLength,
|
||||
aperture: metadata.aperture,
|
||||
shutterSpeed: metadata.shutterSpeed,
|
||||
iso: metadata.iso,
|
||||
takenAt: metadata.takenAt,
|
||||
gpsLatitude: metadata.gpsLatitude,
|
||||
gpsLongitude: metadata.gpsLongitude,
|
||||
uploaderId: request.user?.id || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate variants
|
||||
try {
|
||||
const variants = await generateVariants(filePath, photo.id);
|
||||
await prisma.photo.update({
|
||||
where: { id: photo.id },
|
||||
data: {
|
||||
thumbnailPath: variants.thumbnailPath,
|
||||
mediumPath: variants.mediumPath,
|
||||
largePath: variants.largePath,
|
||||
webpPath: variants.webpPath,
|
||||
},
|
||||
});
|
||||
} catch (variantError) {
|
||||
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
|
||||
}
|
||||
|
||||
results.push({
|
||||
filename: file.filename,
|
||||
success: true,
|
||||
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
|
||||
});
|
||||
|
||||
logger.info(`Batch upload: ${file.filename} -> photo ${photo.id}`);
|
||||
} catch (error) {
|
||||
if (tempFilePath) {
|
||||
try { await unlink(tempFilePath); } catch { /* ignore */ }
|
||||
}
|
||||
logger.error(`Batch upload failed for ${file.filename}:`, error);
|
||||
results.push({
|
||||
filename: file.filename,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Upload failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
return reply.code(207).send({
|
||||
message: `Batch upload: ${successCount} succeeded, ${failCount} failed`,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Batch photo upload failed:', error);
|
||||
return reply.code(500).send({
|
||||
message: error instanceof Error ? error.message : 'Batch upload failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
473
api/src/modules/media/routes/photos-public.routes.ts
Normal file
473
api/src/modules/media/routes/photos-public.routes.ts
Normal file
@ -0,0 +1,473 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* Public photo/album/gallery endpoints (prefix: /api)
|
||||
*/
|
||||
|
||||
interface PublicPhotosQuery {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
sort?: 'recent' | 'popular' | 'oldest';
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface UnifiedGalleryQuery {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
sort?: 'recent' | 'popular';
|
||||
category?: string;
|
||||
mediaType?: 'all' | 'video' | 'photo';
|
||||
}
|
||||
|
||||
interface ImageQuery {
|
||||
size?: 'thumb' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export async function photosPublicRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/public/photos - Published photos (paginated)
|
||||
fastify.get<{ Querystring: PublicPhotosQuery }>(
|
||||
'/public/photos',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const sort = request.query.sort || 'recent';
|
||||
const category = request.query.category;
|
||||
|
||||
const where: any = {
|
||||
isPublished: true,
|
||||
isLocked: false,
|
||||
};
|
||||
if (category) where.category = category;
|
||||
|
||||
let orderBy: any = { publishedAt: 'desc' };
|
||||
if (sort === 'oldest') orderBy = { publishedAt: 'asc' };
|
||||
if (sort === 'popular') orderBy = { viewCount: 'desc' };
|
||||
|
||||
const [photos, total] = await Promise.all([
|
||||
prisma.photo.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
format: true,
|
||||
producer: true,
|
||||
category: true,
|
||||
publishedAt: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
commentCount: true,
|
||||
albumId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.photo.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
photos: photos.map(p => ({
|
||||
...p,
|
||||
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
|
||||
imageUrl: `/media/public/photos/${p.id}/image`,
|
||||
})),
|
||||
pagination: { total, limit, offset, hasMore: offset + limit < total },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/photos/:id - Single published photo
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/photos/:id',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const photo = await prisma.photo.findFirst({
|
||||
where: { id, isPublished: true, isLocked: false },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
format: true,
|
||||
producer: true,
|
||||
creator: true,
|
||||
category: true,
|
||||
cameraMake: true,
|
||||
cameraModel: true,
|
||||
focalLength: true,
|
||||
aperture: true,
|
||||
shutterSpeed: true,
|
||||
iso: true,
|
||||
takenAt: true,
|
||||
publishedAt: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
commentCount: true,
|
||||
albumId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
...photo,
|
||||
thumbnailUrl: `/media/public/photos/${photo.id}/thumbnail`,
|
||||
imageUrl: `/media/public/photos/${photo.id}/image`,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/photos/:id/image - Serve optimized image
|
||||
fastify.get<{ Params: { id: string }; Querystring: ImageQuery }>(
|
||||
'/public/photos/:id/image',
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const size = request.query.size || 'large';
|
||||
|
||||
const photo = await prisma.photo.findFirst({
|
||||
where: { id, isPublished: true, isLocked: false },
|
||||
select: {
|
||||
thumbnailPath: true,
|
||||
mediumPath: true,
|
||||
largePath: true,
|
||||
webpPath: true,
|
||||
format: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Check if client accepts WebP
|
||||
const acceptsWebP = request.headers.accept?.includes('image/webp');
|
||||
|
||||
// Pick the right variant
|
||||
let filePath: string | null = null;
|
||||
let contentType = 'image/jpeg';
|
||||
|
||||
if (acceptsWebP && photo.webpPath) {
|
||||
filePath = photo.webpPath;
|
||||
contentType = 'image/webp';
|
||||
} else {
|
||||
switch (size) {
|
||||
case 'thumb':
|
||||
filePath = photo.thumbnailPath;
|
||||
break;
|
||||
case 'medium':
|
||||
filePath = photo.mediumPath;
|
||||
break;
|
||||
case 'large':
|
||||
default:
|
||||
filePath = photo.largePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath || filePath.includes('..')) {
|
||||
return reply.code(404).send({ message: 'Image variant not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Image file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||
return reply.send(createReadStream(filePath));
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/photos/:id/thumbnail - Serve thumbnail
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/photos/:id/thumbnail',
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const photo = await prisma.photo.findFirst({
|
||||
where: { id, isPublished: true, isLocked: false },
|
||||
select: { thumbnailPath: true },
|
||||
});
|
||||
|
||||
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
|
||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(photo.thumbnailPath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'image/jpeg');
|
||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||
return reply.send(createReadStream(photo.thumbnailPath));
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/albums - Published albums
|
||||
fastify.get<{ Querystring: PublicPhotosQuery }>(
|
||||
'/public/albums',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const category = request.query.category;
|
||||
|
||||
const where: any = { isPublished: true, isLocked: false };
|
||||
if (category) where.category = category;
|
||||
|
||||
const [albums, total] = await Promise.all([
|
||||
prisma.photoAlbum.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
category: true,
|
||||
photoCount: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
publishedAt: true,
|
||||
coverPhoto: {
|
||||
select: { id: true, thumbnailPath: true, width: true, height: true },
|
||||
},
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.photoAlbum.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
albums: albums.map(a => ({
|
||||
...a,
|
||||
coverThumbnailUrl: a.coverPhoto
|
||||
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
|
||||
: null,
|
||||
})),
|
||||
pagination: { total, limit, offset, hasMore: offset + limit < total },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/albums/:id - Published album with photos
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/albums/:id',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const album = await prisma.photoAlbum.findFirst({
|
||||
where: { id, isPublished: true, isLocked: false },
|
||||
include: {
|
||||
coverPhoto: { select: { id: true, thumbnailPath: true } },
|
||||
photos: {
|
||||
where: { isPublished: true, isLocked: false },
|
||||
orderBy: { albumPosition: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
format: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
commentCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!album) {
|
||||
return reply.code(404).send({ message: 'Album not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
...album,
|
||||
photos: album.photos.map(p => ({
|
||||
...p,
|
||||
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
|
||||
imageUrl: `/media/public/photos/${p.id}/image`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/public/gallery - Unified feed (videos + photos + albums)
|
||||
fastify.get<{ Querystring: UnifiedGalleryQuery }>(
|
||||
'/public/gallery',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const sort = request.query.sort || 'recent';
|
||||
const category = request.query.category;
|
||||
const mediaType = request.query.mediaType || 'all';
|
||||
|
||||
const orderByDate = sort === 'popular' ? undefined : 'desc';
|
||||
const items: Array<{ type: 'video' | 'photo' | 'album'; data: any; publishedAt: Date }> = [];
|
||||
|
||||
// Fetch videos if needed
|
||||
if (mediaType === 'all' || mediaType === 'video') {
|
||||
const videoWhere: any = { isPublished: true, isLocked: false };
|
||||
if (category) videoWhere.category = category;
|
||||
|
||||
const videos = await prisma.video.findMany({
|
||||
where: videoWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
durationSeconds: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
quality: true,
|
||||
producer: true,
|
||||
thumbnailPath: true,
|
||||
publishedAt: true,
|
||||
category: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
commentCount: true,
|
||||
isShort: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
|
||||
take: limit + offset, // Over-fetch for merge
|
||||
});
|
||||
|
||||
for (const v of videos) {
|
||||
items.push({
|
||||
type: 'video',
|
||||
data: {
|
||||
...v,
|
||||
duration: v.durationSeconds,
|
||||
thumbnailUrl: v.thumbnailPath ? `/media/videos/${v.id}/thumbnail` : null,
|
||||
videoUrl: `/media/videos/${v.id}/stream`,
|
||||
},
|
||||
publishedAt: v.publishedAt || v.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch photos (non-album) if needed
|
||||
if (mediaType === 'all' || mediaType === 'photo') {
|
||||
const photoWhere: any = { isPublished: true, isLocked: false, albumId: null };
|
||||
if (category) photoWhere.category = category;
|
||||
|
||||
const photos = await prisma.photo.findMany({
|
||||
where: photoWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
width: true,
|
||||
height: true,
|
||||
orientation: true,
|
||||
format: true,
|
||||
producer: true,
|
||||
category: true,
|
||||
publishedAt: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
commentCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
|
||||
take: limit + offset,
|
||||
});
|
||||
|
||||
for (const p of photos) {
|
||||
items.push({
|
||||
type: 'photo',
|
||||
data: {
|
||||
...p,
|
||||
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
|
||||
imageUrl: `/media/public/photos/${p.id}/image`,
|
||||
},
|
||||
publishedAt: p.publishedAt || p.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch albums
|
||||
const albumWhere: any = { isPublished: true, isLocked: false };
|
||||
if (category) albumWhere.category = category;
|
||||
|
||||
const albums = await prisma.photoAlbum.findMany({
|
||||
where: albumWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
category: true,
|
||||
photoCount: true,
|
||||
viewCount: true,
|
||||
upvoteCount: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
coverPhoto: {
|
||||
select: { id: true, thumbnailPath: true, width: true, height: true },
|
||||
},
|
||||
},
|
||||
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
|
||||
take: limit + offset,
|
||||
});
|
||||
|
||||
for (const a of albums) {
|
||||
items.push({
|
||||
type: 'album',
|
||||
data: {
|
||||
...a,
|
||||
coverThumbnailUrl: a.coverPhoto
|
||||
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
|
||||
: null,
|
||||
},
|
||||
publishedAt: a.publishedAt || a.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort merged results
|
||||
if (sort === 'popular') {
|
||||
items.sort((a, b) => (b.data.viewCount || 0) - (a.data.viewCount || 0));
|
||||
} else {
|
||||
items.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
|
||||
}
|
||||
|
||||
// Apply offset and limit
|
||||
const paged = items.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
items: paged.map(({ type, data }) => ({ type, data })),
|
||||
pagination: {
|
||||
total: items.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < items.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
388
api/src/modules/media/routes/photos.routes.ts
Normal file
388
api/src/modules/media/routes/photos.routes.ts
Normal file
@ -0,0 +1,388 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { unlink } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Admin photo CRUD routes (prefix: /api/photos)
|
||||
*/
|
||||
|
||||
interface PhotosQuery {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
search?: string;
|
||||
format?: string;
|
||||
orientation?: 'H' | 'V' | 'S';
|
||||
producer?: string;
|
||||
albumId?: string;
|
||||
isPublished?: string;
|
||||
}
|
||||
|
||||
interface PhotoUpdateBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
producer?: string;
|
||||
creator?: string;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
accessLevel?: string;
|
||||
}
|
||||
|
||||
interface BulkIdsBody {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export async function photosRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/photos - List photos (admin, paginated)
|
||||
fastify.get<{ Querystring: PhotosQuery }>(
|
||||
'/',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const { search, format, orientation, producer, albumId, isPublished } = request.query;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ originalFilename: { contains: search, mode: 'insensitive' } },
|
||||
{ producer: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (format) where.format = format;
|
||||
if (orientation) where.orientation = orientation;
|
||||
if (producer) where.producer = producer;
|
||||
if (albumId) where.albumId = parseInt(albumId);
|
||||
if (isPublished !== undefined) where.isPublished = isPublished === 'true';
|
||||
|
||||
const [photos, total] = await Promise.all([
|
||||
prisma.photo.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
album: { select: { id: true, title: true } },
|
||||
},
|
||||
}),
|
||||
prisma.photo.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
photos: photos.map(p => ({
|
||||
...p,
|
||||
fileSize: p.fileSize?.toString() ?? null,
|
||||
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/producers - Distinct producers list
|
||||
fastify.get(
|
||||
'/producers',
|
||||
{ preHandler: requireAdminRole },
|
||||
async () => {
|
||||
const results = await prisma.photo.findMany({
|
||||
where: { producer: { not: null } },
|
||||
select: { producer: true },
|
||||
distinct: ['producer'],
|
||||
});
|
||||
return results.map(r => r.producer).filter(Boolean);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/formats - Distinct formats list
|
||||
fastify.get(
|
||||
'/formats',
|
||||
{ preHandler: requireAdminRole },
|
||||
async () => {
|
||||
const results = await prisma.photo.findMany({
|
||||
where: { format: { not: null } },
|
||||
select: { format: true },
|
||||
distinct: ['format'],
|
||||
});
|
||||
return results.map(r => r.format).filter(Boolean);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/:id - Single photo detail
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const photo = await prisma.photo.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
album: { select: { id: true, title: true } },
|
||||
uploader: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
...photo,
|
||||
fileSize: photo.fileSize?.toString() ?? null,
|
||||
thumbnailUrl: photo.thumbnailPath ? `/media/photos/${photo.id}/thumbnail` : null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/:id/thumbnail - Serve thumbnail image (admin)
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id/thumbnail',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const photo = await prisma.photo.findUnique({
|
||||
where: { id },
|
||||
select: { thumbnailPath: true },
|
||||
});
|
||||
|
||||
if (!photo?.thumbnailPath) {
|
||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
if (photo.thumbnailPath.includes('..')) {
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
const { createReadStream } = await import('fs');
|
||||
const { access } = await import('fs/promises');
|
||||
|
||||
try {
|
||||
await access(photo.thumbnailPath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'image/jpeg');
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
return reply.send(createReadStream(photo.thumbnailPath));
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/photos/:id/image - Serve full image (admin, size: thumb/medium/large)
|
||||
fastify.get<{ Params: { id: string }; Querystring: { size?: string } }>(
|
||||
'/:id/image',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const size = request.query.size || 'large';
|
||||
|
||||
const photo = await prisma.photo.findUnique({
|
||||
where: { id },
|
||||
select: { thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true, path: true, format: true },
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Pick variant based on size param
|
||||
let filePath: string | null = null;
|
||||
let contentType = 'image/jpeg';
|
||||
|
||||
switch (size) {
|
||||
case 'thumb':
|
||||
filePath = photo.thumbnailPath;
|
||||
break;
|
||||
case 'medium':
|
||||
filePath = photo.mediumPath;
|
||||
break;
|
||||
case 'large':
|
||||
default:
|
||||
filePath = photo.largePath || photo.mediumPath;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!filePath || filePath.includes('..')) {
|
||||
return reply.code(404).send({ message: 'Image variant not found' });
|
||||
}
|
||||
|
||||
const { createReadStream } = await import('fs');
|
||||
const { access } = await import('fs/promises');
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Image file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
return reply.send(createReadStream(filePath));
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/photos/:id - Update photo metadata
|
||||
fastify.patch<{ Params: { id: string }; Body: PhotoUpdateBody }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const { title, description, producer, creator, tags, category, accessLevel } = request.body;
|
||||
|
||||
const photo = await prisma.photo.findUnique({ where: { id } });
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
const updated = await prisma.photo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title !== undefined && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(producer !== undefined && { producer }),
|
||||
...(creator !== undefined && { creator }),
|
||||
...(tags !== undefined && { tags: tags as any }),
|
||||
...(category !== undefined && { category }),
|
||||
...(accessLevel !== undefined && { accessLevel }),
|
||||
},
|
||||
});
|
||||
|
||||
return { ...updated, fileSize: updated.fileSize?.toString() ?? null };
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/photos/:id - Delete photo + variant files
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
|
||||
const photo = await prisma.photo.findUnique({ where: { id } });
|
||||
if (!photo) {
|
||||
return reply.code(404).send({ message: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Delete variant files
|
||||
const filesToDelete = [
|
||||
photo.path,
|
||||
photo.thumbnailPath,
|
||||
photo.mediumPath,
|
||||
photo.largePath,
|
||||
photo.webpPath,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const filePath of filesToDelete) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
// If this was a cover photo for an album, clear the cover
|
||||
if (photo.albumId) {
|
||||
await prisma.photoAlbum.updateMany({
|
||||
where: { coverPhotoId: id },
|
||||
data: { coverPhotoId: null },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.photo.delete({ where: { id } });
|
||||
|
||||
return { message: 'Photo deleted' };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/:id/publish
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/:id/publish',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const photo = await prisma.photo.update({
|
||||
where: { id },
|
||||
data: { isPublished: true, publishedAt: new Date() },
|
||||
});
|
||||
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/:id/unpublish
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/:id/unpublish',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id as string);
|
||||
const photo = await prisma.photo.update({
|
||||
where: { id },
|
||||
data: { isPublished: false },
|
||||
});
|
||||
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/bulk-publish
|
||||
fastify.post<{ Body: BulkIdsBody }>(
|
||||
'/bulk-publish',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request) => {
|
||||
const { ids } = request.body;
|
||||
const result = await prisma.photo.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { isPublished: true, publishedAt: new Date() },
|
||||
});
|
||||
return { updated: result.count };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/bulk-unpublish
|
||||
fastify.post<{ Body: BulkIdsBody }>(
|
||||
'/bulk-unpublish',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request) => {
|
||||
const { ids } = request.body;
|
||||
const result = await prisma.photo.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { isPublished: false },
|
||||
});
|
||||
return { updated: result.count };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/photos/bulk-delete
|
||||
fastify.post<{ Body: BulkIdsBody }>(
|
||||
'/bulk-delete',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request) => {
|
||||
const { ids } = request.body;
|
||||
|
||||
// Get file paths before deleting
|
||||
const photos = await prisma.photo.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { path: true, thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true },
|
||||
});
|
||||
|
||||
// Delete files
|
||||
for (const photo of photos) {
|
||||
const paths = [photo.path, photo.thumbnailPath, photo.mediumPath, photo.largePath, photo.webpPath].filter(Boolean) as string[];
|
||||
for (const p of paths) {
|
||||
try { await unlink(p); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Clear album covers referencing these photos
|
||||
await prisma.photoAlbum.updateMany({
|
||||
where: { coverPhotoId: { in: ids } },
|
||||
data: { coverPhotoId: null },
|
||||
});
|
||||
|
||||
const result = await prisma.photo.deleteMany({ where: { id: { in: ids } } });
|
||||
return { deleted: result.count };
|
||||
}
|
||||
);
|
||||
}
|
||||
282
api/src/modules/media/services/photo-processing.service.ts
Normal file
282
api/src/modules/media/services/photo-processing.service.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import sharp from 'sharp';
|
||||
import { stat, mkdir } from 'fs/promises';
|
||||
import { join, extname } from 'path';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// Supported image extensions
|
||||
export const ALLOWED_IMAGE_EXTENSIONS = [
|
||||
'.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tiff', '.tif', '.heic', '.heif',
|
||||
];
|
||||
|
||||
// Variant output directories (relative to /media/local/photos/)
|
||||
const PHOTOS_BASE = '/media/local/photos';
|
||||
const DIRS = {
|
||||
inbox: join(PHOTOS_BASE, 'inbox'),
|
||||
thumbnails: join(PHOTOS_BASE, 'thumbnails'),
|
||||
medium: join(PHOTOS_BASE, 'medium'),
|
||||
large: join(PHOTOS_BASE, 'large'),
|
||||
webp: join(PHOTOS_BASE, 'webp'),
|
||||
};
|
||||
|
||||
export interface PhotoMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: 'H' | 'V' | 'S';
|
||||
fileSize: number;
|
||||
format: string;
|
||||
colorSpace: string | null;
|
||||
hasAlpha: boolean;
|
||||
dpi: number | null;
|
||||
// EXIF
|
||||
cameraMake: string | null;
|
||||
cameraModel: string | null;
|
||||
focalLength: string | null;
|
||||
aperture: string | null;
|
||||
shutterSpeed: string | null;
|
||||
iso: number | null;
|
||||
takenAt: Date | null;
|
||||
gpsLatitude: number | null;
|
||||
gpsLongitude: number | null;
|
||||
}
|
||||
|
||||
export interface PhotoVariants {
|
||||
thumbnailPath: string;
|
||||
mediumPath: string;
|
||||
largePath: string;
|
||||
webpPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file is a real image (not just a renamed binary).
|
||||
* Returns metadata if valid, throws if not.
|
||||
*/
|
||||
export async function validateImage(filePath: string): Promise<sharp.Metadata> {
|
||||
try {
|
||||
const metadata = await sharp(filePath).metadata();
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Image has no dimensions');
|
||||
}
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid image file: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from an image file including EXIF data.
|
||||
*/
|
||||
export async function extractPhotoMetadata(filePath: string): Promise<PhotoMetadata> {
|
||||
const metadata = await sharp(filePath).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Image has no dimensions');
|
||||
}
|
||||
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
// Determine orientation
|
||||
let orientation: 'H' | 'V' | 'S' = 'H';
|
||||
if (metadata.width === metadata.height) {
|
||||
orientation = 'S';
|
||||
} else if (metadata.height > metadata.width) {
|
||||
orientation = 'V';
|
||||
}
|
||||
|
||||
// Parse EXIF data
|
||||
const exif = metadata.exif ? parseExif(metadata) : null;
|
||||
|
||||
return {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
orientation,
|
||||
fileSize: fileStat.size,
|
||||
format: metadata.format || extname(filePath).slice(1).toLowerCase(),
|
||||
colorSpace: metadata.space || null,
|
||||
hasAlpha: metadata.hasAlpha || false,
|
||||
dpi: metadata.density || null,
|
||||
cameraMake: exif?.make || null,
|
||||
cameraModel: exif?.model || null,
|
||||
focalLength: exif?.focalLength || null,
|
||||
aperture: exif?.aperture || null,
|
||||
shutterSpeed: exif?.shutterSpeed || null,
|
||||
iso: exif?.iso || null,
|
||||
takenAt: exif?.takenAt || null,
|
||||
gpsLatitude: exif?.gpsLatitude || null,
|
||||
gpsLongitude: exif?.gpsLongitude || null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedExif {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
focalLength: string | null;
|
||||
aperture: string | null;
|
||||
shutterSpeed: string | null;
|
||||
iso: number | null;
|
||||
takenAt: Date | null;
|
||||
gpsLatitude: number | null;
|
||||
gpsLongitude: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EXIF data from sharp metadata.
|
||||
* sharp exposes raw EXIF buffer; we parse key IFD tags manually.
|
||||
*/
|
||||
function parseExif(metadata: sharp.Metadata): ParsedExif {
|
||||
const result: ParsedExif = {
|
||||
make: null,
|
||||
model: null,
|
||||
focalLength: null,
|
||||
aperture: null,
|
||||
shutterSpeed: null,
|
||||
iso: null,
|
||||
takenAt: null,
|
||||
gpsLatitude: null,
|
||||
gpsLongitude: null,
|
||||
};
|
||||
|
||||
// sharp provides some EXIF-derived fields directly on metadata
|
||||
// For deeper EXIF, we'd need exif-reader, but sharp gives us basics
|
||||
// We'll use the raw exif buffer if available
|
||||
|
||||
try {
|
||||
if (metadata.exif) {
|
||||
// Use dynamic import for exif-reader if available, otherwise skip
|
||||
// For now, rely on sharp's built-in metadata fields
|
||||
// sharp exposes: orientation, density (DPI)
|
||||
// Additional EXIF requires the exif-reader package
|
||||
|
||||
// Try to parse raw EXIF with built-in support
|
||||
const exifBuffer = metadata.exif;
|
||||
if (exifBuffer && exifBuffer.length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const exifReader = require('exif-reader');
|
||||
const parsed = exifReader(exifBuffer);
|
||||
|
||||
if (parsed.Image) {
|
||||
result.make = parsed.Image.Make || null;
|
||||
result.model = parsed.Image.Model || null;
|
||||
}
|
||||
if (parsed.Photo || parsed.Exif) {
|
||||
const photo = parsed.Photo || parsed.Exif || {};
|
||||
if (photo.FocalLength) result.focalLength = `${photo.FocalLength}mm`;
|
||||
if (photo.FNumber) result.aperture = `f/${photo.FNumber}`;
|
||||
if (photo.ExposureTime) {
|
||||
result.shutterSpeed = photo.ExposureTime < 1
|
||||
? `1/${Math.round(1 / photo.ExposureTime)}s`
|
||||
: `${photo.ExposureTime}s`;
|
||||
}
|
||||
if (photo.ISOSpeedRatings) {
|
||||
result.iso = Array.isArray(photo.ISOSpeedRatings)
|
||||
? photo.ISOSpeedRatings[0]
|
||||
: photo.ISOSpeedRatings;
|
||||
}
|
||||
if (photo.DateTimeOriginal) {
|
||||
result.takenAt = new Date(photo.DateTimeOriginal);
|
||||
}
|
||||
}
|
||||
if (parsed.GPSInfo || parsed.GPS) {
|
||||
const gps = parsed.GPSInfo || parsed.GPS || {};
|
||||
if (gps.GPSLatitude && gps.GPSLatitudeRef) {
|
||||
result.gpsLatitude = dmsToDecimal(gps.GPSLatitude, gps.GPSLatitudeRef);
|
||||
}
|
||||
if (gps.GPSLongitude && gps.GPSLongitudeRef) {
|
||||
result.gpsLongitude = dmsToDecimal(gps.GPSLongitude, gps.GPSLongitudeRef);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// exif-reader not available or parse failed — that's fine
|
||||
logger.debug('EXIF parsing skipped (exif-reader not available or parse error)');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('EXIF extraction failed', { error });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert DMS (degrees/minutes/seconds) array to decimal degrees.
|
||||
*/
|
||||
function dmsToDecimal(dms: number[], ref: string): number {
|
||||
if (!dms || dms.length < 3) return 0;
|
||||
let decimal = dms[0] + dms[1] / 60 + dms[2] / 3600;
|
||||
if (ref === 'S' || ref === 'W') decimal = -decimal;
|
||||
return Math.round(decimal * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all photo variant directories exist.
|
||||
*/
|
||||
export async function ensurePhotoDirs(): Promise<void> {
|
||||
for (const dir of Object.values(DIRS)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all image variants (thumbnail, medium, large, webp).
|
||||
* Auto-orients using EXIF data, strips GPS from output variants.
|
||||
*/
|
||||
export async function generateVariants(
|
||||
sourcePath: string,
|
||||
photoId: number
|
||||
): Promise<PhotoVariants> {
|
||||
await ensurePhotoDirs();
|
||||
|
||||
const thumbPath = join(DIRS.thumbnails, `${photoId}_thumb.jpg`);
|
||||
const mediumPath = join(DIRS.medium, `${photoId}_medium.jpg`);
|
||||
const largePath = join(DIRS.large, `${photoId}_large.jpg`);
|
||||
const webpPath = join(DIRS.webp, `${photoId}.webp`);
|
||||
|
||||
// Base pipeline: auto-orient (apply EXIF rotation) and strip metadata (privacy)
|
||||
const basePipeline = () => sharp(sourcePath).rotate().withMetadata({ orientation: undefined });
|
||||
|
||||
// Generate all variants in parallel
|
||||
await Promise.all([
|
||||
// Thumbnail: 320px longest edge, JPEG q80
|
||||
basePipeline()
|
||||
.resize(320, 320, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbPath),
|
||||
|
||||
// Medium: 800px longest edge, JPEG q85
|
||||
basePipeline()
|
||||
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(mediumPath),
|
||||
|
||||
// Large: 1600px longest edge, JPEG q90
|
||||
basePipeline()
|
||||
.resize(1600, 1600, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(largePath),
|
||||
|
||||
// WebP: 1200px longest edge, WebP q82
|
||||
basePipeline()
|
||||
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toFile(webpPath),
|
||||
]);
|
||||
|
||||
logger.info(`Generated 4 variants for photo ${photoId}`);
|
||||
|
||||
return {
|
||||
thumbnailPath: thumbPath,
|
||||
mediumPath,
|
||||
largePath,
|
||||
webpPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inbox directory path for photo uploads.
|
||||
*/
|
||||
export function getPhotoInboxDir(): string {
|
||||
return DIRS.inbox;
|
||||
}
|
||||
48
api/src/modules/volunteer-invite/volunteer-invite.routes.ts
Normal file
48
api/src/modules/volunteer-invite/volunteer-invite.routes.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { quickJoinRateLimit } from '../../middleware/rate-limit';
|
||||
import { volunteerInviteService } from './volunteer-invite.service';
|
||||
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/volunteer-invite/generate — Admin-only: create a signed invite token
|
||||
router.post(
|
||||
'/generate',
|
||||
authenticate,
|
||||
requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'),
|
||||
validate(generateInviteSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { cutId, shiftId } = req.body;
|
||||
const token = volunteerInviteService.generateInviteToken(req.user!.id, cutId, shiftId);
|
||||
res.json({ token });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/volunteer-invite/redeem — Public: redeem an invite token
|
||||
router.post(
|
||||
'/redeem',
|
||||
quickJoinRateLimit,
|
||||
validate(redeemInviteSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await volunteerInviteService.redeemInvite(req.body);
|
||||
res.json({
|
||||
accessToken: result.tokens.accessToken,
|
||||
refreshToken: result.tokens.refreshToken,
|
||||
cutId: result.cutId,
|
||||
shiftId: result.shiftId,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { router as volunteerInviteRouter };
|
||||
16
api/src/modules/volunteer-invite/volunteer-invite.schemas.ts
Normal file
16
api/src/modules/volunteer-invite/volunteer-invite.schemas.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const generateInviteSchema = z.object({
|
||||
cutId: z.string().optional(),
|
||||
shiftId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const redeemInviteSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
name: z.string().max(200).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
export type GenerateInviteInput = z.infer<typeof generateInviteSchema>;
|
||||
export type RedeemInviteInput = z.infer<typeof redeemInviteSchema>;
|
||||
106
api/src/modules/volunteer-invite/volunteer-invite.service.ts
Normal file
106
api/src/modules/volunteer-invite/volunteer-invite.service.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { UserCreatedVia, UserRole, UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { authService } from '../auth/auth.service';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import type { RedeemInviteInput } from './volunteer-invite.schemas';
|
||||
|
||||
interface InviteTokenPayload {
|
||||
type: 'volunteer_invite';
|
||||
adminUserId: string;
|
||||
cutId?: string;
|
||||
shiftId?: string;
|
||||
}
|
||||
|
||||
export const volunteerInviteService = {
|
||||
/**
|
||||
* Generate a signed invite token (JWT, 30 min expiry).
|
||||
* Contains the inviting admin's ID and optional cut/shift context.
|
||||
*/
|
||||
generateInviteToken(adminUserId: string, cutId?: string, shiftId?: string): string {
|
||||
const payload: InviteTokenPayload = {
|
||||
type: 'volunteer_invite',
|
||||
adminUserId,
|
||||
...(cutId && { cutId }),
|
||||
...(shiftId && { shiftId }),
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Redeem an invite token: verify it, create (or reactivate) a TEMP user,
|
||||
* and return a JWT token pair for immediate login.
|
||||
*/
|
||||
async redeemInvite(input: RedeemInviteInput) {
|
||||
// 1. Verify and decode the invite token
|
||||
let payload: InviteTokenPayload;
|
||||
try {
|
||||
const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET);
|
||||
payload = decoded as InviteTokenPayload;
|
||||
} catch {
|
||||
throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN');
|
||||
}
|
||||
|
||||
// 2. Validate token type to prevent JWT confusion attacks
|
||||
if (payload.type !== 'volunteer_invite') {
|
||||
throw new AppError(400, 'Invalid invite token', 'INVALID_TOKEN_TYPE');
|
||||
}
|
||||
|
||||
const email = input.email.toLowerCase().trim();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h
|
||||
|
||||
// 3. Check for existing user
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (existingUser) {
|
||||
// Active non-TEMP user — just generate new token pair (re-login)
|
||||
if (existingUser.status === UserStatus.ACTIVE && existingUser.role !== UserRole.TEMP) {
|
||||
const tokens = await authService.generateTokenPair(existingUser);
|
||||
return { tokens, user: existingUser, cutId: payload.cutId, shiftId: payload.shiftId };
|
||||
}
|
||||
|
||||
// Expired or inactive TEMP user — reactivate with extended expiry
|
||||
if (existingUser.role === UserRole.TEMP) {
|
||||
const reactivated = await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
status: UserStatus.ACTIVE,
|
||||
expiresAt,
|
||||
name: input.name || existingUser.name,
|
||||
phone: input.phone || existingUser.phone,
|
||||
},
|
||||
});
|
||||
const tokens = await authService.generateTokenPair(reactivated);
|
||||
return { tokens, user: reactivated, cutId: payload.cutId, shiftId: payload.shiftId };
|
||||
}
|
||||
|
||||
// Suspended/inactive non-TEMP user — block
|
||||
throw new AppError(403, 'Account is not active. Please contact an administrator.', 'ACCOUNT_INACTIVE');
|
||||
}
|
||||
|
||||
// 4. Create new TEMP user with random password (never shown to user)
|
||||
const randomPassword = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = await bcrypt.hash(randomPassword, 10);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name: input.name || null,
|
||||
phone: input.phone || null,
|
||||
role: UserRole.TEMP,
|
||||
roles: JSON.stringify([UserRole.TEMP]),
|
||||
status: UserStatus.ACTIVE,
|
||||
createdVia: UserCreatedVia.QUICK_JOIN_INVITE,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = await authService.generateTokenPair(newUser);
|
||||
return { tokens, user: newUser, cutId: payload.cutId, shiftId: payload.shiftId };
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -127,6 +127,7 @@ services:
|
||||
- ${MEDIA_ROOT:-./media}:/media:ro
|
||||
- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw
|
||||
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
|
||||
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
|
||||
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
|
||||
depends_on:
|
||||
v2-postgres:
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "changemaker.lite",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
1
package.json
Normal file
1
package.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
Loading…
x
Reference in New Issue
Block a user