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:
@@ -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
196
src/app/admin/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
src/components/TagSelector.tsx
Normal file
151
src/components/TagSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,4 +163,5 @@ export type TranslationKeys = {
|
||||
noSessions: string;
|
||||
expiryWarning: string;
|
||||
};
|
||||
aroma: Record<string, string>;
|
||||
};
|
||||
|
||||
6
src/lib/ai-client.ts
Normal file
6
src/lib/ai-client.ts
Normal 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
19
src/lib/supabase-admin.ts
Normal 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;
|
||||
124
src/services/analyze-bottle-nebius.ts
Normal file
124
src/services/analyze-bottle-nebius.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
60
src/services/brave-search.ts
Normal file
60
src/services/brave-search.ts
Normal 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.'
|
||||
};
|
||||
}
|
||||
}
|
||||
87
src/services/magic-scan.ts
Normal file
87
src/services/magic-scan.ts
Normal 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.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
79
src/services/tags.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user