@startsimpli/ui 0.4.5 → 0.4.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/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandPalette.tsx +344 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +3 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/index.ts +14 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +791 -0
- package/src/components/email-editor/email-editor.tsx +886 -0
- package/src/components/email-editor/index.ts +50 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttChart.tsx +25 -25
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/index.ts +6 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
4
|
+
import { Search, ArrowRight } from 'lucide-react'
|
|
5
|
+
import { useCommandPalette } from './command-palette-context'
|
|
6
|
+
|
|
7
|
+
export interface SearchResult {
|
|
8
|
+
id: string
|
|
9
|
+
type: string
|
|
10
|
+
name: string
|
|
11
|
+
detail: string
|
|
12
|
+
path: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface QuickAction {
|
|
16
|
+
id: string
|
|
17
|
+
icon: React.ElementType
|
|
18
|
+
name: string
|
|
19
|
+
shortcut?: string
|
|
20
|
+
action: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NavigationItem {
|
|
24
|
+
id: string
|
|
25
|
+
icon: React.ElementType
|
|
26
|
+
name: string
|
|
27
|
+
path: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommandPaletteProps {
|
|
31
|
+
searchFn?: (query: string) => Promise<SearchResult[]>
|
|
32
|
+
quickActions?: QuickAction[]
|
|
33
|
+
navigationItems?: NavigationItem[]
|
|
34
|
+
recentItems?: SearchResult[]
|
|
35
|
+
typeIcons?: Record<string, React.ElementType>
|
|
36
|
+
typeColors?: Record<string, string>
|
|
37
|
+
placeholder?: string
|
|
38
|
+
onNavigate: (path: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CommandPalette({
|
|
42
|
+
searchFn,
|
|
43
|
+
quickActions = [],
|
|
44
|
+
navigationItems = [],
|
|
45
|
+
recentItems = [],
|
|
46
|
+
typeIcons = {},
|
|
47
|
+
typeColors = {},
|
|
48
|
+
placeholder = 'Search or type a command...',
|
|
49
|
+
onNavigate,
|
|
50
|
+
}: CommandPaletteProps) {
|
|
51
|
+
const { isOpen, close } = useCommandPalette()
|
|
52
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
53
|
+
const [query, setQuery] = useState('')
|
|
54
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
55
|
+
const [results, setResults] = useState<SearchResult[]>([])
|
|
56
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
57
|
+
|
|
58
|
+
// Calculate all items for keyboard navigation
|
|
59
|
+
const filteredNav = query
|
|
60
|
+
? navigationItems.filter(n => n.name.toLowerCase().includes(query.toLowerCase()))
|
|
61
|
+
: []
|
|
62
|
+
|
|
63
|
+
const allItems = [
|
|
64
|
+
...results.map(r => ({ ...r, itemType: 'result' as const })),
|
|
65
|
+
...quickActions.map(a => ({ ...a, itemType: 'action' as const })),
|
|
66
|
+
...(!query ? recentItems.slice(0, 5).map(r => ({ ...r, itemType: 'recent' as const })) : []),
|
|
67
|
+
...filteredNav.map(n => ({ ...n, itemType: 'nav' as const })),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
// Focus input when opened
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (isOpen) {
|
|
73
|
+
setQuery('')
|
|
74
|
+
setSelectedIndex(0)
|
|
75
|
+
setResults([])
|
|
76
|
+
setTimeout(() => inputRef.current?.focus(), 0)
|
|
77
|
+
}
|
|
78
|
+
}, [isOpen])
|
|
79
|
+
|
|
80
|
+
// Search debounce
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!query.trim() || !searchFn) {
|
|
83
|
+
setResults([])
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const timer = setTimeout(async () => {
|
|
88
|
+
setIsLoading(true)
|
|
89
|
+
try {
|
|
90
|
+
const data = await searchFn(query)
|
|
91
|
+
setResults(data)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Search error:', error)
|
|
94
|
+
setResults([])
|
|
95
|
+
} finally {
|
|
96
|
+
setIsLoading(false)
|
|
97
|
+
}
|
|
98
|
+
}, 200)
|
|
99
|
+
|
|
100
|
+
return () => clearTimeout(timer)
|
|
101
|
+
}, [query, searchFn])
|
|
102
|
+
|
|
103
|
+
// Keyboard navigation
|
|
104
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
105
|
+
if (e.key === 'ArrowDown') {
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
setSelectedIndex(prev => Math.min(prev + 1, allItems.length - 1))
|
|
108
|
+
} else if (e.key === 'ArrowUp') {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
setSelectedIndex(prev => Math.max(prev - 1, 0))
|
|
111
|
+
} else if (e.key === 'Enter' && allItems[selectedIndex]) {
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
const item = allItems[selectedIndex]
|
|
114
|
+
if (item.itemType === 'action') {
|
|
115
|
+
(item as unknown as QuickAction).action()
|
|
116
|
+
} else if ('path' in item) {
|
|
117
|
+
onNavigate(item.path)
|
|
118
|
+
}
|
|
119
|
+
close()
|
|
120
|
+
}
|
|
121
|
+
}, [allItems, selectedIndex, onNavigate, close])
|
|
122
|
+
|
|
123
|
+
// Reset selection when results change
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setSelectedIndex(0)
|
|
126
|
+
}, [results])
|
|
127
|
+
|
|
128
|
+
if (!isOpen) return null
|
|
129
|
+
|
|
130
|
+
const DefaultIcon = Search
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-20"
|
|
135
|
+
onClick={close}
|
|
136
|
+
>
|
|
137
|
+
{/* Backdrop */}
|
|
138
|
+
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm pointer-events-none" />
|
|
139
|
+
|
|
140
|
+
{/* Palette */}
|
|
141
|
+
<div
|
|
142
|
+
role="dialog"
|
|
143
|
+
aria-modal="true"
|
|
144
|
+
aria-label="Command palette"
|
|
145
|
+
className="relative w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
|
|
146
|
+
onClick={e => e.stopPropagation()}
|
|
147
|
+
>
|
|
148
|
+
{/* Search input */}
|
|
149
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
|
|
150
|
+
<Search className="w-5 h-5 text-gray-400" />
|
|
151
|
+
<input
|
|
152
|
+
ref={inputRef}
|
|
153
|
+
type="text"
|
|
154
|
+
value={query}
|
|
155
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
156
|
+
onKeyDown={handleKeyDown}
|
|
157
|
+
placeholder={placeholder}
|
|
158
|
+
className="flex-1 outline-none text-lg bg-transparent"
|
|
159
|
+
/>
|
|
160
|
+
<kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded">ESC</kbd>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Results */}
|
|
164
|
+
<div role="listbox" aria-label="Search results" className="max-h-96 overflow-y-auto">
|
|
165
|
+
{/* Search Results */}
|
|
166
|
+
{query && results.length > 0 && (
|
|
167
|
+
<>
|
|
168
|
+
<div className="px-3 py-2">
|
|
169
|
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
170
|
+
Results for "{query}"
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
{results.map((result, i) => {
|
|
174
|
+
const Icon = typeIcons[result.type] || DefaultIcon
|
|
175
|
+
const colorClass = typeColors[result.type] || 'bg-gray-100 text-gray-600'
|
|
176
|
+
const itemIndex = i
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
key={result.id}
|
|
181
|
+
role="option"
|
|
182
|
+
aria-selected={selectedIndex === itemIndex}
|
|
183
|
+
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
184
|
+
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
185
|
+
}`}
|
|
186
|
+
onClick={() => {
|
|
187
|
+
onNavigate(result.path)
|
|
188
|
+
close()
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${colorClass}`}>
|
|
192
|
+
<Icon className="w-4 h-4" />
|
|
193
|
+
</div>
|
|
194
|
+
<div className="flex-1">
|
|
195
|
+
<p className="font-medium text-gray-900">{result.name}</p>
|
|
196
|
+
<p className="text-sm text-gray-500">{result.detail}</p>
|
|
197
|
+
</div>
|
|
198
|
+
<ArrowRight className="w-4 h-4 text-gray-400" />
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
})}
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Loading state */}
|
|
206
|
+
{isLoading && (
|
|
207
|
+
<div className="px-4 py-3 text-gray-500 text-sm">
|
|
208
|
+
Searching...
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{/* No results */}
|
|
213
|
+
{query && !isLoading && results.length === 0 && (
|
|
214
|
+
<div className="px-4 py-3 text-gray-500 text-sm">
|
|
215
|
+
No results found for "{query}"
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Quick Actions */}
|
|
220
|
+
{quickActions.length > 0 && (
|
|
221
|
+
<>
|
|
222
|
+
<div className="px-3 py-2 border-t border-gray-100">
|
|
223
|
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
224
|
+
Quick Actions
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
{quickActions.map((action, i) => {
|
|
228
|
+
const itemIndex = results.length + i
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
key={action.id}
|
|
232
|
+
role="option"
|
|
233
|
+
aria-selected={selectedIndex === itemIndex}
|
|
234
|
+
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
235
|
+
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
236
|
+
}`}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
action.action()
|
|
239
|
+
close()
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
243
|
+
<action.icon className="w-4 h-4 text-gray-600" />
|
|
244
|
+
</div>
|
|
245
|
+
<p className="flex-1 font-medium text-gray-700">{action.name}</p>
|
|
246
|
+
{action.shortcut && (
|
|
247
|
+
<kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded font-mono">
|
|
248
|
+
{action.shortcut}
|
|
249
|
+
</kbd>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
})}
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Recent Items (when no query) */}
|
|
258
|
+
{!query && recentItems.length > 0 && (
|
|
259
|
+
<>
|
|
260
|
+
<div className="px-3 py-2 border-t border-gray-100">
|
|
261
|
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
262
|
+
Recent
|
|
263
|
+
</p>
|
|
264
|
+
</div>
|
|
265
|
+
{recentItems.slice(0, 5).map((item, i) => {
|
|
266
|
+
const Icon = typeIcons[item.type] || DefaultIcon
|
|
267
|
+
const itemIndex = results.length + quickActions.length + i
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
key={item.id}
|
|
272
|
+
role="option"
|
|
273
|
+
aria-selected={selectedIndex === itemIndex}
|
|
274
|
+
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
275
|
+
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
276
|
+
}`}
|
|
277
|
+
onClick={() => {
|
|
278
|
+
onNavigate(item.path)
|
|
279
|
+
close()
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
283
|
+
<Icon className="w-4 h-4 text-gray-600" />
|
|
284
|
+
</div>
|
|
285
|
+
<div className="flex-1">
|
|
286
|
+
<p className="font-medium text-gray-700">{item.name}</p>
|
|
287
|
+
<p className="text-sm text-gray-500">{item.detail}</p>
|
|
288
|
+
</div>
|
|
289
|
+
<ArrowRight className="w-4 h-4 text-gray-400" />
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
})}
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{/* Filtered Navigation (when query matches) */}
|
|
297
|
+
{filteredNav.length > 0 && (
|
|
298
|
+
<>
|
|
299
|
+
<div className="px-3 py-2 border-t border-gray-100">
|
|
300
|
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
301
|
+
Go to
|
|
302
|
+
</p>
|
|
303
|
+
</div>
|
|
304
|
+
{filteredNav.map((navItem, i) => {
|
|
305
|
+
const itemIndex = results.length + quickActions.length + i
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
key={navItem.id}
|
|
310
|
+
role="option"
|
|
311
|
+
aria-selected={selectedIndex === itemIndex}
|
|
312
|
+
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
313
|
+
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
314
|
+
}`}
|
|
315
|
+
onClick={() => {
|
|
316
|
+
onNavigate(navItem.path)
|
|
317
|
+
close()
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
321
|
+
<navItem.icon className="w-4 h-4 text-gray-600" />
|
|
322
|
+
</div>
|
|
323
|
+
<p className="flex-1 font-medium text-gray-700">{navItem.name}</p>
|
|
324
|
+
<ArrowRight className="w-4 h-4 text-gray-400" />
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
327
|
+
})}
|
|
328
|
+
</>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Footer */}
|
|
333
|
+
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
|
|
334
|
+
<div className="flex items-center gap-4">
|
|
335
|
+
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">↑↓</kbd> Navigate</span>
|
|
336
|
+
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">↵</kbd> Select</span>
|
|
337
|
+
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">ESC</kbd> Close</span>
|
|
338
|
+
</div>
|
|
339
|
+
<span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded">&#8984;K</kbd> anywhere</span>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface CommandPaletteContextType {
|
|
6
|
+
isOpen: boolean
|
|
7
|
+
open: () => void
|
|
8
|
+
close: () => void
|
|
9
|
+
toggle: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CommandPaletteContext = createContext<CommandPaletteContextType | undefined>(undefined)
|
|
13
|
+
|
|
14
|
+
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|
15
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
16
|
+
|
|
17
|
+
const open = useCallback(() => setIsOpen(true), [])
|
|
18
|
+
const close = useCallback(() => setIsOpen(false), [])
|
|
19
|
+
const toggle = useCallback(() => setIsOpen(prev => !prev), [])
|
|
20
|
+
|
|
21
|
+
// Global keyboard shortcut: CMD+K / Ctrl+K
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
24
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
toggle()
|
|
27
|
+
}
|
|
28
|
+
if (e.key === 'Escape' && isOpen) {
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
close()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
35
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
36
|
+
}, [isOpen, toggle, close])
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<CommandPaletteContext.Provider value={{ isOpen, open, close, toggle }}>
|
|
40
|
+
{children}
|
|
41
|
+
</CommandPaletteContext.Provider>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useCommandPalette() {
|
|
46
|
+
const context = useContext(CommandPaletteContext)
|
|
47
|
+
if (!context) {
|
|
48
|
+
throw new Error('useCommandPalette must be used within a CommandPaletteProvider')
|
|
49
|
+
}
|
|
50
|
+
return context
|
|
51
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from 'react'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import { ArrowLeft, Save, Eye, Send } from 'lucide-react'
|
|
6
|
+
import { SaveStatusIndicator } from './save-status-indicator'
|
|
7
|
+
|
|
8
|
+
interface ComposeHeaderProps {
|
|
9
|
+
title: string
|
|
10
|
+
saving: boolean
|
|
11
|
+
lastSavedLabel: string | null
|
|
12
|
+
hasUnsavedChanges: boolean
|
|
13
|
+
canSend: boolean
|
|
14
|
+
onBack: () => void
|
|
15
|
+
onSave: () => void
|
|
16
|
+
onPreview: () => void
|
|
17
|
+
onSend: () => void
|
|
18
|
+
extraActions?: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ComposeHeader({
|
|
22
|
+
title,
|
|
23
|
+
saving,
|
|
24
|
+
lastSavedLabel,
|
|
25
|
+
hasUnsavedChanges,
|
|
26
|
+
canSend,
|
|
27
|
+
onBack,
|
|
28
|
+
onSave,
|
|
29
|
+
onPreview,
|
|
30
|
+
onSend,
|
|
31
|
+
extraActions,
|
|
32
|
+
}: ComposeHeaderProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex items-center justify-between px-4 py-2 border-b bg-background">
|
|
35
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
36
|
+
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onBack}>
|
|
37
|
+
<ArrowLeft className="h-4 w-4" />
|
|
38
|
+
</Button>
|
|
39
|
+
<span className="text-sm font-medium truncate">{title}</span>
|
|
40
|
+
<SaveStatusIndicator
|
|
41
|
+
saving={saving}
|
|
42
|
+
lastSavedLabel={lastSavedLabel}
|
|
43
|
+
hasUnsavedChanges={hasUnsavedChanges}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
47
|
+
<Button
|
|
48
|
+
variant="outline"
|
|
49
|
+
size="sm"
|
|
50
|
+
onClick={onSave}
|
|
51
|
+
disabled={saving}
|
|
52
|
+
>
|
|
53
|
+
<Save className="mr-1.5 h-3.5 w-3.5" />
|
|
54
|
+
Save
|
|
55
|
+
</Button>
|
|
56
|
+
<Button variant="outline" size="sm" onClick={onPreview}>
|
|
57
|
+
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
|
58
|
+
Preview
|
|
59
|
+
</Button>
|
|
60
|
+
{extraActions}
|
|
61
|
+
<Button
|
|
62
|
+
size="sm"
|
|
63
|
+
disabled={!canSend}
|
|
64
|
+
onClick={onSend}
|
|
65
|
+
>
|
|
66
|
+
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
67
|
+
Send
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
export function ComposeLoading() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="p-6">
|
|
6
|
+
<div className="animate-pulse">
|
|
7
|
+
<div className="h-8 bg-gray-300 rounded w-1/4 mb-6"></div>
|
|
8
|
+
<div className="h-12 bg-gray-200 rounded mb-4"></div>
|
|
9
|
+
<div className="h-64 bg-gray-200 rounded"></div>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useAutoSave } from './useAutoSave'
|
|
2
|
+
export { SaveStatusIndicator } from './save-status-indicator'
|
|
3
|
+
export { ComposeHeader } from './compose-header'
|
|
4
|
+
export { SendConfirmationDialog } from './send-confirmation-dialog'
|
|
5
|
+
export { SubjectInput } from './subject-input'
|
|
6
|
+
export { ComposeLoading } from './compose-loading'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
Tooltip,
|
|
6
|
+
TooltipContent,
|
|
7
|
+
TooltipProvider,
|
|
8
|
+
TooltipTrigger,
|
|
9
|
+
} from '../ui/tooltip'
|
|
10
|
+
|
|
11
|
+
interface SaveStatusIndicatorProps {
|
|
12
|
+
saving: boolean
|
|
13
|
+
lastSavedLabel: string | null
|
|
14
|
+
hasUnsavedChanges: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SaveStatusIndicator({
|
|
18
|
+
saving,
|
|
19
|
+
lastSavedLabel,
|
|
20
|
+
hasUnsavedChanges,
|
|
21
|
+
}: SaveStatusIndicatorProps) {
|
|
22
|
+
const label = saving
|
|
23
|
+
? 'Saving...'
|
|
24
|
+
: lastSavedLabel
|
|
25
|
+
? `Saved ${lastSavedLabel}`
|
|
26
|
+
: hasUnsavedChanges
|
|
27
|
+
? 'Unsaved changes'
|
|
28
|
+
: 'New draft'
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<TooltipProvider delayDuration={200}>
|
|
32
|
+
<Tooltip>
|
|
33
|
+
<TooltipTrigger asChild>
|
|
34
|
+
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground cursor-default">
|
|
35
|
+
{saving ? (
|
|
36
|
+
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
|
37
|
+
) : (
|
|
38
|
+
<span
|
|
39
|
+
className={`inline-block h-2 w-2 rounded-full ${
|
|
40
|
+
hasUnsavedChanges
|
|
41
|
+
? 'bg-amber-400'
|
|
42
|
+
: lastSavedLabel
|
|
43
|
+
? 'bg-green-500'
|
|
44
|
+
: 'border border-muted-foreground'
|
|
45
|
+
}`}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
{label}
|
|
49
|
+
</span>
|
|
50
|
+
</TooltipTrigger>
|
|
51
|
+
<TooltipContent side="bottom">
|
|
52
|
+
<p className="text-xs">{label}</p>
|
|
53
|
+
</TooltipContent>
|
|
54
|
+
</Tooltip>
|
|
55
|
+
</TooltipProvider>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from '../ui/dialog'
|
|
11
|
+
import { Button } from '../ui/button'
|
|
12
|
+
import { Send, Loader2, Clock } from 'lucide-react'
|
|
13
|
+
|
|
14
|
+
interface SendSummaryItem {
|
|
15
|
+
label: string
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SendConfirmationDialogProps {
|
|
20
|
+
open: boolean
|
|
21
|
+
onOpenChange: (open: boolean) => void
|
|
22
|
+
onConfirm: () => void
|
|
23
|
+
sending: boolean
|
|
24
|
+
title?: string
|
|
25
|
+
description?: string
|
|
26
|
+
summaryItems: SendSummaryItem[]
|
|
27
|
+
warningMessage?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SendConfirmationDialog({
|
|
31
|
+
open,
|
|
32
|
+
onOpenChange,
|
|
33
|
+
onConfirm,
|
|
34
|
+
sending,
|
|
35
|
+
title = 'Send Update',
|
|
36
|
+
description = 'Are you ready to send this?',
|
|
37
|
+
summaryItems,
|
|
38
|
+
warningMessage = 'Emails will be sent immediately. Each recipient will receive a personalized version with their information.',
|
|
39
|
+
}: SendConfirmationDialogProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
42
|
+
<DialogContent>
|
|
43
|
+
<DialogHeader>
|
|
44
|
+
<DialogTitle>{title}</DialogTitle>
|
|
45
|
+
<DialogDescription>{description}</DialogDescription>
|
|
46
|
+
</DialogHeader>
|
|
47
|
+
|
|
48
|
+
<div className="space-y-4 py-4">
|
|
49
|
+
<div className="p-4 bg-muted rounded-lg space-y-2">
|
|
50
|
+
{summaryItems.map((item) => (
|
|
51
|
+
<div key={item.label} className="flex justify-between text-sm">
|
|
52
|
+
<span className="text-muted-foreground">{item.label}:</span>
|
|
53
|
+
<span className="font-medium">{item.value}</span>
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{warningMessage && (
|
|
59
|
+
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
60
|
+
<Clock className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
61
|
+
<p className="text-sm">{warningMessage}</p>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<DialogFooter>
|
|
67
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
|
68
|
+
Cancel
|
|
69
|
+
</Button>
|
|
70
|
+
<Button onClick={onConfirm} disabled={sending}>
|
|
71
|
+
{sending ? (
|
|
72
|
+
<>
|
|
73
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
74
|
+
Sending...
|
|
75
|
+
</>
|
|
76
|
+
) : (
|
|
77
|
+
<>
|
|
78
|
+
<Send className="mr-2 h-4 w-4" />
|
|
79
|
+
Send Now
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
</Button>
|
|
83
|
+
</DialogFooter>
|
|
84
|
+
</DialogContent>
|
|
85
|
+
</Dialog>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Input } from '../ui/input'
|
|
4
|
+
|
|
5
|
+
interface SubjectInputProps {
|
|
6
|
+
value: string
|
|
7
|
+
onChange: (value: string) => void
|
|
8
|
+
placeholder?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SubjectInput({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
placeholder = 'Subject line...',
|
|
15
|
+
}: SubjectInputProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Input
|
|
18
|
+
id="subject"
|
|
19
|
+
value={value}
|
|
20
|
+
onChange={(e) => onChange(e.target.value)}
|
|
21
|
+
placeholder={placeholder}
|
|
22
|
+
className="border-0 shadow-none focus-visible:ring-0 text-lg font-medium px-0 h-auto py-0"
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|