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/dist/jat-feedback.js +42 -32
- package/dist/jat-feedback.mjs +2186 -2034
- package/package.json +1 -1
- package/routes/tasks/+page.server.ts +14 -4
- package/routes/tasks/+page.svelte +228 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jat-feedback",
|
|
3
|
-
"version": "3.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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
{
|
|
291
|
-
|
|
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
|
-
<
|
|
295
|
-
{
|
|
296
|
-
|
|
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
|
-
<
|
|
300
|
-
{task.
|
|
301
|
-
|
|
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
|
|
304
|
-
|
|
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
|
|
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
|
-
{
|
|
341
|
-
<
|
|
342
|
-
|
|
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
|
-
<
|
|
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
|
-
<!--
|
|
524
|
+
<!-- Editable fields -->
|
|
379
525
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
380
|
-
<
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
<
|
|
399
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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>
|