feat: implement advanced tagging system, tag weighting, and app focus refactoring

- Implemented reusable TagSelector component with i18n support
- Added tag weighting system (popularity scores 1-5)
- Created admin panel for tag management
- Integrated Nebius AI and Brave Search for 'Magic Scan'
- Refactored app focus: removed bottle status, updated counters, and displayed extended bottle details
- Updated i18n for German and English
- Added database migration scripts
This commit is contained in:
2025-12-19 12:58:44 +01:00
parent 9eb9b41061
commit b2a1d292da
30 changed files with 2420 additions and 194 deletions

View File

@@ -100,6 +100,12 @@ export default async function AdminPage() {
>
Manage Plans
</Link>
<Link
href="/admin/tags"
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
>
Manage Tags
</Link>
<Link
href="/admin/users"
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"

196
src/app/admin/tags/page.tsx Normal file
View File

@@ -0,0 +1,196 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import Link from 'next/link';
import { ChevronLeft, Tag as TagIcon, Plus, Search, Trash2, Shield, User, Filter, Download } from 'lucide-react';
import { Tag, TagCategory, getTagsByCategory } from '@/services/tags';
import { useI18n } from '@/i18n/I18nContext';
export default function AdminTagsPage() {
const { t } = useI18n();
const supabase = createClientComponentClient();
const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<TagCategory | 'all'>('all');
useEffect(() => {
fetchTags();
}, []);
const fetchTags = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('tags')
.select('*')
.order('popularity_score', { ascending: false })
.order('name');
if (data) setTags(data);
setIsLoading(false);
};
const filteredTags = tags.filter(tag => {
const matchesSearch = tag.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = categoryFilter === 'all' || tag.category === categoryFilter;
return matchesSearch && matchesCategory;
});
const handleDelete = async (id: string) => {
if (!confirm('Tag wirklich löschen?')) return;
const { error } = await supabase.from('tags').delete().eq('id', id);
if (!error) setTags(prev => prev.filter(t => t.id !== id));
};
const toggleSystemDefault = async (tag: Tag) => {
const { error } = await supabase
.from('tags')
.update({ is_system_default: !tag.is_system_default })
.eq('id', tag.id);
if (!error) {
setTags(prev => prev.map(t => t.id === tag.id ? { ...t, is_system_default: !t.is_system_default } : t));
}
};
const updatePopularity = async (tagId: string, score: number) => {
const { error } = await supabase
.from('tags')
.update({ popularity_score: score })
.eq('id', tagId);
if (!error) {
setTags(prev => prev.map(t => t.id === tagId ? { ...t, popularity_score: score } : t));
}
};
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<Link href="/admin" className="text-zinc-500 hover:text-amber-600 transition-colors flex items-center gap-2 text-sm font-bold mb-2">
<ChevronLeft size={16} /> Admin Dashboard
</Link>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tighter flex items-center gap-3">
<TagIcon className="text-amber-600" /> Aroma Tags verwalten
</h1>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl overflow-hidden">
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50 flex flex-col md:flex-row gap-4 justify-between">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tags suchen..."
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={18} className="text-zinc-400" />
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)}
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
>
<option value="all">Alle Kategorien</option>
<option value="nose">Nose</option>
<option value="taste">Taste</option>
<option value="finish">Finish</option>
<option value="texture">Texture</option>
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.15em] border-b border-zinc-100 dark:border-zinc-800">
<th className="px-6 py-4">Name</th>
<th className="px-6 py-4">Kategorie</th>
<th className="px-6 py-4">Popularität</th>
<th className="px-6 py-4">Typ</th>
<th className="px-6 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{isLoading ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Lade Tags...</td>
</tr>
) : filteredTags.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Keine Tags gefunden.</td>
</tr>
) : (
filteredTags.map((tag) => (
<tr key={tag.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors group">
<td className="px-6 py-4">
<div className="font-bold text-zinc-900 dark:text-zinc-100">
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</div>
{tag.is_system_default && (
<div className="text-[10px] text-zinc-400 font-mono">{tag.name} (Key)</div>
)}
</td>
<td className="px-6 py-4">
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase tracking-widest ${tag.category === 'nose' ? 'bg-green-100 text-green-700' :
tag.category === 'taste' ? 'bg-blue-100 text-blue-700' :
tag.category === 'finish' ? 'bg-amber-100 text-amber-700' :
'bg-zinc-100 text-zinc-700'
}`}>
{tag.category}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
onClick={() => updatePopularity(tag.id, score)}
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
? 'bg-amber-600 text-white shadow-sm'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
}`}
>
{score}
</button>
))}
</div>
</td>
<td className="px-6 py-4">
<button
onClick={() => toggleSystemDefault(tag)}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${tag.is_system_default
? 'bg-amber-600 text-white'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200'
}`}
>
{tag.is_system_default ? <Shield size={10} /> : <User size={10} />}
{tag.is_system_default ? 'System' : 'Custom'}
</button>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleDelete(tag.id)}
className="p-2 text-zinc-400 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</main>
);
}

View File

@@ -2,10 +2,9 @@ import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle } from 'lucide-react';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react';
import { getStorageUrl } from '@/lib/supabase';
import TastingNoteForm from '@/components/TastingNoteForm';
import StatusSwitcher from '@/components/StatusSwitcher';
import TastingList from '@/components/TastingList';
import DeleteBottleButton from '@/components/DeleteBottleButton';
import EditBottleForm from '@/components/EditBottleForm';
@@ -48,11 +47,19 @@ export default async function BottlePage({
id,
name
),
tasting_tags (
tasting_buddies (
buddies (
id,
name
)
),
tasting_tags (
tags (
id,
name,
category,
is_system_default
)
)
`)
.eq('bottle_id', params.id)
@@ -68,9 +75,11 @@ export default async function BottlePage({
.select(`
*,
tasting_tags (
buddies (
tags (
id,
name
name,
category,
is_system_default
)
)
`)
@@ -125,32 +134,60 @@ export default async function BottlePage({
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
<Tag size={14} /> Kategorie
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Tag size={12} /> Kategorie
</div>
<div className="font-semibold dark:text-zinc-200">{bottle.category || '-'}</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.category || '-'}</div>
</div>
</div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
<Droplets size={14} /> Alkoholgehalt
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Droplets size={12} /> Alkoholgehalt
</div>
<div className="font-semibold dark:text-zinc-200">{bottle.abv}% Vol.</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.abv}% Vol.</div>
</div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
<Award size={14} /> Alter
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Award size={12} /> Alter
</div>
<div className="font-semibold dark:text-zinc-200">{bottle.age ? `${bottle.age} Jahre` : '-'}</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
</div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
<Calendar size={14} /> Zuletzt verkostet
{bottle.distilled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={12} /> Destilliert
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.distilled_at}</div>
</div>
<div className="font-semibold dark:text-zinc-200">
)}
{bottle.bottled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Package size={12} /> Abgefüllt
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.bottled_at}</div>
</div>
)}
{bottle.batch_info && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-700/50 md:col-span-1">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Info size={12} /> Batch / Code
</div>
<div className="font-mono text-xs dark:text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
</div>
)}
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={12} /> Letzter Dram
</div>
<div className="font-bold text-sm dark:text-zinc-200">
{tastings && tastings.length > 0
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
: 'Noch nie'}
@@ -158,8 +195,7 @@ export default async function BottlePage({
</div>
</div>
<div className="pt-2 space-y-4">
<StatusSwitcher bottleId={bottle.id} currentStatus={bottle.status} />
<div className="pt-2 flex flex-wrap gap-4">
<EditBottleForm bottle={bottle} />
<DeleteBottleButton bottleId={bottle.id} />
</div>

View File

@@ -2,7 +2,7 @@
import React, { useState, useMemo } from 'react';
import Link from 'next/link';
import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react';
import { Search, Filter, X, Calendar, Clock, Package, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react';
import { getStorageUrl } from '@/lib/supabase';
import { useSearchParams } from 'next/navigation';
import { validateSession } from '@/services/validate-session';
@@ -19,7 +19,6 @@ interface Bottle {
age: number;
image_url: string;
purchase_price?: number | null;
status: 'sealed' | 'open' | 'sampled' | 'empty';
created_at: string;
last_tasted?: string | null;
is_whisky?: boolean;
@@ -33,15 +32,6 @@ interface BottleCardProps {
function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n();
const statusConfig = {
open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: t('bottle.status.open') },
sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: t('bottle.status.sampled') },
empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: t('bottle.status.empty') },
sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: t('bottle.status.sealed') },
};
const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock;
const statusStyle = statusConfig[bottle.status as keyof typeof statusConfig] || statusConfig.sealed;
return (
<Link
@@ -71,10 +61,6 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
</div>
)}
<div className={`absolute bottom-3 left-3 px-3 py-1.5 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 backdrop-blur-md border shadow-lg ${statusStyle.color}`}>
<StatusIcon size={12} />
{statusStyle.label}
</div>
</div>
<div className="p-3 md:p-5 space-y-3 md:space-y-4">
@@ -144,7 +130,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedDistillery, setSelectedDistillery] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'last_tasted' | 'created_at'>('created_at');
const categories = useMemo(() => {
@@ -174,9 +159,8 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
const matchesCategory = !selectedCategory || bottle.category === selectedCategory;
const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery;
const matchesStatus = !selectedStatus || bottle.status === selectedStatus;
return matchesSearch && matchesCategory && matchesDistillery && matchesStatus;
return matchesSearch && matchesCategory && matchesDistillery;
});
// Sorting logic
@@ -191,7 +175,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}
});
}, [bottles, searchQuery, selectedCategory, selectedDistillery, selectedStatus, sortBy]);
}, [bottles, searchQuery, selectedCategory, selectedDistillery, sortBy]);
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
@@ -203,7 +187,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
);
}
const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0) + (selectedStatus ? 1 : 0);
const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0);
return (
<div className="w-full space-y-8">
@@ -314,23 +298,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 px-1">{t('grid.filter.status')}</label>
<div className="flex flex-wrap gap-2">
{['sealed', 'open', 'sampled', 'empty'].map((status) => (
<button
key={status}
onClick={() => setSelectedStatus(selectedStatus === status ? null : status)}
className={`px-3 py-1.5 rounded-xl text-[10px] font-black transition-all border ${selectedStatus === status
? status === 'open' ? 'bg-amber-500 border-amber-500 text-white' : status === 'sampled' ? 'bg-purple-500 border-purple-500 text-white' : status === 'empty' ? 'bg-zinc-500 border-zinc-500 text-white' : 'bg-blue-600 border-blue-600 text-white'
: 'bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500'
}`}
>
{t(`bottle.status.${status}`).toUpperCase()}
</button>
))}
</div>
</div>
</div>
<div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center">
@@ -338,7 +305,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
onClick={() => {
setSelectedCategory(null);
setSelectedDistillery(null);
setSelectedStatus(null);
setSearchQuery('');
}}
className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline"

View File

@@ -1,7 +1,8 @@
'use client';
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight } from 'lucide-react';
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle';
@@ -17,7 +18,7 @@ import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format';
import { magicScan } from '@/services/magic-scan';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void;
@@ -63,6 +64,23 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
const [isDiscovering, setIsDiscovering] = useState(false);
const [originalFile, setOriginalFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'nebius'>('gemini');
React.useEffect(() => {
const checkAdmin = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data } = await supabase
.from('admin_users')
.select('role')
.eq('user_id', user.id)
.maybeSingle();
setIsAdmin(!!data);
}
};
checkAdmin();
}, [supabase]);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -116,11 +134,19 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return;
}
const response = await analyzeBottle(compressedBase64);
const response = await magicScan(compressedBase64, aiProvider);
if (response.success && response.data) {
setAnalysisResult(response.data);
if (response.wb_id) {
setWbDiscovery({
id: response.wb_id,
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
title: `${response.data.distillery || ''} ${response.data.name || ''}`
});
}
// Duplicate Check
const match = await findMatchingBottle(response.data);
if (match) {
@@ -299,7 +325,26 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return (
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
<div className="flex items-center justify-between w-full">
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
{isAdmin && (
<div className="flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-xl border border-zinc-200 dark:border-zinc-700">
<button
onClick={() => setAiProvider('gemini')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
>
Gemini
</button>
<button
onClick={() => setAiProvider('nebius')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'nebius' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
>
Nebius
</button>
</div>
)}
</div>
<div
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"

View File

@@ -7,7 +7,6 @@ import { useI18n } from '@/i18n/I18nContext';
interface Bottle {
id: string;
purchase_price?: number | null;
status: 'sealed' | 'open' | 'sampled' | 'empty';
distillery?: string;
tastings?: { rating: number }[];
}
@@ -19,7 +18,6 @@ interface StatsDashboardProps {
export default function StatsDashboard({ bottles }: StatsDashboardProps) {
const { t, locale } = useI18n();
const stats = useMemo(() => {
const activeBottles = bottles.filter(b => b.status !== 'empty');
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []);
@@ -38,7 +36,6 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
return {
totalValue,
activeCount: activeBottles.length,
avgRating,
topDistillery,
totalCount: bottles.length
@@ -55,7 +52,7 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
},
{
label: t('home.stats.activeBottles'),
value: stats.activeCount,
value: stats.totalCount,
icon: Home,
color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-900/20'

View File

@@ -1,72 +0,0 @@
'use client';
import React, { useState } from 'react';
import { updateBottleStatus } from '@/services/update-bottle-status';
import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface StatusSwitcherProps {
bottleId: string;
currentStatus: 'sealed' | 'open' | 'sampled' | 'empty';
}
export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) {
const { t } = useI18n();
const [status, setStatus] = useState(currentStatus);
const [loading, setLoading] = useState(false);
const handleStatusChange = async (newStatus: 'sealed' | 'open' | 'sampled' | 'empty') => {
if (newStatus === status || loading) return;
setLoading(true);
try {
const result = await updateBottleStatus(bottleId, newStatus);
if (result.success) {
setStatus(newStatus);
} else {
alert(result.error || t('common.error'));
}
} catch (err) {
alert(t('common.error'));
} finally {
setLoading(false);
}
};
const options = [
{ id: 'sealed', label: t('bottle.status.sealed'), icon: Package, color: 'hover:bg-blue-500' },
{ id: 'open', label: t('bottle.status.open'), icon: Play, color: 'hover:bg-amber-500' },
{ id: 'sampled', label: t('bottle.status.sampled'), icon: FlaskConical, color: 'hover:bg-purple-500' },
{ id: 'empty', label: t('bottle.status.empty'), icon: CheckCircle, color: 'hover:bg-zinc-500' },
] as const;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('bottle.bottleStatus')}</label>
{loading && <Loader2 className="animate-spin text-amber-600" size={14} />}
</div>
<div className="grid grid-cols-4 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
{options.map((opt) => {
const Icon = opt.icon;
const isActive = status === opt.id;
return (
<button
key={opt.id}
type="button"
disabled={loading}
onClick={() => handleStatusChange(opt.id)}
className={`flex flex-col items-center gap-1.5 py-3 px-1 rounded-xl text-[9px] font-black uppercase tracking-tight transition-all border-2 ${isActive
? 'bg-white dark:bg-zinc-700 border-amber-500 text-amber-600 shadow-sm ring-1 ring-black/5'
: 'border-transparent text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
}`}
>
<Icon size={18} className={isActive ? 'text-amber-500' : 'text-zinc-400'} />
{opt.label}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags';
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface TagSelectorProps {
category: TagCategory;
selectedTagIds: string[];
onToggleTag: (tagId: string) => void;
label?: string;
}
export default function TagSelector({ category, selectedTagIds, onToggleTag, label }: TagSelectorProps) {
const { t } = useI18n();
const [tags, setTags] = useState<Tag[]>([]);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
const fetchTags = async () => {
setIsLoading(true);
const data = await getTagsByCategory(category);
setTags(data);
setIsLoading(false);
};
fetchTags();
}, [category]);
const filteredTags = useMemo(() => {
if (!search) return tags;
const s = search.toLowerCase();
return tags.filter(tag => {
const rawMatch = tag.name.toLowerCase().includes(s);
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
return rawMatch || translatedMatch;
});
}, [tags, search, t]);
const handleCreateTag = async () => {
if (!search || isCreating) return;
setIsCreating(true);
const result = await createCustomTag(search, category);
if (result.success && result.tag) {
setTags(prev => [...prev, result.tag!]);
onToggleTag(result.tag!.id);
setSearch('');
}
setIsCreating(false);
};
const selectedTags = tags.filter(t => selectedTagIds.includes(t.id));
return (
<div className="space-y-3">
{label && (
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
)}
{/* Selected Tags */}
<div className="flex flex-wrap gap-2 min-h-[32px]">
{selectedTags.length > 0 ? (
selectedTags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-full text-[10px] font-black uppercase tracking-tight shadow-sm shadow-amber-600/20 animate-in fade-in zoom-in-95"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
<X size={12} />
</button>
))
) : (
<span className="text-[10px] italic text-zinc-400 font-medium">Noch keine Tags gewählt...</span>
)}
</div>
{/* Search and Suggest */}
<div className="relative">
<div className="relative flex items-center">
<Search className="absolute left-3 text-zinc-400" size={14} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tag suchen oder hinzufügen..."
className="w-full pl-9 pr-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
/>
{isCreating && (
<Loader2 className="absolute right-3 animate-spin text-amber-600" size={14} />
)}
</div>
{search && (
<div className="absolute z-10 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="max-h-48 overflow-y-auto">
{filteredTags.length > 0 ? (
filteredTags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => {
onToggleTag(tag.id);
setSearch('');
}}
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 flex items-center justify-between border-b border-zinc-100 dark:border-zinc-700 last:border-0"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-amber-600" />}
</button>
))
) : (
<button
type="button"
onClick={handleCreateTag}
className="w-full px-4 py-3 text-left text-xs font-bold text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/10 flex items-center gap-2"
>
<Plus size={14} />
"{search}" als neuen Tag hinzufügen
</button>
)}
</div>
</div>
)}
</div>
{/* Suggestions Chips (limit to 6 random or most common) */}
{!search && tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{tags
.filter(t => !selectedTagIds.includes(t.id))
.slice(0, 8)
.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-2.5 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors border border-zinc-200/50 dark:border-zinc-700/50"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -16,7 +16,7 @@ interface Tasting {
is_sample?: boolean;
bottle_id: string;
created_at: string;
tasting_tags?: {
tasting_buddies?: {
buddies: {
id: string;
name: string;
@@ -26,6 +26,14 @@ interface Tasting {
id: string;
name: string;
};
tasting_tags?: {
tags: {
id: string;
name: string;
category: string;
is_system_default: boolean;
}
}[];
user_id: string;
}
@@ -188,13 +196,30 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
</div>
{note.tasting_tags && note.tasting_tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2">
{note.tasting_tags.map(tt => (
<span
key={tt.tags.id}
className={`px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-tight border ${tt.tags.category === 'nose' ? 'bg-green-50 text-green-700 border-green-100 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800/50' :
tt.tags.category === 'taste' ? 'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/50' :
tt.tags.category === 'finish' ? 'bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50' :
'bg-zinc-50 text-zinc-700 border-zinc-100 dark:bg-zinc-900/20 dark:text-zinc-400 dark:border-zinc-800/50'
}`}
>
{tt.tags.is_system_default ? t(`aroma.${tt.tags.name}`) : tt.tags.name}
</span>
))}
</div>
)}
{note.tasting_buddies && note.tasting_buddies.length > 0 && (
<div className="pt-3 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-800">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1">
<Users size={12} className="text-amber-500" />
{t('tasting.with') || 'Mit'}:
</span>
<AvatarStack names={note.tasting_tags.map(tag => tag.buddies.name)} />
<AvatarStack names={note.tasting_buddies.map(tag => tag.buddies.name)} />
</div>
</div>
)}

View File

@@ -2,10 +2,11 @@
import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets } from 'lucide-react';
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
interface Buddy {
id: string;
@@ -29,6 +30,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
const [error, setError] = useState<string | null>(null);
const [buddies, setBuddies] = useState<Buddy[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
const { activeSession } = useSession();
const effectiveSessionId = sessionId || activeSession?.id;
@@ -62,6 +66,18 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
);
};
const toggleNoseTag = (id: string) => {
setNoseTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const togglePalateTag = (id: string) => {
setPalateTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const toggleFinishTag = (id: string) => {
setFinishTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -77,6 +93,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
finish_notes: finish,
is_sample: isSample,
buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
});
if (result.success) {
@@ -84,6 +101,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setPalate('');
setFinish('');
setSelectedBuddyIds([]);
setNoseTagIds([]);
setPalateTagIds([]);
setFinishTagIds([]);
// We don't need to manually refresh because of revalidatePath in the server action
} else {
setError(result.error || t('common.error'));
@@ -158,37 +178,71 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.nose')}</label>
<textarea
value={nose}
onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
</div>
<div className="space-y-4">
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
<TagSelector
category="nose"
selectedTagIds={noseTagIds}
onToggleTag={toggleNoseTag}
label={t('tasting.nose')}
/>
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea
value={nose}
onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.palate')}</label>
<textarea
value={palate}
onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
</div>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
<TagSelector
category="taste"
selectedTagIds={palateTagIds}
onToggleTag={togglePalateTag}
label={t('tasting.palate')}
/>
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea
value={palate}
onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.finish')}</label>
<textarea
value={finish}
onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-6">
<TagSelector
category="finish"
selectedTagIds={finishTagIds}
onToggleTag={toggleFinishTag}
label={t('tasting.finish')}
/>
<TagSelector
category="texture"
selectedTagIds={finishTagIds} // Using finish state for texture for now, or separate if needed
onToggleTag={toggleFinishTag}
label="Textur & Mundgefühl"
/>
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea
value={finish}
onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/>
</div>
</div>
</div>
{buddies.length > 0 && (

View File

@@ -165,4 +165,91 @@ export const de: TranslationKeys = {
noSessions: 'Noch keine Sessions vorhanden.',
expiryWarning: 'Diese Session läuft bald ab.',
},
aroma: {
'Apfel': 'Apfel',
'Grüner Apfel': 'Grüner Apfel',
'Bratapfel': 'Bratapfel',
'Birne': 'Birne',
'Zitrus': 'Zitrus',
'Zitrone': 'Zitrone',
'Orange': 'Orange',
'Orangenschale': 'Orangenschale',
'Pfirsich': 'Pfirsich',
'Aprikose': 'Aprikose',
'Banane': 'Banane',
'Ananas': 'Ananas',
'Tropische Früchte': 'Tropische Früchte',
'Kirsche': 'Kirsche',
'Beeren': 'Beeren',
'Brombeere': 'Brombeere',
'Himbeere': 'Himbeere',
'Pflaume': 'Pflaume',
'Trockenfrüchte': 'Trockenfrüchte',
'Rosinen': 'Rosinen',
'Datteln': 'Datteln',
'Feigen': 'Feigen',
'Vanille': 'Vanille',
'Honig': 'Honig',
'Karamell': 'Karamell',
'Toffee': 'Toffee',
'Schokolade': 'Schokolade',
'Zartbitterschokolade': 'Zartbitterschokolade',
'Milchschokolade': 'Milchschokolade',
'Malz': 'Malz',
'Müsli': 'Müsli',
'Butter': 'Butter',
'Butterkeks': 'Butterkeks',
'Marzipan': 'Marzipan',
'Mandel': 'Mandel',
'Sahnebonbon': 'Sahnebonbon',
'Eiche': 'Eiche',
'Zimt': 'Zimt',
'Pfeffer': 'Pfeffer',
'Muskatnuss': 'Muskatnuss',
'Ingwer': 'Ingwer',
'Nelke': 'Nelke',
'Walnuss': 'Walnuss',
'Haselnuss': 'Haselnuss',
'Geröstete Nüsse': 'Geröstete Nüsse',
'Lagerfeuer': 'Lagerfeuer',
'Holzkohle': 'Holzkohle',
'Torfrauch': 'Torfrauch',
'Asche': 'Asche',
'Jod': 'Jod',
'Medizinisch': 'Medizinisch',
'Teer': 'Teer',
'Asphalt': 'Asphalt',
'Geräucherter Schinken': 'Geräucherter Schinken',
'Speck': 'Speck',
'Grillfleisch': 'Grillfleisch',
'Meersalz': 'Meersalz',
'Salzlake': 'Salzlake',
'Seetang': 'Seetang',
'Algen': 'Algen',
'Austern': 'Austern',
'Meeresbrise': 'Meeresbrise',
'Heidekraut': 'Heidekraut',
'Gras': 'Gras',
'Heu': 'Heu',
'Minze': 'Minze',
'Menthol': 'Menthol',
'Eukalyptus': 'Eukalyptus',
'Tabak': 'Tabak',
'Leder': 'Leder',
'Tee': 'Tee',
'Kurz & Knackig': 'Kurz & Knackig',
'Mittellang': 'Mittellang',
'Lang anhaltend': 'Lang anhaltend',
'Ewig': 'Ewig',
'Ölig': 'Ölig',
'Viskos': 'Viskos',
'Trocken': 'Trocken',
'Adstringierend': 'Adstringierend',
'Wärmend': 'Wärmend',
'Scharf': 'Scharf',
'Beißend': 'Beißend',
'Weich': 'Weich',
'Samtig': 'Samtig',
'Wässrig': 'Wässrig',
}
};

View File

@@ -165,4 +165,91 @@ export const en: TranslationKeys = {
noSessions: 'No sessions yet.',
expiryWarning: 'This session will expire soon.',
},
aroma: {
'Apfel': 'Apple',
'Grüner Apfel': 'Green Apple',
'Bratapfel': 'Baked Apple',
'Birne': 'Pear',
'Zitrus': 'Citrus',
'Zitrone': 'Lemon',
'Orange': 'Orange',
'Orangenschale': 'Orange Peel',
'Pfirsich': 'Peach',
'Aprikose': 'Apricot',
'Banane': 'Banana',
'Ananas': 'Pineapple',
'Tropische Früchte': 'Tropical Fruits',
'Kirsche': 'Cherry',
'Beeren': 'Berries',
'Brombeere': 'Blackberry',
'Himbeere': 'Raspberry',
'Pflaume': 'Plum',
'Trockenfrüchte': 'Dried Fruits',
'Rosinen': 'Raisins',
'Datteln': 'Dates',
'Feigen': 'Figs',
'Vanille': 'Vanilla',
'Honig': 'Honey',
'Karamell': 'Caramel',
'Toffee': 'Toffee',
'Schokolade': 'Chocolate',
'Zartbitterschokolade': 'Dark Chocolate',
'Milchschokolade': 'Milk Chocolate',
'Malz': 'Malt',
'Müsli': 'Cereal',
'Butter': 'Butter',
'Butterkeks': 'Butter Cookie',
'Marzipan': 'Marzipan',
'Mandel': 'Almond',
'Sahnebonbon': 'Butterscotch',
'Eiche': 'Oak',
'Zimt': 'Cinnamon',
'Pfeffer': 'Pepper',
'Muskatnuss': 'Nutmeg',
'Ingwer': 'Ginger',
'Nelke': 'Clove',
'Walnuss': 'Walnut',
'Haselnuss': 'Hazelnut',
'Geröstete Nüsse': 'Roasted Nuts',
'Lagerfeuer': 'Campfire',
'Holzkohle': 'Charcoal',
'Torfrauch': 'Peat Smoke',
'Asche': 'Ash',
'Jod': 'Iodine',
'Medizinisch': 'Medicinal',
'Teer': 'Tar',
'Asphalt': 'Asphalt',
'Geräucherter Schinken': 'Smoked Ham',
'Speck': 'Bacon',
'Grillfleisch': 'Grilled Meat',
'Meersalz': 'Sea Salt',
'Salzlake': 'Brine',
'Seetang': 'Seaweed',
'Algen': 'Algae',
'Austern': 'Oysters',
'Meeresbrise': 'Sea Breeze',
'Heidekraut': 'Heather',
'Gras': 'Grass',
'Heu': 'Hay',
'Minze': 'Mint',
'Menthol': 'Menthol',
'Eukalyptus': 'Eucalyptus',
'Tabak': 'Tobacco',
'Leder': 'Leather',
'Tee': 'Tea',
'Kurz & Knackig': 'Short & Sharp',
'Mittellang': 'Medium',
'Lang anhaltend': 'Long Finish',
'Ewig': 'Eternal',
'Ölig': 'Oily',
'Viskos': 'Viscous',
'Trocken': 'Dry',
'Adstringierend': 'Astringent',
'Wärmend': 'Warming',
'Scharf': 'Sharp',
'Beißend': 'Biting',
'Weich': 'Soft',
'Samtig': 'Velvety',
'Wässrig': 'Watery',
}
};

View File

@@ -163,4 +163,5 @@ export type TranslationKeys = {
noSessions: string;
expiryWarning: string;
};
aroma: Record<string, string>;
};

6
src/lib/ai-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { OpenAI } from 'openai';
export const aiClient = new OpenAI({
baseURL: 'https://api.tokenfactory.nebius.com/v1/',
apiKey: process.env.NEBIUS_API_KEY,
});

19
src/lib/supabase-admin.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Supabase Admin Error: SUPABASE_SERVICE_ROLE_KEY is missing.');
}
}
export const supabaseAdmin = (supabaseUrl && supabaseServiceKey)
? createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
: null as any;

View File

@@ -0,0 +1,124 @@
'use server';
import { aiClient } from '@/lib/ai-client';
import { SYSTEM_INSTRUCTION as GEMINI_SYSTEM_INSTRUCTION } from '@/lib/gemini';
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { createHash } from 'crypto';
import { trackApiUsage } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
export async function analyzeBottleNebius(base64Image: string): Promise<AnalysisResponse & { search_string?: string }> {
const supabase = createServerActionClient({ cookies });
if (!process.env.NEBIUS_API_KEY) {
return { success: false, error: 'NEBIUS_API_KEY is not configured.' };
}
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session || !session.user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
// Check credit balance (using same gemini_ai type for now or create new one)
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`
};
}
const base64Data = base64Image.split(',')[1] || base64Image;
const imageHash = createHash('sha256').update(base64Data).digest('hex');
// Check Cache (Optional: skip if you want fresh AI results for testing)
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
if (cachedResult) {
console.log(`[Nebius Cache] Hit! hash: ${imageHash}`);
return {
success: true,
data: cachedResult.result as any,
};
}
console.log(`[Nebius AI] Calling Qwen2.5-VL...`);
const response = await aiClient.chat.completions.create({
model: "Qwen/Qwen2.5-VL-72B-Instruct",
messages: [
{
role: "system",
content: GEMINI_SYSTEM_INSTRUCTION + "\nAdditionally, generate a 'search_string' field for Whiskybase in this format: 'site:whiskybase.com [Distillery] [Name] [Vintage]'. Include this field in the JSON object."
},
{
role: "user",
content: [
{
type: "text",
text: "Extract whisky metadata from this image."
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64Data}`
}
}
]
}
],
response_format: { type: "json_object" }
});
const content = response.choices[0].message.content;
if (!content) throw new Error('Empty response from Nebius AI');
// Extract JSON content in case the model wraps it in markdown blocks
const jsonContent = content.match(/\{[\s\S]*\}/)?.[0] || content;
const jsonData = JSON.parse(jsonContent);
// Extract search_string before validation if it's not in schema
const searchString = jsonData.search_string;
delete jsonData.search_string;
const validatedData = BottleMetadataSchema.parse(jsonData);
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai', // Keep tracking as gemini_ai for budget or separate later
endpoint: 'nebius/qwen2.5-vl',
success: true
});
// Deduct credits
await deductCredits(userId, 'gemini_ai', 'Nebius AI analysis');
// Store in Cache
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString
};
} catch (error) {
console.error('Nebius Analysis Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Nebius AI analysis failed.',
};
}
}

