I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.

This commit is contained in:
Codex Agent
2025-12-28 20:48:32 +01:00
parent d3b6c6c029
commit 1e0c38fce4
23 changed files with 1250 additions and 112 deletions

View File

@@ -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 (
<Pressable
<PhotoSwipeCard
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
photo={photo}
disabled={swipeDisabled}
onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
onModerate={(action) => handleModerationAction(action, photo)}
>
<YStack
borderRadius={10}
@@ -817,7 +822,7 @@ export default function MobileEventPhotosPage() {
</XStack>
) : null}
</YStack>
</Pressable>
</PhotoSwipeCard>
);
})}
</div>
@@ -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 (
<div style={{ position: 'relative' }}>
{leftConfig || rightConfig ? (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$2"
borderRadius="$3"
pointerEvents="none"
style={{ position: 'absolute', inset: 0 }}
>
<XStack flex={1} alignItems="center" justifyContent="flex-start">
{rightConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={rightConfig.bg}
>
<rightConfig.icon size={12} color={rightConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={rightConfig.text}>
{rightConfig.label}
</Text>
</XStack>
) : null}
</XStack>
<XStack flex={1} alignItems="center" justifyContent="flex-end">
{leftConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={leftConfig.bg}
>
<leftConfig.icon size={12} color={leftConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={leftConfig.text}>
{leftConfig.label}
</Text>
</XStack>
) : null}
</XStack>
</XStack>
) : null}
<motion.div
drag={canSwipe ? 'x' : false}
dragElastic={0.2}
dragConstraints={{ left: -80, right: 80 }}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={controls}
initial={{ x: 0 }}
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
);
}
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {