jat-feedback 3.0.0 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jat-feedback",
3
- "version": "3.0.0",
3
+ "version": "3.0.3",
4
4
  "description": "Embeddable feedback widget for bug reports and feature requests. Captures screenshots, console logs, and user context as a web component.",
5
5
  "type": "module",
6
6
  "main": "dist/jat-feedback.js",
@@ -31,14 +31,24 @@ export const load: PageServerLoad = async ({
31
31
  query = query.eq("priority", priority)
32
32
  }
33
33
 
34
- const { data: tasks, error } = await query.limit(100)
34
+ // Fetch tasks and team members in parallel
35
+ // NOTE: Customize the role filter for your project's profile roles
36
+ const [tasksResult, teamResult] = await Promise.all([
37
+ query.limit(100),
38
+ supabase
39
+ .from("profiles")
40
+ .select("id, full_name, role")
41
+ .not("full_name", "is", null)
42
+ .order("full_name"),
43
+ ])
35
44
 
36
- if (error) {
37
- console.error("Failed to load tasks:", error.message)
45
+ if (tasksResult.error) {
46
+ console.error("Failed to load tasks:", tasksResult.error.message)
38
47
  }
39
48
 
40
49
  return {
41
- tasks: tasks ?? [],
50
+ tasks: tasksResult.data ?? [],
51
+ teamMembers: teamResult.data ?? [],
42
52
  filters: { status, issueType, priority },
43
53
  user,
44
54
  }
@@ -1,9 +1,53 @@
1
1
  <script lang="ts">
2
- import { goto } from "$app/navigation"
2
+ import { goto, invalidate } from "$app/navigation"
3
3
  import { page } from "$app/state"
4
+ import { onMount } from "svelte"
5
+ import { SearchDropdown, InlineEdit } from "@joewinke/jatui"
6
+ import type { SearchDropdownGroup } from "@joewinke/jatui"
4
7
 
5
8
  let { data } = $props()
6
9
 
10
+ // ── Reactive task list (server data + realtime inserts) ──
11
+ let tasks = $state(data.tasks)
12
+
13
+ // Sync when server data changes (e.g. filter navigation)
14
+ $effect(() => {
15
+ tasks = data.tasks
16
+ })
17
+
18
+ // Realtime subscription — new/updated rows refresh the list
19
+ onMount(() => {
20
+ const channel = data.supabase
21
+ .channel("project_tasks_changes")
22
+ .on(
23
+ "postgres_changes",
24
+ { event: "*", schema: "public", table: "project_tasks" },
25
+ async () => {
26
+ // Re-fetch with current filters applied
27
+ let query = data.supabase
28
+ .from("project_tasks")
29
+ .select("*, project_tasks_comments(count)")
30
+ .order("created_at", { ascending: false })
31
+ .limit(100)
32
+
33
+ const status = page.url.searchParams.get("status")
34
+ const issueType = page.url.searchParams.get("type")
35
+ const priority = page.url.searchParams.get("priority")
36
+ if (status) query = query.eq("status", status)
37
+ if (issueType) query = query.eq("issue_type", issueType)
38
+ if (priority) query = query.eq("priority", priority)
39
+
40
+ const { data: fresh } = await query
41
+ if (fresh) tasks = fresh
42
+ },
43
+ )
44
+ .subscribe()
45
+
46
+ return () => {
47
+ data.supabase.removeChannel(channel)
48
+ }
49
+ })
50
+
7
51
  // ── Task detail drawer state ──
8
52
  let selectedTask: (typeof data.tasks)[0] | null = $state(null)
9
53
  let comments: { id: string; author: string; author_role: string; text: string; created_at: string }[] = $state([])
@@ -187,6 +231,77 @@
187
231
  }
188
232
  return 0
189
233
  }