View File

@@ -0,0 +1,60 @@
'use server';
/**
* Service to search Brave for a Whiskybase link and extract the ID.
*/
export async function searchBraveForWhiskybase(query: string) {
const apiKey = process.env.BRAVE_API_KEY;
if (!apiKey) {
console.error('BRAVE_API_KEY is not configured.');
return { success: false, error: 'Brave Search API Key missing.' };
}
try {
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query + ' site:whiskybase.com')}`;
const response = await fetch(url, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Brave API error: ${response.status}`);
}
const data = await response.json();
if (!data.web || !data.web.results || data.web.results.length === 0) {
return { success: false, error: 'No results found on Brave.' };
}
// Try to find a Whiskybase ID in the results
const wbRegex = /whiskybase\.com\/whiskies\/whisky\/(\d+)\//;
for (const result of data.web.results) {
const url = result.url;
const match = url.match(wbRegex);
if (match && match[1]) {
return {
success: true,
id: match[1],
url: url,
title: result.title
};
}
}
return { success: false, error: 'No valid Whiskybase ID found in results.' };
} catch (error) {
console.error('Brave Search Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Brave search.'
};
}
}

View File

@@ -0,0 +1,87 @@
'use server';
import { analyzeBottle } from './analyze-bottle';
import { analyzeBottleNebius } from './analyze-bottle-nebius';
import { searchBraveForWhiskybase } from './brave-search';
import { supabase } from '@/lib/supabase';
import { supabaseAdmin } from '@/lib/supabase-admin';
import { AnalysisResponse, BottleMetadata } from '@/types/whisky';
export async function magicScan(base64Image: string, provider: 'gemini' | 'nebius' = 'gemini'): Promise<AnalysisResponse & { wb_id?: string }> {
try {
// 1. AI Analysis
let aiResponse: any;
if (provider === 'nebius') {
aiResponse = await analyzeBottleNebius(base64Image);
} else {
aiResponse = await analyzeBottle(base64Image);
}
if (!aiResponse.success || !aiResponse.data) {
return aiResponse;
}
const data: BottleMetadata = aiResponse.data;
const searchString = aiResponse.search_string || `${data.distillery || ''} ${data.name || ''} ${data.vintage || data.age || ''}`.trim();
if (!searchString) {
return { ...aiResponse, wb_id: undefined };
}
// 2. DB Cache Check (global_products)
// We use the regular supabase client for reading
const { data: cacheHit } = await supabase
.from('global_products')
.select('wb_id')
.textSearch('search_vector', `'${searchString}'`, { config: 'simple' })
.limit(1)
.maybeSingle();
if (cacheHit) {
console.log(`[Magic Scan] Cache Hit for ${searchString}: ${cacheHit.wb_id}`);
return {
...aiResponse,
wb_id: cacheHit.wb_id
};
}
// 3. Fallback to Brave Search
console.log(`[Magic Scan] Cache Miss for ${searchString}. Calling Brave...`);
const braveResult = await searchBraveForWhiskybase(searchString);
if (braveResult.success && braveResult.id) {
console.log(`[Magic Scan] Brave found ID: ${braveResult.id}`);
// 4. Cache Write (using Admin client)
if (supabaseAdmin) {
const { error: saveError } = await supabaseAdmin
.from('global_products')
.insert({
wb_id: braveResult.id,
full_name: searchString, // We save the search string as the name for future hits
});
if (saveError) {
console.warn(`[Magic Scan] Failed to save to global_products: ${saveError.message}`);
}
}
return {
...aiResponse,
wb_id: braveResult.id
};
}
return {
...aiResponse,
wb_id: undefined
};
} catch (error) {
console.error('Magic Scan Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Magic Scan failed.'
};
}
}

