@kitconcept/volto-light-theme 4.0.1 → 5.0.1

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/.changelog.draft CHANGED
@@ -1,12 +1,13 @@
1
- ## 4.0.1 (2024-06-28)
1
+ ## 5.0.1 (2024-10-09)
2
2
 
3
3
  ### Bugfix
4
4
 
5
- - Fix Invalid html structure in caption component @iRohitSingh [#398](https://github.com/kitconcept/volto-light-theme/pull/398)
6
- - Fix install in Volto 17 @sneridagh [#400](https://github.com/kitconcept/volto-light-theme/pull/400)
5
+ - Fixed missing key in header @sneridagh [#417](https://github.com/kitconcept/volto-light-theme/pull/417)
7
6
 
8
7
  ### Internal
9
8
 
10
- - Upgrade to Volto 18a37 @sneridagh [#403](https://github.com/kitconcept/volto-light-theme/pull/403)
9
+ - Update versions to latest volto-highlight-block, volto-button-block @sneridagh [#408](https://github.com/kitconcept/volto-light-theme/pull/408)
10
+ - Bump `volto-button-block` version @sneridagh
11
+ Bump to Volto 18.0.0-alpha.45 [#417](https://github.com/kitconcept/volto-light-theme/pull/417)
11
12
 
12
13
 
package/.release-it.json CHANGED
@@ -12,6 +12,9 @@
12
12
  ],
13
13
  "after:release": "rm .changelog.draft"
14
14
  },
15
+ "npm": {
16
+ "publish": false
17
+ },
15
18
  "git": {
16
19
  "changelog": "pipx run towncrier build --draft --yes --version 0.0.0",
17
20
  "requireUpstream": false,
package/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@
8
8
 
9
9
  <!-- towncrier release notes start -->
10
10
 
11
+ ## 5.0.1 (2024-10-09)
12
+
13
+ ### Bugfix
14
+
15
+ - Fixed missing key in header @sneridagh [#417](https://github.com/kitconcept/volto-light-theme/pull/417)
16
+
17
+ ### Internal
18
+
19
+ - Update versions to latest volto-highlight-block, volto-button-block @sneridagh [#408](https://github.com/kitconcept/volto-light-theme/pull/408)
20
+ - Bump `volto-button-block` version @sneridagh
21
+ Bump to Volto 18.0.0-alpha.45 [#417](https://github.com/kitconcept/volto-light-theme/pull/417)
22
+
23
+ ## 5.0.0 (2024-07-02)
24
+
25
+ ### Breaking
26
+
27
+ - Upgrade to a39, enable new image widget @sneridagh
28
+
29
+ Breaking:
30
+ The new Image widget component is used in the VLT shadowed image component.
31
+ The minimum Volto version requirements have changed for this reason.
32
+ The new image widget is present in core from these versions on:
33
+ - Volto 17.18.0
34
+ - Volto 18.0.0-alpha.36
35
+
36
+ For more information, please take a look at the upgrade guide:
37
+ https://github.com/kitconcept/volto-light-theme/blob/main/UPGRADE-GUIDE.md [#405](https://github.com/kitconcept/volto-light-theme/pull/405)
38
+
11
39
  ## 4.0.1 (2024-06-28)
12
40
 
13
41
  ### Bugfix
package/README.md CHANGED
@@ -232,17 +232,24 @@ They will be noted properly in the changelog.
232
232
 
233
233
  See a detailed upgrade guide in: https://github.com/kitconcept/volto-light-theme/blob/main/UPGRADE-GUIDE.md
234
234
 
235
- ## Development
235
+ ## Compatibility
236
+
237
+ | VLT version | Volto version |
238
+ |-------------|---------------|
239
+ | 3.x.x | >= Volto 17.0.0-alpha.16 |
240
+ | 4.x.x | < Volto 17.18.0 |
241
+ | 5.x.x | >= Volto 17.18.0 or >=Volto 18.0.0-alpha.36 |
236
242
 
237
- This theme works under Volto 17 alpha 16 onwards.
238
- Compatibility with Volto 16 might be achieved, but it has to be at customization level in the
239
- specific project add-on.
243
+ Compatibility with Volto 16 might be achieved, but it has to be at customization level in the specific project add-on.
240
244
  This is mainly due to the `RenderBlocks` customization that is based in the one in 17 because of the Grid block in core and the autogrouping feature.
241
245
  See more information about the other dependencies in `peerDependencies` in `package.json`.
246
+
247
+ ## Development
248
+
242
249
  The development of this add-on is done in isolation using a new approach using pnpm workspaces and latest `mrs-developer` and other Volto core improvements.
243
250
  For this reason, it only works with pnpm and Volto 18 (currently in alpha) but it does not mean that the add-on will only work in 18.
244
251
 
245
- ### Requisites
252
+ ### Development requisites
246
253
 
247
254
  - Volto 18 (2024-03-21: currently in alpha)
248
255
  - pnpm as package manager
@@ -252,19 +259,32 @@ For this reason, it only works with pnpm and Volto 18 (currently in alpha) but i
252
259
  Run `make help` to list the available commands.
253
260
 
254
261
  ```text
255
- help Show this help
256
- install Installs the dev environment using mrs-developer
257
- i18n Sync i18n
258
- format Format codebase
259
- lint Lint Codebase
260
- test Run unit tests
261
- test-ci Run unit tests in CI
262
- start-backend-docker Starts a Docker-based backend for developing
263
- start-test-acceptance-frontend-dev Start acceptance frontend in dev mode
264
- start-test-acceptance-frontend Start acceptance frontend in prod mode
265
- start-test-acceptance-server Start acceptance server
266
- test-acceptance Start Cypress in interactive mode
267
- test-acceptance-headless Run cypress tests in headless mode for CI
262
+ help Show this help
263
+ install Installs the add-on in a development environment
264
+ start Starts Volto, allowing reloading of the add-on during development
265
+ build Build a production bundle for distribution of the project with the add-on
266
+ build-deps Build dependencies
267
+ i18n Sync i18n
268
+ ci-i18n Check if i18n is not synced
269
+ format Format codebase
270
+ lint Lint, or catch and remove problems, in code base
271
+ release Release the add-on on npmjs.org
272
+ release-dry-run Dry-run the release of the add-on on npmjs.org
273
+ test Run unit tests
274
+ ci-test Run unit tests in CI
275
+ backend-docker-start Starts a Docker-based backend for development
276
+ storybook-start Start Storybook server on port 6006
277
+ storybook-build Build Storybook
278
+ acceptance-frontend-dev-start Start acceptance frontend in development mode
279
+ acceptance-frontend-prod-start Start acceptance frontend in production mode
280
+ acceptance-backend-start Start backend acceptance server
281
+ ci-acceptance-backend-start Start backend acceptance server in headless mode for CI
282
+ acceptance-test Start Cypress in interactive mode
283
+ ci-acceptance-test Run cypress tests in headless mode for CI
284
+ acceptance-a11y-frontend-prod-start Start a11y acceptance frontend in prod mode
285
+ ci-acceptance-a11y-backend-start Start acceptance a11y server in CI mode (no terminal attached)
286
+ acceptance-a11y-test Start a11y Cypress in interactive mode
287
+ ci-acceptance-a11y-test Run a11y cypress tests in headless mode for CI
268
288
  ```
269
289
 
270
290
  ### Development Environment Setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitconcept/volto-light-theme",
3
- "version": "4.0.1",
3
+ "version": "5.0.1",
4
4
  "description": "Volto Light Theme by kitconcept",
5
5
  "main": "src/index.js",
6
6
  "types": "src/types/index.d.ts",
@@ -26,20 +26,20 @@
26
26
  "access": "public"
27
27
  },
28
28
  "devDependencies": {
29
- "@plone/scripts": "^3.6.1",
30
- "release-it": "^17.1.1"
29
+ "@plone/scripts": "^3.6.2",
30
+ "release-it": "^17.7.0"
31
31
  },
32
32
  "dependencies": {
33
- "@plone/components": "2.0.0-alpha.11"
33
+ "@plone/components": "2.0.0-alpha.13"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@eeacms/volto-accordion-block": "^10.4.6",
37
- "@kitconcept/volto-button-block": "^2.3.1",
37
+ "@kitconcept/volto-button-block": "^3.0.2",
38
38
  "@kitconcept/volto-dsgvo-banner": "^2.3.2",
39
39
  "@kitconcept/volto-heading-block": "^2.4.0",
40
- "@kitconcept/volto-highlight-block": "^3.0.1",
40
+ "@kitconcept/volto-highlight-block": "^4.0.0",
41
41
  "@kitconcept/volto-introduction-block": "^1.0.0",
42
- "@kitconcept/volto-separator-block": "^4.1.1",
42
+ "@kitconcept/volto-separator-block": "^4.1.2",
43
43
  "@kitconcept/volto-slider-block": "^6.3.1"
44
44
  },
45
45
  "scripts": {
@@ -1,275 +1,52 @@
1
- /**
2
- * Edit image block.
3
- * @module components/manage/Blocks/Image/Edit
4
- */
5
-
6
- import React, { Component } from 'react';
7
- import PropTypes from 'prop-types';
8
- import { connect } from 'react-redux';
9
- import { compose } from 'redux';
10
- import { readAsDataURL } from 'promise-file-reader';
11
- import { Button, Dimmer, Input, Loader, Message } from 'semantic-ui-react';
12
- import { defineMessages, injectIntl } from 'react-intl';
13
- import loadable from '@loadable/component';
1
+ import React from 'react';
14
2
  import cx from 'classnames';
15
- import { isEqual } from 'lodash';
16
-
17
- import Caption from '../../Caption/Caption';
3
+ import { ImageSidebar, SidebarPortal } from '@plone/volto/components';
18
4
 
19
- import { Icon, ImageSidebar, SidebarPortal } from '@plone/volto/components';
20
- import { createContent } from '@plone/volto/actions';
21
5
  import {
22
6
  flattenToAppURL,
23
- getBaseUrl,
24
7
  isInternalURL,
25
8
  withBlockExtensions,
26
- validateFileUploadSize,
27
9
  } from '@plone/volto/helpers';
28
10
  import config from '@plone/volto/registry';
29
11
 
30
- import imageBlockSVG from '@plone/volto/components/manage/Blocks/Image/block-image.svg';
31
- import clearSVG from '@plone/volto/icons/clear.svg';
32
- import navTreeSVG from '@plone/volto/icons/nav.svg';
33
- import aheadSVG from '@plone/volto/icons/ahead.svg';
34
- import uploadSVG from '@plone/volto/icons/upload.svg';
35
-
36
- const Dropzone = loadable(() => import('react-dropzone'));
37
-
38
- const messages = defineMessages({
39
- ImageBlockInputPlaceholder: {
40
- id: 'Browse the site, drop an image, or type an URL',
41
- defaultMessage: 'Browse the site, drop an image, or type an URL',
42
- },
43
- uploadingImage: {
44
- id: 'Uploading image',
45
- defaultMessage: 'Uploading image',
46
- },
47
- });
48
-
49
- /**
50
- * Edit image block class.
51
- * @class Edit
52
- * @extends Component
53
- */
54
- class Edit extends Component {
55
- /**
56
- * Property types.
57
- * @property {Object} propTypes Property types.
58
- * @static
59
- */
60
- static propTypes = {
61
- selected: PropTypes.bool.isRequired,
62
- block: PropTypes.string.isRequired,
63
- index: PropTypes.number.isRequired,
64
- data: PropTypes.objectOf(PropTypes.any).isRequired,
65
- content: PropTypes.objectOf(PropTypes.any),
66
- request: PropTypes.shape({
67
- loading: PropTypes.bool,
68
- loaded: PropTypes.bool,
69
- }).isRequired,
70
- pathname: PropTypes.string.isRequired,
71
- onChangeBlock: PropTypes.func.isRequired,
72
- onSelectBlock: PropTypes.func.isRequired,
73
- onDeleteBlock: PropTypes.func.isRequired,
74
- onFocusPreviousBlock: PropTypes.func.isRequired,
75
- onFocusNextBlock: PropTypes.func.isRequired,
76
- handleKeyDown: PropTypes.func.isRequired,
77
- createContent: PropTypes.func.isRequired,
78
- openObjectBrowser: PropTypes.func.isRequired,
79
- };
80
-
81
- state = {
82
- uploading: false,
83
- url: '',
84
- dragging: false,
85
- };
12
+ import { ImageInput } from '@plone/volto/components/manage/Widgets/ImageWidget';
13
+ import Caption from '../../Caption/Caption';
86
14
 
87
- /**
88
- * Component will receive props
89
- * @method componentWillReceiveProps
90
- * @param {Object} nextProps Next properties
91
- * @returns {undefined}
92
- */
93
- UNSAFE_componentWillReceiveProps(nextProps) {
94
- // Update block data after upload finished
95
- if (
96
- this.props.request.loading &&
97
- nextProps.request.loaded &&
98
- this.state.uploading
99
- ) {
100
- this.setState({
101
- uploading: false,
15
+ function Edit(props) {
16
+ const { data } = props;
17
+ const Image = config.getComponent({ name: 'Image' }).component;
18
+ const onSelectItem = React.useCallback(
19
+ (url, item) => {
20
+ const dataAdapter = props.blocksConfig[props.data['@type']].dataAdapter;
21
+ dataAdapter({
22
+ block: props.block,
23
+ data: props.data,
24
+ onChangeBlock: props.onChangeBlock,
25
+ id: 'url',
26
+ value: url,
27
+ item,
102
28
  });
103
- this.props.onChangeBlock(this.props.block, {
104
- ...this.props.data,
105
- url: nextProps.content['@id'],
106
- image_field: 'image',
107
- image_scales: { image: [nextProps.content.image] },
108
- alt: '',
29
+ },
30
+ [props],
31
+ );
32
+
33
+ const handleChange = React.useCallback(
34
+ async (id, image, { title, image_field, image_scales } = {}) => {
35
+ const url = image ? image['@id'] || image : '';
36
+
37
+ props.onChangeBlock(props.block, {
38
+ ...props.data,
39
+ url: flattenToAppURL(url),
40
+ image_field,
41
+ image_scales,
42
+ alt: props.data.alt || title || '',
109
43
  });
110
- }
111
- }
112
-
113
- /**
114
- * @param {*} nextProps
115
- * @returns {boolean}
116
- * @memberof Edit
117
- */
118
- shouldComponentUpdate(nextProps) {
119
- return (
120
- this.props.selected ||
121
- nextProps.selected ||
122
- !isEqual(this.props.data, nextProps.data)
123
- );
124
- }
125
-
126
- /**
127
- * Upload image handler (not used), but useful in case that we want a button
128
- * not powered by react-dropzone
129
- * @method onUploadImage
130
- * @returns {undefined}
131
- */
132
- onUploadImage = (e) => {
133
- e.stopPropagation();
134
- const file = e.target.files[0];
135
- if (!validateFileUploadSize(file, this.props.intl.formatMessage)) return;
136
- this.setState({
137
- uploading: true,
138
- });
139
- readAsDataURL(file).then((data) => {
140
- const fields = data.match(/^data:(.*);(.*),(.*)$/);
141
- this.props.createContent(
142
- getBaseUrl(this.props.pathname),
143
- {
144
- '@type': 'Image',
145
- title: file.name,
146
- image: {
147
- data: fields[3],
148
- encoding: fields[2],
149
- 'content-type': fields[1],
150
- filename: file.name,
151
- },
152
- },
153
- this.props.block,
154
- );
155
- });
156
- };
157
-
158
- /**
159
- * Change url handler
160
- * @method onChangeUrl
161
- * @param {Object} target Target object
162
- * @returns {undefined}
163
- */
164
- onChangeUrl = ({ target }) => {
165
- this.setState({
166
- url: target.value,
167
- });
168
- };
44
+ },
45
+ [props],
46
+ );
169
47
 
170
- /**
171
- * Submit url handler
172
- * @method onSubmitUrl
173
- * @param {object} e Event
174
- * @returns {undefined}
175
- */
176
- onSubmitUrl = () => {
177
- this.props.onChangeBlock(this.props.block, {
178
- ...this.props.data,
179
- url: flattenToAppURL(this.state.url),
180
- });
181
- };
182
-
183
- /**
184
- * Drop handler
185
- * @method onDrop
186
- * @param {array} files File objects
187
- * @returns {undefined}
188
- */
189
- onDrop = (files) => {
190
- if (!validateFileUploadSize(files[0], this.props.intl.formatMessage)) {
191
- this.setState({ dragging: false });
192
- return;
193
- }
194
- this.setState({ uploading: true });
195
-
196
- readAsDataURL(files[0]).then((data) => {
197
- const fields = data.match(/^data:(.*);(.*),(.*)$/);
198
- this.props.createContent(
199
- getBaseUrl(this.props.pathname),
200
- {
201
- '@type': 'Image',
202
- title: files[0].name,
203
- image: {
204
- data: fields[3],
205
- encoding: fields[2],
206
- 'content-type': fields[1],
207
- filename: files[0].name,
208
- },
209
- },
210
- this.props.block,
211
- );
212
- });
213
- };
214
-
215
- /**
216
- * Keydown handler on Variant Menu Form
217
- * This is required since the ENTER key is already mapped to a onKeyDown
218
- * event and needs to be overriden with a child onKeyDown.
219
- * @method onKeyDownVariantMenuForm
220
- * @param {Object} e Event object
221
- * @returns {undefined}
222
- */
223
- onKeyDownVariantMenuForm = (e) => {
224
- if (e.key === 'Enter') {
225
- e.preventDefault();
226
- e.stopPropagation();
227
- this.onSubmitUrl();
228
- } else if (e.key === 'Escape') {
229
- e.preventDefault();
230
- e.stopPropagation();
231
- // TODO: Do something on ESC key
232
- }
233
- };
234
- onDragEnter = () => {
235
- this.setState({ dragging: true });
236
- };
237
- onDragLeave = () => {
238
- this.setState({ dragging: false });
239
- };
240
-
241
- node = React.createRef();
242
-
243
- // START CUSTOMIZATION - Add custom dataAdapter
244
- // It has to be a class method because if used directly we have closure issues while
245
- // passing arguments to the dataAdapter function
246
- onSelectItem = (url, item) => {
247
- const dataAdapter =
248
- this.props.blocksConfig[this.props.data['@type']].dataAdapter;
249
- dataAdapter({
250
- block: this.props.block,
251
- data: this.props.data,
252
- onChangeBlock: this.props.onChangeBlock,
253
- id: 'url',
254
- value: url,
255
- item,
256
- });
257
- };
258
- // END CUSTOMIZATION - Add custom dataAdapter
259
-
260
- /**
261
- * Render method.
262
- * @method render
263
- * @returns {string} Markup for the component.
264
- */
265
- render() {
266
- const Image = config.getComponent({ name: 'Image' }).component;
267
- const { data } = this.props;
268
- const placeholder =
269
- this.props.data.placeholder ||
270
- this.props.intl.formatMessage(messages.ImageBlockInputPlaceholder);
271
-
272
- return (
48
+ return (
49
+ <>
273
50
  <div
274
51
  className={cx(
275
52
  'block image align',
@@ -280,7 +57,6 @@ class Edit extends Component {
280
57
  )}
281
58
  >
282
59
  {data.url ? (
283
- // START CUSTOMIZATION - Added `figure` tag
284
60
  <figure
285
61
  className={cx(
286
62
  'figure',
@@ -299,13 +75,14 @@ class Edit extends Component {
299
75
  )}
300
76
  >
301
77
  <Image
302
- // Is this needed?
78
+ // START CUSTOMIZATION - Moved to the figure
303
79
  // className={cx({
304
80
  // 'full-width': data.align === 'full',
305
81
  // large: data.size === 'l',
306
- // medium: data.size === 'm' || !data.size,
82
+ // medium: data.size === 'm',
307
83
  // small: data.size === 's',
308
84
  // })}
85
+ // END CUSTOMIZATION
309
86
  item={
310
87
  data.image_scales
311
88
  ? {
@@ -328,9 +105,7 @@ class Edit extends Component {
328
105
  data.url,
329
106
  )}/@@images/image/preview`;
330
107
  if (data.size === 's')
331
- return `${flattenToAppURL(
332
- data.url,
333
- )}/@@images/image/mini`;
108
+ return `${flattenToAppURL(data.url)}/@@images/image/mini`;
334
109
  return `${flattenToAppURL(data.url)}/@@images/image`;
335
110
  })()
336
111
  : data.url
@@ -345,125 +120,23 @@ class Edit extends Component {
345
120
  description={data.description}
346
121
  credit={data?.copyright_and_sources ?? data.credit?.data}
347
122
  />
348
- {/* // END CUSTOMIZATION - Added `figure` tag */}
349
123
  </figure>
350
124
  ) : (
351
- <div>
352
- {this.props.editable && (
353
- <Dropzone
354
- noClick
355
- onDrop={this.onDrop}
356
- onDragEnter={this.onDragEnter}
357
- onDragLeave={this.onDragLeave}
358
- className="dropzone"
359
- >
360
- {({ getRootProps, getInputProps }) => (
361
- <div {...getRootProps()}>
362
- <Message>
363
- {this.state.dragging && <Dimmer active></Dimmer>}
364
- {this.state.uploading && (
365
- <Dimmer active>
366
- <Loader indeterminate>
367
- {this.props.intl.formatMessage(
368
- messages.uploadingImage,
369
- )}
370
- </Loader>
371
- </Dimmer>
372
- )}
373
- <div className="no-image-wrapper">
374
- <img src={imageBlockSVG} alt="" />
375
- <div className="toolbar-inner">
376
- <Button.Group>
377
- <Button
378
- basic
379
- icon
380
- onClick={(e) => {
381
- e.stopPropagation();
382
- e.preventDefault();
383
- this.props.openObjectBrowser({
384
- onSelectItem: this.onSelectItem,
385
- });
386
- }}
387
- >
388
- <Icon name={navTreeSVG} size="24px" />
389
- </Button>
390
- </Button.Group>
391
- <Button.Group>
392
- <label className="ui button basic icon">
393
- <Icon name={uploadSVG} size="24px" />
394
- <input
395
- {...getInputProps({
396
- type: 'file',
397
- onChange: this.onUploadImage,
398
- style: { display: 'none' },
399
- })}
400
- />
401
- </label>
402
- </Button.Group>
403
- <Input
404
- onKeyDown={this.onKeyDownVariantMenuForm}
405
- onChange={this.onChangeUrl}
406
- placeholder={placeholder}
407
- value={this.state.url}
408
- onClick={(e) => {
409
- e.target.focus();
410
- }}
411
- onFocus={(e) => {
412
- this.props.onSelectBlock(this.props.id);
413
- }}
414
- />
415
- {this.state.url && (
416
- <Button.Group>
417
- <Button
418
- basic
419
- className="cancel"
420
- onClick={(e) => {
421
- e.stopPropagation();
422
- this.setState({ url: '' });
423
- }}
424
- >
425
- <Icon name={clearSVG} size="30px" />
426
- </Button>
427
- </Button.Group>
428
- )}
429
- <Button.Group>
430
- <Button
431
- basic
432
- primary
433
- disabled={!this.state.url}
434
- onClick={(e) => {
435
- e.stopPropagation();
436
- this.onSubmitUrl();
437
- }}
438
- >
439
- <Icon name={aheadSVG} size="30px" />
440
- </Button>
441
- </Button.Group>
442
- </div>
443
- </div>
444
- </Message>
445
- </div>
446
- )}
447
- </Dropzone>
448
- )}
449
- </div>
125
+ <ImageInput
126
+ onChange={handleChange}
127
+ placeholderLinkInput={data.placeholder}
128
+ block={props.block}
129
+ id={props.block}
130
+ objectBrowserPickerType={'image'}
131
+ onSelectItem={onSelectItem}
132
+ />
450
133
  )}
451
- <SidebarPortal selected={this.props.selected}>
452
- <ImageSidebar {...this.props} />
134
+ <SidebarPortal selected={props.selected}>
135
+ <ImageSidebar {...props} />
453
136
  </SidebarPortal>
454
137
  </div>
455
- );
456
- }
138
+ </>
139
+ );
457
140
  }
458
141
 
459
- export default compose(
460
- injectIntl,
461
- withBlockExtensions,
462
- connect(
463
- (state, ownProps) => ({
464
- request: state.content.subrequests[ownProps.block] || {},
465
- content: state.content.subrequests[ownProps.block]?.data,
466
- }),
467
- { createContent },
468
- ),
469
- )(Edit);
142
+ export default withBlockExtensions(Edit);
@@ -74,7 +74,9 @@ const IntranetHeader = ({ pathname, siteLabel, token, siteAction }) => {
74
74
  {!token && <Anontools />}
75
75
  {siteAction &&
76
76
  siteAction.map((item) => (
77
- <UniversalLink href={item.url}>{item.title}</UniversalLink>
77
+ <UniversalLink key={item.url} href={item.url}>
78
+ {item.title}
79
+ </UniversalLink>
78
80
  ))}
79
81
  </div>
80
82
  {siteLabel && (