@joewinke/jatui 0.1.6 → 0.1.7

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": "@joewinke/jatui",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "vite": "^6.0.0"
45
45
  },
46
46
  "dependencies": {
47
+ "pdfjs-dist": "^5.6.205",
47
48
  "svelte-dnd-action": "^0.9.69"
48
49
  }
49
50
  }
@@ -0,0 +1,211 @@
1
+ <!--
2
+ AvatarUpload — circular avatar picker with client-side crop to square.
3
+ Shows current avatar or initials fallback. Click to select a new image.
4
+ Crops to square via canvas, fires onSelect(file) with the cropped File.
5
+ Parent handles the actual upload to storage.
6
+ -->
7
+ <script lang="ts">
8
+ interface Props {
9
+ /** Current avatar URL (from DB) */
10
+ avatarUrl?: string | null
11
+ /** User's name for initials fallback */
12
+ name?: string | null
13
+ /** User's email for initials fallback */
14
+ email?: string | null
15
+ /** Pixel size to crop/resize to */
16
+ cropSize?: number
17
+ /** Max file size in bytes before cropping */
18
+ maxSize?: number
19
+ /** Whether the upload is disabled */
20
+ disabled?: boolean
21
+ /** Whether an upload is in progress */
22
+ uploading?: boolean
23
+ /** Fired with the cropped File when user selects an image */
24
+ onSelect?: (file: File) => void
25
+ /** Fired when user clicks remove */
26
+ onRemove?: () => void
27
+ }
28
+
29
+ let {
30
+ avatarUrl = null,
31
+ name = null,
32
+ email = null,
33
+ cropSize = 256,
34
+ maxSize = 10 * 1024 * 1024,
35
+ disabled = false,
36
+ uploading = false,
37
+ onSelect = undefined,
38
+ onRemove = undefined,
39
+ }: Props = $props()
40
+
41
+ let error = $state("")
42
+ let previewUrl = $state<string | null>(null)
43
+ let fileInput: HTMLInputElement | undefined = $state()
44
+
45
+ const displayUrl = $derived(previewUrl ?? avatarUrl)
46
+
47
+ const initials = $derived.by(() => {
48
+ const source = name || email || ""
49
+ if (!source) return "?"
50
+ const parts = source.includes("@")
51
+ ? [source.split("@")[0]]
52
+ : source.split(" ").filter(Boolean)
53
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
54
+ return parts[0]?.charAt(0).toUpperCase() || "?"
55
+ })
56
+
57
+ function openPicker() {
58
+ if (disabled || uploading) return
59
+ fileInput?.click()
60
+ }
61
+
62
+ async function handleFileInput(event: Event) {
63
+ error = ""
64
+ const input = event.target as HTMLInputElement
65
+ if (!input.files?.length) return
66
+
67
+ const file = input.files[0]
68
+ if (file.size > maxSize) {
69
+ error = `File too large. Max ${formatSize(maxSize)}.`
70
+ input.value = ""
71
+ return
72
+ }
73
+
74
+ if (!file.type.startsWith("image/")) {
75
+ error = "Please select an image file."
76
+ input.value = ""
77
+ return
78
+ }
79
+
80
+ try {
81
+ const cropped = await cropToSquare(file, cropSize)
82
+ if (previewUrl) URL.revokeObjectURL(previewUrl)
83
+ previewUrl = URL.createObjectURL(cropped)
84
+ onSelect?.(cropped)
85
+ } catch (e) {
86
+ error = "Failed to process image."
87
+ console.error("AvatarUpload crop error:", e)
88
+ }
89
+ input.value = ""
90
+ }
91
+
92
+ function handleRemove() {
93
+ if (previewUrl) {
94
+ URL.revokeObjectURL(previewUrl)
95
+ previewUrl = null
96
+ }
97
+ onRemove?.()
98
+ }
99
+
100
+ function cropToSquare(file: File, size: number): Promise<File> {
101
+ return new Promise((resolve, reject) => {
102
+ const img = new Image()
103
+ img.onload = () => {
104
+ const canvas = document.createElement("canvas")
105
+ canvas.width = size
106
+ canvas.height = size
107
+ const ctx = canvas.getContext("2d")
108
+ if (!ctx) { reject(new Error("No canvas context")); return }
109
+
110
+ // Center-crop: take the largest centered square from the source
111
+ const min = Math.min(img.width, img.height)
112
+ const sx = (img.width - min) / 2
113
+ const sy = (img.height - min) / 2
114
+
115
+ ctx.drawImage(img, sx, sy, min, min, 0, 0, size, size)
116
+ canvas.toBlob(
117
+ (blob) => {
118
+ if (!blob) { reject(new Error("Canvas toBlob failed")); return }
119
+ resolve(new File([blob], "avatar.webp", { type: "image/webp" }))
120
+ },
121
+ "image/webp",
122
+ 0.85,
123
+ )
124
+ URL.revokeObjectURL(img.src)
125
+ }
126
+ img.onerror = () => { URL.revokeObjectURL(img.src); reject(new Error("Image load failed")) }
127
+ img.src = URL.createObjectURL(file)
128
+ })
129
+ }
130
+
131
+ function formatSize(bytes: number): string {
132
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
133
+ return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
134
+ }
135
+
136
+ $effect(() => {
137
+ return () => {
138
+ if (previewUrl) URL.revokeObjectURL(previewUrl)
139
+ }
140
+ })
141
+ </script>
142
+
143
+ <div class="flex items-center gap-4">
144
+ <!-- Avatar circle -->
145
+ <button
146
+ type="button"
147
+ class="relative group rounded-full overflow-hidden w-20 h-20 shrink-0 cursor-pointer border-2 border-base-300 hover:border-primary transition-colors"
148
+ class:opacity-50={disabled || uploading}
149
+ onclick={openPicker}
150
+ {disabled}
151
+ >
152
+ {#if displayUrl}
153
+ <img src={displayUrl} alt="Avatar" class="w-full h-full object-cover" />
154
+ {:else}
155
+ <div class="w-full h-full bg-neutral text-neutral-content flex items-center justify-center text-2xl font-semibold">
156
+ {initials}
157
+ </div>
158
+ {/if}
159
+
160
+ <!-- Hover overlay -->
161
+ {#if !disabled && !uploading}
162
+ <div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
163
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
164
+ <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
165
+ <circle cx="12" cy="13" r="4"/>
166
+ </svg>
167
+ </div>
168
+ {/if}
169
+
170
+ <!-- Loading spinner -->
171
+ {#if uploading}
172
+ <div class="absolute inset-0 bg-black/40 flex items-center justify-center">
173
+ <span class="loading loading-spinner loading-sm text-white"></span>
174
+ </div>
175
+ {/if}
176
+ </button>
177
+
178
+ <!-- Label + remove -->
179
+ <div class="flex flex-col gap-1">
180
+ <button
181
+ type="button"
182
+ class="btn btn-sm btn-ghost text-primary"
183
+ class:btn-disabled={disabled || uploading}
184
+ onclick={openPicker}
185
+ >
186
+ {displayUrl ? "Change photo" : "Upload photo"}
187
+ </button>
188
+ {#if displayUrl && onRemove}
189
+ <button
190
+ type="button"
191
+ class="btn btn-sm btn-ghost text-error"
192
+ class:btn-disabled={disabled || uploading}
193
+ onclick={handleRemove}
194
+ >
195
+ Remove
196
+ </button>
197
+ {/if}
198
+ </div>
199
+
200
+ <input
201
+ bind:this={fileInput}
202
+ type="file"
203
+ class="hidden"
204
+ accept="image/*"
205
+ onchange={handleFileInput}
206
+ />
207
+ </div>
208
+
209
+ {#if error}
210
+ <p class="text-error text-sm mt-2">{error}</p>
211
+ {/if}
@@ -79,6 +79,8 @@
79
79
  </button>
80
80
 
81
81
  {#if open}
82
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
83
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
82
84
  <div
83
85
  class="emoji-picker-dropdown absolute left-0 top-full mt-1 z-50 rounded-lg overflow-hidden"
84
86
  style="background: oklch(0.18 0.02 250); border: 1px solid oklch(0.30 0.02 250); box-shadow: 0 8px 32px oklch(0 0 0 / 0.5); width: 280px;"
@@ -0,0 +1,419 @@
1
+ <script lang="ts">
2
+ /**
3
+ * FilePicker — Browse and select files from Supabase storage.
4
+ *
5
+ * Opens a modal to browse existing uploads (personal + team) from the
6
+ * user-files bucket, with inline upload support. Returns selected file
7
+ * paths/URLs via onSelect callback.
8
+ */
9
+
10
+ import type { FilePickerFile, FilePickerSelection } from "../types/filePicker"
11
+
12
+ interface Props {
13
+ open?: boolean
14
+ multiple?: boolean
15
+ accept?: string
16
+ title?: string
17
+ apiBase?: string
18
+ onSelect?: (selections: FilePickerSelection[]) => void
19
+ onclose?: () => void
20
+ }
21
+
22
+ let {
23
+ open = $bindable(false),
24
+ multiple = false,
25
+ accept = "",
26
+ title = "Select File",
27
+ apiBase = "/api/files",
28
+ onSelect,
29
+ onclose,
30
+ }: Props = $props()
31
+
32
+ let activeTab = $state<"personal" | "team">("personal")
33
+ let personalFiles = $state<FilePickerFile[]>([])
34
+ let teamFiles = $state<FilePickerFile[]>([])
35
+ let loading = $state(false)
36
+ let error = $state<string | null>(null)
37
+ let selected = $state<Set<string>>(new Set())
38
+ let isUploading = $state(false)
39
+ let uploadError = $state<string | null>(null)
40
+ let searchQuery = $state("")
41
+ let fileInput = $state<HTMLInputElement>(undefined!)
42
+
43
+ const currentFiles = $derived(activeTab === "personal" ? personalFiles : teamFiles)
44
+
45
+ const filteredFiles = $derived(() => {
46
+ let files = currentFiles
47
+ if (searchQuery.trim()) {
48
+ const q = searchQuery.toLowerCase()
49
+ files = files.filter((f) => f.name.toLowerCase().includes(q))
50
+ }
51
+ if (accept) {
52
+ const acceptTypes = accept.split(",").map((t) => t.trim().toLowerCase())
53
+ files = files.filter((f) => {
54
+ const mime = f.metadata?.mimetype || ""
55
+ const ext = "." + f.name.split(".").pop()?.toLowerCase()
56
+ return acceptTypes.some(
57
+ (t) =>
58
+ t === mime ||
59
+ t === ext ||
60
+ (t.endsWith("/*") && mime.startsWith(t.replace("/*", "/")))
61
+ )
62
+ })
63
+ }
64
+ return files
65
+ })
66
+
67
+ $effect(() => {
68
+ if (open) {
69
+ selected = new Set()
70
+ searchQuery = ""
71
+ uploadError = null
72
+ loadFiles()
73
+ }
74
+ })
75
+
76
+ async function loadFiles() {
77
+ loading = true
78
+ error = null
79
+ try {
80
+ const res = await fetch(`${apiBase}/list`)
81
+ if (!res.ok) throw new Error("Failed to load files")
82
+ const data = await res.json()
83
+ personalFiles = (data.personalFiles || []).map((f: any) => ({
84
+ ...f,
85
+ scope: "personal" as const,
86
+ }))
87
+ teamFiles = (data.teamFiles || []).map((f: any) => ({
88
+ ...f,
89
+ scope: "team" as const,
90
+ }))
91
+ } catch (err) {
92
+ error = err instanceof Error ? err.message : "Failed to load files"
93
+ } finally {
94
+ loading = false
95
+ }
96
+ }
97
+
98
+ function toggleSelect(file: FilePickerFile) {
99
+ const key = `${file.scope}:${file.name}`
100
+ if (multiple) {
101
+ const next = new Set(selected)
102
+ if (next.has(key)) next.delete(key)
103
+ else next.add(key)
104
+ selected = next
105
+ } else {
106
+ selected = new Set([key])
107
+ }
108
+ }
109
+
110
+ function isSelected(file: FilePickerFile): boolean {
111
+ return selected.has(`${file.scope}:${file.name}`)
112
+ }
113
+
114
+ function confirmSelection() {
115
+ const allFiles = [...personalFiles, ...teamFiles]
116
+ const selections: FilePickerSelection[] = []
117
+ for (const key of selected) {
118
+ const [scope, ...nameParts] = key.split(":")
119
+ const name = nameParts.join(":")
120
+ const file = allFiles.find((f) => f.scope === scope && f.name === name)
121
+ if (file) {
122
+ selections.push({
123
+ file,
124
+ url: `${apiBase}/${encodeURIComponent(file.name)}/view?scope=${file.scope}`,
125
+ downloadUrl: `${apiBase}/${encodeURIComponent(file.name)}/download?scope=${file.scope}`,
126
+ })
127
+ }
128
+ }
129
+ onSelect?.(selections)
130
+ close()
131
+ }
132
+
133
+ function close() {
134
+ open = false
135
+ onclose?.()
136
+ }
137
+
138
+ async function handleUpload(e: Event) {
139
+ const input = e.target as HTMLInputElement
140
+ if (!input.files?.length) return
141
+
142
+ isUploading = true
143
+ uploadError = null
144
+
145
+ for (const file of Array.from(input.files)) {
146
+ try {
147
+ const formData = new FormData()
148
+ formData.append("file", file)
149
+ formData.append("scope", activeTab)
150
+
151
+ const res = await fetch(apiBase, { method: "POST", body: formData })
152
+ if (!res.ok) {
153
+ const err = await res.json().catch(() => ({}))
154
+ throw new Error(err.message || `Failed to upload ${file.name}`)
155
+ }
156
+ } catch (err) {
157
+ uploadError =
158
+ err instanceof Error ? err.message : `Failed to upload ${file.name}`
159
+ }
160
+ }
161
+
162
+ input.value = ""
163
+ isUploading = false
164
+ await loadFiles()
165
+ }
166
+
167
+ function isImage(file: FilePickerFile): boolean {
168
+ return file.metadata?.mimetype?.startsWith("image/") ?? false
169
+ }
170
+
171
+ function isPdf(file: FilePickerFile): boolean {
172
+ return file.metadata?.mimetype === "application/pdf"
173
+ }
174
+
175
+ function getPreviewUrl(file: FilePickerFile): string {
176
+ return `${apiBase}/${encodeURIComponent(file.name)}/view?scope=${file.scope}`
177
+ }
178
+
179
+ function formatSize(bytes: number | undefined): string {
180
+ if (!bytes) return ""
181
+ const k = 1024
182
+ const sizes = ["B", "KB", "MB", "GB"]
183
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
184
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
185
+ }
186
+
187
+ function getFileExtension(name: string): string {
188
+ return name.split(".").pop()?.toUpperCase() || "FILE"
189
+ }
190
+ </script>
191
+
192
+ {#if open}
193
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
194
+ <dialog
195
+ class="modal modal-open"
196
+ aria-modal="true"
197
+ aria-label={title}
198
+ open
199
+ onkeydown={(e) => {
200
+ if (e.key === "Escape") {
201
+ e.preventDefault()
202
+ close()
203
+ }
204
+ }}
205
+ tabindex="-1"
206
+ style="z-index: 1000"
207
+ >
208
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
209
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
210
+ <div
211
+ class="modal-backdrop"
212
+ onclick={(e) => {
213
+ if (e.target === e.currentTarget) close()
214
+ }}
215
+ >
216
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
217
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
218
+ <div
219
+ class="modal-box relative bg-base-100 text-base-content max-w-3xl w-full max-h-[80vh] flex flex-col"
220
+ onclick={(e) => e.stopPropagation()}
221
+ >
222
+ <!-- Header -->
223
+ <div class="flex items-center justify-between mb-4 shrink-0">
224
+ <h2 class="text-lg font-semibold">{title}</h2>
225
+ <button
226
+ type="button"
227
+ class="btn btn-sm btn-circle btn-ghost"
228
+ onclick={close}
229
+ aria-label="Close"
230
+ >
231
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
233
+ </svg>
234
+ </button>
235
+ </div>
236
+
237
+ <!-- Tabs + Search + Upload -->
238
+ <div class="flex items-center gap-3 mb-4 shrink-0 flex-wrap">
239
+ <div role="tablist" class="tabs tabs-boxed tabs-sm">
240
+ <button
241
+ role="tab"
242
+ class="tab {activeTab === 'personal' ? 'tab-active' : ''}"
243
+ onclick={() => (activeTab = "personal")}
244
+ >
245
+ My Files
246
+ {#if personalFiles.length > 0}
247
+ <span class="badge badge-sm ml-1">{personalFiles.length}</span>
248
+ {/if}
249
+ </button>
250
+ <button
251
+ role="tab"
252
+ class="tab {activeTab === 'team' ? 'tab-active' : ''}"
253
+ onclick={() => (activeTab = "team")}
254
+ >
255
+ Team Files
256
+ {#if teamFiles.length > 0}
257
+ <span class="badge badge-sm badge-primary ml-1">{teamFiles.length}</span>
258
+ {/if}
259
+ </button>
260
+ </div>
261
+
262
+ <input
263
+ type="text"
264
+ class="input input-sm input-bordered flex-1 min-w-[120px]"
265
+ placeholder="Search files..."
266
+ bind:value={searchQuery}
267
+ />
268
+
269
+ <input
270
+ bind:this={fileInput}
271
+ type="file"
272
+ multiple
273
+ accept={accept || undefined}
274
+ onchange={handleUpload}
275
+ class="hidden"
276
+ />
277
+ <button
278
+ class="btn btn-sm btn-primary"
279
+ onclick={() => fileInput?.click()}
280
+ disabled={isUploading}
281
+ >
282
+ {#if isUploading}
283
+ <span class="loading loading-spinner loading-xs"></span>
284
+ {:else}
285
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
286
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
287
+ </svg>
288
+ {/if}
289
+ Upload
290
+ </button>
291
+ </div>
292
+
293
+ {#if uploadError}
294
+ <div class="alert alert-warning alert-sm mb-3 shrink-0">
295
+ <span class="text-sm">{uploadError}</span>
296
+ <button class="btn btn-xs btn-ghost" onclick={() => (uploadError = null)}>Dismiss</button>
297
+ </div>
298
+ {/if}
299
+
300
+ <!-- File grid -->
301
+ <div class="flex-1 overflow-y-auto min-h-0">
302
+ {#if loading}
303
+ <div class="flex items-center justify-center py-16">
304
+ <span class="loading loading-spinner loading-lg text-primary"></span>
305
+ </div>
306
+ {:else if error}
307
+ <div class="text-center py-16">
308
+ <p class="text-error text-sm">{error}</p>
309
+ <button class="btn btn-sm btn-ghost mt-2" onclick={loadFiles}>Retry</button>
310
+ </div>
311
+ {:else if filteredFiles().length === 0}
312
+ <div class="text-center py-16">
313
+ <svg class="mx-auto h-10 w-10 text-base-content/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
314
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
315
+ </svg>
316
+ <p class="mt-3 text-sm text-base-content/50">
317
+ {searchQuery ? "No files match your search" : "No files yet. Upload one above."}
318
+ </p>
319
+ </div>
320
+ {:else}
321
+ <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
322
+ {#each filteredFiles() as file (file.id)}
323
+ <button
324
+ class="flex flex-col bg-base-100 border rounded-lg overflow-hidden text-left transition-all duration-150 hover:shadow-md
325
+ {isSelected(file)
326
+ ? 'border-primary ring-2 ring-primary/30'
327
+ : 'border-base-300 hover:border-base-content/20'}"
328
+ onclick={() => toggleSelect(file)}
329
+ ondblclick={() => {
330
+ toggleSelect(file)
331
+ confirmSelection()
332
+ }}
333
+ >
334
+ <!-- Thumbnail -->
335
+ <div class="aspect-square bg-base-200 flex items-center justify-center overflow-hidden relative">
336
+ {#if isImage(file)}
337
+ <img
338
+ src={getPreviewUrl(file)}
339
+ alt={file.name}
340
+ class="w-full h-full object-cover"
341
+ loading="lazy"
342
+ />
343
+ {:else if isPdf(file)}
344
+ <div class="text-center">
345
+ <svg class="mx-auto h-8 w-8 text-error/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
346
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
347
+ </svg>
348
+ <span class="text-xs text-error/60 font-mono mt-1 block">PDF</span>
349
+ </div>
350
+ {:else}
351
+ <div class="text-center">
352
+ <svg class="mx-auto h-8 w-8 text-base-content/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
353
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
354
+ </svg>
355
+ <span class="text-xs text-base-content/40 font-mono mt-1 block">
356
+ {getFileExtension(file.name)}
357
+ </span>
358
+ </div>
359
+ {/if}
360
+ {#if isSelected(file)}
361
+ <div class="absolute top-1.5 right-1.5 bg-primary text-primary-content rounded-full w-5 h-5 flex items-center justify-center">
362
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
363
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
364
+ </svg>
365
+ </div>
366
+ {/if}
367
+ </div>
368
+
369
+ <!-- File info -->
370
+ <div class="p-2">
371
+ <p class="text-xs font-medium text-base-content truncate" title={file.name}>
372
+ {file.name}
373
+ </p>
374
+ {#if file.metadata?.size}
375
+ <p class="text-xs text-base-content/50 mt-0.5">
376
+ {formatSize(file.metadata.size)}
377
+ </p>
378
+ {/if}
379
+ </div>
380
+ </button>
381
+ {/each}
382
+ </div>
383
+ {/if}
384
+ </div>
385
+
386
+ <!-- Footer -->
387
+ <div class="flex items-center justify-between mt-4 pt-3 border-t border-base-300 shrink-0">
388
+ <span class="text-sm text-base-content/50">
389
+ {#if selected.size > 0}
390
+ {selected.size} file{selected.size > 1 ? "s" : ""} selected
391
+ {:else}
392
+ {multiple ? "Click to select files" : "Click to select a file"}
393
+ {/if}
394
+ </span>
395
+ <div class="flex gap-2">
396
+ <button class="btn btn-sm btn-ghost" onclick={close}>Cancel</button>
397
+ <button
398
+ class="btn btn-sm btn-primary"
399
+ disabled={selected.size === 0}
400
+ onclick={confirmSelection}
401
+ >
402
+ {multiple ? `Select (${selected.size})` : "Select"}
403
+ </button>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </dialog>
409
+ {/if}
410
+
411
+ <style>
412
+ .modal-backdrop {
413
+ position: absolute;
414
+ inset: 0;
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: center;
418
+ }
419
+ </style>
@@ -0,0 +1,80 @@
1
+ <script lang="ts">
2
+ import type { Milestone, MilestoneStatus } from '../types/milestone'
3
+
4
+ interface Props {
5
+ milestone: Milestone
6
+ isCurrent?: boolean
7
+ currency?: string
8
+ onAcceptAndPay?: (milestone: Milestone) => void
9
+ }
10
+
11
+ let { milestone, isCurrent = false, currency = 'USD', onAcceptAndPay }: Props = $props()
12
+
13
+ const statusConfig: Record<MilestoneStatus, { label: string; class: string; icon: string }> = {
14
+ pending: { label: 'Pending', class: 'badge-ghost', icon: '○' },
15
+ delivered: { label: 'Delivered', class: 'badge-info', icon: '◉' },
16
+ accepted: { label: 'Accepted', class: 'badge-warning', icon: '◉' },
17
+ paid: { label: 'Paid', class: 'badge-success', icon: '●' }
18
+ }
19
+
20
+ const config = $derived(statusConfig[milestone.status])
21
+
22
+ function formatCurrency(amount: number) {
23
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
24
+ }
25
+
26
+ function formatDate(dateStr: string | null) {
27
+ if (!dateStr) return null
28
+ return new Date(dateStr).toLocaleDateString('en-US', {
29
+ month: 'short',
30
+ day: 'numeric',
31
+ year: 'numeric'
32
+ })
33
+ }
34
+ </script>
35
+
36
+ <div
37
+ class="card bg-base-100 border border-base-300 {isCurrent
38
+ ? 'ring-2 ring-primary/50 shadow-lg'
39
+ : ''}"
40
+ >
41
+ <div class="card-body p-4 gap-2">
42
+ <div class="flex items-start justify-between gap-2">
43
+ <div class="flex-1 min-w-0">
44
+ <h4 class="font-semibold text-base-content text-sm">{milestone.name}</h4>
45
+ {#if milestone.description}
46
+ <p class="text-xs text-base-content/60 mt-0.5">{milestone.description}</p>
47
+ {/if}
48
+ </div>
49
+ <div class="flex items-center gap-2 flex-shrink-0">
50
+ <span class="font-semibold text-sm">{formatCurrency(milestone.amount)}</span>
51
+ <span class="badge badge-sm {config.class}">{config.icon} {config.label}</span>
52
+ </div>
53
+ </div>
54
+
55
+ {#if milestone.acceptance_criteria}
56
+ <div class="text-xs text-base-content/50 mt-1">
57
+ <span class="font-medium">Acceptance:</span>
58
+ {milestone.acceptance_criteria}
59
+ </div>
60
+ {/if}
61
+
62
+ <div class="flex items-center gap-3 text-xs text-base-content/40 mt-1">
63
+ <span>{milestone.percentage}% of total</span>
64
+ {#if milestone.delivered_at}
65
+ <span>Delivered {formatDate(milestone.delivered_at)}</span>
66
+ {/if}
67
+ {#if milestone.paid_at}
68
+ <span>Paid {formatDate(milestone.paid_at)}</span>
69
+ {/if}
70
+ </div>
71
+
72
+ {#if isCurrent && milestone.status === 'delivered' && onAcceptAndPay}
73
+ <div class="mt-2">
74
+ <button class="btn btn-primary btn-sm" onclick={() => onAcceptAndPay(milestone)}>
75
+ Accept & Pay {formatCurrency(milestone.amount)}
76
+ </button>
77
+ </div>
78
+ {/if}
79
+ </div>
80
+ </div>
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import type { Milestone } from '../types/milestone'
3
+ import MilestoneCard from './MilestoneCard.svelte'
4
+
5
+ interface Props {
6
+ milestones: Milestone[]
7
+ currency?: string
8
+ onAcceptAndPay?: (milestone: Milestone) => void
9
+ }
10
+
11
+ let { milestones, currency = 'USD', onAcceptAndPay }: Props = $props()
12
+
13
+ const sorted = $derived([...milestones].sort((a, b) => a.sort_order - b.sort_order))
14
+
15
+ const currentIndex = $derived(sorted.findIndex((m) => m.status !== 'paid'))
16
+
17
+ const paidCount = $derived(sorted.filter((m) => m.status === 'paid').length)
18
+ const totalAmount = $derived(sorted.reduce((sum, m) => sum + m.amount, 0))
19
+ const paidAmount = $derived(
20
+ sorted.filter((m) => m.status === 'paid').reduce((sum, m) => sum + m.amount, 0)
21
+ )
22
+
23
+ function formatCurrency(amount: number) {
24
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
25
+ }
26
+ </script>
27
+
28
+ <div class="space-y-1">
29
+ <!-- Progress summary -->
30
+ <div class="flex items-center justify-between text-sm mb-4">
31
+ <span class="text-base-content/60">
32
+ {paidCount} of {sorted.length} milestones paid
33
+ </span>
34
+ <span class="font-semibold">
35
+ {formatCurrency(paidAmount)} / {formatCurrency(totalAmount)}
36
+ </span>
37
+ </div>
38
+
39
+ <!-- Progress bar -->
40
+ {#if totalAmount > 0}
41
+ <div class="w-full bg-base-300 rounded-full h-2 mb-6">
42
+ <div
43
+ class="bg-success h-2 rounded-full transition-all duration-500"
44
+ style="width: {(paidAmount / totalAmount) * 100}%"
45
+ ></div>
46
+ </div>
47
+ {/if}
48
+
49
+ <!-- Timeline -->
50
+ <div class="relative">
51
+ {#each sorted as milestone, i (milestone.id)}
52
+ <div class="flex gap-4">
53
+ <!-- Timeline connector -->
54
+ <div class="flex flex-col items-center w-6 flex-shrink-0">
55
+ <div
56
+ class="w-3 h-3 rounded-full border-2 {milestone.status === 'paid'
57
+ ? 'bg-success border-success'
58
+ : i === currentIndex
59
+ ? 'bg-primary border-primary'
60
+ : 'bg-base-300 border-base-300'}"
61
+ ></div>
62
+ {#if i < sorted.length - 1}
63
+ <div
64
+ class="w-0.5 flex-1 min-h-4 {milestone.status === 'paid'
65
+ ? 'bg-success/50'
66
+ : 'bg-base-300'}"
67
+ ></div>
68
+ {/if}
69
+ </div>
70
+
71
+ <!-- Card -->
72
+ <div class="flex-1 pb-4">
73
+ <MilestoneCard
74
+ {milestone}
75
+ isCurrent={i === currentIndex}
76
+ {currency}
77
+ {onAcceptAndPay}
78
+ />
79
+ </div>
80
+ </div>
81
+ {/each}
82
+ </div>
83
+ </div>
@@ -155,6 +155,7 @@
155
155
  onkeydown={handleKeyDown}
156
156
  tabindex="-1"
157
157
  >
158
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
158
159
  <!-- svelte-ignore a11y_no_static_element_interactions -->
159
160
  <div class="modal-backdrop" onclick={handleBackdropClick}>
160
161
  <div class={contentClasses}>
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte"
3
+
4
+ interface Props {
5
+ /** URL or path to the PDF file */
6
+ src: string
7
+ /** CSS class for the container */
8
+ class?: string
9
+ }
10
+
11
+ let { src, class: className = "" }: Props = $props()
12
+ let canvas: HTMLCanvasElement
13
+ let loading = $state(true)
14
+ let failed = $state(false)
15
+
16
+ onMount(async () => {
17
+ try {
18
+ const pdfjsLib = await import("pdfjs-dist")
19
+ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
20
+ "pdfjs-dist/build/pdf.worker.mjs",
21
+ import.meta.url,
22
+ ).toString()
23
+
24
+ const response = await fetch(src)
25
+ const buffer = await response.arrayBuffer()
26
+ const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
27
+ const page = await pdf.getPage(1)
28
+
29
+ const containerWidth = canvas.parentElement?.clientWidth || 300
30
+ const viewport = page.getViewport({ scale: 1 })
31
+ const scale = containerWidth / viewport.width
32
+ const scaled = page.getViewport({ scale })
33
+
34
+ canvas.width = scaled.width
35
+ canvas.height = scaled.height
36
+
37
+ await page.render({
38
+ canvasContext: canvas.getContext("2d")!,
39
+ viewport: scaled,
40
+ }).promise
41
+
42
+ loading = false
43
+ } catch {
44
+ failed = true
45
+ loading = false
46
+ }
47
+ })
48
+ </script>
49
+
50
+ {#if failed}
51
+ <div class={className}>
52
+ <svg class="h-14 w-14 text-error/60" fill="currentColor" viewBox="0 0 24 24">
53
+ <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M10.92,12.31C10.68,11.54 10.15,9.08 11.55,9.04C12.95,9 12.03,12.16 12.03,12.16C12.42,13.65 14.05,14.72 14.05,14.72C14.55,14.57 17.4,14.24 17,15.72C16.57,17.2 13.5,15.81 13.5,15.81C11.55,15.95 10.09,16.47 10.09,16.47C8.96,18.58 7.64,19.5 7.1,18.61C6.43,17.5 9.23,16.07 9.23,16.07C10.68,13.67 10.92,12.31 10.92,12.31Z" />
54
+ </svg>
55
+ </div>
56
+ {:else}
57
+ {#if loading}
58
+ <span class="loading loading-spinner loading-sm text-base-content/30"></span>
59
+ {/if}
60
+ <canvas bind:this={canvas} class="w-full h-full object-cover {className}" class:hidden={loading}></canvas>
61
+ {/if}
@@ -165,6 +165,7 @@
165
165
  style="width: {width}px;"
166
166
  >
167
167
  {#if enableResize}
168
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
168
169
  <div
169
170
  class="resize-handle left"
170
171
  style="right: 0;"
@@ -173,6 +174,7 @@
173
174
  >
174
175
  <div class="handle-indicator"></div>
175
176
  </div>
177
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
176
178
  <div
177
179
  class="resize-handle right"
178
180
  style="left: 0;"
@@ -184,6 +186,7 @@
184
186
  {/if}
185
187
 
186
188
  {#if enableDragToSwap}
189
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
187
190
  <div
188
191
  class="drag-handle"
189
192
  onmousedown={handleDragStart}
@@ -15,12 +15,16 @@
15
15
  import { tick } from 'svelte';
16
16
  import { slide } from 'svelte/transition';
17
17
 
18
+ import type { Snippet } from 'svelte';
19
+
18
20
  let {
19
21
  value = '',
20
22
  groups = [],
21
23
  placeholder = 'Filter...',
22
24
  disabled = false,
23
25
  displayValue,
26
+ colorFn,
27
+ footer,
24
28
  onChange,
25
29
  }: {
26
30
  value: string;
@@ -28,6 +32,8 @@
28
32
  placeholder?: string;
29
33
  disabled?: boolean;
30
34
  displayValue?: string;
35
+ colorFn?: (value: string) => string | undefined;
36
+ footer?: Snippet;
31
37
  onChange: (value: string) => void;
32
38
  } = $props();
33
39
 
@@ -47,6 +53,7 @@
47
53
 
48
54
  const triggerLabel = $derived(displayValue || selectedOption?.label || value || placeholder);
49
55
  const triggerIcon = $derived(selectedOption?.icon || '');
56
+ const activeColor = $derived(colorFn ? colorFn(value) : undefined);
50
57
 
51
58
  const filteredGroups = $derived.by(() => {
52
59
  if (!searchQuery.trim()) return groups;
@@ -88,6 +95,7 @@
88
95
  type="button"
89
96
  class="sd-trigger"
90
97
  class:sd-disabled={disabled}
98
+ style={activeColor ? `border-left-color: ${activeColor}; border-left-width: 3px; color: ${activeColor};` : ''}
91
99
  onclick={() => { if (!disabled) open = !open; }}
92
100
  {disabled}
93
101
  >
@@ -144,6 +152,7 @@
144
152
  onclick={() => select(option.value)}
145
153
  class="sd-option"
146
154
  class:sd-option-selected={value === option.value}
155
+ style={colorFn && colorFn(option.value) ? `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};` : ''}
147
156
  >
148
157
  {#if option.icon}<span class="sd-option-icon">{option.icon}</span>{/if}
149
158
  <span class="truncate">{option.label}</span>
@@ -158,6 +167,11 @@
158
167
  <li class="sd-empty">No matches for "{searchQuery}"</li>
159
168
  {/if}
160
169
  </ul>
170
+ {#if footer}
171
+ <div class="sd-footer">
172
+ {@render footer()}
173
+ </div>
174
+ {/if}
161
175
  </div>
162
176
  {/if}
163
177
  </div>
@@ -339,6 +353,10 @@
339
353
  color: var(--sd-success);
340
354
  }
341
355
 
356
+ .sd-footer {
357
+ border-top: 1px solid var(--sd-border);
358
+ }
359
+
342
360
  .sd-empty {
343
361
  padding: 0.75rem;
344
362
  text-align: center;
@@ -0,0 +1,34 @@
1
+ <!--
2
+ SidebarUserFooter — avatar + name (links to account) + sign out button.
3
+ Placed at the bottom of app sidebar drawers (admin, facilitator, portal).
4
+ Used in JST template layouts.
5
+ -->
6
+ <script lang="ts">
7
+ import UserAvatar from './UserAvatar.svelte'
8
+
9
+ interface Props {
10
+ profile?: { full_name?: string | null; avatar_url?: string | null } | null
11
+ user?: { email?: string | null } | null
12
+ /** Where the avatar/name links to. Defaults to /account */
13
+ accountHref?: string
14
+ /** Sign out URL. Defaults to /account/sign_out */
15
+ signOutHref?: string
16
+ }
17
+
18
+ let {
19
+ profile,
20
+ user,
21
+ accountHref = '/account',
22
+ signOutHref = '/account/sign_out',
23
+ }: Props = $props()
24
+
25
+ let displayName = $derived(profile?.full_name || user?.email || '?')
26
+ </script>
27
+
28
+ <div class="flex items-center gap-2 px-2 py-1">
29
+ <a href={accountHref} class="flex items-center gap-2 flex-1 min-w-0" title="Account settings">
30
+ <UserAvatar name={profile?.full_name} email={user?.email} avatarUrl={profile?.avatar_url} size="sm" />
31
+ <span class="truncate text-sm">{displayName}</span>
32
+ </a>
33
+ <a href={signOutHref} class="btn btn-ghost btn-xs shrink-0">Sign out</a>
34
+ </div>
@@ -102,7 +102,8 @@
102
102
 
103
103
  <div class="flex items-center gap-1">
104
104
  <!-- Sort dropdown -->
105
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
105
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
106
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
106
107
  <div class="dropdown dropdown-end flex-shrink-0" onclick={(e) => e.stopPropagation()}>
107
108
  <button
108
109
  tabindex="0"
@@ -134,7 +135,8 @@
134
135
 
135
136
  <!-- Filter input (optional) -->
136
137
  {#if showFilter}
137
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
138
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
138
140
  <div class="flex-shrink-0" onclick={(e) => e.stopPropagation()}>
139
141
  <input
140
142
  type="text"
@@ -233,6 +233,7 @@
233
233
 
234
234
  {#if text && text.trim()}
235
235
  <div class="flex items-center gap-1 justify-end">
236
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
236
237
  <div
237
238
  class="relative"
238
239
  onmouseenter={() => (showControls = true)}
@@ -150,6 +150,7 @@
150
150
  const canDelete = $derived(showDelete && currentUserId === attachment.uploaded_by)
151
151
  </script>
152
152
 
153
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
153
154
  <div class="relative group {className}" oncontextmenu={handleContextMenu}>
154
155
  {#if loading}
155
156
  <div class="flex items-center justify-center bg-base-200 rounded-lg {compact ? 'w-16 h-16' : 'w-48 h-32'}">
@@ -203,7 +204,9 @@
203
204
 
204
205
  <!-- Context Menu -->
205
206
  {#if showContextMenu}
206
- <div class="fixed z-50 bg-base-100 rounded-lg shadow-xl border border-base-300 py-1 min-w-[160px]" style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;" onclick={(e) => e.stopPropagation()} role="menu">
207
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
208
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
209
+ <div class="fixed z-50 bg-base-100 rounded-lg shadow-xl border border-base-300 py-1 min-w-[160px]" style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1">
207
210
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleOpenInNewTab} role="menuitem">↗ Open in new tab</button>
208
211
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleDownload} role="menuitem">⬇ Download</button>
209
212
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleCopyLink} role="menuitem">📋 Copy link</button>
@@ -334,6 +334,7 @@
334
334
 
335
335
  <input bind:this={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime,application/pdf" multiple class="hidden" onchange={handleFileSelect} {disabled} />
336
336
 
337
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
337
338
  <div class="flex flex-col gap-2 relative" ondragenter={handleDragEnter} ondragleave={handleDragLeave} ondragover={handleDragOver} ondrop={handleDrop}>
338
339
  {#if isDragging}
339
340
  <div class="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
package/src/lib/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Components — Universal
2
2
  export { default as UserAvatar } from './components/UserAvatar.svelte';
3
+ export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
3
4
 
4
5
  // Components — from JAT IDE
5
6
  export { default as SearchDropdown } from './components/SearchDropdown.svelte';
@@ -39,6 +40,14 @@ export { default as SpeechForm } from './components/SpeechForm.svelte';
39
40
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
40
41
  export { default as Sparkline } from './components/Sparkline.svelte';
41
42
 
43
+ // Components — from JST
44
+ export { default as AvatarUpload } from './components/AvatarUpload.svelte';
45
+ export { default as PdfThumbnail } from './components/PdfThumbnail.svelte';
46
+ export { default as FilePicker } from './components/FilePicker.svelte';
47
+
48
+ // FilePicker types
49
+ export type { FilePickerFile, FilePickerSelection } from './types/filePicker';
50
+
42
51
  // Components — Emoji
43
52
  export { default as EmojiPicker } from './components/EmojiPicker.svelte';
44
53
 
@@ -51,6 +60,8 @@ export { default as LinkShortener } from './components/LinkShortener.svelte';
51
60
 
52
61
  // Components — from Marduk
53
62
  export { default as SignaturePad } from './components/SignaturePad.svelte';
63
+ export { default as MilestoneCard } from './components/MilestoneCard.svelte';
64
+ export { default as MilestoneTimeline } from './components/MilestoneTimeline.svelte';
54
65
  export { default as TimeSlotPicker } from './components/TimeSlotPicker.svelte';
55
66
  export { default as CalendarPicker } from './components/CalendarPicker.svelte';
56
67
  export { default as AvailabilityModal } from './components/AvailabilityModal.svelte';
@@ -69,6 +80,9 @@ export type { DisplaySegment } from './components/InlineEdit.svelte';
69
80
  export type { SelectOption } from './components/SelectInput.svelte';
70
81
  export type { StatusValue } from './components/StatusBadge.svelte';
71
82
 
83
+ // Milestone types (from JST contract/billing)
84
+ export type { Milestone, MilestoneStatus } from './types/milestone';
85
+
72
86
  // Booking types (centralized)
73
87
  export type {
74
88
  LocationType,
@@ -102,8 +116,17 @@ export {
102
116
  formatDate,
103
117
  getTimeSinceMs,
104
118
  getTimeSinceMinutes,
105
- isWithinMinutes
119
+ isWithinMinutes,
120
+ formatDateTime,
121
+ taskAge,
106
122
  } from './utils/dateFormatters';
123
+ // Utilities — task display helpers
124
+ export {
125
+ statusColor,
126
+ priorityColor,
127
+ typeIcon,
128
+ statusLabel,
129
+ } from './utils/taskUtils';
107
130
 
108
131
  // Utilities — phone formatting
109
132
  export {
@@ -0,0 +1,14 @@
1
+ export interface FilePickerFile {
2
+ id: string
3
+ name: string
4
+ created_at: string
5
+ updated_at: string
6
+ metadata: Record<string, any>
7
+ scope: "personal" | "team"
8
+ }
9
+
10
+ export interface FilePickerSelection {
11
+ file: FilePickerFile
12
+ url: string
13
+ downloadUrl: string
14
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Generic milestone/timeline types for stepped workflows.
3
+ *
4
+ * Used by MilestoneCard and MilestoneTimeline components.
5
+ * Projects extend this with domain-specific fields (e.g. stripe_invoice_id).
6
+ */
7
+
8
+ export type MilestoneStatus = "pending" | "delivered" | "accepted" | "paid"
9
+
10
+ export interface Milestone {
11
+ id: string
12
+ name: string
13
+ description: string
14
+ percentage: number
15
+ amount: number
16
+ acceptance_criteria: string
17
+ status: MilestoneStatus
18
+ sort_order: number
19
+ delivered_at: string | null
20
+ accepted_at: string | null
21
+ paid_at: string | null
22
+ }
@@ -151,3 +151,44 @@ export function getTimeSinceMinutes(timestamp: string | null | undefined): numbe
151
151
  export function isWithinMinutes(timestamp: string | null | undefined, minutes: number): boolean {
152
152
  return getTimeSinceMinutes(timestamp) < minutes;
153
153
  }
154
+
155
+ /**
156
+ * Format date + time for detail views (e.g. "Jan 5, 2:30 PM").
157
+ * No year — use formatFullDate when year is needed.
158
+ */
159
+ export function formatDateTime(dateStr: string | null | undefined): string {
160
+ if (!dateStr) return '';
161
+ const date = parseTimestamp(dateStr);
162
+ if (!date) return '';
163
+ return date.toLocaleString(undefined, {
164
+ month: 'short',
165
+ day: 'numeric',
166
+ hour: 'numeric',
167
+ minute: '2-digit',
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Color-coded relative age for task rows.
173
+ * Returns a label (e.g. "3d", "2w") and an oklch color string
174
+ * that shifts from green (fresh) → blue → amber → orange → red (stale).
175
+ *
176
+ * Usage:
177
+ * const age = taskAge(task.created_at)
178
+ * // <span style="color: {age.color}">{age.label}</span>
179
+ */
180
+ export function taskAge(dateStr: string): { label: string; color: string } {
181
+ const now = Date.now();
182
+ const created = new Date(dateStr).getTime();
183
+ const minutes = Math.floor((now - created) / 60000);
184
+ const hours = Math.floor(minutes / 60);
185
+ const days = Math.floor(hours / 24);
186
+ const weeks = Math.floor(days / 7);
187
+ const months = Math.floor(days / 30);
188
+ if (minutes < 60) return { label: `${minutes}m`, color: 'oklch(0.75 0.18 145)' }; // green — fresh
189
+ if (hours < 24) return { label: `${hours}h`, color: 'oklch(0.75 0.18 145)' }; // green
190
+ if (days < 3) return { label: `${days}d`, color: 'oklch(0.70 0.15 200)' }; // blue — recent
191
+ if (days < 7) return { label: `${days}d`, color: 'oklch(0.75 0.15 85)' }; // amber — aging
192
+ if (weeks < 4) return { label: `${weeks}w`, color: 'oklch(0.65 0.18 50)' }; // orange — stale
193
+ return { label: `${months}mo`, color: 'oklch(0.65 0.20 25)' }; // red — old
194
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared helpers for project_tasks UI.
3
+ * Used across all JST-based projects on tasks/feedback pages.
4
+ */
5
+
6
+ /**
7
+ * DaisyUI badge class for a task status value.
8
+ * Usage: <span class="badge {statusColor(task.status)}">...</span>
9
+ */
10
+ export function statusColor(status: string): string {
11
+ switch (status) {
12
+ case 'submitted': return 'badge-info';
13
+ case 'in_progress': return 'badge-warning';
14
+ case 'completed':
15
+ case 'accepted': return 'badge-success';
16
+ case 'rejected':
17
+ case 'wontfix': return 'badge-error';
18
+ case 'closed': return 'badge-neutral';
19
+ default: return 'badge-ghost';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Tailwind text class for a task priority value.
25
+ * Usage: <span class={priorityColor(task.priority)}>...</span>
26
+ */
27
+ export function priorityColor(priority: string | null | undefined): string {
28
+ switch (priority) {
29
+ case 'critical': return 'text-error font-bold';
30
+ case 'high': return 'text-warning font-semibold';
31
+ case 'medium': return 'text-info';
32
+ case 'low': return 'text-base-content/60';
33
+ default: return '';
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Emoji icon for a task issue_type value.
39
+ * Usage: {typeIcon(task.issue_type)} {task.title}
40
+ */
41
+ export function typeIcon(type: string): string {
42
+ switch (type) {
43
+ case 'bug': return '🐛';
44
+ case 'feature': return '✨';
45
+ case 'task': return '📋';
46
+ case 'epic': return '🏔️';
47
+ default: return '📄';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Human-readable label for a task status value.
53
+ * "in_progress" → "In Progress"
54
+ */
55
+ export function statusLabel(status: string): string {
56
+ return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
57
+ }