@joewinke/jatui 0.1.6 → 0.1.9
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/ChipInput.svelte +1 -0
- package/src/lib/components/ConfirmModal.svelte +1 -1
- package/src/lib/components/EmojiPicker.svelte +2 -0
- package/src/lib/components/FilePicker.svelte +419 -0
- package/src/lib/components/FilterDropdown.svelte +1 -0
- package/src/lib/components/FloatingActionBar.svelte +354 -0
- package/src/lib/components/ImageUpload.svelte +1 -0
- package/src/lib/components/MilestoneCard.svelte +98 -0
- package/src/lib/components/MilestoneTimeline.svelte +91 -0
- package/src/lib/components/Modal.svelte +1 -0
- package/src/lib/components/PdfThumbnail.svelte +61 -0
- package/src/lib/components/ResizableDivider.svelte +3 -1
- package/src/lib/components/ResizablePanel.svelte +3 -0
- package/src/lib/components/SearchDropdown.svelte +62 -3
- package/src/lib/components/SidebarUserFooter.svelte +34 -0
- package/src/lib/components/SignaturePad.svelte +2 -2
- package/src/lib/components/SortDropdown.svelte +4 -2
- package/src/lib/components/VoicePlayer.svelte +1 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +4 -4
- package/src/lib/components/messaging/CreateChannelModal.svelte +7 -7
- package/src/lib/components/messaging/MessageAttachment.svelte +4 -1
- package/src/lib/components/messaging/MessageInput.svelte +1 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +6 -6
- package/src/lib/index.ts +26 -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 +59 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
value: string;
|
|
4
4
|
label: string;
|
|
5
5
|
icon?: string;
|
|
6
|
+
image?: string; // URL for avatar/thumbnail image
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export type SearchDropdownGroup = {
|
|
@@ -15,12 +16,16 @@
|
|
|
15
16
|
import { tick } from 'svelte';
|
|
16
17
|
import { slide } from 'svelte/transition';
|
|
17
18
|
|
|
19
|
+
import type { Snippet } from 'svelte';
|
|
20
|
+
|
|
18
21
|
let {
|
|
19
22
|
value = '',
|
|
20
23
|
groups = [],
|
|
21
24
|
placeholder = 'Filter...',
|
|
22
25
|
disabled = false,
|
|
23
26
|
displayValue,
|
|
27
|
+
colorFn,
|
|
28
|
+
footer,
|
|
24
29
|
onChange,
|
|
25
30
|
}: {
|
|
26
31
|
value: string;
|
|
@@ -28,6 +33,8 @@
|
|
|
28
33
|
placeholder?: string;
|
|
29
34
|
disabled?: boolean;
|
|
30
35
|
displayValue?: string;
|
|
36
|
+
colorFn?: (value: string) => string | undefined;
|
|
37
|
+
footer?: Snippet;
|
|
31
38
|
onChange: (value: string) => void;
|
|
32
39
|
} = $props();
|
|
33
40
|
|
|
@@ -47,6 +54,8 @@
|
|
|
47
54
|
|
|
48
55
|
const triggerLabel = $derived(displayValue || selectedOption?.label || value || placeholder);
|
|
49
56
|
const triggerIcon = $derived(selectedOption?.icon || '');
|
|
57
|
+
const triggerImage = $derived(selectedOption?.image || '');
|
|
58
|
+
const activeColor = $derived(colorFn ? colorFn(value) : undefined);
|
|
50
59
|
|
|
51
60
|
const filteredGroups = $derived.by(() => {
|
|
52
61
|
if (!searchQuery.trim()) return groups;
|
|
@@ -61,6 +70,13 @@
|
|
|
61
70
|
return result;
|
|
62
71
|
});
|
|
63
72
|
|
|
73
|
+
// Generate a deterministic hue from a string for initials avatars
|
|
74
|
+
function nameHue(s: string): number {
|
|
75
|
+
let h = 0;
|
|
76
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
|
|
77
|
+
return h % 360;
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
function select(optionValue: string) {
|
|
65
81
|
open = false;
|
|
66
82
|
searchQuery = '';
|
|
@@ -88,11 +104,20 @@
|
|
|
88
104
|
type="button"
|
|
89
105
|
class="sd-trigger"
|
|
90
106
|
class:sd-disabled={disabled}
|
|
107
|
+
style={activeColor ? `border-left-color: ${activeColor}; border-left-width: 3px; color: ${activeColor};` : ''}
|
|
91
108
|
onclick={() => { if (!disabled) open = !open; }}
|
|
92
109
|
{disabled}
|
|
93
110
|
>
|
|
94
111
|
<span class="sd-trigger-label">
|
|
95
|
-
{#if
|
|
112
|
+
{#if triggerImage}
|
|
113
|
+
<img src={triggerImage} alt="" class="sd-avatar" />
|
|
114
|
+
{:else if triggerIcon}
|
|
115
|
+
<span class="sd-trigger-icon">{triggerIcon}</span>
|
|
116
|
+
{:else if selectedOption && selectedOption.value}
|
|
117
|
+
<span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(selectedOption.label)})">
|
|
118
|
+
{selectedOption.label.charAt(0).toUpperCase()}
|
|
119
|
+
</span>
|
|
120
|
+
{/if}
|
|
96
121
|
<span class="truncate">{triggerLabel}</span>
|
|
97
122
|
</span>
|
|
98
123
|
<svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
@@ -123,7 +148,7 @@
|
|
|
123
148
|
autocomplete="off"
|
|
124
149
|
/>
|
|
125
150
|
{#if searchQuery}
|
|
126
|
-
<button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
|
|
151
|
+
<button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear" aria-label="Clear search">
|
|
127
152
|
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
128
153
|
</button>
|
|
129
154
|
{/if}
|
|
@@ -144,8 +169,17 @@
|
|
|
144
169
|
onclick={() => select(option.value)}
|
|
145
170
|
class="sd-option"
|
|
146
171
|
class:sd-option-selected={value === option.value}
|
|
172
|
+
style={colorFn && colorFn(option.value) ? `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};` : ''}
|
|
147
173
|
>
|
|
148
|
-
{#if option.
|
|
174
|
+
{#if option.image}
|
|
175
|
+
<img src={option.image} alt="" class="sd-avatar" />
|
|
176
|
+
{:else if option.icon}
|
|
177
|
+
<span class="sd-option-icon">{option.icon}</span>
|
|
178
|
+
{:else if option.value}
|
|
179
|
+
<span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(option.label)})">
|
|
180
|
+
{option.label.charAt(0).toUpperCase()}
|
|
181
|
+
</span>
|
|
182
|
+
{/if}
|
|
149
183
|
<span class="truncate">{option.label}</span>
|
|
150
184
|
{#if value === option.value}
|
|
151
185
|
<svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
|
@@ -158,6 +192,11 @@
|
|
|
158
192
|
<li class="sd-empty">No matches for "{searchQuery}"</li>
|
|
159
193
|
{/if}
|
|
160
194
|
</ul>
|
|
195
|
+
{#if footer}
|
|
196
|
+
<div class="sd-footer">
|
|
197
|
+
{@render footer()}
|
|
198
|
+
</div>
|
|
199
|
+
{/if}
|
|
161
200
|
</div>
|
|
162
201
|
{/if}
|
|
163
202
|
</div>
|
|
@@ -213,6 +252,22 @@
|
|
|
213
252
|
flex-shrink: 0;
|
|
214
253
|
font-size: 0.75rem;
|
|
215
254
|
}
|
|
255
|
+
.sd-avatar {
|
|
256
|
+
width: 1.25rem;
|
|
257
|
+
height: 1.25rem;
|
|
258
|
+
border-radius: 50%;
|
|
259
|
+
object-fit: cover;
|
|
260
|
+
flex-shrink: 0;
|
|
261
|
+
}
|
|
262
|
+
.sd-avatar-initials {
|
|
263
|
+
display: inline-flex;
|
|
264
|
+
align-items: center;
|
|
265
|
+
justify-content: center;
|
|
266
|
+
font-size: 0.6rem;
|
|
267
|
+
font-weight: 700;
|
|
268
|
+
color: #fff;
|
|
269
|
+
line-height: 1;
|
|
270
|
+
}
|
|
216
271
|
|
|
217
272
|
.sd-chevron {
|
|
218
273
|
width: 0.75rem;
|
|
@@ -339,6 +394,10 @@
|
|
|
339
394
|
color: var(--sd-success);
|
|
340
395
|
}
|
|
341
396
|
|
|
397
|
+
.sd-footer {
|
|
398
|
+
border-top: 1px solid var(--sd-border);
|
|
399
|
+
}
|
|
400
|
+
|
|
342
401
|
.sd-empty {
|
|
343
402
|
padding: 0.75rem;
|
|
344
403
|
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>
|
|
@@ -136,12 +136,12 @@
|
|
|
136
136
|
<div class="signature-pad-container">
|
|
137
137
|
{#if label}
|
|
138
138
|
<div class="flex justify-between items-center mb-2">
|
|
139
|
-
<
|
|
139
|
+
<span class="text-sm text-base-content/70">
|
|
140
140
|
{label}
|
|
141
141
|
{#if required}
|
|
142
142
|
<span class="text-error">*</span>
|
|
143
143
|
{/if}
|
|
144
|
-
</
|
|
144
|
+
</span>
|
|
145
145
|
<button type="button" onclick={() => clear()} class="btn btn-ghost btn-xs">
|
|
146
146
|
Clear
|
|
147
147
|
</button>
|
|
@@ -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"
|
|
@@ -96,12 +96,12 @@
|
|
|
96
96
|
{#if isEditing}
|
|
97
97
|
<div class="space-y-4">
|
|
98
98
|
<div>
|
|
99
|
-
<label class="label"><span class="label-text font-medium">Name</span></label>
|
|
100
|
-
<input type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
|
|
99
|
+
<label for="channel-info-name" class="label"><span class="label-text font-medium">Name</span></label>
|
|
100
|
+
<input id="channel-info-name" type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
|
|
101
101
|
</div>
|
|
102
102
|
<div>
|
|
103
|
-
<label class="label"><span class="label-text font-medium">Description</span></label>
|
|
104
|
-
<textarea bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
|
|
103
|
+
<label for="channel-info-description" class="label"><span class="label-text font-medium">Description</span></label>
|
|
104
|
+
<textarea id="channel-info-description" bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
|
|
105
105
|
</div>
|
|
106
106
|
<div class="flex gap-2">
|
|
107
107
|
<button class="btn btn-primary btn-sm" onclick={saveEdit} disabled={saving || !editName.trim()}>
|
|
@@ -126,20 +126,20 @@
|
|
|
126
126
|
{selectedEmoji}
|
|
127
127
|
</button>
|
|
128
128
|
<div class="flex-1">
|
|
129
|
-
<label class="label"><span class="label-text font-medium">Channel Name</span></label>
|
|
130
|
-
<input type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
|
|
129
|
+
<label for="create-channel-name" class="label"><span class="label-text font-medium">Channel Name</span></label>
|
|
130
|
+
<input id="create-channel-name" type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
|
|
131
131
|
</div>
|
|
132
132
|
</div>
|
|
133
133
|
|
|
134
134
|
<!-- Description -->
|
|
135
135
|
<div>
|
|
136
|
-
<label class="label"><span class="label-text font-medium">Description</span></label>
|
|
137
|
-
<textarea bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
|
|
136
|
+
<label for="create-channel-description" class="label"><span class="label-text font-medium">Description</span></label>
|
|
137
|
+
<textarea id="create-channel-description" bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
|
|
138
138
|
</div>
|
|
139
139
|
|
|
140
140
|
<!-- Channel type -->
|
|
141
141
|
<div>
|
|
142
|
-
<
|
|
142
|
+
<span class="label"><span class="label-text font-medium">Channel Type</span></span>
|
|
143
143
|
<div class="flex gap-3">
|
|
144
144
|
<label class="cursor-pointer flex items-center gap-2">
|
|
145
145
|
<input type="radio" name="channelType" class="radio radio-primary" bind:group={channelType} value="public" />
|
|
@@ -154,8 +154,8 @@
|
|
|
154
154
|
|
|
155
155
|
<!-- Member selection -->
|
|
156
156
|
<div>
|
|
157
|
-
<label class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
|
|
158
|
-
<input type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
|
|
157
|
+
<label for="create-channel-member-search" class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
|
|
158
|
+
<input id="create-channel-member-search" type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
|
|
159
159
|
<div class="max-h-40 overflow-y-auto space-y-1">
|
|
160
160
|
{#each filteredMembers as member}
|
|
161
161
|
<button type="button" class="w-full flex items-center gap-3 px-2 py-1.5 rounded hover:bg-base-200 transition-colors" onclick={() => toggleMember(member.id)}>
|
|
@@ -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">
|
|
@@ -165,12 +165,12 @@
|
|
|
165
165
|
{#if globalSettings.quiet_hours_enabled}
|
|
166
166
|
<div class="flex gap-3">
|
|
167
167
|
<div class="flex-1">
|
|
168
|
-
<label class="label"><span class="label-text text-xs">Start</span></label>
|
|
169
|
-
<input type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
|
|
168
|
+
<label for="notif-quiet-hours-start" class="label"><span class="label-text text-xs">Start</span></label>
|
|
169
|
+
<input id="notif-quiet-hours-start" type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
|
|
170
170
|
</div>
|
|
171
171
|
<div class="flex-1">
|
|
172
|
-
<label class="label"><span class="label-text text-xs">End</span></label>
|
|
173
|
-
<input type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
|
|
172
|
+
<label for="notif-quiet-hours-end" class="label"><span class="label-text text-xs">End</span></label>
|
|
173
|
+
<input id="notif-quiet-hours-end" type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
|
|
174
174
|
</div>
|
|
175
175
|
</div>
|
|
176
176
|
{/if}
|
|
@@ -197,8 +197,8 @@
|
|
|
197
197
|
</label>
|
|
198
198
|
</div>
|
|
199
199
|
<div>
|
|
200
|
-
<label class="label"><span class="label-text font-medium">Notification Level</span></label>
|
|
201
|
-
<select bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
|
|
200
|
+
<label for="notif-channel-level" class="label"><span class="label-text font-medium">Notification Level</span></label>
|
|
201
|
+
<select id="notif-channel-level" bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
|
|
202
202
|
<option value="all">All Messages</option>
|
|
203
203
|
<option value="mentions">Mentions Only</option>
|
|
204
204
|
<option value="none">None</option>
|
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';
|
|
@@ -9,6 +10,7 @@ export { default as ResizableDivider } from './components/ResizableDivider.svelt
|
|
|
9
10
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|
|
10
11
|
export { default as SortDropdown } from './components/SortDropdown.svelte';
|
|
11
12
|
export { default as InlineEdit } from './components/InlineEdit.svelte';
|
|
13
|
+
export { default as FloatingActionBar } from './components/FloatingActionBar.svelte';
|
|
12
14
|
|
|
13
15
|
// Components — from Flush
|
|
14
16
|
export { default as Button } from './components/Button.svelte';
|
|
@@ -39,6 +41,14 @@ export { default as SpeechForm } from './components/SpeechForm.svelte';
|
|
|
39
41
|
export { default as ThemeSelector } from './components/ThemeSelector.svelte';
|
|
40
42
|
export { default as Sparkline } from './components/Sparkline.svelte';
|
|
41
43
|
|
|
44
|
+
// Components — from JST
|
|
45
|
+
export { default as AvatarUpload } from './components/AvatarUpload.svelte';
|
|
46
|
+
export { default as PdfThumbnail } from './components/PdfThumbnail.svelte';
|
|
47
|
+
export { default as FilePicker } from './components/FilePicker.svelte';
|
|
48
|
+
|
|
49
|
+
// FilePicker types
|
|
50
|
+
export type { FilePickerFile, FilePickerSelection } from './types/filePicker';
|
|
51
|
+
|
|
42
52
|
// Components — Emoji
|
|
43
53
|
export { default as EmojiPicker } from './components/EmojiPicker.svelte';
|
|
44
54
|
|
|
@@ -51,6 +61,8 @@ export { default as LinkShortener } from './components/LinkShortener.svelte';
|
|
|
51
61
|
|
|
52
62
|
// Components — from Marduk
|
|
53
63
|
export { default as SignaturePad } from './components/SignaturePad.svelte';
|
|
64
|
+
export { default as MilestoneCard } from './components/MilestoneCard.svelte';
|
|
65
|
+
export { default as MilestoneTimeline } from './components/MilestoneTimeline.svelte';
|
|
54
66
|
export { default as TimeSlotPicker } from './components/TimeSlotPicker.svelte';
|
|
55
67
|
export { default as CalendarPicker } from './components/CalendarPicker.svelte';
|
|
56
68
|
export { default as AvailabilityModal } from './components/AvailabilityModal.svelte';
|
|
@@ -64,11 +76,15 @@ export type { SearchDropdownOption, SearchDropdownGroup } from './components/Sea
|
|
|
64
76
|
export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
|
|
65
77
|
export type { SortOption } from './components/SortDropdown.svelte';
|
|
66
78
|
export type { DisplaySegment } from './components/InlineEdit.svelte';
|
|
79
|
+
export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
|
|
67
80
|
|
|
68
81
|
// Component types — Flush
|
|
69
82
|
export type { SelectOption } from './components/SelectInput.svelte';
|
|
70
83
|
export type { StatusValue } from './components/StatusBadge.svelte';
|
|
71
84
|
|
|
85
|
+
// Milestone types (from JST contract/billing)
|
|
86
|
+
export type { Milestone, MilestoneStatus } from './types/milestone';
|
|
87
|
+
|
|
72
88
|
// Booking types (centralized)
|
|
73
89
|
export type {
|
|
74
90
|
LocationType,
|
|
@@ -102,8 +118,17 @@ export {
|
|
|
102
118
|
formatDate,
|
|
103
119
|
getTimeSinceMs,
|
|
104
120
|
getTimeSinceMinutes,
|
|
105
|
-
isWithinMinutes
|
|
121
|
+
isWithinMinutes,
|
|
122
|
+
formatDateTime,
|
|
123
|
+
taskAge,
|
|
106
124
|
} from './utils/dateFormatters';
|
|
125
|
+
// Utilities — task display helpers
|
|
126
|
+
export {
|
|
127
|
+
statusColor,
|
|
128
|
+
priorityColor,
|
|
129
|
+
typeIcon,
|
|
130
|
+
statusLabel,
|
|
131
|
+
} from './utils/taskUtils';
|
|
107
132
|
|
|
108
133
|
// Utilities — phone formatting
|
|
109
134
|
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,59 @@
|
|
|
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 'open': return 'badge-ghost';
|
|
13
|
+
case 'in_progress': return 'badge-warning';
|
|
14
|
+
case 'waiting': return 'badge-warning badge-outline';
|
|
15
|
+
case 'blocked': return 'badge-error badge-outline';
|
|
16
|
+
case 'submitted': return 'badge-info';
|
|
17
|
+
case 'accepted': return 'badge-success';
|
|
18
|
+
case 'deployed': return 'badge-success badge-outline';
|
|
19
|
+
case 'closed': return 'badge-neutral';
|
|
20
|
+
case 'dev': return 'badge-ghost badge-outline';
|
|
21
|
+
default: return 'badge-ghost';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tailwind text class for a task priority value.
|
|
27
|
+
* Usage: <span class={priorityColor(task.priority)}>...</span>
|
|
28
|
+
*/
|
|
29
|
+
export function priorityColor(priority: string | null | undefined): string {
|
|
30
|
+
switch (priority) {
|
|
31
|
+
case 'critical': return 'text-error font-bold';
|
|
32
|
+
case 'high': return 'text-warning font-semibold';
|
|
33
|
+
case 'medium': return 'text-info';
|
|
34
|
+
case 'low': return 'text-base-content/60';
|
|
35
|
+
default: return '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Emoji icon for a task issue_type value.
|
|
41
|
+
* Usage: {typeIcon(task.issue_type)} {task.title}
|
|
42
|
+
*/
|
|
43
|
+
export function typeIcon(type: string): string {
|
|
44
|
+
switch (type) {
|
|
45
|
+
case 'bug': return '🐛';
|
|
46
|
+
case 'feature': return '✨';
|
|
47
|
+
case 'task': return '📋';
|
|
48
|
+
case 'epic': return '🏔️';
|
|
49
|
+
default: return '📄';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Human-readable label for a task status value.
|
|
55
|
+
* "in_progress" → "In Progress"
|
|
56
|
+
*/
|
|
57
|
+
export function statusLabel(status: string): string {
|
|
58
|
+
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
59
|
+
}
|