234
+
235
+ // ── Assignee dropdown ──
236
+ const assigneeGroups: SearchDropdownGroup[] = $derived.by(() => {
237
+ const members = data.teamMembers || []
238
+ const options = [
239
+ { value: "", label: "Unassigned" },
240
+ ...members
241
+ .filter((m: { full_name: string | null }) => m.full_name)
242
+ .map((m: { full_name: string | null }) => ({
243
+ value: m.full_name!,
244
+ label: m.full_name!,
245
+ })),
246
+ ]
247
+ return [{ label: "Team", options }]
248
+ })
249
+
250
+ // ── Assignee update (wraps generic update, clears to null for empty) ──
251
+ function updateAssignee(taskId: string, value: string) {
252
+ updateTask(taskId, "assignee", value || null)
253
+ }
254
+
255
+ // ── Generic task update ──
256
+ async function updateTask(taskId: string, field: string, value: string | null) {
257
+ const { error } = await data.supabase
258
+ .from("project_tasks")
259
+ .update({ [field]: value })
260
+ .eq("id", taskId)
261
+
262
+ if (error) {
263
+ console.error(`Failed to update ${field}:`, error.message)
264
+ return
265
+ }
266
+
267
+ tasks = tasks.map((t) =>
268
+ t.id === taskId ? { ...t, [field]: value } : t,
269
+ )
270
+ if (selectedTask?.id === taskId) {
271
+ selectedTask = { ...selectedTask, [field]: value }
272
+ }
273
+ }
274
+
275
+ // ── Dropdown groups for editable fields ──
276
+ const typeGroups: SearchDropdownGroup[] = [
277
+ { label: "Type", options: [
278
+ { value: "bug", label: "Bug", icon: "🐛" },
279
+ { value: "feature", label: "Feature", icon: "✨" },
280
+ { value: "task", label: "Task", icon: "📋" },
281
+ { value: "epic", label: "Epic", icon: "🏔️" },
282
+ ]},
283
+ ]
284
+
285
+ const statusGroups: SearchDropdownGroup[] = [
286
+ { label: "Status", options: [
287
+ { value: "submitted", label: "Submitted" },
288
+ { value: "in_progress", label: "In Progress" },
289
+ { value: "completed", label: "Completed" },
290
+ { value: "accepted", label: "Accepted" },
291
+ { value: "rejected", label: "Rejected" },
292
+ { value: "wontfix", label: "Won't Fix" },
293
+ { value: "closed", label: "Closed" },
294
+ ]},
295
+ ]
296
+
297
+ const priorityGroups: SearchDropdownGroup[] = [
298
+ { label: "Priority", options: [
299
+ { value: "critical", label: "Critical" },
300
+ { value: "high", label: "High" },
301
+ { value: "medium", label: "Medium" },
302
+ { value: "low", label: "Low" },
303
+ ]},
304
+ ]
190
305
  </script>
191
306
 
192
307
  <svelte:head>
@@ -204,7 +319,7 @@
204
319
  </p>
205
320
  </div>
206
321
  <div class="text-sm text-base-content/50">
207
- {data.tasks.length} task{data.tasks.length !== 1 ? "s" : ""}
322
+ {tasks.length} task{tasks.length !== 1 ? "s" : ""}
208
323
  </div>
209
324
  </div>
210
325
 
@@ -251,7 +366,7 @@
251
366
  </div>
252
367
 
253
368
  <!-- Task list -->
