@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,70 @@
1
+ import type { VideoOptimizerJob } from '../pluginId';
2
+ import { adminGet } from './adminFetch';
3
+ import { getProgressEntries, getWatchedJobs } from './jobProgressStore';
4
+ import { isMediaLibraryPath } from './mediaLibraryRoute';
5
+
6
+ const VIDEO_EXT_PATTERN =
7
+ /\.(mp4|webm|mov|avi|mkv|m4v|ogv|wmv|flv|3gp)/i;
8
+
9
+ const CARD_SELECTOR = [
10
+ 'article[role="button"]',
11
+ 'article[aria-labelledby]',
12
+ '[role="listitem"]',
13
+ '[class*="CardContainer"]',
14
+ ].join(', ');
15
+
16
+ const extractFileName = (card: Element): string | null => {
17
+ const titleNode = card.querySelector('[id$="-title"]');
18
+
19
+ if (titleNode?.textContent && VIDEO_EXT_PATTERN.test(titleNode.textContent)) {
20
+ return titleNode.textContent.trim();
21
+ }
22
+
23
+ const figcaption = card.querySelector('figcaption');
24
+
25
+ if (figcaption?.textContent && VIDEO_EXT_PATTERN.test(figcaption.textContent)) {
26
+ return figcaption.textContent.trim();
27
+ }
28
+
29
+ const match = card.textContent?.match(/[\w.-]+\.(mp4|webm|mov|avi|mkv|m4v|ogv|wmv|flv|3gp)/i);
30
+ return match?.[0] ?? null;
31
+ };
32
+
33
+ export const collectVideoCardsDebug = () => {
34
+ const root = document.querySelector('main') ?? document.body;
35
+ return Array.from(root.querySelectorAll(CARD_SELECTOR)).map((card) => ({
36
+ fileName: extractFileName(card),
37
+ hasProgressHost: Boolean(card.querySelector('[data-video-optimizer-progress-host]')),
38
+ fileIdStamp: card.getAttribute('data-video-optimizer-file-id'),
39
+ }));
40
+ };
41
+
42
+ export const debugMediaLibraryProgress = async () => {
43
+ const api = await adminGet<{ jobs?: VideoOptimizerJob[] }>('/video-optimizer/jobs/active');
44
+
45
+ return {
46
+ path: window.location.pathname,
47
+ isMediaLibraryRoute: isMediaLibraryPath(window.location.pathname),
48
+ watchedJobs: getWatchedJobs(),
49
+ progressEntries: getProgressEntries().map((entry) => ({
50
+ fileId: entry.fileId,
51
+ jobId: entry.job.id,
52
+ progress: entry.job.progress,
53
+ status: entry.job.status,
54
+ fileName: entry.job.fileName,
55
+ hostConnected: entry.host.isConnected,
56
+ })),
57
+ apiJobs: api?.jobs ?? null,
58
+ cards: collectVideoCardsDebug(),
59
+ bridgeMounted: Boolean(document.getElementById('video-optimizer-media-library-progress')),
60
+ };
61
+ };
62
+
63
+ export const installDebugMediaLibraryProgress = () => {
64
+ if (typeof window === 'undefined') {
65
+ return;
66
+ }
67
+
68
+ (window as Window & { __videoOptimizerDebug?: typeof debugMediaLibraryProgress }).__videoOptimizerDebug =
69
+ debugMediaLibraryProgress;
70
+ };
@@ -0,0 +1,22 @@
1
+ export const extractAssetDimensions = (card: Element) => {
2
+ const video = card.querySelector('video');
3
+
4
+ if (video instanceof HTMLVideoElement && video.videoWidth > 0 && video.videoHeight > 0) {
5
+ return { width: video.videoWidth, height: video.videoHeight };
6
+ }
7
+
8
+ const img = card.querySelector('img');
9
+
10
+ if (img instanceof HTMLImageElement && img.naturalWidth > 0 && img.naturalHeight > 0) {
11
+ return { width: img.naturalWidth, height: img.naturalHeight };
12
+ }
13
+
14
+ const cardText = card.textContent ?? '';
15
+ const match = cardText.match(/(\d+)\s*[×✕xX]\s*(\d+)/);
16
+
17
+ if (match) {
18
+ return { width: Number(match[1]), height: Number(match[2]) };
19
+ }
20
+
21
+ return undefined;
22
+ };
@@ -0,0 +1,173 @@
1
+ import type { VideoOptimizerJob } from '../pluginId';
2
+ import { adminGet } from './adminFetch';
3
+ import {
4
+ invalidateMediaLibraryCache,
5
+ } from './invalidateMediaLibrary';
6
+ import { syncMediaLibraryProgress } from './initMediaLibraryProgress';
7
+ import { syncMediaLibraryCardActions } from './initMediaLibraryCardActions';
8
+ import { setWatchedJobs } from './jobProgressStore';
9
+ import { isMediaLibraryPath } from './mediaLibraryRoute';
10
+
11
+ const ACTIVE_POLL_INTERVAL_MS = 3000;
12
+ const ROUTE_CHECK_INTERVAL_MS = 1000;
13
+
14
+ let activePollTimer: ReturnType<typeof setInterval> | null = null;
15
+ let routeCheckTimer: ReturnType<typeof setInterval> | null = null;
16
+ let started = false;
17
+ let pollInFlight = false;
18
+ let lastPathname = '';
19
+ const trackedJobs = new Map<string, VideoOptimizerJob>();
20
+
21
+ const isActiveJob = (job: VideoOptimizerJob) =>
22
+ job.status === 'queued' || job.status === 'processing';
23
+
24
+ const hasActiveJobs = (jobs: VideoOptimizerJob[]) => jobs.some(isActiveJob);
25
+
26
+ const stopActivePolling = () => {
27
+ if (!activePollTimer) {
28
+ return;
29
+ }
30
+
31
+ clearInterval(activePollTimer);
32
+ activePollTimer = null;
33
+ };
34
+
35
+ const startActivePolling = () => {
36
+ if (activePollTimer) {
37
+ return;
38
+ }
39
+
40
+ activePollTimer = setInterval(() => {
41
+ void pollActiveJobs();
42
+ }, ACTIVE_POLL_INTERVAL_MS);
43
+ };
44
+
45
+ const clearJobProgress = () => {
46
+ stopActivePolling();
47
+ trackedJobs.clear();
48
+ setWatchedJobs([]);
49
+ syncMediaLibraryProgress();
50
+ };
51
+
52
+ const handleFinishedJob = async (
53
+ jobId: string,
54
+ fallbackFileId: number,
55
+ remainingActiveCount: number
56
+ ) => {
57
+ const previous = trackedJobs.get(jobId);
58
+ trackedJobs.delete(jobId);
59
+
60
+ const finished = await adminGet<VideoOptimizerJob>(`/video-optimizer/jobs/${jobId}`);
61
+ const fileId = finished?.fileId ?? fallbackFileId;
62
+
63
+ await invalidateMediaLibraryCache(fileId || undefined, previous?.fileName, {
64
+ forceFullRefresh: remainingActiveCount === 0,
65
+ });
66
+ };
67
+
68
+ const reconcileFinishedJobs = async (activeJobs: VideoOptimizerJob[]) => {
69
+ const activeIds = new Set(activeJobs.map((job) => job.id));
70
+ const pendingFinished: Array<Promise<void>> = [];
71
+
72
+ for (const [jobId, previous] of trackedJobs.entries()) {
73
+ if (activeIds.has(jobId)) {
74
+ continue;
75
+ }
76
+
77
+ if (previous.status !== 'queued' && previous.status !== 'processing') {
78
+ trackedJobs.delete(jobId);
79
+ continue;
80
+ }
81
+
82
+ pendingFinished.push(
83
+ handleFinishedJob(jobId, previous.fileId, activeJobs.length)
84
+ );
85
+ }
86
+
87
+ if (pendingFinished.length) {
88
+ await Promise.all(pendingFinished);
89
+ }
90
+
91
+ for (const job of activeJobs) {
92
+ trackedJobs.set(job.id, job);
93
+ }
94
+ };
95
+
96
+ export const pollActiveJobs = async () => {
97
+ if (pollInFlight) {
98
+ return;
99
+ }
100
+
101
+ const pathname = window.location.pathname;
102
+
103
+ if (!isMediaLibraryPath(pathname)) {
104
+ clearJobProgress();
105
+ return;
106
+ }
107
+
108
+ pollInFlight = true;
109
+
110
+ try {
111
+ const data = await adminGet<{ jobs?: VideoOptimizerJob[] }>('/video-optimizer/jobs/active');
112
+ const jobs = data?.jobs ?? [];
113
+
114
+ await reconcileFinishedJobs(jobs);
115
+
116
+ if (!hasActiveJobs(jobs)) {
117
+ stopActivePolling();
118
+ trackedJobs.clear();
119
+ setWatchedJobs([]);
120
+ syncMediaLibraryProgress();
121
+ return;
122
+ }
123
+
124
+ setWatchedJobs(jobs);
125
+ syncMediaLibraryProgress();
126
+ startActivePolling();
127
+ } catch {
128
+ stopActivePolling();
129
+ } finally {
130
+ pollInFlight = false;
131
+ }
132
+ };
133
+
134
+ export const wakeJobPoller = () => {
135
+ void pollActiveJobs();
136
+ };
137
+
138
+ const handleRouteChange = (pathname: string) => {
139
+ if (pathname === lastPathname) {
140
+ return;
141
+ }
142
+
143
+ lastPathname = pathname;
144
+
145
+ if (isMediaLibraryPath(pathname)) {
146
+ void pollActiveJobs();
147
+ syncMediaLibraryCardActions();
148
+ return;
149
+ }
150
+
151
+ clearJobProgress();
152
+ };
153
+
154
+ export const initJobPoller = () => {
155
+ if (started || typeof window === 'undefined') {
156
+ return;
157
+ }
158
+
159
+ started = true;
160
+ lastPathname = window.location.pathname;
161
+
162
+ window.addEventListener('popstate', () => {
163
+ handleRouteChange(window.location.pathname);
164
+ });
165
+
166
+ routeCheckTimer = setInterval(() => {
167
+ handleRouteChange(window.location.pathname);
168
+ }, ROUTE_CHECK_INTERVAL_MS);
169
+
170
+ if (isMediaLibraryPath(lastPathname)) {
171
+ void pollActiveJobs();
172
+ }
173
+ };
@@ -0,0 +1,308 @@
1
+ import {
2
+ collectVideoCards,
3
+ isVideoAssetCard,
4
+ prepareCardsForMatching,
5
+ resolveFileIdForCard,
6
+ } from './mediaLibraryCardMatch';
7
+ import { setMediaLibraryCards, type MediaLibraryCardEntry } from './mediaLibraryCardStore';
8
+ import { ensureUploadAssets, type UploadAssetRecord } from './mediaLibraryQueryBridge';
9
+ import { isMediaLibraryPath } from './mediaLibraryRoute';
10
+ import { extractAssetDimensions } from './extractAssetDimensions';
11
+
12
+ let domObserver: MutationObserver | null = null;
13
+ let domObserverTimer: ReturnType<typeof setTimeout> | null = null;
14
+ let started = false;
15
+ let syncInFlight = false;
16
+
17
+ const isMediaLibraryRoute = () => isMediaLibraryPath(window.location.pathname);
18
+
19
+ const getButtonLabel = (button: Element) => {
20
+ const aria = button.getAttribute('aria-label') ?? '';
21
+ const text = button.textContent ?? '';
22
+ const title = button.getAttribute('title') ?? '';
23
+
24
+ return `${aria} ${text} ${title}`.toLowerCase();
25
+ };
26
+
27
+ const isEditButton = (button: Element) => {
28
+ const label = getButtonLabel(button);
29
+
30
+ return label.includes('edit') || label.includes('düzenle');
31
+ };
32
+
33
+ const isPluginActionButton = (button: Element) => {
34
+ const label = getButtonLabel(button);
35
+
36
+ return (
37
+ label.includes('optimization') ||
38
+ label.includes('optimizasyon') ||
39
+ label.includes('cancel optimization') ||
40
+ label.includes('optimizasyonu iptal')
41
+ );
42
+ };
43
+
44
+ const isInsideDialog = (element: Element) =>
45
+ Boolean(element.closest('[role="dialog"], [aria-modal="true"]'));
46
+
47
+ const isPluginMutationNode = (node: Node) => {
48
+ if (!(node instanceof Element)) {
49
+ return false;
50
+ }
51
+
52
+ return Boolean(
53
+ node.id === 'video-optimizer-media-library-card-actions' ||
54
+ node.closest(
55
+ '#video-optimizer-media-library-card-actions, [data-video-optimizer-ml-optimize-host], [data-video-optimizer-ml-cancel-host]'
56
+ )
57
+ );
58
+ };
59
+
60
+ const shouldSyncForMutations = (mutations: MutationRecord[]) =>
61
+ mutations.some((mutation) => {
62
+ if (isPluginMutationNode(mutation.target)) {
63
+ return false;
64
+ }
65
+
66
+ for (const node of mutation.addedNodes) {
67
+ if (!isPluginMutationNode(node)) {
68
+ return true;
69
+ }
70
+ }
71
+
72
+ for (const node of mutation.removedNodes) {
73
+ if (!isPluginMutationNode(node)) {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ return mutation.type === 'characterData';
79
+ });
80
+
81
+ const findCardRoot = (button: Element) => {
82
+ let element = button.parentElement;
83
+ const root = document.querySelector('main') ?? document.body;
84
+
85
+ while (element && element !== root) {
86
+ if (element.getAttribute('role') === 'button') {
87
+ return element;
88
+ }
89
+
90
+ if (element.tagName === 'ARTICLE' || element.querySelector('[id$="-title"], h2')) {
91
+ return element;
92
+ }
93
+
94
+ element = element.parentElement;
95
+ }
96
+
97
+ return null;
98
+ };
99
+
100
+ const ensureHost = (
101
+ actionsContainer: HTMLElement,
102
+ afterElement: Element,
103
+ attribute: 'data-video-optimizer-ml-optimize-host' | 'data-video-optimizer-ml-cancel-host',
104
+ fileId: number
105
+ ) => {
106
+ const selector = `[${attribute}="${fileId}"]`;
107
+ let host = actionsContainer.querySelector(selector) as HTMLElement | null;
108
+
109
+ if (!host) {
110
+ host = document.createElement('span');
111
+ host.setAttribute(attribute, String(fileId));
112
+ afterElement.insertAdjacentElement('afterend', host);
113
+ }
114
+
115
+ host.style.cssText = 'display:contents;';
116
+
117
+ return host;
118
+ };
119
+
120
+ const cleanupCardActionHosts = (activeFileIds: Set<number>) => {
121
+ for (const host of document.querySelectorAll('[data-video-optimizer-ml-optimize-host]')) {
122
+ const fileId = Number(host.getAttribute('data-video-optimizer-ml-optimize-host'));
123
+
124
+ if (!activeFileIds.has(fileId)) {
125
+ host.remove();
126
+ }
127
+ }
128
+
129
+ for (const host of document.querySelectorAll('[data-video-optimizer-ml-cancel-host]')) {
130
+ const fileId = Number(host.getAttribute('data-video-optimizer-ml-cancel-host'));
131
+
132
+ if (!activeFileIds.has(fileId)) {
133
+ host.remove();
134
+ }
135
+ }
136
+ };
137
+
138
+ const collectCardActions = (uploadAssets: UploadAssetRecord[]): MediaLibraryCardEntry[] => {
139
+ if (!isMediaLibraryRoute()) {
140
+ return [];
141
+ }
142
+
143
+ const cards = collectVideoCards();
144
+ prepareCardsForMatching(cards);
145
+ const entries: MediaLibraryCardEntry[] = [];
146
+ const seenCards = new Set<Element>();
147
+ const seenFileIds = new Set<number>();
148
+ const root = document.querySelector('main') ?? document.body;
149
+
150
+ root.querySelectorAll('button').forEach((button) => {
151
+ if (isInsideDialog(button) || !isEditButton(button) || isPluginActionButton(button)) {
152
+ return;
153
+ }
154
+
155
+ const card = findCardRoot(button);
156
+
157
+ if (!card || seenCards.has(card) || !isVideoAssetCard(card)) {
158
+ return;
159
+ }
160
+
161
+ const fileId = resolveFileIdForCard(card, uploadAssets);
162
+
163
+ if (!fileId || seenFileIds.has(fileId)) {
164
+ return;
165
+ }
166
+
167
+ const actionsContainer = button.parentElement as HTMLElement | null;
168
+
169
+ if (!actionsContainer) {
170
+ return;
171
+ }
172
+
173
+ seenCards.add(card);
174
+ seenFileIds.add(fileId);
175
+
176
+ const htmlCard = card as HTMLElement;
177
+ htmlCard.dataset.videoOptimizerFileId = String(fileId);
178
+
179
+ const asset = uploadAssets.find((item) => item.id === fileId);
180
+
181
+ if (asset?.hash) {
182
+ htmlCard.dataset.videoOptimizerMediaHash = asset.hash;
183
+ }
184
+
185
+ const optimizeHost = ensureHost(
186
+ actionsContainer,
187
+ button,
188
+ 'data-video-optimizer-ml-optimize-host',
189
+ fileId
190
+ );
191
+ const cancelHost = ensureHost(
192
+ actionsContainer,
193
+ optimizeHost,
194
+ 'data-video-optimizer-ml-cancel-host',
195
+ fileId
196
+ );
197
+
198
+ const dimensions = extractAssetDimensions(card);
199
+
200
+ entries.push({
201
+ fileId,
202
+ fileName: asset?.name ?? card.querySelector('[id$="-title"]')?.textContent?.trim() ?? '',
203
+ width: dimensions?.width,
204
+ height: dimensions?.height,
205
+ optimizeHost,
206
+ cancelHost,
207
+ });
208
+ });
209
+
210
+ return entries;
211
+ };
212
+
213
+ const syncDomObserver = () => {
214
+ if (!isMediaLibraryRoute()) {
215
+ domObserver?.disconnect();
216
+ domObserver = null;
217
+ return;
218
+ }
219
+
220
+ if (!collectVideoCards().length) {
221
+ domObserver?.disconnect();
222
+ domObserver = null;
223
+ return;
224
+ }
225
+
226
+ if (domObserver) {
227
+ return;
228
+ }
229
+
230
+ const root = document.querySelector('main') ?? document.body;
231
+
232
+ domObserver = new MutationObserver((mutations) => {
233
+ if (!shouldSyncForMutations(mutations)) {
234
+ return;
235
+ }
236
+
237
+ if (domObserverTimer) {
238
+ return;
239
+ }
240
+
241
+ domObserverTimer = setTimeout(() => {
242
+ domObserverTimer = null;
243
+ syncCardActions();
244
+ }, 250);
245
+ });
246
+
247
+ domObserver.observe(root, { childList: true, subtree: true });
248
+ };
249
+
250
+ const syncCardActions = async () => {
251
+ if (syncInFlight) {
252
+ return;
253
+ }
254
+
255
+ syncInFlight = true;
256
+
257
+ try {
258
+ if (!isMediaLibraryRoute()) {
259
+ setMediaLibraryCards([]);
260
+ domObserver?.disconnect();
261
+ domObserver = null;
262
+ return;
263
+ }
264
+
265
+ const videoCards = collectVideoCards();
266
+
267
+ if (!videoCards.length) {
268
+ cleanupCardActionHosts(new Set());
269
+ setMediaLibraryCards([]);
270
+ domObserver?.disconnect();
271
+ domObserver = null;
272
+ return;
273
+ }
274
+
275
+ prepareCardsForMatching(videoCards);
276
+ const uploadAssets = await ensureUploadAssets();
277
+ const entries = collectCardActions(uploadAssets);
278
+ cleanupCardActionHosts(new Set(entries.map((entry) => entry.fileId)));
279
+ setMediaLibraryCards(entries);
280
+ syncDomObserver();
281
+ } finally {
282
+ syncInFlight = false;
283
+ }
284
+ };
285
+
286
+ export const syncMediaLibraryCardActions = () => {
287
+ void syncCardActions();
288
+ };
289
+
290
+ export const initMediaLibraryCardActions = () => {
291
+ if (started || typeof window === 'undefined') {
292
+ return;
293
+ }
294
+
295
+ started = true;
296
+
297
+ const boot = () => {
298
+ if (isMediaLibraryRoute()) {
299
+ void syncCardActions();
300
+ }
301
+ };
302
+
303
+ if (document.readyState === 'loading') {
304
+ document.addEventListener('DOMContentLoaded', boot);
305
+ } else {
306
+ boot();
307
+ }
308
+ };