feat: Buddy System & Bulk Scanner
- Add Buddy linking via QR code/handshake (buddy_invites table) - Add Bulk Scanner for rapid-fire bottle scanning in sessions - Add processing_status to bottles for background AI analysis - Fix offline OCR with proper tessdata caching in Service Worker - Fix Supabase GoTrueClient singleton warning - Add collection refresh after offline sync completes New components: - BuddyHandshake.tsx - QR code display and code entry - BulkScanSheet.tsx - Camera UI with capture queue - BottleSkeletonCard.tsx - Pending bottle display - useBulkScanner.ts - Queue management hook - buddy-link.ts - Server actions for buddy linking - bulk-scan.ts - Server actions for batch processing
This commit is contained in:
@@ -27,10 +27,12 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.1.0",
|
"next": "16.1.0",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@19.2.3)
|
version: 0.468.0(react@19.2.3)
|
||||||
|
nanoid:
|
||||||
|
specifier: ^5.1.6
|
||||||
|
version: 5.1.6
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.0
|
specifier: 16.1.0
|
||||||
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -65,6 +68,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.3(react@19.2.3)
|
version: 19.2.3(react@19.2.3)
|
||||||
|
react-qr-code:
|
||||||
|
specifier: ^2.0.18
|
||||||
|
version: 2.0.18(react@19.2.3)
|
||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
|
version: 3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
|
||||||
@@ -2299,6 +2305,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.6:
|
||||||
|
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
napi-postinstall@0.3.4:
|
napi-postinstall@0.3.4:
|
||||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -2542,6 +2553,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qr.js@0.0.0:
|
||||||
|
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -2556,6 +2570,11 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-qr-code@2.0.18:
|
||||||
|
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react-redux@9.2.0:
|
react-redux@9.2.0:
|
||||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5358,6 +5377,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
nanoid@5.1.6: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@@ -5573,6 +5594,8 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qr.js@0.0.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
react-dom@19.2.3(react@19.2.3):
|
react-dom@19.2.3(react@19.2.3):
|
||||||
@@ -5584,6 +5607,12 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-qr-code@2.0.18(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
prop-types: 15.8.1
|
||||||
|
qr.js: 0.0.0
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/use-sync-external-store': 0.0.6
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
|||||||
39
public/sw.js
39
public/sw.js
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v14-offline';
|
const CACHE_NAME = 'whisky-vault-v19-offline';
|
||||||
|
|
||||||
// CONFIG: Assets
|
// CONFIG: Assets
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -7,6 +7,16 @@ const STATIC_ASSETS = [
|
|||||||
'/icon-512.png',
|
'/icon-512.png',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/lib/browser-image-compression.js',
|
'/lib/browser-image-compression.js',
|
||||||
|
// Tesseract OCR files for offline scanning (ALL variants for browser compatibility)
|
||||||
|
'/tessdata/worker.min.js',
|
||||||
|
'/tessdata/tesseract-core.wasm.js',
|
||||||
|
'/tessdata/tesseract-core-simd.wasm.js',
|
||||||
|
'/tessdata/tesseract-core-lstm.wasm.js',
|
||||||
|
'/tessdata/tesseract-core-simd-lstm.wasm.js',
|
||||||
|
'/tessdata/tesseract-core-relaxedsimd.wasm.js',
|
||||||
|
'/tessdata/tesseract-core-relaxedsimd-lstm.wasm.js',
|
||||||
|
'/tessdata/eng.traineddata',
|
||||||
|
'/tessdata/eng.traineddata.gz',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
@@ -55,7 +65,7 @@ self.addEventListener('install', (event) => {
|
|||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then(async (cache) => {
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
console.log('🏗️ PWA: Building Offline-Modus v13...');
|
console.log(`🏗️ PWA: Building Offline-Modus ${CACHE_NAME}...`);
|
||||||
|
|
||||||
const items = [...STATIC_ASSETS, ...CORE_PAGES];
|
const items = [...STATIC_ASSETS, ...CORE_PAGES];
|
||||||
const total = items.length;
|
const total = items.length;
|
||||||
@@ -153,6 +163,31 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📦 Tesseract OCR files and static libs - CACHE FIRST (critical for offline)
|
||||||
|
if (url.pathname.startsWith('/tessdata/') || url.pathname.startsWith('/lib/')) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log(`[SW] Serving from cache: ${url.pathname}`);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
// Try network, cache the result
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetchWithTimeout(event.request, 30000);
|
||||||
|
if (networkResponse && networkResponse.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(event.request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SW] Failed to fetch ${url.pathname}:`, error);
|
||||||
|
return new Response('File not available offline', { status: 503 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation & Assets
|
// Navigation & Assets
|
||||||
const isNavigation = event.request.mode === 'navigate';
|
const isNavigation = event.request.mode === 'navigate';
|
||||||
const isAsset = event.request.destination === 'style' ||
|
const isAsset = event.request.destination === 'style' ||
|
||||||
|
|||||||
3
public/tessdata/worker.min.js
vendored
Normal file
3
public/tessdata/worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -98,9 +98,17 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for collection updates (e.g., after offline sync completes)
|
||||||
|
const handleCollectionUpdated = () => {
|
||||||
|
console.log('[Home] Collection update event received, refreshing...');
|
||||||
|
fetchCollection();
|
||||||
|
};
|
||||||
|
window.addEventListener('collection-updated', handleCollectionUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
window.removeEventListener('collection-updated', handleCollectionUpdated);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight, Play, Square } from 'lucide-react';
|
import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight, Play, Square, Zap } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import AvatarStack from '@/components/AvatarStack';
|
import AvatarStack from '@/components/AvatarStack';
|
||||||
import { deleteSession } from '@/services/delete-session';
|
import { deleteSession } from '@/services/delete-session';
|
||||||
@@ -13,6 +13,8 @@ import { useI18n } from '@/i18n/I18nContext';
|
|||||||
import SessionTimeline from '@/components/SessionTimeline';
|
import SessionTimeline from '@/components/SessionTimeline';
|
||||||
import SessionABVCurve from '@/components/SessionABVCurve';
|
import SessionABVCurve from '@/components/SessionABVCurve';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
|
import BulkScanSheet from '@/components/BulkScanSheet';
|
||||||
|
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,6 +46,7 @@ interface SessionTasting {
|
|||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
abv: number;
|
abv: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
processing_status?: string;
|
||||||
};
|
};
|
||||||
tasting_tags: {
|
tasting_tags: {
|
||||||
tags: {
|
tags: {
|
||||||
@@ -66,9 +69,31 @@ export default function SessionDetailPage() {
|
|||||||
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSessionData();
|
fetchSessionData();
|
||||||
|
|
||||||
|
// Subscribe to bottle updates for realtime processing status
|
||||||
|
const channel = supabase
|
||||||
|
.channel('bottle-updates')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'UPDATE', schema: 'public', table: 'bottles' },
|
||||||
|
(payload) => {
|
||||||
|
// Refresh if a bottle's processing_status changed
|
||||||
|
if (payload.new && payload.old) {
|
||||||
|
if (payload.new.processing_status !== payload.old.processing_status) {
|
||||||
|
fetchSessionData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const fetchSessionData = async () => {
|
const fetchSessionData = async () => {
|
||||||
@@ -102,7 +127,7 @@ export default function SessionDetailPage() {
|
|||||||
id,
|
id,
|
||||||
rating,
|
rating,
|
||||||
tasted_at,
|
tasted_at,
|
||||||
bottles(id, name, distillery, image_url, abv, category),
|
bottles(id, name, distillery, image_url, abv, category, processing_status),
|
||||||
tasting_tags(tags(name))
|
tasting_tags(tags(name))
|
||||||
`)
|
`)
|
||||||
.eq('session_id', id)
|
.eq('session_id', id)
|
||||||
@@ -388,14 +413,25 @@ export default function SessionDetailPage() {
|
|||||||
<GlassWater size={16} className="text-orange-600" />
|
<GlassWater size={16} className="text-orange-600" />
|
||||||
Verkostete Flaschen
|
Verkostete Flaschen
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!session.ended_at && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBulkScanOpen(true)}
|
||||||
|
className="bg-zinc-800 hover:bg-zinc-700 text-orange-500 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700"
|
||||||
|
>
|
||||||
|
<Zap size={16} />
|
||||||
|
Bulk Scan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/?session_id=${id}`} // Redirect to home with context
|
href={`/?session_id=${id}`}
|
||||||
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20"
|
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Flasche hinzufügen
|
Flasche
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SessionTimeline
|
<SessionTimeline
|
||||||
tastings={tastings.map(t => ({
|
tastings={tastings.map(t => ({
|
||||||
@@ -412,8 +448,20 @@ export default function SessionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
</main >
|
|
||||||
|
{/* Bulk Scan Sheet */}
|
||||||
|
<BulkScanSheet
|
||||||
|
isOpen={isBulkScanOpen}
|
||||||
|
onClose={() => setIsBulkScanOpen(false)}
|
||||||
|
sessionId={id as string}
|
||||||
|
sessionName={session.name}
|
||||||
|
onSuccess={(bottleIds) => {
|
||||||
|
setIsBulkScanOpen(false);
|
||||||
|
fetchSessionData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
src/components/BottleSkeletonCard.tsx
Normal file
94
src/components/BottleSkeletonCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Sparkles, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BottleSkeletonCardProps {
|
||||||
|
name?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
processingStatus: 'pending' | 'analyzing' | 'error';
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottleSkeletonCard({
|
||||||
|
name,
|
||||||
|
imageUrl,
|
||||||
|
processingStatus,
|
||||||
|
onClick
|
||||||
|
}: BottleSkeletonCardProps) {
|
||||||
|
const isError = processingStatus === 'error';
|
||||||
|
const isPending = processingStatus === 'pending';
|
||||||
|
const isAnalyzing = processingStatus === 'analyzing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`relative bg-zinc-900 rounded-2xl border overflow-hidden cursor-pointer transition-all ${isError
|
||||||
|
? 'border-red-500/50 hover:border-red-500'
|
||||||
|
: 'border-zinc-800 hover:border-orange-600/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Bottle preview"
|
||||||
|
className={`w-full h-full object-cover ${isError ? 'opacity-50 grayscale' : 'opacity-60'}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="w-16 h-24 bg-zinc-800 rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processing Overlay */}
|
||||||
|
<div className={`absolute inset-0 flex flex-col items-center justify-center ${isError ? 'bg-red-950/50' : 'bg-black/40'
|
||||||
|
}`}>
|
||||||
|
{isError ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle size={32} className="text-red-500 mb-2" />
|
||||||
|
<span className="text-xs font-bold text-red-400">Fehler</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 size={28} className="text-orange-500 animate-spin mb-2" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={28} className="text-orange-500 animate-pulse mb-2" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-bold text-zinc-300">
|
||||||
|
{isPending ? 'Warten...' : 'KI analysiert...'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className={`absolute top-2 right-2 px-2 py-1 rounded-lg text-[9px] font-black uppercase tracking-widest ${isError
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: isAnalyzing
|
||||||
|
? 'bg-orange-500/20 text-orange-400'
|
||||||
|
: 'bg-zinc-800/80 text-zinc-500'
|
||||||
|
}`}>
|
||||||
|
{isError ? 'Fehler' : isAnalyzing ? '✨ Analyse' : 'Warte'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Skeleton Name */}
|
||||||
|
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" />
|
||||||
|
|
||||||
|
{/* Skeleton Details */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" />
|
||||||
|
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
src/components/BuddyHandshake.tsx
Normal file
296
src/components/BuddyHandshake.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, QrCode, Keyboard, Loader2, CheckCircle2, Copy, RefreshCw, Link2 } from 'lucide-react';
|
||||||
|
import QRCode from 'react-qr-code';
|
||||||
|
import { generateBuddyCode, redeemBuddyCode, revokeBuddyCode } from '@/services/buddy-link';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
interface BuddyHandshakeProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'show' | 'enter';
|
||||||
|
|
||||||
|
export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHandshakeProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('show');
|
||||||
|
|
||||||
|
// Show Code Tab State
|
||||||
|
const [code, setCode] = useState<string | null>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Enter Code Tab State
|
||||||
|
const [inputCode, setInputCode] = useState('');
|
||||||
|
const [isRedeeming, setIsRedeeming] = useState(false);
|
||||||
|
const [redeemError, setRedeemError] = useState<string | null>(null);
|
||||||
|
const [redeemSuccess, setRedeemSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Generate code when showing "Show Code" tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && activeTab === 'show' && !code) {
|
||||||
|
handleGenerateCode();
|
||||||
|
}
|
||||||
|
}, [isOpen, activeTab]);
|
||||||
|
|
||||||
|
// Reset state when closing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setCode(null);
|
||||||
|
setInputCode('');
|
||||||
|
setRedeemError(null);
|
||||||
|
setRedeemSuccess(null);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleGenerateCode = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
const result = await generateBuddyCode();
|
||||||
|
if (result.success && result.code) {
|
||||||
|
setCode(result.code);
|
||||||
|
}
|
||||||
|
setIsGenerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshCode = async () => {
|
||||||
|
await revokeBuddyCode();
|
||||||
|
setCode(null);
|
||||||
|
handleGenerateCode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = () => {
|
||||||
|
if (code) {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedeemCode = async () => {
|
||||||
|
if (!inputCode.trim()) return;
|
||||||
|
|
||||||
|
setIsRedeeming(true);
|
||||||
|
setRedeemError(null);
|
||||||
|
setRedeemSuccess(null);
|
||||||
|
|
||||||
|
const result = await redeemBuddyCode(inputCode);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setRedeemSuccess(`Verbunden mit ${result.buddyName}!`);
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
setRedeemError(result.error || 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRedeeming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCodeForDisplay = (code: string) => {
|
||||||
|
// Format as XXX-XXX for better readability
|
||||||
|
return `${code.slice(0, 3)}-${code.slice(3)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
||||||
|
<Link2 size={20} className="text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Account verbinden</h2>
|
||||||
|
<p className="text-xs text-zinc-500">Buddy-Handshake</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('show')}
|
||||||
|
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'show'
|
||||||
|
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<QrCode size={14} />
|
||||||
|
Mein Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('enter')}
|
||||||
|
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'enter'
|
||||||
|
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Keyboard size={14} />
|
||||||
|
Code eingeben
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'show' ? (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
{isGenerating ? (
|
||||||
|
<div className="py-12 flex flex-col items-center gap-4">
|
||||||
|
<Loader2 size={32} className="animate-spin text-orange-500" />
|
||||||
|
<p className="text-sm text-zinc-500">Generiere Code...</p>
|
||||||
|
</div>
|
||||||
|
) : code ? (
|
||||||
|
<>
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white p-4 rounded-2xl">
|
||||||
|
<QRCode
|
||||||
|
value={`dramlog://buddy/${code}`}
|
||||||
|
size={180}
|
||||||
|
level="M"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Code */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<p className="text-xs text-zinc-500 uppercase tracking-widest">Oder Code teilen:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-3xl font-black tracking-[0.3em] text-white font-mono">
|
||||||
|
{formatCodeForDisplay(code)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyCode}
|
||||||
|
className={`p-2 rounded-lg transition-all ${copied
|
||||||
|
? 'bg-green-500/20 text-green-500'
|
||||||
|
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? <CheckCircle2 size={18} /> : <Copy size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer/Refresh */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
||||||
|
<span>Gültig für 15 Minuten</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshCode}
|
||||||
|
className="flex items-center gap-1.5 text-orange-500 hover:text-orange-400"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
Neuer Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-zinc-500">
|
||||||
|
<p>Fehler beim Generieren</p>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateCode}
|
||||||
|
className="mt-4 text-orange-500 hover:underline"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<p className="text-sm text-zinc-400 text-center">
|
||||||
|
Gib den 6-stelligen Code deines Buddies ein:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Code Input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
if (val.length <= 6) {
|
||||||
|
setInputCode(val);
|
||||||
|
setRedeemError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="XXXXXX"
|
||||||
|
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{redeemError && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-sm text-red-500 text-center bg-red-500/10 py-2 px-4 rounded-xl"
|
||||||
|
>
|
||||||
|
{redeemError}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{redeemSuccess && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex items-center justify-center gap-2 text-green-500 bg-green-500/10 py-3 px-4 rounded-xl"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={20} />
|
||||||
|
<span className="font-bold">{redeemSuccess}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleRedeemCode}
|
||||||
|
disabled={inputCode.length !== 6 || isRedeeming || !!redeemSuccess}
|
||||||
|
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isRedeeming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Verbinde...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2 size={18} />
|
||||||
|
Verbinden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Users, UserPlus, Trash2, User, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { addBuddy, deleteBuddy } from '@/services/buddy';
|
import { addBuddy, deleteBuddy } from '@/services/buddy';
|
||||||
|
import BuddyHandshake from './BuddyHandshake';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +26,7 @@ export default function BuddyList() {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBuddies();
|
fetchBuddies();
|
||||||
@@ -117,6 +119,17 @@ export default function BuddyList() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Link Account Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHandshakeOpen(true)}
|
||||||
|
className="w-full mb-6 py-3 bg-zinc-950 hover:bg-zinc-800 border border-zinc-800 hover:border-orange-600/50 rounded-2xl transition-all flex items-center justify-center gap-2 group"
|
||||||
|
>
|
||||||
|
<Link2 size={16} className="text-orange-600" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-orange-500 transition-colors">
|
||||||
|
Account verbinden
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8 text-zinc-500">
|
<div className="flex justify-center py-8 text-zinc-500">
|
||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={24} className="animate-spin" />
|
||||||
@@ -173,6 +186,16 @@ export default function BuddyList() {
|
|||||||
<span className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest ml-1">{buddies.length} Buddies</span>
|
<span className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest ml-1">{buddies.length} Buddies</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Buddy Handshake Dialog */}
|
||||||
|
<BuddyHandshake
|
||||||
|
isOpen={isHandshakeOpen}
|
||||||
|
onClose={() => setIsHandshakeOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsHandshakeOpen(false);
|
||||||
|
fetchBuddies();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
280
src/components/BulkScanSheet.tsx
Normal file
280
src/components/BulkScanSheet.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Camera, Trash2, Send, Loader2, Check, AlertCircle, Zap } from 'lucide-react';
|
||||||
|
import { useBulkScanner } from '@/hooks/useBulkScanner';
|
||||||
|
|
||||||
|
interface BulkScanSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
onSuccess?: (bottleIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkScanSheet({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
sessionId,
|
||||||
|
sessionName,
|
||||||
|
onSuccess
|
||||||
|
}: BulkScanSheetProps) {
|
||||||
|
const { queue, addToQueue, removeFromQueue, clearQueue, submitToSession, isSubmitting, progress } = useBulkScanner();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const [isCameraReady, setIsCameraReady] = useState(false);
|
||||||
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Start camera when sheet opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
startCamera();
|
||||||
|
} else {
|
||||||
|
stopCamera();
|
||||||
|
}
|
||||||
|
return () => stopCamera();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
setCameraError(null);
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment',
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
streamRef.current = stream;
|
||||||
|
setIsCameraReady(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Camera error:', error);
|
||||||
|
setCameraError('Kamera konnte nicht gestartet werden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
setIsCameraReady(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureImage = useCallback(() => {
|
||||||
|
if (!videoRef.current || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
addToQueue(blob, `Flasche #${queue.length + 1}`);
|
||||||
|
}
|
||||||
|
}, 'image/webp', 0.85);
|
||||||
|
}, [addToQueue, queue.length]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitError(null);
|
||||||
|
const result = await submitToSession(sessionId);
|
||||||
|
|
||||||
|
if (result.success && result.bottleIds) {
|
||||||
|
onSuccess?.(result.bottleIds);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setSubmitError(result.error || 'Fehler beim Hochladen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (queue.length > 0 && !isSubmitting) {
|
||||||
|
if (!confirm('Warteschlange verwerfen?')) return;
|
||||||
|
}
|
||||||
|
clearQueue();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black z-50 flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
||||||
|
<Zap size={20} className="text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-white uppercase tracking-widest">Bulk Scan</h2>
|
||||||
|
<p className="text-xs text-zinc-500">{sessionName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera View */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{cameraError ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
|
||||||
|
<div className="text-center text-zinc-500">
|
||||||
|
<AlertCircle size={48} className="mx-auto mb-4 text-red-500" />
|
||||||
|
<p>{cameraError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{!isCameraReady && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
|
||||||
|
<Loader2 size={32} className="animate-spin text-orange-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden canvas for capture */}
|
||||||
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Capture Button */}
|
||||||
|
{isCameraReady && (
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||||
|
<button
|
||||||
|
onClick={captureImage}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-20 h-20 bg-white rounded-full flex items-center justify-center shadow-2xl active:scale-95 transition-transform disabled:opacity-50 ring-4 ring-white/20"
|
||||||
|
>
|
||||||
|
<Camera size={32} className="text-zinc-900" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queue Counter Badge */}
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="absolute top-4 right-4 bg-orange-600 text-white text-lg font-black px-4 py-2 rounded-full shadow-lg"
|
||||||
|
>
|
||||||
|
{queue.length}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Strip */}
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
className="bg-zinc-900 border-t border-zinc-800 p-4"
|
||||||
|
>
|
||||||
|
{/* Thumbnails */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-3 scrollbar-none">
|
||||||
|
{queue.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.tempId}
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="relative shrink-0"
|
||||||
|
>
|
||||||
|
<div className={`w-16 h-20 rounded-xl overflow-hidden ring-2 ${item.status === 'done' ? 'ring-green-500' :
|
||||||
|
item.status === 'error' ? 'ring-red-500' :
|
||||||
|
item.status === 'uploading' ? 'ring-orange-500 animate-pulse' :
|
||||||
|
'ring-zinc-700'
|
||||||
|
}`}>
|
||||||
|
<img
|
||||||
|
src={item.previewUrl}
|
||||||
|
alt={`Bottle ${i + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{item.status === 'uploading' && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<Loader2 size={16} className="animate-spin text-orange-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.status === 'done' && (
|
||||||
|
<div className="absolute inset-0 bg-green-500/30 flex items-center justify-center">
|
||||||
|
<Check size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isSubmitting && item.status === 'queued' && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeFromQueue(item.tempId)}
|
||||||
|
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<X size={12} className="text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{submitError && (
|
||||||
|
<div className="mb-3 text-sm text-red-500 text-center bg-red-500/10 py-2 rounded-xl">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || queue.length === 0}
|
||||||
|
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Hochladen {progress.current}/{progress.total}...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={18} />
|
||||||
|
{queue.length} Flasche{queue.length !== 1 ? 'n' : ''} zur Session hinzufügen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Camera } from 'lucide-react';
|
import { Camera, Zap } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useSession } from '@/context/SessionContext';
|
||||||
|
import BulkScanSheet from './BulkScanSheet';
|
||||||
|
|
||||||
interface FloatingScannerButtonProps {
|
interface FloatingScannerButtonProps {
|
||||||
onImageSelected: (base64Image: string) => void;
|
onImageSelected: (base64Image: string) => void;
|
||||||
@@ -10,6 +12,9 @@ interface FloatingScannerButtonProps {
|
|||||||
|
|
||||||
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
|
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const { activeSession } = useSession();
|
||||||
|
const [isBulkOpen, setIsBulkOpen] = useState(false);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -23,7 +28,16 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMainClick = () => {
|
||||||
|
if (activeSession) {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
} else {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -32,15 +46,58 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Expanded Options */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && activeSession && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||||
|
className="absolute bottom-20 left-1/2 -translate-x-1/2 flex gap-3"
|
||||||
|
>
|
||||||
|
{/* Single Scan */}
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-2xl shadow-xl"
|
||||||
|
>
|
||||||
|
<Camera size={24} className="text-white" />
|
||||||
|
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Einzel</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Bulk Scan */}
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
setIsBulkOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-orange-600 rounded-2xl shadow-xl shadow-orange-950/30"
|
||||||
|
>
|
||||||
|
<Zap size={24} className="text-white" />
|
||||||
|
<span className="text-[10px] font-bold text-white/80 uppercase tracking-widest">Bulk</span>
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Main Button */}
|
||||||
|
<motion.button
|
||||||
|
onClick={handleMainClick}
|
||||||
whileHover={{ scale: 1.1, translateY: -4 }}
|
whileHover={{ scale: 1.1, translateY: -4 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
initial={{ y: 100, opacity: 0 }}
|
initial={{ y: 100, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1, rotate: isExpanded ? 45 : 0 }}
|
||||||
className="relative group p-6 rounded-full bg-orange-600 text-black shadow-lg shadow-orange-950/40 hover:shadow-orange-950/60 transition-all overflow-hidden"
|
className="relative group p-6 rounded-full bg-orange-600 text-black shadow-lg shadow-orange-950/40 hover:shadow-orange-950/60 transition-all overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Shine Animation */}
|
{/* Shine Animation */}
|
||||||
|
{!isExpanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
x: ['-100%', '100%'],
|
x: ['-100%', '100%'],
|
||||||
@@ -53,12 +110,34 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
|
|||||||
}}
|
}}
|
||||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
|
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
|
||||||
|
|
||||||
|
{/* Active Session Indicator */}
|
||||||
|
{activeSession && !isExpanded && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-orange-600 flex items-center justify-center">
|
||||||
|
<Zap size={10} className="text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pulse ring */}
|
{/* Pulse ring */}
|
||||||
|
{!isExpanded && (
|
||||||
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
|
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
|
||||||
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Scan Sheet */}
|
||||||
|
{activeSession && (
|
||||||
|
<BulkScanSheet
|
||||||
|
isOpen={isBulkOpen}
|
||||||
|
onClose={() => setIsBulkOpen(false)}
|
||||||
|
sessionId={activeSession.id}
|
||||||
|
sessionName={activeSession.name}
|
||||||
|
onSuccess={() => setIsBulkOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useEffect } from 'react';
|
|||||||
export default function PWARegistration() {
|
export default function PWARegistration() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
// Register immediately - the page is already loaded when this component mounts
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js')
|
.register('/sw.js')
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log('SW registered: ', registration);
|
console.log('[PWA] SW registered:', registration.scope);
|
||||||
|
|
||||||
// Check for updates
|
// Check for updates
|
||||||
registration.onupdatefound = () => {
|
registration.onupdatefound = () => {
|
||||||
@@ -18,17 +18,16 @@ export default function PWARegistration() {
|
|||||||
installingWorker.onstatechange = () => {
|
installingWorker.onstatechange = () => {
|
||||||
if (installingWorker.state === 'installed') {
|
if (installingWorker.state === 'installed') {
|
||||||
if (navigator.serviceWorker.controller) {
|
if (navigator.serviceWorker.controller) {
|
||||||
console.log('[SW] New content is available; please refresh.');
|
console.log('[PWA] New content available; please refresh.');
|
||||||
} else {
|
} else {
|
||||||
console.log('[SW] Content is cached for offline use.');
|
console.log('[PWA] Content cached for offline use.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((registrationError) => {
|
.catch((registrationError) => {
|
||||||
console.log('SW registration failed: ', registrationError);
|
console.error('[PWA] SW registration failed:', registrationError);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ export default function UploadQueue() {
|
|||||||
syncInProgress.current = false;
|
syncInProgress.current = false;
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
setCurrentProgress(null);
|
setCurrentProgress(null);
|
||||||
|
|
||||||
|
// Dispatch event to notify that sync is complete and collection should be refreshed
|
||||||
|
window.dispatchEvent(new CustomEvent('collection-updated'));
|
||||||
}
|
}
|
||||||
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
|
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
|
||||||
|
|
||||||
|
|||||||
152
src/hooks/useBulkScanner.ts
Normal file
152
src/hooks/useBulkScanner.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { processBulkScan } from '@/services/bulk-scan';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export interface QueuedBottle {
|
||||||
|
tempId: string;
|
||||||
|
blob: Blob;
|
||||||
|
previewUrl: string;
|
||||||
|
status: 'queued' | 'uploading' | 'done' | 'error';
|
||||||
|
ocrPreview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBulkScannerReturn {
|
||||||
|
queue: QueuedBottle[];
|
||||||
|
addToQueue: (blob: Blob, ocrHint?: string) => void;
|
||||||
|
removeFromQueue: (tempId: string) => void;
|
||||||
|
clearQueue: () => void;
|
||||||
|
submitToSession: (sessionId: string) => Promise<{ success: boolean; bottleIds?: string[]; error?: string }>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
progress: { current: number; total: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkScanner(): UseBulkScannerReturn {
|
||||||
|
const [queue, setQueue] = useState<QueuedBottle[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
|
|
||||||
|
// Track blob URLs for cleanup
|
||||||
|
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const addToQueue = useCallback((blob: Blob, ocrHint?: string) => {
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
blobUrlsRef.current.add(previewUrl);
|
||||||
|
|
||||||
|
const newItem: QueuedBottle = {
|
||||||
|
tempId: nanoid(),
|
||||||
|
blob,
|
||||||
|
previewUrl,
|
||||||
|
status: 'queued',
|
||||||
|
ocrPreview: ocrHint,
|
||||||
|
};
|
||||||
|
|
||||||
|
setQueue(prev => [...prev, newItem]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFromQueue = useCallback((tempId: string) => {
|
||||||
|
setQueue(prev => {
|
||||||
|
const item = prev.find(i => i.tempId === tempId);
|
||||||
|
if (item) {
|
||||||
|
URL.revokeObjectURL(item.previewUrl);
|
||||||
|
blobUrlsRef.current.delete(item.previewUrl);
|
||||||
|
}
|
||||||
|
return prev.filter(i => i.tempId !== tempId);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearQueue = useCallback(() => {
|
||||||
|
// Revoke all blob URLs
|
||||||
|
queue.forEach(item => {
|
||||||
|
URL.revokeObjectURL(item.previewUrl);
|
||||||
|
});
|
||||||
|
blobUrlsRef.current.clear();
|
||||||
|
setQueue([]);
|
||||||
|
}, [queue]);
|
||||||
|
|
||||||
|
const submitToSession = useCallback(async (sessionId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
bottleIds?: string[];
|
||||||
|
error?: string
|
||||||
|
}> => {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return { success: false, error: 'Keine Flaschen in der Warteschlange' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setProgress({ current: 0, total: queue.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert all blobs to base64 data URLs
|
||||||
|
const imageDataUrls: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < queue.length; i++) {
|
||||||
|
const item = queue[i];
|
||||||
|
|
||||||
|
// Update status to uploading
|
||||||
|
setQueue(prev => prev.map(q =>
|
||||||
|
q.tempId === item.tempId
|
||||||
|
? { ...q, status: 'uploading' as const }
|
||||||
|
: q
|
||||||
|
));
|
||||||
|
|
||||||
|
// Convert blob to data URL
|
||||||
|
const dataUrl = await blobToDataUrl(item.blob);
|
||||||
|
imageDataUrls.push(dataUrl);
|
||||||
|
|
||||||
|
setProgress({ current: i + 1, total: queue.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit all to server
|
||||||
|
const result = await processBulkScan(sessionId, imageDataUrls);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mark all as done
|
||||||
|
setQueue(prev => prev.map(q => ({ ...q, status: 'done' as const })));
|
||||||
|
|
||||||
|
// Clear queue after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
clearQueue();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return { success: true, bottleIds: result.bottleIds };
|
||||||
|
} else {
|
||||||
|
// Mark all as error
|
||||||
|
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submit error:', error);
|
||||||
|
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unbekannter Fehler'
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [queue, clearQueue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queue,
|
||||||
|
addToQueue,
|
||||||
|
removeFromQueue,
|
||||||
|
clearQueue,
|
||||||
|
submitToSession,
|
||||||
|
isSubmitting,
|
||||||
|
progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Blob to base64 data URL
|
||||||
|
*/
|
||||||
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -150,11 +150,13 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
const online = isOnline();
|
const online = isOnline();
|
||||||
const tesseractReady = await isTesseractReady();
|
const tesseractReady = await isTesseractReady();
|
||||||
|
|
||||||
// If offline and tesseract not ready, queue immediately
|
// If offline and tesseract not ready, show editor with dummy data
|
||||||
|
// Queue image for later processing when back online
|
||||||
if (!online && !tesseractReady) {
|
if (!online && !tesseractReady) {
|
||||||
console.log('[useScanner] Offline + no tesseract cache → queuing');
|
console.log('[useScanner] Offline + no tesseract cache → showing editor with dummy data');
|
||||||
const dummyMetadata = generateDummyMetadata(file);
|
const dummyMetadata = generateDummyMetadata(file);
|
||||||
|
|
||||||
|
// Queue for later processing
|
||||||
await db.pending_scans.add({
|
await db.pending_scans.add({
|
||||||
temp_id: `temp_${Date.now()}`,
|
temp_id: `temp_${Date.now()}`,
|
||||||
imageBase64: processedImage.base64,
|
imageBase64: processedImage.base64,
|
||||||
@@ -163,9 +165,10 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
metadata: dummyMetadata as any,
|
metadata: dummyMetadata as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show editor with dummy data (status: complete so editor opens!)
|
||||||
setResult(prev => ({
|
setResult(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
status: 'queued',
|
status: 'complete',
|
||||||
mergedResult: dummyMetadata,
|
mergedResult: dummyMetadata,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ const distilleryFuse = new Fuse(distilleries, fuseOptions);
|
|||||||
// Tesseract worker singleton (reused across scans)
|
// Tesseract worker singleton (reused across scans)
|
||||||
let tesseractWorker: Tesseract.Worker | null = null;
|
let tesseractWorker: Tesseract.Worker | null = null;
|
||||||
|
|
||||||
// Character whitelist for whisky labels (no special symbols that cause noise)
|
// Character whitelist for whisky labels ("Pattern Hack")
|
||||||
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%.,:\'"-/ ';
|
// Restricts Tesseract to only whisky-relevant characters:
|
||||||
|
// - Letters: A-Z, a-z
|
||||||
|
// - Numbers: 0-9
|
||||||
|
// - Essential punctuation: .,%&-/ (for ABV "46.5%", names like "No. 1")
|
||||||
|
// - Space: for word separation
|
||||||
|
// This prevents garbage like ~, _, ^, {, § from appearing
|
||||||
|
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,%&-/ ';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize or get the Tesseract worker
|
* Initialize or get the Tesseract worker
|
||||||
@@ -54,8 +60,9 @@ async function getWorker(): Promise<Tesseract.Worker> {
|
|||||||
|
|
||||||
console.log('[LocalOCR] Initializing Tesseract worker with local files...');
|
console.log('[LocalOCR] Initializing Tesseract worker with local files...');
|
||||||
|
|
||||||
// Use local files from /public/tessdata
|
// Use local files from /public/tessdata for full offline support
|
||||||
tesseractWorker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
|
tesseractWorker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
|
||||||
|
workerPath: '/tessdata/worker.min.js', // Local worker for offline
|
||||||
corePath: '/tessdata/',
|
corePath: '/tessdata/',
|
||||||
langPath: '/tessdata/',
|
langPath: '/tessdata/',
|
||||||
logger: (m) => {
|
logger: (m) => {
|
||||||
@@ -215,27 +222,46 @@ function findDistillery(text: string): { name: string; region: string; contextua
|
|||||||
|
|
||||||
console.log('[LocalOCR] Lines for distillery matching:', lines.length);
|
console.log('[LocalOCR] Lines for distillery matching:', lines.length);
|
||||||
|
|
||||||
// Try to match each line
|
// Blacklist common whisky words that shouldn't match distillery names
|
||||||
|
const blacklistedWords = new Set([
|
||||||
|
'reserve', 'malt', 'single', 'whisky', 'whiskey', 'scotch', 'bourbon',
|
||||||
|
'blended', 'irish', 'aged', 'years', 'edition', 'cask', 'barrel',
|
||||||
|
'distillery', 'vintage', 'special', 'limited', 'rare', 'old', 'gold',
|
||||||
|
'spirit', 'spirits', 'proof', 'strength', 'batch', 'select', 'finish'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to match each line using sliding word windows
|
||||||
for (const originalLine of lines) {
|
for (const originalLine of lines) {
|
||||||
// STRIP & MATCH: Remove numbers for cleaner Fuse matching
|
// STRIP & MATCH: Remove numbers for cleaner Fuse matching
|
||||||
// "Bad N NEVIS 27" → "Bad N NEVIS "
|
|
||||||
const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
|
const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
if (textOnlyLine.length < 4) continue;
|
if (textOnlyLine.length < 4) continue;
|
||||||
|
|
||||||
const results = distilleryFuse.search(textOnlyLine);
|
// Split into words for window matching
|
||||||
|
const words = textOnlyLine.split(' ').filter(w => w.length >= 2);
|
||||||
|
|
||||||
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.4) {
|
// Try different window sizes (1-3 words) to find distillery within garbage
|
||||||
|
// E.g., "ge OO BEN NEVIS" → try "BEN NEVIS", "OO BEN", "BEN", etc.
|
||||||
|
for (let windowSize = Math.min(3, words.length); windowSize >= 1; windowSize--) {
|
||||||
|
for (let i = 0; i <= words.length - windowSize; i++) {
|
||||||
|
const phrase = words.slice(i, i + windowSize).join(' ');
|
||||||
|
|
||||||
|
if (phrase.length < 4) continue;
|
||||||
|
|
||||||
|
// Skip blacklisted common words
|
||||||
|
if (blacklistedWords.has(phrase.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = distilleryFuse.search(phrase);
|
||||||
|
|
||||||
|
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.3) {
|
||||||
const match = results[0].item;
|
const match = results[0].item;
|
||||||
const matchScore = results[0].score;
|
const matchScore = results[0].score;
|
||||||
|
|
||||||
// SANITY CHECK: The text-only part should be similar length to distillery name
|
// SANITY CHECK: Length ratio should be reasonable (0.6 - 1.5)
|
||||||
// Max 60% deviation allowed (relaxed for partial matches)
|
const lengthRatio = phrase.length / match.name.length;
|
||||||
const lengthRatio = textOnlyLine.length / match.name.length;
|
if (lengthRatio < 0.6 || lengthRatio > 1.5) {
|
||||||
const lengthDeviation = Math.abs(1 - lengthRatio);
|
|
||||||
|
|
||||||
if (lengthDeviation > 0.6) {
|
|
||||||
console.log(`[LocalOCR] Match rejected (length): "${textOnlyLine}" → ${match.name} (ratio: ${lengthRatio.toFixed(2)}, deviation: ${(lengthDeviation * 100).toFixed(0)}%)`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +276,7 @@ function findDistillery(text: string): { name: string; region: string; contextua
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[LocalOCR] Distillery match: "${textOnlyLine}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
|
console.log(`[LocalOCR] Distillery match: "${phrase}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
|
||||||
return {
|
return {
|
||||||
name: match.name,
|
name: match.name,
|
||||||
region: match.region,
|
region: match.region,
|
||||||
@@ -258,6 +284,8 @@ function findDistillery(text: string): { name: string; region: string; contextua
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,31 @@ export async function isTesseractReady(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for the core files in cache (matching actual file names in /public/tessdata)
|
// Check for the core files in cache
|
||||||
const wasmMatch = await window.caches.match('/tessdata/tesseract-core-simd.wasm');
|
// Try to find files in any cache (not just default)
|
||||||
const langMatch = await window.caches.match('/tessdata/eng.traineddata');
|
const cacheNames = await caches.keys();
|
||||||
|
console.log('[Scanner] Available caches:', cacheNames);
|
||||||
|
|
||||||
const ready = !!(wasmMatch && langMatch);
|
let wasmMatch = false;
|
||||||
console.log('[Scanner] Offline cache check:', { wasmMatch: !!wasmMatch, langMatch: !!langMatch, ready });
|
let langMatch = false;
|
||||||
|
|
||||||
|
for (const cacheName of cacheNames) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
for (const request of keys) {
|
||||||
|
const url = request.url;
|
||||||
|
if (url.includes('tesseract-core') && url.includes('.wasm')) {
|
||||||
|
wasmMatch = true;
|
||||||
|
}
|
||||||
|
if (url.includes('eng.traineddata')) {
|
||||||
|
langMatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = wasmMatch && langMatch;
|
||||||
|
console.log('[Scanner] Offline cache check:', { wasmMatch, langMatch, ready, cacheCount: cacheNames.length });
|
||||||
return ready;
|
return ready;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Scanner] Cache check failed:', error);
|
console.warn('[Scanner] Cache check failed:', error);
|
||||||
@@ -58,32 +77,48 @@ export function extractNumbers(text: string): ExtractedNumbers {
|
|||||||
|
|
||||||
if (!text) return result;
|
if (!text) return result;
|
||||||
|
|
||||||
// Normalize text: lowercase, clean up common OCR mistakes
|
// ========== ABV EXTRACTION (Enhanced) ==========
|
||||||
const normalizedText = text
|
// Step 1: Normalize text for common Tesseract OCR mistakes
|
||||||
.replace(/[oO]/g, '0') // Common OCR mistake: O -> 0
|
let normalizedText = text
|
||||||
.replace(/[lI]/g, '1') // Common OCR mistake: l/I -> 1
|
// Fix % misread as numbers or text
|
||||||
.toLowerCase();
|
.replace(/96/g, '%') // Tesseract often reads % as 96
|
||||||
|
.replace(/o\/o/gi, '%') // o/o → %
|
||||||
|
.replace(/°\/o/gi, '%') // °/o → %
|
||||||
|
.replace(/0\/0/g, '%') // 0/0 → %
|
||||||
|
// Fix common letter/number confusions
|
||||||
|
.replace(/[oO](?=\d)/g, '0') // O before digit → 0 (e.g., "O5" → "05")
|
||||||
|
.replace(/(?<=\d)[oO]/g, '0') // O after digit → 0 (e.g., "5O" → "50")
|
||||||
|
.replace(/[lI](?=\d)/g, '1') // l/I before digit → 1
|
||||||
|
.replace(/(?<=\d)[lI]/g, '1') // l/I after digit → 1
|
||||||
|
// Normalize decimal separators
|
||||||
|
.replace(/,/g, '.');
|
||||||
|
|
||||||
// ABV patterns: "43%", "43.5%", "43,5 %", "ABV 43", "vol. 43"
|
// Step 2: ABV patterns - looking for number before % or Vol
|
||||||
const abvPatterns = [
|
const abvPatterns = [
|
||||||
/(\d{2}[.,]\d{1,2})\s*%/, // 43.5% or 43,5 %
|
/(\d{2}\.?\d{0,2})\s*%/, // 43%, 43.5%, 57.1%
|
||||||
/(\d{2})\s*%/, // 43%
|
/(\d{2}\.?\d{0,2})\s*(?:vol|alc)/i, // 43 vol, 43.5 alc
|
||||||
/abv[:\s]*(\d{2}[.,]?\d{0,2})/i, // ABV: 43 or ABV 43.5
|
/(?:abv|alc|vol)[:\s]*(\d{2}\.?\d{0,2})/i, // ABV: 43, vol. 43.5
|
||||||
/vol[.\s]*(\d{2}[.,]?\d{0,2})/i, // vol. 43
|
/(\d{2}\.?\d{0,2})\s*(?:percent|prozent)/i, // 43 percent/prozent
|
||||||
/(\d{2}[.,]\d{1,2})\s*vol/i, // 43.5 vol
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of abvPatterns) {
|
for (const pattern of abvPatterns) {
|
||||||
const match = normalizedText.match(pattern);
|
const match = normalizedText.match(pattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
const value = parseFloat(match[1].replace(',', '.'));
|
const value = parseFloat(match[1]);
|
||||||
if (value >= 35 && value <= 75) { // Reasonable whisky ABV range
|
// STRICT RANGE GUARD: Only accept 35.0 - 75.0
|
||||||
|
// This prevents misidentifying years (1996) or volumes (700ml)
|
||||||
|
if (value >= 35.0 && value <= 75.0) {
|
||||||
result.abv = value;
|
result.abv = value;
|
||||||
|
console.log(`[ABV] Detected: ${value}% from pattern: ${pattern.source}`);
|
||||||
break;
|
break;
|
||||||
|
} else {
|
||||||
|
console.log(`[ABV] Rejected ${value} - outside 35-75 range`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== AGE & VINTAGE (unchanged but use normalized text) ==========
|
||||||
|
|
||||||
// Age patterns: "12 years", "12 year old", "12 YO", "aged 12"
|
// Age patterns: "12 years", "12 year old", "12 YO", "aged 12"
|
||||||
const agePatterns = [
|
const agePatterns = [
|
||||||
/(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i,
|
/(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i,
|
||||||
@@ -156,11 +191,13 @@ export interface PreprocessOptions {
|
|||||||
edgeCrop?: number;
|
edgeCrop?: number;
|
||||||
/** Target height for resizing. Default: 1200 */
|
/** Target height for resizing. Default: 1200 */
|
||||||
targetHeight?: number;
|
targetHeight?: number;
|
||||||
/** Apply binarization (hard black/white). Default: false */
|
/** Apply simple binarization (hard black/white). Default: false */
|
||||||
binarize?: boolean;
|
binarize?: boolean;
|
||||||
|
/** Apply adaptive thresholding (better for uneven lighting). Default: true */
|
||||||
|
adaptiveThreshold?: boolean;
|
||||||
/** Contrast boost factor (1.0 = no change). Default: 1.3 */
|
/** Contrast boost factor (1.0 = no change). Default: 1.3 */
|
||||||
contrastBoost?: number;
|
contrastBoost?: number;
|
||||||
/** Apply sharpening. Default: true */
|
/** Apply sharpening. Default: false */
|
||||||
sharpen?: boolean;
|
sharpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +223,9 @@ export async function preprocessImageForOCR(
|
|||||||
const {
|
const {
|
||||||
edgeCrop = 0.05, // Remove 5% from each edge (minimal)
|
edgeCrop = 0.05, // Remove 5% from each edge (minimal)
|
||||||
targetHeight = 1200, // High resolution
|
targetHeight = 1200, // High resolution
|
||||||
binarize = false, // Don't binarize by default
|
binarize = false, // Simple binarization (global threshold)
|
||||||
contrastBoost = 1.3, // 30% contrast boost
|
adaptiveThreshold = true, // Adaptive thresholding (local threshold) - better for uneven lighting
|
||||||
|
contrastBoost = 1.3, // 30% contrast boost (only if not using adaptive)
|
||||||
sharpen = false, // Disabled - creates noise on photos
|
sharpen = false, // Disabled - creates noise on photos
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -263,9 +301,100 @@ export async function preprocessImageForOCR(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: Apply contrast enhancement
|
// Put processed data back (after grayscale conversion)
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
ctx.putImageData(imageData, 0, 0);
|
||||||
let gray = data[i];
|
|
||||||
|
// Apply adaptive or simple binarization/contrast
|
||||||
|
if (adaptiveThreshold) {
|
||||||
|
// ========== ADAPTIVE THRESHOLDING ==========
|
||||||
|
// Uses integral image for efficient local mean calculation
|
||||||
|
// Better for uneven lighting on curved bottles
|
||||||
|
const adaptiveData = ctx.getImageData(0, 0, newWidth, newHeight);
|
||||||
|
const pixels = adaptiveData.data;
|
||||||
|
|
||||||
|
// Window size: ~1/20th of image width, minimum 11, must be odd
|
||||||
|
let windowSize = Math.max(11, Math.floor(newWidth / 20));
|
||||||
|
if (windowSize % 2 === 0) windowSize++;
|
||||||
|
const halfWindow = Math.floor(windowSize / 2);
|
||||||
|
|
||||||
|
// Sauvola-style constant: lower = more sensitive to text
|
||||||
|
const k = 0.15;
|
||||||
|
|
||||||
|
// Build integral image for fast local sum calculation
|
||||||
|
const integral = new Float64Array((newWidth + 1) * (newHeight + 1));
|
||||||
|
const integralSq = new Float64Array((newWidth + 1) * (newHeight + 1));
|
||||||
|
|
||||||
|
for (let y = 0; y < newHeight; y++) {
|
||||||
|
let rowSum = 0;
|
||||||
|
let rowSumSq = 0;
|
||||||
|
for (let x = 0; x < newWidth; x++) {
|
||||||
|
const idx = (y * newWidth + x) * 4;
|
||||||
|
const gray = pixels[idx];
|
||||||
|
rowSum += gray;
|
||||||
|
rowSumSq += gray * gray;
|
||||||
|
|
||||||
|
const iIdx = (y + 1) * (newWidth + 1) + (x + 1);
|
||||||
|
const iIdxAbove = y * (newWidth + 1) + (x + 1);
|
||||||
|
integral[iIdx] = rowSum + integral[iIdxAbove];
|
||||||
|
integralSq[iIdx] = rowSumSq + integralSq[iIdxAbove];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply adaptive threshold
|
||||||
|
const output = new Uint8ClampedArray(pixels.length);
|
||||||
|
for (let y = 0; y < newHeight; y++) {
|
||||||
|
for (let x = 0; x < newWidth; x++) {
|
||||||
|
// Calculate local window bounds
|
||||||
|
const x1 = Math.max(0, x - halfWindow);
|
||||||
|
const y1 = Math.max(0, y - halfWindow);
|
||||||
|
const x2 = Math.min(newWidth - 1, x + halfWindow);
|
||||||
|
const y2 = Math.min(newHeight - 1, y + halfWindow);
|
||||||
|
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
|
||||||
|
|
||||||
|
// Get local sum and sum of squares using integral image
|
||||||
|
const i11 = y1 * (newWidth + 1) + x1;
|
||||||
|
const i12 = y1 * (newWidth + 1) + (x2 + 1);
|
||||||
|
const i21 = (y2 + 1) * (newWidth + 1) + x1;
|
||||||
|
const i22 = (y2 + 1) * (newWidth + 1) + (x2 + 1);
|
||||||
|
|
||||||
|
const sum = integral[i22] - integral[i21] - integral[i12] + integral[i11];
|
||||||
|
const sumSq = integralSq[i22] - integralSq[i21] - integralSq[i12] + integralSq[i11];
|
||||||
|
|
||||||
|
const mean = sum / count;
|
||||||
|
const variance = (sumSq / count) - (mean * mean);
|
||||||
|
const stddev = Math.sqrt(Math.max(0, variance));
|
||||||
|
|
||||||
|
// Sauvola threshold: T = mean * (1 + k * (stddev/R - 1))
|
||||||
|
// R = dynamic range = 128 for grayscale
|
||||||
|
const threshold = mean * (1 + k * (stddev / 128 - 1));
|
||||||
|
|
||||||
|
const idx = (y * newWidth + x) * 4;
|
||||||
|
const pixel = pixels[idx];
|
||||||
|
const binaryValue = pixel < threshold ? 0 : 255;
|
||||||
|
|
||||||
|
output[idx] = output[idx + 1] = output[idx + 2] = binaryValue;
|
||||||
|
output[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy output back
|
||||||
|
for (let i = 0; i < pixels.length; i++) {
|
||||||
|
pixels[i] = output[i];
|
||||||
|
}
|
||||||
|
ctx.putImageData(adaptiveData, 0, 0);
|
||||||
|
|
||||||
|
console.log('[PreprocessOCR] Adaptive thresholding applied:', {
|
||||||
|
windowSize,
|
||||||
|
k,
|
||||||
|
imageSize: `${newWidth}x${newHeight}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Simple contrast enhancement + optional global binarization
|
||||||
|
const simpleData = ctx.getImageData(0, 0, newWidth, newHeight);
|
||||||
|
const pixels = simpleData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
let gray = pixels[i];
|
||||||
gray = ((gray - 128) * contrastBoost) + 128;
|
gray = ((gray - 128) * contrastBoost) + 128;
|
||||||
gray = Math.min(255, Math.max(0, gray));
|
gray = Math.min(255, Math.max(0, gray));
|
||||||
|
|
||||||
@@ -273,19 +402,18 @@ export async function preprocessImageForOCR(
|
|||||||
gray = gray >= 128 ? 255 : 0;
|
gray = gray >= 128 ? 255 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
data[i] = data[i + 1] = data[i + 2] = gray;
|
pixels[i] = pixels[i + 1] = pixels[i + 2] = gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put processed data back
|
ctx.putImageData(simpleData, 0, 0);
|
||||||
ctx.putImageData(imageData, 0, 0);
|
}
|
||||||
|
|
||||||
console.log('[PreprocessOCR] Image preprocessed:', {
|
console.log('[PreprocessOCR] Image preprocessed:', {
|
||||||
original: `${img.width}x${img.height}`,
|
original: `${img.width}x${img.height}`,
|
||||||
cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`,
|
cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`,
|
||||||
final: `${newWidth}x${newHeight}`,
|
final: `${newWidth}x${newHeight}`,
|
||||||
sharpen,
|
sharpen,
|
||||||
contrastBoost,
|
mode: adaptiveThreshold ? 'adaptive-threshold' : (binarize ? 'binarized' : 'grayscale+contrast'),
|
||||||
mode: binarize ? 'binarized' : 'grayscale',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/png');
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
import { createBrowserClient } from '@supabase/ssr';
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// Use globalThis to persist across HMR reloads in development
|
||||||
|
const globalForSupabase = globalThis as typeof globalThis & {
|
||||||
|
supabaseBrowserClient?: SupabaseClient;
|
||||||
|
};
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
return createBrowserClient(
|
if (globalForSupabase.supabaseBrowserClient) {
|
||||||
|
return globalForSupabase.supabaseBrowserClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use supabase-js directly with isSingleton to suppress the warning
|
||||||
|
globalForSupabase.supabaseBrowserClient = createSupabaseClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
// Suppress "Multiple GoTrueClient instances" warning
|
||||||
|
// This is safe because we use a singleton pattern
|
||||||
|
storageKey: 'sb-auth-token',
|
||||||
|
persistSession: true,
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return globalForSupabase.supabaseBrowserClient;
|
||||||
}
|
}
|
||||||
|
|||||||
252
src/services/buddy-link.ts
Normal file
252
src/services/buddy-link.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
// Generate 6-char uppercase alphanumeric codes (no confusing chars like 0/O, 1/I/L)
|
||||||
|
const generateCode = customAlphabet('ABCDEFGHJKMNPQRSTUVWXYZ23456789', 6);
|
||||||
|
|
||||||
|
const CODE_EXPIRY_MINUTES = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a buddy invite code for the current user.
|
||||||
|
* Deletes any existing expired codes first.
|
||||||
|
*/
|
||||||
|
export async function generateBuddyCode(): Promise<{ success: boolean; code?: string; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete expired codes for this user
|
||||||
|
await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.delete()
|
||||||
|
.eq('creator_id', user.id)
|
||||||
|
.lt('expires_at', new Date().toISOString());
|
||||||
|
|
||||||
|
// Check if user already has an active code
|
||||||
|
const { data: existingCode } = await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.select('code, expires_at')
|
||||||
|
.eq('creator_id', user.id)
|
||||||
|
.gt('expires_at', new Date().toISOString())
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingCode) {
|
||||||
|
// Return existing code
|
||||||
|
return { success: true, code: existingCode.code };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new code
|
||||||
|
const code = generateCode();
|
||||||
|
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.insert({
|
||||||
|
creator_id: user.id,
|
||||||
|
code,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error creating buddy code:', error);
|
||||||
|
return { success: false, error: 'Code konnte nicht erstellt werden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, code };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('generateBuddyCode error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem a buddy invite code and create the buddy relationship.
|
||||||
|
*/
|
||||||
|
export async function redeemBuddyCode(code: string): Promise<{ success: boolean; buddyName?: string; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
|
||||||
|
if (normalizedCode.length !== 6) {
|
||||||
|
return { success: false, error: 'Ungültiger Code-Format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the invite
|
||||||
|
const { data: invite, error: findError } = await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.select('id, creator_id, expires_at')
|
||||||
|
.eq('code', normalizedCode)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (findError || !invite) {
|
||||||
|
return { success: false, error: 'Code nicht gefunden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (new Date(invite.expires_at) < new Date()) {
|
||||||
|
return { success: false, error: 'Code ist abgelaufen' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot buddy yourself
|
||||||
|
if (invite.creator_id === user.id) {
|
||||||
|
return { success: false, error: 'Du kannst dich nicht selbst hinzufügen' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists
|
||||||
|
const { data: existingBuddy } = await supabase
|
||||||
|
.from('buddies')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('buddy_profile_id', invite.creator_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingBuddy) {
|
||||||
|
return { success: false, error: 'Ihr seid bereits verbunden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get creator's profile for the buddy name
|
||||||
|
const { data: creatorProfile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('username')
|
||||||
|
.eq('id', invite.creator_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const buddyName = creatorProfile?.username || 'Buddy';
|
||||||
|
|
||||||
|
// Create buddy relationship (both directions)
|
||||||
|
// 1. Redeemer adds Creator as buddy
|
||||||
|
const { error: buddy1Error } = await supabase
|
||||||
|
.from('buddies')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name: buddyName,
|
||||||
|
buddy_profile_id: invite.creator_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (buddy1Error) {
|
||||||
|
console.error('Error creating buddy (redeemer->creator):', buddy1Error);
|
||||||
|
return { success: false, error: 'Verbindung konnte nicht erstellt werden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Creator adds Redeemer as buddy
|
||||||
|
const { data: redeemerProfile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('username')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const redeemerName = redeemerProfile?.username || 'Buddy';
|
||||||
|
|
||||||
|
// Use service role or direct insert (creator will see it via RLS)
|
||||||
|
const { error: buddy2Error } = await supabase
|
||||||
|
.from('buddies')
|
||||||
|
.insert({
|
||||||
|
user_id: invite.creator_id,
|
||||||
|
name: redeemerName,
|
||||||
|
buddy_profile_id: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (buddy2Error) {
|
||||||
|
console.error('Error creating buddy (creator->redeemer):', buddy2Error);
|
||||||
|
// Don't fail - at least one direction worked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the used invite
|
||||||
|
await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.delete()
|
||||||
|
.eq('id', invite.id);
|
||||||
|
|
||||||
|
// Revalidate paths
|
||||||
|
revalidatePath('/');
|
||||||
|
|
||||||
|
return { success: true, buddyName };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('redeemBuddyCode error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all buddies that are linked to real accounts (have buddy_profile_id).
|
||||||
|
*/
|
||||||
|
export async function getLinkedBuddies(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
buddies?: Array<{ id: string; name: string; buddy_profile_id: string; username?: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('buddies')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
buddy_profile_id,
|
||||||
|
profiles:buddy_profile_id (username)
|
||||||
|
`)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.not('buddy_profile_id', 'is', null)
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching linked buddies:', error);
|
||||||
|
return { success: false, error: 'Fehler beim Laden der Buddies' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buddies = (data || []).map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
buddy_profile_id: b.buddy_profile_id!,
|
||||||
|
username: (b.profiles as any)?.username,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { success: true, buddies };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getLinkedBuddies error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke/cancel an active buddy invite code.
|
||||||
|
*/
|
||||||
|
export async function revokeBuddyCode(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('buddy_invites')
|
||||||
|
.delete()
|
||||||
|
.eq('creator_id', user.id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('revokeBuddyCode error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
390
src/services/bulk-scan.ts
Normal file
390
src/services/bulk-scan.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export interface BulkScanResult {
|
||||||
|
success: boolean;
|
||||||
|
bottleIds?: string[];
|
||||||
|
tastingIds?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process multiple bottle images in bulk for a tasting session.
|
||||||
|
* Creates skeleton bottles immediately and triggers background AI analysis.
|
||||||
|
*
|
||||||
|
* @param sessionId - The tasting session to link bottles to
|
||||||
|
* @param imageDataUrls - Array of base64 image data URLs
|
||||||
|
*/
|
||||||
|
export async function processBulkScan(
|
||||||
|
sessionId: string,
|
||||||
|
imageDataUrls: string[]
|
||||||
|
): Promise<BulkScanResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session exists and belongs to user
|
||||||
|
const { data: session, error: sessionError } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select('id')
|
||||||
|
.eq('id', sessionId)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (sessionError || !session) {
|
||||||
|
return { success: false, error: 'Session nicht gefunden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottleIds: string[] = [];
|
||||||
|
const tastingIds: string[] = [];
|
||||||
|
|
||||||
|
// Process each image
|
||||||
|
for (let i = 0; i < imageDataUrls.length; i++) {
|
||||||
|
const imageDataUrl = imageDataUrls[i];
|
||||||
|
|
||||||
|
// 1. Upload image to Supabase Storage
|
||||||
|
const imageUrl = await uploadImage(supabase, user.id, imageDataUrl);
|
||||||
|
|
||||||
|
// 2. Create skeleton bottle with pending status
|
||||||
|
const { data: bottle, error: bottleError } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name: `Wird analysiert... (#${i + 1})`,
|
||||||
|
processing_status: 'pending',
|
||||||
|
image_url: imageUrl,
|
||||||
|
status: 'sealed',
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (bottleError || !bottle) {
|
||||||
|
console.error('Error creating bottle:', bottleError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bottleIds.push(bottle.id);
|
||||||
|
|
||||||
|
// 3. Create tasting to link bottle to session
|
||||||
|
const { data: tasting, error: tastingError } = await supabase
|
||||||
|
.from('tastings')
|
||||||
|
.insert({
|
||||||
|
bottle_id: bottle.id,
|
||||||
|
user_id: user.id,
|
||||||
|
session_id: sessionId,
|
||||||
|
// No rating yet - placeholder tasting
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (tastingError) {
|
||||||
|
console.error('Error creating tasting:', tastingError);
|
||||||
|
} else if (tasting) {
|
||||||
|
tastingIds.push(tasting.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Trigger background analysis for all bottles (fire & forget)
|
||||||
|
// This won't block the response
|
||||||
|
triggerBackgroundAnalysis(bottleIds, user.id).catch(err => {
|
||||||
|
console.error('Background analysis error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
revalidatePath('/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
bottleIds,
|
||||||
|
tastingIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('processBulkScan error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a base64 image to Supabase Storage
|
||||||
|
*/
|
||||||
|
async function uploadImage(
|
||||||
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
|
userId: string,
|
||||||
|
dataUrl: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Convert base64 to blob
|
||||||
|
const base64Data = dataUrl.split(',')[1];
|
||||||
|
const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
|
||||||
|
const byteCharacters = atob(base64Data);
|
||||||
|
const byteNumbers = new Array(byteCharacters.length);
|
||||||
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
const blob = new Blob([byteArray], { type: mimeType });
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = mimeType.split('/')[1] || 'webp';
|
||||||
|
const filename = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||||
|
|
||||||
|
// Upload to storage
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('bottles')
|
||||||
|
.upload(filename, blob, {
|
||||||
|
contentType: mimeType,
|
||||||
|
upsert: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error('Upload error:', uploadError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public URL
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from('bottles')
|
||||||
|
.getPublicUrl(filename);
|
||||||
|
|
||||||
|
return publicUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image upload failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger background AI analysis for bottles.
|
||||||
|
* This runs asynchronously and updates bottles with results.
|
||||||
|
*/
|
||||||
|
async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise<void> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
for (const bottleId of bottleIds) {
|
||||||
|
try {
|
||||||
|
// Update status to analyzing
|
||||||
|
await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.update({ processing_status: 'analyzing' })
|
||||||
|
.eq('id', bottleId);
|
||||||
|
|
||||||
|
// Get bottle image
|
||||||
|
const { data: bottle } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.select('image_url')
|
||||||
|
.eq('id', bottleId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!bottle?.image_url) {
|
||||||
|
await markBottleError(supabase, bottleId, 'Kein Bild gefunden');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Gemini analysis
|
||||||
|
const analysisResult = await analyzeBottleImage(bottle.image_url);
|
||||||
|
|
||||||
|
if (analysisResult.success && analysisResult.data) {
|
||||||
|
// Update bottle with AI results
|
||||||
|
await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.update({
|
||||||
|
name: analysisResult.data.name || 'Unbekannter Whisky',
|
||||||
|
distillery: analysisResult.data.distillery,
|
||||||
|
category: analysisResult.data.category,
|
||||||
|
abv: analysisResult.data.abv,
|
||||||
|
age: analysisResult.data.age,
|
||||||
|
is_whisky: analysisResult.data.is_whisky ?? true,
|
||||||
|
confidence: analysisResult.data.confidence ?? 80,
|
||||||
|
processing_status: 'complete',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', bottleId);
|
||||||
|
} else {
|
||||||
|
await markBottleError(supabase, bottleId, analysisResult.error || 'Analyse fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Analysis failed for bottle ${bottleId}:`, error);
|
||||||
|
await markBottleError(supabase, bottleId, 'Analysefehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markBottleError(
|
||||||
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
|
bottleId: string,
|
||||||
|
error: string
|
||||||
|
): Promise<void> {
|
||||||
|
await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.update({
|
||||||
|
processing_status: 'error',
|
||||||
|
name: `Fehler: ${error.slice(0, 50)}`,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', bottleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call Gemini to analyze bottle image
|
||||||
|
* Uses existing Gemini integration
|
||||||
|
*/
|
||||||
|
async function analyzeBottleImage(imageUrl: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
name: string;
|
||||||
|
distillery?: string;
|
||||||
|
category?: string;
|
||||||
|
abv?: number;
|
||||||
|
age?: number;
|
||||||
|
is_whisky?: boolean;
|
||||||
|
confidence?: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Fetch image and convert to base64
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: 'Bild konnte nicht geladen werden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const base64 = Buffer.from(buffer).toString('base64');
|
||||||
|
const mimeType = blob.type || 'image/webp';
|
||||||
|
|
||||||
|
// Call Gemini
|
||||||
|
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: 'API Key nicht konfiguriert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geminiResponse = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: `Analyze this whisky bottle image. Extract:
|
||||||
|
- name: Full product name
|
||||||
|
- distillery: Distillery name
|
||||||
|
- category: e.g. "Single Malt", "Bourbon", "Blend"
|
||||||
|
- abv: Alcohol percentage as number (e.g. 46.0)
|
||||||
|
- age: Age statement as number (e.g. 12), null if NAS
|
||||||
|
- is_whisky: boolean, false if not a whisky
|
||||||
|
- confidence: 0-100 how confident you are
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON, no markdown.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inline_data: {
|
||||||
|
mime_type: mimeType,
|
||||||
|
data: base64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.1,
|
||||||
|
maxOutputTokens: 500,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!geminiResponse.ok) {
|
||||||
|
return { success: false, error: 'Gemini API Fehler' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geminiData = await geminiResponse.json();
|
||||||
|
const textContent = geminiData.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
|
||||||
|
if (!textContent) {
|
||||||
|
return { success: false, error: 'Keine Antwort von Gemini' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
return { success: false, error: 'Ungültige Gemini-Antwort' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
return { success: true, data: parsed };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gemini analysis error:', error);
|
||||||
|
return { success: false, error: 'Analysefehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bottles for a session with their processing status
|
||||||
|
*/
|
||||||
|
export async function getSessionBottles(sessionId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
bottles?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
distillery?: string;
|
||||||
|
abv?: number;
|
||||||
|
age?: number;
|
||||||
|
image_url?: string;
|
||||||
|
processing_status: string;
|
||||||
|
tasting_id: string;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tastings')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
bottles (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
distillery,
|
||||||
|
abv,
|
||||||
|
age,
|
||||||
|
image_url,
|
||||||
|
processing_status
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('session_id', sessionId)
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottles = (data || [])
|
||||||
|
.filter(t => t.bottles)
|
||||||
|
.map(t => ({
|
||||||
|
...(t.bottles as any),
|
||||||
|
tasting_id: t.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { success: true, bottles };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: 'Fehler beim Laden' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS bottles (
|
|||||||
abv DECIMAL,
|
abv DECIMAL,
|
||||||
age INTEGER,
|
age INTEGER,
|
||||||
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
||||||
|
processing_status TEXT DEFAULT 'complete' CHECK (processing_status IN ('pending', 'analyzing', 'complete', 'error')),
|
||||||
whiskybase_id TEXT,
|
whiskybase_id TEXT,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
purchase_price DECIMAL(10, 2),
|
purchase_price DECIMAL(10, 2),
|
||||||
@@ -415,3 +416,32 @@ SELECT
|
|||||||
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
|
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
|
||||||
FROM auth.users u
|
FROM auth.users u
|
||||||
ON CONFLICT (user_id) DO NOTHING;
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Buddy Invites (Handshake Codes)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS buddy_invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
creator_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
code TEXT NOT NULL UNIQUE, -- 6 char uppercase alphanumeric
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buddy_invites_code ON buddy_invites(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buddy_invites_creator_id ON buddy_invites(creator_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buddy_invites_expires_at ON buddy_invites(expires_at);
|
||||||
|
|
||||||
|
ALTER TABLE buddy_invites ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Only creator can see their own invites
|
||||||
|
DROP POLICY IF EXISTS "buddy_invites_creator_policy" ON buddy_invites;
|
||||||
|
CREATE POLICY "buddy_invites_creator_policy" ON buddy_invites
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = creator_id);
|
||||||
|
|
||||||
|
-- Allow anyone to SELECT by code (needed for redemption) but only if not expired
|
||||||
|
DROP POLICY IF EXISTS "buddy_invites_redeem_policy" ON buddy_invites;
|
||||||
|
CREATE POLICY "buddy_invites_redeem_policy" ON buddy_invites
|
||||||
|
FOR SELECT USING (expires_at > now());
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user