a tool for shared writing and social publishing
1"use client";
2import { CloseTiny } from "components/Icons/CloseTiny";
3import { Input } from "components/Input";
4import { useState, useRef } from "react";
5import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6import { Popover } from "components/Popover";
7import Link from "next/link";
8import { searchTags, type TagSearchResult } from "actions/searchTags";
9
10export const Tag = (props: {
11 name: string;
12 selected?: boolean;
13 onDelete?: (tag: string) => void;
14 className?: string;
15}) => {
16 return (
17 <div
18 className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`}
19 >
20 <Link
21 href={`https://leaflet.pub/tag/${encodeURIComponent(props.name)}`}
22 className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`}
23 >
24 {props.name}{" "}
25 </Link>
26 {props.selected ? (
27 <button
28 type="button"
29 onClick={() => (props.onDelete ? props.onDelete(props.name) : null)}
30 >
31 <CloseTiny className="scale-75 pr-1 text-accent-2" />
32 </button>
33 ) : null}
34 </div>
35 );
36};
37
38export const TagSelector = (props: {
39 selectedTags: string[];
40 setSelectedTags: (tags: string[]) => void;
41}) => {
42 return (
43 <div className="flex flex-col gap-2 text-primary">
44 <TagSearchInput
45 selectedTags={props.selectedTags}
46 setSelectedTags={props.setSelectedTags}
47 />
48 {props.selectedTags.length > 0 ? (
49 <div className="flex flex-wrap gap-2 ">
50 {props.selectedTags.map((tag) => (
51 <Tag
52 key={tag}
53 name={tag}
54 selected
55 onDelete={() => {
56 props.setSelectedTags(
57 props.selectedTags.filter((t) => t !== tag),
58 );
59 }}
60 />
61 ))}
62 </div>
63 ) : (
64 <div className="text-tertiary italic text-sm h-6">no tags selected</div>
65 )}
66 </div>
67 );
68};
69
70export const TagSearchInput = (props: {
71 selectedTags: string[];
72 setSelectedTags: (tags: string[]) => void;
73}) => {
74 let [tagInputValue, setTagInputValue] = useState("");
75 let [isOpen, setIsOpen] = useState(false);
76 let [highlightedIndex, setHighlightedIndex] = useState(0);
77 let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]);
78 let [isSearching, setIsSearching] = useState(false);
79
80 const placeholderInputRef = useRef<HTMLButtonElement | null>(null);
81
82 let inputWidth = placeholderInputRef.current?.clientWidth;
83
84 // Fetch tags whenever the input value changes
85 useDebouncedEffect(
86 async () => {
87 setIsSearching(true);
88 const results = await searchTags(tagInputValue);
89 if (results) {
90 setSearchResults(results);
91 }
92 setIsSearching(false);
93 },
94 300,
95 [tagInputValue],
96 );
97
98 const filteredTags = searchResults
99 .filter((tag) => !props.selectedTags.includes(tag.name))
100 .filter((tag) =>
101 tag.name.toLowerCase().includes(tagInputValue.toLowerCase()),
102 );
103
104 const showResults = tagInputValue.length >= 3;
105
106 function clearTagInput() {
107 setHighlightedIndex(0);
108 setTagInputValue("");
109 }
110
111 function selectTag(tag: string) {
112 // Normalize tag to lowercase for consistent storage and querying
113 const normalizedTag = tag.toLowerCase();
114 console.log("selected " + normalizedTag);
115 props.setSelectedTags([...props.selectedTags, normalizedTag]);
116 clearTagInput();
117 }
118
119 const handleKeyDown = (
120 e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
121 ) => {
122 if (!isOpen) return;
123
124 if (e.key === "ArrowDown") {
125 e.preventDefault();
126 setHighlightedIndex((prev) =>
127 prev < filteredTags.length ? prev + 1 : prev,
128 );
129 } else if (e.key === "ArrowUp") {
130 e.preventDefault();
131 setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
132 } else if (e.key === "Enter") {
133 e.preventDefault();
134 selectTag(
135 userInputResult
136 ? highlightedIndex === 0
137 ? tagInputValue
138 : filteredTags[highlightedIndex - 1].name
139 : filteredTags[highlightedIndex].name,
140 );
141 clearTagInput();
142 } else if (e.key === "Escape") {
143 setIsOpen(false);
144 }
145 };
146
147 const userInputResult =
148 showResults &&
149 tagInputValue !== "" &&
150 !filteredTags.some((tag) => tag.name === tagInputValue);
151
152 return (
153 <div className="relative">
154 <Input
155 className="input-with-border grow w-full outline-none! lowercase"
156 id="placeholder-tag-search-input"
157 value={tagInputValue}
158 placeholder="search tags…"
159 onChange={(e) => {
160 setTagInputValue(e.target.value.toLowerCase());
161 setIsOpen(true);
162 setHighlightedIndex(0);
163 }}
164 onKeyDown={handleKeyDown}
165 onFocus={() => {
166 setIsOpen(true);
167 document.getElementById("tag-search-input")?.focus();
168 }}
169 />
170 <Popover
171 open={isOpen}
172 onOpenChange={() => {
173 setIsOpen(!isOpen);
174 if (!isOpen)
175 setTimeout(() => {
176 document.getElementById("tag-search-input")?.focus();
177 }, 100);
178 }}
179 className="w-full p-2! min-w-xs text-primary"
180 sideOffset={-39}
181 onOpenAutoFocus={(e) => e.preventDefault()}
182 asChild
183 trigger={
184 <button
185 ref={placeholderInputRef}
186 className="absolute left-0 top-0 right-0 h-[30px]"
187 ></button>
188 }
189 noArrow
190 >
191 <div className="" style={{ width: `${inputWidth}px` }}>
192 <Input
193 className="input-with-border grow w-full mb-2"
194 id="tag-search-input"
195 placeholder="search tags…"
196 value={tagInputValue}
197 onChange={(e) => {
198 setTagInputValue(e.target.value.toLowerCase());
199 setIsOpen(true);
200 setHighlightedIndex(0);
201 }}
202 onKeyDown={handleKeyDown}
203 onFocus={() => {
204 setIsOpen(true);
205 }}
206 />
207 {props.selectedTags.length > 0 ? (
208 <div className="flex flex-wrap gap-2 pb-[6px]">
209 {props.selectedTags.map((tag) => (
210 <Tag
211 key={tag}
212 name={tag}
213 selected
214 onDelete={() => {
215 props.setSelectedTags(
216 props.selectedTags.filter((t) => t !== tag),
217 );
218 }}
219 />
220 ))}
221 </div>
222 ) : (
223 <div className="text-tertiary italic text-sm h-6">
224 no tags selected
225 </div>
226 )}
227 <hr className=" mb-[2px] border-border-light" />
228
229 {showResults ? (
230 <>
231 {userInputResult && (
232 <TagResult
233 key={"userInput"}
234 index={0}
235 name={tagInputValue}
236 tagged={0}
237 highlighted={0 === highlightedIndex}
238 setHighlightedIndex={setHighlightedIndex}
239 onSelect={() => {
240 selectTag(tagInputValue);
241 }}
242 />
243 )}
244 {filteredTags.map((tag, i) => (
245 <TagResult
246 key={tag.name}
247 index={userInputResult ? i + 1 : i}
248 name={tag.name}
249 tagged={tag.document_count}
250 highlighted={
251 (userInputResult ? i + 1 : i) === highlightedIndex
252 }
253 setHighlightedIndex={setHighlightedIndex}
254 onSelect={() => {
255 selectTag(tag.name);
256 }}
257 />
258 ))}
259 </>
260 ) : (
261 <div className="text-tertiary italic text-sm py-1">
262 type at least 3 characters to search
263 </div>
264 )}
265 </div>
266 </Popover>
267 </div>
268 );
269};
270
271const TagResult = (props: {
272 name: string;
273 tagged: number;
274 onSelect: () => void;
275 index: number;
276 highlighted: boolean;
277 setHighlightedIndex: (i: number) => void;
278}) => {
279 return (
280 <div className="-mx-1">
281 <button
282 className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`}
283 onSelect={(e) => {
284 e.preventDefault();
285 props.onSelect();
286 }}
287 onClick={(e) => {
288 e.preventDefault();
289 props.onSelect();
290 }}
291 onMouseEnter={(e) => props.setHighlightedIndex(props.index)}
292 >
293 {props.name}
294 <div className="text-tertiary text-sm"> {props.tagged}</div>
295 </button>
296 </div>
297 );
298};