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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user