@umituz/react-native-ai-generation-content 1.12.11 → 1.12.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/domains/creations/domain/entities/Creation.ts +3 -0
- package/src/domains/creations/domain/repositories/ICreationsRepository.ts +2 -0
- package/src/domains/creations/domain/value-objects/CreationsConfig.ts +1 -0
- package/src/domains/creations/infrastructure/repositories/CreationsRepository.ts +30 -0
- package/src/domains/creations/presentation/components/CreationCard.tsx +81 -16
- package/src/domains/creations/presentation/components/CreationsGrid.tsx +12 -0
- package/src/domains/creations/presentation/components/GalleryHeader.tsx +70 -19
- package/src/domains/creations/presentation/hooks/index.ts +2 -0
- package/src/domains/creations/presentation/hooks/useCreationsFilter.ts +36 -16
- package/src/domains/creations/presentation/hooks/useDeleteMultipleCreations.ts +57 -0
- package/src/domains/creations/presentation/hooks/useToggleFavorite.ts +59 -0
- package/src/domains/creations/presentation/screens/CreationsGalleryScreen.tsx +132 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-generation-content",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.14",
|
|
4
4
|
"description": "Provider-agnostic AI generation orchestration for React Native",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -78,4 +78,4 @@
|
|
|
78
78
|
"publishConfig": {
|
|
79
79
|
"access": "public"
|
|
80
80
|
}
|
|
81
|
-
}
|
|
81
|
+
}
|
|
@@ -12,6 +12,7 @@ export interface Creation {
|
|
|
12
12
|
readonly originalUri?: string;
|
|
13
13
|
readonly createdAt: Date;
|
|
14
14
|
readonly isShared: boolean;
|
|
15
|
+
readonly isFavorite: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface CreationDocument {
|
|
@@ -26,6 +27,7 @@ export interface CreationDocument {
|
|
|
26
27
|
readonly type?: string;
|
|
27
28
|
readonly status?: string;
|
|
28
29
|
readonly isShared: boolean;
|
|
30
|
+
readonly isFavorite: boolean;
|
|
29
31
|
readonly createdAt: FirebaseTimestamp | Date; // Allow Date for writing
|
|
30
32
|
readonly completedAt?: FirebaseTimestamp | Date;
|
|
31
33
|
}
|
|
@@ -47,5 +49,6 @@ export function mapDocumentToCreation(
|
|
|
47
49
|
originalUri: data.originalImageUrl || data.originalImage,
|
|
48
50
|
createdAt: (data.createdAt as any)?.toDate?.() || (data.createdAt instanceof Date ? data.createdAt : new Date()),
|
|
49
51
|
isShared: data.isShared ?? false,
|
|
52
|
+
isFavorite: data.isFavorite ?? false,
|
|
50
53
|
};
|
|
51
54
|
}
|
|
@@ -15,6 +15,8 @@ export interface ICreationsRepository {
|
|
|
15
15
|
updates: Partial<Creation>,
|
|
16
16
|
): Promise<boolean>;
|
|
17
17
|
delete(userId: string, creationId: string): Promise<boolean>;
|
|
18
|
+
deleteMultiple(userId: string, creationIds: string[]): Promise<boolean>;
|
|
19
|
+
toggleFavorite(userId: string, creationId: string): Promise<boolean>;
|
|
18
20
|
updateShared(
|
|
19
21
|
userId: string,
|
|
20
22
|
creationId: string,
|
|
@@ -52,6 +52,7 @@ export interface CreationsConfig {
|
|
|
52
52
|
readonly gridColumns?: number;
|
|
53
53
|
readonly pathBuilder?: PathBuilder;
|
|
54
54
|
readonly documentMapper?: DocumentMapper;
|
|
55
|
+
readonly enableFiltering?: boolean;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export const DEFAULT_TRANSLATIONS: CreationsTranslations = {
|
|
@@ -157,6 +157,7 @@ export class CreationsRepository
|
|
|
157
157
|
createdAt: creation.createdAt,
|
|
158
158
|
metadata: creation.metadata || {},
|
|
159
159
|
isShared: creation.isShared || false,
|
|
160
|
+
isFavorite: creation.isFavorite || false,
|
|
160
161
|
};
|
|
161
162
|
|
|
162
163
|
await setDoc(docRef, data);
|
|
@@ -183,6 +184,9 @@ export class CreationsRepository
|
|
|
183
184
|
if (updates.isShared !== undefined) {
|
|
184
185
|
updateData.isShared = updates.isShared;
|
|
185
186
|
}
|
|
187
|
+
if (updates.isFavorite !== undefined) {
|
|
188
|
+
updateData.isFavorite = updates.isFavorite;
|
|
189
|
+
}
|
|
186
190
|
if (updates.uri !== undefined) {
|
|
187
191
|
updateData.uri = updates.uri;
|
|
188
192
|
}
|
|
@@ -215,6 +219,32 @@ export class CreationsRepository
|
|
|
215
219
|
}
|
|
216
220
|
}
|
|
217
221
|
|
|
222
|
+
async deleteMultiple(userId: string, creationIds: string[]): Promise<boolean> {
|
|
223
|
+
try {
|
|
224
|
+
const promises = creationIds.map((id) => this.delete(userId, id));
|
|
225
|
+
const results = await Promise.all(promises);
|
|
226
|
+
return results.every((r) => r);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async toggleFavorite(userId: string, creationId: string): Promise<boolean> {
|
|
233
|
+
const docRef = this.getDocRef(userId, creationId);
|
|
234
|
+
if (!docRef) return false;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const docSnap = await getDoc(docRef);
|
|
238
|
+
if (!docSnap.exists()) return false;
|
|
239
|
+
|
|
240
|
+
const current = docSnap.data() as CreationDocument;
|
|
241
|
+
await updateDoc(docRef, { isFavorite: !current.isFavorite });
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
218
248
|
async updateShared(
|
|
219
249
|
userId: string,
|
|
220
250
|
creationId: string,
|
|
@@ -19,6 +19,10 @@ interface CreationCardProps {
|
|
|
19
19
|
readonly onView?: (creation: Creation) => void;
|
|
20
20
|
readonly onShare: (creation: Creation) => void;
|
|
21
21
|
readonly onDelete: (creation: Creation) => void;
|
|
22
|
+
readonly onToggleFavorite?: (creation: Creation) => void;
|
|
23
|
+
readonly isSelected?: boolean;
|
|
24
|
+
readonly onSelect?: (creation: Creation) => void;
|
|
25
|
+
readonly isSelectionMode?: boolean;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function CreationCard({
|
|
@@ -27,6 +31,10 @@ export function CreationCard({
|
|
|
27
31
|
onView,
|
|
28
32
|
onShare,
|
|
29
33
|
onDelete,
|
|
34
|
+
onToggleFavorite,
|
|
35
|
+
isSelected,
|
|
36
|
+
onSelect,
|
|
37
|
+
isSelectionMode,
|
|
30
38
|
}: CreationCardProps) {
|
|
31
39
|
const tokens = useAppDesignTokens();
|
|
32
40
|
|
|
@@ -40,6 +48,14 @@ export function CreationCard({
|
|
|
40
48
|
() => onDelete(creation),
|
|
41
49
|
[creation, onDelete],
|
|
42
50
|
);
|
|
51
|
+
const handleToggleFavorite = useCallback(
|
|
52
|
+
() => onToggleFavorite?.(creation),
|
|
53
|
+
[creation, onToggleFavorite],
|
|
54
|
+
);
|
|
55
|
+
const handleSelect = useCallback(
|
|
56
|
+
() => onSelect?.(creation),
|
|
57
|
+
[creation, onSelect],
|
|
58
|
+
);
|
|
43
59
|
|
|
44
60
|
const formattedDate = useMemo(() => {
|
|
45
61
|
const date =
|
|
@@ -63,11 +79,42 @@ export function CreationCard({
|
|
|
63
79
|
borderRadius: tokens.spacing.md,
|
|
64
80
|
overflow: "hidden",
|
|
65
81
|
marginBottom: tokens.spacing.md,
|
|
82
|
+
borderWidth: 1,
|
|
83
|
+
borderColor: isSelected ? tokens.colors.primary : "transparent",
|
|
84
|
+
},
|
|
85
|
+
imageContainer: {
|
|
86
|
+
width: 100,
|
|
87
|
+
height: 100,
|
|
88
|
+
position: "relative",
|
|
66
89
|
},
|
|
67
90
|
thumbnail: {
|
|
68
91
|
width: 100,
|
|
69
92
|
height: 100,
|
|
70
93
|
},
|
|
94
|
+
favoriteBtn: {
|
|
95
|
+
position: "absolute",
|
|
96
|
+
top: 4,
|
|
97
|
+
right: 4,
|
|
98
|
+
width: 28,
|
|
99
|
+
height: 28,
|
|
100
|
+
borderRadius: 14,
|
|
101
|
+
backgroundColor: "rgba(255,255,255,0.7)",
|
|
102
|
+
justifyContent: "center",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
},
|
|
105
|
+
selectionOverlay: {
|
|
106
|
+
position: "absolute",
|
|
107
|
+
top: 4,
|
|
108
|
+
left: 4,
|
|
109
|
+
width: 24,
|
|
110
|
+
height: 24,
|
|
111
|
+
borderRadius: 12,
|
|
112
|
+
backgroundColor: isSelected ? tokens.colors.primary : "rgba(255,255,255,0.5)",
|
|
113
|
+
justifyContent: "center",
|
|
114
|
+
alignItems: "center",
|
|
115
|
+
borderWidth: 1,
|
|
116
|
+
borderColor: tokens.colors.primary,
|
|
117
|
+
},
|
|
71
118
|
content: {
|
|
72
119
|
flex: 1,
|
|
73
120
|
padding: tokens.spacing.md,
|
|
@@ -103,12 +150,33 @@ export function CreationCard({
|
|
|
103
150
|
alignItems: "center",
|
|
104
151
|
},
|
|
105
152
|
}),
|
|
106
|
-
[tokens],
|
|
153
|
+
[tokens, isSelected],
|
|
107
154
|
);
|
|
108
155
|
|
|
109
156
|
return (
|
|
110
|
-
<
|
|
111
|
-
|
|
157
|
+
<TouchableOpacity
|
|
158
|
+
activeOpacity={0.9}
|
|
159
|
+
style={styles.container}
|
|
160
|
+
onPress={isSelectionMode ? handleSelect : handleView}
|
|
161
|
+
onLongPress={handleSelect}
|
|
162
|
+
>
|
|
163
|
+
<View style={styles.imageContainer}>
|
|
164
|
+
<Image source={{ uri: creation.uri }} style={styles.thumbnail} />
|
|
165
|
+
{isSelectionMode && (
|
|
166
|
+
<View style={styles.selectionOverlay}>
|
|
167
|
+
{isSelected && <AtomicIcon name="checkmark-circle" size="xs" color="white" />}
|
|
168
|
+
</View>
|
|
169
|
+
)}
|
|
170
|
+
{!isSelectionMode && onToggleFavorite && (
|
|
171
|
+
<TouchableOpacity style={styles.favoriteBtn} onPress={handleToggleFavorite}>
|
|
172
|
+
<AtomicIcon
|
|
173
|
+
name={creation.isFavorite ? "heart" : "heart-outline"}
|
|
174
|
+
size="xs"
|
|
175
|
+
color={creation.isFavorite ? "error" : "secondary"}
|
|
176
|
+
/>
|
|
177
|
+
</TouchableOpacity>
|
|
178
|
+
)}
|
|
179
|
+
</View>
|
|
112
180
|
<View style={styles.content}>
|
|
113
181
|
<View>
|
|
114
182
|
<View style={styles.typeRow}>
|
|
@@ -117,20 +185,17 @@ export function CreationCard({
|
|
|
117
185
|
</View>
|
|
118
186
|
<AtomicText style={styles.dateText}>{formattedDate}</AtomicText>
|
|
119
187
|
</View>
|
|
120
|
-
|
|
121
|
-
{
|
|
122
|
-
<TouchableOpacity style={styles.actionBtn} onPress={
|
|
123
|
-
<AtomicIcon name="
|
|
188
|
+
{!isSelectionMode && (
|
|
189
|
+
<View style={styles.actions}>
|
|
190
|
+
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
|
|
191
|
+
<AtomicIcon name="share-social" size="sm" color="primary" />
|
|
124
192
|
</TouchableOpacity>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
</
|
|
129
|
-
|
|
130
|
-
<AtomicIcon name="trash" size="sm" color="error" />
|
|
131
|
-
</TouchableOpacity>
|
|
132
|
-
</View>
|
|
193
|
+
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete}>
|
|
194
|
+
<AtomicIcon name="trash" size="sm" color="error" />
|
|
195
|
+
</TouchableOpacity>
|
|
196
|
+
</View>
|
|
197
|
+
)}
|
|
133
198
|
</View>
|
|
134
|
-
</
|
|
199
|
+
</TouchableOpacity>
|
|
135
200
|
);
|
|
136
201
|
}
|
|
@@ -13,6 +13,10 @@ interface CreationsGridProps {
|
|
|
13
13
|
readonly onView: (creation: Creation) => void;
|
|
14
14
|
readonly onShare: (creation: Creation) => void;
|
|
15
15
|
readonly onDelete: (creation: Creation) => void;
|
|
16
|
+
readonly onToggleFavorite?: (creation: Creation) => void;
|
|
17
|
+
readonly selectedIds?: string[];
|
|
18
|
+
readonly onSelect?: (creation: Creation) => void;
|
|
19
|
+
readonly isSelectionMode?: boolean;
|
|
16
20
|
readonly contentContainerStyle?: any;
|
|
17
21
|
readonly ListEmptyComponent?: React.ReactElement | null;
|
|
18
22
|
readonly ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
|
@@ -26,6 +30,10 @@ export const CreationsGrid: React.FC<CreationsGridProps> = ({
|
|
|
26
30
|
onView,
|
|
27
31
|
onShare,
|
|
28
32
|
onDelete,
|
|
33
|
+
onToggleFavorite,
|
|
34
|
+
selectedIds = [],
|
|
35
|
+
onSelect,
|
|
36
|
+
isSelectionMode = false,
|
|
29
37
|
contentContainerStyle,
|
|
30
38
|
ListEmptyComponent,
|
|
31
39
|
ListHeaderComponent,
|
|
@@ -40,6 +48,10 @@ export const CreationsGrid: React.FC<CreationsGridProps> = ({
|
|
|
40
48
|
onView={() => onView(item)}
|
|
41
49
|
onShare={() => onShare(item)}
|
|
42
50
|
onDelete={() => onDelete(item)}
|
|
51
|
+
onToggleFavorite={onToggleFavorite}
|
|
52
|
+
isSelected={selectedIds.includes(item.id)}
|
|
53
|
+
onSelect={onSelect}
|
|
54
|
+
isSelectionMode={isSelectionMode}
|
|
43
55
|
/>
|
|
44
56
|
);
|
|
45
57
|
|
|
@@ -10,7 +10,12 @@ interface GalleryHeaderProps {
|
|
|
10
10
|
readonly onFilterPress: () => void;
|
|
11
11
|
readonly filterLabel?: string;
|
|
12
12
|
readonly filterIcon?: any;
|
|
13
|
+
readonly onFavoritesPress?: () => void;
|
|
14
|
+
readonly showOnlyFavorites?: boolean;
|
|
13
15
|
readonly style?: any;
|
|
16
|
+
readonly subtitle?: string;
|
|
17
|
+
readonly isFilterEnabled?: boolean;
|
|
18
|
+
readonly showCount?: boolean;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export const GalleryHeader: React.FC<GalleryHeaderProps> = ({
|
|
@@ -21,6 +26,11 @@ export const GalleryHeader: React.FC<GalleryHeaderProps> = ({
|
|
|
21
26
|
onFilterPress,
|
|
22
27
|
filterLabel = 'Filter',
|
|
23
28
|
filterIcon = 'filter-outline',
|
|
29
|
+
onFavoritesPress,
|
|
30
|
+
showOnlyFavorites = false,
|
|
31
|
+
subtitle,
|
|
32
|
+
isFilterEnabled = true,
|
|
33
|
+
showCount = true,
|
|
24
34
|
style,
|
|
25
35
|
}) => {
|
|
26
36
|
const tokens = useAppDesignTokens();
|
|
@@ -28,29 +38,46 @@ export const GalleryHeader: React.FC<GalleryHeaderProps> = ({
|
|
|
28
38
|
|
|
29
39
|
return (
|
|
30
40
|
<View style={[styles.headerArea, style]}>
|
|
31
|
-
<View>
|
|
32
|
-
<AtomicText style={styles.title}>{title}</AtomicText>
|
|
41
|
+
<View style={styles.titleArea}>
|
|
42
|
+
{!!title && <AtomicText style={styles.title}>{title}</AtomicText>}
|
|
33
43
|
<AtomicText style={styles.subtitle}>
|
|
34
|
-
{count} {countLabel}
|
|
44
|
+
{subtitle || (showCount ? `${count} ${countLabel}` : '')}
|
|
35
45
|
</AtomicText>
|
|
36
46
|
</View>
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
<View style={styles.actions}>
|
|
48
|
+
{onFavoritesPress && (
|
|
49
|
+
<TouchableOpacity
|
|
50
|
+
onPress={onFavoritesPress}
|
|
51
|
+
style={[styles.actionButton, showOnlyFavorites && styles.actionButtonActive]}
|
|
52
|
+
activeOpacity={0.7}
|
|
53
|
+
>
|
|
54
|
+
<AtomicIcon
|
|
55
|
+
name={showOnlyFavorites ? "heart" : "heart-outline"}
|
|
56
|
+
size="sm"
|
|
57
|
+
color={showOnlyFavorites ? "error" : "secondary"}
|
|
58
|
+
/>
|
|
59
|
+
</TouchableOpacity>
|
|
60
|
+
)}
|
|
61
|
+
{isFilterEnabled && (
|
|
62
|
+
<TouchableOpacity
|
|
63
|
+
onPress={onFilterPress}
|
|
64
|
+
style={[styles.filterButton, isFiltered && styles.filterButtonActive]}
|
|
65
|
+
activeOpacity={0.7}
|
|
66
|
+
>
|
|
67
|
+
<AtomicIcon
|
|
68
|
+
name={filterIcon}
|
|
69
|
+
size="sm"
|
|
70
|
+
color={isFiltered ? "primary" : "secondary"}
|
|
71
|
+
/>
|
|
72
|
+
<AtomicText style={[styles.filterText, { color: isFiltered ? tokens.colors.primary : tokens.colors.textSecondary }]}>
|
|
73
|
+
{filterLabel}
|
|
74
|
+
</AtomicText>
|
|
75
|
+
{isFiltered && (
|
|
76
|
+
<View style={styles.badge} />
|
|
77
|
+
)}
|
|
78
|
+
</TouchableOpacity>
|
|
52
79
|
)}
|
|
53
|
-
</
|
|
80
|
+
</View>
|
|
54
81
|
</View>
|
|
55
82
|
);
|
|
56
83
|
};
|
|
@@ -64,6 +91,10 @@ const useStyles = (tokens: any) => StyleSheet.create({
|
|
|
64
91
|
paddingVertical: tokens.spacing.sm,
|
|
65
92
|
marginBottom: tokens.spacing.sm,
|
|
66
93
|
},
|
|
94
|
+
titleArea: {
|
|
95
|
+
flex: 1,
|
|
96
|
+
marginRight: tokens.spacing.md,
|
|
97
|
+
},
|
|
67
98
|
title: {
|
|
68
99
|
fontSize: 20,
|
|
69
100
|
fontWeight: "700",
|
|
@@ -75,6 +106,25 @@ const useStyles = (tokens: any) => StyleSheet.create({
|
|
|
75
106
|
color: tokens.colors.textSecondary,
|
|
76
107
|
opacity: 0.6
|
|
77
108
|
},
|
|
109
|
+
actions: {
|
|
110
|
+
flexDirection: 'row',
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
gap: tokens.spacing.sm,
|
|
113
|
+
},
|
|
114
|
+
actionButton: {
|
|
115
|
+
width: 40,
|
|
116
|
+
height: 40,
|
|
117
|
+
borderRadius: 20,
|
|
118
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
119
|
+
justifyContent: 'center',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
borderWidth: 1,
|
|
122
|
+
borderColor: 'transparent',
|
|
123
|
+
},
|
|
124
|
+
actionButtonActive: {
|
|
125
|
+
backgroundColor: tokens.colors.primary + "15",
|
|
126
|
+
borderColor: tokens.colors.primary + "30",
|
|
127
|
+
},
|
|
78
128
|
filterButton: {
|
|
79
129
|
flexDirection: 'row',
|
|
80
130
|
alignItems: 'center',
|
|
@@ -85,6 +135,7 @@ const useStyles = (tokens: any) => StyleSheet.create({
|
|
|
85
135
|
backgroundColor: tokens.colors.surfaceVariant,
|
|
86
136
|
borderWidth: 1,
|
|
87
137
|
borderColor: 'transparent',
|
|
138
|
+
height: 40,
|
|
88
139
|
},
|
|
89
140
|
filterButtonActive: {
|
|
90
141
|
backgroundColor: tokens.colors.primary + "15",
|
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
export { useCreations } from "./useCreations";
|
|
6
6
|
export { useDeleteCreation } from "./useDeleteCreation";
|
|
7
7
|
export { useCreationsFilter } from "./useCreationsFilter";
|
|
8
|
+
export { useToggleFavorite } from "./useToggleFavorite";
|
|
9
|
+
export { useDeleteMultipleCreations } from "./useDeleteMultipleCreations";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useCreationsFilter Hook
|
|
3
|
-
* Handles filtering of creations by type
|
|
3
|
+
* Handles filtering of creations by type, search, and favorites
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useState, useMemo, useCallback } from "react";
|
|
@@ -16,41 +16,55 @@ export function useCreationsFilter({
|
|
|
16
16
|
defaultFilterId = "all"
|
|
17
17
|
}: UseCreationsFilterProps) {
|
|
18
18
|
const [selectedIds, setSelectedIds] = useState<string[]>([defaultFilterId]);
|
|
19
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
20
|
+
const [showOnlyFavorites, setShowOnlyFavorites] = useState(false);
|
|
19
21
|
|
|
20
22
|
const filtered = useMemo(() => {
|
|
21
23
|
if (!creations) return [];
|
|
22
|
-
if (selectedIds.includes(defaultFilterId)) return creations;
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
25
|
+
let result = creations;
|
|
26
|
+
|
|
27
|
+
// 1. Filter by Favorites
|
|
28
|
+
if (showOnlyFavorites) {
|
|
29
|
+
result = result.filter(c => c.isFavorite);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Filter by Category/Type
|
|
33
|
+
if (!selectedIds.includes(defaultFilterId)) {
|
|
34
|
+
result = result.filter((c) =>
|
|
35
|
+
selectedIds.includes(c.type) ||
|
|
36
|
+
selectedIds.some(id => (c as any).metadata?.tags?.includes(id))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Filter by Search Query
|
|
41
|
+
if (searchQuery.trim()) {
|
|
42
|
+
const query = searchQuery.toLowerCase();
|
|
43
|
+
result = result.filter(c =>
|
|
44
|
+
c.prompt?.toLowerCase().includes(query) ||
|
|
45
|
+
c.type.toLowerCase().includes(query)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}, [creations, selectedIds, defaultFilterId, searchQuery, showOnlyFavorites]);
|
|
29
51
|
|
|
30
52
|
const toggleFilter = useCallback((filterId: string, multiSelect: boolean = false) => {
|
|
31
53
|
setSelectedIds(prev => {
|
|
32
|
-
// If selecting 'all', clear everything else
|
|
33
54
|
if (filterId === defaultFilterId) return [defaultFilterId];
|
|
34
55
|
|
|
35
56
|
let newIds: string[];
|
|
36
57
|
if (!multiSelect) {
|
|
37
|
-
// Single select
|
|
38
|
-
// If we tap the already selected item in single mode, should we Deselect it?
|
|
39
|
-
// Typically in radio-button style filters, no. But let's assume valid toggling behavior suitable for the UI.
|
|
40
|
-
// If single select, simply switch to the new one.
|
|
41
58
|
if (prev.includes(filterId) && prev.length === 1) return prev;
|
|
42
59
|
newIds = [filterId];
|
|
43
60
|
} else {
|
|
44
|
-
// Multi select
|
|
45
61
|
if (prev.includes(filterId)) {
|
|
46
62
|
newIds = prev.filter(id => id !== filterId);
|
|
47
63
|
} else {
|
|
48
|
-
// Remove 'all' if present
|
|
49
64
|
newIds = [...prev.filter(id => id !== defaultFilterId), filterId];
|
|
50
65
|
}
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
// If nothing selected, revert to 'all'
|
|
54
68
|
if (newIds.length === 0) return [defaultFilterId];
|
|
55
69
|
return newIds;
|
|
56
70
|
});
|
|
@@ -58,13 +72,19 @@ export function useCreationsFilter({
|
|
|
58
72
|
|
|
59
73
|
const clearFilters = useCallback(() => {
|
|
60
74
|
setSelectedIds([defaultFilterId]);
|
|
75
|
+
setSearchQuery("");
|
|
76
|
+
setShowOnlyFavorites(false);
|
|
61
77
|
}, [defaultFilterId]);
|
|
62
78
|
|
|
63
79
|
return {
|
|
64
80
|
filtered,
|
|
65
81
|
selectedIds,
|
|
82
|
+
searchQuery,
|
|
83
|
+
setSearchQuery,
|
|
84
|
+
showOnlyFavorites,
|
|
85
|
+
setShowOnlyFavorites,
|
|
66
86
|
toggleFilter,
|
|
67
87
|
clearFilters,
|
|
68
|
-
isFiltered: !selectedIds.includes(defaultFilterId),
|
|
88
|
+
isFiltered: !selectedIds.includes(defaultFilterId) || !!searchQuery || showOnlyFavorites,
|
|
69
89
|
};
|
|
70
90
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDeleteMultipleCreations Hook
|
|
3
|
+
* Handles batch deletion of user creations with optimistic update
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import type { ICreationsRepository } from "../../domain/repositories/ICreationsRepository";
|
|
8
|
+
import type { Creation } from "../../domain/entities/Creation";
|
|
9
|
+
|
|
10
|
+
interface UseDeleteMultipleCreationsProps {
|
|
11
|
+
readonly userId: string | null;
|
|
12
|
+
readonly repository: ICreationsRepository;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useDeleteMultipleCreations({
|
|
16
|
+
userId,
|
|
17
|
+
repository,
|
|
18
|
+
}: UseDeleteMultipleCreationsProps) {
|
|
19
|
+
const queryClient = useQueryClient();
|
|
20
|
+
|
|
21
|
+
return useMutation({
|
|
22
|
+
mutationFn: async (creationIds: string[]) => {
|
|
23
|
+
if (!userId) return false;
|
|
24
|
+
return repository.deleteMultiple(userId, creationIds);
|
|
25
|
+
},
|
|
26
|
+
onMutate: async (creationIds) => {
|
|
27
|
+
if (!userId) return;
|
|
28
|
+
|
|
29
|
+
await queryClient.cancelQueries({
|
|
30
|
+
queryKey: ["creations", userId],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const previous = queryClient.getQueryData<Creation[]>([
|
|
34
|
+
"creations",
|
|
35
|
+
userId,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
queryClient.setQueryData<Creation[]>(
|
|
39
|
+
["creations", userId],
|
|
40
|
+
(old) =>
|
|
41
|
+
old?.filter((c) => !creationIds.includes(c.id)) ?? [],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return { previous };
|
|
45
|
+
},
|
|
46
|
+
onError: (_err, _ids, rollback) => {
|
|
47
|
+
if (userId && rollback?.previous) {
|
|
48
|
+
queryClient.setQueryData(["creations", userId], rollback.previous);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
onSettled: () => {
|
|
52
|
+
if (userId) {
|
|
53
|
+
queryClient.invalidateQueries({ queryKey: ["creations", userId] });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useToggleFavorite Hook
|
|
3
|
+
* Handles favoriting/unfavoriting of user creations with optimistic update
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import type { ICreationsRepository } from "../../domain/repositories/ICreationsRepository";
|
|
8
|
+
import type { Creation } from "../../domain/entities/Creation";
|
|
9
|
+
|
|
10
|
+
interface UseToggleFavoriteProps {
|
|
11
|
+
readonly userId: string | null;
|
|
12
|
+
readonly repository: ICreationsRepository;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useToggleFavorite({
|
|
16
|
+
userId,
|
|
17
|
+
repository,
|
|
18
|
+
}: UseToggleFavoriteProps) {
|
|
19
|
+
const queryClient = useQueryClient();
|
|
20
|
+
|
|
21
|
+
return useMutation({
|
|
22
|
+
mutationFn: async (creationId: string) => {
|
|
23
|
+
if (!userId) return false;
|
|
24
|
+
return repository.toggleFavorite(userId, creationId);
|
|
25
|
+
},
|
|
26
|
+
onMutate: async (creationId) => {
|
|
27
|
+
if (!userId) return;
|
|
28
|
+
|
|
29
|
+
await queryClient.cancelQueries({
|
|
30
|
+
queryKey: ["creations", userId],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const previous = queryClient.getQueryData<Creation[]>([
|
|
34
|
+
"creations",
|
|
35
|
+
userId,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
queryClient.setQueryData<Creation[]>(
|
|
39
|
+
["creations", userId],
|
|
40
|
+
(old) =>
|
|
41
|
+
old?.map((c) =>
|
|
42
|
+
c.id === creationId ? { ...c, isFavorite: !c.isFavorite } : c,
|
|
43
|
+
) ?? [],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return { previous };
|
|
47
|
+
},
|
|
48
|
+
onError: (_err, _id, rollback) => {
|
|
49
|
+
if (userId && rollback?.previous) {
|
|
50
|
+
queryClient.setQueryData(["creations", userId], rollback.previous);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
onSettled: () => {
|
|
54
|
+
if (userId) {
|
|
55
|
+
queryClient.invalidateQueries({ queryKey: ["creations", userId] });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
import React, { useMemo, useCallback, useState } from "react";
|
|
2
|
-
import { View, StyleSheet
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
useAppDesignTokens,
|
|
5
|
+
useSharing,
|
|
6
|
+
ScreenLayout,
|
|
7
|
+
ScreenHeader,
|
|
8
|
+
useAlert,
|
|
9
|
+
AlertMode,
|
|
10
|
+
type BottomSheetModalRef
|
|
11
|
+
} from "@umituz/react-native-design-system";
|
|
6
12
|
import { useFocusEffect } from "@react-navigation/native";
|
|
7
13
|
import { useCreations } from "../hooks/useCreations";
|
|
8
14
|
import { useDeleteCreation } from "../hooks/useDeleteCreation";
|
|
9
15
|
import { useCreationsFilter } from "../hooks/useCreationsFilter";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
16
|
+
import { useToggleFavorite } from "../hooks/useToggleFavorite";
|
|
17
|
+
import { useDeleteMultipleCreations } from "../hooks/useDeleteMultipleCreations";
|
|
18
|
+
import {
|
|
19
|
+
GalleryHeader,
|
|
20
|
+
CreationsGrid,
|
|
21
|
+
FilterBottomSheet,
|
|
22
|
+
CreationImageViewer,
|
|
23
|
+
CreationsGalleryEmptyState
|
|
24
|
+
} from "../components";
|
|
25
|
+
import { SearchBar } from "@umituz/react-native-design-system";
|
|
12
26
|
import { getTranslatedTypes, getFilterCategoriesFromConfig } from "../utils/filterUtils";
|
|
13
27
|
import type { Creation } from "../../domain/entities/Creation";
|
|
14
28
|
import type { CreationsConfig } from "../../domain/value-objects/CreationsConfig";
|
|
15
29
|
import type { ICreationsRepository } from "../../domain/repositories/ICreationsRepository";
|
|
16
|
-
import { CreationDetailScreen } from "./CreationDetailScreen";
|
|
17
30
|
|
|
18
31
|
interface CreationsGalleryScreenProps {
|
|
19
32
|
readonly userId: string | null;
|
|
20
33
|
readonly repository: ICreationsRepository;
|
|
21
34
|
readonly config: CreationsConfig;
|
|
22
|
-
readonly t: (key: string) => string;
|
|
35
|
+
readonly t: (key: string, options?: any) => string;
|
|
23
36
|
readonly enableEditing?: boolean;
|
|
24
37
|
readonly onImageEdit?: (uri: string, creationId: string) => void | Promise<void>;
|
|
25
38
|
readonly onEmptyAction?: () => void;
|
|
26
39
|
readonly emptyActionLabel?: string;
|
|
40
|
+
readonly onBackPress?: () => void;
|
|
41
|
+
readonly headerTitle?: string;
|
|
42
|
+
readonly showCount?: boolean;
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
export function CreationsGalleryScreen({
|
|
@@ -35,9 +51,11 @@ export function CreationsGalleryScreen({
|
|
|
35
51
|
onImageEdit,
|
|
36
52
|
onEmptyAction,
|
|
37
53
|
emptyActionLabel,
|
|
54
|
+
onBackPress,
|
|
55
|
+
headerTitle,
|
|
56
|
+
showCount = true,
|
|
38
57
|
}: CreationsGalleryScreenProps) {
|
|
39
58
|
const tokens = useAppDesignTokens();
|
|
40
|
-
const insets = useSafeAreaInsets();
|
|
41
59
|
const { share } = useSharing();
|
|
42
60
|
const alert = useAlert();
|
|
43
61
|
|
|
@@ -48,8 +66,27 @@ export function CreationsGalleryScreen({
|
|
|
48
66
|
|
|
49
67
|
const { data: creationsData, isLoading, refetch } = useCreations({ userId, repository });
|
|
50
68
|
const creations = creationsData as Creation[] | undefined;
|
|
69
|
+
|
|
51
70
|
const deleteMutation = useDeleteCreation({ userId, repository });
|
|
52
|
-
const
|
|
71
|
+
const toggleFavoriteMutation = useToggleFavorite({ userId, repository });
|
|
72
|
+
const deleteMultipleMutation = useDeleteMultipleCreations({ userId, repository });
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
filtered,
|
|
76
|
+
selectedIds: selectedCategoryIds,
|
|
77
|
+
searchQuery,
|
|
78
|
+
setSearchQuery,
|
|
79
|
+
showOnlyFavorites,
|
|
80
|
+
setShowOnlyFavorites,
|
|
81
|
+
toggleFilter,
|
|
82
|
+
clearFilters,
|
|
83
|
+
isFiltered
|
|
84
|
+
} = useCreationsFilter({ creations });
|
|
85
|
+
|
|
86
|
+
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
|
|
87
|
+
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
88
|
+
|
|
89
|
+
|
|
53
90
|
|
|
54
91
|
// Refetch creations when screen comes into focus
|
|
55
92
|
useFocusEffect(
|
|
@@ -85,37 +122,96 @@ export function CreationsGalleryScreen({
|
|
|
85
122
|
);
|
|
86
123
|
}, [alert, config, deleteMutation, t]);
|
|
87
124
|
|
|
125
|
+
const handleToggleFavorite = useCallback((creation: Creation) => {
|
|
126
|
+
toggleFavoriteMutation.mutate(creation.id);
|
|
127
|
+
}, [toggleFavoriteMutation]);
|
|
128
|
+
|
|
129
|
+
const toggleSelection = useCallback((creation: Creation) => {
|
|
130
|
+
setSelectedItemIds(prev => {
|
|
131
|
+
const isSelected = prev.includes(creation.id);
|
|
132
|
+
const next = isSelected ? prev.filter(id => id !== creation.id) : [...prev, creation.id];
|
|
133
|
+
if (next.length === 0) setIsSelectionMode(false);
|
|
134
|
+
else setIsSelectionMode(true);
|
|
135
|
+
return next;
|
|
136
|
+
});
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const handleDeleteSelected = useCallback(() => {
|
|
140
|
+
if (selectedItemIds.length === 0) return;
|
|
141
|
+
|
|
142
|
+
alert.showWarning(
|
|
143
|
+
t(config.translations.deleteTitle),
|
|
144
|
+
t("common.delete_selected_confirm") || `Are you sure you want to delete ${selectedItemIds.length} items?`,
|
|
145
|
+
{
|
|
146
|
+
mode: AlertMode.MODAL,
|
|
147
|
+
actions: [
|
|
148
|
+
{ id: 'cancel', label: t("common.cancel"), onPress: () => { } },
|
|
149
|
+
{
|
|
150
|
+
id: 'delete', label: t("common.delete"), style: 'destructive', onPress: async () => {
|
|
151
|
+
const success = await deleteMultipleMutation.mutateAsync(selectedItemIds);
|
|
152
|
+
if (success) {
|
|
153
|
+
setSelectedItemIds([]);
|
|
154
|
+
setIsSelectionMode(false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}, [alert, config, deleteMultipleMutation, selectedItemIds, t]);
|
|
162
|
+
|
|
88
163
|
// Handle viewing a creation - shows detail screen
|
|
89
164
|
const handleView = useCallback((creation: Creation) => {
|
|
90
165
|
setSelectedCreation(creation);
|
|
91
|
-
|
|
166
|
+
setViewerIndex(filtered.findIndex(c => c.id === creation.id));
|
|
167
|
+
setViewerVisible(true);
|
|
168
|
+
}, [filtered]);
|
|
92
169
|
|
|
93
|
-
|
|
94
|
-
const
|
|
170
|
+
const screenTitle = headerTitle || t(config.translations.title) || 'My Creations';
|
|
171
|
+
const showScreenHeader = !!onBackPress;
|
|
95
172
|
|
|
96
173
|
return (
|
|
97
|
-
<
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
174
|
+
<ScreenLayout
|
|
175
|
+
edges={['top']}
|
|
176
|
+
scrollable={false}
|
|
177
|
+
header={
|
|
178
|
+
<View>
|
|
179
|
+
{showScreenHeader && (
|
|
180
|
+
<ScreenHeader
|
|
181
|
+
title={isSelectionMode ? `${selectedItemIds.length} Selected` : screenTitle}
|
|
182
|
+
onBackPress={isSelectionMode ? () => { setIsSelectionMode(false); setSelectedItemIds([]); } : onBackPress}
|
|
183
|
+
rightAction={isSelectionMode ? {
|
|
184
|
+
icon: 'trash',
|
|
185
|
+
onPress: handleDeleteSelected,
|
|
186
|
+
} : undefined}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
{!isSelectionMode && (
|
|
190
|
+
<View style={{ paddingHorizontal: tokens.spacing.md, paddingBottom: tokens.spacing.xs }}>
|
|
191
|
+
<SearchBar
|
|
192
|
+
placeholder={t("common.search") || "Search Prompt..."}
|
|
193
|
+
value={searchQuery}
|
|
194
|
+
onChangeText={setSearchQuery}
|
|
195
|
+
/>
|
|
196
|
+
</View>
|
|
197
|
+
)}
|
|
198
|
+
</View>
|
|
199
|
+
}
|
|
200
|
+
contentContainerStyle={{ paddingHorizontal: 0 }}
|
|
201
|
+
>
|
|
110
202
|
{(!creations || creations?.length === 0) && !isLoading ? null : (
|
|
111
203
|
<GalleryHeader
|
|
112
|
-
title={
|
|
204
|
+
title={showScreenHeader || isSelectionMode ? '' : screenTitle}
|
|
113
205
|
count={filtered.length}
|
|
114
|
-
countLabel=
|
|
206
|
+
countLabel=''
|
|
207
|
+
subtitle={showCount ? t(config.translations.photoCount, { count: filtered.length }) : undefined}
|
|
115
208
|
isFiltered={isFiltered}
|
|
116
209
|
filterLabel={t(config.translations.filterLabel) || 'Filter'}
|
|
117
210
|
onFilterPress={() => filterSheetRef.current?.present()}
|
|
118
|
-
|
|
211
|
+
onFavoritesPress={() => setShowOnlyFavorites(!showOnlyFavorites)}
|
|
212
|
+
showOnlyFavorites={showOnlyFavorites}
|
|
213
|
+
isFilterEnabled={config.enableFiltering ?? false}
|
|
214
|
+
showCount={showCount}
|
|
119
215
|
/>
|
|
120
216
|
)}
|
|
121
217
|
|
|
@@ -128,7 +224,11 @@ export function CreationsGalleryScreen({
|
|
|
128
224
|
onView={handleView}
|
|
129
225
|
onShare={handleShare}
|
|
130
226
|
onDelete={handleDelete}
|
|
131
|
-
|
|
227
|
+
onToggleFavorite={handleToggleFavorite}
|
|
228
|
+
isSelectionMode={isSelectionMode}
|
|
229
|
+
selectedIds={selectedItemIds}
|
|
230
|
+
onSelect={toggleSelection}
|
|
231
|
+
contentContainerStyle={{ paddingBottom: tokens.spacing.xl }}
|
|
132
232
|
ListEmptyComponent={
|
|
133
233
|
<CreationsGalleryEmptyState
|
|
134
234
|
isLoading={isLoading}
|
|
@@ -156,15 +256,11 @@ export function CreationsGalleryScreen({
|
|
|
156
256
|
<FilterBottomSheet
|
|
157
257
|
ref={filterSheetRef}
|
|
158
258
|
categories={allCategories}
|
|
159
|
-
selectedIds={
|
|
259
|
+
selectedIds={selectedCategoryIds}
|
|
160
260
|
onFilterPress={(id, catId) => toggleFilter(id, allCategories.find(c => c.id === catId)?.multiSelect)}
|
|
161
261
|
onClearFilters={clearFilters}
|
|
162
262
|
title={t(config.translations.filterTitle) || t("common.filter")}
|
|
163
263
|
/>
|
|
164
|
-
</
|
|
264
|
+
</ScreenLayout>
|
|
165
265
|
);
|
|
166
266
|
}
|
|
167
|
-
|
|
168
|
-
const useStyles = (tokens: any) => StyleSheet.create({
|
|
169
|
-
container: { flex: 1, backgroundColor: tokens.colors.background },
|
|
170
|
-
});
|