feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard
- BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const coerceNumber = z.preprocess((val) => {
|
||||
const coerceAbv = z.preprocess((val) => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const n = Number(val);
|
||||
if (isNaN(n)) return null;
|
||||
return n;
|
||||
}, z.number().min(0).max(100).nullish());
|
||||
|
||||
const coerceAge = z.preprocess((val) => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const n = Number(val);
|
||||
if (isNaN(n)) return null;
|
||||
@@ -15,8 +22,8 @@ export const BottleMetadataSchema = z.object({
|
||||
bottler: z.string().trim().max(255).nullish(),
|
||||
series: z.string().trim().max(255).nullish(),
|
||||
category: z.string().trim().max(100).nullish(),
|
||||
abv: coerceNumber,
|
||||
age: coerceNumber,
|
||||
abv: coerceAbv,
|
||||
age: coerceAge,
|
||||
vintage: z.string().trim().max(50).nullish(),
|
||||
bottleCode: z.string().trim().max(100).nullish(),
|
||||
whiskybaseId: z.string().trim().max(50).nullish(),
|
||||
@@ -26,6 +33,7 @@ export const BottleMetadataSchema = z.object({
|
||||
cask_type: z.string().trim().max(255).nullish(),
|
||||
is_whisky: z.boolean().default(true),
|
||||
confidence: z.number().min(0).max(100).default(100),
|
||||
confidence_scores: z.record(z.string(), z.number().min(0).max(100)).nullish(),
|
||||
purchase_price: z.number().min(0).nullish(),
|
||||
status: z.enum(['sealed', 'open', 'sampled', 'empty']).default('sealed').nullish(),
|
||||
suggested_tags: z.array(z.string().trim().max(100)).nullish(),
|
||||
@@ -45,6 +53,18 @@ export const TastingNoteSchema = z.object({
|
||||
buddy_ids: z.array(z.string().uuid()).optional(),
|
||||
tag_ids: z.array(z.string().uuid()).optional(),
|
||||
tasted_at: z.string().datetime().optional(),
|
||||
blind_label: z.string().trim().max(100).nullish(),
|
||||
guess_abv: z.number().min(0).max(100).nullish(),
|
||||
guess_age: z.number().min(0).max(200).nullish(),
|
||||
guess_region: z.string().trim().max(100).nullish(),
|
||||
guess_points: z.number().min(0).nullish(),
|
||||
flavor_profile: z.object({
|
||||
smoky: z.number().min(0).max(100),
|
||||
fruity: z.number().min(0).max(100),
|
||||
spicy: z.number().min(0).max(100),
|
||||
sweet: z.number().min(0).max(100),
|
||||
floral: z.number().min(0).max(100),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type TastingNoteData = z.infer<typeof TastingNoteSchema>;
|
||||
@@ -53,8 +73,8 @@ export const UpdateBottleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255).nullish(),
|
||||
distillery: z.string().trim().max(255).nullish(),
|
||||
category: z.string().trim().max(100).nullish(),
|
||||
abv: coerceNumber,
|
||||
age: coerceNumber,
|
||||
abv: coerceAbv,
|
||||
age: coerceAge,
|
||||
whiskybase_id: z.string().trim().max(50).nullish(),
|
||||
purchase_price: z.number().min(0).nullish(),
|
||||
distilled_at: z.string().trim().max(50).nullish(),
|
||||
@@ -93,8 +113,8 @@ export type AdminSettingsData = z.infer<typeof AdminSettingsSchema>;
|
||||
export const DiscoveryDataSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
distillery: z.string().trim().max(255).nullish(),
|
||||
abv: coerceNumber,
|
||||
age: coerceNumber,
|
||||
abv: coerceAbv,
|
||||
age: coerceAge,
|
||||
distilled_at: z.string().trim().max(50).nullish(),
|
||||
bottled_at: z.string().trim().max(50).nullish(),
|
||||
batch_info: z.string().trim().max(255).nullish(),
|
||||
|
||||
Reference in New Issue
Block a user