@pushwoosh/smart-components-kit 1.11.1 → 1.13.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.
- package/MediaStorage/GenerateImageTab.d.ts +12 -0
- package/MediaStorage/GenerateImageTab.js +104 -0
- package/MediaStorage/MediaStorage.js +107 -7
- package/MediaStorage/MediaStoragePaginated.js +3 -9
- package/MediaStorage/StockPhotosTab.d.ts +9 -0
- package/MediaStorage/StockPhotosTab.js +135 -0
- package/MediaStorage/aiGallery.d.ts +49 -0
- package/MediaStorage/aiGallery.js +55 -0
- package/MediaStorage/hooks.d.ts +3 -0
- package/MediaStorage/hooks.js +16 -0
- package/MediaStorage/index.d.ts +1 -1
- package/MediaStorage/mediaFile.utils.js +3 -1
- package/MediaStorage/polyglot/messages_ar.js +28 -1
- package/MediaStorage/polyglot/messages_bn.js +28 -1
- package/MediaStorage/polyglot/messages_de.js +28 -1
- package/MediaStorage/polyglot/messages_en.js +28 -1
- package/MediaStorage/polyglot/messages_es.js +28 -1
- package/MediaStorage/polyglot/messages_fr.js +28 -1
- package/MediaStorage/polyglot/messages_he.js +28 -1
- package/MediaStorage/polyglot/messages_hi.js +28 -1
- package/MediaStorage/polyglot/messages_ja.js +28 -1
- package/MediaStorage/polyglot/messages_ko.js +28 -1
- package/MediaStorage/polyglot/messages_pt.js +28 -1
- package/MediaStorage/polyglot/messages_ru.js +28 -1
- package/MediaStorage/polyglot/messages_th.js +28 -1
- package/MediaStorage/polyglot/messages_zh.js +28 -1
- package/MediaStorage/polyglot/types.d.ts +27 -0
- package/MediaStorage/styles.d.ts +54 -0
- package/MediaStorage/styles.js +46 -2
- package/MediaStorage/types.d.ts +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type FC } from 'react';
|
|
2
|
+
import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
|
|
3
|
+
type GenerateImageTabProps = {
|
|
4
|
+
application: string;
|
|
5
|
+
aspectRatios?: string[];
|
|
6
|
+
defaultAspectRatio?: string;
|
|
7
|
+
onSelect: (file: MediaFile, alt: string) => void;
|
|
8
|
+
onGenerated: () => void;
|
|
9
|
+
};
|
|
10
|
+
/** Prompt → one image that onyx stores in the library; the preview is already the stored file. */
|
|
11
|
+
export declare const GenerateImageTab: FC<GenerateImageTabProps>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Button, NativeSelect } from '@pushwoosh/dumb-components';
|
|
4
|
+
import { useSPAContext } from '@pushwoosh/spa-context';
|
|
5
|
+
import { GENERATE_ASPECT_RATIOS, toMediaFile } from './aiGallery';
|
|
6
|
+
import { useErrorToast } from './hooks';
|
|
7
|
+
import { useLang } from './polyglot';
|
|
8
|
+
import { GenerateControls, GeneratedPreview, GenerateForm, PromptField } from './styles';
|
|
9
|
+
const ASPECT_LABELS = {
|
|
10
|
+
'1:1': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_SQUARE',
|
|
11
|
+
'4:3': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_LANDSCAPE',
|
|
12
|
+
'16:9': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_WIDE',
|
|
13
|
+
'21:9': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_BANNER',
|
|
14
|
+
'3:4': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_PORTRAIT',
|
|
15
|
+
'9:16': 'SMART.MEDIA_STORAGE.GENERATE.ASPECT_TALL'
|
|
16
|
+
};
|
|
17
|
+
const isLabelledAspect = ratio => ratio in ASPECT_LABELS;
|
|
18
|
+
const generateButtonMessage = (busy, hasResult) => {
|
|
19
|
+
if (busy) return 'SMART.MEDIA_STORAGE.GENERATE.GENERATING';
|
|
20
|
+
return hasResult ? 'SMART.MEDIA_STORAGE.GENERATE.AGAIN' : 'SMART.MEDIA_STORAGE.GENERATE.BUTTON';
|
|
21
|
+
};
|
|
22
|
+
/** Prompt → one image that onyx stores in the library; the preview is already the stored file. */
|
|
23
|
+
export const GenerateImageTab = ({
|
|
24
|
+
application,
|
|
25
|
+
aspectRatios,
|
|
26
|
+
defaultAspectRatio,
|
|
27
|
+
onSelect,
|
|
28
|
+
onGenerated
|
|
29
|
+
}) => {
|
|
30
|
+
const {
|
|
31
|
+
rpcGatewayAIAssistant
|
|
32
|
+
} = useSPAContext();
|
|
33
|
+
const errorToast = useErrorToast();
|
|
34
|
+
const lang = useLang();
|
|
35
|
+
const ratios = aspectRatios?.length ? aspectRatios : GENERATE_ASPECT_RATIOS;
|
|
36
|
+
const [prompt, setPrompt] = useState('');
|
|
37
|
+
const [aspect, setAspect] = useState(defaultAspectRatio && ratios.includes(defaultAspectRatio) ? defaultAspectRatio : ratios[0]);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const [generated, setGenerated] = useState(null);
|
|
40
|
+
const aspectLabel = ratio => {
|
|
41
|
+
return isLabelledAspect(ratio) ? lang({
|
|
42
|
+
message: ASPECT_LABELS[ratio]
|
|
43
|
+
}) : ratio;
|
|
44
|
+
};
|
|
45
|
+
const generate = async () => {
|
|
46
|
+
const text = prompt.trim();
|
|
47
|
+
if (!text || busy) return;
|
|
48
|
+
setGenerated(null);
|
|
49
|
+
setBusy(true);
|
|
50
|
+
try {
|
|
51
|
+
const res = await rpcGatewayAIAssistant.aiGen.GenerateMediaImage({
|
|
52
|
+
application,
|
|
53
|
+
prompt: text,
|
|
54
|
+
aspectRatio: aspect
|
|
55
|
+
});
|
|
56
|
+
setGenerated({
|
|
57
|
+
file: toMediaFile(res, 'ai', `${res.mediaUuid}.png`),
|
|
58
|
+
alt: text
|
|
59
|
+
});
|
|
60
|
+
onGenerated();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
errorToast('SMART.MEDIA_STORAGE.GENERATE.ERROR', error);
|
|
63
|
+
} finally {
|
|
64
|
+
setBusy(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return _jsxs(GenerateForm, {
|
|
68
|
+
children: [_jsx(PromptField, {
|
|
69
|
+
value: prompt,
|
|
70
|
+
rows: 3,
|
|
71
|
+
placeholder: lang({
|
|
72
|
+
message: 'SMART.MEDIA_STORAGE.GENERATE.PROMPT_PLACEHOLDER'
|
|
73
|
+
}),
|
|
74
|
+
onChange: event => setPrompt(event.target.value)
|
|
75
|
+
}), _jsxs(GenerateControls, {
|
|
76
|
+
children: [ratios.length > 1 && _jsx(NativeSelect, {
|
|
77
|
+
value: aspect,
|
|
78
|
+
onChange: event => setAspect(event.target.value),
|
|
79
|
+
children: ratios.map(ratio => _jsx("option", {
|
|
80
|
+
value: ratio,
|
|
81
|
+
children: aspectLabel(ratio)
|
|
82
|
+
}, ratio))
|
|
83
|
+
}), _jsx(Button, {
|
|
84
|
+
color: "primary",
|
|
85
|
+
size: "field",
|
|
86
|
+
isDisabled: busy || !prompt.trim(),
|
|
87
|
+
onClick: () => void generate(),
|
|
88
|
+
children: lang({
|
|
89
|
+
message: generateButtonMessage(busy, generated !== null)
|
|
90
|
+
})
|
|
91
|
+
}), generated && _jsx(Button, {
|
|
92
|
+
color: "secondary",
|
|
93
|
+
size: "field",
|
|
94
|
+
onClick: () => onSelect(generated.file, generated.alt),
|
|
95
|
+
children: lang({
|
|
96
|
+
message: 'SMART.MEDIA_STORAGE.GENERATE.SELECT'
|
|
97
|
+
})
|
|
98
|
+
})]
|
|
99
|
+
}), generated && _jsx(GeneratedPreview, {
|
|
100
|
+
src: generated.file.url,
|
|
101
|
+
alt: generated.alt
|
|
102
|
+
})]
|
|
103
|
+
});
|
|
104
|
+
};
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { Button, Checkbox, DropdownMenuButton, DropdownMenuItem, DropdownMenuSection, ModalConfirm, useToast } from '@pushwoosh/dumb-components';
|
|
3
|
+
import { Button, Checkbox, DropdownMenuButton, DropdownMenuItem, DropdownMenuSection, ModalConfirm, Tab, TabBar, TabPanel, Tabs, useToast } from '@pushwoosh/dumb-components';
|
|
4
4
|
import { FlexContainer, useAsync } from '@pushwoosh/kit-helpers';
|
|
5
5
|
import { CheckIcon, DropdownIcon, EditIcon, OpenIcon, TrashIcon, UploadIcon } from '@pushwoosh/kit-icons';
|
|
6
6
|
import { Text } from '@pushwoosh/kit-typography';
|
|
7
7
|
import { useSPAContext } from '@pushwoosh/spa-context';
|
|
8
|
+
import { visibleSources } from './aiGallery';
|
|
8
9
|
import { CANVA_DESIGN_PRESETS } from './canvaEdit';
|
|
9
10
|
import { CanvaImportDrawer } from './CanvaImportDrawer';
|
|
10
11
|
import { CreateCropDrawer } from './CreateCropDrawer';
|
|
12
|
+
import { GenerateImageTab } from './GenerateImageTab';
|
|
11
13
|
import { useCanvaEdit, useDeleteMediaFile, useDeleteMediaFiles } from './hooks';
|
|
12
14
|
import { formatBytes, getCropCaption, getSourceLabel, getThumbUrl, hasVersions } from './mediaFile.utils';
|
|
13
15
|
import { Lang, useLang } from './polyglot';
|
|
14
|
-
import {
|
|
16
|
+
import { StockPhotosTab } from './StockPhotosTab';
|
|
17
|
+
import { CanvaActions, CountBadge, Dropzone, DropzoneRow, EmptyState, Grid, HiddenInput, SelectionBar, SearchField, SearchRow, SelectionCount, SourceBadge, TabBody, Tile, TileActions, TileBadges, TileCheckbox, TileImage, UsageLine, WarningBadge } from './styles';
|
|
15
18
|
const UPLOAD_PATH = '/api/media/upload';
|
|
16
19
|
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
20
|
+
const TAB_MESSAGES = {
|
|
21
|
+
library: 'SMART.MEDIA_STORAGE.TAB.LIBRARY',
|
|
22
|
+
stock: 'SMART.MEDIA_STORAGE.TAB.STOCK',
|
|
23
|
+
generate: 'SMART.MEDIA_STORAGE.TAB.GENERATE'
|
|
24
|
+
};
|
|
17
25
|
const isAllowedImage = file => {
|
|
18
26
|
const name = file.name.toLowerCase();
|
|
19
27
|
return ALLOWED_IMAGE_EXTENSIONS.some(ext => name.endsWith(ext));
|
|
@@ -30,7 +38,11 @@ export const MediaStorage = ({
|
|
|
30
38
|
onPageChange,
|
|
31
39
|
onSelect,
|
|
32
40
|
extensions,
|
|
33
|
-
fileWarning
|
|
41
|
+
fileWarning,
|
|
42
|
+
sources,
|
|
43
|
+
aspectRatios,
|
|
44
|
+
defaultAspectRatio,
|
|
45
|
+
onSuggestAlt
|
|
34
46
|
}) => {
|
|
35
47
|
const lang = useLang();
|
|
36
48
|
const {
|
|
@@ -64,6 +76,8 @@ export const MediaStorage = ({
|
|
|
64
76
|
const [selectedUuids, setSelectedUuids] = useState([]);
|
|
65
77
|
const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false);
|
|
66
78
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
|
79
|
+
const [search, setSearch] = useState('');
|
|
80
|
+
const [searchDraft, setSearchDraft] = useState('');
|
|
67
81
|
// Format filtering belongs to the query, not the response: `total` and the page count come
|
|
68
82
|
// from the filtered set, so a client-side filter would paginate over files it then hides.
|
|
69
83
|
const listAsync = useAsync(async () => {
|
|
@@ -71,14 +85,15 @@ export const MediaStorage = ({
|
|
|
71
85
|
application,
|
|
72
86
|
page,
|
|
73
87
|
perPage,
|
|
74
|
-
extensions
|
|
88
|
+
extensions,
|
|
89
|
+
searchByName: search || undefined
|
|
75
90
|
});
|
|
76
91
|
// protobuf JSON omits empty `repeated` fields, so `files` can be undefined.
|
|
77
92
|
return {
|
|
78
93
|
files: response.files ?? [],
|
|
79
94
|
total: response.total
|
|
80
95
|
};
|
|
81
|
-
}, [rpcGateway, application, page, perPage, extensions, reloadToken]);
|
|
96
|
+
}, [rpcGateway, application, page, perPage, extensions, search, reloadToken]);
|
|
82
97
|
const usageAsync = useAsync(() => rpcGateway.mediaFiles.GetUsage({
|
|
83
98
|
application
|
|
84
99
|
}), [rpcGateway, application, reloadToken, usageToken]);
|
|
@@ -94,6 +109,24 @@ export const MediaStorage = ({
|
|
|
94
109
|
setReloadToken(token => token + 1);
|
|
95
110
|
};
|
|
96
111
|
const canvaEdit = useCanvaEdit(application, Boolean(canvaStatus?.available), showImportedFirst);
|
|
112
|
+
const tabs = visibleSources(sources, extensions);
|
|
113
|
+
const [selectedTab, setTab] = useState(tabs[0]);
|
|
114
|
+
const tab = tabs.includes(selectedTab) ? selectedTab : tabs[0];
|
|
115
|
+
const pickStored = (file, alt) => {
|
|
116
|
+
const warning = fileWarning?.(file);
|
|
117
|
+
if (warning) {
|
|
118
|
+
openToast({
|
|
119
|
+
title: warning,
|
|
120
|
+
type: 'warning'
|
|
121
|
+
});
|
|
122
|
+
setTab('library');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (alt) {
|
|
126
|
+
onSuggestAlt?.(file, alt);
|
|
127
|
+
}
|
|
128
|
+
onSelect(file);
|
|
129
|
+
};
|
|
97
130
|
const {
|
|
98
131
|
loading
|
|
99
132
|
} = listAsync;
|
|
@@ -144,6 +177,14 @@ export const MediaStorage = ({
|
|
|
144
177
|
cancelled = true;
|
|
145
178
|
};
|
|
146
179
|
}, [listAsync.value, rpcGateway, application, extensions]);
|
|
180
|
+
const applySearch = value => {
|
|
181
|
+
if (value === search) return;
|
|
182
|
+
setSearch(value);
|
|
183
|
+
if (pageRef.current !== 0) {
|
|
184
|
+
onPageChange(0);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const commitSearch = () => applySearch(searchDraft.trim());
|
|
147
188
|
const uploadFiles = async selected => {
|
|
148
189
|
if (selected.length === 0) return;
|
|
149
190
|
const images = selected.filter(isAllowedImage);
|
|
@@ -255,7 +296,7 @@ export const MediaStorage = ({
|
|
|
255
296
|
const isAllSelected = files.length > 0 && selectedUuids.length === files.length;
|
|
256
297
|
const totalPages = Math.ceil(total / perPage);
|
|
257
298
|
const showPagination = !loading && files.length > 0 && totalPages > 1;
|
|
258
|
-
|
|
299
|
+
const library = _jsxs(_Fragment, {
|
|
259
300
|
children: [_jsxs(DropzoneRow, {
|
|
260
301
|
children: [_jsxs(Dropzone, {
|
|
261
302
|
type: "button",
|
|
@@ -316,6 +357,24 @@ export const MediaStorage = ({
|
|
|
316
357
|
accept: ALLOWED_IMAGE_EXTENSIONS.join(','),
|
|
317
358
|
multiple: true,
|
|
318
359
|
onChange: event => void uploadFiles(Array.from(event.target.files ?? []))
|
|
360
|
+
}), _jsx(SearchRow, {
|
|
361
|
+
children: _jsx(SearchField, {
|
|
362
|
+
type: "search",
|
|
363
|
+
value: searchDraft,
|
|
364
|
+
placeholder: lang({
|
|
365
|
+
message: 'SMART.MEDIA_STORAGE.SEARCH_PLACEHOLDER'
|
|
366
|
+
}),
|
|
367
|
+
onChange: event => {
|
|
368
|
+
setSearchDraft(event.target.value);
|
|
369
|
+
if (event.target.value === '') {
|
|
370
|
+
applySearch('');
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
onKeyDown: event => {
|
|
374
|
+
if (event.key === 'Enter') commitSearch();
|
|
375
|
+
},
|
|
376
|
+
onBlur: commitSearch
|
|
377
|
+
})
|
|
319
378
|
}), loading && _jsx(EmptyState, {
|
|
320
379
|
children: lang({
|
|
321
380
|
message: 'SMART.MEDIA_STORAGE.LOADING'
|
|
@@ -497,7 +556,48 @@ export const MediaStorage = ({
|
|
|
497
556
|
size: formatBytes(usageAsync.value.totalSize)
|
|
498
557
|
})
|
|
499
558
|
})]
|
|
500
|
-
})
|
|
559
|
+
})]
|
|
560
|
+
});
|
|
561
|
+
return _jsxs(_Fragment, {
|
|
562
|
+
children: [tabs.length > 1 ? _jsxs(Tabs, {
|
|
563
|
+
activeCode: tab,
|
|
564
|
+
onChange: code => setTab(code),
|
|
565
|
+
children: [_jsx(TabBar, {
|
|
566
|
+
children: tabs.map(source => _jsx(Tab, {
|
|
567
|
+
code: source,
|
|
568
|
+
children: _jsx(Lang, {
|
|
569
|
+
message: TAB_MESSAGES[source]
|
|
570
|
+
})
|
|
571
|
+
}, source))
|
|
572
|
+
}), _jsx(TabPanel, {
|
|
573
|
+
code: "library",
|
|
574
|
+
children: _jsx(TabBody, {
|
|
575
|
+
children: library
|
|
576
|
+
})
|
|
577
|
+
}), tabs.includes('stock') && _jsx(TabPanel, {
|
|
578
|
+
code: "stock",
|
|
579
|
+
children: _jsx(TabBody, {
|
|
580
|
+
children: _jsx(StockPhotosTab, {
|
|
581
|
+
application: application,
|
|
582
|
+
onImported: (file, alt) => {
|
|
583
|
+
showImportedFirst();
|
|
584
|
+
pickStored(file, alt);
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
}), tabs.includes('generate') && _jsx(TabPanel, {
|
|
589
|
+
code: "generate",
|
|
590
|
+
children: _jsx(TabBody, {
|
|
591
|
+
children: _jsx(GenerateImageTab, {
|
|
592
|
+
application: application,
|
|
593
|
+
aspectRatios: aspectRatios,
|
|
594
|
+
defaultAspectRatio: defaultAspectRatio,
|
|
595
|
+
onGenerated: showImportedFirst,
|
|
596
|
+
onSelect: pickStored
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
})]
|
|
600
|
+
}) : library, canvaStatus?.available && _jsx(CanvaImportDrawer, {
|
|
501
601
|
application: application,
|
|
502
602
|
isOpen: isCanvaOpen,
|
|
503
603
|
status: canvaStatus,
|
|
@@ -8,21 +8,15 @@ const DEFAULT_PER_PAGE = 30;
|
|
|
8
8
|
* `MediaStorage` directly to drive the page from the URL or another store.
|
|
9
9
|
*/
|
|
10
10
|
export const MediaStoragePaginated = ({
|
|
11
|
-
application,
|
|
12
11
|
initialPage = 0,
|
|
13
12
|
perPage = DEFAULT_PER_PAGE,
|
|
14
|
-
|
|
15
|
-
extensions,
|
|
16
|
-
fileWarning
|
|
13
|
+
...props
|
|
17
14
|
}) => {
|
|
18
15
|
const [page, setPage] = useState(initialPage);
|
|
19
16
|
return _jsx(MediaStorage, {
|
|
20
|
-
|
|
17
|
+
...props,
|
|
21
18
|
page: page,
|
|
22
19
|
perPage: perPage,
|
|
23
|
-
onPageChange: setPage
|
|
24
|
-
onSelect: onSelect,
|
|
25
|
-
extensions: extensions,
|
|
26
|
-
fileWarning: fileWarning
|
|
20
|
+
onPageChange: setPage
|
|
27
21
|
});
|
|
28
22
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FC } from 'react';
|
|
2
|
+
import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
|
|
3
|
+
type StockPhotosTabProps = {
|
|
4
|
+
application: string;
|
|
5
|
+
onImported: (file: MediaFile, alt: string) => void;
|
|
6
|
+
};
|
|
7
|
+
/** Pexels search through onyx; picking a photo copies it into the library and hands back the stored file. */
|
|
8
|
+
export declare const StockPhotosTab: FC<StockPhotosTabProps>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Button } from '@pushwoosh/dumb-components';
|
|
4
|
+
import { useSPAContext } from '@pushwoosh/spa-context';
|
|
5
|
+
import { isStockUnconfigured, STOCK_QUERY_MAX_CHARS, toMediaFile } from './aiGallery';
|
|
6
|
+
import { useErrorToast } from './hooks';
|
|
7
|
+
import { Lang, useLang } from './polyglot';
|
|
8
|
+
import { EmptyState, Grid, ProviderNote, SearchField, SearchRow, StockCredit, StockTile, TileImage } from './styles';
|
|
9
|
+
const PEXELS_HOME = 'https://www.pexels.com';
|
|
10
|
+
/** Pexels search through onyx; picking a photo copies it into the library and hands back the stored file. */
|
|
11
|
+
export const StockPhotosTab = ({
|
|
12
|
+
application,
|
|
13
|
+
onImported
|
|
14
|
+
}) => {
|
|
15
|
+
const {
|
|
16
|
+
rpcGatewayAIAssistant
|
|
17
|
+
} = useSPAContext();
|
|
18
|
+
const errorToast = useErrorToast();
|
|
19
|
+
const lang = useLang();
|
|
20
|
+
const [query, setQuery] = useState('');
|
|
21
|
+
const [photos, setPhotos] = useState([]);
|
|
22
|
+
const [searched, setSearched] = useState(false);
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [unconfigured, setUnconfigured] = useState(false);
|
|
25
|
+
const [importingId, setImportingId] = useState(null);
|
|
26
|
+
const search = async () => {
|
|
27
|
+
const q = query.trim();
|
|
28
|
+
if (!q || loading) return;
|
|
29
|
+
setLoading(true);
|
|
30
|
+
try {
|
|
31
|
+
const res = await rpcGatewayAIAssistant.aiGen.SearchStockPhotos({
|
|
32
|
+
application,
|
|
33
|
+
query: q,
|
|
34
|
+
page: 1,
|
|
35
|
+
provider: 'STOCK_PROVIDER_PEXELS'
|
|
36
|
+
});
|
|
37
|
+
setPhotos(res.photos ?? []);
|
|
38
|
+
setSearched(true);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isStockUnconfigured(error)) {
|
|
41
|
+
setUnconfigured(true);
|
|
42
|
+
} else {
|
|
43
|
+
errorToast('SMART.MEDIA_STORAGE.STOCK.SEARCH_ERROR', error);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const importPhoto = async photo => {
|
|
50
|
+
if (importingId) return;
|
|
51
|
+
setImportingId(photo.id);
|
|
52
|
+
try {
|
|
53
|
+
const res = await rpcGatewayAIAssistant.aiGen.ImportStockPhoto({
|
|
54
|
+
application,
|
|
55
|
+
provider: 'STOCK_PROVIDER_PEXELS',
|
|
56
|
+
photoId: photo.id
|
|
57
|
+
});
|
|
58
|
+
onImported(toMediaFile(res, 'stock', `pexels-${photo.id}.jpg`), res.alt || photo.alt || '');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
errorToast('SMART.MEDIA_STORAGE.STOCK.IMPORT_ERROR', error);
|
|
61
|
+
} finally {
|
|
62
|
+
setImportingId(null);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
if (unconfigured) {
|
|
66
|
+
return _jsx(EmptyState, {
|
|
67
|
+
children: _jsx(Lang, {
|
|
68
|
+
message: "SMART.MEDIA_STORAGE.STOCK.UNCONFIGURED"
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return _jsxs(_Fragment, {
|
|
73
|
+
children: [_jsxs(SearchRow, {
|
|
74
|
+
children: [_jsx(SearchField, {
|
|
75
|
+
type: "text",
|
|
76
|
+
value: query,
|
|
77
|
+
maxLength: STOCK_QUERY_MAX_CHARS,
|
|
78
|
+
placeholder: lang({
|
|
79
|
+
message: 'SMART.MEDIA_STORAGE.STOCK.SEARCH_PLACEHOLDER'
|
|
80
|
+
}),
|
|
81
|
+
onChange: event => setQuery(event.target.value),
|
|
82
|
+
onKeyDown: event => {
|
|
83
|
+
if (event.key === 'Enter') void search();
|
|
84
|
+
}
|
|
85
|
+
}), _jsx(Button, {
|
|
86
|
+
color: "secondary",
|
|
87
|
+
size: "field",
|
|
88
|
+
isDisabled: loading || !query.trim(),
|
|
89
|
+
onClick: () => void search(),
|
|
90
|
+
children: lang({
|
|
91
|
+
message: loading ? 'SMART.MEDIA_STORAGE.STOCK.SEARCHING' : 'SMART.MEDIA_STORAGE.STOCK.SEARCH_BUTTON'
|
|
92
|
+
})
|
|
93
|
+
})]
|
|
94
|
+
}), !searched && !loading && _jsx(EmptyState, {
|
|
95
|
+
children: _jsx(Lang, {
|
|
96
|
+
message: "SMART.MEDIA_STORAGE.STOCK.HINT"
|
|
97
|
+
})
|
|
98
|
+
}), searched && !loading && photos.length === 0 && _jsx(EmptyState, {
|
|
99
|
+
children: _jsx(Lang, {
|
|
100
|
+
message: "SMART.MEDIA_STORAGE.STOCK.NO_RESULTS"
|
|
101
|
+
})
|
|
102
|
+
}), photos.length > 0 && _jsx(Grid, {
|
|
103
|
+
children: photos.map(photo => _jsxs("div", {
|
|
104
|
+
children: [_jsx(StockTile, {
|
|
105
|
+
type: "button",
|
|
106
|
+
disabled: importingId !== null,
|
|
107
|
+
title: lang({
|
|
108
|
+
message: importingId === photo.id ? 'SMART.MEDIA_STORAGE.STOCK.IMPORTING' : 'SMART.MEDIA_STORAGE.STOCK.CLICK_TO_SELECT'
|
|
109
|
+
}),
|
|
110
|
+
"$avgColor": photo.avgColor,
|
|
111
|
+
"$dimmed": importingId !== null && importingId !== photo.id,
|
|
112
|
+
onClick: () => void importPhoto(photo),
|
|
113
|
+
children: _jsx(TileImage, {
|
|
114
|
+
src: photo.thumbUrl,
|
|
115
|
+
alt: photo.alt
|
|
116
|
+
})
|
|
117
|
+
}), _jsx(StockCredit, {
|
|
118
|
+
href: photo.photographerUrl,
|
|
119
|
+
target: "_blank",
|
|
120
|
+
rel: "noreferrer",
|
|
121
|
+
children: photo.photographer
|
|
122
|
+
})]
|
|
123
|
+
}, photo.id))
|
|
124
|
+
}), _jsxs(ProviderNote, {
|
|
125
|
+
children: [_jsx(Lang, {
|
|
126
|
+
message: "SMART.MEDIA_STORAGE.STOCK.ATTRIBUTION"
|
|
127
|
+
}), ' ', _jsx("a", {
|
|
128
|
+
href: PEXELS_HOME,
|
|
129
|
+
target: "_blank",
|
|
130
|
+
rel: "noreferrer",
|
|
131
|
+
children: "Pexels"
|
|
132
|
+
})]
|
|
133
|
+
})]
|
|
134
|
+
});
|
|
135
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
|
|
2
|
+
import type { MediaSource } from './types';
|
|
3
|
+
/** Stored file as onyx returns it with a generated or imported image (`AiGenMediaFile`). */
|
|
4
|
+
export type AiGenMediaFile = {
|
|
5
|
+
uuid: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
url: string;
|
|
8
|
+
fileSize: number;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
thumbnailUrl: string;
|
|
15
|
+
thumbnailWidth: number;
|
|
16
|
+
thumbnailHeight: number;
|
|
17
|
+
};
|
|
18
|
+
/** The part of a Generate / Stock import response the gallery reads; `file` is absent on older onyx builds. */
|
|
19
|
+
export type AiGenStoredImage = {
|
|
20
|
+
url: string;
|
|
21
|
+
mediaUuid: string;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
file?: AiGenMediaFile;
|
|
25
|
+
};
|
|
26
|
+
/** Aspect ratios onyx accepts for a generated image, in picker order. */
|
|
27
|
+
export declare const GENERATE_ASPECT_RATIOS: string[];
|
|
28
|
+
/** One Pexels hit from `aiGen.SearchStockPhotos`. */
|
|
29
|
+
export type StockPhoto = {
|
|
30
|
+
id: string;
|
|
31
|
+
thumbUrl: string;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
photographer: string;
|
|
35
|
+
photographerUrl: string;
|
|
36
|
+
avgColor: string;
|
|
37
|
+
alt: string;
|
|
38
|
+
};
|
|
39
|
+
/** Longest stock query onyx accepts (`stockphotos.MaxQueryChars`); capping the input keeps 400 meaning "not configured". */
|
|
40
|
+
export declare const STOCK_QUERY_MAX_CHARS = 200;
|
|
41
|
+
/** onyx answers an unconfigured Pexels key with FailedPrecondition, which the gateway turns into HTTP 400. */
|
|
42
|
+
export declare const isStockUnconfigured: (error: unknown) => boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Sources to show: the requested ones minus those whose output the channel's `extensions` filter rejects.
|
|
45
|
+
* Stock is always JPEG; Generate is PNG or JPEG depending on the model, so it needs both accepted.
|
|
46
|
+
*/
|
|
47
|
+
export declare const visibleSources: (sources: MediaSource[] | undefined, extensions: string[] | undefined) => MediaSource[];
|
|
48
|
+
/** Media-library file for an image onyx just stored, so `onSelect` gets the same shape as a library pick. */
|
|
49
|
+
export declare const toMediaFile: (stored: AiGenStoredImage, source: "ai" | "stock", fallbackName: string) => MediaFile;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Aspect ratios onyx accepts for a generated image, in picker order. */
|
|
2
|
+
export const GENERATE_ASPECT_RATIOS = ['1:1', '4:3', '3:2', '16:9', '21:9', '5:4', '4:5', '3:4', '2:3', '9:16'];
|
|
3
|
+
/** Longest stock query onyx accepts (`stockphotos.MaxQueryChars`); capping the input keeps 400 meaning "not configured". */
|
|
4
|
+
export const STOCK_QUERY_MAX_CHARS = 200;
|
|
5
|
+
/** onyx answers an unconfigured Pexels key with FailedPrecondition, which the gateway turns into HTTP 400. */
|
|
6
|
+
export const isStockUnconfigured = error => error.code === 400;
|
|
7
|
+
const SOURCE_EXTENSIONS = {
|
|
8
|
+
stock: [['jpg', 'jpeg']],
|
|
9
|
+
generate: [['png'], ['jpg', 'jpeg']]
|
|
10
|
+
};
|
|
11
|
+
const normalizeExtension = ext => ext.replace(/^\./, '').toLowerCase();
|
|
12
|
+
/**
|
|
13
|
+
* Sources to show: the requested ones minus those whose output the channel's `extensions` filter rejects.
|
|
14
|
+
* Stock is always JPEG; Generate is PNG or JPEG depending on the model, so it needs both accepted.
|
|
15
|
+
*/
|
|
16
|
+
export const visibleSources = (sources, extensions) => {
|
|
17
|
+
const requested = sources?.length ? sources : ['library'];
|
|
18
|
+
const allowed = extensions?.map(normalizeExtension);
|
|
19
|
+
return requested.filter(source => {
|
|
20
|
+
if (source === 'library' || !allowed) return true;
|
|
21
|
+
return SOURCE_EXTENSIONS[source].every(formats => formats.some(ext => allowed.includes(ext)));
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/** Media-library file for an image onyx just stored, so `onSelect` gets the same shape as a library pick. */
|
|
25
|
+
export const toMediaFile = (stored, source, fallbackName) => {
|
|
26
|
+
const {
|
|
27
|
+
file
|
|
28
|
+
} = stored;
|
|
29
|
+
const width = file?.width ?? stored.width;
|
|
30
|
+
const height = file?.height ?? stored.height;
|
|
31
|
+
return {
|
|
32
|
+
uuid: file?.uuid || stored.mediaUuid,
|
|
33
|
+
filename: file?.filename || fallbackName,
|
|
34
|
+
url: file?.url || stored.url,
|
|
35
|
+
fileSize: file?.fileSize ?? 0,
|
|
36
|
+
mimeType: file?.mimeType ?? '',
|
|
37
|
+
createdAt: file?.createdAt ? new Date(file.createdAt) : new Date(),
|
|
38
|
+
description: file?.description,
|
|
39
|
+
source,
|
|
40
|
+
version: 0,
|
|
41
|
+
versionRootUuid: file?.uuid || stored.mediaUuid,
|
|
42
|
+
versionsCount: 1,
|
|
43
|
+
meta: {
|
|
44
|
+
type: 'imageMeta',
|
|
45
|
+
data: {
|
|
46
|
+
width,
|
|
47
|
+
height,
|
|
48
|
+
thumbnailUrl: file?.thumbnailUrl || file?.url || stored.url,
|
|
49
|
+
thumbnailWidth: file?.thumbnailWidth ?? width,
|
|
50
|
+
thumbnailHeight: file?.thumbnailHeight ?? height,
|
|
51
|
+
ratio: ''
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
};
|
package/MediaStorage/hooks.d.ts
CHANGED
|
@@ -13,4 +13,7 @@ type CanvaEdit = {
|
|
|
13
13
|
};
|
|
14
14
|
/** Sends images to Canva and, on the page Canva returns to, stores what the user brought back. */
|
|
15
15
|
export declare function useCanvaEdit(application: string, isAvailable: boolean, onSaved: () => void): CanvaEdit;
|
|
16
|
+
type ErrorToastTitle = 'SMART.MEDIA_STORAGE.STOCK.SEARCH_ERROR' | 'SMART.MEDIA_STORAGE.STOCK.IMPORT_ERROR' | 'SMART.MEDIA_STORAGE.GENERATE.ERROR';
|
|
17
|
+
/** Error toast with a fixed title and the server's message as the description. */
|
|
18
|
+
export declare function useErrorToast(): (title: ErrorToastTitle, error: unknown) => void;
|
|
16
19
|
export {};
|
package/MediaStorage/hooks.js
CHANGED
|
@@ -204,4 +204,20 @@ export function useCanvaEdit(application, isAvailable, onSaved) {
|
|
|
204
204
|
create,
|
|
205
205
|
isSaving
|
|
206
206
|
};
|
|
207
|
+
}
|
|
208
|
+
/** Error toast with a fixed title and the server's message as the description. */
|
|
209
|
+
export function useErrorToast() {
|
|
210
|
+
const {
|
|
211
|
+
openToast
|
|
212
|
+
} = useToast();
|
|
213
|
+
const lang = useLang();
|
|
214
|
+
return usePersistentFunction((title, error) => {
|
|
215
|
+
openToast({
|
|
216
|
+
title: lang({
|
|
217
|
+
message: title
|
|
218
|
+
}),
|
|
219
|
+
description: error.message,
|
|
220
|
+
type: 'error'
|
|
221
|
+
});
|
|
222
|
+
});
|
|
207
223
|
}
|
package/MediaStorage/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { MediaStorage } from './MediaStorage';
|
|
2
2
|
export { MediaStoragePaginated } from './MediaStoragePaginated';
|
|
3
|
-
export type { MediaStorageProps, MediaStoragePaginatedProps } from './types';
|
|
3
|
+
export type { MediaSource, MediaStorageProps, MediaStoragePaginatedProps } from './types';
|
|
4
4
|
export { connectCanva, ConsentClosedError, disconnectCanva, PopupBlockedError, UnsafeReturnPageError, } from './canva';
|
|
@@ -13,7 +13,9 @@ export const getCropCaption = file => {
|
|
|
13
13
|
};
|
|
14
14
|
const SOURCE_LABELS = {
|
|
15
15
|
canva: 'Canva',
|
|
16
|
-
figma: 'Figma'
|
|
16
|
+
figma: 'Figma',
|
|
17
|
+
ai: 'AI',
|
|
18
|
+
stock: 'Pexels'
|
|
17
19
|
};
|
|
18
20
|
/** Brand name of the service an image was imported from; undefined for a file uploaded from disk. */
|
|
19
21
|
export const getSourceLabel = file => file.source ? SOURCE_LABELS[file.source] : undefined;
|
|
@@ -79,5 +79,32 @@ export const messages = {
|
|
|
79
79
|
'SMART.MEDIA_STORAGE.CANVA.SAVED': 'تم الحفظ من Canva',
|
|
80
80
|
'SMART.MEDIA_STORAGE.CANVA.EDIT_COPY': 'تصميم Canva لهذه الصورة يخص مستخدمًا آخر — أنت تحرر نسخة',
|
|
81
81
|
'SMART.MEDIA_STORAGE.CANVA.EDIT_ERROR': 'تعذّر فتح Canva',
|
|
82
|
-
'SMART.MEDIA_STORAGE.CANVA.SAVE_ERROR': 'تعذّر حفظ التصميم من Canva'
|
|
82
|
+
'SMART.MEDIA_STORAGE.CANVA.SAVE_ERROR': 'تعذّر حفظ التصميم من Canva',
|
|
83
|
+
'SMART.MEDIA_STORAGE.TAB.LIBRARY': 'المكتبة',
|
|
84
|
+
'SMART.MEDIA_STORAGE.TAB.STOCK': 'صور المخزون',
|
|
85
|
+
'SMART.MEDIA_STORAGE.TAB.GENERATE': 'إنشاء بالذكاء الاصطناعي',
|
|
86
|
+
'SMART.MEDIA_STORAGE.STOCK.SEARCH_PLACEHOLDER': 'ابحث عن صور مجانية… (مثل: تخفيضات الصيف، قهوة، مدينة)',
|
|
87
|
+
'SMART.MEDIA_STORAGE.STOCK.SEARCH_BUTTON': 'بحث',
|
|
88
|
+
'SMART.MEDIA_STORAGE.STOCK.SEARCHING': 'جارٍ البحث…',
|
|
89
|
+
'SMART.MEDIA_STORAGE.STOCK.HINT': 'ابحث في ملايين الصور المجانية',
|
|
90
|
+
'SMART.MEDIA_STORAGE.STOCK.NO_RESULTS': 'لم يتم العثور على شيء — جرب بحثًا آخر',
|
|
91
|
+
'SMART.MEDIA_STORAGE.STOCK.UNCONFIGURED': 'صور المخزون غير مهيأة في هذا التثبيت.',
|
|
92
|
+
'SMART.MEDIA_STORAGE.STOCK.SEARCH_ERROR': 'فشل البحث في الصور',
|
|
93
|
+
'SMART.MEDIA_STORAGE.STOCK.IMPORT_ERROR': 'تعذر استيراد الصورة',
|
|
94
|
+
'SMART.MEDIA_STORAGE.STOCK.IMPORTING': 'جارٍ الاستيراد…',
|
|
95
|
+
'SMART.MEDIA_STORAGE.STOCK.CLICK_TO_SELECT': 'انقر للتحديد',
|
|
96
|
+
'SMART.MEDIA_STORAGE.STOCK.ATTRIBUTION': 'الصور مقدمة من',
|
|
97
|
+
'SMART.MEDIA_STORAGE.GENERATE.PROMPT_PLACEHOLDER': 'صف الصورة… (مثال: صورة مسطحة لمستلزمات نزهة صيفية على خلفية بألوان الباستيل)',
|
|
98
|
+
'SMART.MEDIA_STORAGE.GENERATE.BUTTON': 'إنشاء',
|
|
99
|
+
'SMART.MEDIA_STORAGE.GENERATE.AGAIN': 'إنشاء مرة أخرى',
|
|
100
|
+
'SMART.MEDIA_STORAGE.GENERATE.GENERATING': 'جارٍ الإنشاء…',
|
|
101
|
+
'SMART.MEDIA_STORAGE.GENERATE.ERROR': 'فشل إنشاء الصورة',
|
|
102
|
+
'SMART.MEDIA_STORAGE.GENERATE.SELECT': 'إدراج صورة',
|
|
103
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_SQUARE': 'مربع (1:1)',
|
|
104
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_LANDSCAPE': 'منظر طبيعي (4:3)',
|
|
105
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_WIDE': 'عريض (16:9)',
|
|
106
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_BANNER': 'بانر (21:9)',
|
|
107
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_PORTRAIT': 'بورتريه (3:4)',
|
|
108
|
+
'SMART.MEDIA_STORAGE.GENERATE.ASPECT_TALL': 'طويل (9:16)',
|
|
109
|
+
'SMART.MEDIA_STORAGE.SEARCH_PLACEHOLDER': 'ابحث باسم الملف…'
|
|
83
110
|
};
|