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>
166 lines
3.9 KiB
TypeScript
166 lines
3.9 KiB
TypeScript
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;
|
|
},
|
|
});
|