@pushwoosh/smart-components-kit 1.3.37 → 1.5.0

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.
@@ -0,0 +1,14 @@
1
+ import { type FC } from 'react';
2
+ import type { GetCanvaStatusResponse } from '@pushwoosh/rpc-v2-http-api-data/canva';
3
+ import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
4
+ type CanvaImportDrawerProps = {
5
+ application: string;
6
+ isOpen: boolean;
7
+ status: GetCanvaStatusResponse;
8
+ onStatusChange: () => void;
9
+ onClose: () => void;
10
+ onImported: (files: MediaFile[]) => void;
11
+ };
12
+ /** Drawer that connects the user's Canva and imports pages of a chosen design into the library. */
13
+ export declare const CanvaImportDrawer: FC<CanvaImportDrawerProps>;
14
+ export {};
@@ -0,0 +1,386 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Button, Chip, Drawer, DrawerBody, DrawerHeader, DrawerTitle, NativeInput, useToast } from '@pushwoosh/dumb-components';
4
+ import { FlexContainer, useAsync, Vertical } from '@pushwoosh/kit-helpers';
5
+ import { CloseIcon } from '@pushwoosh/kit-icons';
6
+ import { Text } from '@pushwoosh/kit-typography';
7
+ import { useSPAContext } from '@pushwoosh/spa-context';
8
+ import { ConsentClosedError, connectCanva, disconnectCanva, PopupBlockedError, UnsafeReturnPageError } from './canva';
9
+ import { togglePages } from './canvaPages';
10
+ import { useLang } from './polyglot';
11
+ import { Chips, DesignCaption, DesignTile, EmptyState, FieldLabel, Grid, Segment, SegmentItem, TileImage } from './styles';
12
+ const MAX_PAGES = 10;
13
+ const SEARCH_DELAY_MS = 400;
14
+ const POLL_INTERVAL_MS = 1500;
15
+ const IMPORT_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const errorMessage = (error, fallback) => error instanceof Error && error.message ? error.message : fallback;
17
+ const sleep = ms => new Promise(resolve => {
18
+ window.setTimeout(resolve, ms);
19
+ });
20
+ /** Drawer that connects the user's Canva and imports pages of a chosen design into the library. */
21
+ export const CanvaImportDrawer = ({
22
+ application,
23
+ isOpen,
24
+ status,
25
+ onStatusChange,
26
+ onClose,
27
+ onImported
28
+ }) => {
29
+ const lang = useLang();
30
+ const {
31
+ rpcGateway
32
+ } = useSPAContext();
33
+ const {
34
+ openToast
35
+ } = useToast();
36
+ const [query, setQuery] = useState('');
37
+ const [debouncedQuery, setDebouncedQuery] = useState('');
38
+ const [designs, setDesigns] = useState([]);
39
+ const [continuation, setContinuation] = useState('');
40
+ const [selected, setSelected] = useState(null);
41
+ const [pages, setPages] = useState([1]);
42
+ const [format, setFormat] = useState('CANVA_IMPORT_FORMAT_PNG');
43
+ const [isConnecting, setIsConnecting] = useState(false);
44
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
45
+ const [progress, setProgress] = useState(null);
46
+ const runRef = useRef(0);
47
+ const queryRef = useRef('');
48
+ const isOpenRef = useRef(isOpen);
49
+ isOpenRef.current = isOpen;
50
+ queryRef.current = debouncedQuery;
51
+ const isReady = status.connected && status.tokenValid;
52
+ useEffect(() => {
53
+ const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DELAY_MS);
54
+ return () => window.clearTimeout(timer);
55
+ }, [query]);
56
+ const designsAsync = useAsync(async () => {
57
+ if (!isOpen || !isReady) return null;
58
+ return rpcGateway.canva.ListDesigns({
59
+ query: debouncedQuery
60
+ });
61
+ }, [rpcGateway, isOpen, isReady, debouncedQuery]);
62
+ useEffect(() => {
63
+ setDesigns(designsAsync.value?.designs ?? []);
64
+ setContinuation(designsAsync.value?.continuation ?? '');
65
+ }, [designsAsync.value]);
66
+ useEffect(() => {
67
+ if (designsAsync.error) {
68
+ openToast({
69
+ title: errorMessage(designsAsync.error, lang({
70
+ message: 'SMART.MEDIA_STORAGE.CANVA.ERROR'
71
+ })),
72
+ type: 'error'
73
+ });
74
+ }
75
+ }, [designsAsync.error, openToast, lang]);
76
+ useEffect(() => () => {
77
+ runRef.current += 1;
78
+ }, []);
79
+ const loadMore = async () => {
80
+ if (isLoadingMore) return;
81
+ const queryAtStart = debouncedQuery;
82
+ setIsLoadingMore(true);
83
+ try {
84
+ const page = await rpcGateway.canva.ListDesigns({
85
+ query: queryAtStart,
86
+ continuation
87
+ });
88
+ if (queryRef.current !== queryAtStart) return;
89
+ setDesigns(prev => {
90
+ const seen = new Set(prev.map(design => design.id));
91
+ return [...prev, ...(page.designs ?? []).filter(design => !seen.has(design.id))];
92
+ });
93
+ setContinuation(page.continuation ?? '');
94
+ } catch (error) {
95
+ openToast({
96
+ title: errorMessage(error, lang({
97
+ message: 'SMART.MEDIA_STORAGE.CANVA.ERROR'
98
+ })),
99
+ type: 'error'
100
+ });
101
+ } finally {
102
+ setIsLoadingMore(false);
103
+ }
104
+ };
105
+ const handleConnect = async () => {
106
+ setIsConnecting(true);
107
+ try {
108
+ await connectCanva(rpcGateway);
109
+ onStatusChange();
110
+ } catch (error) {
111
+ if (!(error instanceof ConsentClosedError)) {
112
+ const title = error instanceof PopupBlockedError ? lang({
113
+ message: 'SMART.MEDIA_STORAGE.CANVA.POPUP_BLOCKED'
114
+ }) : lang({
115
+ message: 'SMART.MEDIA_STORAGE.CANVA.CONNECT_FAILED'
116
+ });
117
+ if (error instanceof UnsafeReturnPageError) {
118
+ console.error('Canva connect refused: /robots.txt on this origin is not served as plain text');
119
+ }
120
+ openToast({
121
+ title,
122
+ type: 'error'
123
+ });
124
+ }
125
+ } finally {
126
+ setIsConnecting(false);
127
+ }
128
+ };
129
+ const handleDisconnect = async () => {
130
+ try {
131
+ await disconnectCanva(rpcGateway);
132
+ onStatusChange();
133
+ } catch (error) {
134
+ openToast({
135
+ title: errorMessage(error, lang({
136
+ message: 'SMART.MEDIA_STORAGE.CANVA.ERROR'
137
+ })),
138
+ type: 'error'
139
+ });
140
+ }
141
+ };
142
+ const selectDesign = design => {
143
+ setSelected(design);
144
+ setPages([1]);
145
+ };
146
+ const togglePage = page => {
147
+ setPages(prev => togglePages(prev, page, MAX_PAGES));
148
+ };
149
+ const handleImport = async () => {
150
+ if (!selected) return;
151
+ runRef.current += 1;
152
+ const run = runRef.current;
153
+ setProgress(lang({
154
+ message: 'SMART.MEDIA_STORAGE.CANVA.EXPORTING'
155
+ }));
156
+ try {
157
+ const {
158
+ importId
159
+ } = await rpcGateway.canva.StartImport({
160
+ application,
161
+ designId: selected.id,
162
+ pages,
163
+ format
164
+ });
165
+ const startedAt = Date.now();
166
+ for (;;) {
167
+ await sleep(POLL_INTERVAL_MS);
168
+ if (runRef.current !== run) return;
169
+ if (Date.now() - startedAt > IMPORT_TIMEOUT_MS) {
170
+ throw new Error(lang({
171
+ message: 'SMART.MEDIA_STORAGE.CANVA.ERROR'
172
+ }));
173
+ }
174
+ const state = await rpcGateway.canva.GetImport({
175
+ importId
176
+ });
177
+ if (runRef.current !== run) return;
178
+ if (state.state === 'CANVA_IMPORT_STATE_FAILED') {
179
+ throw new Error(state.error);
180
+ }
181
+ if (state.state === 'CANVA_IMPORT_STATE_DONE') {
182
+ openToast({
183
+ title: lang({
184
+ message: 'SMART.MEDIA_STORAGE.CANVA.SUCCESS'
185
+ }),
186
+ type: 'success'
187
+ });
188
+ onImported(state.files ?? []);
189
+ if (isOpenRef.current) onClose();
190
+ return;
191
+ }
192
+ if (state.state === 'CANVA_IMPORT_STATE_UPLOADING') {
193
+ setProgress(lang({
194
+ message: 'SMART.MEDIA_STORAGE.CANVA.IMPORTING',
195
+ done: state.pagesDone,
196
+ total: state.pagesTotal
197
+ }));
198
+ }
199
+ }
200
+ } catch (error) {
201
+ if (runRef.current === run) {
202
+ openToast({
203
+ title: errorMessage(error, lang({
204
+ message: 'SMART.MEDIA_STORAGE.CANVA.ERROR'
205
+ })),
206
+ type: 'error'
207
+ });
208
+ }
209
+ } finally {
210
+ if (runRef.current === run) setProgress(null);
211
+ }
212
+ };
213
+ const pageCount = selected ? Math.max(selected.pageCount, 1) : 0;
214
+ return _jsxs(Drawer, {
215
+ size: "medium",
216
+ isOpen: isOpen,
217
+ onClose: onClose,
218
+ children: [_jsx(DrawerHeader, {
219
+ children: _jsxs(DrawerTitle, {
220
+ children: [_jsx("span", {
221
+ children: lang({
222
+ message: 'SMART.MEDIA_STORAGE.CANVA.TITLE'
223
+ })
224
+ }), _jsx(Button, {
225
+ view: "ghost",
226
+ color: "secondary",
227
+ size: "compact",
228
+ icon: _jsx(CloseIcon, {
229
+ size: "small"
230
+ }),
231
+ "aria-label": lang({
232
+ message: 'SMART.MEDIA_STORAGE.CANVA.CLOSE'
233
+ }),
234
+ onClick: onClose
235
+ })]
236
+ })
237
+ }), _jsxs(DrawerBody, {
238
+ children: [!isReady && _jsxs(Vertical, {
239
+ "$gap": 12,
240
+ children: [_jsx(Text, {
241
+ children: status.connected ? lang({
242
+ message: 'SMART.MEDIA_STORAGE.CANVA.RECONNECT_HINT'
243
+ }) : lang({
244
+ message: 'SMART.MEDIA_STORAGE.CANVA.CONNECT_HINT'
245
+ })
246
+ }), _jsx("div", {
247
+ children: _jsx(Button, {
248
+ color: "primary",
249
+ isLoading: isConnecting,
250
+ isDisabled: isConnecting,
251
+ onClick: () => void handleConnect(),
252
+ children: lang({
253
+ message: 'SMART.MEDIA_STORAGE.CANVA.CONNECT'
254
+ })
255
+ })
256
+ })]
257
+ }), isReady && _jsxs(Vertical, {
258
+ "$gap": 16,
259
+ children: [_jsxs(FlexContainer, {
260
+ "$alignItems": "center",
261
+ "$gap": 8,
262
+ children: [_jsx(Text, {
263
+ children: lang({
264
+ message: 'SMART.MEDIA_STORAGE.CANVA.CONNECTED_AS',
265
+ name: status.identity
266
+ })
267
+ }), _jsx(Button, {
268
+ view: "ghost",
269
+ color: "secondary",
270
+ size: "compact",
271
+ onClick: () => void handleDisconnect(),
272
+ children: lang({
273
+ message: 'SMART.MEDIA_STORAGE.CANVA.DISCONNECT'
274
+ })
275
+ })]
276
+ }), _jsx(NativeInput, {
277
+ value: query,
278
+ placeholder: lang({
279
+ message: 'SMART.MEDIA_STORAGE.CANVA.SEARCH'
280
+ }),
281
+ "aria-label": lang({
282
+ message: 'SMART.MEDIA_STORAGE.CANVA.SEARCH'
283
+ }),
284
+ onChange: event => setQuery(event.target.value)
285
+ }), !designsAsync.loading && designs.length === 0 && _jsx(EmptyState, {
286
+ children: lang({
287
+ message: 'SMART.MEDIA_STORAGE.CANVA.EMPTY'
288
+ })
289
+ }), _jsx(Grid, {
290
+ children: designs.map(design => _jsxs(DesignTile, {
291
+ type: "button",
292
+ "$selected": selected?.id === design.id,
293
+ disabled: progress !== null,
294
+ onClick: () => selectDesign(design),
295
+ children: [_jsx(TileImage, {
296
+ src: design.thumbnailUrl,
297
+ alt: "",
298
+ loading: "lazy"
299
+ }), _jsxs(DesignCaption, {
300
+ children: [_jsx("span", {
301
+ children: design.title || lang({
302
+ message: 'SMART.MEDIA_STORAGE.CANVA.UNTITLED'
303
+ })
304
+ }), _jsx("span", {
305
+ children: lang({
306
+ message: 'SMART.MEDIA_STORAGE.CANVA.PAGES',
307
+ count: Math.max(design.pageCount, 1)
308
+ })
309
+ })]
310
+ })]
311
+ }, design.id))
312
+ }), continuation && _jsx("div", {
313
+ children: _jsx(Button, {
314
+ view: "ghost",
315
+ color: "secondary",
316
+ isLoading: isLoadingMore,
317
+ isDisabled: isLoadingMore,
318
+ onClick: () => void loadMore(),
319
+ children: lang({
320
+ message: 'SMART.MEDIA_STORAGE.CANVA.LOAD_MORE'
321
+ })
322
+ })
323
+ }), selected && _jsxs(Vertical, {
324
+ "$gap": 12,
325
+ children: [pageCount > 1 && _jsxs("div", {
326
+ children: [_jsx(FieldLabel, {
327
+ children: lang({
328
+ message: 'SMART.MEDIA_STORAGE.CANVA.PAGES_LABEL',
329
+ max: MAX_PAGES
330
+ })
331
+ }), _jsx(Chips, {
332
+ children: Array.from({
333
+ length: pageCount
334
+ }, (_, index) => index + 1).map(page => _jsx(Chip, {
335
+ "$selected": pages.includes(page),
336
+ "aria-pressed": pages.includes(page),
337
+ onClick: () => togglePage(page),
338
+ children: page
339
+ }, page))
340
+ })]
341
+ }), _jsxs("div", {
342
+ children: [_jsx(FieldLabel, {
343
+ children: lang({
344
+ message: 'SMART.MEDIA_STORAGE.CANVA.FORMAT_LABEL'
345
+ })
346
+ }), _jsxs(Segment, {
347
+ role: "radiogroup",
348
+ "aria-label": lang({
349
+ message: 'SMART.MEDIA_STORAGE.CANVA.FORMAT_LABEL'
350
+ }),
351
+ children: [_jsx(SegmentItem, {
352
+ type: "button",
353
+ role: "radio",
354
+ "aria-checked": format === 'CANVA_IMPORT_FORMAT_PNG',
355
+ "$active": format === 'CANVA_IMPORT_FORMAT_PNG',
356
+ onClick: () => setFormat('CANVA_IMPORT_FORMAT_PNG'),
357
+ children: "PNG"
358
+ }), _jsx(SegmentItem, {
359
+ type: "button",
360
+ role: "radio",
361
+ "aria-checked": format === 'CANVA_IMPORT_FORMAT_JPG',
362
+ "$active": format === 'CANVA_IMPORT_FORMAT_JPG',
363
+ onClick: () => setFormat('CANVA_IMPORT_FORMAT_JPG'),
364
+ children: "JPG"
365
+ })]
366
+ })]
367
+ }), _jsxs(FlexContainer, {
368
+ "$alignItems": "center",
369
+ "$gap": 12,
370
+ children: [_jsx(Button, {
371
+ color: "primary",
372
+ isLoading: progress !== null,
373
+ isDisabled: progress !== null,
374
+ onClick: () => void handleImport(),
375
+ children: lang({
376
+ message: 'SMART.MEDIA_STORAGE.CANVA.IMPORT'
377
+ })
378
+ }), progress && _jsx(Text, {
379
+ children: progress
380
+ })]
381
+ })]
382
+ })]
383
+ })]
384
+ })]
385
+ });
386
+ };
@@ -5,11 +5,12 @@ import { FlexContainer, useAsync } from '@pushwoosh/kit-helpers';
5
5
  import { CheckIcon, EditIcon, TrashIcon, UploadIcon } from '@pushwoosh/kit-icons';