254
- {#if data.tasks.length === 0}
369
+ {#if tasks.length === 0}
255
370
  <div class="text-center py-16 text-base-content/50">
256
371
  <div class="text-4xl mb-3">📭</div>
257
372
  <p class="text-lg font-medium">No tasks found</p>
@@ -265,8 +380,8 @@
265
380
  </div>
266
381
  {:else}
267
382
  <!-- Desktop table -->
268
- <div class="hidden sm:block overflow-x-auto">
269
- <table class="table table-sm">
383
+ <div class="hidden sm:block overflow-x-auto w-full">
384
+ <table class="table table-sm w-full">
270
385
  <thead>
271
386
  <tr class="text-base-content/60">
272
387
  <th>Title</th>
@@ -279,29 +394,50 @@
279
394
  </tr>
280
395
  </thead>
281
396
  <tbody>
282
- {#each data.tasks as task}
397
+ {#each tasks as task (task.id)}
283
398
  <tr
284
399
  class="hover:bg-base-200/50 cursor-pointer transition-colors"
285
400
  onclick={() => openTask(task)}
286
401
  >
287
- <td class="font-medium max-w-xs truncate">{task.title}</td>
288
- <td>
289
- <span class="text-sm" title={task.issue_type}>
290
- {typeIcon(task.issue_type)} {task.issue_type}
291
- </span>
402
+ <td class="font-medium max-w-xs" onclick={(e) => e.stopPropagation()}>
403
+ <InlineEdit
404
+ value={task.title}
405
+ onSave={(v) => updateTask(task.id, "title", v)}
406
+ truncate
407
+ placeholder="Untitled"
408
+ />
292
409
  </td>
293
- <td>
294
- <span class="badge badge-sm {statusColor(task.status)}">
295
- {statusLabel(task.status)}
296
- </span>
410
+ <td onclick={(e) => e.stopPropagation()}>
411
+ <SearchDropdown
412
+ value={task.issue_type}
413
+ groups={typeGroups}
414
+ placeholder="Type..."
415
+ onChange={(v) => updateTask(task.id, "issue_type", v)}
416
+ />
297
417
  </td>
298
- <td>
299
- <span class={priorityColor(task.priority)}>
300
- {task.priority || "—"}
301
- </span>
418
+ <td onclick={(e) => e.stopPropagation()}>
419
+ <SearchDropdown
420
+ value={task.status}
421
+ groups={statusGroups}
422
+ placeholder="Status..."
423
+ onChange={(v) => updateTask(task.id, "status", v)}
424
+ />
302
425
  </td>
303
- <td class="text-sm text-base-content/60">
304
- {task.assignee || "—"}
426
+ <td onclick={(e) => e.stopPropagation()}>
427
+ <SearchDropdown
428
+ value={task.priority || ""}
429
+ groups={priorityGroups}
430
+ placeholder="Priority..."
431
+ onChange={(v) => updateTask(task.id, "priority", v || null)}
432
+ />
433
+ </td>
434
+ <td class="text-sm" onclick={(e) => e.stopPropagation()}>
435
+ <SearchDropdown
436
+ value={task.assignee || ""}
437
+ groups={assigneeGroups}
438
+ placeholder="Assign..."
439
+ onChange={(v) => updateAssignee(task.id, v)}
440
+ />
305
441
  </td>
306
442
  <td class="text-sm text-base-content/60">
307
443
  {formatDate(task.created_at)}
@@ -321,7 +457,7 @@
321
457
 
322
458
  <!-- Mobile cards -->
323
459
  <div class="sm:hidden flex flex-col gap-3">
324
- {#each data.tasks as task}
460
+ {#each tasks as task (task.id)}
325
461
  <button
326
462
  class="card bg-base-100 shadow-sm border border-base-300 p-4 text-left w-full"
327
463
  onclick={() => openTask(task)}
@@ -337,9 +473,14 @@
337
473
  </div>
338
474
  <div class="flex items-center gap-3 mt-2 text-xs text-base-content/50">
339
475
  <span class={priorityColor(task.priority)}>{task.priority || "—"}</span>
340
- {#if task.assignee}
341
- <span>{task.assignee}</span>
342
- {/if}
476
+ <span onclick={(e) => e.stopPropagation()}>
477
+ <SearchDropdown
478
+ value={task.assignee || ""}
479
+ groups={assigneeGroups}
480
+ placeholder="Assign..."
481
+ onChange={(v) => updateAssignee(task.id, v)}
482
+ />
483
+ </span>
343
484
  <span>{formatDate(task.created_at)}</span>
344
485
  {#if commentCount(task) > 0}
345
486
  <span>💬 {commentCount(task)}</span>
@@ -364,9 +505,14 @@
364
505
  <div class="fixed inset-y-0 right-0 z-50 w-full max-w-lg bg-base-100 shadow-xl flex flex-col overflow-hidden">
365
506
  <!-- Header -->
366
507
  <div class="flex items-center justify-between px-5 py-4 border-b border-base-300">
367
- <div class="flex items-center gap-2 min-w-0">
508
+ <div class="flex items-center gap-2 min-w-0 flex-1">
368
509
  <span>{typeIcon(selectedTask.issue_type)}</span>
369
- <h2 class="text-lg font-bold truncate">{selectedTask.title}</h2>
510
+ <InlineEdit
511
+ value={selectedTask.title}
512
+ onSave={(v) => updateTask(selectedTask!.id, "title", v)}
513
+ class="text-lg font-bold"
514
+ placeholder="Untitled"
515
+ />
370
516
  </div>
371
517
  <button class="btn btn-ghost btn-sm btn-square" onclick={closeDrawer}>
372
518
 
@@ -375,17 +521,26 @@
375
521
 
376
522
  <!-- Scrollable content -->
377
523
  <div class="flex-1 overflow-y-auto px-5 py-4">
378
- <!-- Meta badges -->
524
+ <!-- Editable fields -->
379
525
  <div class="flex flex-wrap gap-2 mb-4">
380
- <span class="badge {statusColor(selectedTask.status)}">
381
- {statusLabel(selectedTask.status)}
382
- </span>
383
- <span class="badge badge-outline">{selectedTask.issue_type}</span>
384
- {#if selectedTask.priority}
385
- <span class="badge badge-outline {priorityColor(selectedTask.priority)}">
386
- {selectedTask.priority}
387
- </span>
388
- {/if}
526
+ <SearchDropdown
527
+ value={selectedTask.status}
528
+ groups={statusGroups}
529
+ placeholder="Status..."
530
+ onChange={(v) => updateTask(selectedTask!.id, "status", v)}
531
+ />
532
+ <SearchDropdown
533
+ value={selectedTask.issue_type}
534
+ groups={typeGroups}
535
+ placeholder="Type..."
536
+ onChange={(v) => updateTask(selectedTask!.id, "issue_type", v)}
537
+ />
538
+ <SearchDropdown
539
+ value={selectedTask.priority || ""}
540
+ groups={priorityGroups}
541
+ placeholder="Priority..."
542
+ onChange={(v) => updateTask(selectedTask!.id, "priority", v || null)}
543
+ />
389
544
  {#if selectedTask.source && selectedTask.source !== "feedback"}
390
545
  <span class="badge badge-ghost">{selectedTask.source}</span>
391
546
  {/if}
@@ -393,10 +548,15 @@
393
548
 
394
549
  <!-- Details grid -->
395
550
  <div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-6">
396
- {#if selectedTask.assignee}
397
- <div class="text-base-content/50">Assignee</div>
398
- <div>{selectedTask.assignee}</div>
399
- {/if}
551
+ <div class="text-base-content/50">Assignee</div>
552
+ <div>
553
+ <SearchDropdown
554
+ value={selectedTask.assignee || ""}
555
+ groups={assigneeGroups}
556
+ placeholder="Assign..."
557
+ onChange={(v) => updateAssignee(selectedTask!.id, v)}
558
+ />
559
+ </div>
400
560
  {#if selectedTask.due_date}
401
561
  <div class="text-base-content/50">Due date</div>
402
562
  <div>{formatDate(selectedTask.due_date)}</div>
@@ -443,8 +603,9 @@
443
603
  <h3 class="text-sm font-semibold text-base-content/60 mb-2">Screenshots</h3>
444
604
  <div class="flex gap-2 flex-wrap">
445
605
  {#each selectedTask.screenshot_paths as path}
446
- <a href={path} target="_blank" rel="noopener" class="block">
447
- <img src={path} alt="Screenshot" class="w-32 h-24 object-cover rounded border border-base-300" />
606
+ {@const proxyUrl = `/api/feedback/screenshots/${path}`}
607
+ <a href={proxyUrl} target="_blank" rel="noopener" class="block">
608
+ <img src={proxyUrl} alt="Screenshot" class="w-32 h-24 object-cover rounded border border-base-300" />
448
609
  </a>
449
610
  {/each}
450
611
  </div>
@@ -511,3 +672,27 @@
511
672
  </div>
512
673
  </div>
513
674
  {/if}
675
+
676
+ <style>
677
+ /* Elevate table cells and cards when a SearchDropdown is open,
678
+ so the panel renders above subsequent sibling rows/cards */
679
+ :global(td:has(.sd-panel)) {
680
+ position: relative;
681
+ z-index: 100;
682
+ }
683
+ :global(button.card:has(.sd-panel)) {
684
+ z-index: 100;
685
+ }
686
+ /* Ensure panel has an opaque, elevated background */
687
+ :global(.sd-panel) {
688
+ background: var(--color-base-300, var(--color-base-100, Canvas)) !important;
689
+ box-shadow: 0 8px 32px oklch(0 0 0 / 0.5) !important;
690
+ }
691
+ /* Hover highlight on dropdown options */
692
+ :global(.sd-panel .sd-option:hover) {
693
+ background: color-mix(in oklch, var(--color-base-content, CanvasText) 10%, var(--color-base-300, Canvas)) !important;
694
+ }
695
+ :global(.sd-panel .sd-option-selected) {
696
+ background: color-mix(in oklch, var(--color-primary, LinkText) 15%, var(--color-base-300, Canvas)) !important;
697
+ }
698
+ </style>