@frkntmbs/strapi-plugin-video-optimizer 1.0.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +286 -0
  3. package/admin/custom.d.ts +8 -0
  4. package/admin/src/buildVersion.ts +3 -0
  5. package/admin/src/components/AssetOptimizationLabel.tsx +61 -0
  6. package/admin/src/components/BridgeProviders.tsx +123 -0
  7. package/admin/src/components/MediaLibraryCacheBridge.tsx +24 -0
  8. package/admin/src/components/MediaLibraryCardActionsBridge.tsx +249 -0
  9. package/admin/src/components/MediaLibraryJobWatcher.tsx +136 -0
  10. package/admin/src/components/MediaLibraryProgressBridge.tsx +97 -0
  11. package/admin/src/components/OptimizationChoicePicker.tsx +65 -0
  12. package/admin/src/components/OptimizationResizeFields.tsx +120 -0
  13. package/admin/src/components/OptimizationVideoFields.tsx +217 -0
  14. package/admin/src/components/UploadEnhancerBridge.tsx +205 -0
  15. package/admin/src/components/upload/PendingAssetStep.tsx +97 -0
  16. package/admin/src/defaultGlobalSettings.ts +32 -0
  17. package/admin/src/hooks/useDefaultOptimizationMode.ts +24 -0
  18. package/admin/src/hooks/useUploadWithOptimizer.ts +45 -0
  19. package/admin/src/index.ts +84 -0
  20. package/admin/src/pages/SettingsPage.tsx +208 -0
  21. package/admin/src/pluginId.ts +79 -0
  22. package/admin/src/translations/en.json +74 -0
  23. package/admin/src/translations/tr.json +74 -0
  24. package/admin/src/utils/adminFetch.ts +57 -0
  25. package/admin/src/utils/captureQueryClient.ts +34 -0
  26. package/admin/src/utils/debugMediaLibraryProgress.ts +70 -0
  27. package/admin/src/utils/extractAssetDimensions.ts +22 -0
  28. package/admin/src/utils/initJobPoller.ts +173 -0
  29. package/admin/src/utils/initMediaLibraryCardActions.ts +308 -0
  30. package/admin/src/utils/initMediaLibraryProgress.ts +219 -0
  31. package/admin/src/utils/initUploadEnhancer.ts +447 -0
  32. package/admin/src/utils/invalidateMediaLibrary.ts +203 -0
  33. package/admin/src/utils/jobProgressStore.ts +113 -0
  34. package/admin/src/utils/mediaLibraryCardMatch.ts +414 -0
  35. package/admin/src/utils/mediaLibraryCardStore.ts +223 -0
  36. package/admin/src/utils/mediaLibraryQueryBridge.ts +113 -0
  37. package/admin/src/utils/mediaLibraryRoute.ts +9 -0
  38. package/admin/src/utils/optimizationFields.ts +17 -0
  39. package/admin/src/utils/probeVideoDimensions.ts +94 -0
  40. package/admin/src/utils/uploadAssetStore.ts +670 -0
  41. package/admin/tsconfig.json +8 -0
  42. package/dist/admin/SettingsPage-CN2fR83m.js +150 -0
  43. package/dist/admin/SettingsPage-D6e536P0.mjs +150 -0
  44. package/dist/admin/en-CqM903j3.js +77 -0
  45. package/dist/admin/en-CsHicGzL.mjs +77 -0
  46. package/dist/admin/index-BjWoS0YU.js +2542 -0
  47. package/dist/admin/index-Cs_uiChW.mjs +2541 -0
  48. package/dist/admin/index-DOuHOS2G.js +8799 -0
  49. package/dist/admin/index-rAmxCQz6.mjs +8781 -0
  50. package/dist/admin/index.js +4 -0
  51. package/dist/admin/index.mjs +4 -0
  52. package/dist/admin/tr-Y0-ANilh.mjs +77 -0
  53. package/dist/admin/tr-muzHkdC4.js +77 -0
  54. package/dist/server/index.js +1538 -0
  55. package/dist/server/index.mjs +1533 -0
  56. package/package.json +100 -0
  57. package/server/index.js +1 -0
  58. package/server/src/bootstrap.ts +377 -0
  59. package/server/src/buildVersion.ts +1 -0
  60. package/server/src/config/defaults.ts +91 -0
  61. package/server/src/config/index.ts +51 -0
  62. package/server/src/constants.ts +83 -0
  63. package/server/src/controllers/index.ts +7 -0
  64. package/server/src/controllers/job.ts +102 -0
  65. package/server/src/controllers/preference.ts +206 -0
  66. package/server/src/index.ts +15 -0
  67. package/server/src/register.ts +19 -0
  68. package/server/src/routes/index.ts +103 -0
  69. package/server/src/services/index.ts +9 -0
  70. package/server/src/services/job-queue.ts +663 -0
  71. package/server/src/services/optimizer.ts +284 -0
  72. package/server/src/services/preference.ts +172 -0
  73. package/server/src/utils/request-context.ts +7 -0
  74. package/server/src/utils/upload-preferences-context.ts +202 -0
  75. package/server/tsconfig.json +8 -0
  76. package/strapi-admin.js +7 -0
  77. package/strapi-server.js +7 -0