View File

@@ -14,6 +14,7 @@ export async function saveTasting(data: {
finish_notes?: string;
is_sample?: boolean;
buddy_ids?: string[];
tag_ids?: string[];
}) {
const supabase = createServerActionClient({ cookies });
@@ -48,22 +49,38 @@ export async function saveTasting(data: {
// Add buddy tags if any
if (data.buddy_ids && data.buddy_ids.length > 0) {
const tags = data.buddy_ids.map(buddyId => ({
const buddies = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id,
buddy_id: buddyId,
user_id: session.user.id
}));
const { error: tagError } = await supabase
.from('tasting_tags')
.insert(tags);
.from('tasting_buddies')
.insert(buddies);
if (tagError) {
console.error('Error adding tasting tags:', tagError);
console.error('Error adding tasting buddies:', tagError);
// We don't throw here to not fail the whole tasting save,
// but in a real app we might want more robust error handling
}
}
// Add aroma tags if any
if (data.tag_ids && data.tag_ids.length > 0) {
const aromaTags = data.tag_ids.map(tagId => ({
tasting_id: tasting.id,
tag_id: tagId,
user_id: session.user.id
}));
const { error: aromaTagError } = await supabase
.from('tasting_tags')
.insert(aromaTags);
if (aromaTagError) {
console.error('Error adding aroma tags:', aromaTagError);
}
}
revalidatePath(`/bottles/${data.bottle_id}`);
return { success: true, data: tasting };

79
src/services/tags.ts Normal file
View File

@@ -0,0 +1,79 @@
'use server';
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export type TagCategory = 'nose' | 'taste' | 'finish' | 'texture';
export interface Tag {
id: string;
name: string;
category: TagCategory;
is_system_default: boolean;
popularity_score: number;
created_by?: string;
}
/**
* Fetch tags by category
*/
export async function getTagsByCategory(category: TagCategory): Promise<Tag[]> {
const supabase = createServerActionClient({ cookies });
const { data, error } = await supabase
.from('tags')
.select('*')
.eq('category', category)
.order('popularity_score', { ascending: false })
.order('name');
if (error) {
console.error(`Error fetching tags for ${category}:`, error);
return [];
}
return data || [];
}
/**
* Create a custom user tag
*/
export async function createCustomTag(name: string, category: TagCategory): Promise<{ success: boolean; tag?: Tag; error?: string }> {
const supabase = createServerActionClient({ cookies });
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('tags')
.insert({
name,
category,
is_system_default: false,
created_by: session.user.id
})
.select()
.single();
if (error) {
if (error.code === '23505') { // Unique constraint violation
// Try to fetch the existing tag
const { data: existingTag } = await supabase
.from('tags')
.select('*')
.eq('name', name)
.eq('category', category)
.single();
return { success: true, tag: existingTag || undefined };
}
throw error;
}
return { success: true, tag: data };
} catch (error) {
console.error('Error creating custom tag:', error);
return { success: false, error: 'Tag konnte nicht erstellt werden' };
}
}

View File

@@ -1,17 +1,17 @@
import { z } from 'zod';
export const BottleMetadataSchema = z.object({
name: z.string().nullable(),
distillery: z.string().nullable(),
category: z.string().nullable(),
abv: z.number().nullable(),
age: z.number().nullable(),
vintage: z.string().nullable(),
bottleCode: z.string().nullable(),
whiskybaseId: z.string().nullable(),
distilled_at: z.string().nullable(),
bottled_at: z.string().nullable(),
batch_info: z.string().nullable(),
name: z.string().nullish(),
distillery: z.string().nullish(),
category: z.string().nullish(),
abv: z.number().nullish(),
age: z.number().nullish(),
vintage: z.string().nullish(),
bottleCode: z.string().nullish(),
whiskybaseId: z.string().nullish(),
distilled_at: z.string().nullish(),
bottled_at: z.string().nullish(),
batch_info: z.string().nullish(),
is_whisky: z.boolean().default(true),
confidence: z.number().min(0).max(100).default(100),
});