a tool for shared writing and social publishing
at main 8.8 kB view raw
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};