@joewinke/jatui 0.1.0
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 +46 -0
- package/src/lib/components/AudioWaveform.svelte +694 -0
- package/src/lib/components/AvailabilityModal.svelte +173 -0
- package/src/lib/components/Badge.svelte +38 -0
- package/src/lib/components/BookingForm.svelte +276 -0
- package/src/lib/components/Button.svelte +72 -0
- package/src/lib/components/CalendarPicker.svelte +284 -0
- package/src/lib/components/Card.svelte +67 -0
- package/src/lib/components/CharacterCounter.svelte +82 -0
- package/src/lib/components/ChipInput.svelte +596 -0
- package/src/lib/components/ColorSelector.svelte +163 -0
- package/src/lib/components/ConfirmModal.svelte +75 -0
- package/src/lib/components/CountdownTimer.svelte +94 -0
- package/src/lib/components/DateRangePicker.svelte +192 -0
- package/src/lib/components/Drawer.svelte +110 -0
- package/src/lib/components/FilterDropdown.svelte +202 -0
- package/src/lib/components/ImageUpload.svelte +97 -0
- package/src/lib/components/InlineEdit.svelte +283 -0
- package/src/lib/components/LazyImage.svelte +122 -0
- package/src/lib/components/LoadingSpinner.svelte +102 -0
- package/src/lib/components/Modal.svelte +208 -0
- package/src/lib/components/PhoneInput.svelte +92 -0
- package/src/lib/components/ResizableDivider.svelte +305 -0
- package/src/lib/components/ResizablePanel.svelte +302 -0
- package/src/lib/components/SearchDropdown.svelte +341 -0
- package/src/lib/components/SelectInput.svelte +215 -0
- package/src/lib/components/SignaturePad.svelte +171 -0
- package/src/lib/components/SortDropdown.svelte +148 -0
- package/src/lib/components/Sparkline.svelte +107 -0
- package/src/lib/components/SpeechForm.svelte +114 -0
- package/src/lib/components/StatusBadge.svelte +155 -0
- package/src/lib/components/TextArea.svelte +143 -0
- package/src/lib/components/TextInput.svelte +108 -0
- package/src/lib/components/ThemeSelector.svelte +195 -0
- package/src/lib/components/TimeSlotPicker.svelte +162 -0
- package/src/lib/components/VoicePlayer.svelte +420 -0
- package/src/lib/components/messaging/Avatar.svelte +81 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
- package/src/lib/components/messaging/ChannelList.svelte +107 -0
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
- package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
- package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
- package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
- package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
- package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
- package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
- package/src/lib/components/messaging/MessageInput.svelte +451 -0
- package/src/lib/components/messaging/MessageItem.svelte +338 -0
- package/src/lib/components/messaging/MessageThread.svelte +306 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
- package/src/lib/components/messaging/StartDMModal.svelte +100 -0
- package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
- package/src/lib/index.ts +185 -0
- package/src/lib/types/booking.ts +143 -0
- package/src/lib/types/messaging.ts +459 -0
- package/src/lib/utils/currency.ts +20 -0
- package/src/lib/utils/daisyuiColors.ts +243 -0
- package/src/lib/utils/dateFormatters.ts +153 -0
- package/src/lib/utils/mentionParser.ts +188 -0
- package/src/lib/utils/phoneFormat.ts +74 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { slide } from "svelte/transition"
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
selectedColor = null,
|
|
6
|
+
onSelect = (color: string | null) => {},
|
|
7
|
+
colorLabel = "Collection Color",
|
|
8
|
+
compact = false,
|
|
9
|
+
} = $props<{
|
|
10
|
+
selectedColor: string | null
|
|
11
|
+
onSelect?: (color: string | null) => void
|
|
12
|
+
colorLabel?: string
|
|
13
|
+
compact?: boolean
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
let showSelector = $state(false)
|
|
17
|
+
let lastSelectedColor = $state(selectedColor)
|
|
18
|
+
let selectionAnimating = $state(false)
|
|
19
|
+
|
|
20
|
+
const colorOptions = [
|
|
21
|
+
{ name: "Primary", value: "primary", bgClass: "bg-primary", textClass: "text-primary-content" },
|
|
22
|
+
{ name: "Secondary", value: "secondary", bgClass: "bg-secondary", textClass: "text-secondary-content" },
|
|
23
|
+
{ name: "Accent", value: "accent", bgClass: "bg-accent", textClass: "text-accent-content" },
|
|
24
|
+
{ name: "Neutral", value: "neutral", bgClass: "bg-neutral", textClass: "text-neutral-content" },
|
|
25
|
+
{ name: "Info", value: "info", bgClass: "bg-info", textClass: "text-info-content" },
|
|
26
|
+
{ name: "Success", value: "success", bgClass: "bg-success", textClass: "text-success-content" },
|
|
27
|
+
{ name: "Warning", value: "warning", bgClass: "bg-warning", textClass: "text-warning-content" },
|
|
28
|
+
{ name: "Error", value: "error", bgClass: "bg-error", textClass: "text-error-content" },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function isSelected(colorValue: string): boolean {
|
|
32
|
+
if (!selectedColor) return false
|
|
33
|
+
return selectedColor === colorValue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleColorSelect(colorValue: string) {
|
|
37
|
+
if (colorValue !== selectedColor) {
|
|
38
|
+
lastSelectedColor = colorValue
|
|
39
|
+
selectionAnimating = true
|
|
40
|
+
onSelect(colorValue)
|
|
41
|
+
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
selectionAnimating = false
|
|
44
|
+
if (compact) {
|
|
45
|
+
setTimeout(() => { showSelector = false }, 800)
|
|
46
|
+
}
|
|
47
|
+
}, 500)
|
|
48
|
+
} else {
|
|
49
|
+
onSelect(colorValue)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="form-control">
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
class="flex w-full items-center justify-between px-2 py-3 hover:bg-base-200 rounded-lg transition-colors"
|
|
58
|
+
onclick={() => (showSelector = !showSelector)}
|
|
59
|
+
>
|
|
60
|
+
<div class="label flex items-center gap-2 cursor-pointer">
|
|
61
|
+
<span class="label-text font-medium">{colorLabel}</span>
|
|
62
|
+
{#if selectedColor}
|
|
63
|
+
<div class="ml-4">
|
|
64
|
+
<div class="color-preview {selectedColor}"></div>
|
|
65
|
+
</div>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform duration-300 {showSelector ? 'rotate-180' : ''}">
|
|
69
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
{#if showSelector}
|
|
74
|
+
<div class="color-selector-wrapper transition-all" transition:slide={{ duration: 200 }}>
|
|
75
|
+
<div class="grid grid-cols-4 gap-3 p-4 bg-base-200 rounded-lg">
|
|
76
|
+
{#each colorOptions as option}
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
class="tooltip tooltip-top color-btn {option.bgClass} border-2 border-base-300
|
|
80
|
+
{isSelected(option.value) ? 'selected' : ''}
|
|
81
|
+
{selectionAnimating && lastSelectedColor === option.value ? 'selecting' : ''}"
|
|
82
|
+
data-tip={option.name}
|
|
83
|
+
onclick={() => handleColorSelect(option.value)}
|
|
84
|
+
aria-label="Select {option.name} color"
|
|
85
|
+
>
|
|
86
|
+
{#if isSelected(option.value) || (selectionAnimating && lastSelectedColor === option.value)}
|
|
87
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="{option.textClass} {selectionAnimating && lastSelectedColor === option.value ? 'animate-checkmark' : ''}">
|
|
88
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
89
|
+
</svg>
|
|
90
|
+
{/if}
|
|
91
|
+
</button>
|
|
92
|
+
{/each}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{#if selectedColor}
|
|
96
|
+
<div class="flex justify-end mt-2">
|
|
97
|
+
<button
|
|
98
|
+
class="text-xs text-base-content/60 hover:text-base-content transition-colors px-2 py-1 rounded hover:bg-base-200"
|
|
99
|
+
onclick={() => onSelect(null)}
|
|
100
|
+
>
|
|
101
|
+
Clear color
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
.color-selector-wrapper { padding-top: 0.5rem; }
|
|
111
|
+
|
|
112
|
+
.color-btn {
|
|
113
|
+
width: 100%;
|
|
114
|
+
aspect-ratio: 1;
|
|
115
|
+
border-radius: 0.75rem;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
transition: all 0.2s ease;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.color-btn:hover {
|
|
125
|
+
transform: scale(1.05);
|
|
126
|
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.color-btn.selected {
|
|
130
|
+
border-color: white;
|
|
131
|
+
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.color-btn.selecting {
|
|
135
|
+
transform: scale(1.15);
|
|
136
|
+
border-color: white;
|
|
137
|
+
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.3), 0 0 15px rgba(255, 255, 255, 0.5);
|
|
138
|
+
animation: pulse 0.5s ease;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.color-preview {
|
|
142
|
+
width: 15px;
|
|
143
|
+
height: 15px;
|
|
144
|
+
border-radius: 50%;
|
|
145
|
+
border: 1px solid hsla(var(--bc) / 0.2);
|
|
146
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@keyframes pulse {
|
|
150
|
+
0% { transform: scale(1); }
|
|
151
|
+
50% { transform: scale(1.15); }
|
|
152
|
+
100% { transform: scale(1.1); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.animate-checkmark {
|
|
156
|
+
animation: zoom-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@keyframes zoom-in {
|
|
160
|
+
0% { transform: scale(0); opacity: 0; }
|
|
161
|
+
100% { transform: scale(1); opacity: 1; }
|
|
162
|
+
}
|
|
163
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let {
|
|
3
|
+
title = "Confirm Action",
|
|
4
|
+
message = "Are you sure you want to proceed?",
|
|
5
|
+
confirmText = "Confirm",
|
|
6
|
+
cancelText = "Cancel",
|
|
7
|
+
confirmButtonClass = "btn-primary",
|
|
8
|
+
isLoading = false,
|
|
9
|
+
iconClass = "text-warning",
|
|
10
|
+
onconfirm,
|
|
11
|
+
oncancel,
|
|
12
|
+
} = $props<{
|
|
13
|
+
title?: string
|
|
14
|
+
message?: string
|
|
15
|
+
confirmText?: string
|
|
16
|
+
cancelText?: string
|
|
17
|
+
confirmButtonClass?: string
|
|
18
|
+
isLoading?: boolean
|
|
19
|
+
iconClass?: string
|
|
20
|
+
onconfirm?: () => void
|
|
21
|
+
oncancel?: () => void
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
function confirm() {
|
|
25
|
+
onconfirm?.()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cancel() {
|
|
29
|
+
oncancel?.()
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div class="modal modal-open">
|
|
34
|
+
<div class="modal-box max-w-md">
|
|
35
|
+
<div class="flex justify-between items-center mb-4">
|
|
36
|
+
<h3 class="font-bold text-lg">{title}</h3>
|
|
37
|
+
<button class="btn btn-ghost btn-sm" onclick={cancel}>
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="py-4">
|
|
43
|
+
<div class="flex justify-center mb-4">
|
|
44
|
+
<div class="p-4 rounded-full bg-base-200">
|
|
45
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={iconClass}><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<p class="text-center mb-6">{message}</p>
|
|
50
|
+
|
|
51
|
+
<div class="flex gap-2">
|
|
52
|
+
<button type="button" class="btn btn-outline flex-1" onclick={cancel} disabled={isLoading}>
|
|
53
|
+
{cancelText}
|
|
54
|
+
</button>
|
|
55
|
+
<button type="button" class={`btn ${confirmButtonClass} flex-1`} onclick={confirm} disabled={isLoading}>
|
|
56
|
+
{#if isLoading}
|
|
57
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
58
|
+
Processing...
|
|
59
|
+
{:else}
|
|
60
|
+
{confirmText}
|
|
61
|
+
{/if}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div
|
|
68
|
+
class="modal-backdrop"
|
|
69
|
+
onclick={cancel}
|
|
70
|
+
onkeydown={(e) => { if (e.key === 'Escape') cancel() }}
|
|
71
|
+
role="button"
|
|
72
|
+
tabindex="0"
|
|
73
|
+
aria-label="Close modal"
|
|
74
|
+
></div>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fade } from "svelte/transition"
|
|
3
|
+
|
|
4
|
+
type TimerProps = {
|
|
5
|
+
targetDate?: string | null
|
|
6
|
+
minutesToAdd?: number
|
|
7
|
+
onComplete?: () => void
|
|
8
|
+
size?: "sm" | "md" | "lg"
|
|
9
|
+
completionMessage?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
targetDate = null,
|
|
14
|
+
minutesToAdd = 1,
|
|
15
|
+
onComplete = () => {},
|
|
16
|
+
size = "sm",
|
|
17
|
+
completionMessage = "Complete",
|
|
18
|
+
} = $props<TimerProps>()
|
|
19
|
+
|
|
20
|
+
let minutes = $state(0)
|
|
21
|
+
let seconds = $state(0)
|
|
22
|
+
let countdownInterval: number | null = null
|
|
23
|
+
let startTime: Date | null = null
|
|
24
|
+
let isComplete = $state(false)
|
|
25
|
+
|
|
26
|
+
const sizeClasses: Record<"sm" | "md" | "lg", string> = {
|
|
27
|
+
sm: "text-lg",
|
|
28
|
+
md: "text-2xl",
|
|
29
|
+
lg: "text-4xl",
|
|
30
|
+
}
|
|
31
|
+
let sizeClass = $derived(sizeClasses[size])
|
|
32
|
+
|
|
33
|
+
function updateCountdown() {
|
|
34
|
+
const now = new Date()
|
|
35
|
+
let targetTime: Date
|
|
36
|
+
|
|
37
|
+
if (targetDate) {
|
|
38
|
+
const finishTime = new Date(targetDate)
|
|
39
|
+
targetTime = new Date(finishTime.getTime() + minutesToAdd * 60 * 1000)
|
|
40
|
+
} else {
|
|
41
|
+
if (!startTime) {
|
|
42
|
+
startTime = now
|
|
43
|
+
}
|
|
44
|
+
targetTime = new Date(startTime.getTime() + minutesToAdd * 60 * 1000)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const diff = targetTime.getTime() - now.getTime()
|
|
48
|
+
|
|
49
|
+
if (diff <= 0) {
|
|
50
|
+
if (countdownInterval) {
|
|
51
|
+
clearInterval(countdownInterval)
|
|
52
|
+
countdownInterval = null
|
|
53
|
+
}
|
|
54
|
+
minutes = 0
|
|
55
|
+
seconds = 0
|
|
56
|
+
isComplete = true
|
|
57
|
+
onComplete()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
minutes = Math.floor(diff / (1000 * 60))
|
|
62
|
+
seconds = Math.floor((diff % (1000 * 60)) / 1000)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
$effect(() => {
|
|
66
|
+
if (targetDate || minutesToAdd > 0) {
|
|
67
|
+
updateCountdown()
|
|
68
|
+
countdownInterval = setInterval(updateCountdown, 1000) as unknown as number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
if (countdownInterval) {
|
|
73
|
+
clearInterval(countdownInterval)
|
|
74
|
+
countdownInterval = null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<div class="text-info">
|
|
81
|
+
{#if !isComplete}
|
|
82
|
+
EST.
|
|
83
|
+
<span class="countdown font-mono {sizeClass}" transition:fade>
|
|
84
|
+
<span style="--value:{minutes};"></span>:
|
|
85
|
+
<span style="--value:{seconds};"></span>
|
|
86
|
+
REMAINING
|
|
87
|
+
</span>
|
|
88
|
+
{:else}
|
|
89
|
+
<span class="countdown {sizeClass}" transition:fade>
|
|
90
|
+
<span class="loading loading-dots loading-md mr-3"></span>
|
|
91
|
+
{completionMessage}
|
|
92
|
+
</span>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* DateRangePicker Component
|
|
4
|
+
* DaisyUI dropdown for filtering by date range with quick presets
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatShortDate } from '../utils/dateFormatters';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
selectedRange: string; // 'today' | 'yesterday' | 'week' | 'month' | 'all' | 'custom'
|
|
11
|
+
customFrom?: string | null; // ISO date for custom range
|
|
12
|
+
customTo?: string | null; // ISO date for custom range
|
|
13
|
+
onRangeChange: (range: string, from?: string, to?: string) => void;
|
|
14
|
+
compact?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
selectedRange = 'all',
|
|
19
|
+
customFrom = null,
|
|
20
|
+
customTo = null,
|
|
21
|
+
onRangeChange,
|
|
22
|
+
compact = false
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
// Local state for custom date inputs
|
|
26
|
+
let localFrom = $state(customFrom || '');
|
|
27
|
+
let localTo = $state(customTo || '');
|
|
28
|
+
let showCustom = $state(false);
|
|
29
|
+
|
|
30
|
+
// Preset options
|
|
31
|
+
const presets = [
|
|
32
|
+
{ value: 'today', label: 'Today', icon: '1' },
|
|
33
|
+
{ value: 'yesterday', label: 'Yesterday', icon: '-1' },
|
|
34
|
+
{ value: 'week', label: 'Last 7 days', icon: '7' },
|
|
35
|
+
{ value: 'month', label: 'Last 30 days', icon: '30' },
|
|
36
|
+
{ value: 'all', label: 'All time', icon: '*' }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Get display label for current selection
|
|
40
|
+
const displayLabel = $derived.by(() => {
|
|
41
|
+
if (selectedRange === 'custom' && customFrom && customTo) {
|
|
42
|
+
return `${formatShortDate(customFrom)} - ${formatShortDate(customTo)}`;
|
|
43
|
+
}
|
|
44
|
+
if (selectedRange === 'custom' && customFrom) {
|
|
45
|
+
return `From ${formatShortDate(customFrom)}`;
|
|
46
|
+
}
|
|
47
|
+
if (selectedRange === 'custom' && customTo) {
|
|
48
|
+
return `Until ${formatShortDate(customTo)}`;
|
|
49
|
+
}
|
|
50
|
+
return presets.find(p => p.value === selectedRange)?.label || 'All time';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function handlePresetSelect(preset: string) {
|
|
54
|
+
showCustom = false;
|
|
55
|
+
localFrom = '';
|
|
56
|
+
localTo = '';
|
|
57
|
+
onRangeChange(preset);
|
|
58
|
+
|
|
59
|
+
// Close dropdown
|
|
60
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
61
|
+
document.activeElement.blur();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleCustomApply() {
|
|
66
|
+
if (localFrom || localTo) {
|
|
67
|
+
onRangeChange('custom', localFrom || undefined, localTo || undefined);
|
|
68
|
+
|
|
69
|
+
// Close dropdown
|
|
70
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
71
|
+
document.activeElement.blur();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toggleCustom() {
|
|
77
|
+
showCustom = !showCustom;
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<!-- Date Range Picker -->
|
|
82
|
+
<div class="dropdown dropdown-end">
|
|
83
|
+
<!-- Trigger Button -->
|
|
84
|
+
<div
|
|
85
|
+
tabindex="0"
|
|
86
|
+
role="button"
|
|
87
|
+
class="{compact ? 'px-2 py-1 text-[10px]' : 'px-2.5 py-1.5 text-xs'} rounded cursor-pointer transition-all flex items-center gap-1.5 font-mono tracking-wider whitespace-nowrap bg-base-200 border border-base-300 text-base-content/60"
|
|
88
|
+
>
|
|
89
|
+
<!-- Calendar icon -->
|
|
90
|
+
<svg
|
|
91
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
92
|
+
fill="none"
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
stroke-width="1.5"
|
|
95
|
+
stroke="currentColor"
|
|
96
|
+
class="w-4 h-4 text-primary"
|
|
97
|
+
>
|
|
98
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
|
99
|
+
</svg>
|
|
100
|
+
<span class="hidden sm:inline">{displayLabel}</span>
|
|
101
|
+
<span class="sm:hidden">
|
|
102
|
+
{#if selectedRange === 'today'}
|
|
103
|
+
1d
|
|
104
|
+
{:else if selectedRange === 'yesterday'}
|
|
105
|
+
-1d
|
|
106
|
+
{:else if selectedRange === 'week'}
|
|
107
|
+
7d
|
|
108
|
+
{:else if selectedRange === 'month'}
|
|
109
|
+
30d
|
|
110
|
+
{:else if selectedRange === 'all'}
|
|
111
|
+
All
|
|
112
|
+
{:else}
|
|
113
|
+
Custom
|
|
114
|
+
{/if}
|
|
115
|
+
</span>
|
|
116
|
+
<svg
|
|
117
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
118
|
+
fill="none"
|
|
119
|
+
viewBox="0 0 24 24"
|
|
120
|
+
stroke-width="1.5"
|
|
121
|
+
stroke="currentColor"
|
|
122
|
+
class="w-3 h-3 text-base-content/50"
|
|
123
|
+
>
|
|
124
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
125
|
+
</svg>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<!-- Dropdown Content -->
|
|
129
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
130
|
+
<div
|
|
131
|
+
tabindex="0"
|
|
132
|
+
class="dropdown-content rounded-box z-40 w-72 p-3 shadow-lg mt-1 bg-base-200 border border-base-300"
|
|
133
|
+
>
|
|
134
|
+
<!-- Quick Presets -->
|
|
135
|
+
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
136
|
+
{#each presets as preset}
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
class="px-2 py-1 rounded cursor-pointer transition-all duration-200 font-mono text-xs border {selectedRange === preset.value ? 'bg-primary/20 border-primary text-primary' : 'bg-base-300 border-base-content/20 text-base-content opacity-60 hover:opacity-100'}"
|
|
140
|
+
onclick={() => handlePresetSelect(preset.value)}
|
|
141
|
+
>
|
|
142
|
+
{preset.label}
|
|
143
|
+
</button>
|
|
144
|
+
{/each}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- Divider -->
|
|
148
|
+
<div class="my-2 flex items-center gap-2">
|
|
149
|
+
<div class="flex-1 h-px bg-base-300"></div>
|
|
150
|
+
<span class="text-xs font-mono uppercase tracking-wider text-base-content/40">or custom</span>
|
|
151
|
+
<div class="flex-1 h-px bg-base-300"></div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Custom Range Section -->
|
|
155
|
+
<div class="space-y-2">
|
|
156
|
+
<div class="flex gap-2">
|
|
157
|
+
<div class="form-control flex-1">
|
|
158
|
+
<label class="label py-0.5" for="date-range-from">
|
|
159
|
+
<span class="label-text text-xs font-mono uppercase tracking-wider text-base-content/50">From</span>
|
|
160
|
+
</label>
|
|
161
|
+
<input
|
|
162
|
+
id="date-range-from"
|
|
163
|
+
type="date"
|
|
164
|
+
class="input input-sm w-full font-mono bg-base-300 border border-base-300 text-base-content"
|
|
165
|
+
bind:value={localFrom}
|
|
166
|
+
max={localTo || undefined}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="form-control flex-1">
|
|
170
|
+
<label class="label py-0.5" for="date-range-to">
|
|
171
|
+
<span class="label-text text-xs font-mono uppercase tracking-wider text-base-content/50">To</span>
|
|
172
|
+
</label>
|
|
173
|
+
<input
|
|
174
|
+
id="date-range-to"
|
|
175
|
+
type="date"
|
|
176
|
+
class="input input-sm w-full font-mono bg-base-300 border border-base-300 text-base-content"
|
|
177
|
+
bind:value={localTo}
|
|
178
|
+
min={localFrom || undefined}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
class="btn btn-sm btn-primary w-full font-mono"
|
|
185
|
+
onclick={handleCustomApply}
|
|
186
|
+
disabled={!localFrom && !localTo}
|
|
187
|
+
>
|
|
188
|
+
Apply Custom Range
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Drawer Component
|
|
4
|
+
*
|
|
5
|
+
* Sliding panel overlay with backdrop, escape key, and header actions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Snippet } from 'svelte';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
open?: boolean;
|
|
12
|
+
title?: string;
|
|
13
|
+
position?: 'left' | 'right';
|
|
14
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
|
15
|
+
showCloseButton?: boolean;
|
|
16
|
+
contentClass?: string;
|
|
17
|
+
onclose?: () => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
headerActions?: Snippet;
|
|
20
|
+
children?: Snippet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
open = $bindable(false),
|
|
25
|
+
title = '',
|
|
26
|
+
position = 'right',
|
|
27
|
+
size = 'md',
|
|
28
|
+
showCloseButton = true,
|
|
29
|
+
contentClass = '',
|
|
30
|
+
onclose,
|
|
31
|
+
class: className = '',
|
|
32
|
+
headerActions,
|
|
33
|
+
children
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
const sizeClasses: Record<string, string> = {
|
|
37
|
+
sm: 'w-80',
|
|
38
|
+
md: 'w-96',
|
|
39
|
+
lg: 'w-[640px]',
|
|
40
|
+
xl: 'w-[800px]',
|
|
41
|
+
'2xl': 'w-[960px]',
|
|
42
|
+
'3xl': 'w-[1024px] max-w-[calc(100vw-2rem)]',
|
|
43
|
+
full: 'w-full'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function handleClose() {
|
|
47
|
+
open = false;
|
|
48
|
+
onclose?.();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
52
|
+
if (e.key === 'Escape' && open) {
|
|
53
|
+
e.stopImmediatePropagation();
|
|
54
|
+
handleClose();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
class="fixed inset-0 bg-black/50 transition-opacity duration-300 {open
|
|
63
|
+
? 'z-[100] opacity-100'
|
|
64
|
+
: 'opacity-0 pointer-events-none -z-10'}"
|
|
65
|
+
onclick={open ? handleClose : undefined}
|
|
66
|
+
role={open ? 'presentation' : undefined}
|
|
67
|
+
></div>
|
|
68
|
+
|
|
69
|
+
<div
|
|
70
|
+
class="fixed top-0 {position === 'right'
|
|
71
|
+
? 'right-0'
|
|
72
|
+
: 'left-0'} h-full bg-base-100 shadow-xl {sizeClasses[size]} flex flex-col transition-all duration-300 {open
|
|
73
|
+
? 'z-[101] translate-x-0'
|
|
74
|
+
: '-z-10 ' + (position === 'right' ? 'translate-x-full' : '-translate-x-full')} {className}"
|
|
75
|
+
role="dialog"
|
|
76
|
+
aria-modal={open}
|
|
77
|
+
aria-label={title}
|
|
78
|
+
aria-hidden={!open}
|
|
79
|
+
>
|
|
80
|
+
{#if title || showCloseButton || headerActions}
|
|
81
|
+
<div class="flex items-center justify-between p-6 border-b border-base-200 flex-shrink-0">
|
|
82
|
+
{#if title}
|
|
83
|
+
<h2 class="text-xl font-semibold">{title}</h2>
|
|
84
|
+
{/if}
|
|
85
|
+
<div class="flex items-center gap-2 ml-auto">
|
|
86
|
+
{#if headerActions}
|
|
87
|
+
{@render headerActions()}
|
|
88
|
+
{/if}
|
|
89
|
+
{#if showCloseButton}
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
class="btn btn-sm btn-circle btn-ghost"
|
|
93
|
+
onclick={handleClose}
|
|
94
|
+
aria-label="Close drawer"
|
|
95
|
+
>
|
|
96
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
97
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
98
|
+
</svg>
|
|
99
|
+
</button>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<div class="flex-1 overflow-y-auto {contentClass}">
|
|
106
|
+
{#if open && children}
|
|
107
|
+
{@render children()}
|
|
108
|
+
{/if}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|