@joewinke/jatui 0.1.5 → 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 +2 -1
- package/src/lib/components/AvatarUpload.svelte +211 -0
- package/src/lib/components/EmojiPicker.svelte +2 -0
- package/src/lib/components/FilePicker.svelte +419 -0
- package/src/lib/components/MilestoneCard.svelte +80 -0
- package/src/lib/components/MilestoneTimeline.svelte +83 -0
- package/src/lib/components/Modal.svelte +1 -0
- package/src/lib/components/PdfThumbnail.svelte +61 -0
- package/src/lib/components/ResizablePanel.svelte +3 -0
- package/src/lib/components/SearchDropdown.svelte +18 -0
- package/src/lib/components/SidebarUserFooter.svelte +34 -0
- package/src/lib/components/SortDropdown.svelte +4 -2
- package/src/lib/components/UserAvatar.svelte +59 -0
- package/src/lib/components/VoicePlayer.svelte +1 -0
- package/src/lib/components/messaging/MessageAttachment.svelte +4 -1
- package/src/lib/components/messaging/MessageInput.svelte +1 -0
- package/src/lib/index.ts +27 -1
- package/src/lib/types/filePicker.ts +14 -0
- package/src/lib/types/milestone.ts +22 -0
- package/src/lib/utils/dateFormatters.ts +41 -0
- package/src/lib/utils/taskUtils.ts +57 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joewinke/jatui",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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
|
|
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"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
UserAvatar — shows a user's avatar image or initials fallback.
|
|
3
|
+
Generic component with no DB type coupling.
|
|
4
|
+
For use anywhere a user needs to be visually represented.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
interface Props {
|
|
8
|
+
name?: string | null
|
|
9
|
+
email?: string | null
|
|
10
|
+
avatarUrl?: string | null
|
|
11
|
+
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
12
|
+
tooltip?: boolean
|
|
13
|
+
class?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
name = null,
|
|
18
|
+
email = null,
|
|
19
|
+
avatarUrl = null,
|
|
20
|
+
size = 'sm',
|
|
21
|
+
tooltip = true,
|
|
22
|
+
class: className = '',
|
|
23
|
+
}: Props = $props()
|
|
24
|
+
|
|
25
|
+
const sizeClasses: Record<string, string> = {
|
|
26
|
+
xs: 'w-5 h-5 text-[9px]',
|
|
27
|
+
sm: 'w-7 h-7 text-xs',
|
|
28
|
+
md: 'w-9 h-9 text-sm',
|
|
29
|
+
lg: 'w-12 h-12 text-base',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const initials = $derived.by(() => {
|
|
33
|
+
const source = name || email || ''
|
|
34
|
+
if (!source) return '?'
|
|
35
|
+
// For email, use first char before @
|
|
36
|
+
const parts = source.includes('@')
|
|
37
|
+
? [source.split('@')[0]]
|
|
38
|
+
: source.split(' ').filter(Boolean)
|
|
39
|
+
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
40
|
+
return parts[0]?.charAt(0).toUpperCase() || '?'
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const label = $derived(name || email || 'Unknown')
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
class="inline-flex shrink-0 {className}"
|
|
48
|
+
title={tooltip ? label : undefined}
|
|
49
|
+
>
|
|
50
|
+
<div class="rounded-full overflow-hidden {sizeClasses[size]}">
|
|
51
|
+
{#if avatarUrl}
|
|
52
|
+
<img src={avatarUrl} alt={label} class="w-full h-full object-cover" />
|
|
53
|
+
{:else}
|
|
54
|
+
<div class="w-full h-full bg-neutral text-neutral-content flex items-center justify-center font-semibold">
|
|
55
|
+
{initials}
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
@@ -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
|
-
|
|
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,3 +1,7 @@
|
|
|
1
|
+
// Components — Universal
|
|
2
|
+
export { default as UserAvatar } from './components/UserAvatar.svelte';
|
|
3
|
+
export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
|
|
4
|
+
|
|
1
5
|
// Components — from JAT IDE
|
|
2
6
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
3
7
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
@@ -36,6 +40,14 @@ export { default as SpeechForm } from './components/SpeechForm.svelte';
|
|
|
36
40
|
export { default as ThemeSelector } from './components/ThemeSelector.svelte';
|
|
37
41
|
export { default as Sparkline } from './components/Sparkline.svelte';
|
|
38
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
|
+
|
|
39
51
|
// Components — Emoji
|
|
40
52
|
export { default as EmojiPicker } from './components/EmojiPicker.svelte';
|
|
41
53
|
|
|
@@ -48,6 +60,8 @@ export { default as LinkShortener } from './components/LinkShortener.svelte';
|
|
|
48
60
|
|
|
49
61
|
// Components — from Marduk
|
|
50
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';
|
|
51
65
|
export { default as TimeSlotPicker } from './components/TimeSlotPicker.svelte';
|
|
52
66
|
export { default as CalendarPicker } from './components/CalendarPicker.svelte';
|
|
53
67
|
export { default as AvailabilityModal } from './components/AvailabilityModal.svelte';
|
|
@@ -66,6 +80,9 @@ export type { DisplaySegment } from './components/InlineEdit.svelte';
|
|
|
66
80
|
export type { SelectOption } from './components/SelectInput.svelte';
|
|
67
81
|
export type { StatusValue } from './components/StatusBadge.svelte';
|
|
68
82
|
|
|
83
|
+
// Milestone types (from JST contract/billing)
|
|
84
|
+
export type { Milestone, MilestoneStatus } from './types/milestone';
|
|
85
|
+
|
|
69
86
|
// Booking types (centralized)
|
|
70
87
|
export type {
|
|
71
88
|
LocationType,
|
|
@@ -99,8 +116,17 @@ export {
|
|
|
99
116
|
formatDate,
|
|
100
117
|
getTimeSinceMs,
|
|
101
118
|
getTimeSinceMinutes,
|
|
102
|
-
isWithinMinutes
|
|
119
|
+
isWithinMinutes,
|
|
120
|
+
formatDateTime,
|
|
121
|
+
taskAge,
|
|
103
122
|
} from './utils/dateFormatters';
|
|
123
|
+
// Utilities — task display helpers
|
|
124
|
+
export {
|
|
125
|
+
statusColor,
|
|
126
|
+
priorityColor,
|
|
127
|
+
typeIcon,
|
|
128
|
+
statusLabel,
|
|
129
|
+
} from './utils/taskUtils';
|
|
104
130
|
|
|
105
131
|
// Utilities — phone formatting
|
|
106
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
|
+
}
|