diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json
index eb72d5b..ea49a17 100644
--- a/resources/js/admin/i18n/locales/de/management.json
+++ b/resources/js/admin/i18n/locales/de/management.json
@@ -286,6 +286,7 @@
"general": "Allgemein"
},
"markAllRead": "Alle als gelesen markieren",
+ "markScopeRead": "Als gelesen markieren",
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
"unread": "Ungelesen"
},
@@ -317,6 +318,38 @@
"draft": "Entwurf",
"archived": "Archiviert"
},
+ "list": {
+ "title": "Deine Events",
+ "subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
+ "filters": {
+ "all": "Alle",
+ "upcoming": "Bevorstehend",
+ "draft": "Entwurf",
+ "past": "Vergangen"
+ },
+ "empty": {
+ "filtered": "Keine Events passen zu diesem Filter.",
+ "filteredHint": "Wähle einen anderen Status oder lösche die Suche."
+ },
+ "actions": {
+ "create": "Neues Event",
+ "settings": "Einstellungen",
+ "open": "Event öffnen"
+ },
+ "stats": {
+ "photos": "Fotos",
+ "guests": "Gäste",
+ "tasks": "Tasks"
+ },
+ "overview": {
+ "title": "Übersicht",
+ "empty": "Noch keine Events – lege dein erstes an.",
+ "count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
+ "badge": {
+ "dashboard": "Kundendashboard"
+ }
+ }
+ },
"errors": {
"missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.",
@@ -1775,6 +1808,32 @@
"draft": "Entwurf",
"archived": "Archiviert"
},
+ "list": {
+ "title": "Deine Events",
+ "subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
+ "filters": {
+ "all": "Alle",
+ "upcoming": "Bevorstehend",
+ "draft": "Entwurf",
+ "past": "Vergangen"
+ },
+ "empty": {
+ "filtered": "Keine Events passen zu diesem Filter.",
+ "filteredHint": "Wähle einen anderen Status oder lösche die Suche."
+ },
+ "actions": {
+ "create": "Neues Event",
+ "settings": "Einstellungen"
+ },
+ "overview": {
+ "title": "Übersicht",
+ "empty": "Noch keine Events – lege dein erstes an.",
+ "count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
+ "badge": {
+ "dashboard": "Kundendashboard"
+ }
+ }
+ },
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.",
@@ -1864,6 +1923,19 @@
"disabledTitle": "Task-Modus ist für dieses Event aus",
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
"title": "Tasks & Checklisten",
+ "quickNav": "Schnellzugriff",
+ "sections": {
+ "assigned": "Zugewiesen",
+ "library": "Bibliothek",
+ "collections": "Sammlungen",
+ "emotions": "Emotionen"
+ },
+ "summary": {
+ "assigned": "Zugewiesen",
+ "library": "Bibliothek",
+ "collections": "Sammlungen",
+ "emotions": "Emotionen"
+ },
"actions": "Aktionen",
"assigned": "Task hinzugefügt",
"updateFailed": "Task konnte nicht gespeichert werden.",
@@ -2077,7 +2149,9 @@
"prompt": "Berechtigung nötig",
"unsupported": "Nicht unterstützt",
"persisted": "Geschützt",
- "available": "Nicht geschützt"
+ "available": "Nicht geschützt",
+ "online": "Online",
+ "offline": "Offline"
},
"deviceStatus": {
"notifications": {
@@ -2091,6 +2165,10 @@
"storage": {
"label": "Offline-Speicher",
"description": "Schützt zwischengespeicherte Daten vor Löschung."
+ },
+ "connection": {
+ "label": "Verbindung",
+ "description": "Zeigt an, ob die App online oder offline ist."
}
},
"experienceTitle": "Erlebnis",
diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json
index 03b1c25..a4f9e11 100644
--- a/resources/js/admin/i18n/locales/en/management.json
+++ b/resources/js/admin/i18n/locales/en/management.json
@@ -281,6 +281,7 @@
"general": "General"
},
"markAllRead": "Mark all read",
+ "markScopeRead": "Mark read",
"markFailed": "Could not update notifications.",
"unread": "Unread"
},
@@ -315,9 +316,25 @@
"list": {
"title": "Your events",
"subtitle": "Plan memorable moments. Manage everything around your events here.",
+ "filters": {
+ "all": "All",
+ "upcoming": "Upcoming",
+ "draft": "Draft",
+ "past": "Past"
+ },
+ "empty": {
+ "filtered": "No events match this filter.",
+ "filteredHint": "Try a different status or clear your search."
+ },
"actions": {
"create": "New event",
- "settings": "Settings"
+ "settings": "Settings",
+ "open": "Open event"
+ },
+ "stats": {
+ "photos": "Photos",
+ "guests": "Guests",
+ "tasks": "Tasks"
},
"overview": {
"title": "Overview",
@@ -1795,6 +1812,32 @@
"draft": "Draft",
"archived": "Archived"
},
+ "list": {
+ "title": "Your events",
+ "subtitle": "Plan memorable moments. Manage everything around your events here.",
+ "filters": {
+ "all": "All",
+ "upcoming": "Upcoming",
+ "draft": "Draft",
+ "past": "Past"
+ },
+ "empty": {
+ "filtered": "No events match this filter.",
+ "filteredHint": "Try a different status or clear your search."
+ },
+ "actions": {
+ "create": "New event",
+ "settings": "Settings"
+ },
+ "overview": {
+ "title": "Overview",
+ "empty": "No events yet – create your first one to get started.",
+ "count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
+ "badge": {
+ "dashboard": "Customer dashboard"
+ }
+ }
+ },
"errors": {
"missingSlug": "No event slug provided.",
"missingType": "Event type is missing. Please save the event again in the admin.",
@@ -1884,6 +1927,19 @@
"disabledTitle": "Task mode is off for this event",
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
"title": "Tasks & checklists",
+ "quickNav": "Quick jump",
+ "sections": {
+ "assigned": "Assigned",
+ "library": "Library",
+ "collections": "Collections",
+ "emotions": "Emotions"
+ },
+ "summary": {
+ "assigned": "Assigned",
+ "library": "Library",
+ "collections": "Collections",
+ "emotions": "Emotions"
+ },
"actions": "Actions",
"assigned": "Task added",
"updateFailed": "Task could not be saved.",
@@ -2097,7 +2153,9 @@
"prompt": "Needs permission",
"unsupported": "Not supported",
"persisted": "Protected",
- "available": "Not protected"
+ "available": "Not protected",
+ "online": "Online",
+ "offline": "Offline"
},
"deviceStatus": {
"notifications": {
@@ -2111,6 +2169,10 @@
"storage": {
"label": "Offline storage",
"description": "Protect cached data from eviction."
+ },
+ "connection": {
+ "label": "Connection",
+ "description": "Shows if the app is online or offline."
}
},
"experienceTitle": "Experience",
diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx
index e682169..69e592a 100644
--- a/resources/js/admin/mobile/EventPhotosPage.tsx
+++ b/resources/js/admin/mobile/EventPhotosPage.tsx
@@ -1,11 +1,11 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Image as ImageIcon, RefreshCcw, Filter, Check } from 'lucide-react';
+import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
-import { AnimatePresence, motion } from 'framer-motion';
+import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
@@ -44,6 +44,7 @@ import {
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
+import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
@@ -775,10 +776,14 @@ export default function MobileEventPhotosPage() {
>
{photos.map((photo) => {
const isSelected = selectedIds.includes(photo.id);
+ const swipeDisabled = selectionMode || busyId === photo.id;
return (
- (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
+ photo={photo}
+ disabled={swipeDisabled}
+ onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
+ onModerate={(action) => handleModerationAction(action, photo)}
>
) : null}
-
+
);
})}
@@ -1087,6 +1092,153 @@ export default function MobileEventPhotosPage() {
);
}
+type PhotoSwipeCardProps = {
+ photo: TenantPhoto;
+ disabled?: boolean;
+ onOpen: () => void;
+ onModerate: (action: PhotoModerationAction['action']) => void;
+ children: React.ReactNode;
+};
+
+type SwipeActionConfig = {
+ label: string;
+ bg: string;
+ text: string;
+ icon: typeof Eye;
+};
+
+function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
+ const { t } = useTranslation('management');
+ const theme = useTheme();
+ const controls = useAnimationControls();
+ const dragged = React.useRef(false);
+ const leftAction = resolvePhotoSwipeAction(photo, 'left');
+ const rightAction = resolvePhotoSwipeAction(photo, 'right');
+ const canSwipe = !disabled && (leftAction || rightAction);
+
+ const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => {
+ if (!action) {
+ return null;
+ }
+ if (action === 'approve') {
+ return {
+ label: t('photos.actions.approve', 'Approve'),
+ bg: String(theme.green3?.val ?? '#dcfce7'),
+ text: String(theme.green11?.val ?? '#166534'),
+ icon: Check,
+ };
+ }
+ if (action === 'hide') {
+ return {
+ label: t('photos.actions.hide', 'Hide'),
+ bg: String(theme.red3?.val ?? '#fee2e2'),
+ text: String(theme.red11?.val ?? '#b91c1c'),
+ icon: EyeOff,
+ };
+ }
+ return {
+ label: t('photos.actions.show', 'Show'),
+ bg: String(theme.blue3?.val ?? '#dbeafe'),
+ text: String(theme.blue11?.val ?? '#1d4ed8'),
+ icon: Eye,
+ };
+ };
+
+ const leftConfig = resolveActionConfig(leftAction);
+ const rightConfig = resolveActionConfig(rightAction);
+
+ const handleDrag = (_event: PointerEvent, info: PanInfo) => {
+ if (!canSwipe) {
+ return;
+ }
+ dragged.current = Math.abs(info.offset.x) > 6;
+ };
+
+ const handleDragEnd = (_event: PointerEvent, info: PanInfo) => {
+ if (!canSwipe) {
+ return;
+ }
+ const swipeThreshold = 64;
+ if (info.offset.x > swipeThreshold && rightAction) {
+ onModerate(rightAction);
+ } else if (info.offset.x < -swipeThreshold && leftAction) {
+ onModerate(leftAction);
+ }
+ dragged.current = false;
+ void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
+ };
+
+ const handlePress = () => {
+ if (dragged.current) {
+ dragged.current = false;
+ return;
+ }
+ onOpen();
+ };
+
+ return (
+
+ {leftConfig || rightConfig ? (
+
+
+ {rightConfig ? (
+
+
+
+ {rightConfig.label}
+
+
+ ) : null}
+
+
+ {leftConfig ? (
+
+
+
+ {leftConfig.label}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
type LimitTranslator = (key: string, options?: Record) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator {
diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx
index c614d51..444fcbb 100644
--- a/resources/js/admin/mobile/EventTasksPage.tsx
+++ b/resources/js/admin/mobile/EventTasksPage.tsx
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
-import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
+import { MobileCard, CTAButton, SkeletonCard, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
@@ -39,12 +39,63 @@ import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
+import { buildTaskSummary } from './lib/taskSummary';
+import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
function InlineSeparator() {
const theme = useTheme();
return ;
}
+function TaskSummaryCard({
+ summary,
+ text,
+ muted,
+ border,
+}: {
+ summary: ReturnType;
+ text: string;
+ muted: string;
+ border: string;
+}) {
+ const { t } = useTranslation('management');
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SummaryItem({
+ label,
+ value,
+ text,
+ muted,
+}: {
+ label: string;
+ value: number;
+ text: string;
+ muted: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
export default function MobileEventTasksPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const { activeEvent, selectEvent } = useEventContext();
@@ -89,7 +140,16 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
+ const assignedRef = React.useRef(null);
+ const libraryRef = React.useRef(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
+ const summary = buildTaskSummary({
+ assigned: assignedTasks.length,
+ library: library.length,
+ collections: collections.length,
+ emotions: emotions.length,
+ });
+ const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -102,6 +162,28 @@ export default function MobileEventTasksPage() {
setSearchTerm('');
}, [slug]);
+ const scrollToSection = (ref: React.RefObject) => {
+ if (ref.current) {
+ ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ const handleQuickNav = (key: TaskSectionKey) => {
+ if (key === 'assigned') {
+ scrollToSection(assignedRef);
+ return;
+ }
+ if (key === 'library') {
+ scrollToSection(libraryRef);
+ return;
+ }
+ if (key === 'collections') {
+ setShowCollectionSheet(true);
+ return;
+ }
+ setShowEmotionSheet(true);
+ };
+
const load = React.useCallback(async () => {
if (!slug) {
try {
@@ -374,6 +456,44 @@ export default function MobileEventTasksPage() {
) : null}
+ {!loading ? (
+
+ ) : null}
+
+ {!loading ? (
+
+
+ {t('events.tasks.quickNav', 'Quick jump')}
+
+
+ {sectionCounts.map((section) => (
+ handleQuickNav(section.key)} style={{ flexGrow: 1 }}>
+
+
+ {t(`events.tasks.sections.${section.key}`, section.key)}
+
+ {section.count}
+
+
+ ))}
+
+
+ ) : null}
+
{loading ? (
{Array.from({ length: 4 }).map((_, idx) => (
@@ -471,6 +591,7 @@ export default function MobileEventTasksPage() {
) : (
+
+
{t('events.tasks.library', 'Weitere Aufgaben')}
diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx
index 899f7ad..a0e7237 100644
--- a/resources/js/admin/mobile/EventsPage.tsx
+++ b/resources/js/admin/mobile/EventsPage.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
-import { CalendarDays, MapPin, Plus, Search } from 'lucide-react';
+import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -14,6 +14,8 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useTheme } from '@tamagui/core';
import { useBackNavigation } from './hooks/useBackNavigation';
+import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
+import { buildEventListStats } from './lib/eventListStats';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
@@ -22,6 +24,7 @@ export default function MobileEventsPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [query, setQuery] = React.useState('');
+ const [statusFilter, setStatusFilter] = React.useState('all');
const searchRef = React.useRef(null);
const back = useBackNavigation();
const theme = useTheme();
@@ -92,27 +95,14 @@ export default function MobileEventsPage() {
navigate(adminPath('/events/new'))} />
) : (
-
- {events
- .filter((event) => {
- if (!query.trim()) return true;
- const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
- return hay.includes(query.toLowerCase());
- })
- .map((event) => (
- navigate(adminPath(`/mobile/events/${slug}`))}
- onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
- />
- ))}
-
+ navigate(adminPath(`/mobile/events/${slug}`))}
+ onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
+ />
)}
void;
+ onOpen: (slug: string) => void;
+ onEdit: (slug: string) => void;
+}) {
+ const { t } = useTranslation('management');
+ const theme = useTheme();
+ const text = String(theme.color?.val ?? '#111827');
+ const muted = String(theme.gray?.val ?? '#4b5563');
+ const subtle = String(theme.gray8?.val ?? '#6b7280');
+ const border = String(theme.borderColor?.val ?? '#e5e7eb');
+ const primary = String(theme.primary?.val ?? '#007AFF');
+ const surface = String(theme.surface?.val ?? '#ffffff');
+ const activeBg = String(theme.blue3?.val ?? '#e0f2fe');
+ const activeBorder = String(theme.blue6?.val ?? '#bfdbfe');
+
+ const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
+ const filteredByStatus = React.useMemo(
+ () => filterEventsByStatus(events, statusFilter),
+ [events, statusFilter]
+ );
+ const filteredEvents = React.useMemo(() => {
+ if (!query.trim()) return filteredByStatus;
+ const needle = query.toLowerCase();
+ return filteredByStatus.filter((event) => {
+ const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
+ return hay.includes(needle);
+ });
+ }, [filteredByStatus, query]);
+
+ const filters: Array<{ key: EventStatusKey; label: string; count: number }> = [
+ { key: 'all', label: t('events.list.filters.all', 'All'), count: statusCounts.all },
+ { key: 'upcoming', label: t('events.list.filters.upcoming', 'Upcoming'), count: statusCounts.upcoming },
+ { key: 'draft', label: t('events.list.filters.draft', 'Draft'), count: statusCounts.draft },
+ { key: 'past', label: t('events.list.filters.past', 'Past'), count: statusCounts.past },
+ ];
+
+ return (
+
+
+ {filters.map((filter) => {
+ const active = filter.key === statusFilter;
+ return (
+ onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
+
+
+ {filter.label}
+
+ {filter.count}
+
+
+ );
+ })}
+
+
+ {filteredEvents.length === 0 ? (
+
+
+ {t('events.list.empty.filtered', 'No events match this filter.')}
+
+
+ {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
+
+ onStatusChange('all')}
+ />
+
+ ) : (
+ filteredEvents.map((event) => {
+ const statusKey = resolveEventStatusKey(event);
+ const statusLabel =
+ statusKey === 'draft'
+ ? t('events.list.filters.draft', 'Draft')
+ : statusKey === 'past'
+ ? t('events.list.filters.past', 'Past')
+ : t('events.list.filters.upcoming', 'Upcoming');
+ const statusTone = statusKey === 'draft' ? 'warning' : statusKey === 'past' ? 'muted' : 'success';
+ return (
+
+ );
+ })
+ )}
+
+ );
+}
+
function EventRow({
event,
text,
@@ -131,6 +242,8 @@ function EventRow({
subtle,
border,
primary,
+ statusLabel,
+ statusTone,
onOpen,
onEdit,
}: {
@@ -140,10 +253,13 @@ function EventRow({
subtle: string;
border: string;
primary: string;
+ statusLabel: string;
+ statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void;
onEdit: (slug: string) => void;
}) {
- const status = resolveStatus(event);
+ const { t } = useTranslation('management');
+ const stats = buildEventListStats(event);
return (
@@ -163,7 +279,27 @@ function EventRow({
{resolveLocation(event)}
- {status.label}
+ {statusLabel}
+
+
+
+
+
onEdit(event.slug)}>
@@ -176,7 +312,7 @@ function EventRow({
- Open event
+ {t('events.list.actions.open', 'Open event')}
@@ -184,14 +320,25 @@ function EventRow({
);
}
-function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
- if (event.status === 'published') {
- return { label: 'Upcoming', tone: 'success' };
- }
- if (event.status === 'draft') {
- return { label: 'Draft', tone: 'warning' };
- }
- return { label: 'Past', tone: 'muted' };
+function EventStatChip({
+ icon: Icon,
+ label,
+ value,
+ muted,
+}: {
+ icon: typeof Camera;
+ label: string;
+ value: number;
+ muted: string;
+}) {
+ return (
+
+
+
+ {value} {label}
+
+
+ );
}
function renderName(name: TenantEvent['name']): string {
diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx
index 147c1b8..57d4c50 100644
--- a/resources/js/admin/mobile/NotificationsPage.tsx
+++ b/resources/js/admin/mobile/NotificationsPage.tsx
@@ -19,6 +19,9 @@ import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
+import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
+import { collectUnreadIds } from './lib/notificationUnread';
+import { formatRelativeTime } from './lib/relativeTime';
type NotificationItem = {
id: string;
@@ -27,8 +30,9 @@ type NotificationItem = {
time: string;
tone: 'info' | 'warning';
eventId?: number | null;
+ eventName?: string | null;
is_read?: boolean;
- scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
+ scope: NotificationScope;
};
type NotificationSwipeRowProps = {
@@ -167,6 +171,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -181,6 +186,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -194,6 +200,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -208,6 +215,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -221,6 +229,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -236,6 +245,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -251,6 +261,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -262,6 +273,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
+ eventName,
is_read: isRead,
scope,
};
@@ -389,10 +401,9 @@ export default function MobileNotificationsPage() {
return scoped.filter((item) => !item.is_read);
}, [scoped, statusParam]);
- const unreadIds = React.useMemo(
- () => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)),
- [scoped]
- );
+ const grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
+
+ const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
@@ -421,6 +432,34 @@ export default function MobileNotificationsPage() {
}, [markNotificationRead, selectedNotification]);
const notificationListPath = adminPath('/mobile/notifications');
+ const updateFilters = React.useCallback(
+ (params: { scope?: string; status?: string; event?: string | null }) => {
+ const next = new URLSearchParams({
+ status: params.status ?? statusParam,
+ scope: params.scope ?? scopeParam,
+ event: params.event ?? slug ?? '',
+ });
+ navigate(`${notificationListPath}?${next.toString()}`, { replace: false });
+ },
+ [navigate, notificationListPath, scopeParam, slug, statusParam],
+ );
+
+ const markGroupRead = React.useCallback(
+ async (group: NotificationGroup) => {
+ const ids = collectUnreadIds(group.items);
+ if (!ids.length) {
+ return;
+ }
+ try {
+ await markNotificationLogs(ids, 'read');
+ void reload();
+ triggerHaptic('success');
+ } catch {
+ toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
+ }
+ },
+ [reload, t],
+ );
const openNotification = React.useCallback(
(item: NotificationItem) => {
@@ -475,7 +514,7 @@ export default function MobileNotificationsPage() {
{
- navigate(notificationListPath, { replace: true });
+ updateFilters({ event: '' });
}}
>
@@ -488,15 +527,7 @@ export default function MobileNotificationsPage() {
- navigate(
- `${notificationListPath}?${new URLSearchParams({
- status: e.target.value,
- scope: scopeParam,
- event: slug ?? '',
- }).toString()}`,
- )
- }
+ onChange={(e) => updateFilters({ status: e.target.value })}
compact
style={{ minWidth: 120 }}
>
@@ -504,28 +535,6 @@ export default function MobileNotificationsPage() {
-
- navigate(
- `${notificationListPath}?${new URLSearchParams({
- scope: e.target.value,
- status: statusParam,
- event: slug ?? '',
- }).toString()}`,
- )
- }
- compact
- style={{ minWidth: 140 }}
- >
-
-
-
-
-
-
-
-
{unreadIds.length ? (
+
+ {([
+ { key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
+ { key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
+ { key: 'guests', label: t('notificationLogs.scope.guests', 'Guests') },
+ { key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
+ { key: 'events', label: t('notificationLogs.scope.events', 'Events') },
+ { key: 'package', label: t('notificationLogs.scope.package', 'Package') },
+ { key: 'general', label: t('notificationLogs.scope.general', 'General') },
+ ] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
+ const active = scopeParam === filter.key;
+ return (
+ updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}>
+
+
+ {filter.label}
+
+
+
+ );
+ })}
+
+
{loading ? (
{Array.from({ length: 4 }).map((_, idx) => (
@@ -574,38 +615,68 @@ export default function MobileNotificationsPage() {
) : null}
- {statusFiltered.map((item) => (
-
-
-
-
-
-
-
-
- {item.title}
-
-
- {item.body}
-
-
- {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null}
- {item.time}
+ {grouped.map((group) => (
+
+
+
+ {t(`notificationLogs.scope.${group.scope}`, group.scope)}
+
+
+ {group.unread > 0 ? (
+ markGroupRead(group)}>
+
+ {t('notificationLogs.markScopeRead', 'Mark read')}
+
+
+ ) : null}
+ {group.unread > 0 ? (
+
+ {t('notificationLogs.unread', 'Unread')} {group.unread}
+
+ ) : null}
+ {group.items.length}
-
-
+
+ {group.items.map((item) => {
+ const formattedTime = formatRelativeTime(item.time) || item.time || '—';
+ return (
+
+
+
+
+
+
+
+
+ {item.title}
+
+
+ {item.body}
+
+ {item.eventName ? (
+ {item.eventName}
+ ) : null}
+
+ {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null}
+ {formattedTime}
+
+
+
+ );
+ })}
+
))}
)}
@@ -641,7 +712,7 @@ export default function MobileNotificationsPage() {
{!selectedNotification.is_read ? {t('notificationLogs.unread', 'Unread')} : null}
- {selectedNotification.time}
+ {formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
) : null}
@@ -665,7 +736,7 @@ export default function MobileNotificationsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
- navigate(`${notificationListPath}?event=${ev.slug}`);
+ updateFilters({ event: ev.slug });
}
}}
>
diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx
index 54d840d..054e834 100644
--- a/resources/js/admin/mobile/SettingsPage.tsx
+++ b/resources/js/admin/mobile/SettingsPage.tsx
@@ -26,6 +26,7 @@ import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstall
import { MobileInstallBanner } from './components/MobileInstallBanner';
import { setTourSeen } from './lib/mobileTour';
import { useBackNavigation } from './hooks/useBackNavigation';
+import { useOnlineStatus } from './hooks/useOnlineStatus';
type PreferenceKey = keyof NotificationPreferences;
@@ -59,6 +60,7 @@ export default function MobileSettingsPage() {
const [storageError, setStorageError] = React.useState(null);
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
+ const online = useOnlineStatus();
const installPrompt = useInstallPrompt();
const back = useBackNavigation(adminPath('/mobile/profile'));
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
@@ -109,6 +111,9 @@ export default function MobileSettingsPage() {
t(`mobileSettings.deviceStatusValues.${status}`, status);
const storageLabel = (status: StorageStatus) =>
t(`mobileSettings.deviceStatusValues.${status}`, status);
+ const connectionLabel = online
+ ? t('mobileSettings.deviceStatusValues.online', 'Online')
+ : t('mobileSettings.deviceStatusValues.offline', 'Offline');
React.useEffect(() => {
(async () => {
@@ -375,6 +380,17 @@ export default function MobileSettingsPage() {
{storageLabel(devicePermissions.storage)}
+
+
+
+ {t('mobileSettings.deviceStatus.connection.label', 'Connection')}
+
+
+ {t('mobileSettings.deviceStatus.connection.description', 'Shows if the app is online or offline.')}
+
+
+ {connectionLabel}
+
)}
{devicePermissions.storage === 'available' ? (
diff --git a/resources/js/admin/mobile/lib/eventFilters.test.ts b/resources/js/admin/mobile/lib/eventFilters.test.ts
new file mode 100644
index 0000000..c1a7273
--- /dev/null
+++ b/resources/js/admin/mobile/lib/eventFilters.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import type { TenantEvent } from '../../api';
+import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey } from './eventFilters';
+
+const baseEvent = (overrides: Partial): TenantEvent => ({
+ id: overrides.id ?? 1,
+ slug: overrides.slug ?? 'event',
+ name: overrides.name ?? 'Event',
+ event_date: overrides.event_date ?? null,
+ status: overrides.status ?? 'published',
+ settings: overrides.settings ?? {},
+ tasks_count: overrides.tasks_count ?? 0,
+ active_invites_count: overrides.active_invites_count ?? 0,
+ total_invites_count: overrides.total_invites_count ?? 0,
+ photo_count: overrides.photo_count ?? 0,
+ likes_sum: overrides.likes_sum ?? 0,
+ engagement_mode: overrides.engagement_mode ?? 'tasks',
+});
+
+describe('resolveEventStatusKey', () => {
+ it('returns draft when event is draft', () => {
+ const event = baseEvent({ status: 'draft' });
+ expect(resolveEventStatusKey(event)).toBe('draft');
+ });
+
+ it('returns past when published event date is in the past', () => {
+ const event = baseEvent({ status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() });
+ expect(resolveEventStatusKey(event)).toBe('past');
+ });
+
+ it('returns upcoming when published event date is in the future', () => {
+ const event = baseEvent({ status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() });
+ expect(resolveEventStatusKey(event)).toBe('upcoming');
+ });
+});
+
+describe('buildEventStatusCounts', () => {
+ it('counts events by status', () => {
+ const events: TenantEvent[] = [
+ baseEvent({ id: 1, status: 'draft' }),
+ baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() }),
+ baseEvent({ id: 3, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
+ ];
+ const counts = buildEventStatusCounts(events);
+ expect(counts.all).toBe(3);
+ expect(counts.draft).toBe(1);
+ expect(counts.upcoming).toBe(1);
+ expect(counts.past).toBe(1);
+ });
+});
+
+describe('filterEventsByStatus', () => {
+ it('filters by status', () => {
+ const events: TenantEvent[] = [
+ baseEvent({ id: 1, status: 'draft' }),
+ baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
+ ];
+ const filtered = filterEventsByStatus(events, 'draft');
+ expect(filtered).toHaveLength(1);
+ expect(filtered[0]?.id).toBe(1);
+ });
+});
diff --git a/resources/js/admin/mobile/lib/eventFilters.ts b/resources/js/admin/mobile/lib/eventFilters.ts
new file mode 100644
index 0000000..893de3d
--- /dev/null
+++ b/resources/js/admin/mobile/lib/eventFilters.ts
@@ -0,0 +1,43 @@
+import type { TenantEvent } from '../../api';
+import { isPastEvent } from '../eventDate';
+
+export type EventStatusKey = 'all' | 'upcoming' | 'draft' | 'past';
+
+export function resolveEventStatusKey(event: TenantEvent): Exclude {
+ if (event.status === 'draft') {
+ return 'draft';
+ }
+ if (event.status === 'published') {
+ if (event.event_date && isPastEvent(event.event_date)) {
+ return 'past';
+ }
+ return 'upcoming';
+ }
+ if (event.event_date && isPastEvent(event.event_date)) {
+ return 'past';
+ }
+ return 'upcoming';
+}
+
+export function buildEventStatusCounts(events: TenantEvent[]): Record {
+ const counts: Record = {
+ all: events.length,
+ upcoming: 0,
+ draft: 0,
+ past: 0,
+ };
+
+ events.forEach((event) => {
+ const key = resolveEventStatusKey(event);
+ counts[key] += 1;
+ });
+
+ return counts;
+}
+
+export function filterEventsByStatus(events: TenantEvent[], status: EventStatusKey): TenantEvent[] {
+ if (status === 'all') {
+ return events;
+ }
+ return events.filter((event) => resolveEventStatusKey(event) === status);
+}
diff --git a/resources/js/admin/mobile/lib/eventListStats.test.ts b/resources/js/admin/mobile/lib/eventListStats.test.ts
new file mode 100644
index 0000000..c47af37
--- /dev/null
+++ b/resources/js/admin/mobile/lib/eventListStats.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import type { TenantEvent } from '../../api';
+import { buildEventListStats } from './eventListStats';
+
+const baseEvent = (overrides: Partial): TenantEvent => ({
+ id: overrides.id ?? 1,
+ slug: overrides.slug ?? 'event',
+ name: overrides.name ?? 'Event',
+ event_date: overrides.event_date ?? null,
+ status: overrides.status ?? 'published',
+ settings: overrides.settings ?? {},
+ tasks_count: overrides.tasks_count ?? 0,
+ active_invites_count: overrides.active_invites_count ?? 0,
+ total_invites_count: overrides.total_invites_count ?? 0,
+ photo_count: overrides.photo_count ?? 0,
+ likes_sum: overrides.likes_sum ?? 0,
+ engagement_mode: overrides.engagement_mode ?? 'tasks',
+});
+
+describe('buildEventListStats', () => {
+ it('uses active invite count when available', () => {
+ const stats = buildEventListStats(
+ baseEvent({ photo_count: 12, tasks_count: 4, active_invites_count: 9, total_invites_count: 20 }),
+ );
+ expect(stats).toEqual({ photos: 12, guests: 9, tasks: 4 });
+ });
+
+ it('falls back to total invite count when active is missing', () => {
+ const event = {
+ ...baseEvent({ photo_count: 3, total_invites_count: 6 }),
+ active_invites_count: undefined,
+ } as TenantEvent;
+ const stats = buildEventListStats(event);
+ expect(stats.guests).toBe(6);
+ });
+});
diff --git a/resources/js/admin/mobile/lib/eventListStats.ts b/resources/js/admin/mobile/lib/eventListStats.ts
new file mode 100644
index 0000000..ed32fad
--- /dev/null
+++ b/resources/js/admin/mobile/lib/eventListStats.ts
@@ -0,0 +1,15 @@
+import type { TenantEvent } from '../../api';
+
+export type EventListStats = {
+ photos: number;
+ guests: number;
+ tasks: number;
+};
+
+export function buildEventListStats(event: TenantEvent): EventListStats {
+ return {
+ photos: event.photo_count ?? 0,
+ guests: event.active_invites_count ?? event.total_invites_count ?? 0,
+ tasks: event.tasks_count ?? 0,
+ };
+}
diff --git a/resources/js/admin/mobile/lib/notificationGrouping.test.ts b/resources/js/admin/mobile/lib/notificationGrouping.test.ts
new file mode 100644
index 0000000..85e89c9
--- /dev/null
+++ b/resources/js/admin/mobile/lib/notificationGrouping.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import { groupNotificationsByScope, type NotificationScope } from './notificationGrouping';
+
+type Item = { id: string; scope: NotificationScope; is_read?: boolean };
+
+describe('groupNotificationsByScope', () => {
+ it('groups items and counts unread', () => {
+ const items: Item[] = [
+ { id: '1', scope: 'photos', is_read: false },
+ { id: '2', scope: 'photos', is_read: true },
+ { id: '3', scope: 'package', is_read: false },
+ ];
+
+ const grouped = groupNotificationsByScope(items);
+
+ expect(grouped[0]?.scope).toBe('photos');
+ expect(grouped[0]?.items).toHaveLength(2);
+ expect(grouped[0]?.unread).toBe(1);
+ expect(grouped[1]?.scope).toBe('package');
+ expect(grouped[1]?.unread).toBe(1);
+ });
+
+ it('sorts groups by predefined scope order', () => {
+ const items: Item[] = [
+ { id: '1', scope: 'general', is_read: true },
+ { id: '2', scope: 'events', is_read: true },
+ { id: '3', scope: 'photos', is_read: true },
+ ];
+
+ const grouped = groupNotificationsByScope(items);
+ expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
+ });
+});
diff --git a/resources/js/admin/mobile/lib/notificationGrouping.ts b/resources/js/admin/mobile/lib/notificationGrouping.ts
new file mode 100644
index 0000000..7fc386f
--- /dev/null
+++ b/resources/js/admin/mobile/lib/notificationGrouping.ts
@@ -0,0 +1,49 @@
+export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
+
+export type ScopedNotification = {
+ scope: NotificationScope;
+ is_read?: boolean;
+};
+
+export type NotificationGroup = {
+ scope: NotificationScope;
+ items: T[];
+ unread: number;
+};
+
+const SCOPE_ORDER: NotificationScope[] = [
+ 'photos',
+ 'guests',
+ 'gallery',
+ 'events',
+ 'package',
+ 'general',
+];
+
+export function groupNotificationsByScope(items: T[]): NotificationGroup[] {
+ const groups = new Map>();
+
+ items.forEach((item) => {
+ const scope = item.scope ?? 'general';
+ const existing = groups.get(scope);
+ if (existing) {
+ existing.items.push(item);
+ if (!item.is_read) {
+ existing.unread += 1;
+ }
+ return;
+ }
+
+ groups.set(scope, {
+ scope,
+ items: [item],
+ unread: item.is_read ? 0 : 1,
+ });
+ });
+
+ return Array.from(groups.values()).sort((a, b) => {
+ const aIndex = SCOPE_ORDER.indexOf(a.scope);
+ const bIndex = SCOPE_ORDER.indexOf(b.scope);
+ return aIndex - bIndex;
+ });
+}
diff --git a/resources/js/admin/mobile/lib/notificationUnread.test.ts b/resources/js/admin/mobile/lib/notificationUnread.test.ts
new file mode 100644
index 0000000..ba9b37c
--- /dev/null
+++ b/resources/js/admin/mobile/lib/notificationUnread.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { collectUnreadIds } from './notificationUnread';
+
+describe('collectUnreadIds', () => {
+ it('returns numeric ids for unread items', () => {
+ const ids = collectUnreadIds([
+ { id: '12', is_read: false },
+ { id: 5, is_read: false },
+ { id: 'x', is_read: false },
+ { id: 9, is_read: true },
+ ]);
+
+ expect(ids).toEqual([12, 5]);
+ });
+
+ it('returns empty array when all items are read', () => {
+ const ids = collectUnreadIds([
+ { id: 1, is_read: true },
+ { id: '2', is_read: true },
+ ]);
+
+ expect(ids).toEqual([]);
+ });
+});
diff --git a/resources/js/admin/mobile/lib/notificationUnread.ts b/resources/js/admin/mobile/lib/notificationUnread.ts
new file mode 100644
index 0000000..21af28f
--- /dev/null
+++ b/resources/js/admin/mobile/lib/notificationUnread.ts
@@ -0,0 +1,11 @@
+export type NotificationReadItem = {
+ id: string | number;
+ is_read?: boolean;
+};
+
+export function collectUnreadIds(items: NotificationReadItem[]): number[] {
+ return items
+ .filter((item) => !item.is_read)
+ .map((item) => (typeof item.id === 'string' ? Number(item.id) : item.id))
+ .filter((id) => Number.isFinite(id));
+}
diff --git a/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts b/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts
new file mode 100644
index 0000000..37a9d04
--- /dev/null
+++ b/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import type { TenantPhoto } from '../../api';
+import { resolvePhotoSwipeAction } from './photoModerationSwipe';
+
+const basePhoto = (overrides: Partial): TenantPhoto => ({
+ id: overrides.id ?? 1,
+ filename: overrides.filename ?? null,
+ original_name: overrides.original_name ?? null,
+ mime_type: overrides.mime_type ?? null,
+ size: overrides.size ?? 1024,
+ url: overrides.url ?? null,
+ thumbnail_url: overrides.thumbnail_url ?? null,
+ status: overrides.status ?? 'approved',
+ is_featured: overrides.is_featured ?? false,
+ likes_count: overrides.likes_count ?? 0,
+ uploaded_at: overrides.uploaded_at ?? new Date().toISOString(),
+ uploader_name: overrides.uploader_name ?? null,
+ ingest_source: overrides.ingest_source ?? null,
+ caption: overrides.caption ?? null,
+});
+
+describe('resolvePhotoSwipeAction', () => {
+ it('approves pending photos on right swipe', () => {
+ const photo = basePhoto({ status: 'pending' });
+ expect(resolvePhotoSwipeAction(photo, 'right')).toBe('approve');
+ });
+
+ it('hides pending photos on left swipe', () => {
+ const photo = basePhoto({ status: 'pending' });
+ expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
+ });
+
+ it('shows hidden photos on right swipe', () => {
+ const photo = basePhoto({ status: 'hidden' });
+ expect(resolvePhotoSwipeAction(photo, 'right')).toBe('show');
+ });
+
+ it('returns null for hidden photos on left swipe', () => {
+ const photo = basePhoto({ status: 'hidden' });
+ expect(resolvePhotoSwipeAction(photo, 'left')).toBeNull();
+ });
+
+ it('hides visible photos on left swipe', () => {
+ const photo = basePhoto({ status: 'approved' });
+ expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
+ });
+
+ it('returns null for visible photos on right swipe', () => {
+ const photo = basePhoto({ status: 'approved' });
+ expect(resolvePhotoSwipeAction(photo, 'right')).toBeNull();
+ });
+});
diff --git a/resources/js/admin/mobile/lib/photoModerationSwipe.ts b/resources/js/admin/mobile/lib/photoModerationSwipe.ts
new file mode 100644
index 0000000..177ccd2
--- /dev/null
+++ b/resources/js/admin/mobile/lib/photoModerationSwipe.ts
@@ -0,0 +1,24 @@
+import type { TenantPhoto } from '../../api';
+import type { PhotoModerationAction } from './photoModerationQueue';
+
+export type SwipeDirection = 'left' | 'right';
+
+export type SwipeModerationAction = PhotoModerationAction['action'] | null;
+
+export function resolvePhotoSwipeAction(photo: TenantPhoto, direction: SwipeDirection): SwipeModerationAction {
+ if (direction === 'right') {
+ if (photo.status === 'pending') {
+ return 'approve';
+ }
+ if (photo.status === 'hidden') {
+ return 'show';
+ }
+ return null;
+ }
+
+ if (photo.status !== 'hidden') {
+ return 'hide';
+ }
+
+ return null;
+}
diff --git a/resources/js/admin/mobile/lib/relativeTime.test.ts b/resources/js/admin/mobile/lib/relativeTime.test.ts
new file mode 100644
index 0000000..236a095
--- /dev/null
+++ b/resources/js/admin/mobile/lib/relativeTime.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { formatRelativeTime } from './relativeTime';
+
+describe('formatRelativeTime', () => {
+ const now = new Date('2024-01-10T12:00:00Z');
+
+ it('returns empty string when value is missing', () => {
+ expect(formatRelativeTime(null, { now })).toBe('');
+ });
+
+ it('returns original value for invalid date strings', () => {
+ expect(formatRelativeTime('not-a-date', { now })).toBe('not-a-date');
+ });
+
+ it('formats recent timestamps', () => {
+ const result = formatRelativeTime('2024-01-10T11:59:30Z', { now, locale: 'en' });
+ expect(result).toContain('second');
+ });
+
+ it('formats hours ago', () => {
+ const result = formatRelativeTime('2024-01-10T11:00:00Z', { now, locale: 'en' });
+ expect(result).toBe('1 hour ago');
+ });
+
+ it('formats future days', () => {
+ const result = formatRelativeTime('2024-01-13T12:00:00Z', { now, locale: 'en' });
+ expect(result).toBe('in 3 days');
+ });
+});
diff --git a/resources/js/admin/mobile/lib/relativeTime.ts b/resources/js/admin/mobile/lib/relativeTime.ts
new file mode 100644
index 0000000..f50579f
--- /dev/null
+++ b/resources/js/admin/mobile/lib/relativeTime.ts
@@ -0,0 +1,52 @@
+type RelativeTimeOptions = {
+ now?: Date;
+ locale?: string;
+};
+
+export function formatRelativeTime(value: string | null | undefined, options?: RelativeTimeOptions): string {
+ if (!value) {
+ return '';
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ const now = options?.now ?? new Date();
+ const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
+ const absSeconds = Math.abs(diffSeconds);
+ const rtf = new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' });
+
+ if (absSeconds < 60) {
+ return rtf.format(diffSeconds, 'second');
+ }
+
+ const diffMinutes = Math.round(diffSeconds / 60);
+ if (Math.abs(diffMinutes) < 60) {
+ return rtf.format(diffMinutes, 'minute');
+ }
+
+ const diffHours = Math.round(diffMinutes / 60);
+ if (Math.abs(diffHours) < 24) {
+ return rtf.format(diffHours, 'hour');
+ }
+
+ const diffDays = Math.round(diffHours / 24);
+ if (Math.abs(diffDays) < 7) {
+ return rtf.format(diffDays, 'day');
+ }
+
+ const diffWeeks = Math.round(diffDays / 7);
+ if (Math.abs(diffWeeks) < 4) {
+ return rtf.format(diffWeeks, 'week');
+ }
+
+ const diffMonths = Math.round(diffDays / 30);
+ if (Math.abs(diffMonths) < 12) {
+ return rtf.format(diffMonths, 'month');
+ }
+
+ const diffYears = Math.round(diffDays / 365);
+ return rtf.format(diffYears, 'year');
+}
diff --git a/resources/js/admin/mobile/lib/taskSectionCounts.test.ts b/resources/js/admin/mobile/lib/taskSectionCounts.test.ts
new file mode 100644
index 0000000..4b5004f
--- /dev/null
+++ b/resources/js/admin/mobile/lib/taskSectionCounts.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { buildTaskSectionCounts } from './taskSectionCounts';
+
+describe('buildTaskSectionCounts', () => {
+ it('maps summary values in a stable order', () => {
+ const counts = buildTaskSectionCounts({ assigned: 2, library: 5, collections: 3, emotions: 1 });
+ expect(counts).toEqual([
+ { key: 'assigned', count: 2 },
+ { key: 'library', count: 5 },
+ { key: 'collections', count: 3 },
+ { key: 'emotions', count: 1 },
+ ]);
+ });
+});
diff --git a/resources/js/admin/mobile/lib/taskSectionCounts.ts b/resources/js/admin/mobile/lib/taskSectionCounts.ts
new file mode 100644
index 0000000..272cf00
--- /dev/null
+++ b/resources/js/admin/mobile/lib/taskSectionCounts.ts
@@ -0,0 +1,17 @@
+import type { TaskSummary } from './taskSummary';
+
+export type TaskSectionKey = 'assigned' | 'library' | 'collections' | 'emotions';
+
+export type TaskSectionCount = {
+ key: TaskSectionKey;
+ count: number;
+};
+
+export function buildTaskSectionCounts(summary: TaskSummary): TaskSectionCount[] {
+ return [
+ { key: 'assigned', count: summary.assigned },
+ { key: 'library', count: summary.library },
+ { key: 'collections', count: summary.collections },
+ { key: 'emotions', count: summary.emotions },
+ ];
+}
diff --git a/resources/js/admin/mobile/lib/taskSummary.test.ts b/resources/js/admin/mobile/lib/taskSummary.test.ts
new file mode 100644
index 0000000..bf0d0bf
--- /dev/null
+++ b/resources/js/admin/mobile/lib/taskSummary.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { buildTaskSummary } from './taskSummary';
+
+describe('buildTaskSummary', () => {
+ it('returns summary counts', () => {
+ const summary = buildTaskSummary({ assigned: 3, library: 5, collections: 2, emotions: 4 });
+ expect(summary).toEqual({ assigned: 3, library: 5, collections: 2, emotions: 4 });
+ });
+});
diff --git a/resources/js/admin/mobile/lib/taskSummary.ts b/resources/js/admin/mobile/lib/taskSummary.ts
new file mode 100644
index 0000000..59977a5
--- /dev/null
+++ b/resources/js/admin/mobile/lib/taskSummary.ts
@@ -0,0 +1,20 @@
+export type TaskSummary = {
+ assigned: number;
+ library: number;
+ collections: number;
+ emotions: number;
+};
+
+export function buildTaskSummary(params: {
+ assigned: number;
+ library: number;
+ collections: number;
+ emotions: number;
+}): TaskSummary {
+ return {
+ assigned: params.assigned,
+ library: params.library,
+ collections: params.collections,
+ emotions: params.emotions,
+ };
+}