@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.
@@ -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 { CanvaActions, CountBadge, Dropzone, DropzoneRow, EmptyState, Grid, HiddenInput, SelectionBar, SelectionCount, SourceBadge, Tile, TileActions, TileBadges, TileCheckbox, TileImage, UsageLine, WarningBadge } from './styles';
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
- return _jsxs(_Fragment, {
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
- }), canvaStatus?.available && _jsx(CanvaImportDrawer, {
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
- onSelect,
15
- extensions,
16
- fileWarning
13
+ ...props
17
14
  }) => {
18
15
  const [page, setPage] = useState(initialPage);
19
16
  return _jsx(MediaStorage, {
20
- application: application,
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
+ };
@@ -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 {};
@@ -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
  }
@@ -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
  };