Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:
- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)
Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support
Removes all .NET/MAUI code (src/, test/, tools/).
💘 Generated with Crush
Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
376
app/modal.tsx
Normal file
376
app/modal.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* Card detail modal - shows full card info with quantity controls.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { FontAwesome } from "@expo/vector-icons";
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
import { api } from "../convex/_generated/api";
|
||||
import { useCurrentUser } from "@/lib/hooks";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const CARD_WIDTH = SCREEN_WIDTH - 64;
|
||||
const CARD_HEIGHT = CARD_WIDTH * (88 / 63);
|
||||
|
||||
export default function CardDetailModal() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
collectionEntryId: string;
|
||||
cardId: string;
|
||||
}>();
|
||||
|
||||
// Get authenticated user
|
||||
const { user } = useCurrentUser();
|
||||
const userId = user?._id ?? null;
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Fetch card details
|
||||
const card = useQuery(
|
||||
api.cards.getByScryfallId,
|
||||
params.cardId ? { scryfallId: params.cardId } : "skip"
|
||||
);
|
||||
|
||||
// Fetch collection entry
|
||||
const collection = useQuery(
|
||||
api.collections.getByUser,
|
||||
userId ? { userId: userId as any } : "skip"
|
||||
);
|
||||
|
||||
const collectionEntry = collection?.find(
|
||||
(entry) => entry._id === params.collectionEntryId
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const updateQuantity = useMutation(api.collections.updateQuantity);
|
||||
const removeCard = useMutation(api.collections.remove);
|
||||
|
||||
const handleQuantityChange = async (delta: number) => {
|
||||
if (!collectionEntry) return;
|
||||
|
||||
const newQuantity = collectionEntry.quantity + delta;
|
||||
|
||||
if (newQuantity <= 0) {
|
||||
handleRemove();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateQuantity({
|
||||
entryId: collectionEntry._id,
|
||||
quantity: newQuantity,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update quantity:", error);
|
||||
Alert.alert("Error", "Failed to update quantity");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
Alert.alert(
|
||||
"Remove Card",
|
||||
`Remove ${card?.name || "this card"} from your collection?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Remove",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
if (!collectionEntry) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await removeCard({ entryId: collectionEntry._id });
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove card:", error);
|
||||
Alert.alert("Error", "Failed to remove card");
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (!card || !collectionEntry) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Card image */}
|
||||
<View style={styles.imageContainer}>
|
||||
{card.imageUri ? (
|
||||
<Image
|
||||
source={{ uri: card.imageUri }}
|
||||
style={styles.cardImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.cardImage, styles.cardPlaceholder]}>
|
||||
<FontAwesome name="image" size={48} color="#666" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Card info */}
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={styles.cardName}>{card.name}</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Set</Text>
|
||||
<Text style={styles.metaValue}>{card.setCode.toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Number</Text>
|
||||
<Text style={styles.metaValue}>{card.collectorNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Rarity</Text>
|
||||
<Text style={styles.metaValue}>{card.rarity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{card.artist && (
|
||||
<View style={styles.artistRow}>
|
||||
<FontAwesome name="paint-brush" size={12} color="#888" />
|
||||
<Text style={styles.artistText}>{card.artist}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Quantity controls */}
|
||||
<View style={styles.quantityContainer}>
|
||||
<Text style={styles.quantityLabel}>Quantity</Text>
|
||||
|
||||
<View style={styles.quantityControls}>
|
||||
<Pressable
|
||||
style={styles.quantityButton}
|
||||
onPress={() => handleQuantityChange(-1)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<FontAwesome name="minus" size={16} color="#fff" />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.quantityDisplay}>
|
||||
{isUpdating ? (
|
||||
<ActivityIndicator size="small" color="#007AFF" />
|
||||
) : (
|
||||
<Text style={styles.quantityValue}>{collectionEntry.quantity}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.quantityButton}
|
||||
onPress={() => handleQuantityChange(1)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<FontAwesome name="plus" size={16} color="#fff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{collectionEntry.isFoil && (
|
||||
<View style={styles.foilBadge}>
|
||||
<FontAwesome name="star" size={12} color="#FFD700" />
|
||||
<Text style={styles.foilText}>Foil</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Added date */}
|
||||
<Text style={styles.addedDate}>
|
||||
Added {new Date(collectionEntry.addedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.removeButton]}
|
||||
onPress={handleRemove}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<FontAwesome name="trash" size={16} color="#FF6B6B" />
|
||||
<Text style={styles.removeButtonText}>Remove from Collection</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
imageContainer: {
|
||||
alignItems: "center",
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
cardImage: {
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
cardPlaceholder: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
infoContainer: {
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
cardName: {
|
||||
color: "#fff",
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: 24,
|
||||
},
|
||||
metaItem: {
|
||||
alignItems: "center",
|
||||
},
|
||||
metaLabel: {
|
||||
color: "#666",
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 4,
|
||||
},
|
||||
metaValue: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
artistRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
artistText: {
|
||||
color: "#888",
|
||||
fontSize: 14,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
quantityContainer: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
marginHorizontal: 24,
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
quantityLabel: {
|
||||
color: "#888",
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 12,
|
||||
},
|
||||
quantityControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 20,
|
||||
},
|
||||
quantityButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "#007AFF",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
quantityDisplay: {
|
||||
width: 60,
|
||||
alignItems: "center",
|
||||
},
|
||||
quantityValue: {
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
foilBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "rgba(255,215,0,0.1)",
|
||||
borderRadius: 6,
|
||||
},
|
||||
foilText: {
|
||||
color: "#FFD700",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
addedDate: {
|
||||
color: "#666",
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
marginBottom: 24,
|
||||
},
|
||||
actions: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
removeButton: {
|
||||
backgroundColor: "rgba(255,107,107,0.1)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255,107,107,0.3)",
|
||||
},
|
||||
removeButtonText: {
|
||||
color: "#FF6B6B",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue