view who was fronting when a record was made
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>