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

@@ -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 && (