@startsimpli/ui 0.4.23 → 0.4.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
33
  "react": "^18.0.0 || ^19.0.0",
34
34
  "react-dom": "^18.0.0 || ^19.0.0",
35
- "@startsimpli/auth": "0.4.20"
35
+ "@startsimpli/auth": "0.4.22"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@startsimpli/auth": {
@@ -26,7 +26,12 @@ export function SlideCanvas({ slide, onRetry, renderStructured, className }: Sli
26
26
  return (
27
27
  <div
28
28
  className={cn(
29
- 'relative mx-auto aspect-video w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-white shadow-sm',
29
+ // Fit the largest 16:9 box inside the stage on BOTH axes: drive size
30
+ // from the stage HEIGHT (h-full) so a wide pane can't make the card
31
+ // taller than the stage and bleed over the toolbar; max-w-full clamps
32
+ // width on tall/narrow panes. object-contain on the img handles any
33
+ // residual letterboxing.
34
+ 'relative mx-auto aspect-video h-full max-h-full w-auto max-w-full overflow-hidden rounded-lg border border-border bg-white shadow-sm',
30
35
  className
31
36
  )}
32
37
  data-status={status}
@@ -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
- const onKeyDown = (e: React.KeyboardEvent) => {
58
- if (e.key === 'ArrowRight') {
59
- e.preventDefault()
60
- goTo(index + 1)
61
- } else if (e.key === 'ArrowLeft') {
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 &amp; 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'