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:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

166
convex/collections.ts Normal file
View file

@ -0,0 +1,166 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// Get user's collection (alias for backwards compat)
export const getByUser = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
// Enrich with card data
const enriched = await Promise.all(
entries.map(async (entry) => {
const card = await ctx.db.get(entry.cardId);
return {
...entry,
card,
};
})
);
return enriched.filter((e) => e.card !== null);
},
});
// Get user's collection
export const getMyCollection = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
// Enrich with card data
const enriched = await Promise.all(
entries.map(async (entry) => {
const card = await ctx.db.get(entry.cardId);
return {
...entry,
card,
};
})
);
return enriched.filter((e) => e.card !== null);
},
});
// Get collection stats
export const getStats = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const totalCards = entries.reduce((sum, e) => sum + e.quantity, 0);
const uniqueCards = entries.length;
const foilCount = entries.filter((e) => e.isFoil).length;
return {
totalCards,
uniqueCards,
foilCount,
};
},
});
// Add card to collection
export const addCard = mutation({
args: {
userId: v.id("users"),
cardId: v.id("cards"),
quantity: v.number(),
isFoil: v.boolean(),
},
handler: async (ctx, { userId, cardId, quantity, isFoil }) => {
// Check if entry already exists
const existing = await ctx.db
.query("collections")
.withIndex("by_user_card", (q) =>
q.eq("userId", userId).eq("cardId", cardId).eq("isFoil", isFoil)
)
.first();
if (existing) {
// Update quantity
await ctx.db.patch(existing._id, {
quantity: existing.quantity + quantity,
});
return existing._id;
}
// Create new entry
return await ctx.db.insert("collections", {
userId,
cardId,
quantity,
isFoil,
addedAt: Date.now(),
});
},
});
// Alias for addCard
export const add = addCard;
// Update card quantity
export const updateQuantity = mutation({
args: {
entryId: v.id("collections"),
quantity: v.number(),
},
handler: async (ctx, { entryId, quantity }) => {
if (quantity <= 0) {
await ctx.db.delete(entryId);
return null;
}
await ctx.db.patch(entryId, { quantity });
return entryId;
},
});
// Remove card from collection
export const removeCard = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
await ctx.db.delete(entryId);
},
});
// Alias for removeCard
export const remove = removeCard;
// Decrease quantity by 1
export const decrementQuantity = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
const entry = await ctx.db.get(entryId);
if (!entry) return null;
if (entry.quantity <= 1) {
await ctx.db.delete(entryId);
return null;
}
await ctx.db.patch(entryId, { quantity: entry.quantity - 1 });
return entryId;
},
});
// Increase quantity by 1
export const incrementQuantity = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
const entry = await ctx.db.get(entryId);
if (!entry) return null;
await ctx.db.patch(entryId, { quantity: entry.quantity + 1 });
return entryId;
},
});