Skip to content
This repository was archived by the owner on Jun 14, 2025. It is now read-only.

Commit 0dfd886

Browse files
committed
added tags
1 parent 2378b84 commit 0dfd886

File tree

8 files changed

+1038
-570
lines changed

8 files changed

+1038
-570
lines changed

src/components/TagInput.tsx

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import { api } from '~/utils/api';
3+
import { X } from 'lucide-react';
4+
5+
interface Tag {
6+
id: string;
7+
name: string;
8+
color?: string | null;
9+
}
10+
11+
interface TagInputProps {
12+
selectedTags: Tag[];
13+
onTagsChange: (tags: Tag[]) => void;
14+
placeholder?: string;
15+
}
16+
17+
const TagInput: React.FC<TagInputProps> = ({
18+
selectedTags,
19+
onTagsChange,
20+
placeholder = "Search or create tags..."
21+
}) => {
22+
const [searchQuery, setSearchQuery] = useState('');
23+
const [showSuggestions, setShowSuggestions] = useState(false);
24+
const [isCreating, setIsCreating] = useState(false);
25+
const inputRef = useRef<HTMLInputElement>(null);
26+
const containerRef = useRef<HTMLDivElement>(null);
27+
28+
const { data: searchResults, isLoading: isSearching } = api.categoriesTags.searchTags.useQuery(
29+
{ query: searchQuery, limit: 10 },
30+
{
31+
enabled: searchQuery.length >= 1,
32+
refetchOnWindowFocus: false,
33+
}
34+
);
35+
36+
const createTagMutation = api.categoriesTags.createOrFindTag.useMutation({
37+
onSuccess: (newTag) => {
38+
if (!selectedTags.find(tag => tag.id === newTag.id)) {
39+
onTagsChange([...selectedTags, newTag]);
40+
}
41+
setSearchQuery('');
42+
setShowSuggestions(false);
43+
setIsCreating(false);
44+
},
45+
onError: (error) => {
46+
console.error('Error creating tag:', error);
47+
setIsCreating(false);
48+
},
49+
});
50+
51+
// Close suggestions when clicking outside
52+
useEffect(() => {
53+
const handleClickOutside = (event: MouseEvent) => {
54+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
55+
setShowSuggestions(false);
56+
}
57+
};
58+
59+
document.addEventListener('mousedown', handleClickOutside);
60+
return () => document.removeEventListener('mousedown', handleClickOutside);
61+
}, []);
62+
63+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
64+
const value = e.target.value;
65+
setSearchQuery(value);
66+
setShowSuggestions(value.length > 0);
67+
};
68+
69+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
70+
if (e.key === 'Enter') {
71+
e.preventDefault();
72+
73+
if (searchQuery.trim()) {
74+
// Check if exact match exists in search results
75+
const exactMatch = searchResults?.find(
76+
tag => tag.name.toLowerCase() === searchQuery.trim().toLowerCase()
77+
);
78+
79+
if (exactMatch) {
80+
selectTag(exactMatch);
81+
} else {
82+
// Create new tag
83+
void createNewTag(searchQuery.trim());
84+
}
85+
}
86+
} else if (e.key === 'Escape') {
87+
setShowSuggestions(false);
88+
setSearchQuery('');
89+
}
90+
};
91+
92+
const selectTag = (tag: Tag) => {
93+
if (!selectedTags.find(selectedTag => selectedTag.id === tag.id)) {
94+
onTagsChange([...selectedTags, tag]);
95+
}
96+
setSearchQuery('');
97+
setShowSuggestions(false);
98+
};
99+
100+
const removeTag = (tagToRemove: Tag) => {
101+
onTagsChange(selectedTags.filter(tag => tag.id !== tagToRemove.id));
102+
};
103+
104+
const createNewTag = async (name: string) => {
105+
if (isCreating) return;
106+
107+
setIsCreating(true);
108+
void createTagMutation.mutate({ name });
109+
};
110+
111+
const filteredResults = searchResults?.filter(
112+
tag => !selectedTags.find(selectedTag => selectedTag.id === tag.id)
113+
) ?? [];
114+
115+
const showCreateOption = searchQuery.trim() &&
116+
!filteredResults.find(tag => tag.name.toLowerCase() === searchQuery.trim().toLowerCase());
117+
118+
return (
119+
<div className="space-y-2">
120+
{/* Selected Tags */}
121+
{selectedTags.length > 0 && (
122+
<div className="flex flex-wrap gap-2">
123+
{selectedTags.map((tag) => (
124+
<span
125+
key={tag.id}
126+
className="inline-flex items-center px-3 py-1 bg-primary/10 text-primary rounded-full text-sm"
127+
style={{
128+
backgroundColor: tag.color ? `${tag.color}20` : undefined,
129+
color: tag.color ?? undefined,
130+
}}
131+
>
132+
{tag.name}
133+
<button
134+
onClick={() => removeTag(tag)}
135+
className="ml-2 hover:text-primary/70"
136+
type="button"
137+
title="Remove tag"
138+
>
139+
<X className="w-3 h-3" />
140+
</button>
141+
</span>
142+
))}
143+
</div>
144+
)}
145+
146+
{/* Input Container */}
147+
<div ref={containerRef} className="relative">
148+
<input
149+
ref={inputRef}
150+
type="text"
151+
value={searchQuery}
152+
onChange={handleInputChange}
153+
onKeyDown={handleKeyDown}
154+
onFocus={() => setShowSuggestions(searchQuery.length > 0)}
155+
placeholder={placeholder}
156+
className="input w-full"
157+
/>
158+
159+
{/* Suggestions Dropdown */}
160+
{showSuggestions && (
161+
<div className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-auto backdrop-blur-sm">
162+
{isSearching ? (
163+
<div className="p-3 text-sm text-muted-foreground bg-background">
164+
Searching...
165+
</div>
166+
) : (
167+
<>
168+
{/* Existing tags */}
169+
{filteredResults.map((tag) => (
170+
<button
171+
key={tag.id}
172+
onClick={() => selectTag(tag)}
173+
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors bg-background"
174+
type="button"
175+
>
176+
<span
177+
className="inline-block w-3 h-3 rounded-full mr-2"
178+
style={{ backgroundColor: tag.color ?? '#6b7280' }}
179+
/>
180+
{tag.name}
181+
</button>
182+
))}
183+
184+
{/* Create new tag option */}
185+
{showCreateOption && (
186+
<button
187+
onClick={() => createNewTag(searchQuery.trim())}
188+
disabled={isCreating}
189+
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors text-primary disabled:opacity-50 bg-background"
190+
type="button"
191+
>
192+
{isCreating ? (
193+
<>Creating &ldquo;{searchQuery.trim()}&rdquo;...</>
194+
) : (
195+
<>Create &ldquo;{searchQuery.trim()}&rdquo;</>
196+
)}
197+
</button>
198+
)}
199+
200+
{/* No results */}
201+
{filteredResults.length === 0 && !showCreateOption && (
202+
<div className="p-3 text-sm text-muted-foreground bg-background">
203+
No tags found
204+
</div>
205+
)}
206+
</>
207+
)}
208+
</div>
209+
)}
210+
</div>
211+
</div>
212+
);
213+
};
214+
215+
export default TagInput;

0 commit comments

Comments
 (0)