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