I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user