6
6
  import { Text } from '@pushwoosh/kit-typography';
7
7
  import { useSPAContext } from '@pushwoosh/spa-context';
8
+ import { CanvaImportDrawer } from './CanvaImportDrawer';
8
9
  import { CreateCropDrawer } from './CreateCropDrawer';
9
10
  import { useDeleteMediaFile, useDeleteMediaFiles } from './hooks';
10
- import { formatBytes, getCropCaption, getThumbUrl } from './mediaFile.utils';
11
+ import { formatBytes, getCropCaption, getSourceLabel, getThumbUrl, hasVersions } from './mediaFile.utils';
11
12
  import { Lang, useLang } from './polyglot';
12
- import { CropBadge, Dropzone, EmptyState, Grid, HiddenInput, SelectionBar, SelectionCount, Tile, TileActions, TileCheckbox, TileImage, UsageLine, WarningBadge } from './styles';
13
+ import { CountBadge, Dropzone, DropzoneRow, EmptyState, Grid, HiddenInput, SelectionBar, SelectionCount, SourceBadge, Tile, TileActions, TileBadges, TileCheckbox, TileImage, UsageLine, WarningBadge } from './styles';
13
14
  const UPLOAD_PATH = '/api/media/upload';
14
15
  const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
15
16
  const isAllowedImage = file => {
@@ -80,6 +81,10 @@ export const MediaStorage = ({
80
81
  const usageAsync = useAsync(() => rpcGateway.mediaFiles.GetUsage({
81
82
  application
82
83
  }), [rpcGateway, application, reloadToken, usageToken]);
84
+ const [canvaToken, setCanvaToken] = useState(0);
85
+ const [isCanvaOpen, setIsCanvaOpen] = useState(false);
86
+ const canvaStatusAsync = useAsync(() => rpcGateway.canva.GetStatus({}), [rpcGateway, canvaToken]);
87
+ const canvaStatus = canvaStatusAsync.value;
83
88
  const {
84
89
  loading
85
90
  } = listAsync;
@@ -242,28 +247,36 @@ export const MediaStorage = ({
242
247
  const totalPages = Math.ceil(total / perPage);
243
248
  const showPagination = !loading && files.length > 0 && totalPages > 1;
244
249
  return _jsxs(_Fragment, {
245
- children: [_jsxs(Dropzone, {
246
- type: "button",
247
- "$dragOver": isDragOver,
248
- disabled: isUploading,
249
- onClick: () => uploadInputRef.current?.click(),
250
- onDragOver: event => {
251
- event.preventDefault();
252
- setIsDragOver(true);
253
- },
254
- onDragLeave: () => setIsDragOver(false),
255
- onDrop: event => {
256
- event.preventDefault();
257
- setIsDragOver(false);
258
- void uploadFiles(Array.from(event.dataTransfer.files));
259
- },
260
- children: [_jsx(UploadIcon, {
261
- size: "medium",
262
- view: "lined"
263
- }), isUploading ? lang({
264
- message: 'SMART.MEDIA_STORAGE.UPLOADING'
265
- }) : lang({
266
- message: 'SMART.MEDIA_STORAGE.DROPZONE_HINT'
250
+ children: [_jsxs(DropzoneRow, {
251
+ children: [_jsxs(Dropzone, {
252
+ type: "button",
253
+ "$dragOver": isDragOver,
254
+ disabled: isUploading,
255
+ onClick: () => uploadInputRef.current?.click(),
256
+ onDragOver: event => {
257
+ event.preventDefault();
258
+ setIsDragOver(true);
259
+ },
260
+ onDragLeave: () => setIsDragOver(false),
261
+ onDrop: event => {
262
+ event.preventDefault();
263
+ setIsDragOver(false);
264
+ void uploadFiles(Array.from(event.dataTransfer.files));
265
+ },
266
+ children: [_jsx(UploadIcon, {
267
+ size: "medium",
268
+ view: "lined"
269
+ }), isUploading ? lang({
270
+ message: 'SMART.MEDIA_STORAGE.UPLOADING'
271
+ }) : lang({
272
+ message: 'SMART.MEDIA_STORAGE.DROPZONE_HINT'
273
+ })]
274
+ }), canvaStatus?.available && _jsx(Button, {
275
+ color: "secondary",
276
+ onClick: () => setIsCanvaOpen(true),
277
+ children: lang({
278
+ message: 'SMART.MEDIA_STORAGE.CANVA.BUTTON'
279
+ })
267
280
  })]
268
281
  }), _jsx(HiddenInput, {
269
282
  ref: uploadInputRef,
@@ -314,6 +327,7 @@ export const MediaStorage = ({
314
327
  children: files.map(file => {
315
328
  const crops = cropsByUuid[file.uuid] ?? [];
316
329
  const warning = fileWarning?.(file);
330
+ const sourceLabel = getSourceLabel(file);
317
331
  return _jsxs(Tile, {
318
332
  "$selected": selectedUuids.includes(file.uuid),
319
333
  children: [_jsx(TileImage, {
@@ -328,11 +342,20 @@ export const MediaStorage = ({
328
342
  isDisabled: isBulkDeleting,
329
343
  onChange: () => toggleSelected(file.uuid)
330
344
  })
331
- }), crops.length > 0 && _jsx(CropBadge, {
332
- children: lang({
333
- message: 'SMART.MEDIA_STORAGE.CROPS_BADGE',
334
- count: crops.length
335
- })
345
+ }), _jsxs(TileBadges, {
346
+ children: [sourceLabel && _jsx(SourceBadge, {
347
+ children: sourceLabel
348
+ }), hasVersions(file) && _jsx(CountBadge, {
349
+ children: lang({
350
+ message: 'SMART.MEDIA_STORAGE.VERSIONS_BADGE',
351
+ count: file.versionsCount
352
+ })
353
+ }), crops.length > 0 && _jsx(CountBadge, {
354
+ children: lang({
355
+ message: 'SMART.MEDIA_STORAGE.CROPS_BADGE',
356
+ count: crops.length
357
+ })
358
+ })]
336
359
  }), warning && _jsx(WarningBadge, {
337
360
  title: warning,
338
361
  children: warning
@@ -428,6 +451,18 @@ export const MediaStorage = ({
428
451
  size: formatBytes(usageAsync.value.totalSize)
429
452
  })
430
453
  })]
454
+ }), canvaStatus?.available && _jsx(CanvaImportDrawer, {
455
+ application: application,
456
+ isOpen: isCanvaOpen,
457
+ status: canvaStatus,
458
+ onStatusChange: () => setCanvaToken(token => token + 1),
459
+ onClose: () => setIsCanvaOpen(false),
460
+ onImported: () => {
461
+ if (pageRef.current !== 0) {
462
+ onPageChange(0);
463
+ }
464
+ setReloadToken(token => token + 1);
465
+ }
431
466
  }), _jsx(CreateCropDrawer, {
432
467
  application: application,
433
468
  original: createCropFor,
@@ -442,7 +477,7 @@ export const MediaStorage = ({
442
477
  message: 'SMART.MEDIA_STORAGE.DELETE_CONFIRM.TITLE'
443
478
  }),
444
479
  description: lang({
445
- message: 'SMART.MEDIA_STORAGE.DELETE_CONFIRM.DESCRIPTION'
480
+ message: pendingDelete && hasVersions(pendingDelete) ? 'SMART.MEDIA_STORAGE.DELETE_CONFIRM.DESCRIPTION_VERSION' : 'SMART.MEDIA_STORAGE.DELETE_CONFIRM.DESCRIPTION'
446
481
  }),
447
482
  confirmButtonText: lang({
448
483
  message: 'SMART.MEDIA_STORAGE.DELETE_CONFIRM.BUTTON'
@@ -0,0 +1,18 @@
1
+ import type { API as RpcGatewayAPI } from '@pushwoosh/rpc-v2-http-api-data';
2
+ /** Error raised when the browser refused to open the Canva consent window. */
3
+ export declare class PopupBlockedError extends Error {
4
+ }
5
+ /** Consent result read from the pop-up once it is back on our origin; null while it is still elsewhere. */
6
+ export declare const readConsentReturn: (location: Pick<Location, "origin" | "hash">, origin: string) => URLSearchParams | null;
7
+ /** Error raised when the user closed the consent pop-up without finishing. */
8
+ export declare class ConsentClosedError extends Error {
9
+ }
10
+ /** Error raised when the return page is not plain static text: the code must not land in an app. */
11
+ export declare class UnsafeReturnPageError extends Error {
12
+ }
13
+ /** True when the response is served as plain text, i.e. no app bundle runs on that page. */
14
+ export declare const isStaticText: (contentType: string | null) => boolean;
15
+ /** Connects the user's Canva in a pop-up that returns to a static text file on this origin. */
16
+ export declare const connectCanva: (rpcGateway: RpcGatewayAPI) => Promise<void>;
17
+ /** Revokes and forgets the signed-in user's Canva connection. */
18
+ export declare const disconnectCanva: (rpcGateway: RpcGatewayAPI) => Promise<unknown>;
@@ -0,0 +1,86 @@
1
+ const PROVIDER = 'canva';
2
+ const POLL_INTERVAL_MS = 500;
3
+ const CONNECT_TIMEOUT_MS = 10 * 60 * 1000;
4
+ /** Error raised when the browser refused to open the Canva consent window. */
5
+ export class PopupBlockedError extends Error {}
6
+ /** Consent result read from the pop-up once it is back on our origin; null while it is still elsewhere. */
7
+ export const readConsentReturn = (location, origin) => {
8
+ if (location.origin !== origin) return null;
9
+ const params = new URLSearchParams(location.hash.replace(/^#/, ''));
10
+ return params.get('oauth_provider') === PROVIDER ? params : null;
11
+ };
12
+ /** Error raised when the user closed the consent pop-up without finishing. */
13
+ export class ConsentClosedError extends Error {}
14
+ const waitForReturn = (popup, origin) => new Promise((resolve, reject) => {
15
+ const startedAt = Date.now();
16
+ const timer = window.setInterval(() => {
17
+ if (popup.closed) {
18
+ window.clearInterval(timer);
19
+ reject(new ConsentClosedError());
20
+ return;
21
+ }
22
+ if (Date.now() - startedAt > CONNECT_TIMEOUT_MS) {
23
+ window.clearInterval(timer);
24
+ popup.close();
25
+ reject(new Error('timeout'));
26
+ return;
27
+ }
28
+ let params = null;
29
+ try {
30
+ params = readConsentReturn(popup.location, origin);
31
+ } catch {
32
+ return;
33
+ }
34
+ if (!params) return;
35
+ window.clearInterval(timer);
36
+ popup.close();
37
+ resolve(params);
38
+ }, POLL_INTERVAL_MS);
39
+ });
40
+ /** Error raised when the return page is not plain static text: the code must not land in an app. */
41
+ export class UnsafeReturnPageError extends Error {}
42
+ /** True when the response is served as plain text, i.e. no app bundle runs on that page. */
43
+ export const isStaticText = contentType => (contentType ?? '').toLowerCase().startsWith('text/plain');
44
+ const ensureStaticReturn = async url => {
45
+ const response = await fetch(url, {
46
+ method: 'HEAD',
47
+ cache: 'no-store',
48
+ credentials: 'omit'
49
+ });
50
+ if (!response.ok || !isStaticText(response.headers.get('content-type'))) throw new UnsafeReturnPageError();
51
+ };
52
+ /** Connects the user's Canva in a pop-up that returns to a static text file on this origin. */
53
+ export const connectCanva = async rpcGateway => {
54
+ const {
55
+ origin
56
+ } = window.location;
57
+ const returnTo = `${origin}/robots.txt`;
58
+ const popup = window.open('', 'pushwoosh-canva-connect', 'width=640,height=760');
59
+ if (!popup) throw new PopupBlockedError();
60
+ try {
61
+ await ensureStaticReturn(returnTo);
62
+ const {
63
+ authUrl
64
+ } = await rpcGateway.integrations.GetOAuthAuthURL({
65
+ provider: PROVIDER,
66
+ returnTo
67
+ });
68
+ popup.location.href = authUrl;
69
+ } catch (error) {
70
+ popup.close();
71
+ throw error;
72
+ }
73
+ const params = await waitForReturn(popup, origin);
74
+ if (params.get('oauth_status') !== 'pending') {
75
+ throw new Error(params.get('oauth_error') || 'Canva consent was not given');
76
+ }
77
+ await rpcGateway.integrations.CompleteOAuthAuthorization({
78
+ provider: PROVIDER,
79
+ state: params.get('oauth_state') ?? '',
80
+ code: params.get('oauth_code') ?? ''
81
+ });
82
+ };
83
+ /** Revokes and forgets the signed-in user's Canva connection. */
84
+ export const disconnectCanva = rpcGateway => rpcGateway.integrations.DeleteOAuthIntegration({
85
+ provider: PROVIDER
86
+ });
@@ -0,0 +1,2 @@
1
+ /** Next page selection after a click: at least one page stays selected, at most `max`, in order. */
2
+ export declare const togglePages: (selected: number[], page: number, max: number) => number[];
@@ -0,0 +1,7 @@
1
+ /** Next page selection after a click: at least one page stays selected, at most `max`, in order. */
2
+ export const togglePages = (selected, page, max) => {
3
+ if (selected.includes(page)) {
4
+ return selected.length > 1 ? selected.filter(item => item !== page) : selected;
5
+ }
6
+ return selected.length < max ? [...selected, page].sort((a, b) => a - b) : selected;
7
+ };
@@ -1,3 +1,4 @@
1
1
  export { MediaStorage } from './MediaStorage';
2
2
  export { MediaStoragePaginated } from './MediaStoragePaginated';
3
3
  export type { MediaStorageProps, MediaStoragePaginatedProps } from './types';
4
+ export { connectCanva, disconnectCanva, PopupBlockedError } from './canva';
@@ -1,2 +1,3 @@
1
1
  export { MediaStorage } from './MediaStorage';
2
- export { MediaStoragePaginated } from './MediaStoragePaginated';
2
+ export { MediaStoragePaginated } from './MediaStoragePaginated';
3
+ export { connectCanva, disconnectCanva, PopupBlockedError } from './canva';