@startsimpli/ui 0.4.24 → 0.4.26
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 +1 -1
- package/src/components/__tests__/chat.test.tsx +188 -1
- package/src/components/__tests__/document-status-badge.test.tsx +48 -0
- package/src/components/badge/DocumentStatusBadge.tsx +76 -0
- package/src/components/badge/StatusBadge.tsx +5 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/chat/ChatComposer.tsx +307 -27
- package/src/components/chat/ChatMessage.tsx +57 -3
- package/src/components/chat/ChatThread.tsx +29 -2
- package/src/components/chat/attachment-utils.ts +24 -0
- package/src/components/chat/image-downscale.test.ts +28 -0
- package/src/components/chat/image-downscale.ts +88 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +31 -0
- package/src/components/chat/useComposerUploads.test.tsx +125 -0
- package/src/components/chat/useComposerUploads.ts +285 -0
- package/src/components/slide-deck/SlideCanvas.tsx +6 -1
- package/src/components/slide-deck/SlideDeckViewer.tsx +142 -10
- package/src/components/slide-deck/SlideFilmstrip.tsx +21 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
|
5
5
|
import { cn } from '../../lib/utils'
|
|
6
6
|
import { SlideCanvas } from './SlideCanvas'
|
|
7
7
|
import { SlideFilmstrip } from './SlideFilmstrip'
|
|
@@ -14,6 +14,16 @@ export interface SlideDeckViewerProps {
|
|
|
14
14
|
onActiveChange?: (id: string) => void
|
|
15
15
|
/** Regenerate the currently-active slide. */
|
|
16
16
|
onRegenerate?: (slide: SlideData) => void
|
|
17
|
+
/** Delete the currently-active slide. Shows a delete control when provided. */
|
|
18
|
+
onDelete?: (slide: SlideData) => void
|
|
19
|
+
/** Move the active slide one position left/right. Shows move controls when provided. */
|
|
20
|
+
onMove?: (slide: SlideData, direction: 'left' | 'right') => void
|
|
21
|
+
/** Insert a blank slide after the active one. Shows an add control when provided. */
|
|
22
|
+
onInsert?: (afterSlide: SlideData) => void
|
|
23
|
+
/** Edit the active slide's title/content (then it regenerates). Shows an edit control. */
|
|
24
|
+
onEdit?: (slide: SlideData, edits: { title?: string; content?: string }) => void
|
|
25
|
+
/** Drag-to-reorder in the filmstrip: source + target 0-based indices. */
|
|
26
|
+
onReorder?: (fromIndex: number, toIndex: number) => void
|
|
17
27
|
emptyState?: React.ReactNode
|
|
18
28
|
/** Extra actions rendered in the stage toolbar (e.g. approve / edit). */
|
|
19
29
|
toolbarActions?: (slide: SlideData) => React.ReactNode
|
|
@@ -31,6 +41,11 @@ export function SlideDeckViewer({
|
|
|
31
41
|
activeId,
|
|
32
42
|
onActiveChange,
|
|
33
43
|
onRegenerate,
|
|
44
|
+
onDelete,
|
|
45
|
+
onMove,
|
|
46
|
+
onInsert,
|
|
47
|
+
onEdit,
|
|
48
|
+
onReorder,
|
|
34
49
|
emptyState,
|
|
35
50
|
toolbarActions,
|
|
36
51
|
renderStructured,
|
|
@@ -43,6 +58,20 @@ export function SlideDeckViewer({
|
|
|
43
58
|
const index = Math.max(0, slides.findIndex((s) => s.id === currentId))
|
|
44
59
|
const active = slides[index]
|
|
45
60
|
|
|
61
|
+
// Inline edit of the active slide's title/content.
|
|
62
|
+
const [editing, setEditing] = React.useState(false)
|
|
63
|
+
const [editTitle, setEditTitle] = React.useState('')
|
|
64
|
+
const [editContent, setEditContent] = React.useState('')
|
|
65
|
+
const openEditor = () => {
|
|
66
|
+
setEditTitle(active?.title ?? '')
|
|
67
|
+
setEditContent(active?.content ?? '')
|
|
68
|
+
setEditing(true)
|
|
69
|
+
}
|
|
70
|
+
const saveEdit = () => {
|
|
71
|
+
if (active) onEdit?.(active, { title: editTitle, content: editContent })
|
|
72
|
+
setEditing(false)
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
const goTo = React.useCallback(
|
|
47
76
|
(i: number) => {
|
|
48
77
|
const clamped = Math.min(slides.length - 1, Math.max(0, i))
|
|
@@ -54,15 +83,25 @@ export function SlideDeckViewer({
|
|
|
54
83
|
[slides, isControlled, onActiveChange]
|
|
55
84
|
)
|
|
56
85
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
// Global ←/→ navigation: works without first focusing the stage, but stays
|
|
87
|
+
// out of the way of text entry — when the user is typing in the chat composer
|
|
88
|
+
// (or any input/textarea/contenteditable) or holding a modifier, the arrows
|
|
89
|
+
// belong to the cursor, not the deck.
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
function onKey(e: KeyboardEvent) {
|
|
92
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
|
93
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return
|
|
94
|
+
const t = e.target as HTMLElement | null
|
|
95
|
+
const tag = t?.tagName
|
|
96
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
62
99
|
e.preventDefault()
|
|
63
|
-
goTo(index - 1)
|
|
100
|
+
goTo(e.key === 'ArrowRight' ? index + 1 : index - 1)
|
|
64
101
|
}
|
|
65
|
-
|
|
102
|
+
window.addEventListener('keydown', onKey)
|
|
103
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
104
|
+
}, [goTo, index])
|
|
66
105
|
|
|
67
106
|
if (slides.length === 0) {
|
|
68
107
|
return (
|
|
@@ -100,6 +139,48 @@ export function SlideDeckViewer({
|
|
|
100
139
|
</button>
|
|
101
140
|
</div>
|
|
102
141
|
<div className="flex items-center gap-2">
|
|
142
|
+
{onMove && active && slides.length > 1 && (
|
|
143
|
+
<div className="flex items-center gap-1">
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => onMove(active, 'left')}
|
|
147
|
+
disabled={index === 0}
|
|
148
|
+
aria-label={`Move slide ${active.slideNumber} left`}
|
|
149
|
+
className="rounded-md border border-input bg-background p-1.5 shadow-sm hover:bg-accent disabled:opacity-30"
|
|
150
|
+
>
|
|
151
|
+
<ChevronsLeft className="h-4 w-4" aria-hidden="true" />
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => onMove(active, 'right')}
|
|
156
|
+
disabled={index === slides.length - 1}
|
|
157
|
+
aria-label={`Move slide ${active.slideNumber} right`}
|
|
158
|
+
className="rounded-md border border-input bg-background p-1.5 shadow-sm hover:bg-accent disabled:opacity-30"
|
|
159
|
+
>
|
|
160
|
+
<ChevronsRight className="h-4 w-4" aria-hidden="true" />
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{onInsert && active && (
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={() => onInsert(active)}
|
|
168
|
+
aria-label="Add a slide"
|
|
169
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
|
|
170
|
+
>
|
|
171
|
+
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> Add
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
{onEdit && active && (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={openEditor}
|
|
178
|
+
aria-label={`Edit slide ${active.slideNumber}`}
|
|
179
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
|
|
180
|
+
>
|
|
181
|
+
<Pencil className="h-3.5 w-3.5" aria-hidden="true" /> Edit
|
|
182
|
+
</button>
|
|
183
|
+
)}
|
|
103
184
|
{active && toolbarActions?.(active)}
|
|
104
185
|
{onRegenerate && active && (
|
|
105
186
|
<button
|
|
@@ -110,17 +191,26 @@ export function SlideDeckViewer({
|
|
|
110
191
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> Regenerate
|
|
111
192
|
</button>
|
|
112
193
|
)}
|
|
194
|
+
{onDelete && active && slides.length > 1 && (
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => onDelete(active)}
|
|
198
|
+
aria-label={`Delete slide ${active.slideNumber}`}
|
|
199
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm text-red-600 shadow-sm hover:bg-red-50"
|
|
200
|
+
>
|
|
201
|
+
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" /> Delete
|
|
202
|
+
</button>
|
|
203
|
+
)}
|
|
113
204
|
</div>
|
|
114
205
|
</div>
|
|
115
206
|
|
|
116
207
|
{/* Stage */}
|
|
117
208
|
<div
|
|
118
|
-
className="flex min-h-0 flex-1 items-center justify-center p-4 focus:outline-none"
|
|
209
|
+
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4 focus:outline-none"
|
|
119
210
|
data-testid="slide-deck-stage"
|
|
120
211
|
tabIndex={0}
|
|
121
212
|
role="group"
|
|
122
213
|
aria-label={`Slide ${index + 1} of ${slides.length}`}
|
|
123
|
-
onKeyDown={onKeyDown}
|
|
124
214
|
>
|
|
125
215
|
{active && (
|
|
126
216
|
<SlideCanvas
|
|
@@ -129,6 +219,47 @@ export function SlideDeckViewer({
|
|
|
129
219
|
renderStructured={renderStructured}
|
|
130
220
|
/>
|
|
131
221
|
)}
|
|
222
|
+
{editing && active && (
|
|
223
|
+
<div
|
|
224
|
+
className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 p-4"
|
|
225
|
+
data-testid="slide-editor"
|
|
226
|
+
>
|
|
227
|
+
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 shadow-lg">
|
|
228
|
+
<h3 className="text-sm font-semibold">Edit slide {active.slideNumber}</h3>
|
|
229
|
+
<label className="mt-3 block text-xs font-medium text-muted-foreground">Title</label>
|
|
230
|
+
<input
|
|
231
|
+
value={editTitle}
|
|
232
|
+
onChange={(e) => setEditTitle(e.target.value)}
|
|
233
|
+
aria-label="Slide title"
|
|
234
|
+
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
235
|
+
/>
|
|
236
|
+
<label className="mt-3 block text-xs font-medium text-muted-foreground">Content</label>
|
|
237
|
+
<textarea
|
|
238
|
+
value={editContent}
|
|
239
|
+
onChange={(e) => setEditContent(e.target.value)}
|
|
240
|
+
rows={5}
|
|
241
|
+
aria-label="Slide content"
|
|
242
|
+
className="mt-1 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
243
|
+
/>
|
|
244
|
+
<div className="mt-3 flex justify-end gap-2">
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => setEditing(false)}
|
|
248
|
+
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm hover:bg-accent"
|
|
249
|
+
>
|
|
250
|
+
Cancel
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={saveEdit}
|
|
255
|
+
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:opacity-90"
|
|
256
|
+
>
|
|
257
|
+
Save & regenerate
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
132
263
|
</div>
|
|
133
264
|
|
|
134
265
|
{/* Filmstrip */}
|
|
@@ -137,6 +268,7 @@ export function SlideDeckViewer({
|
|
|
137
268
|
slides={slides}
|
|
138
269
|
activeId={currentId}
|
|
139
270
|
onSelect={(id) => goTo(slides.findIndex((s) => s.id === id))}
|
|
271
|
+
onReorder={onReorder}
|
|
140
272
|
/>
|
|
141
273
|
</div>
|
|
142
274
|
</div>
|
|
@@ -9,6 +9,8 @@ export interface SlideFilmstripProps {
|
|
|
9
9
|
slides: SlideData[]
|
|
10
10
|
activeId?: string
|
|
11
11
|
onSelect: (id: string) => void
|
|
12
|
+
/** Drag-to-reorder: emits source + target 0-based indices on drop. Enables DnD. */
|
|
13
|
+
onReorder?: (fromIndex: number, toIndex: number) => void
|
|
12
14
|
/** `horizontal` (default) for a bottom strip, `vertical` for a side rail. */
|
|
13
15
|
orientation?: 'horizontal' | 'vertical'
|
|
14
16
|
className?: string
|
|
@@ -22,9 +24,11 @@ export function SlideFilmstrip({
|
|
|
22
24
|
slides,
|
|
23
25
|
activeId,
|
|
24
26
|
onSelect,
|
|
27
|
+
onReorder,
|
|
25
28
|
orientation = 'horizontal',
|
|
26
29
|
className,
|
|
27
30
|
}: SlideFilmstripProps) {
|
|
31
|
+
const dragIndex = React.useRef<number | null>(null)
|
|
28
32
|
return (
|
|
29
33
|
<div
|
|
30
34
|
className={cn(
|
|
@@ -35,9 +39,24 @@ export function SlideFilmstrip({
|
|
|
35
39
|
role="group"
|
|
36
40
|
aria-label="Slides"
|
|
37
41
|
>
|
|
38
|
-
{slides.map((slide) => {
|
|
42
|
+
{slides.map((slide, idx) => {
|
|
39
43
|
const active = slide.id === activeId
|
|
40
44
|
const status = slide.status ?? (slide.imageUrl ? 'ready' : 'pending')
|
|
45
|
+
const dnd = onReorder
|
|
46
|
+
? {
|
|
47
|
+
draggable: true,
|
|
48
|
+
onDragStart: () => {
|
|
49
|
+
dragIndex.current = idx
|
|
50
|
+
},
|
|
51
|
+
onDragOver: (e: React.DragEvent) => e.preventDefault(),
|
|
52
|
+
onDrop: (e: React.DragEvent) => {
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
const from = dragIndex.current
|
|
55
|
+
dragIndex.current = null
|
|
56
|
+
if (from !== null && from !== idx) onReorder(from, idx)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: {}
|
|
41
60
|
return (
|
|
42
61
|
<button
|
|
43
62
|
key={slide.id}
|
|
@@ -45,6 +64,7 @@ export function SlideFilmstrip({
|
|
|
45
64
|
aria-current={active ? 'true' : undefined}
|
|
46
65
|
aria-label={`Slide ${slide.slideNumber}${slide.title ? `: ${slide.title}` : ''}`}
|
|
47
66
|
onClick={() => onSelect(slide.id)}
|
|
67
|
+
{...dnd}
|
|
48
68
|
className={cn(
|
|
49
69
|
'group relative aspect-video w-28 shrink-0 overflow-hidden rounded-md border bg-white text-left transition-all',
|
|
50
70
|
active ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
|