@flamingo-stack/openframe-frontend-core 0.0.700 → 0.0.701-snapshot.20261005171139
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/dist/{chunk-MFORWVGD.js → chunk-2V4Z44G6.js} +4 -4
- package/dist/{chunk-GFXZMFDX.cjs → chunk-3G76LRGL.cjs} +9 -9
- package/dist/{chunk-65YVUYWJ.cjs → chunk-3WSFIV5G.cjs} +32 -32
- package/dist/{chunk-GD4JALTR.js → chunk-446KS5BG.js} +1 -1
- package/dist/{chunk-BRQY2HKD.cjs → chunk-AEQANLBW.cjs} +17 -17
- package/dist/{chunk-ZSPHOHOW.cjs → chunk-ASN65P3F.cjs} +2 -2
- package/dist/{chunk-YBC346FY.cjs → chunk-B3LZJ6J3.cjs} +12 -12
- package/dist/{chunk-JPVDJB27.js → chunk-BO5ZHDA4.js} +3 -3
- package/dist/{chunk-2CSYUMNM.cjs → chunk-CXDJUKC6.cjs} +2 -2
- package/dist/{chunk-QPNR4N7N.cjs → chunk-CYYTD5ZK.cjs} +16 -16
- package/dist/{chunk-B7M6QEEM.js → chunk-CZ34HXDW.js} +661 -275
- package/dist/{chunk-4H5Q6MQG.cjs → chunk-E3DH543A.cjs} +35 -35
- package/dist/{chunk-BZYMGI4L.js → chunk-ETFFEA3J.js} +1 -1
- package/dist/{chunk-QDIX2ZRL.cjs → chunk-FD4DBTFO.cjs} +430 -44
- package/dist/{chunk-BPS7JQCI.cjs → chunk-FHM7GD5B.cjs} +2 -2
- package/dist/{chunk-QZGFJBG5.cjs → chunk-HIXGAY2L.cjs} +50 -50
- package/dist/{chunk-V5QNDOEL.js → chunk-HPJ4XS2L.js} +3 -3
- package/dist/{chunk-DJLZXJYB.js → chunk-HYFLTFFB.js} +1 -1
- package/dist/{chunk-CUPJCGLB.js → chunk-IZKOQVXO.js} +1 -1
- package/dist/{chunk-N7DPIH57.js → chunk-JOY6HVPV.js} +1 -1
- package/dist/{chunk-NIT257DV.cjs → chunk-L7KZA7ZD.cjs} +2 -2
- package/dist/{chunk-BQ34PIFC.cjs → chunk-LZNJH23A.cjs} +3 -3
- package/dist/{chunk-EONNPRAW.js → chunk-MWUBBBWZ.js} +1 -1
- package/dist/{chunk-N7HMUQRV.js → chunk-PLK6GHN7.js} +1 -1
- package/dist/{chunk-DG2I57UM.cjs → chunk-QFW3RUZS.cjs} +16 -16
- package/dist/{chunk-S4ADN25W.js → chunk-RS7HWAYX.js} +1 -1
- package/dist/{chunk-PNWIVND4.js → chunk-SAA7B3DA.js} +2 -2
- package/dist/{chunk-6JG75WVO.js → chunk-UJJWTJ6S.js} +4 -4
- package/dist/{chunk-7ZDTAABP.cjs → chunk-YGRHITPY.cjs} +3 -3
- package/dist/{chunk-A5ZGKBN2.js → chunk-Z5HDCZAS.js} +1 -1
- package/dist/components/announcement-bar.d.ts +1 -1
- package/dist/components/announcement-bar.d.ts.map +1 -1
- package/dist/components/case-studies/index.cjs +11 -11
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +22 -2
- package/dist/components/features/index.js +21 -1
- package/dist/components/features/remote-session/index.d.ts +9 -1
- package/dist/components/features/remote-session/index.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-block.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-block.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-events.d.ts +24 -0
- package/dist/components/features/remote-session/remote-session-events.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts +41 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-parts.d.ts +13 -2
- package/dist/components/features/remote-session/remote-session-parts.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-status.d.ts +34 -0
- package/dist/components/features/remote-session/remote-session-status.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts +15 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts.map +1 -0
- package/dist/components/features/remote-session/types.d.ts +22 -0
- package/dist/components/features/remote-session/types.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +49 -49
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +55 -34
- package/dist/components/index.js +35 -14
- package/dist/components/meeting-scheduler/index.cjs +22 -22
- package/dist/components/meeting-scheduler/index.js +1 -1
- package/dist/components/navigation/index.cjs +5 -5
- package/dist/components/navigation/index.js +4 -4
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +26 -6
- package/dist/index.js +25 -5
- package/dist/tailwind.config.cjs +7 -1
- package/dist/tailwind.config.js +7 -1
- package/dist/types/announcement.d.ts +1 -6
- package/dist/types/announcement.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/announcement-bar.tsx +7 -2
- package/src/components/features/remote-session/index.ts +28 -0
- package/src/components/features/remote-session/remote-session-block.tsx +6 -3
- package/src/components/features/remote-session/remote-session-chat-panel.tsx +11 -7
- package/src/components/features/remote-session/remote-session-events.tsx +93 -0
- package/src/components/features/remote-session/remote-session-keep-modals.tsx +208 -0
- package/src/components/features/remote-session/remote-session-parts.tsx +32 -3
- package/src/components/features/remote-session/remote-session-status.tsx +102 -0
- package/src/components/features/remote-session/remote-session-viewers.tsx +103 -0
- package/src/components/features/remote-session/types.ts +26 -0
- package/src/types/announcement.ts +1 -6
- package/tailwind.config.ts +6 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useState } from 'react';
|
|
4
|
+
import { HourglassIcon, InfoCircleIcon } from '../../icons-v2-generated';
|
|
5
|
+
import { Button } from '../../ui/button/button';
|
|
6
|
+
import { Input } from '../../ui/input';
|
|
7
|
+
import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from '../../ui/modal-v2';
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
|
9
|
+
import type { RemoteSessionKeepReason } from './types';
|
|
10
|
+
|
|
11
|
+
/** How each Keep reason reads in the dialog, the session list and the release notice. */
|
|
12
|
+
export const REMOTE_SESSION_KEEP_REASON_LABELS: Record<RemoteSessionKeepReason, string> = {
|
|
13
|
+
CLIENT_DISPUTE: 'Client dispute',
|
|
14
|
+
INTERNAL_REVIEW: 'Internal review',
|
|
15
|
+
LEGAL_OR_COMPLIANCE: 'Legal or compliance request',
|
|
16
|
+
OTHER: 'Other',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const KEEP_REASONS = Object.keys(REMOTE_SESSION_KEEP_REASON_LABELS) as RemoteSessionKeepReason[];
|
|
20
|
+
|
|
21
|
+
const DESCRIPTION_MAX_LENGTH = 500;
|
|
22
|
+
|
|
23
|
+
/** A notice inside the Keep dialogs: an icon, a bold line and the explanation under it. */
|
|
24
|
+
function KeepNotice({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex w-full items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]">
|
|
27
|
+
<span className="flex shrink-0 text-ods-text-secondary">{icon}</span>
|
|
28
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
29
|
+
<p className="text-ods-text-primary text-h3">{title}</p>
|
|
30
|
+
<p className="text-ods-text-secondary text-h4">{children}</p>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface KeepRecordingSelection {
|
|
37
|
+
reason: RemoteSessionKeepReason;
|
|
38
|
+
/** Trimmed; set for `OTHER` only. */
|
|
39
|
+
description: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KeepRecordingModalProps {
|
|
43
|
+
isOpen: boolean;
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
/** What kept recordings take of the tenant's allowance, e.g. "612 MB of 50 GB". */
|
|
46
|
+
keptUsage: string;
|
|
47
|
+
/** The Keep is in flight: the button spins and the dialog refuses to close. */
|
|
48
|
+
isPending?: boolean;
|
|
49
|
+
onConfirm: (selection: KeepRecordingSelection) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keep this recording: exempts it from automatic deletion until someone releases it. */
|
|
53
|
+
export function KeepRecordingModal({
|
|
54
|
+
isOpen,
|
|
55
|
+
onClose,
|
|
56
|
+
keptUsage,
|
|
57
|
+
isPending = false,
|
|
58
|
+
onConfirm,
|
|
59
|
+
}: KeepRecordingModalProps) {
|
|
60
|
+
const [reason, setReason] = useState<RemoteSessionKeepReason | ''>('');
|
|
61
|
+
const [description, setDescription] = useState('');
|
|
62
|
+
|
|
63
|
+
// Every opening starts blank. Reset while rendering, not from an effect: the
|
|
64
|
+
// modal stays mounted when it closes, so an effect would paint the opening
|
|
65
|
+
// frame with the previous answer still filled in.
|
|
66
|
+
const [seededOpen, setSeededOpen] = useState(isOpen);
|
|
67
|
+
if (seededOpen !== isOpen) {
|
|
68
|
+
setSeededOpen(isOpen);
|
|
69
|
+
if (isOpen) {
|
|
70
|
+
setReason('');
|
|
71
|
+
setDescription('');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const trimmed = description.trim();
|
|
76
|
+
const canKeep = reason !== '' && (reason !== 'OTHER' || trimmed !== '');
|
|
77
|
+
|
|
78
|
+
const handleKeep = () => {
|
|
79
|
+
if (!canKeep || isPending) return;
|
|
80
|
+
onConfirm({ reason, description: reason === 'OTHER' ? trimmed : null });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
|
|
85
|
+
<ModalV2Header>
|
|
86
|
+
<ModalV2Title>Keep this recording</ModalV2Title>
|
|
87
|
+
</ModalV2Header>
|
|
88
|
+
|
|
89
|
+
<KeepNotice
|
|
90
|
+
icon={<InfoCircleIcon size={24} color="currentColor" />}
|
|
91
|
+
title="This recording won't be deleted automatically"
|
|
92
|
+
>
|
|
93
|
+
It stays until you stop keeping it. Kept recordings use {keptUsage}.
|
|
94
|
+
</KeepNotice>
|
|
95
|
+
|
|
96
|
+
<div className="flex w-full flex-col gap-[var(--spacing-system-xs)]">
|
|
97
|
+
<Select
|
|
98
|
+
value={reason}
|
|
99
|
+
onValueChange={value => setReason(value as RemoteSessionKeepReason)}
|
|
100
|
+
disabled={isPending}
|
|
101
|
+
>
|
|
102
|
+
<SelectTrigger label="Reason" labelVariant="large">
|
|
103
|
+
<SelectValue placeholder="Select Reason" />
|
|
104
|
+
</SelectTrigger>
|
|
105
|
+
<SelectContent>
|
|
106
|
+
{KEEP_REASONS.map(value => (
|
|
107
|
+
<SelectItem key={value} value={value}>
|
|
108
|
+
{REMOTE_SESSION_KEEP_REASON_LABELS[value]}
|
|
109
|
+
</SelectItem>
|
|
110
|
+
))}
|
|
111
|
+
</SelectContent>
|
|
112
|
+
</Select>
|
|
113
|
+
{reason === 'OTHER' && (
|
|
114
|
+
<Input
|
|
115
|
+
aria-label="Reason description"
|
|
116
|
+
value={description}
|
|
117
|
+
onChange={event => setDescription(event.target.value)}
|
|
118
|
+
placeholder="Briefly describe what's going on"
|
|
119
|
+
maxLength={DESCRIPTION_MAX_LENGTH}
|
|
120
|
+
disabled={isPending}
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<ModalV2Footer>
|
|
126
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
|
|
127
|
+
Cancel
|
|
128
|
+
</Button>
|
|
129
|
+
<div className="hidden flex-1 md:block" />
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
variant="accent"
|
|
133
|
+
onClick={handleKeep}
|
|
134
|
+
loading={isPending}
|
|
135
|
+
disabled={!canKeep}
|
|
136
|
+
className="flex-1"
|
|
137
|
+
>
|
|
138
|
+
Keep
|
|
139
|
+
</Button>
|
|
140
|
+
</ModalV2Footer>
|
|
141
|
+
</ModalV2>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ReleaseKeepingModalProps {
|
|
146
|
+
isOpen: boolean;
|
|
147
|
+
onClose: () => void;
|
|
148
|
+
/** Who placed the Keep. */
|
|
149
|
+
keptBy: string;
|
|
150
|
+
/** When the Keep was placed, formatted, e.g. "27 Jul 2026". */
|
|
151
|
+
keptOn: string;
|
|
152
|
+
/** The reason as it reads in the sentence, e.g. "Client dispute". */
|
|
153
|
+
reason: string;
|
|
154
|
+
/** The ticket or reference the Keep was placed for. */
|
|
155
|
+
ticket?: string | null;
|
|
156
|
+
/** When the recording will be deleted once released, formatted. */
|
|
157
|
+
expiresOn: string;
|
|
158
|
+
/** The original expiry date when it has already passed, so the release adds the grace period. */
|
|
159
|
+
dueOn?: string | null;
|
|
160
|
+
/** The release is in flight: the button spins and the dialog refuses to close. */
|
|
161
|
+
isPending?: boolean;
|
|
162
|
+
onConfirm: () => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Release Keeping: hands the recording back to the normal expiry, nothing is deleted on the spot. */
|
|
166
|
+
export function ReleaseKeepingModal({
|
|
167
|
+
isOpen,
|
|
168
|
+
onClose,
|
|
169
|
+
keptBy,
|
|
170
|
+
keptOn,
|
|
171
|
+
reason,
|
|
172
|
+
ticket,
|
|
173
|
+
expiresOn,
|
|
174
|
+
dueOn,
|
|
175
|
+
isPending = false,
|
|
176
|
+
onConfirm,
|
|
177
|
+
}: ReleaseKeepingModalProps) {
|
|
178
|
+
return (
|
|
179
|
+
<ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
|
|
180
|
+
<ModalV2Header>
|
|
181
|
+
<ModalV2Title>Release Keeping</ModalV2Title>
|
|
182
|
+
</ModalV2Header>
|
|
183
|
+
|
|
184
|
+
<div className="flex w-full flex-col gap-[var(--spacing-system-xsf)] text-ods-text-primary text-h4">
|
|
185
|
+
<p>
|
|
186
|
+
Kept by {keptBy} on {keptOn} for {reason}.
|
|
187
|
+
</p>
|
|
188
|
+
{ticket && <p>Ticket: {ticket}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<KeepNotice icon={<HourglassIcon size={24} color="currentColor" />} title={`It will expire on ${expiresOn}`}>
|
|
192
|
+
{dueOn
|
|
193
|
+
? `It was due to expire on ${dueOn}. Releasing gives it 3 days' grace, and it shows amber until then. Whoever placed the Keep is reminded the day before it goes. Nothing is deleted now.`
|
|
194
|
+
: 'Nothing is deleted now.'}
|
|
195
|
+
</KeepNotice>
|
|
196
|
+
|
|
197
|
+
<ModalV2Footer>
|
|
198
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
|
|
199
|
+
Cancel
|
|
200
|
+
</Button>
|
|
201
|
+
<div className="hidden flex-1 md:block" />
|
|
202
|
+
<Button type="button" variant="accent" onClick={onConfirm} loading={isPending} className="flex-1">
|
|
203
|
+
Release Keeping
|
|
204
|
+
</Button>
|
|
205
|
+
</ModalV2Footer>
|
|
206
|
+
</ModalV2>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -5,7 +5,8 @@ import { cn } from '../../../utils/cn';
|
|
|
5
5
|
import { DraggerIcon, RecordingIcon } from '../../icons-v2-generated';
|
|
6
6
|
import { OpenFrameLogo } from '../../icons/openframe-logo';
|
|
7
7
|
import { SquareAvatar } from '../../ui/square-avatar';
|
|
8
|
-
import
|
|
8
|
+
import { RemoteSessionViewers } from './remote-session-viewers';
|
|
9
|
+
import type { RemoteSessionDragHandlers, RemoteSessionViewer } from './types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The remote session surfaces are fixed-size desktop windows (440 / 600 px
|
|
@@ -90,15 +91,43 @@ export function RemoteSessionOrgLogo({ name, logoUrl, sizePx, className }: Remot
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/** The green SESSION RECORDING tag. */
|
|
93
|
-
export function RemoteSessionRecordingTag() {
|
|
94
|
+
export function RemoteSessionRecordingTag({ className }: { className?: string }) {
|
|
94
95
|
return (
|
|
95
|
-
<div
|
|
96
|
+
<div
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex h-8 items-center gap-[var(--spacing-system-xs)] self-start rounded-md bg-ods-success-secondary p-[var(--spacing-system-xsf)]',
|
|
99
|
+
className,
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
96
102
|
<RecordingIcon size={16} color="currentColor" className="text-ods-success" />
|
|
97
103
|
<span className="text-ods-success text-h5">Session Recording</span>
|
|
98
104
|
</div>
|
|
99
105
|
);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The row under the block's header: the SESSION RECORDING tag and, while
|
|
110
|
+
* colleagues watch, who is in the session. With viewers the tag stretches to
|
|
111
|
+
* the left and the viewers sit on the right.
|
|
112
|
+
*/
|
|
113
|
+
export function RemoteSessionStatusRow({
|
|
114
|
+
showRecordingTag,
|
|
115
|
+
viewers,
|
|
116
|
+
}: {
|
|
117
|
+
showRecordingTag: boolean;
|
|
118
|
+
viewers?: RemoteSessionViewer[];
|
|
119
|
+
}) {
|
|
120
|
+
const hasViewers = !!viewers && viewers.length > 0;
|
|
121
|
+
if (!showRecordingTag && !hasViewers) return null;
|
|
122
|
+
if (!hasViewers) return <RemoteSessionRecordingTag />;
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex w-full items-center gap-[var(--spacing-system-m)]">
|
|
125
|
+
{showRecordingTag && <RemoteSessionRecordingTag className="flex-1 justify-center self-auto" />}
|
|
126
|
+
<RemoteSessionViewers viewers={viewers} className="ml-auto" />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
102
131
|
/** Grab cursor on the surfaces the window is moved by. */
|
|
103
132
|
export const REMOTE_SESSION_DRAG_HANDLE_CLASS = 'cursor-grab active:cursor-grabbing';
|
|
104
133
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../utils/cn';
|
|
4
|
+
import { AlertTriangleIcon, LockIcon } from '../../icons-v2-generated';
|
|
5
|
+
import { Tag } from '../../ui/tag';
|
|
6
|
+
import type { RemoteSessionStatus } from './types';
|
|
7
|
+
|
|
8
|
+
/** The pulsing dot of the LIVE tag; still when the user asks for reduced motion. */
|
|
9
|
+
function LiveDot() {
|
|
10
|
+
return <span aria-hidden className="size-2 animate-live-pulse rounded-full bg-current motion-reduce:animate-none" />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_TAG: Record<RemoteSessionStatus, { label: string; variant: 'critical' | 'warning' | 'error' }> = {
|
|
14
|
+
live: { label: 'Live', variant: 'critical' },
|
|
15
|
+
processing: { label: 'Processing', variant: 'warning' },
|
|
16
|
+
failed: { label: 'Failed', variant: 'error' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface RemoteSessionStatusTagProps {
|
|
20
|
+
status: RemoteSessionStatus;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** LIVE / PROCESSING / FAILED beside a session's date in the session list and on its page. */
|
|
25
|
+
export function RemoteSessionStatusTag({ status, className }: RemoteSessionStatusTagProps) {
|
|
26
|
+
const { label, variant } = STATUS_TAG[status];
|
|
27
|
+
return (
|
|
28
|
+
<Tag
|
|
29
|
+
label={label}
|
|
30
|
+
variant={variant}
|
|
31
|
+
icon={status === 'live' ? <LiveDot /> : undefined}
|
|
32
|
+
className={cn('pointer-events-none shrink-0', className)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RemoteSessionExpiryProps = { className?: string } & (
|
|
38
|
+
| { state: 'kept'; keptBy: string; reason: string }
|
|
39
|
+
| { state: 'scheduled'; date: string; remaining?: string | null }
|
|
40
|
+
| { state: 'expired' }
|
|
41
|
+
| { state: 'none' }
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** The EXPIRES cell of a recording: its expiry date, the Keep holding it, or that it has expired. */
|
|
45
|
+
export function RemoteSessionExpiry(props: RemoteSessionExpiryProps) {
|
|
46
|
+
const { className } = props;
|
|
47
|
+
if (props.state === 'none') {
|
|
48
|
+
return <p className={cn('text-ods-text-primary text-h4', className)}>-</p>;
|
|
49
|
+
}
|
|
50
|
+
if (props.state === 'expired') {
|
|
51
|
+
return <p className={cn('text-ods-text-secondary text-h4', className)}>Expired</p>;
|
|
52
|
+
}
|
|
53
|
+
if (props.state === 'kept') {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn('flex min-w-0 flex-col', className)}>
|
|
56
|
+
<p className="flex items-center gap-[var(--spacing-system-xxs)] text-ods-text-primary text-h4">
|
|
57
|
+
<LockIcon size={24} color="currentColor" className="shrink-0" />
|
|
58
|
+
Kept
|
|
59
|
+
</p>
|
|
60
|
+
<p className="truncate text-ods-text-secondary text-h6">
|
|
61
|
+
Kept by {props.keptBy} · {props.reason}
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex min-w-0 flex-col', className)}>
|
|
68
|
+
<p className="text-ods-text-primary text-h4">{props.date}</p>
|
|
69
|
+
{props.remaining && <p className="truncate text-ods-text-secondary text-h6">{props.remaining}</p>}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RemoteSessionStorageAlertProps {
|
|
75
|
+
/** `device` on the device page (new sessions there aren't recorded); `session` on a running session. */
|
|
76
|
+
scope: 'device' | 'session';
|
|
77
|
+
/** The plan's recording storage, e.g. "100 GB"; named on the device page. */
|
|
78
|
+
capacity?: string | null;
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The banner shown while the tenant's recording storage is full. */
|
|
83
|
+
export function RemoteSessionStorageAlert({ scope, capacity, className }: RemoteSessionStorageAlertProps) {
|
|
84
|
+
const message =
|
|
85
|
+
scope === 'session'
|
|
86
|
+
? "This session isn't being recorded. Recording storage is full. New sessions aren't recorded until space is freed."
|
|
87
|
+
: `Recording storage full. New sessions aren't recorded.${
|
|
88
|
+
capacity ? ` All ${capacity} of recording storage is used.` : ''
|
|
89
|
+
}`;
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
role="alert"
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex w-full items-start gap-[var(--spacing-system-m)] rounded-md bg-ods-warning-secondary p-[var(--spacing-system-s)] text-ods-warning',
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<AlertTriangleIcon size={24} color="currentColor" className="shrink-0" />
|
|
99
|
+
<p className="min-w-0 flex-1 text-h3">{message}</p>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { cn } from '../../../utils/cn';
|
|
5
|
+
import { Chevron02DownIcon, Chevron02UpIcon } from '../../icons-v2-generated';
|
|
6
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../ui/dropdown-menu';
|
|
7
|
+
import { SquareAvatar } from '../../ui/square-avatar';
|
|
8
|
+
import type { RemoteSessionViewer } from './types';
|
|
9
|
+
|
|
10
|
+
function viewerLabel(viewer: RemoteSessionViewer): string {
|
|
11
|
+
if (viewer.isHost) return `${viewer.name} (host)`;
|
|
12
|
+
if (viewer.isYou) return `${viewer.name} (you)`;
|
|
13
|
+
return viewer.name;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The host first, everyone else in the order the backend reports them. */
|
|
17
|
+
function hostFirst(viewers: RemoteSessionViewer[]): RemoteSessionViewer[] {
|
|
18
|
+
return [...viewers.filter(viewer => viewer.isHost), ...viewers.filter(viewer => !viewer.isHost)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RemoteSessionViewersProps {
|
|
22
|
+
/** Everyone in the session, the host included. */
|
|
23
|
+
viewers: RemoteSessionViewer[];
|
|
24
|
+
/** Faces shown before the rest collapse into "+N". */
|
|
25
|
+
max?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Who is in a live session: an avatar stack (the host ringed in the accent
|
|
31
|
+
* colour) that opens the full list. Shown to the host, to the watchers and to
|
|
32
|
+
* the end user.
|
|
33
|
+
*/
|
|
34
|
+
export function RemoteSessionViewers({ viewers, max = 4, className }: RemoteSessionViewersProps) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
if (viewers.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
const ordered = hostFirst(viewers);
|
|
39
|
+
const faces = ordered.slice(0, max);
|
|
40
|
+
const overflow = ordered.length - faces.length;
|
|
41
|
+
const Chevron = open ? Chevron02UpIcon : Chevron02DownIcon;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
45
|
+
<DropdownMenuTrigger
|
|
46
|
+
aria-label={`In this session: ${ordered.map(viewerLabel).join(', ')}`}
|
|
47
|
+
className={cn(
|
|
48
|
+
'flex shrink-0 items-center gap-[var(--spacing-system-xxs)] rounded-md outline-none',
|
|
49
|
+
'focus-visible:ring-2 focus-visible:ring-ods-focus',
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<span className="flex items-center">
|
|
54
|
+
{faces.map((viewer, index) => (
|
|
55
|
+
<SquareAvatar
|
|
56
|
+
key={viewer.id}
|
|
57
|
+
variant="round"
|
|
58
|
+
sizePx={32}
|
|
59
|
+
src={viewer.avatarUrl ?? undefined}
|
|
60
|
+
alt={viewer.name}
|
|
61
|
+
fallback={viewer.name}
|
|
62
|
+
className={cn(
|
|
63
|
+
'relative bg-ods-bg',
|
|
64
|
+
viewer.isHost ? 'ring-2 ring-ods-accent' : 'ring-1 ring-ods-border',
|
|
65
|
+
index > 0 && '-ml-2',
|
|
66
|
+
)}
|
|
67
|
+
style={{ zIndex: faces.length - index + 1 }}
|
|
68
|
+
/>
|
|
69
|
+
))}
|
|
70
|
+
{overflow > 0 && (
|
|
71
|
+
<span className="relative -ml-2 flex size-8 shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary ring-1 ring-ods-border text-h6">
|
|
72
|
+
+{overflow}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="flex size-8 items-center justify-center rounded-md text-ods-text-primary">
|
|
77
|
+
<Chevron size={16} color="currentColor" />
|
|
78
|
+
</span>
|
|
79
|
+
</DropdownMenuTrigger>
|
|
80
|
+
<DropdownMenuContent align="end" className="w-[280px] p-0">
|
|
81
|
+
{ordered.map(viewer => (
|
|
82
|
+
<DropdownMenuItem
|
|
83
|
+
key={viewer.id}
|
|
84
|
+
className={cn(
|
|
85
|
+
'gap-[var(--spacing-system-xsf)] rounded-none border-b border-ods-border bg-ods-bg last:border-b-0',
|
|
86
|
+
'py-[var(--spacing-system-s)] pl-[var(--spacing-system-mf)] pr-[var(--spacing-system-xs)] text-h4',
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<SquareAvatar
|
|
90
|
+
variant="round"
|
|
91
|
+
sizePx={24}
|
|
92
|
+
src={viewer.avatarUrl ?? undefined}
|
|
93
|
+
alt=""
|
|
94
|
+
fallback={viewer.name}
|
|
95
|
+
className="shrink-0"
|
|
96
|
+
/>
|
|
97
|
+
<span className="min-w-0 flex-1 truncate">{viewerLabel(viewer)}</span>
|
|
98
|
+
</DropdownMenuItem>
|
|
99
|
+
))}
|
|
100
|
+
</DropdownMenuContent>
|
|
101
|
+
</DropdownMenu>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -31,6 +31,32 @@ export interface RemoteSessionChatMessage {
|
|
|
31
31
|
/** The terminal dialogs of a session: the end confirm and the two "it is over" notices. */
|
|
32
32
|
export type RemoteSessionDialogVariant = 'end-confirm' | 'session-ended' | 'connection-lost';
|
|
33
33
|
|
|
34
|
+
/** Someone in a live session: the technician running it (the host) or a colleague watching. */
|
|
35
|
+
export interface RemoteSessionViewer {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
avatarUrl?: string | null;
|
|
39
|
+
/** The technician running the session; listed first and ringed in the accent colour. */
|
|
40
|
+
isHost?: boolean;
|
|
41
|
+
/** The signed-in user, labelled "(you)" in the list. */
|
|
42
|
+
isYou?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a remote session's recording is in its lifecycle, as the session list tags it. */
|
|
46
|
+
export type RemoteSessionStatus = 'live' | 'processing' | 'failed';
|
|
47
|
+
|
|
48
|
+
/** Why a recording is kept past its expiry date. */
|
|
49
|
+
export type RemoteSessionKeepReason = 'CLIENT_DISPUTE' | 'INTERNAL_REVIEW' | 'LEGAL_OR_COMPLIANCE' | 'OTHER';
|
|
50
|
+
|
|
51
|
+
/** A moment of the session, placed on the recording by its offset from the start. */
|
|
52
|
+
export interface RemoteSessionEvent {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Milliseconds from the start of the recording. */
|
|
55
|
+
offsetMs: number;
|
|
56
|
+
title: string;
|
|
57
|
+
detail?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/**
|
|
35
61
|
* Pointer handlers a host attaches to the surfaces the user moves the window
|
|
36
62
|
* by (the desktop chat starts a native window drag from them).
|
|
@@ -18,7 +18,7 @@ export interface Announcement {
|
|
|
18
18
|
is_active: boolean;
|
|
19
19
|
/** A one-line form of the title for tight spots (a hero's news pill). */
|
|
20
20
|
short_title?: string | null;
|
|
21
|
-
/**
|
|
21
|
+
/** Path patterns the bar is NOT shown on (regular expressions matched against the whole path): the page shows the news itself. */
|
|
22
22
|
excluded_paths?: string[] | null;
|
|
23
23
|
// CTA (Call-To-Action) fields
|
|
24
24
|
cta_enabled?: boolean;
|
|
@@ -248,11 +248,6 @@ export interface AnnouncementBarProps {
|
|
|
248
248
|
* currentPlatform()).
|
|
249
249
|
*/
|
|
250
250
|
initialAnnouncement?: Announcement | null;
|
|
251
|
-
/**
|
|
252
|
-
* The path the bar is rendered on. When the announcement lists it in
|
|
253
|
-
* `excluded_paths`, the bar stays collapsed. Omitted: never excluded.
|
|
254
|
-
*/
|
|
255
|
-
pathname?: string | null;
|
|
256
251
|
/**
|
|
257
252
|
* Render-only mode for the admin live preview: no fetch/revalidation, inert
|
|
258
253
|
* dismiss button, and no storage side effects (never writes dismissal
|
package/tailwind.config.ts
CHANGED
|
@@ -413,6 +413,11 @@ const config: Config = {
|
|
|
413
413
|
'0%, 80%, 100%': { transform: 'scale(1)', opacity: '0.7' },
|
|
414
414
|
'40%': { transform: 'scale(1.5)', opacity: '1' },
|
|
415
415
|
},
|
|
416
|
+
// The dot on a LIVE tag: opacity only, so it reads as "on air" without moving.
|
|
417
|
+
'live-pulse': {
|
|
418
|
+
'0%, 100%': { opacity: '1' },
|
|
419
|
+
'50%': { opacity: '0.25' },
|
|
420
|
+
},
|
|
416
421
|
},
|
|
417
422
|
animation: {
|
|
418
423
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
|
@@ -420,6 +425,7 @@ const config: Config = {
|
|
|
420
425
|
'fade-in': 'fade-in 0.3s ease-out',
|
|
421
426
|
'cursor-blink': 'cursor-blink 1s steps(1) infinite',
|
|
422
427
|
'dot-pulse': 'dot-pulse 1.4s ease-in-out infinite',
|
|
428
|
+
'live-pulse': 'live-pulse 1.6s ease-in-out infinite',
|
|
423
429
|
},
|
|
424
430
|
},
|
|
425
431
|
},
|