view who was fronting when a record was made
at main 10 kB view raw
1<script lang="ts"> 2 import { fetchMember, type MemberUri } from "@/lib/utils"; 3 4 interface Props { 5 fronters: string[]; 6 onUpdate: (fronters: string[]) => void; 7 label?: string; 8 placeholder?: string; 9 note?: string; 10 fetchNames?: boolean; // If true, treat as PK member IDs and fetch names 11 } 12 13 let { 14 fronters = $bindable([]), 15 onUpdate, 16 label = "FRONTERS", 17 placeholder = "enter_identifier", 18 note = "list of identifiers", 19 fetchNames = false, 20 }: Props = $props(); 21 22 let inputValue = $state(""); 23 let inputElement: HTMLInputElement; 24 let memberNames = $state<Map<string, string | null>>(new Map()); 25 let memberErrors = $state<Map<string, string>>(new Map()); 26 27 const fetchMemberName = async (memberId: string) => { 28 try { 29 const memberUri: MemberUri = { type: "pk", memberId }; 30 const name = await fetchMember(memberUri); 31 if (name) { 32 memberNames.set(memberId, name); 33 memberErrors.delete(memberId); 34 } else { 35 memberNames.set(memberId, null); 36 memberErrors.set(memberId, "Member not found"); 37 } 38 } catch (error) { 39 memberNames.set(memberId, null); 40 memberErrors.set(memberId, `Error: ${error}`); 41 } 42 // Trigger reactivity 43 memberNames = new Map(memberNames); 44 memberErrors = new Map(memberErrors); 45 }; 46 47 const addFronter = (name: string) => { 48 const trimmedName = name.trim(); 49 if (!trimmedName || fronters.includes(trimmedName)) return; 50 51 const updatedFronters = [...fronters, trimmedName]; 52 fronters = updatedFronters; 53 onUpdate(updatedFronters); 54 inputValue = ""; 55 56 // Fetch the member name if this is a PK fronter 57 if (fetchNames) { 58 fetchMemberName(trimmedName); 59 } 60 }; 61 62 const removeFronter = (index: number) => { 63 const identifier = fronters[index]; 64 const updatedFronters = fronters.filter((_, i) => i !== index); 65 fronters = updatedFronters; 66 onUpdate(updatedFronters); 67 68 // Clean up the member name cache if this is a PK fronter 69 if (fetchNames) { 70 memberNames.delete(identifier); 71 memberErrors.delete(identifier); 72 memberNames = new Map(memberNames); 73 memberErrors = new Map(memberErrors); 74 } 75 76 inputElement?.focus(); 77 }; 78 79 const handleKeyPress = (event: KeyboardEvent) => { 80 if (event.key === "Enter" || event.key === "," || event.key === " ") { 81 event.preventDefault(); 82 addFronter(inputValue); 83 } else if ( 84 event.key === "Backspace" && 85 inputValue === "" && 86 fronters.length > 0 87 ) { 88 // Remove last tag when backspacing on empty input 89 removeFronter(fronters.length - 1); 90 } 91 }; 92 93 const handleInput = (event: Event) => { 94 const target = event.target as HTMLInputElement; 95 const value = target.value; 96 97 // Check for comma or space at the end 98 if (value.endsWith(",") || value.endsWith(" ")) { 99 addFronter(value.slice(0, -1)); 100 } else { 101 inputValue = value; 102 } 103 }; 104 105 const focusInput = () => { 106 inputElement?.focus(); 107 }; 108 109 // Load existing member names on mount (only for PK fronters) 110 $effect(() => { 111 if (fetchNames) { 112 fronters.forEach((identifier) => { 113 if ( 114 !memberNames.has(identifier) && 115 !memberErrors.has(identifier) 116 ) { 117 fetchMemberName(identifier); 118 } 119 }); 120 } 121 }); 122 123 // Helper function to get display text for a fronter 124 const getDisplayText = (identifier: string) => { 125 if (!fetchNames) return identifier; 126 return memberNames.get(identifier) || identifier; 127 }; 128 129 // Helper function to check if we should show error/loading state 130 const getStatusInfo = (identifier: string) => { 131 if (!fetchNames) return null; 132 133 if (memberErrors.has(identifier)) { 134 return { type: "error", text: memberErrors.get(identifier) }; 135 } 136 if (memberNames.get(identifier) === undefined) { 137 return { type: "loading", text: "loading..." }; 138 } 139 return null; 140 }; 141</script> 142 143<div class="config-card"> 144 <div class="config-row"> 145 <span class="config-label">{label}</span> 146 <div 147 class="tag-input-container" 148 onclick={focusInput} 149 onkeydown={(e) => e.key === "Enter" && focusInput()} 150 role="textbox" 151 tabindex="0" 152 > 153 <div class="tag-input-wrapper"> 154 {#each fronters as identifier, index} 155 <div class="fronter-tag"> 156 <div class="tag-content"> 157 <span class="tag-text"> 158 {getDisplayText(identifier)} 159 </span> 160 {#if getStatusInfo(identifier)} 161 {@const status = getStatusInfo(identifier)} 162 {#if status} 163 <span class="tag-{status.type}" 164 >{status.text}</span 165 > 166 {/if} 167 {/if} 168 </div> 169 <button 170 onclick={() => removeFronter(index)} 171 class="tag-remove" 172 title="Remove fronter" 173 > 174 × 175 </button> 176 </div> 177 {/each} 178 <input 179 bind:this={inputElement} 180 type="text" 181 placeholder={fronters.length === 0 ? placeholder : ""} 182 value={inputValue} 183 oninput={handleInput} 184 onkeydown={handleKeyPress} 185 class="tag-input" 186 /> 187 </div> 188 </div> 189 </div> 190 191 <div class="config-note"> 192 <span class="note-text">{note}</span> 193 </div> 194</div> 195 196<style> 197 .config-card { 198 background: #0d0d0d; 199 border: 1px solid #2a2a2a; 200 border-left: 3px solid #444444; 201 padding: 10px; 202 display: flex; 203 flex-direction: column; 204 gap: 6px; 205 transition: border-left-color 0.2s ease; 206 } 207 208 .config-card:hover { 209 border-left-color: #555555; 210 } 211 212 .config-row { 213 display: flex; 214 align-items: center; 215 gap: 12px; 216 margin-bottom: 0; 217 } 218 219 .config-label { 220 font-size: 12px; 221 color: #cccccc; 222 letter-spacing: 1px; 223 font-weight: 700; 224 white-space: nowrap; 225 min-width: 90px; 226 } 227 228 .tag-input-container { 229 flex: 1; 230 background: #181818; 231 border: 1px solid #333333; 232 transition: border-color 0.2s ease; 233 cursor: text; 234 min-height: 42px; 235 display: flex; 236 align-items: center; 237 } 238 239 .tag-input-container:focus-within { 240 border-color: #666666; 241 } 242 243 .tag-input-container:focus-within:has(.fronter-tag) { 244 border-bottom-color: #00ff41; 245 } 246 247 .tag-input-wrapper { 248 display: flex; 249 flex-wrap: wrap; 250 align-items: center; 251 gap: 6px; 252 padding: 8px 12px; 253 width: 100%; 254 min-height: 26px; 255 } 256 257 .fronter-tag { 258 display: flex; 259 align-items: center; 260 background: #2a2a2a; 261 border: 1px solid #444444; 262 border-radius: 3px; 263 padding: 4px 6px; 264 gap: 6px; 265 font-size: 11px; 266 color: #ffffff; 267 font-weight: 600; 268 line-height: 1; 269 transition: all 0.15s ease; 270 animation: tagAppear 0.2s ease-out; 271 } 272 273 .fronter-tag:hover { 274 background: #333333; 275 border-color: #555555; 276 } 277 278 .tag-content { 279 display: flex; 280 flex-direction: column; 281 gap: 2px; 282 } 283 284 .tag-text { 285 white-space: nowrap; 286 letter-spacing: 0.5px; 287 } 288 289 .tag-error { 290 font-size: 9px; 291 color: #ff6666; 292 font-weight: 500; 293 letter-spacing: 0.3px; 294 } 295 296 .tag-loading { 297 font-size: 9px; 298 color: #888888; 299 font-weight: 500; 300 letter-spacing: 0.3px; 301 font-style: italic; 302 } 303 304 .tag-remove { 305 background: none; 306 border: none; 307 color: #888888; 308 font-size: 14px; 309 font-weight: 700; 310 cursor: pointer; 311 padding: 0; 312 line-height: 1; 313 transition: color 0.15s ease; 314 display: flex; 315 align-items: center; 316 justify-content: center; 317 width: 14px; 318 height: 14px; 319 margin-left: 2px; 320 } 321 322 .tag-remove:hover { 323 color: #ff4444; 324 } 325 326 .tag-input { 327 background: transparent; 328 border: none; 329 outline: none; 330 color: #ffffff; 331 font-family: inherit; 332 font-size: 12px; 333 font-weight: 500; 334 flex: 1; 335 min-width: 120px; 336 height: 26px; 337 } 338 339 .tag-input::placeholder { 340 color: #777777; 341 font-size: 12px; 342 } 343 344 .config-note { 345 padding: 0; 346 background: transparent; 347 border: none; 348 margin: 0; 349 } 350 351 .note-text { 352 font-size: 11px; 353 color: #bbbbbb; 354 line-height: 1.3; 355 font-weight: 500; 356 letter-spacing: 0.5px; 357 } 358 359 @keyframes tagAppear { 360 0% { 361 opacity: 0; 362 transform: scale(0.8); 363 } 364 100% { 365 opacity: 1; 366 transform: scale(1); 367 } 368 } 369</style>