Bugfixes and Improvements Tag Management

This commit is contained in:
Stefan Hardegger
2025-08-18 08:54:18 +02:00
parent 1a99d9830d
commit 95ce5fb532
4 changed files with 491 additions and 42 deletions

View File

@@ -355,9 +355,13 @@ public class TagService {
// Calculate preview data // Calculate preview data
int targetStoryCount = targetTag.getStories().size(); int targetStoryCount = targetTag.getStories().size();
int totalStories = targetStoryCount + sourceTags.stream()
.mapToInt(tag -> tag.getStories().size()) // Collect all unique stories from all tags (including target) to handle overlaps correctly
.sum(); Set<Story> allUniqueStories = new HashSet<>(targetTag.getStories());
for (Tag sourceTag : sourceTags) {
allUniqueStories.addAll(sourceTag.getStories());
}
int totalStories = allUniqueStories.size();
List<String> aliasesToCreate = sourceTags.stream() List<String> aliasesToCreate = sourceTags.stream()
.map(Tag::getName) .map(Tag::getName)

View File

@@ -24,6 +24,12 @@ export default function TagMaintenancePage() {
const [mergeTargetTagId, setMergeTargetTagId] = useState<string>(''); const [mergeTargetTagId, setMergeTargetTagId] = useState<string>('');
const [mergePreview, setMergePreview] = useState<any>(null); const [mergePreview, setMergePreview] = useState<any>(null);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [isMergeSuggestionsModalOpen, setIsMergeSuggestionsModalOpen] = useState(false);
const [mergeSuggestions, setMergeSuggestions] = useState<Array<{
group: Tag[];
similarity: number;
reason: string;
}>>([]);
useEffect(() => { useEffect(() => {
loadTags(); loadTags();
@@ -107,6 +113,142 @@ export default function TagMaintenancePage() {
} }
}; };
const handleSelectUnused = () => {
const unusedTags = filteredTags.filter(tag => !tag.storyCount || tag.storyCount === 0);
setSelectedTagIds(new Set(unusedTags.map(tag => tag.id)));
};
const handleDeleteSelected = async () => {
if (selectedTagIds.size === 0) return;
const confirmation = confirm(
`Are you sure you want to delete ${selectedTagIds.size} selected tag(s)? This action cannot be undone.`
);
if (!confirmation) return;
try {
const deletePromises = Array.from(selectedTagIds).map(tagId =>
tagApi.deleteTag(tagId)
);
await Promise.all(deletePromises);
// Reload tags and reset selection
await loadTags();
setSelectedTagIds(new Set());
} catch (error) {
console.error('Failed to delete tags:', error);
alert('Failed to delete some tags. Please try again.');
}
};
const generateMergeSuggestions = () => {
const suggestions: Array<{
group: Tag[];
similarity: number;
reason: string;
}> = [];
// Helper function to calculate similarity between two strings
const calculateSimilarity = (str1: string, str2: string): number => {
const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase();
// Exact match
if (s1 === s2) return 1.0;
// Check for common patterns
const patterns = [
// Plural vs singular
{ regex: /(.+)s$/, match: (a: string, b: string) => a === b + 's' || b === a + 's' },
// Hyphen vs underscore vs space
{ regex: /[-_\s]/, match: (a: string, b: string) =>
a.replace(/[-_\s]/g, '') === b.replace(/[-_\s]/g, '') },
// Common abbreviations
{ regex: /\b(and|&)\b/, match: (a: string, b: string) =>
a.replace(/\band\b/g, '&') === b || a === b.replace(/\band\b/g, '&') },
];
for (const pattern of patterns) {
if (pattern.match(s1, s2)) return 0.9;
}
// Levenshtein distance for similar words
const distance = levenshteinDistance(s1, s2);
const maxLength = Math.max(s1.length, s2.length);
const similarity = 1 - (distance / maxLength);
return similarity > 0.8 ? similarity : 0;
};
// Simple Levenshtein distance implementation
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1,
matrix[j - 1][i] + 1,
matrix[j - 1][i - 1] + indicator
);
}
}
return matrix[str2.length][str1.length];
};
// Find similar tags
const processedTags = new Set<string>();
for (let i = 0; i < tags.length; i++) {
if (processedTags.has(tags[i].id)) continue;
const similarTags = [tags[i]];
processedTags.add(tags[i].id);
for (let j = i + 1; j < tags.length; j++) {
if (processedTags.has(tags[j].id)) continue;
const similarity = calculateSimilarity(tags[i].name, tags[j].name);
if (similarity > 0.8) {
similarTags.push(tags[j]);
processedTags.add(tags[j].id);
}
}
if (similarTags.length > 1) {
const maxSimilarity = Math.max(...similarTags.slice(1).map(tag =>
calculateSimilarity(similarTags[0].name, tag.name)
));
let reason = 'Similar names detected';
if (maxSimilarity === 0.9) {
reason = 'Likely plural/singular or formatting variations';
} else if (maxSimilarity > 0.95) {
reason = 'Very similar names, possible duplicates';
}
suggestions.push({
group: similarTags,
similarity: maxSimilarity,
reason
});
}
}
// Sort by similarity descending
suggestions.sort((a, b) => b.similarity - a.similarity);
setMergeSuggestions(suggestions);
setIsMergeSuggestionsModalOpen(true);
};
const handleMergeSelected = () => { const handleMergeSelected = () => {
if (selectedTagIds.size < 2) { if (selectedTagIds.size < 2) {
alert('Please select at least 2 tags to merge'); alert('Please select at least 2 tags to merge');
@@ -296,6 +438,13 @@ export default function TagMaintenancePage() {
Tags ({filteredTags.length}) Tags ({filteredTags.length})
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={generateMergeSuggestions}
>
🔍 Merge Suggestions
</Button>
{selectedTagIds.size > 0 && ( {selectedTagIds.size > 0 && (
<> <>
<Button <Button
@@ -305,6 +454,13 @@ export default function TagMaintenancePage() {
> >
Clear Selection ({selectedTagIds.size}) Clear Selection ({selectedTagIds.size})
</Button> </Button>
<Button
variant="danger"
size="sm"
onClick={handleDeleteSelected}
>
🗑 Delete Selected
</Button>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
@@ -319,7 +475,8 @@ export default function TagMaintenancePage() {
</div> </div>
{filteredTags.length > 0 && ( {filteredTags.length > 0 && (
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length} checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
@@ -328,6 +485,15 @@ export default function TagMaintenancePage() {
/> />
<label className="text-sm theme-text">Select All</label> <label className="text-sm theme-text">Select All</label>
</div> </div>
<Button
variant="ghost"
size="sm"
onClick={handleSelectUnused}
disabled={tagStats.unused === 0}
>
Select Unused ({tagStats.unused})
</Button>
</div>
)} )}
{filteredTags.length === 0 ? ( {filteredTags.length === 0 ? (
@@ -368,7 +534,13 @@ export default function TagMaintenancePage() {
</p> </p>
)} )}
<div className="flex gap-4 text-xs theme-text-muted mt-1"> <div className="flex gap-4 text-xs theme-text-muted mt-1">
<span>{tag.storyCount || 0} stories</span> <a
href={`/library?tags=${encodeURIComponent(tag.name)}`}
className="hover:theme-accent hover:underline cursor-pointer"
title={`View ${tag.storyCount || 0} stories with tag "${tag.name}"`}
>
{tag.storyCount || 0} stories
</a>
{tag.aliasCount && tag.aliasCount > 0 && ( {tag.aliasCount && tag.aliasCount > 0 && (
<span>{tag.aliasCount} aliases</span> <span>{tag.aliasCount} aliases</span>
)} )}
@@ -504,7 +676,7 @@ export default function TagMaintenancePage() {
<Button <Button
onClick={handleConfirmMerge} onClick={handleConfirmMerge}
variant="primary" variant="primary"
disabled={!mergePreview || merging} disabled={!mergeTargetTagId || merging}
className="flex-1" className="flex-1"
> >
{merging ? 'Merging...' : 'Confirm Merge'} {merging ? 'Merging...' : 'Confirm Merge'}
@@ -514,6 +686,114 @@ export default function TagMaintenancePage() {
</div> </div>
</div> </div>
)} )}
{/* Merge Suggestions Modal */}
{isMergeSuggestionsModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<h2 className="text-2xl font-bold theme-header mb-4">Merge Suggestions</h2>
<div className="space-y-4">
<p className="theme-text">
Found {mergeSuggestions.length} potential merge opportunities based on similar tag names.
</p>
{mergeSuggestions.length === 0 ? (
<div className="text-center py-8">
<p className="theme-text text-lg">No similar tags found.</p>
<p className="theme-text-muted text-sm mt-2">
All your tags appear to have unique names.
</p>
</div>
) : (
<div className="space-y-4">
{mergeSuggestions.map((suggestion, index) => (
<div
key={index}
className="border theme-border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-900/20"
>
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-medium theme-header">
Suggestion {index + 1}
</h3>
<p className="text-sm theme-text-muted">
{suggestion.reason} (Similarity: {(suggestion.similarity * 100).toFixed(1)}%)
</p>
</div>
<Button
variant="primary"
size="sm"
onClick={() => {
// Pre-select these tags for merging and go directly to merge modal
const suggestedTagIds = new Set(suggestion.group.map(tag => tag.id));
setSelectedTagIds(suggestedTagIds);
setIsMergeSuggestionsModalOpen(false);
// Open merge modal directly
setIsMergeModalOpen(true);
setMergeTargetTagId('');
setMergePreview(null);
}}
>
Merge These
</Button>
</div>
<div className="flex flex-wrap gap-2">
{suggestion.group.map((tag, tagIndex) => (
<div key={tag.id} className="flex items-center gap-2">
<TagDisplay
tag={tag}
size="sm"
showAliasesTooltip={true}
clickable={false}
/>
<span className="text-xs theme-text-muted">
({tag.storyCount || 0} stories)
</span>
{tagIndex < suggestion.group.length - 1 && (
<span className="text-gray-400"></span>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t theme-border">
<Button
onClick={() => setIsMergeSuggestionsModalOpen(false)}
variant="ghost"
className="flex-1"
>
Close
</Button>
{mergeSuggestions.length > 0 && (
<Button
onClick={() => {
// Select all suggested tags for batch processing
const allSuggestedTagIds = new Set<string>();
mergeSuggestions.forEach(suggestion => {
suggestion.group.forEach(tag => allSuggestedTagIds.add(tag.id));
});
setSelectedTagIds(allSuggestedTagIds);
setIsMergeSuggestionsModalOpen(false);
}}
variant="secondary"
className="flex-1"
>
Select All Suggested ({mergeSuggestions.reduce((acc, s) => acc + s.group.length, 0)} tags)
</Button>
)}
</div>
</div>
</div>
</div>
)}
</AppLayout> </AppLayout>
); );
} }

View File

@@ -6,6 +6,7 @@ import Image from 'next/image';
import { Story } from '../../types/api'; import { Story } from '../../types/api';
import { storyApi, getImageUrl } from '../../lib/api'; import { storyApi, getImageUrl } from '../../lib/api';
import Button from '../ui/Button'; import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
interface StoryCardProps { interface StoryCardProps {
story: Story; story: Story;
@@ -27,7 +28,10 @@ export default function StoryCard({
const [rating, setRating] = useState(story.rating || 0); const [rating, setRating] = useState(story.rating || 0);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const handleRatingClick = async (newRating: number) => { const handleRatingClick = async (e: React.MouseEvent, newRating: number) => {
e.preventDefault();
e.stopPropagation();
if (updating) return; if (updating) return;
try { try {
@@ -106,12 +110,12 @@ export default function StoryCard({
{Array.isArray(story.tags) && story.tags.length > 0 && ( {Array.isArray(story.tags) && story.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{story.tags.slice(0, 3).map((tag) => ( {story.tags.slice(0, 3).map((tag) => (
<span <TagDisplay
key={tag.id} key={tag.id}
className="px-2 py-1 text-xs rounded theme-accent-bg text-white" tag={tag}
> size="sm"
{tag.name} clickable={false}
</span> />
))} ))}
{story.tags.length > 3 && ( {story.tags.length > 3 && (
<span className="px-2 py-1 text-xs theme-text"> <span className="px-2 py-1 text-xs theme-text">
@@ -129,7 +133,7 @@ export default function StoryCard({
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<button <button
key={star} key={star}
onClick={() => handleRatingClick(star)} onClick={(e) => handleRatingClick(e, star)}
className={`text-lg ${ className={`text-lg ${
star <= rating star <= rating
? 'text-yellow-400' ? 'text-yellow-400'
@@ -207,7 +211,7 @@ export default function StoryCard({
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<button <button
key={star} key={star}
onClick={() => handleRatingClick(star)} onClick={(e) => handleRatingClick(e, star)}
className={`text-sm ${ className={`text-sm ${
star <= rating star <= rating
? 'text-yellow-400' ? 'text-yellow-400'
@@ -237,12 +241,12 @@ export default function StoryCard({
{Array.isArray(story.tags) && story.tags.length > 0 && ( {Array.isArray(story.tags) && story.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{story.tags.slice(0, 2).map((tag) => ( {story.tags.slice(0, 2).map((tag) => (
<span <TagDisplay
key={tag.id} key={tag.id}
className="px-2 py-1 text-xs rounded theme-accent-bg text-white" tag={tag}
> size="sm"
{tag.name} clickable={false}
</span> />
))} ))}
{story.tags.length > 2 && ( {story.tags.length > 2 && (
<span className="px-2 py-1 text-xs theme-text"> <span className="px-2 py-1 text-xs theme-text">

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { tagApi } from '../../lib/api'; import { tagApi } from '../../lib/api';
import { Tag } from '../../types/api';
interface TagInputProps { interface TagInputProps {
tags: string[]; tags: string[];
@@ -9,25 +10,178 @@ interface TagInputProps {
placeholder?: string; placeholder?: string;
} }
// Fuzzy matching utilities
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1,
matrix[j - 1][i] + 1,
matrix[j - 1][i - 1] + indicator
);
}
}
return matrix[str2.length][str1.length];
};
const calculateSimilarity = (query: string, target: string): number => {
const q = query.toLowerCase().trim();
const t = target.toLowerCase().trim();
// Don't match very short queries to avoid noise
if (q.length < 2) return 0;
// Exact match
if (q === t) return 1.0;
// Starts with match (high priority)
if (t.startsWith(q)) return 0.95;
// Contains match (word boundary preferred)
if (t.includes(q)) {
// Higher score if it's a word boundary match
const words = t.split(/\s+|[-_]/);
if (words.some(word => word.startsWith(q))) return 0.85;
return 0.75;
}
// Common typo patterns
// 1. Adjacent character swaps (e.g., "sicfi" -> "scifi")
if (Math.abs(q.length - t.length) <= 1) {
for (let i = 0; i < Math.min(q.length - 1, t.length - 1); i++) {
const swapped = q.substring(0, i) + q[i + 1] + q[i] + q.substring(i + 2);
if (swapped === t) return 0.9;
}
}
// 2. Missing character (e.g., "fantsy" -> "fantasy")
if (t.length === q.length + 1) {
for (let i = 0; i <= t.length; i++) {
const withMissing = t.substring(0, i) + t.substring(i + 1);
if (withMissing === q) return 0.88;
}
}
// 3. Extra character (e.g., "fantasy" -> "fantassy")
if (q.length === t.length + 1) {
for (let i = 0; i <= q.length; i++) {
const withExtra = q.substring(0, i) + q.substring(i + 1);
if (withExtra === t) return 0.88;
}
}
// 4. Double letter corrections (e.g., "scii" -> "sci", "romace" -> "romance")
const qNormalized = q.replace(/(.)\1+/g, '$1');
const tNormalized = t.replace(/(.)\1+/g, '$1');
if (qNormalized === tNormalized) return 0.87;
// 5. Common letter substitutions (keyboard layout)
const keyboardSubs: { [key: string]: string[] } = {
'a': ['s', 'q'], 's': ['a', 'd', 'w'], 'd': ['s', 'f', 'e'], 'f': ['d', 'g', 'r'],
'q': ['w', 'a'], 'w': ['q', 'e', 's'], 'e': ['w', 'r', 'd'], 'r': ['e', 't', 'f'],
'z': ['x', 'a'], 'x': ['z', 'c', 's'], 'c': ['x', 'v', 'd'], 'v': ['c', 'b', 'f'],
'i': ['u', 'o'], 'o': ['i', 'p'], 'u': ['y', 'i'], 'y': ['t', 'u'],
'n': ['b', 'm'], 'm': ['n', 'j'], 'j': ['h', 'k'], 'k': ['j', 'l']
};
let substitutionScore = 0;
if (q.length === t.length) {
let matches = 0;
for (let i = 0; i < q.length; i++) {
if (q[i] === t[i]) {
matches++;
} else if (keyboardSubs[q[i]]?.includes(t[i]) || keyboardSubs[t[i]]?.includes(q[i])) {
matches += 0.8; // Partial credit for keyboard mistakes
}
}
substitutionScore = matches / q.length;
if (substitutionScore > 0.8) return substitutionScore * 0.85;
}
// Levenshtein distance-based similarity (fallback)
const distance = levenshteinDistance(q, t);
const maxLength = Math.max(q.length, t.length);
const similarity = 1 - (distance / maxLength);
// Boost score for shorter strings with fewer differences
if (maxLength <= 6 && distance <= 2) {
return Math.min(0.8, similarity + 0.1);
}
// Only consider it a match if similarity is high enough
return similarity > 0.65 ? similarity * 0.7 : 0;
};
export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }: TagInputProps) { export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }: TagInputProps) {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<{name: string, similarity: number}[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
const [allTags, setAllTags] = useState<Tag[]>([]);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
// Load all tags once for fuzzy matching
useEffect(() => {
const loadAllTags = async () => {
try {
const response = await tagApi.getTags({ page: 0, size: 1000 });
setAllTags(response.content || []);
} catch (error) {
console.error('Failed to load all tags:', error);
}
};
loadAllTags();
}, []);
useEffect(() => { useEffect(() => {
const fetchSuggestions = async () => { const fetchSuggestions = async () => {
if (inputValue.length > 0) { if (inputValue.length > 0) {
try { try {
const suggestionList = await tagApi.getTagAutocomplete(inputValue); // First try backend autocomplete for exact/prefix matches
// Filter out already selected tags const backendSuggestions = await tagApi.getTagAutocomplete(inputValue);
const filteredSuggestions = suggestionList.filter(
suggestion => !tags.includes(suggestion) // Apply fuzzy matching to all tags for better results
); const fuzzyMatches = allTags
setSuggestions(filteredSuggestions); .map(tag => ({
setShowSuggestions(filteredSuggestions.length > 0); name: tag.name,
similarity: calculateSimilarity(inputValue, tag.name)
}))
.filter(match => match.similarity > 0 && !tags.includes(match.name))
.sort((a, b) => b.similarity - a.similarity);
// Combine backend results with fuzzy matches, prioritizing backend results
const combinedResults = new Map<string, {name: string, similarity: number}>();
// Add backend results with high priority
backendSuggestions.forEach(name => {
if (!tags.includes(name)) {
combinedResults.set(name, { name, similarity: 0.99 });
}
});
// Add fuzzy matches that aren't already from backend
fuzzyMatches.forEach(match => {
if (!combinedResults.has(match.name)) {
combinedResults.set(match.name, match);
}
});
// Convert to array and limit results
const finalSuggestions = Array.from(combinedResults.values())
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 10);
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
} catch (error) { } catch (error) {
console.error('Failed to fetch tag suggestions:', error); console.error('Failed to fetch tag suggestions:', error);
setSuggestions([]); setSuggestions([]);
@@ -41,7 +195,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
const debounce = setTimeout(fetchSuggestions, 300); const debounce = setTimeout(fetchSuggestions, 300);
return () => clearTimeout(debounce); return () => clearTimeout(debounce);
}, [inputValue, tags]); }, [inputValue, tags, allTags]);
const addTag = async (tag: string) => { const addTag = async (tag: string) => {
const trimmedTag = tag.trim().toLowerCase(); const trimmedTag = tag.trim().toLowerCase();
@@ -79,7 +233,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
case ',': case ',':
e.preventDefault(); e.preventDefault();
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) { if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
addTag(suggestions[activeSuggestionIndex]); addTag(suggestions[activeSuggestionIndex].name);
} else if (inputValue.trim()) { } else if (inputValue.trim()) {
addTag(inputValue); addTag(inputValue);
} }
@@ -110,8 +264,8 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
} }
}; };
const handleSuggestionClick = (suggestion: string) => { const handleSuggestionClick = (suggestionName: string) => {
addTag(suggestion); addTag(suggestionName);
inputRef.current?.focus(); inputRef.current?.focus();
}; };
@@ -159,9 +313,9 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
> >
{suggestions.map((suggestion, index) => ( {suggestions.map((suggestion, index) => (
<button <button
key={suggestion} key={suggestion.name}
type="button" type="button"
onClick={() => handleSuggestionClick(suggestion)} onClick={() => handleSuggestionClick(suggestion.name)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
index === activeSuggestionIndex index === activeSuggestionIndex
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
@@ -170,14 +324,21 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
index === suggestions.length - 1 ? 'rounded-b-lg' : '' index === suggestions.length - 1 ? 'rounded-b-lg' : ''
}`} }`}
> >
{suggestion} <div className="flex justify-between items-center">
<span>{suggestion.name}</span>
{suggestion.similarity < 0.95 && (
<span className="text-xs opacity-50 ml-2">
{(suggestion.similarity * 100).toFixed(0)}%
</span>
)}
</div>
</button> </button>
))} ))}
</div> </div>
)} )}
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Type and press Enter or comma to add tags Type and press Enter or comma to add tags. Supports fuzzy matching for typos.
</p> </p>
</div> </div>
); );