@@ -0,0 +1,249 @@
1
+ import React, { useSyncExternalStore, type MouseEvent, type PointerEvent } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import {
4
+ Box,
5
+ Button,
6
+ Flex,
7
+ IconButton,
8
+ Typography,
9
+ } from '@strapi/design-system';
10
+ import { Cross, Sparkle, Stop } from '@strapi/icons';
11
+ import { useIntl } from 'react-intl';
12
+ import { OptimizationChoicePicker } from './OptimizationChoicePicker';
13
+ import { OptimizationCustomForm } from './OptimizationVideoFields';
14
+ import { getTranslationKey } from '../pluginId';
15
+ import {
16
+ getActiveJobFileIds,
17
+ subscribeJobProgress,
18
+ } from '../utils/jobProgressStore';
19
+ import {
20
+ cancelMediaLibraryJob,
21
+ closeMediaLibraryEditor,
22
+ createCustomForMediaLibraryFile,
23
+ getEditingMediaLibraryDimensions,
24
+ getEditingMediaLibraryFileId,
25
+ getEditingMediaLibraryFileName,
26
+ getMediaLibraryCards,
27
+ getMediaLibraryDraftPreference,
28
+ getMediaLibraryStoreRevision,
29
+ isMediaLibraryCancelInFlight,
30
+ isMediaLibraryEnqueueInFlight,
31
+ openMediaLibraryEditor,
32
+ saveMediaLibraryEditor,
33
+ setMediaLibraryDraftChoice,
34
+ setMediaLibraryDraftCustom,
35
+ subscribeMediaLibraryCards,
36
+ } from '../utils/mediaLibraryCardStore';
37
+
38
+ const stopEventPropagation = (event: MouseEvent | PointerEvent) => {
39
+ event.stopPropagation();
40
+ };
41
+
42
+ export const MediaLibraryCardActionsBridge = () => {
43
+ const { formatMessage } = useIntl();
44
+ const cards = useSyncExternalStore(subscribeMediaLibraryCards, getMediaLibraryCards);
45
+ const activeJobFileIds = useSyncExternalStore(subscribeJobProgress, getActiveJobFileIds);
46
+ const editingFileId = useSyncExternalStore(subscribeMediaLibraryCards, getEditingMediaLibraryFileId);
47
+ const editingFileName = useSyncExternalStore(subscribeMediaLibraryCards, getEditingMediaLibraryFileName);
48
+ const editingDimensions = useSyncExternalStore(
49
+ subscribeMediaLibraryCards,
50
+ getEditingMediaLibraryDimensions
51
+ );
52
+ const draftPreference = useSyncExternalStore(
53
+ subscribeMediaLibraryCards,
54
+ getMediaLibraryDraftPreference
55
+ );
56
+ const enqueueInFlight = useSyncExternalStore(
57
+ subscribeMediaLibraryCards,
58
+ isMediaLibraryEnqueueInFlight
59
+ );
60
+ useSyncExternalStore(subscribeMediaLibraryCards, getMediaLibraryStoreRevision);
61
+
62
+ const activeJobIdSet = React.useMemo(
63
+ () => new Set(activeJobFileIds),
64
+ [activeJobFileIds]
65
+ );
66
+
67
+ const canEnqueue = draftPreference.choice !== 'original';
68
+
69
+ const resolvedCustom =
70
+ draftPreference.choice === 'custom'
71
+ ? draftPreference.custom ?? createCustomForMediaLibraryFile()
72
+ : undefined;
73
+
74
+ const editorPanel =
75
+ editingFileId !== null ? (
76
+ <Box
77
+ position="fixed"
78
+ top={0}
79
+ left={0}
80
+ right={0}
81
+ bottom={0}
82
+ style={{ zIndex: 100, pointerEvents: 'auto' }}
83
+ onPointerDown={stopEventPropagation}
84
+ onMouseDown={stopEventPropagation}
85
+ onClick={stopEventPropagation}
86
+ >
87
+ <Box
88
+ position="absolute"
89
+ top={0}
90
+ left={0}
91
+ right={0}
92
+ bottom={0}
93
+ background="neutral800"
94
+ style={{ opacity: 0.2 }}
95
+ onClick={closeMediaLibraryEditor}
96
+ />
97
+
98
+ <Flex
99
+ direction="column"
100
+ alignItems="stretch"
101
+ background="neutral0"
102
+ hasRadius
103
+ shadow="popupShadow"
104
+ style={{
105
+ position: 'fixed',
106
+ top: '50%',
107
+ left: '50%',
108
+ transform: 'translate(-50%, -50%)',
109
+ width: 'min(520px, calc(100% - 32px))',
110
+ maxHeight: 'min(90vh, 640px)',
111
+ overflow: 'hidden',
112
+ zIndex: 101,
113
+ }}
114
+ >
115
+ <Flex
116
+ tag="header"
117
+ padding={4}
118
+ paddingLeft={5}
119
+ paddingRight={5}
120
+ background="neutral100"
121
+ justifyContent="space-between"
122
+ alignItems="center"
123
+ borderColor="neutral150"
124
+ borderWidth="0 0 1px"
125
+ borderStyle="solid"
126
+ >
127
+ <Typography variant="omega" fontWeight="bold" textColor="neutral800">
128
+ {formatMessage({ id: getTranslationKey('mediaLibrary.modal.title') })}
129
+ </Typography>
130
+ <IconButton
131
+ label={formatMessage({ id: getTranslationKey('upload.modal.cancel') })}
132
+ onClick={closeMediaLibraryEditor}
133
+ >
134
+ <Cross />
135
+ </IconButton>
136
+ </Flex>
137
+
138
+ <Box padding={7} style={{ overflow: 'auto' }}>
139
+ <Flex direction="column" alignItems="stretch" gap={5}>
140
+ {editingFileName && (
141
+ <Typography variant="pi" textColor="neutral600">
142
+ {editingFileName}
143
+ </Typography>
144
+ )}
145
+ <OptimizationChoicePicker
146
+ value={draftPreference.choice}
147
+ onChange={setMediaLibraryDraftChoice}
148
+ />
149
+ {draftPreference.choice === 'custom' && (
150
+ <Box background="neutral100" padding={5} hasRadius>
151
+ <OptimizationCustomForm
152
+ value={resolvedCustom ?? createCustomForMediaLibraryFile()}
153
+ onChange={setMediaLibraryDraftCustom}
154
+ sourceWidth={editingDimensions?.width}
155
+ sourceHeight={editingDimensions?.height}
156
+ />
157
+ </Box>
158
+ )}
159
+ </Flex>
160
+ </Box>
161
+
162
+ <Flex
163
+ tag="footer"
164
+ gap={2}
165
+ justifyContent="flex-end"
166
+ padding={4}
167
+ paddingLeft={5}
168
+ paddingRight={5}
169
+ background="neutral100"
170
+ borderColor="neutral150"
171
+ borderWidth="1px 0 0"
172
+ borderStyle="solid"
173
+ >
174
+ <Button onClick={closeMediaLibraryEditor} variant="tertiary">
175
+ {formatMessage({ id: getTranslationKey('upload.modal.cancel') })}
176
+ </Button>
177
+ <Button
178
+ onClick={() => {
179
+ void saveMediaLibraryEditor();
180
+ }}
181
+ disabled={!canEnqueue || enqueueInFlight}
182
+ loading={enqueueInFlight}
183
+ >
184
+ {formatMessage({ id: getTranslationKey('mediaLibrary.modal.start') })}
185
+ </Button>
186
+ </Flex>
187
+ </Flex>
188
+ </Box>
189
+ ) : null;
190
+
191
+ return (
192
+ <>
193
+ {cards.map((card) => {
194
+ const hasActiveJob = activeJobIdSet.has(card.fileId);
195
+ const cancelInFlight = isMediaLibraryCancelInFlight(card.fileId);
196
+
197
+ if (!card.optimizeHost.isConnected || !card.cancelHost.isConnected) {
198
+ return null;
199
+ }
200
+
201
+ return (
202
+ <React.Fragment key={card.fileId}>
203
+ {!hasActiveJob
204
+ ? createPortal(
205
+ <IconButton
206
+ label={formatMessage({
207
+ id: getTranslationKey('mediaLibrary.button.optimize'),
208
+ })}
209
+ onClick={(event) => {
210
+ event.preventDefault();
211
+ event.stopPropagation();
212
+ openMediaLibraryEditor(card.fileId, card.fileName, {
213
+ width: card.width,
214
+ height: card.height,
215
+ });
216
+ }}
217
+ >
218
+ <Sparkle />
219
+ </IconButton>,
220
+ card.optimizeHost
221
+ )
222
+ : null}
223
+
224
+ {hasActiveJob
225
+ ? createPortal(
226
+ <IconButton
227
+ label={formatMessage({
228
+ id: getTranslationKey('mediaLibrary.button.cancel'),
229
+ })}
230
+ onClick={(event) => {
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ void cancelMediaLibraryJob(card.fileId);
234
+ }}
235
+ disabled={cancelInFlight}
236
+ >
237
+ <Stop />
238
+ </IconButton>,
239
+ card.cancelHost
240
+ )
241
+ : null}
242
+ </React.Fragment>
243
+ );
244
+ })}
245
+
246
+ {editorPanel ? createPortal(editorPanel, document.body) : null}
247
+ </>
248
+ );
249
+ };
@@ -0,0 +1,136 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import { useDispatch } from 'react-redux';
4
+ import { adminApi, useFetchClient, useNotification } from '@strapi/strapi/admin';
5
+ import { useIntl } from 'react-intl';
6
+ import { getTranslationKey, type VideoOptimizerJob } from '../pluginId';
7
+ import { isMediaLibraryPath } from '../utils/mediaLibraryRoute';
8
+ import { syncMediaLibraryProgress } from '../utils/initMediaLibraryProgress';
9
+ import { setWatchedJobs } from '../utils/jobProgressStore';
10
+
11
+ const POLL_INTERVAL_MS = 3000;
12
+
13
+ const invalidateMediaLibrary = (dispatch: ReturnType<typeof useDispatch>, fileId?: number) => {
14
+ dispatch(
15
+ adminApi.util.invalidateTags([
16
+ { type: 'Asset', id: 'LIST' },
17
+ ...(fileId ? [{ type: 'Asset' as const, id: fileId }] : []),
18
+ { type: 'Folder', id: 'LIST' },
19
+ ])
20
+ );
21
+ };
22
+
23
+ export const MediaLibraryJobWatcher = () => {
24
+ const { formatMessage } = useIntl();
25
+ const { get } = useFetchClient();
26
+ const { toggleNotification } = useNotification();
27
+ const dispatch = useDispatch();
28
+ const location = useLocation();
29
+ const trackedJobs = useRef(new Map<string, VideoOptimizerJob>());
30
+ const notifiedCompletedJobs = useRef(new Set<string>());
31
+ const hadActiveJobs = useRef(false);
32
+ const pollInFlight = useRef(false);
33
+
34
+ const isMediaLibrary = isMediaLibraryPath(location.pathname);
35
+
36
+ useEffect(() => {
37
+ if (!isMediaLibrary) {
38
+ setWatchedJobs([]);
39
+ hadActiveJobs.current = false;
40
+ return;
41
+ }
42
+
43
+ let cancelled = false;
44
+
45
+ const poll = async () => {
46
+ if (pollInFlight.current) {
47
+ return;
48
+ }
49
+
50
+ pollInFlight.current = true;
51
+
52
+ try {
53
+ const { data } = await get<{ jobs?: VideoOptimizerJob[] }>('/video-optimizer/jobs/active');
54
+ const jobs = data?.jobs ?? [];
55
+
56
+ if (cancelled) {
57
+ return;
58
+ }
59
+
60
+ setWatchedJobs(jobs);
61
+ syncMediaLibraryProgress();
62
+
63
+ if (jobs.length > 0) {
64
+ hadActiveJobs.current = true;
65
+ } else if (hadActiveJobs.current) {
66
+ invalidateMediaLibrary(dispatch);
67
+ hadActiveJobs.current = false;
68
+ }
69
+
70
+ const activeIds = new Set(jobs.map((job) => job.id));
71
+
72
+ for (const [jobId, previous] of trackedJobs.current.entries()) {
73
+ if (activeIds.has(jobId)) {
74
+ continue;
75
+ }
76
+
77
+ if (previous.status !== 'queued' && previous.status !== 'processing') {
78
+ trackedJobs.current.delete(jobId);
79
+ continue;
80
+ }
81
+
82
+ const { data: finishedJob } = await get<VideoOptimizerJob>(`/video-optimizer/jobs/${jobId}`);
83
+
84
+ if (cancelled || !finishedJob) {
85
+ continue;
86
+ }
87
+
88
+ if (finishedJob.status === 'completed' && !notifiedCompletedJobs.current.has(jobId)) {
89
+ notifiedCompletedJobs.current.add(jobId);
90
+ invalidateMediaLibrary(dispatch, finishedJob.fileId);
91
+
92
+ toggleNotification({
93
+ type: 'success',
94
+ message: formatMessage(
95
+ { id: getTranslationKey('jobs.notification.completed') },
96
+ { fileId: finishedJob.fileId, progress: finishedJob.progress }
97
+ ),
98
+ });
99
+ } else if (finishedJob.status === 'failed' && !notifiedCompletedJobs.current.has(jobId)) {
100
+ notifiedCompletedJobs.current.add(jobId);
101
+ toggleNotification({
102
+ type: 'danger',
103
+ message: formatMessage(
104
+ { id: getTranslationKey('jobs.notification.failed') },
105
+ { error: finishedJob.error ?? 'Unknown error' }
106
+ ),
107
+ });
108
+ }
109
+
110
+ trackedJobs.current.delete(jobId);
111
+ }
112
+
113
+ for (const job of jobs) {
114
+ trackedJobs.current.set(job.id, job);
115
+ }
116
+ } catch {
117
+ // Ignore polling errors.
118
+ } finally {
119
+ pollInFlight.current = false;
120
+ }
121
+ };
122
+
123
+ void poll();
124
+ const timer = window.setInterval(() => {
125
+ void poll();
126
+ }, POLL_INTERVAL_MS);
127
+
128
+ return () => {
129
+ cancelled = true;
130
+ window.clearInterval(timer);
131
+ setWatchedJobs([]);
132
+ };
133
+ }, [dispatch, formatMessage, get, isMediaLibrary, toggleNotification]);
134
+
135
+ return null;
136
+ };
@@ -0,0 +1,97 @@
1
+ import React, { useSyncExternalStore } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Box, Flex, Loader, ProgressBar, Typography } from '@strapi/design-system';
4
+ import { useIntl } from 'react-intl';
5
+ import { styled } from 'styled-components';
6
+ import { getTranslationKey, type VideoOptimizerJob } from '../pluginId';
7
+ import { getProgressEntries, subscribeJobProgress } from '../utils/jobProgressStore';
8
+
9
+ const ProgressSection = styled(Box)`
10
+ width: 100%;
11
+ margin-top: ${({ theme }) => theme.spaces[3]};
12
+ border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
13
+ background: transparent;
14
+ `;
15
+
16
+ const ProgressHeader = styled(Flex)`
17
+ padding-top: ${({ theme }) => theme.spaces[3]};
18
+ padding-bottom: ${({ theme }) => theme.spaces[2]};
19
+ `;
20
+
21
+ const StyledProgressBar = styled(ProgressBar)`
22
+ width: 100%;
23
+
24
+ & > div {
25
+ background-color: ${({ theme }) => theme.colors.primary600};
26
+ }
27
+ `;
28
+
29
+ const QueuedLoader = styled(Loader)`
30
+ width: 20px;
31
+ height: 20px;
32
+ margin-top: -5px;
33
+ margin-bottom: -5px;
34
+
35
+ svg {
36
+ width: 20px;
37
+ height: 20px;
38
+ }
39
+ `;
40
+
41
+ const JobProgressBadge = ({ job }: { job: VideoOptimizerJob }) => {
42
+ const { formatMessage } = useIntl();
43
+
44
+ const isQueued = job.status === 'queued';
45
+
46
+ const stageLabel =
47
+ !isQueued && job.stage && job.status === 'processing'
48
+ ? formatMessage({
49
+ id: getTranslationKey(`jobs.stage.${job.stage}`),
50
+ defaultMessage: job.stage,
51
+ })
52
+ : null;
53
+
54
+ const formatLabel = job.settings?.defaultFormat?.toUpperCase();
55
+
56
+ return (
57
+ <ProgressSection data-video-optimizer-progress={job.fileId}>
58
+ <ProgressHeader justifyContent="space-between" alignItems="center" gap={2}>
59
+ <Typography variant="pi" fontWeight="semiBold" textColor="neutral800">
60
+ {formatMessage(
61
+ { id: getTranslationKey('jobs.card.progress') },
62
+ { progress: job.progress, format: formatLabel ?? '—' }
63
+ )}
64
+ </Typography>
65
+
66
+ {isQueued ? (
67
+ <Flex alignItems="center" gap={2}>
68
+ <QueuedLoader small />
69
+ <Typography variant="pi" textColor="neutral600">
70
+ {formatMessage({ id: getTranslationKey('jobs.card.queued') })}
71
+ </Typography>
72
+ </Flex>
73
+ ) : (
74
+ stageLabel && (
75
+ <Typography variant="pi" textColor="neutral600">
76
+ {stageLabel}
77
+ </Typography>
78
+ )
79
+ )}
80
+ </ProgressHeader>
81
+
82
+ <StyledProgressBar value={job.progress} size="M" />
83
+ </ProgressSection>
84
+ );
85
+ };
86
+
87
+ export const MediaLibraryProgressBridge = () => {
88
+ const entries = useSyncExternalStore(subscribeJobProgress, getProgressEntries);
89
+
90
+ return (
91
+ <>
92
+ {entries.map((entry) =>
93
+ createPortal(<JobProgressBadge job={entry.job} />, entry.host, String(entry.fileId))
94
+ )}
95
+ </>
96
+ );
97
+ };
@@ -0,0 +1,65 @@
1
+ import {
2
+ Box,
3
+ Field,
4
+ Flex,
5
+ Radio,
6
+ Typography,
7
+ } from '@strapi/design-system';
8
+ import { useIntl } from 'react-intl';
9
+ import { getTranslationKey, type OptimizationChoice } from '../pluginId';
10
+
11
+ interface OptimizationChoicePickerProps {
12
+ value: OptimizationChoice;
13
+ onChange: (choice: OptimizationChoice) => void;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ const CHOICES: OptimizationChoice[] = ['original', 'global', 'custom'];
18
+
19
+ export const OptimizationChoicePicker = ({
20
+ value,
21
+ onChange,
22
+ disabled = false,
23
+ }: OptimizationChoicePickerProps) => {
24
+ const { formatMessage } = useIntl();
25
+
26
+ return (
27
+ <Field.Root name="optimizationChoice">
28
+ <Radio.Group
29
+ value={value}
30
+ onValueChange={(nextValue) => onChange(nextValue as OptimizationChoice)}
31
+ disabled={disabled}
32
+ >
33
+ <Flex direction="column" gap={3} alignItems="stretch">
34
+ {CHOICES.map((choice) => {
35
+ const selected = value === choice;
36
+
37
+ return (
38
+ <Box
39
+ key={choice}
40
+ padding={4}
41
+ hasRadius
42
+ background={selected ? 'primary100' : 'neutral100'}
43
+ onClick={() => {
44
+ if (!disabled) {
45
+ onChange(choice);
46
+ }
47
+ }}
48
+ style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
49
+ >
50
+ <Radio.Item value={choice} id={`optimizer-choice-${choice}`}>
51
+ {formatMessage({ id: getTranslationKey(`choice.${choice}`) })}
52
+ </Radio.Item>
53
+ <Box paddingLeft={6} paddingTop={1} style={{ pointerEvents: 'none' }}>
54
+ <Typography variant="pi" textColor="neutral600">
55
+ {formatMessage({ id: getTranslationKey(`choice.${choice}.description`) })}
56
+ </Typography>
57
+ </Box>
58
+ </Box>
59
+ );
60
+ })}
61
+ </Flex>
62
+ </Radio.Group>
63
+ </Field.Root>
64
+ );
65
+ };
@@ -0,0 +1,120 @@
1
+ import React from 'react';
2
+ import { Field, Grid, TextInput, Typography } from '@strapi/design-system';
3
+ import { useIntl } from 'react-intl';
4
+ import { getTranslationKey, type OptimizationSettings } from '../pluginId';
5
+
6
+ interface OptimizationResizeFieldsProps {
7
+ value: Pick<OptimizationSettings, 'maxWidth' | 'maxHeight'>;
8
+ sourceWidth?: number;
9
+ sourceHeight?: number;
10
+ onChange: (patch: Partial<OptimizationSettings>) => void;
11
+ disabled?: boolean;
12
+ namePrefix?: string;
13
+ variant?: 'custom' | 'global';
14
+ }
15
+
16
+ export const OptimizationResizeFields = ({
17
+ value,
18
+ sourceWidth,
19
+ sourceHeight,
20
+ onChange,
21
+ disabled = false,
22
+ namePrefix = '',
23
+ variant = 'custom',
24
+ }: OptimizationResizeFieldsProps) => {
25
+ const { formatMessage } = useIntl();
26
+
27
+ const aspectRatio =
28
+ sourceWidth && sourceHeight && sourceHeight > 0 ? sourceWidth / sourceHeight : null;
29
+
30
+ const handleWidthChange = (rawValue: string) => {
31
+ const nextWidth = Math.max(1, Number(rawValue) || 1);
32
+
33
+ if (!aspectRatio) {
34
+ onChange({ maxWidth: nextWidth });
35
+ return;
36
+ }
37
+
38
+ onChange({
39
+ maxWidth: nextWidth,
40
+ maxHeight: Math.max(1, Math.round(nextWidth / aspectRatio)),
41
+ });
42
+ };
43
+
44
+ const handleHeightChange = (rawValue: string) => {
45
+ const nextHeight = Math.max(1, Number(rawValue) || 1);
46
+
47
+ if (!aspectRatio) {
48
+ onChange({ maxHeight: nextHeight });
49
+ return;
50
+ }
51
+
52
+ onChange({
53
+ maxHeight: nextHeight,
54
+ maxWidth: Math.max(1, Math.round(nextHeight * aspectRatio)),
55
+ });
56
+ };
57
+
58
+ return (
59
+ <>
60
+ <Grid.Item col={12} direction="column" alignItems="stretch">
61
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
62
+ {formatMessage({
63
+ id: getTranslationKey('settings.resize.title'),
64
+ defaultMessage: 'Output dimensions',
65
+ })}
66
+ </Typography>
67
+ </Grid.Item>
68
+
69
+ <Grid.Item col={6} xs={12} direction="column" alignItems="stretch">
70
+ <Field.Root name={`${namePrefix}maxWidth`}>
71
+ <Field.Label>
72
+ {formatMessage({
73
+ id: getTranslationKey('settings.resize.width'),
74
+ defaultMessage: 'Width (px)',
75
+ })}
76
+ </Field.Label>
77
+ <TextInput
78
+ type="number"
79
+ min={1}
80
+ value={value.maxWidth}
81
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleWidthChange(e.target.value)}
82
+ disabled={disabled}
83
+ />
84
+ </Field.Root>
85
+ </Grid.Item>
86
+
87
+ <Grid.Item col={6} xs={12} direction="column" alignItems="stretch">
88
+ <Field.Root name={`${namePrefix}maxHeight`}>
89
+ <Field.Label>
90
+ {formatMessage({
91
+ id: getTranslationKey('settings.resize.height'),
92
+ defaultMessage: 'Height (px)',
93
+ })}
94
+ </Field.Label>
95
+ <TextInput
96
+ type="number"
97
+ min={1}
98
+ value={value.maxHeight}
99
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleHeightChange(e.target.value)}
100
+ disabled={disabled}
101
+ />
102
+ </Field.Root>
103
+ </Grid.Item>
104
+
105
+ <Grid.Item col={12} direction="column" alignItems="stretch">
106
+ <Typography variant="pi" textColor="neutral600">
107
+ {formatMessage({
108
+ id: getTranslationKey(
109
+ variant === 'global' ? 'settings.resize.globalHint' : 'settings.resize.hint'
110
+ ),
111
+ defaultMessage:
112
+ variant === 'global'
113
+ ? 'Videos larger than this are scaled down while preserving aspect ratio.'
114
+ : 'Defaults to the original video size. Change either value to resize; the other updates to keep aspect ratio.',
115
+ })}
116
+ </Typography>
117
+ </Grid.Item>
118
+ </>
119
+ );
120
+ };