a tool for shared writing and social publishing
at fix/tag-case-insensitive 298 lines 9.0 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 // 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};