Bugfixes and Improvements Tag Management
This commit is contained in:
@@ -355,9 +355,13 @@ public class TagService {
|
||||
|
||||
// Calculate preview data
|
||||
int targetStoryCount = targetTag.getStories().size();
|
||||
int totalStories = targetStoryCount + sourceTags.stream()
|
||||
.mapToInt(tag -> tag.getStories().size())
|
||||
.sum();
|
||||
|
||||
// Collect all unique stories from all tags (including target) to handle overlaps correctly
|
||||
Set<Story> allUniqueStories = new HashSet<>(targetTag.getStories());
|
||||
for (Tag sourceTag : sourceTags) {
|
||||
allUniqueStories.addAll(sourceTag.getStories());
|
||||
}
|
||||
int totalStories = allUniqueStories.size();
|
||||
|
||||
List<String> aliasesToCreate = sourceTags.stream()
|
||||
.map(Tag::getName)
|
||||
|
||||
@@ -24,6 +24,12 @@ export default function TagMaintenancePage() {
|
||||
const [mergeTargetTagId, setMergeTargetTagId] = useState<string>('');
|
||||
const [mergePreview, setMergePreview] = useState<any>(null);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [isMergeSuggestionsModalOpen, setIsMergeSuggestionsModalOpen] = useState(false);
|
||||
const [mergeSuggestions, setMergeSuggestions] = useState<Array<{
|
||||
group: Tag[];
|
||||
similarity: number;
|
||||
reason: string;
|
||||
}>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
if (selectedTagIds.size < 2) {
|
||||
alert('Please select at least 2 tags to merge');
|
||||
@@ -296,6 +438,13 @@ export default function TagMaintenancePage() {
|
||||
Tags ({filteredTags.length})
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={generateMergeSuggestions}
|
||||
>
|
||||
🔍 Merge Suggestions
|
||||
</Button>
|
||||
{selectedTagIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
@@ -305,6 +454,13 @@ export default function TagMaintenancePage() {
|
||||
>
|
||||
Clear Selection ({selectedTagIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
🗑️ Delete Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -319,14 +475,24 @@ export default function TagMaintenancePage() {
|
||||
</div>
|
||||
|
||||
{filteredTags.length > 0 && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label className="text-sm theme-text">Select All</label>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label className="text-sm theme-text">Select All</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectUnused}
|
||||
disabled={tagStats.unused === 0}
|
||||
>
|
||||
Select Unused ({tagStats.unused})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -368,7 +534,13 @@ export default function TagMaintenancePage() {
|
||||
</p>
|
||||
)}
|
||||
<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 && (
|
||||
<span>{tag.aliasCount} aliases</span>
|
||||
)}
|
||||
@@ -504,7 +676,7 @@ export default function TagMaintenancePage() {
|
||||
<Button
|
||||
onClick={handleConfirmMerge}
|
||||
variant="primary"
|
||||
disabled={!mergePreview || merging}
|
||||
disabled={!mergeTargetTagId || merging}
|
||||
className="flex-1"
|
||||
>
|
||||
{merging ? 'Merging...' : 'Confirm Merge'}
|
||||
@@ -514,6 +686,114 @@ export default function TagMaintenancePage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Image from 'next/image';
|
||||
import { Story } from '../../types/api';
|
||||
import { storyApi, getImageUrl } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
|
||||
interface StoryCardProps {
|
||||
story: Story;
|
||||
@@ -27,7 +28,10 @@ export default function StoryCard({
|
||||
const [rating, setRating] = useState(story.rating || 0);
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -106,12 +110,12 @@ export default function StoryCard({
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
{story.tags.length > 3 && (
|
||||
<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) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onClick={(e) => handleRatingClick(e, star)}
|
||||
className={`text-lg ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
@@ -207,7 +211,7 @@ export default function StoryCard({
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onClick={(e) => handleRatingClick(e, star)}
|
||||
className={`text-sm ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
@@ -237,12 +241,12 @@ export default function StoryCard({
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
{story.tags.length > 2 && (
|
||||
<span className="px-2 py-1 text-xs theme-text">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { tagApi } from '../../lib/api';
|
||||
import { Tag } from '../../types/api';
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
@@ -9,25 +10,178 @@ interface TagInputProps {
|
||||
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) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<{name: string, similarity: number}[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(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(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (inputValue.length > 0) {
|
||||
try {
|
||||
const suggestionList = await tagApi.getTagAutocomplete(inputValue);
|
||||
// Filter out already selected tags
|
||||
const filteredSuggestions = suggestionList.filter(
|
||||
suggestion => !tags.includes(suggestion)
|
||||
);
|
||||
setSuggestions(filteredSuggestions);
|
||||
setShowSuggestions(filteredSuggestions.length > 0);
|
||||
// First try backend autocomplete for exact/prefix matches
|
||||
const backendSuggestions = await tagApi.getTagAutocomplete(inputValue);
|
||||
|
||||
// Apply fuzzy matching to all tags for better results
|
||||
const fuzzyMatches = allTags
|
||||
.map(tag => ({
|
||||
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) {
|
||||
console.error('Failed to fetch tag suggestions:', error);
|
||||
setSuggestions([]);
|
||||
@@ -41,7 +195,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
|
||||
const debounce = setTimeout(fetchSuggestions, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [inputValue, tags]);
|
||||
}, [inputValue, tags, allTags]);
|
||||
|
||||
const addTag = async (tag: string) => {
|
||||
const trimmedTag = tag.trim().toLowerCase();
|
||||
@@ -79,7 +233,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
|
||||
addTag(suggestions[activeSuggestionIndex]);
|
||||
addTag(suggestions[activeSuggestionIndex].name);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
@@ -110,8 +264,8 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
addTag(suggestion);
|
||||
const handleSuggestionClick = (suggestionName: string) => {
|
||||
addTag(suggestionName);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
@@ -159,9 +313,9 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
key={suggestion.name}
|
||||
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 ${
|
||||
index === activeSuggestionIndex
|
||||
? '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' : ''
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user