Skip to content

Commit acc69c5

Browse files
committed
feat(mobile/submissions): full-screen photo manager modal
Photos move out of the inline horizontal scroll into a dedicated modal so users see proper-sized thumbnails in a 2-column grid, with a fullscreen tap-to-preview and separate library/camera buttons. The form row now shows a stacked thumbnail cluster + count and opens the modal on tap.
1 parent ac13c40 commit acc69c5

5 files changed

Lines changed: 352 additions & 124 deletions

File tree

apps/mobile/features/submissions/components/photo-gallery-editor.tsx

Lines changed: 0 additions & 122 deletions
This file was deleted.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { Image } from "expo-image";
2+
import { useState } from "react";
3+
import {
4+
ActivityIndicator,
5+
Alert,
6+
Dimensions,
7+
Modal,
8+
Pressable,
9+
ScrollView,
10+
View,
11+
} from "react-native";
12+
import { SafeAreaView } from "react-native-safe-area-context";
13+
import { Button } from "@/components/ui/button";
14+
import { Icon } from "@/components/ui/icon";
15+
import { IconButton } from "@/components/ui/icon-button";
16+
import { Text } from "@/components/ui/text";
17+
import { usePickAndUploadPhotos } from "@/features/uploads";
18+
import { PhotoPreviewModal } from "./photo-preview-modal";
19+
20+
const HORIZONTAL_PAD = 24;
21+
const GRID_GAP = 12;
22+
23+
type Props = {
24+
visible: boolean;
25+
onClose: () => void;
26+
photos: string[];
27+
onChange: (next: string[]) => void;
28+
maxPhotos?: number;
29+
};
30+
31+
const DEFAULT_MAX = 12;
32+
33+
export function PhotoManagerModal({
34+
visible,
35+
onClose,
36+
photos,
37+
onChange,
38+
maxPhotos = DEFAULT_MAX,
39+
}: Props) {
40+
const upload = usePickAndUploadPhotos();
41+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
42+
const remaining = maxPhotos - photos.length;
43+
const addDisabled = remaining <= 0 || upload.isPending;
44+
45+
// 2-column grid sized off the screen width so tiles scale nicely on tablets.
46+
const tileSize =
47+
(Dimensions.get("window").width - HORIZONTAL_PAD * 2 - GRID_GAP) / 2;
48+
49+
const handleAdd = async (source: "camera" | "library") => {
50+
try {
51+
const urls = await upload.mutateAsync(source);
52+
if (urls.length === 0) return;
53+
const next = [...photos, ...urls].slice(0, maxPhotos);
54+
onChange(next);
55+
} catch (err) {
56+
Alert.alert(
57+
"Upload failed",
58+
err instanceof Error ? err.message : "Try again.",
59+
);
60+
}
61+
};
62+
63+
const handleRemove = (url: string) => {
64+
onChange(photos.filter((p) => p !== url));
65+
};
66+
67+
return (
68+
<Modal
69+
visible={visible}
70+
animationType="slide"
71+
onRequestClose={onClose}
72+
presentationStyle="pageSheet"
73+
>
74+
<View className="flex-1 bg-cream">
75+
<SafeAreaView edges={["top"]} className="bg-cream">
76+
<View className="flex-row items-center justify-between px-s-5 py-s-3">
77+
<IconButton
78+
icon="x"
79+
size="sm"
80+
variant="ghost"
81+
onPress={onClose}
82+
accessibilityLabel="Close"
83+
/>
84+
<Text variant="display-sm">
85+
Photos · {photos.length}/{maxPhotos}
86+
</Text>
87+
<View className="h-10 w-10" />
88+
</View>
89+
</SafeAreaView>
90+
91+
<ScrollView
92+
contentContainerStyle={{
93+
paddingHorizontal: HORIZONTAL_PAD,
94+
paddingTop: 4,
95+
paddingBottom: 40,
96+
}}
97+
showsVerticalScrollIndicator={false}
98+
>
99+
<View className="gap-s-3">
100+
<Button
101+
label={upload.isPending ? "Uploading…" : "Add from library"}
102+
onPress={() => handleAdd("library")}
103+
disabled={addDisabled}
104+
leading={<Icon name="pin" size={16} color="#ffffff" />}
105+
/>
106+
<Button
107+
label="Take a photo"
108+
variant="outline"
109+
onPress={() => handleAdd("camera")}
110+
disabled={addDisabled}
111+
leading={<Icon name="pencil" size={16} color="#2e5d45" />}
112+
/>
113+
</View>
114+
115+
{upload.isPending ? (
116+
<View className="mt-s-4 flex-row items-center justify-center gap-s-2">
117+
<ActivityIndicator color="#2e5d45" />
118+
<Text variant="caption" tone="muted">
119+
Uploading to R2…
120+
</Text>
121+
</View>
122+
) : null}
123+
124+
<View className="mt-s-6">
125+
{photos.length === 0 ? (
126+
<View className="items-center gap-s-2 rounded-md border border-dashed border-line bg-white px-s-5 py-s-8">
127+
<Icon name="pin" size={28} color="#6b7a70" />
128+
<Text variant="label" tone="muted">
129+
No photos yet
130+
</Text>
131+
<Text variant="caption" tone="muted" className="text-center">
132+
Optional — you can submit without photos and add them later.
133+
</Text>
134+
</View>
135+
) : (
136+
<View className="flex-row flex-wrap" style={{ gap: GRID_GAP }}>
137+
{photos.map((url) => (
138+
<PhotoTile
139+
key={url}
140+
url={url}
141+
size={tileSize}
142+
onPreview={() => setPreviewUrl(url)}
143+
onRemove={() => handleRemove(url)}
144+
/>
145+
))}
146+
</View>
147+
)}
148+
</View>
149+
</ScrollView>
150+
151+
<SafeAreaView
152+
edges={["bottom"]}
153+
className="border-line border-t bg-cream"
154+
>
155+
<View className="px-s-5 py-s-3">
156+
<Button label="Done" onPress={onClose} />
157+
</View>
158+
</SafeAreaView>
159+
160+
<PhotoPreviewModal
161+
url={previewUrl}
162+
onClose={() => setPreviewUrl(null)}
163+
/>
164+
</View>
165+
</Modal>
166+
);
167+
}
168+
169+
function PhotoTile({
170+
url,
171+
size,
172+
onPreview,
173+
onRemove,
174+
}: {
175+
url: string;
176+
size: number;
177+
onPreview: () => void;
178+
onRemove: () => void;
179+
}) {
180+
return (
181+
<Pressable
182+
onPress={onPreview}
183+
style={{ width: size, height: size }}
184+
className="overflow-hidden rounded-md bg-white"
185+
>
186+
<Image
187+
source={{ uri: url }}
188+
contentFit="cover"
189+
style={{ width: size, height: size }}
190+
/>
191+
<Pressable
192+
onPress={onRemove}
193+
hitSlop={12}
194+
className="absolute right-2 top-2 h-7 w-7 items-center justify-center rounded-pill bg-black/60"
195+
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
196+
accessibilityLabel="Remove photo"
197+
>
198+
<Icon name="x" size={14} color="#ffffff" />
199+
</Pressable>
200+
</Pressable>
201+
);
202+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Image } from "expo-image";
2+
import { Modal, Pressable, View } from "react-native";
3+
import { SafeAreaView } from "react-native-safe-area-context";
4+
import { Icon } from "@/components/ui/icon";
5+
6+
type Props = {
7+
url: string | null;
8+
onClose: () => void;
9+
};
10+
11+
export function PhotoPreviewModal({ url, onClose }: Props) {
12+
return (
13+
<Modal
14+
visible={url !== null}
15+
animationType="fade"
16+
onRequestClose={onClose}
17+
statusBarTranslucent
18+
transparent
19+
>
20+
<View className="flex-1 bg-black">
21+
<SafeAreaView
22+
edges={["top"]}
23+
className="flex-row justify-end px-s-4 py-s-2"
24+
>
25+
<Pressable
26+
onPress={onClose}
27+
className="h-10 w-10 items-center justify-center rounded-pill bg-black/50"
28+
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
29+
accessibilityLabel="Close preview"
30+
>
31+
<Icon name="x" size={18} color="#ffffff" />
32+
</Pressable>
33+
</SafeAreaView>
34+
{url ? (
35+
<View className="flex-1 items-center justify-center">
36+
<Image
37+
source={{ uri: url }}
38+
contentFit="contain"
39+
style={{ width: "100%", height: "100%" }}
40+
/>
41+
</View>
42+
) : null}
43+
</View>
44+
</Modal>
45+
);
46+
}

0 commit comments

Comments
 (0)