+ {/* Title */}
+
+ {campaign.title}
+
+ {campaign.description && !compact && (
+
+ {campaign.description}
+
+ )}
+
+ {/* Step: Lookup */}
+ {step === 'lookup' && (
+
+ )}
+
+ {/* Step: Representatives */}
+ {step === 'reps' && (
+
+ {allSent ? (
+
+
✅
+
+ All messages sent! Thank you.
+
+
+
+ ) : (
+ <>
+
+ {representatives.length} representative{representatives.length !== 1 ? 's' : ''} found for {postalCode.toUpperCase()}
+
+
+ {representatives.map((rep, i) => (
+
+
+
{rep.name}
+
{rep.representativeSetName}
+
+
+ {sentTo.has(rep.email || '') ? (
+ Sent ✓
+ ) : (
+ <>
+ {campaign.allowSmtpEmail && rep.email && (
+
+ )}
+ {campaign.allowMailtoLink && rep.email && (
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/admin/src/components/map/CutOverlayControls.tsx b/admin/src/components/map/CutOverlayControls.tsx
index 1666be4..3bd259e 100644
--- a/admin/src/components/map/CutOverlayControls.tsx
+++ b/admin/src/components/map/CutOverlayControls.tsx
@@ -1,4 +1,4 @@
-import { Checkbox, Button, Space } from 'antd';
+import { Checkbox, Button, Space, Grid } from 'antd';
import type { Cut, PublicCut } from '@/types/api';
const VARIANT_BG = {
@@ -15,6 +15,8 @@ interface Props {
}
export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, variant = 'admin', style }: Props) {
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const allVisible = cuts.every((c) => visibleCutIds.has(c.id));
const noneVisible = cuts.every((c) => !visibleCutIds.has(c.id));
@@ -22,7 +24,7 @@ export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, v
(null);
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState('');
@@ -122,7 +124,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
title={album?.title || 'Album Detail'}
open={open}
onClose={onClose}
- width={600}
+ width={isMobile ? '100%' : 600}
loading={loading}
footer={
diff --git a/admin/src/components/media/EditPlaylistModal.tsx b/admin/src/components/media/EditPlaylistModal.tsx
index 779eddd..215a9ed 100644
--- a/admin/src/components/media/EditPlaylistModal.tsx
+++ b/admin/src/components/media/EditPlaylistModal.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd';
+import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme, Grid } from 'antd';
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
@@ -29,6 +29,8 @@ export default function EditPlaylistModal({
}: EditPlaylistModalProps) {
const [form] = Form.useForm();
const { token } = theme.useToken();
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [videos, setVideos] = useState
([]);
@@ -126,7 +128,7 @@ export default function EditPlaylistModal({
onClose();
}}
placement="right"
- width={520}
+ width={isMobile ? '100%' : 520}
style={{ top: 64 }}
loading={loading}
>
diff --git a/admin/src/components/media/FetchVideosDrawer.tsx b/admin/src/components/media/FetchVideosDrawer.tsx
index fb8c496..fe858d0 100644
--- a/admin/src/components/media/FetchVideosDrawer.tsx
+++ b/admin/src/components/media/FetchVideosDrawer.tsx
@@ -13,6 +13,7 @@ import {
Collapse,
List,
Tooltip,
+ Grid,
} from 'antd';
import {
CloudDownloadOutlined,
@@ -76,6 +77,8 @@ const STATE_ICONS: Record = {
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
const [urls, setUrls] = useState('');
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [submitting, setSubmitting] = useState(false);
const [jobs, setJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(null);
@@ -292,7 +295,7 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
}
open={open}
onClose={onClose}
- width={560}
+ width={isMobile ? '100%' : 560}
destroyOnClose
>
{/* URL Input Section */}
diff --git a/admin/src/components/media/MediaBottomNav.tsx b/admin/src/components/media/MediaBottomNav.tsx
index 3602328..25e7219 100644
--- a/admin/src/components/media/MediaBottomNav.tsx
+++ b/admin/src/components/media/MediaBottomNav.tsx
@@ -66,7 +66,8 @@ export default function MediaBottomNav() {
bottom: 0,
left: 0,
right: 0,
- height: 48,
+ height: `calc(48px + env(safe-area-inset-bottom, 0px))`,
+ paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
backdropFilter: isShorts ? 'blur(12px)' : undefined,
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
diff --git a/admin/src/components/media/PhotoViewerModal.tsx b/admin/src/components/media/PhotoViewerModal.tsx
index 2d4070f..0418829 100644
--- a/admin/src/components/media/PhotoViewerModal.tsx
+++ b/admin/src/components/media/PhotoViewerModal.tsx
@@ -1,4 +1,4 @@
-import { Modal, Descriptions, Tag } from 'antd';
+import { Modal, Descriptions, Tag, Grid } from 'antd';
import { CameraOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import type { Photo } from '@/types/media';
@@ -19,6 +19,9 @@ interface PhotoViewerModalProps {
}
export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) {
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
+
if (!photo) return null;
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
@@ -28,7 +31,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
open={open}
onCancel={onClose}
footer={null}
- width={900}
+ width={isMobile ? '95vw' : 900}
centered
styles={{ body: { padding: 0 } }}
>
diff --git a/admin/src/components/media/PublicVideoCard.tsx b/admin/src/components/media/PublicVideoCard.tsx
index d4a3237..241df07 100644
--- a/admin/src/components/media/PublicVideoCard.tsx
+++ b/admin/src/components/media/PublicVideoCard.tsx
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
-import { Card, Tag, Space, Typography, theme, Modal } from 'antd';
+import { Card, Tag, Space, Typography, theme, Modal, Grid } from 'antd';
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
@@ -27,6 +27,8 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
const { token } = theme.useToken();
const navigate = useNavigate();
const { expandVideo } = useExpandedVideo();
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
// Hover video preview state
const [hovering, setHovering] = useState(false);
@@ -210,7 +212,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
)}
- {/* Play button overlay */}
+ {/* Play button overlay — always visible on mobile, hover-only on desktop */}
{!video.isLocked && (
{
- e.currentTarget.style.opacity = '1';
+ if (!isMobile) e.currentTarget.style.opacity = '1';
}}
onMouseLeave={(e) => {
- e.currentTarget.style.opacity = '0';
+ if (!isMobile) e.currentTarget.style.opacity = '0';
}}
>
{
- e.currentTarget.style.transform = 'scale(1.1)';
+ if (!isMobile) e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
- e.currentTarget.style.transform = 'scale(1)';
+ if (!isMobile) e.currentTarget.style.transform = 'scale(1)';
}}
>
-
+
)}
diff --git a/admin/src/components/media/QuickAnalyticsModal.tsx b/admin/src/components/media/QuickAnalyticsModal.tsx
index 7a3b599..b5d4c95 100644
--- a/admin/src/components/media/QuickAnalyticsModal.tsx
+++ b/admin/src/components/media/QuickAnalyticsModal.tsx
@@ -1,4 +1,4 @@
-import { Modal, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton } from 'antd';
+import { Modal, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton, Grid } from 'antd';
import {
EyeOutlined,
UserOutlined,
@@ -24,6 +24,8 @@ export default function QuickAnalyticsModal({
onClose,
}: QuickAnalyticsModalProps) {
const [loading, setLoading] = useState(false);
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [error, setError] = useState
(null);
const [analytics, setAnalytics] = useState(null);
@@ -66,7 +68,7 @@ export default function QuickAnalyticsModal({
open={open}
onCancel={onClose}
footer={null}
- width={800}
+ width={isMobile ? '95vw' : 800}
aria-label="Video analytics modal"
>
{error ? (
diff --git a/admin/src/components/media/ScheduleCalendarDrawer.tsx b/admin/src/components/media/ScheduleCalendarDrawer.tsx
index 0606ec8..02c7284 100644
--- a/admin/src/components/media/ScheduleCalendarDrawer.tsx
+++ b/admin/src/components/media/ScheduleCalendarDrawer.tsx
@@ -1,4 +1,4 @@
-import { Drawer, Calendar, Badge, List, Tag, Button, Space, message, Empty, Alert, Skeleton } from 'antd';
+import { Drawer, Calendar, Badge, List, Tag, Button, Space, message, Empty, Alert, Skeleton, Grid } from 'antd';
import type { CalendarProps } from 'antd';
import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
@@ -26,6 +26,8 @@ export default function ScheduleCalendarDrawer({
onRefresh,
}: ScheduleCalendarDrawerProps) {
const [schedules, setSchedules] = useState([]);
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedDate, setSelectedDate] = useState(dayjs());
@@ -117,7 +119,7 @@ export default function ScheduleCalendarDrawer({
open={open}
onClose={onClose}
placement="right"
- width={700}
+ width={isMobile ? '100%' : 700}
mask={false}
destroyOnClose={false}
styles={{
diff --git a/admin/src/components/media/SchedulePublishModal.tsx b/admin/src/components/media/SchedulePublishModal.tsx
index 4693b55..72f857d 100644
--- a/admin/src/components/media/SchedulePublishModal.tsx
+++ b/admin/src/components/media/SchedulePublishModal.tsx
@@ -1,4 +1,4 @@
-import { Modal, DatePicker, Select, Space, Alert, Switch, message } from 'antd';
+import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
@@ -39,6 +39,8 @@ export default function SchedulePublishModal({
onSuccess,
}: SchedulePublishModalProps) {
const [publishNow, setPublishNow] = useState(false);
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [publishAt, setPublishAt] = useState(null);
const [selectedTimezone, setSelectedTimezone] = useState('UTC');
const [unpublishEnabled, setUnpublishEnabled] = useState(false);
@@ -161,7 +163,7 @@ export default function SchedulePublishModal({
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
- width={600}
+ width={isMobile ? '95vw' : 600}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
diff --git a/admin/src/components/media/UploadVideoDrawer.tsx b/admin/src/components/media/UploadVideoDrawer.tsx
index 950476a..91a1e95 100644
--- a/admin/src/components/media/UploadVideoDrawer.tsx
+++ b/admin/src/components/media/UploadVideoDrawer.tsx
@@ -11,6 +11,7 @@ import {
List,
Tag,
Button,
+ Grid,
} from 'antd';
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -33,6 +34,8 @@ interface UploadResult {
export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVideoDrawerProps) {
const [form] = Form.useForm();
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [fileList, setFileList] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
@@ -149,7 +152,7 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
open={open}
onClose={handleClose}
placement="right"
- width={520}
+ width={isMobile ? '100%' : 520}
mask={false}
destroyOnClose
closable={!uploading}
diff --git a/admin/src/components/media/VideoAnalyticsModal.tsx b/admin/src/components/media/VideoAnalyticsModal.tsx
index dcca912..5b8610a 100644
--- a/admin/src/components/media/VideoAnalyticsModal.tsx
+++ b/admin/src/components/media/VideoAnalyticsModal.tsx
@@ -1,4 +1,4 @@
-import { Modal, Tabs, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton } from 'antd';
+import { Modal, Tabs, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton, Grid } from 'antd';
import {
EyeOutlined,
UserOutlined,
@@ -27,6 +27,8 @@ export default function VideoAnalyticsModal({
onClose,
}: VideoAnalyticsModalProps) {
const [loading, setLoading] = useState(false);
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [error, setError] = useState(null);
const [analytics, setAnalytics] = useState(null);
@@ -224,7 +226,7 @@ export default function VideoAnalyticsModal({
open={open}
onCancel={onClose}
footer={null}
- width={1000}
+ width={isMobile ? '95vw' : 1000}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
diff --git a/admin/src/components/media/VideoPickerModal.tsx b/admin/src/components/media/VideoPickerModal.tsx
index 408f8a5..18fdc78 100644
--- a/admin/src/components/media/VideoPickerModal.tsx
+++ b/admin/src/components/media/VideoPickerModal.tsx
@@ -13,6 +13,7 @@ import {
Button,
Tag,
message,
+ Grid,
} from 'antd';
import {
SearchOutlined,
@@ -60,6 +61,8 @@ export const VideoPickerModal: React.FC = ({
title = 'Select Video',
}) => {
const [activeTab, setActiveTab] = useState('library');
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [videos, setVideos] = useState