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 props.setSelectedTags([...props.selectedTags, tag]);
113 clearTagInput();
114 }
115
116 const handleKeyDown = (
117 e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
118 ) => {
119 if (!isOpen) return;
120
121 if (e.key === "ArrowDown") {
122 e.preventDefault();
123 setHighlightedIndex((prev) =>
124 prev < filteredTags.length ? prev + 1 : prev,
125 );
126 } else if (e.key === "ArrowUp") {
127 e.preventDefault();
128 setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
129 } else if (e.key === "Enter") {
130 e.preventDefault();
131 selectTag(
132 userInputResult
133 ? highlightedIndex === 0
134 ? tagInputValue
135 : filteredTags[highlightedIndex - 1].name
136 : filteredTags[highlightedIndex].name,
137 );
138 clearTagInput();
139 } else if (e.key === "Escape") {
140 setIsOpen(false);
141 }
142 };
143
144 const userInputResult =
145 showResults &&
146 tagInputValue !== "" &&
147 !filteredTags.some((tag) => tag.name === tagInputValue);
148
149 return (
150 <div className="relative">
151 <Input
152 className="input-with-border grow w-full outline-none!"
153 id="placeholder-tag-search-input"
154 value={tagInputValue}
155 placeholder="search tags…"
156 onChange={(e) => {
157 setTagInputValue(e.target.value);
158 setIsOpen(true);
159 setHighlightedIndex(0);
160 }}
161 onKeyDown={handleKeyDown}
162 onFocus={() => {
163 setIsOpen(true);
164 document.getElementById("tag-search-input")?.focus();
165 }}
166 />
167 <Popover
168 open={isOpen}
169 onOpenChange={() => {
170 setIsOpen(!isOpen);
171 if (!isOpen)
172 setTimeout(() => {
173 document.getElementById("tag-search-input")?.focus();
174 }, 100);
175 }}
176 className="w-full p-2! min-w-xs text-primary"
177 sideOffset={-39}
178 onOpenAutoFocus={(e) => e.preventDefault()}
179 asChild
180 trigger={
181 <button
182 ref={placeholderInputRef}
183 className="absolute left-0 top-0 right-0 h-[30px]"
184 ></button>
185 }
186 noArrow
187 >
188 <div className="" style={{ width: `${inputWidth}px` }}>
189 <Input
190 className="input-with-border grow w-full mb-2"
191 id="tag-search-input"
192 placeholder="search tags…"
193 value={tagInputValue}
194 onChange={(e) => {
195 setTagInputValue(e.target.value);
196 setIsOpen(true);
197 setHighlightedIndex(0);
198 }}
199 onKeyDown={handleKeyDown}
200 onFocus={() => {
201 setIsOpen(true);
202 }}
203 />
204 {props.selectedTags.length > 0 ? (
205 <div className="flex flex-wrap gap-2 pb-[6px]">
206 {props.selectedTags.map((tag) => (
207 <Tag
208 key={tag}
209 name={tag}
210 selected
211 onDelete={() => {
212 props.setSelectedTags(
213 props.selectedTags.filter((t) => t !== tag),
214 );
215 }}
216 />
217 ))}
218 </div>
219 ) : (
220 <div className="text-tertiary italic text-sm h-6">
221 no tags selected
222 </div>
223 )}
224 <hr className=" mb-[2px] border-border-light" />
225
226 {showResults ? (
227 <>
228 {userInputResult && (
229 <TagResult
230 key={"userInput"}
231 index={0}
232 name={tagInputValue}
233 tagged={0}
234 highlighted={0 === highlightedIndex}
235 setHighlightedIndex={setHighlightedIndex}
236 onSelect={() => {
237 selectTag(tagInputValue);
238 }}
239 />
240 )}
241 {filteredTags.map((tag, i) => (
242 <TagResult
243 key={tag.name}
244 index={userInputResult ? i + 1 : i}
245 name={tag.name}
246 tagged={tag.document_count}
247 highlighted={
248 (userInputResult ? i + 1 : i) === highlightedIndex
249 }
250 setHighlightedIndex={setHighlightedIndex}
251 onSelect={() => {
252 selectTag(tag.name);
253 }}
254 />
255 ))}
256 </>
257 ) : (
258 <div className="text-tertiary italic text-sm py-1">
259 type at least 3 characters to search
260 </div>
261 )}
262 </div>
263 </Popover>
264 </div>
265 );
266};
267
268const TagResult = (props: {
269 name: string;
270 tagged: number;
271 onSelect: () => void;
272 index: number;
273 highlighted: boolean;
274 setHighlightedIndex: (i: number) => void;
275}) => {
276 return (
277 <div className="-mx-1">
278 <button
279 className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`}
280 onSelect={(e) => {
281 e.preventDefault();
282 props.onSelect();
283 }}
284 onClick={(e) => {
285 e.preventDefault();
286 props.onSelect();
287 }}
288 onMouseEnter={(e) => props.setHighlightedIndex(props.index)}
289 >
290 {props.name}
291 <div className="text-tertiary text-sm"> {props.tagged}</div>
292 </button>
293 </div>
294 );
295};