a tool for shared writing and social publishing
at update/reader 295 lines 8.7 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 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};