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>
|
||||
|
||||
Reference in New Issue
Block a user