strapi-dz-component-duplicator 0.1.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/LICENSE +21 -0
- package/README.md +52 -0
- package/admin/src/components/DynamicZoneActionInjector.mjs +403 -0
- package/admin/src/index.mjs +37 -0
- package/admin/src/pluginId.mjs +1 -0
- package/assets/dynamicZoneDuplicate.png +0 -0
- package/assets/dynamicZoneDuplicateCloseUp.png +0 -0
- package/package.json +65 -0
- package/server/src/index.js +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Djordje Savanovic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# strapi-dz-component-duplicator
|
|
2
|
+
|
|
3
|
+
A Strapi 5 admin plugin that adds a **Duplicate** button to every dynamic zone block in the Content Manager edit view, so editors can clone a block in one click and then tweak only what changes.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
In content types that use [dynamic zones](https://docs.strapi.io/dev-docs/backend-customization/models#dynamic-zones), each block (e.g. a "Quote", "Image", or "CTA" component) gets an action row with delete, drag, and more actions. This plugin injects a **Duplicate** action in that same row.
|
|
8
|
+
|
|
9
|
+
- **One-click clone** — Duplicate creates a full copy of the block (including nested components and media references) and inserts it right after the current block.
|
|
10
|
+
- **Clean copy** — Transient fields like `id`, `documentId`, and `__temp_key__` are stripped so Strapi treats the duplicate as a new entry.
|
|
11
|
+
- **No extra steps** — Works in the existing edit view; no new screens or modals.
|
|
12
|
+
|
|
13
|
+
Useful when you have several similar blocks (e.g. multiple testimonials or CTAs): duplicate once, then change only the differing fields instead of re-adding the component and re-entering everything.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install strapi-dz-component-duplicator
|
|
23
|
+
# or
|
|
24
|
+
pnpm add strapi-dz-component-duplicator
|
|
25
|
+
# or
|
|
26
|
+
yarn add strapi-dz-component-duplicator
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Enable in `config/plugins.ts`:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
export default () => ({
|
|
33
|
+
'strapi-dz-component-duplicator': {
|
|
34
|
+
enabled: true,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then rebuild admin:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm run build
|
|
43
|
+
# or
|
|
44
|
+
npm run develop
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Duplicate button only appears on edit views for content types that have at least one dynamic zone field. If you don’t see it, confirm the plugin is enabled, the admin was rebuilt after installing, and you’re editing an entry whose content type uses a dynamic zone.
|
|
48
|
+
|
|
49
|
+
## Compatibility
|
|
50
|
+
|
|
51
|
+
- Strapi: `^5.0.0`
|
|
52
|
+
- Node: `>=20.0.0 <=24.x.x`
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useNotification } from '@strapi/admin/strapi-admin';
|
|
3
|
+
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/content-manager/strapi-admin';
|
|
4
|
+
import { useIntl } from 'react-intl';
|
|
5
|
+
|
|
6
|
+
const DUPLICATE_CONTAINER_ATTR = 'data-dz-component-duplicator-action';
|
|
7
|
+
const INDEX_SEGMENT_REGEX = /^\d+$/;
|
|
8
|
+
const DUPLICATE_ICON_PATH =
|
|
9
|
+
'M27 4H11a1 1 0 0 0-1 1v5H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1m-1 16h-4v-9a1 1 0 0 0-1-1h-9V6h14z';
|
|
10
|
+
|
|
11
|
+
const isPlainObject = (value) => {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const getIn = (source, dottedPath) => {
|
|
16
|
+
if (!dottedPath) {
|
|
17
|
+
return source;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const segments = dottedPath.split('.');
|
|
21
|
+
let current = source;
|
|
22
|
+
|
|
23
|
+
for (const segment of segments) {
|
|
24
|
+
if (current === null || current === undefined) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (INDEX_SEGMENT_REGEX.test(segment)) {
|
|
29
|
+
if (!Array.isArray(current)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
current = current[Number(segment)];
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return current;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isDynamicZoneItem = (value) => {
|
|
44
|
+
return isPlainObject(value) && typeof value.__component === 'string';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const collectDynamicZonePaths = (value, currentPath = '', acc = []) => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
if (currentPath && value.length > 0 && value.every(isDynamicZoneItem)) {
|
|
50
|
+
acc.push(currentPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
54
|
+
const nestedPath = currentPath ? `${currentPath}.${index}` : `${index}`;
|
|
55
|
+
collectDynamicZonePaths(value[index], nestedPath, acc);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return acc;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!isPlainObject(value)) {
|
|
62
|
+
return acc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
66
|
+
const nestedPath = currentPath ? `${currentPath}.${key}` : key;
|
|
67
|
+
collectDynamicZonePaths(nested, nestedPath, acc);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return acc;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const cloneValue = (value) => {
|
|
74
|
+
if (typeof structuredClone === 'function') {
|
|
75
|
+
return structuredClone(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return JSON.parse(JSON.stringify(value));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const stripTransientKeys = (value) => {
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
return value.map(stripTransientKeys);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!isPlainObject(value)) {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const next = {};
|
|
91
|
+
|
|
92
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
93
|
+
if (key === 'id' || key === 'documentId' || key === '__temp_key__') {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
next[key] = stripTransientKeys(nested);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return next;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const getActionAnchor = (listItem) => {
|
|
104
|
+
const header = listItem.querySelector('h3');
|
|
105
|
+
|
|
106
|
+
if (!header) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const buttons = header.querySelectorAll('button');
|
|
111
|
+
|
|
112
|
+
// Dynamic zone headers include delete, drag and more-actions controls.
|
|
113
|
+
// Repeatable components usually don't include the extra menu action.
|
|
114
|
+
if (buttons.length < 3) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return buttons[1];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const findDynamicZoneLocationFromFields = (listItem, values) => {
|
|
122
|
+
const fields = Array.from(listItem.querySelectorAll('[name]'))
|
|
123
|
+
.map((element) => element.getAttribute('name') || '')
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.sort((a, b) => a.length - b.length);
|
|
126
|
+
|
|
127
|
+
for (const fieldName of fields) {
|
|
128
|
+
const segments = fieldName.split('.');
|
|
129
|
+
|
|
130
|
+
for (let indexPosition = segments.length - 2; indexPosition >= 0; indexPosition -= 1) {
|
|
131
|
+
const segment = segments[indexPosition];
|
|
132
|
+
|
|
133
|
+
if (!INDEX_SEGMENT_REGEX.test(segment)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const itemPath = segments.slice(0, indexPosition + 1).join('.');
|
|
138
|
+
const itemValue = getIn(values, itemPath);
|
|
139
|
+
|
|
140
|
+
if (isDynamicZoneItem(itemValue)) {
|
|
141
|
+
return {
|
|
142
|
+
dynamicZonePath: segments.slice(0, indexPosition).join('.'),
|
|
143
|
+
index: Number(segment),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const findDynamicZoneLocationFromList = (listItem, values, components) => {
|
|
153
|
+
const list = listItem.closest('ol');
|
|
154
|
+
|
|
155
|
+
if (!list) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const siblings = Array.from(list.children).filter((node) => node.tagName === 'LI');
|
|
160
|
+
const index = siblings.indexOf(listItem);
|
|
161
|
+
|
|
162
|
+
if (index < 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const listLength = siblings.length;
|
|
167
|
+
const dynamicZonePaths = collectDynamicZonePaths(values);
|
|
168
|
+
const candidates = dynamicZonePaths.filter((dynamicZonePath) => {
|
|
169
|
+
const zone = getIn(values, dynamicZonePath);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
Array.isArray(zone) &&
|
|
173
|
+
zone.length === listLength &&
|
|
174
|
+
isDynamicZoneItem(getIn(values, `${dynamicZonePath}.${index}`))
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (candidates.length === 1) {
|
|
179
|
+
return {
|
|
180
|
+
dynamicZonePath: candidates[0],
|
|
181
|
+
index,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!components || !isPlainObject(components)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const listItemText = (listItem.textContent || '').toLowerCase();
|
|
190
|
+
const byDisplayName = candidates.filter((dynamicZonePath) => {
|
|
191
|
+
const uid = getIn(values, `${dynamicZonePath}.${index}.__component`);
|
|
192
|
+
const displayName = components?.[uid]?.info?.displayName;
|
|
193
|
+
|
|
194
|
+
return typeof displayName === 'string' && listItemText.includes(displayName.toLowerCase());
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (byDisplayName.length === 1) {
|
|
198
|
+
return {
|
|
199
|
+
dynamicZonePath: byDisplayName[0],
|
|
200
|
+
index,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const findDynamicZoneLocation = (listItem, values, components) => {
|
|
208
|
+
const locationFromFields = findDynamicZoneLocationFromFields(listItem, values);
|
|
209
|
+
|
|
210
|
+
if (locationFromFields) {
|
|
211
|
+
return locationFromFields;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return findDynamicZoneLocationFromList(listItem, values, components);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const createDuplicateButton = (anchor, label, onClick) => {
|
|
218
|
+
const button = document.createElement('button');
|
|
219
|
+
button.type = 'button';
|
|
220
|
+
button.className = anchor.className;
|
|
221
|
+
button.setAttribute(DUPLICATE_CONTAINER_ATTR, 'true');
|
|
222
|
+
button.setAttribute('aria-label', label);
|
|
223
|
+
button.setAttribute('title', label);
|
|
224
|
+
button.setAttribute('data-state', 'closed');
|
|
225
|
+
|
|
226
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
227
|
+
icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
228
|
+
icon.setAttribute('viewBox', '0 0 32 32');
|
|
229
|
+
icon.setAttribute('width', '16');
|
|
230
|
+
icon.setAttribute('height', '16');
|
|
231
|
+
icon.setAttribute('fill', 'currentColor');
|
|
232
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
233
|
+
icon.setAttribute('focusable', 'false');
|
|
234
|
+
|
|
235
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
236
|
+
path.setAttribute('d', DUPLICATE_ICON_PATH);
|
|
237
|
+
icon.appendChild(path);
|
|
238
|
+
|
|
239
|
+
const textTemplate = anchor.querySelector('span');
|
|
240
|
+
const text = document.createElement('span');
|
|
241
|
+
if (textTemplate?.className) {
|
|
242
|
+
text.className = textTemplate.className;
|
|
243
|
+
}
|
|
244
|
+
text.textContent = label;
|
|
245
|
+
|
|
246
|
+
button.append(icon, text);
|
|
247
|
+
button.addEventListener('click', (event) => {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
event.stopPropagation();
|
|
250
|
+
onClick();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return button;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const DynamicZoneActionInjector = () => {
|
|
257
|
+
const { formatMessage } = useIntl();
|
|
258
|
+
const { toggleNotification } = useNotification();
|
|
259
|
+
const { form, isLoading, components } = useContentManagerContext();
|
|
260
|
+
|
|
261
|
+
const values = form?.values;
|
|
262
|
+
const valuesRef = React.useRef(values);
|
|
263
|
+
const observerRef = React.useRef(null);
|
|
264
|
+
|
|
265
|
+
valuesRef.current = values;
|
|
266
|
+
|
|
267
|
+
const duplicateLabel = formatMessage({
|
|
268
|
+
id: 'strapi-dz-component-duplicator.action.duplicate',
|
|
269
|
+
defaultMessage: 'Duplicate component',
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const duplicateErrorLabel = formatMessage({
|
|
273
|
+
id: 'strapi-dz-component-duplicator.error.duplicate',
|
|
274
|
+
defaultMessage: 'Could not duplicate this component.',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const cleanupInjectedButtons = React.useCallback(() => {
|
|
278
|
+
if (typeof document === 'undefined') {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const injectedNodes = document.querySelectorAll(`[${DUPLICATE_CONTAINER_ATTR}]`);
|
|
283
|
+
for (const node of injectedNodes) {
|
|
284
|
+
node.remove();
|
|
285
|
+
}
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const handleDuplicate = React.useCallback(
|
|
289
|
+
(dynamicZonePath, index) => {
|
|
290
|
+
if (!form || typeof form.addFieldRow !== 'function') {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const item = getIn(valuesRef.current, `${dynamicZonePath}.${index}`);
|
|
295
|
+
|
|
296
|
+
if (!isDynamicZoneItem(item)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const cloned = stripTransientKeys(cloneValue(item));
|
|
302
|
+
form.addFieldRow(dynamicZonePath, cloned, index + 1);
|
|
303
|
+
} catch {
|
|
304
|
+
toggleNotification({
|
|
305
|
+
type: 'danger',
|
|
306
|
+
message: duplicateErrorLabel,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
[form, toggleNotification, duplicateErrorLabel]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const injectButtons = React.useCallback(() => {
|
|
314
|
+
if (typeof document === 'undefined') {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const observer = observerRef.current;
|
|
319
|
+
|
|
320
|
+
if (observer) {
|
|
321
|
+
observer.disconnect();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
cleanupInjectedButtons();
|
|
325
|
+
|
|
326
|
+
const currentValues = valuesRef.current;
|
|
327
|
+
|
|
328
|
+
if (!currentValues || isLoading) {
|
|
329
|
+
if (observer) {
|
|
330
|
+
observer.observe(document.body, {
|
|
331
|
+
childList: true,
|
|
332
|
+
subtree: true,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const listItems = document.querySelectorAll('ol > li');
|
|
340
|
+
|
|
341
|
+
for (const listItem of listItems) {
|
|
342
|
+
const location = findDynamicZoneLocation(listItem, currentValues, components);
|
|
343
|
+
|
|
344
|
+
if (!location) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const anchor = getActionAnchor(listItem);
|
|
349
|
+
|
|
350
|
+
if (!anchor || !anchor.parentElement) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const duplicateButton = createDuplicateButton(anchor, duplicateLabel, () => {
|
|
355
|
+
handleDuplicate(location.dynamicZonePath, location.index);
|
|
356
|
+
});
|
|
357
|
+
anchor.parentElement.insertBefore(
|
|
358
|
+
duplicateButton,
|
|
359
|
+
anchor
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (observer) {
|
|
364
|
+
observer.observe(document.body, {
|
|
365
|
+
childList: true,
|
|
366
|
+
subtree: true,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}, [cleanupInjectedButtons, duplicateLabel, handleDuplicate, isLoading]);
|
|
370
|
+
|
|
371
|
+
React.useEffect(() => {
|
|
372
|
+
if (typeof document === 'undefined') {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const observer = new MutationObserver(() => {
|
|
377
|
+
injectButtons();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
observerRef.current = observer;
|
|
381
|
+
|
|
382
|
+
observer.observe(document.body, {
|
|
383
|
+
childList: true,
|
|
384
|
+
subtree: true,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
injectButtons();
|
|
388
|
+
|
|
389
|
+
return () => {
|
|
390
|
+
observer.disconnect();
|
|
391
|
+
observerRef.current = null;
|
|
392
|
+
cleanupInjectedButtons();
|
|
393
|
+
};
|
|
394
|
+
}, [cleanupInjectedButtons, injectButtons]);
|
|
395
|
+
|
|
396
|
+
React.useEffect(() => {
|
|
397
|
+
injectButtons();
|
|
398
|
+
}, [injectButtons, values]);
|
|
399
|
+
|
|
400
|
+
return null;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export { DynamicZoneActionInjector };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PLUGIN_ID } from './pluginId.mjs';
|
|
2
|
+
import { DynamicZoneActionInjector } from './components/DynamicZoneActionInjector.mjs';
|
|
3
|
+
|
|
4
|
+
const plugin = {
|
|
5
|
+
register(app) {
|
|
6
|
+
app.registerPlugin({
|
|
7
|
+
id: PLUGIN_ID,
|
|
8
|
+
name: PLUGIN_ID,
|
|
9
|
+
});
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
bootstrap(app) {
|
|
13
|
+
const contentManager = app.getPlugin('content-manager');
|
|
14
|
+
|
|
15
|
+
if (!contentManager || typeof contentManager.injectComponent !== 'function') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const injectionZone = 'right-links';
|
|
20
|
+
const injectorName = `${PLUGIN_ID}-dynamic-zone-action-injector`;
|
|
21
|
+
const existing =
|
|
22
|
+
typeof contentManager.getInjectedComponents === 'function'
|
|
23
|
+
? contentManager.getInjectedComponents('editView', injectionZone)
|
|
24
|
+
: [];
|
|
25
|
+
|
|
26
|
+
if (Array.isArray(existing) && existing.some((component) => component.name === injectorName)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
contentManager.injectComponent('editView', injectionZone, {
|
|
31
|
+
name: injectorName,
|
|
32
|
+
Component: DynamicZoneActionInjector,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default plugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PLUGIN_ID = 'strapi-dz-component-duplicator';
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strapi-dz-component-duplicator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Duplicate dynamic zone components directly from Strapi 5 edit view.",
|
|
5
|
+
"author": "Djordje Savanovic",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"strapi",
|
|
8
|
+
"strapi-plugin",
|
|
9
|
+
"dynamic-zone",
|
|
10
|
+
"component",
|
|
11
|
+
"duplicator"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator",
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"verify:pack": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
"./strapi-admin": {
|
|
28
|
+
"source": "./admin/src/index.mjs",
|
|
29
|
+
"import": "./admin/src/index.mjs",
|
|
30
|
+
"require": "./admin/src/index.mjs",
|
|
31
|
+
"default": "./admin/src/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./strapi-server": {
|
|
34
|
+
"source": "./server/src/index.js",
|
|
35
|
+
"import": "./server/src/index.js",
|
|
36
|
+
"require": "./server/src/index.js",
|
|
37
|
+
"default": "./server/src/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"admin",
|
|
43
|
+
"server",
|
|
44
|
+
"assets"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@strapi/admin": "^5.0.0",
|
|
48
|
+
"@strapi/content-manager": "^5.0.0",
|
|
49
|
+
"@strapi/strapi": "^5.0.0",
|
|
50
|
+
"react": "^18.0.0",
|
|
51
|
+
"react-intl": "^6.0.0"
|
|
52
|
+
},
|
|
53
|
+
"strapi": {
|
|
54
|
+
"name": "strapi-dz-component-duplicator",
|
|
55
|
+
"displayName": "Dynamic Zone Component Duplicator",
|
|
56
|
+
"description": "Adds a duplicate button to Strapi dynamic zone blocks in edit view.",
|
|
57
|
+
"kind": "plugin"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20.0.0 <=24.x.x"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|