feat: implement AI custom tag proposals
- AI now suggests dominant notes not in the system list (Part 3: Custom Suggestions) - Updated TagSelector to show 'Neu anlegen?' buttons for AI-proposed custom tags - Added suggested_custom_tags to bottles table and metadata schema - Updated TastingNoteForm to handle both system and custom AI suggestions
This commit is contained in:
@@ -10,14 +10,17 @@ interface TagSelectorProps {
|
||||
selectedTagIds: string[];
|
||||
onToggleTag: (tagId: string) => void;
|
||||
label?: string;
|
||||
suggestedTagNames?: string[];
|
||||
suggestedCustomTagNames?: string[];
|
||||
}
|
||||
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label }: TagSelectorProps) {
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [creatingSuggestion, setCreatingSuggestion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
@@ -128,11 +131,68 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{!search && suggestedTagNames && suggestedTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-amber-500">
|
||||
<Sparkles size={10} /> {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags
|
||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
||||
.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-2.5 py-1 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 text-[10px] font-bold uppercase tracking-tight hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors border border-amber-200 dark:border-amber-800/50 flex items-center gap-1.5"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Custom Suggestions */}
|
||||
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-zinc-400">
|
||||
Dominante Note anlegen?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestedCustomTagNames
|
||||
.filter(name => !tags.some(t => t.name.toLowerCase() === name.toLowerCase()))
|
||||
.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
disabled={creatingSuggestion === name}
|
||||
onClick={async () => {
|
||||
setCreatingSuggestion(name);
|
||||
const result = await createCustomTag(name, category);
|
||||
if (result.success && result.tag) {
|
||||
setTags(prev => [...prev, result.tag!]);
|
||||
onToggleTag(result.tag!.id);
|
||||
}
|
||||
setCreatingSuggestion(null);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 text-zinc-500 dark:text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-amber-600 hover:text-white transition-all border border-dashed border-zinc-200 dark:border-zinc-700/50 flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{creatingSuggestion === name ? <Loader2 size={10} className="animate-spin" /> : <Plus size={10} />}
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</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))
|
||||
.filter(t => !selectedTagIds.includes(t.id) && (!suggestedTagNames || !suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase())))
|
||||
.slice(0, 8)
|
||||
.map(tag => (
|
||||
<button
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
|
||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||
const { activeSession } = useSession();
|
||||
|
||||
const effectiveSessionId = sessionId || activeSession?.id;
|
||||
@@ -43,6 +44,17 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name');
|
||||
setBuddies(buddiesData || []);
|
||||
|
||||
// Fetch Bottle Suggestions
|
||||
const { data: bottleData } = await supabase
|
||||
.from('bottles')
|
||||
.select('suggested_tags')
|
||||
.eq('id', bottleId)
|
||||
.maybeSingle();
|
||||
|
||||
if (bottleData?.suggested_tags) {
|
||||
setSuggestedTags(bottleData.suggested_tags);
|
||||
}
|
||||
|
||||
// If Session ID, fetch session participants and pre-select them
|
||||
if (effectiveSessionId) {
|
||||
const { data: participants } = await supabase
|
||||
@@ -58,7 +70,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [effectiveSessionId]);
|
||||
}, [effectiveSessionId, bottleId]);
|
||||
|
||||
const toggleBuddy = (id: string) => {
|
||||
setSelectedBuddyIds(prev =>
|
||||
@@ -185,6 +197,8 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
selectedTagIds={noseTagIds}
|
||||
onToggleTag={toggleNoseTag}
|
||||
label={t('tasting.nose')}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
|
||||
@@ -204,6 +218,8 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
selectedTagIds={palateTagIds}
|
||||
onToggleTag={togglePalateTag}
|
||||
label={t('tasting.palate')}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
|
||||
@@ -223,6 +239,8 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={toggleFinishTag}
|
||||
label={t('tasting.finish')}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
|
||||
<TagSelector
|
||||
@@ -230,6 +248,8 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
selectedTagIds={finishTagIds} // Using finish state for texture for now, or separate if needed
|
||||
onToggleTag={toggleFinishTag}
|
||||
label="Textur & Mundgefühl"
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user