@startsimpli/ui 0.4.14 → 0.4.15
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/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -11,7 +11,6 @@ export interface ChangePasswordFormProps {
|
|
|
11
11
|
onSubmit: (values: {
|
|
12
12
|
old_password: string
|
|
13
13
|
new_password: string
|
|
14
|
-
new_password_confirm: string
|
|
15
14
|
}) => Promise<void>
|
|
16
15
|
/** Called after a successful password change (e.g. to sign out) */
|
|
17
16
|
onSuccess?: () => void
|
|
@@ -21,38 +20,27 @@ export interface ChangePasswordFormProps {
|
|
|
21
20
|
export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePasswordFormProps) {
|
|
22
21
|
const [oldPassword, setOldPassword] = useState('')
|
|
23
22
|
const [newPassword, setNewPassword] = useState('')
|
|
24
|
-
const [confirmPassword, setConfirmPassword] = useState('')
|
|
25
23
|
const [saving, setSaving] = useState(false)
|
|
26
24
|
const [error, setError] = useState<string | null>(null)
|
|
27
25
|
const [success, setSuccess] = useState(false)
|
|
28
26
|
|
|
29
|
-
const isValid =
|
|
30
|
-
oldPassword.length > 0 &&
|
|
31
|
-
newPassword.length >= 8 &&
|
|
32
|
-
newPassword === confirmPassword
|
|
27
|
+
const isValid = oldPassword.length > 0 && newPassword.length >= 8
|
|
33
28
|
|
|
34
29
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
35
30
|
e.preventDefault()
|
|
36
31
|
setError(null)
|
|
37
32
|
setSuccess(false)
|
|
38
33
|
|
|
39
|
-
if (newPassword !== confirmPassword) {
|
|
40
|
-
setError('New passwords do not match.')
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
|
|
44
34
|
setSaving(true)
|
|
45
35
|
|
|
46
36
|
try {
|
|
47
37
|
await onSubmit({
|
|
48
38
|
old_password: oldPassword,
|
|
49
39
|
new_password: newPassword,
|
|
50
|
-
new_password_confirm: confirmPassword,
|
|
51
40
|
})
|
|
52
41
|
setSuccess(true)
|
|
53
42
|
setOldPassword('')
|
|
54
43
|
setNewPassword('')
|
|
55
|
-
setConfirmPassword('')
|
|
56
44
|
onSuccess?.()
|
|
57
45
|
} catch (err) {
|
|
58
46
|
setError(err instanceof Error ? err.message : 'Failed to change password')
|
|
@@ -100,21 +88,6 @@ export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePass
|
|
|
100
88
|
)}
|
|
101
89
|
</div>
|
|
102
90
|
|
|
103
|
-
<div className="space-y-2">
|
|
104
|
-
<Label htmlFor="cp-confirm-password">Confirm new password</Label>
|
|
105
|
-
<Input
|
|
106
|
-
id="cp-confirm-password"
|
|
107
|
-
type="password"
|
|
108
|
-
value={confirmPassword}
|
|
109
|
-
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
110
|
-
disabled={disabled || saving}
|
|
111
|
-
autoComplete="new-password"
|
|
112
|
-
/>
|
|
113
|
-
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
|
114
|
-
<p className="text-xs text-destructive">Passwords do not match.</p>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
91
|
{error && (
|
|
119
92
|
<p className="text-sm text-destructive">{error}</p>
|
|
120
93
|
)}
|
|
@@ -115,6 +115,22 @@ export interface CalendarViewProps<T = unknown> {
|
|
|
115
115
|
minHeight?: number
|
|
116
116
|
/** className passed to the wrapping div for Tailwind overrides. */
|
|
117
117
|
className?: string
|
|
118
|
+
/**
|
|
119
|
+
* Per-event styling. Receives the original CalendarEvent (with resource)
|
|
120
|
+
* and returns optional className / inline style applied to the rendered
|
|
121
|
+
* event block. Useful for color-coding by source (e.g. real Outlook event
|
|
122
|
+
* vs locally-scheduled meeting).
|
|
123
|
+
*/
|
|
124
|
+
eventPropGetter?: (event: CalendarEvent<T>) => {
|
|
125
|
+
className?: string
|
|
126
|
+
style?: React.CSSProperties
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* When the month grid runs out of vertical space, react-big-calendar
|
|
130
|
+
* collapses the overflow into a "+N more" link. Set true (default) to
|
|
131
|
+
* open the day's full event list as a popover when that link is clicked.
|
|
132
|
+
*/
|
|
133
|
+
popup?: boolean
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
/**
|
|
@@ -150,6 +166,8 @@ export function CalendarView<T = unknown>({
|
|
|
150
166
|
selectable = true,
|
|
151
167
|
minHeight = 600,
|
|
152
168
|
className,
|
|
169
|
+
eventPropGetter,
|
|
170
|
+
popup = true,
|
|
153
171
|
}: CalendarViewProps<T>) {
|
|
154
172
|
const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
|
|
155
173
|
|
|
@@ -196,6 +214,17 @@ export function CalendarView<T = unknown>({
|
|
|
196
214
|
[views],
|
|
197
215
|
)
|
|
198
216
|
|
|
217
|
+
const rbcEventPropGetter = React.useCallback(
|
|
218
|
+
(rbcEvent: object) => {
|
|
219
|
+
if (!eventPropGetter) return {}
|
|
220
|
+
const id = (rbcEvent as InternalRBCEvent).id
|
|
221
|
+
const original = eventByInternalId.get(id)
|
|
222
|
+
if (!original) return {}
|
|
223
|
+
return eventPropGetter(original)
|
|
224
|
+
},
|
|
225
|
+
[eventPropGetter, eventByInternalId],
|
|
226
|
+
)
|
|
227
|
+
|
|
199
228
|
return (
|
|
200
229
|
<div className={className} style={{ minHeight }}>
|
|
201
230
|
<Calendar
|
|
@@ -215,6 +244,8 @@ export function CalendarView<T = unknown>({
|
|
|
215
244
|
onNavigate={onNavigate}
|
|
216
245
|
onView={handleViewChange}
|
|
217
246
|
selectable={selectable}
|
|
247
|
+
popup={popup}
|
|
248
|
+
eventPropGetter={eventPropGetter ? rbcEventPropGetter : undefined}
|
|
218
249
|
style={{ height: "100%", minHeight }}
|
|
219
250
|
/>
|
|
220
251
|
</div>
|
|
@@ -11,3 +11,10 @@ export type {
|
|
|
11
11
|
UpcomingMeetingStatus,
|
|
12
12
|
UpcomingMeetingsProps,
|
|
13
13
|
} from "./upcoming-meetings"
|
|
14
|
+
|
|
15
|
+
export { MeetingsList } from "./meetings-list"
|
|
16
|
+
export type {
|
|
17
|
+
MeetingsListItem,
|
|
18
|
+
MeetingsListProps,
|
|
19
|
+
MeetingsListStatus,
|
|
20
|
+
} from "./meetings-list"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MeetingsList — table-style meeting list with status badges, join links,
|
|
5
|
+
* and an optional delete action.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from UpcomingMeetings (sidebar card-stack); this is the
|
|
8
|
+
* full-width table-row layout used by /calendar's "All Meetings" section
|
|
9
|
+
* and /fundraises/[id]/outreach's MeetingsTab (Upcoming + Past lists).
|
|
10
|
+
*
|
|
11
|
+
* Pure presentational. The consumer owns sort, filter, fetch, and delete
|
|
12
|
+
* confirmation.
|
|
13
|
+
*
|
|
14
|
+
* lifecycle:calendar-ui (raise-simpli-9cp)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as React from "react"
|
|
18
|
+
import { CalendarDays, ExternalLink, Plus, Trash2 } from "lucide-react"
|
|
19
|
+
import { Button } from "../ui/button"
|
|
20
|
+
import { cn } from "../../utils"
|
|
21
|
+
|
|
22
|
+
export type MeetingsListStatus = "pending" | "confirmed" | "cancelled" | "completed"
|
|
23
|
+
|
|
24
|
+
export interface MeetingsListItem {
|
|
25
|
+
id: string | number
|
|
26
|
+
title: string
|
|
27
|
+
/** ISO 8601 timestamp string */
|
|
28
|
+
scheduled_at: string
|
|
29
|
+
status: MeetingsListStatus
|
|
30
|
+
duration_minutes?: number
|
|
31
|
+
investor_name?: string
|
|
32
|
+
investor_email?: string
|
|
33
|
+
meeting_link?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MeetingsListProps<T extends MeetingsListItem = MeetingsListItem> {
|
|
37
|
+
meetings: T[]
|
|
38
|
+
/** Whether the list is still loading. */
|
|
39
|
+
loading?: boolean
|
|
40
|
+
/** Number of skeleton rows to show while loading. Defaults to 4. */
|
|
41
|
+
loadingRowCount?: number
|
|
42
|
+
/** If set, each row gets a delete (trash) button that calls this with the id. */
|
|
43
|
+
onDelete?: (id: string | number) => void
|
|
44
|
+
/** If set, the whole row becomes clickable. */
|
|
45
|
+
onRowClick?: (meeting: T) => void
|
|
46
|
+
/** If set, the empty state shows a "Schedule First Meeting" CTA wired to this. */
|
|
47
|
+
onSchedule?: () => void
|
|
48
|
+
/** Empty-state copy. Defaults to "No meetings scheduled yet." */
|
|
49
|
+
emptyMessage?: string
|
|
50
|
+
/** className passed to the wrapping div. */
|
|
51
|
+
className?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const STATUS_BADGE: Record<MeetingsListStatus, { label: string; className: string }> = {
|
|
55
|
+
pending: { label: "Pending", className: "bg-yellow-100 text-yellow-800" },
|
|
56
|
+
confirmed: { label: "Confirmed", className: "bg-green-100 text-green-800" },
|
|
57
|
+
cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800" },
|
|
58
|
+
completed: { label: "Completed", className: "bg-gray-100 text-gray-800" },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatDate(isoStr: string): string {
|
|
62
|
+
return new Date(isoStr).toLocaleDateString("en-US", {
|
|
63
|
+
weekday: "short",
|
|
64
|
+
month: "short",
|
|
65
|
+
day: "numeric",
|
|
66
|
+
year: "numeric",
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTime(isoStr: string): string {
|
|
71
|
+
return new Date(isoStr).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @example
|
|
76
|
+
* <MeetingsList
|
|
77
|
+
* meetings={meetings}
|
|
78
|
+
* loading={loading}
|
|
79
|
+
* onDelete={id => deleteMeeting(id)}
|
|
80
|
+
* onSchedule={() => setShowSchedule(true)}
|
|
81
|
+
* />
|
|
82
|
+
*/
|
|
83
|
+
export function MeetingsList<T extends MeetingsListItem = MeetingsListItem>({
|
|
84
|
+
meetings,
|
|
85
|
+
loading = false,
|
|
86
|
+
loadingRowCount = 4,
|
|
87
|
+
onDelete,
|
|
88
|
+
onRowClick,
|
|
89
|
+
onSchedule,
|
|
90
|
+
emptyMessage = "No meetings scheduled yet.",
|
|
91
|
+
className,
|
|
92
|
+
}: MeetingsListProps<T>) {
|
|
93
|
+
if (loading) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn("p-4 space-y-2", className)}>
|
|
96
|
+
{Array.from({ length: loadingRowCount }).map((_, i) => (
|
|
97
|
+
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (meetings.length === 0) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("text-center py-12 text-muted-foreground", className)}>
|
|
106
|
+
<CalendarDays className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
|
107
|
+
<p>{emptyMessage}</p>
|
|
108
|
+
{onSchedule && (
|
|
109
|
+
<Button className="mt-3" onClick={onSchedule}>
|
|
110
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
111
|
+
Schedule First Meeting
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={cn("divide-y", className)}>
|
|
120
|
+
{meetings.map(m => {
|
|
121
|
+
const badge = STATUS_BADGE[m.status]
|
|
122
|
+
const interactive = !!onRowClick
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
key={m.id}
|
|
126
|
+
role={interactive ? "button" : undefined}
|
|
127
|
+
tabIndex={interactive ? 0 : undefined}
|
|
128
|
+
onClick={interactive ? () => onRowClick(m) : undefined}
|
|
129
|
+
onKeyDown={
|
|
130
|
+
interactive
|
|
131
|
+
? e => {
|
|
132
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
onRowClick(m)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
: undefined
|
|
138
|
+
}
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex items-center justify-between px-4 py-3 transition-colors",
|
|
141
|
+
interactive && "hover:bg-muted/50 cursor-pointer",
|
|
142
|
+
!interactive && "hover:bg-muted/50",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center gap-4 min-w-0">
|
|
146
|
+
<div className="min-w-0">
|
|
147
|
+
<p className="font-medium text-sm truncate">{m.title}</p>
|
|
148
|
+
{(m.investor_name || m.investor_email) && (
|
|
149
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
150
|
+
{m.investor_name || m.investor_email}
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex items-center gap-4 shrink-0 ml-4">
|
|
157
|
+
<div className="text-right text-xs text-muted-foreground hidden sm:block">
|
|
158
|
+
<p>{formatDate(m.scheduled_at)}</p>
|
|
159
|
+
<p>
|
|
160
|
+
{formatTime(m.scheduled_at)}
|
|
161
|
+
{m.duration_minutes ? ` · ${m.duration_minutes}min` : ""}
|
|
162
|
+
</p>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<span className={cn("text-xs px-2 py-0.5 rounded font-medium", badge.className)}>
|
|
166
|
+
{badge.label}
|
|
167
|
+
</span>
|
|
168
|
+
|
|
169
|
+
{m.meeting_link && (
|
|
170
|
+
<a
|
|
171
|
+
href={m.meeting_link}
|
|
172
|
+
target="_blank"
|
|
173
|
+
rel="noreferrer"
|
|
174
|
+
onClick={e => e.stopPropagation()}
|
|
175
|
+
className="text-primary"
|
|
176
|
+
title="Join meeting"
|
|
177
|
+
>
|
|
178
|
+
<ExternalLink className="h-4 w-4" />
|
|
179
|
+
</a>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{onDelete && (
|
|
183
|
+
<Button
|
|
184
|
+
variant="ghost"
|
|
185
|
+
size="sm"
|
|
186
|
+
onClick={e => {
|
|
187
|
+
e.stopPropagation()
|
|
188
|
+
onDelete(m.id)
|
|
189
|
+
}}
|
|
190
|
+
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-600"
|
|
191
|
+
aria-label="Delete meeting"
|
|
192
|
+
>
|
|
193
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
194
|
+
</Button>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -31,12 +31,12 @@ export interface UpcomingMeeting {
|
|
|
31
31
|
meeting_link?: string
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export interface UpcomingMeetingsProps {
|
|
35
|
-
meetings:
|
|
34
|
+
export interface UpcomingMeetingsProps<T extends UpcomingMeeting = UpcomingMeeting> {
|
|
35
|
+
meetings: T[]
|
|
36
36
|
/** Whether the list is still loading. Renders skeletons. */
|
|
37
37
|
loading?: boolean
|
|
38
38
|
/** Optional click handler when a meeting row is clicked (whole row). */
|
|
39
|
-
onMeetingClick?: (meeting:
|
|
39
|
+
onMeetingClick?: (meeting: T) => void
|
|
40
40
|
/** Optional URL for the "View all" header link. Hidden if absent. */
|
|
41
41
|
viewAllHref?: string
|
|
42
42
|
/** Optional click handler for the "View all" header link. Used instead of href if provided. */
|
|
@@ -82,7 +82,7 @@ function formatTime(isoStr: string): string {
|
|
|
82
82
|
* onSchedule={() => setShowSchedule(true)}
|
|
83
83
|
* />
|
|
84
84
|
*/
|
|
85
|
-
export function UpcomingMeetings({
|
|
85
|
+
export function UpcomingMeetings<T extends UpcomingMeeting = UpcomingMeeting>({
|
|
86
86
|
meetings,
|
|
87
87
|
loading = false,
|
|
88
88
|
onMeetingClick,
|
|
@@ -92,7 +92,7 @@ export function UpcomingMeetings({
|
|
|
92
92
|
className,
|
|
93
93
|
title = "Upcoming Meetings",
|
|
94
94
|
loadingRowCount = 3,
|
|
95
|
-
}: UpcomingMeetingsProps) {
|
|
95
|
+
}: UpcomingMeetingsProps<T>) {
|
|
96
96
|
return (
|
|
97
97
|
<Card className={className}>
|
|
98
98
|
<CardHeader className="pb-3">
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { SendHorizonal } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
export interface ChatComposerProps {
|
|
8
|
+
/** Controlled value. Omit for uncontrolled usage. */
|
|
9
|
+
value?: string
|
|
10
|
+
onChange?: (value: string) => void
|
|
11
|
+
/** Called with the trimmed message text on submit. */
|
|
12
|
+
onSubmit: (text: string) => void
|
|
13
|
+
placeholder?: string
|
|
14
|
+
/** Disables input entirely. */
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
/** Submission in flight — input stays editable but submit is blocked. */
|
|
17
|
+
busy?: boolean
|
|
18
|
+
submitLabel?: string
|
|
19
|
+
/** Max rows the textarea grows to before scrolling. Default 10. */
|
|
20
|
+
maxRows?: number
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a
|
|
26
|
+
* newline, and large pastes are accepted as-is. Controlled or uncontrolled.
|
|
27
|
+
*/
|
|
28
|
+
export function ChatComposer({
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
onSubmit,
|
|
32
|
+
placeholder = 'Message…',
|
|
33
|
+
disabled = false,
|
|
34
|
+
busy = false,
|
|
35
|
+
submitLabel = 'Send',
|
|
36
|
+
maxRows = 10,
|
|
37
|
+
className,
|
|
38
|
+
}: ChatComposerProps) {
|
|
39
|
+
const isControlled = value !== undefined
|
|
40
|
+
const [internal, setInternal] = React.useState('')
|
|
41
|
+
const text = isControlled ? value! : internal
|
|
42
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
43
|
+
|
|
44
|
+
const setText = (next: string) => {
|
|
45
|
+
if (!isControlled) setInternal(next)
|
|
46
|
+
onChange?.(next)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// auto-grow
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
const el = textareaRef.current
|
|
52
|
+
if (!el) return
|
|
53
|
+
el.style.height = 'auto'
|
|
54
|
+
const lineHeight = 24
|
|
55
|
+
el.style.height = `${Math.min(el.scrollHeight, lineHeight * maxRows)}px`
|
|
56
|
+
}, [text, maxRows])
|
|
57
|
+
|
|
58
|
+
const canSubmit = !disabled && !busy && text.trim().length > 0
|
|
59
|
+
|
|
60
|
+
const submit = () => {
|
|
61
|
+
if (!canSubmit) return
|
|
62
|
+
onSubmit(text.trim())
|
|
63
|
+
if (!isControlled) setInternal('')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
67
|
+
if (e.key !== 'Enter') return
|
|
68
|
+
// Cmd/Ctrl+Enter always submits (handy for multi-line content).
|
|
69
|
+
if (e.metaKey || e.ctrlKey) {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
submit()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (e.shiftKey) return // explicit newline
|
|
75
|
+
// Bare Enter submits ONLY single-line content. When the value already
|
|
76
|
+
// spans multiple lines (e.g. a pasted outline) Enter inserts a newline so
|
|
77
|
+
// the multi-line block stays intact — submit via the button or Cmd+Enter.
|
|
78
|
+
if (text.includes('\n')) return
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
submit()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn('flex items-end gap-2 border-t border-border bg-background p-3', className)}>
|
|
85
|
+
<textarea
|
|
86
|
+
ref={textareaRef}
|
|
87
|
+
value={text}
|
|
88
|
+
onChange={(e) => setText(e.target.value)}
|
|
89
|
+
onKeyDown={onKeyDown}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
disabled={disabled}
|
|
92
|
+
rows={1}
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
|
|
95
|
+
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
96
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
97
|
+
)}
|
|
98
|
+
/>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={submit}
|
|
102
|
+
disabled={!canSubmit}
|
|
103
|
+
aria-label={submitLabel}
|
|
104
|
+
className={cn(
|
|
105
|
+
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
|
|
106
|
+
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<SendHorizonal className="h-4 w-4" aria-hidden="true" />
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Wrench } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { ChatMessageData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ChatMessageProps {
|
|
9
|
+
message: ChatMessageData
|
|
10
|
+
/** Optional custom renderer for the message body (e.g. markdown). */
|
|
11
|
+
renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function StreamingDots() {
|
|
16
|
+
return (
|
|
17
|
+
<span className="ml-1 inline-flex gap-0.5 align-middle" aria-hidden="true">
|
|
18
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
19
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
20
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
|
|
21
|
+
</span>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A single chat row. Text messages render as aligned bubbles (user right,
|
|
27
|
+
* assistant left); `tool` / `status` kinds render as compact centered system
|
|
28
|
+
* rows. Fully generic — no presentation-specific knowledge.
|
|
29
|
+
*/
|
|
30
|
+
export function ChatMessage({ message, renderContent, className }: ChatMessageProps) {
|
|
31
|
+
const { role, content, status, kind = 'text', toolName } = message
|
|
32
|
+
const isStreaming = status === 'streaming'
|
|
33
|
+
const isError = status === 'error'
|
|
34
|
+
|
|
35
|
+
if (kind === 'tool' || kind === 'status') {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
data-role={role}
|
|
39
|
+
data-kind={kind}
|
|
40
|
+
data-status={status}
|
|
41
|
+
aria-busy={isStreaming || undefined}
|
|
42
|
+
className={cn(
|
|
43
|
+
'mx-auto flex max-w-[90%] items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground',
|
|
44
|
+
isError && 'border-red-300 bg-red-50 text-red-700',
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{kind === 'tool' && <Wrench className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
|
|
49
|
+
{toolName && <span className="font-medium text-foreground">{toolName}</span>}
|
|
50
|
+
<span className="truncate">{content}</span>
|
|
51
|
+
{isStreaming && <StreamingDots />}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isUser = role === 'user'
|
|
57
|
+
const body = renderContent ? renderContent(content, message) : content
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
data-role={role}
|
|
62
|
+
data-kind={kind}
|
|
63
|
+
data-status={status}
|
|
64
|
+
aria-busy={isStreaming || undefined}
|
|
65
|
+
className={cn('flex w-full', isUser ? 'justify-end' : 'justify-start', className)}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-4 py-2 text-sm shadow-sm',
|
|
70
|
+
isUser
|
|
71
|
+
? 'rounded-br-sm bg-primary text-primary-foreground'
|
|
72
|
+
: 'rounded-bl-sm bg-muted text-foreground',
|
|
73
|
+
isError && 'border border-red-300 bg-red-50 text-red-700'
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{body}
|
|
77
|
+
{isStreaming && <StreamingDots />}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
import { ChatMessage } from './ChatMessage'
|
|
6
|
+
import type { ChatMessageData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ChatThreadProps {
|
|
9
|
+
messages: ChatMessageData[]
|
|
10
|
+
/** Rendered when there are no messages. */
|
|
11
|
+
emptyState?: React.ReactNode
|
|
12
|
+
/** Custom message renderer; defaults to <ChatMessage>. */
|
|
13
|
+
renderMessage?: (message: ChatMessageData) => React.ReactNode
|
|
14
|
+
/** Auto-scroll to the newest message on change. Default true. */
|
|
15
|
+
autoScroll?: boolean
|
|
16
|
+
renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scrollable, auto-scrolling list of chat messages. App-agnostic.
|
|
22
|
+
*/
|
|
23
|
+
export function ChatThread({
|
|
24
|
+
messages,
|
|
25
|
+
emptyState,
|
|
26
|
+
renderMessage,
|
|
27
|
+
autoScroll = true,
|
|
28
|
+
renderContent,
|
|
29
|
+
className,
|
|
30
|
+
}: ChatThreadProps) {
|
|
31
|
+
const anchorRef = React.useRef<HTMLDivElement>(null)
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
if (autoScroll) anchorRef.current?.scrollIntoView({ block: 'end' })
|
|
35
|
+
}, [messages, autoScroll])
|
|
36
|
+
|
|
37
|
+
if (messages.length === 0 && emptyState) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn('flex h-full items-center justify-center p-6 text-center', className)}>
|
|
40
|
+
{emptyState}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('flex h-full flex-col gap-3 overflow-y-auto p-4', className)} role="log" aria-live="polite">
|
|
47
|
+
{messages.map((m) =>
|
|
48
|
+
renderMessage ? (
|
|
49
|
+
<React.Fragment key={m.id}>{renderMessage(m)}</React.Fragment>
|
|
50
|
+
) : (
|
|
51
|
+
<ChatMessage key={m.id} message={m} renderContent={renderContent} />
|
|
52
|
+
)
|
|
53
|
+
)}
|
|
54
|
+
<div ref={anchorRef} data-testid="chat-thread-anchor" />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ChatMessage } from './ChatMessage'
|
|
2
|
+
export type { ChatMessageProps } from './ChatMessage'
|
|
3
|
+
export { ChatThread } from './ChatThread'
|
|
4
|
+
export type { ChatThreadProps } from './ChatThread'
|
|
5
|
+
export { ChatComposer } from './ChatComposer'
|
|
6
|
+
export type { ChatComposerProps } from './ChatComposer'
|
|
7
|
+
export type {
|
|
8
|
+
ChatMessageData,
|
|
9
|
+
ChatRole,
|
|
10
|
+
ChatMessageStatus,
|
|
11
|
+
ChatMessageKind,
|
|
12
|
+
} from './types'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ChatRole = 'user' | 'assistant' | 'system' | 'tool'
|
|
2
|
+
|
|
3
|
+
export type ChatMessageStatus = 'streaming' | 'complete' | 'error'
|
|
4
|
+
|
|
5
|
+
export type ChatMessageKind = 'text' | 'tool' | 'status'
|
|
6
|
+
|
|
7
|
+
export interface ChatMessageData {
|
|
8
|
+
id: string
|
|
9
|
+
role: ChatRole
|
|
10
|
+
content: string
|
|
11
|
+
/** Display name for the author (defaults derived from role). */
|
|
12
|
+
author?: string
|
|
13
|
+
timestamp?: string | number | Date
|
|
14
|
+
/** `streaming` shows a live indicator; `error` styles the bubble as failed. */
|
|
15
|
+
status?: ChatMessageStatus
|
|
16
|
+
/** `tool`/`status` render as compact system rows rather than chat bubbles. */
|
|
17
|
+
kind?: ChatMessageKind
|
|
18
|
+
/** Name of the tool when kind === 'tool'. */
|
|
19
|
+
toolName?: string
|
|
20
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -53,6 +53,15 @@ export * from './unified-table/utils'
|
|
|
53
53
|
// Export Navigation
|
|
54
54
|
export * from './navigation/sidebar'
|
|
55
55
|
|
|
56
|
+
// Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
|
|
57
|
+
export * from './workspace'
|
|
58
|
+
|
|
59
|
+
// Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
|
|
60
|
+
export * from './chat'
|
|
61
|
+
|
|
62
|
+
// Slide-deck viewer (Google-Slides-style canvas + filmstrip)
|
|
63
|
+
export * from './slide-deck'
|
|
64
|
+
|
|
56
65
|
// HTML rendering with XSS protection
|
|
57
66
|
export { SafeHtml } from './safe-html'
|
|
58
67
|
|
|
@@ -101,6 +110,10 @@ export * from './command-palette'
|
|
|
101
110
|
// Settings components (SettingsLayout, SettingsNav, SettingsCard)
|
|
102
111
|
export * from './settings'
|
|
103
112
|
|
|
113
|
+
// Team management (startsim-o7s) — MembersTable, InviteMemberDialog, RoleSelector,
|
|
114
|
+
// PendingInvitationCallout, DomainClaimCard, LeaveTeamDialog, OrgSwitcher.
|
|
115
|
+
export * from './team'
|
|
116
|
+
|
|
104
117
|
// Kanban board layout
|
|
105
118
|
export * from './kanban'
|
|
106
119
|
|