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

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

View File

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

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

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

View File

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