@ndla/ui 34.6.2 → 34.6.4

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 (153) hide show
  1. package/es/Article/Article.js +11 -6
  2. package/es/Aside/Aside.js +5 -2
  3. package/es/CopyParagraphButton/CopyParagraphButtonV2.js +85 -0
  4. package/es/CopyParagraphButton/index.js +2 -1
  5. package/es/Embed/AudioEmbed.js +254 -0
  6. package/es/Embed/BrightcoveEmbed.js +250 -0
  7. package/es/Embed/ConceptEmbed.js +359 -0
  8. package/es/Embed/ConceptListEmbed.js +71 -0
  9. package/es/Embed/ContentLinkEmbed.js +42 -0
  10. package/es/Embed/ExternalEmbed.js +91 -0
  11. package/es/Embed/FootnoteEmbed.js +32 -0
  12. package/es/Embed/H5pEmbed.js +87 -0
  13. package/es/Embed/IframeEmbed.js +83 -0
  14. package/es/Embed/ImageEmbed.js +322 -0
  15. package/es/Embed/RelatedContentEmbed.js +58 -0
  16. package/es/Embed/UnknownEmbed.js +27 -0
  17. package/es/Embed/conceptComponents.js +282 -0
  18. package/es/Embed/index.js +21 -0
  19. package/es/FactBox/FactBoxV2.js +90 -0
  20. package/es/FactBox/index.js +1 -0
  21. package/es/Figure/Figure.js +8 -5
  22. package/es/Figure/FigureLicenseDialogContent.js +72 -0
  23. package/es/FileList/FileListV2.js +47 -0
  24. package/es/FileList/FileV2.js +34 -0
  25. package/es/FileList/PdfFile.js +25 -0
  26. package/es/FileList/index.js +3 -0
  27. package/es/Notion/Notion.js +5 -5
  28. package/es/Notion/NotionVisualElement.js +2 -2
  29. package/es/RelatedArticleList/RelatedArticleV2.js +101 -0
  30. package/es/RelatedArticleList/index.js +2 -1
  31. package/es/Table/Table.js +95 -8
  32. package/es/all.css +1 -1
  33. package/es/index.js +5 -4
  34. package/es/locale/messages-en.js +32 -2
  35. package/es/locale/messages-nb.js +32 -2
  36. package/es/locale/messages-nn.js +32 -2
  37. package/es/locale/messages-se.js +32 -2
  38. package/es/locale/messages-sma.js +32 -2
  39. package/lib/Article/Article.d.ts +2 -1
  40. package/lib/Article/Article.js +11 -6
  41. package/lib/Aside/Aside.d.ts +2 -1
  42. package/lib/Aside/Aside.js +5 -2
  43. package/lib/CopyParagraphButton/CopyParagraphButtonV2.d.ts +14 -0
  44. package/lib/CopyParagraphButton/CopyParagraphButtonV2.js +84 -0
  45. package/lib/CopyParagraphButton/index.d.ts +2 -1
  46. package/lib/CopyParagraphButton/index.js +7 -0
  47. package/lib/Embed/AudioEmbed.d.ts +20 -0
  48. package/lib/Embed/AudioEmbed.js +252 -0
  49. package/lib/Embed/BrightcoveEmbed.d.ts +16 -0
  50. package/lib/Embed/BrightcoveEmbed.js +250 -0
  51. package/lib/Embed/ConceptEmbed.d.ts +19 -0
  52. package/lib/Embed/ConceptEmbed.js +359 -0
  53. package/lib/Embed/ConceptListEmbed.d.ts +13 -0
  54. package/lib/Embed/ConceptListEmbed.js +70 -0
  55. package/lib/Embed/ContentLinkEmbed.d.ts +14 -0
  56. package/lib/Embed/ContentLinkEmbed.js +50 -0
  57. package/lib/Embed/ExternalEmbed.d.ts +14 -0
  58. package/lib/Embed/ExternalEmbed.js +90 -0
  59. package/lib/Embed/FootnoteEmbed.d.ts +13 -0
  60. package/lib/Embed/FootnoteEmbed.js +39 -0
  61. package/lib/Embed/H5pEmbed.d.ts +14 -0
  62. package/lib/Embed/H5pEmbed.js +86 -0
  63. package/lib/Embed/IframeEmbed.d.ts +14 -0
  64. package/lib/Embed/IframeEmbed.js +91 -0
  65. package/lib/Embed/ImageEmbed.d.ts +37 -0
  66. package/lib/Embed/ImageEmbed.js +326 -0
  67. package/lib/Embed/RelatedContentEmbed.d.ts +16 -0
  68. package/lib/Embed/RelatedContentEmbed.js +64 -0
  69. package/lib/Embed/UnknownEmbed.d.ts +13 -0
  70. package/lib/Embed/UnknownEmbed.js +35 -0
  71. package/lib/Embed/conceptComponents.d.ts +32 -0
  72. package/lib/Embed/conceptComponents.js +280 -0
  73. package/lib/Embed/index.d.ts +20 -0
  74. package/lib/Embed/index.js +97 -0
  75. package/lib/FactBox/FactBoxV2.d.ts +13 -0
  76. package/lib/FactBox/FactBoxV2.js +92 -0
  77. package/lib/FactBox/index.d.ts +1 -0
  78. package/lib/FactBox/index.js +7 -0
  79. package/lib/Figure/Figure.d.ts +5 -2
  80. package/lib/Figure/Figure.js +8 -5
  81. package/lib/Figure/FigureLicenseDialogContent.d.ts +22 -0
  82. package/lib/Figure/FigureLicenseDialogContent.js +71 -0
  83. package/lib/FileList/FileListV2.d.ts +13 -0
  84. package/lib/FileList/FileListV2.js +46 -0
  85. package/lib/FileList/FileV2.d.ts +16 -0
  86. package/lib/FileList/FileV2.js +42 -0
  87. package/lib/FileList/PdfFile.d.ts +13 -0
  88. package/lib/FileList/PdfFile.js +31 -0
  89. package/lib/FileList/index.d.ts +3 -0
  90. package/lib/FileList/index.js +21 -0
  91. package/lib/Notion/Notion.js +5 -5
  92. package/lib/Notion/NotionVisualElement.d.ts +1 -1
  93. package/lib/Notion/NotionVisualElement.js +2 -2
  94. package/lib/RelatedArticleList/RelatedArticleV2.d.ts +25 -0
  95. package/lib/RelatedArticleList/RelatedArticleV2.js +101 -0
  96. package/lib/RelatedArticleList/index.d.ts +2 -1
  97. package/lib/RelatedArticleList/index.js +7 -0
  98. package/lib/Table/Table.js +98 -8
  99. package/lib/all.css +1 -1
  100. package/lib/index.d.ts +5 -4
  101. package/lib/index.js +117 -2
  102. package/lib/locale/messages-en.d.ts +30 -0
  103. package/lib/locale/messages-en.js +32 -2
  104. package/lib/locale/messages-nb.d.ts +30 -0
  105. package/lib/locale/messages-nb.js +32 -2
  106. package/lib/locale/messages-nn.d.ts +30 -0
  107. package/lib/locale/messages-nn.js +32 -2
  108. package/lib/locale/messages-se.d.ts +30 -0
  109. package/lib/locale/messages-se.js +32 -2
  110. package/lib/locale/messages-sma.d.ts +30 -0
  111. package/lib/locale/messages-sma.js +32 -2
  112. package/lib/types.d.ts +1 -1
  113. package/package.json +16 -12
  114. package/src/Article/Article.tsx +8 -3
  115. package/src/Aside/Aside.tsx +9 -1
  116. package/src/Aside/component.aside.scss +3 -0
  117. package/src/CopyParagraphButton/CopyParagraphButtonV2.tsx +84 -0
  118. package/src/CopyParagraphButton/index.tsx +2 -1
  119. package/src/Embed/AudioEmbed.tsx +249 -0
  120. package/src/Embed/BrightcoveEmbed.tsx +203 -0
  121. package/src/Embed/ConceptEmbed.tsx +403 -0
  122. package/src/Embed/ConceptListEmbed.tsx +64 -0
  123. package/src/Embed/ContentLinkEmbed.tsx +41 -0
  124. package/src/Embed/ExternalEmbed.tsx +80 -0
  125. package/src/Embed/FootnoteEmbed.tsx +30 -0
  126. package/src/Embed/H5pEmbed.tsx +74 -0
  127. package/src/Embed/IframeEmbed.tsx +84 -0
  128. package/src/Embed/ImageEmbed.tsx +314 -0
  129. package/src/Embed/RelatedContentEmbed.tsx +62 -0
  130. package/src/Embed/UnknownEmbed.tsx +27 -0
  131. package/src/Embed/conceptComponents.tsx +393 -0
  132. package/src/Embed/index.ts +21 -0
  133. package/src/FactBox/FactBoxV2.tsx +56 -0
  134. package/src/FactBox/index.ts +2 -0
  135. package/src/Figure/Figure.tsx +28 -15
  136. package/src/Figure/FigureLicenseDialogContent.tsx +80 -0
  137. package/src/Figure/component.figure.scss +0 -1
  138. package/src/FileList/FileListV2.tsx +58 -0
  139. package/src/FileList/FileV2.tsx +35 -0
  140. package/src/FileList/PdfFile.tsx +25 -0
  141. package/src/FileList/index.ts +3 -0
  142. package/src/Notion/Notion.tsx +0 -1
  143. package/src/Notion/NotionVisualElement.tsx +1 -1
  144. package/src/RelatedArticleList/RelatedArticleV2.tsx +84 -0
  145. package/src/RelatedArticleList/index.ts +2 -1
  146. package/src/Table/Table.tsx +77 -4
  147. package/src/index.ts +19 -4
  148. package/src/locale/messages-en.ts +33 -0
  149. package/src/locale/messages-nb.ts +33 -0
  150. package/src/locale/messages-nn.ts +33 -0
  151. package/src/locale/messages-se.ts +33 -0
  152. package/src/locale/messages-sma.ts +33 -0
  153. package/src/types.ts +1 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Copyright (c) 2023-present, NDLA.
3
+ *
4
+ * This source code is licensed under the GPLv3 license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import sortBy from 'lodash/sortBy';
10
+ import isNumber from 'lodash/isNumber';
11
+ import styled from '@emotion/styled';
12
+ import { spacing } from '@ndla/core';
13
+ import { getGroupedContributorDescriptionList, getLicenseByAbbreviation } from '@ndla/licenses';
14
+ import { useEffect, useRef, useState } from 'react';
15
+ import { ModalV2 } from '@ndla/modal';
16
+ import { SafeLinkButton } from '@ndla/safelink';
17
+ import { BrightcoveEmbedData, BrightcoveMetaData, BrightcoveVideoSource } from '@ndla/types-embed';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { ButtonV2, CopyButton } from '@ndla/button';
20
+ import { Figure, FigureCaption } from '../Figure';
21
+ import { FigureLicenseDialogContent } from '../Figure/FigureLicenseDialogContent';
22
+ import { getFirstNonEmptyLicenseCredits } from './AudioEmbed';
23
+
24
+ interface Props {
25
+ embed: BrightcoveMetaData;
26
+ isConcept?: boolean;
27
+ }
28
+
29
+ const LinkedVideoButton = styled(ButtonV2)`
30
+ margin-left: ${spacing.small};
31
+ `;
32
+
33
+ const BrightcoveIframe = styled.iframe`
34
+ height: auto;
35
+ `;
36
+
37
+ export const makeIframeString = (url: string, width: string | number, height: string | number, title: string = '') => {
38
+ const strippedWidth = isNumber(width) ? width : width.replace(/\s*px/, '');
39
+ const strippedHeight = isNumber(height) ? height : height.replace(/\s*px/, '');
40
+ const urlOrTitle = title || url;
41
+ return `<iframe title="${urlOrTitle}" aria-label="${urlOrTitle}" src="${url}" width="${strippedWidth}" height="${strippedHeight}" allowfullscreen scrolling="no" frameborder="0" loading="lazy"></iframe>`;
42
+ };
43
+
44
+ export const isNumeric = (value: any) => !Number.isNaN(value - parseFloat(value));
45
+
46
+ const getIframeProps = (data: BrightcoveEmbedData, sources: BrightcoveVideoSource[]) => {
47
+ const { account, videoid, player = 'default' } = data;
48
+
49
+ const source = sortBy(
50
+ sources.filter((s) => s.width && s.height),
51
+ (s) => s.height,
52
+ )[0];
53
+
54
+ return {
55
+ src: `https://players.brightcove.net/${account}/${player}_default/index.html?videoId=${videoid}`,
56
+ height: source?.height ?? '480',
57
+ width: source?.width ?? '640',
58
+ };
59
+ };
60
+ const BrightcoveEmbed = ({ embed, isConcept }: Props) => {
61
+ const [isOpen, setIsOpen] = useState(false);
62
+ const [showOriginalVideo, setShowOriginalVideo] = useState(true);
63
+ const { t, i18n } = useTranslation();
64
+ const iframeRef = useRef<HTMLIFrameElement>(null);
65
+ const { embedData } = embed;
66
+
67
+ useEffect(() => {
68
+ const iframe = iframeRef.current;
69
+ if (iframe) {
70
+ const [width, height] = [parseInt(iframe.width), parseInt(iframe.height)];
71
+ iframe.style.aspectRatio = `${width}/${height}`;
72
+ }
73
+ }, []);
74
+ if (embed.status === 'error') {
75
+ return (
76
+ <Figure type={isConcept ? 'full-column' : 'full'} resizeIframe>
77
+ <BrightcoveIframe
78
+ ref={iframeRef}
79
+ title={`Video: ${embedData.videoid ?? ''}`}
80
+ aria-label={`Video: ${embedData.videoid ?? ''}`}
81
+ frameBorder="0"
82
+ {...getIframeProps(embedData, [])}
83
+ allowFullScreen
84
+ />
85
+ <figcaption>{t('video.error')}</figcaption>
86
+ </Figure>
87
+ );
88
+ }
89
+ const { data, seq } = embed;
90
+
91
+ const linkedVideoId = isNumeric(data.link?.text) ? data.link?.text : undefined;
92
+
93
+ const license = getLicenseByAbbreviation(data.copyright?.license.license ?? '', i18n.language);
94
+ const contributors = data.copyright
95
+ ? getGroupedContributorDescriptionList(data.copyright, i18n.language).map((item) => ({
96
+ name: item.description,
97
+ type: item.label,
98
+ }))
99
+ : [];
100
+
101
+ const { rightsholders = [], creators = [], processors = [] } = data.copyright ?? {};
102
+
103
+ const download = sortBy(
104
+ data.sources.filter((src) => src.container === 'MP4' && src.src),
105
+ (src) => src.size,
106
+ )?.[0]?.src;
107
+
108
+ const figureId = `figure-${seq}-${data.id}`;
109
+ const originalVideoProps = getIframeProps(embedData, data.sources);
110
+ const alternativeVideoProps = linkedVideoId
111
+ ? getIframeProps({ ...embedData, videoid: linkedVideoId }, data.sources)
112
+ : undefined;
113
+ const captionAuthors = getFirstNonEmptyLicenseCredits({ rightsholders, creators, processors });
114
+
115
+ return (
116
+ <Figure id={figureId} type={isConcept ? 'full-column' : 'full'} resizeIframe>
117
+ <div className="brightcove-video">
118
+ <BrightcoveIframe
119
+ ref={iframeRef}
120
+ className="original"
121
+ title={`Video: ${data.name}`}
122
+ aria-label={`Video: ${data.name}`}
123
+ frameBorder="0"
124
+ {...(alternativeVideoProps && !showOriginalVideo ? alternativeVideoProps : originalVideoProps)}
125
+ allowFullScreen
126
+ />
127
+ </div>
128
+ <FigureCaption
129
+ figureId={figureId}
130
+ id={data.id}
131
+ locale={i18n.language}
132
+ caption={embedData.caption ?? ''}
133
+ modalButton={
134
+ <ButtonV2 variant="outline" shape="pill" size="small" onClick={() => setIsOpen(true)}>
135
+ {t('video.reuse')}
136
+ </ButtonV2>
137
+ }
138
+ linkedVideoButton={
139
+ <LinkedVideoButton
140
+ variant="outline"
141
+ shape="pill"
142
+ size="small"
143
+ onClick={() => setShowOriginalVideo((p) => !p)}>
144
+ {t(`figure.button.${showOriginalVideo ? 'original' : 'alternative'}`)}
145
+ </LinkedVideoButton>
146
+ }
147
+ licenseRights={license.rights}
148
+ authors={captionAuthors}
149
+ hasLinkedVideo={!!linkedVideoId}
150
+ />
151
+ <ModalV2 controlled isOpen={isOpen} onClose={() => setIsOpen(false)} labelledBy="license-dialog-rules-heading">
152
+ {(close) => (
153
+ <FigureLicenseDialogContent
154
+ onClose={close}
155
+ title={data.name}
156
+ locale={i18n.language}
157
+ license={license}
158
+ authors={contributors}
159
+ type="video">
160
+ <VideoLicenseButtons
161
+ download={download}
162
+ licenseCode={data.copyright?.license.license}
163
+ src={originalVideoProps.src}
164
+ width={originalVideoProps.width}
165
+ height={originalVideoProps.height}
166
+ name={data.name}
167
+ />
168
+ </FigureLicenseDialogContent>
169
+ )}
170
+ </ModalV2>
171
+ </Figure>
172
+ );
173
+ };
174
+
175
+ interface VideoLicenseButtonsProps {
176
+ download: string;
177
+ licenseCode?: string;
178
+ src: string;
179
+ width: string | number;
180
+ height: string | number;
181
+ name?: string;
182
+ }
183
+
184
+ const VideoLicenseButtons = ({ download, src, width, height, name, licenseCode }: VideoLicenseButtonsProps) => {
185
+ const { t } = useTranslation();
186
+ return (
187
+ <>
188
+ {licenseCode !== 'COPYRIGHTED' && (
189
+ <SafeLinkButton key="download" to={download} variant="outline" download>
190
+ {t('video.download')}
191
+ </SafeLinkButton>
192
+ )}
193
+ <CopyButton
194
+ variant="outline"
195
+ copyNode={t('license.hasCopiedTitle')}
196
+ onClick={() => navigator.clipboard.writeText(makeIframeString(src, width, height, name))}>
197
+ {t('license.embed')}
198
+ </CopyButton>
199
+ </>
200
+ );
201
+ };
202
+
203
+ export default BrightcoveEmbed;
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Copyright (c) 2023-present, NDLA.
3
+ *
4
+ * This source code is licensed under the GPLv3 license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import { useCallback, useRef, useState } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import styled from '@emotion/styled';
12
+ import { isMobile } from 'react-device-detect';
13
+ import { Root, Trigger, Content, Anchor, Close, Portal } from '@radix-ui/react-popover';
14
+ import { ButtonV2, IconButtonV2 } from '@ndla/button';
15
+ import { Cross } from '@ndla/icons/action';
16
+ import { breakpoints, colors, mq, spacing } from '@ndla/core';
17
+ import { getGroupedContributorDescriptionList, getLicenseByAbbreviation, getLicenseCredits } from '@ndla/licenses';
18
+ import { ModalV2 } from '@ndla/modal';
19
+ import { ConceptMetaData } from '@ndla/types-embed';
20
+ import Tooltip from '@ndla/tooltip';
21
+ import { Notion as UINotion } from '../Notion';
22
+ import { Figure, FigureCaption } from '../Figure';
23
+ import { FigureLicenseDialogContent } from '../Figure/FigureLicenseDialogContent';
24
+ import { NotionImage } from '../Notion/NotionImage';
25
+ import { ConceptNotionV2, ConceptNotionData } from './conceptComponents';
26
+
27
+ const BottomBorder = styled.div`
28
+ margin-top: ${spacing.normal};
29
+ border-bottom: 1px solid ${colors.brand.greyLight};
30
+ `;
31
+
32
+ interface PopoverPosition {
33
+ top?: number;
34
+ }
35
+
36
+ const PopoverWrapper = styled.div<PopoverPosition>`
37
+ div[data-radix-popper-content-wrapper] {
38
+ position: absolute !important;
39
+ left: 50% !important;
40
+ transform: translateX(-50%) !important;
41
+ top: ${({ top }) => top}px !important;
42
+ }
43
+
44
+ ${mq.range({ until: breakpoints.tablet })} {
45
+ div[data-radix-popper-content-wrapper] {
46
+ // Fix for popover positioning on mobile.
47
+ // If we modify all popovers we break license icons.
48
+ // https://github.com/radix-ui/primitives/issues/1839
49
+ position: fixed !important;
50
+ transform: none !important;
51
+ top: 0 !important;
52
+ left: 0 !important;
53
+ width: 100vw;
54
+ z-index: 9999 !important;
55
+ height: 100vh;
56
+ min-width: 100vw !important;
57
+ }
58
+ }
59
+ `;
60
+
61
+ const ImageWrapper = styled.div`
62
+ float: right;
63
+ padding-left: ${spacing.normal};
64
+ position: relative;
65
+
66
+ ${mq.range({ until: breakpoints.tabletWide })} {
67
+ width: 100%;
68
+ padding-left: 0;
69
+ }
70
+ `;
71
+
72
+ interface Props {
73
+ embed: ConceptMetaData;
74
+ fullWidth?: boolean;
75
+ }
76
+
77
+ const StyledButton = styled.button`
78
+ background: none;
79
+ border: none;
80
+ font-family: inherit;
81
+ font-style: inherit;
82
+ line-height: 1em;
83
+ padding: 0 0 4px 0;
84
+ margin-bottom: -4px;
85
+ text-decoration: none;
86
+ color: #000;
87
+ position: relative;
88
+ cursor: pointer;
89
+ &:focus,
90
+ &:hover {
91
+ color: ${colors.brand.primary};
92
+ outline: none;
93
+ }
94
+ `;
95
+
96
+ export const ConceptEmbed = ({ embed, fullWidth }: Props) => {
97
+ if (embed.status === 'error') {
98
+ return <span>{embed.embedData.linkText}</span>;
99
+ }
100
+
101
+ const {
102
+ data: { concept, visualElement },
103
+ } = embed;
104
+
105
+ if (embed.embedData.type === 'block') {
106
+ return (
107
+ <BlockConcept
108
+ fullWidth={fullWidth}
109
+ title={concept.title.title}
110
+ content={concept.content?.content}
111
+ metaImage={concept.metaImage}
112
+ copyright={concept.copyright}
113
+ source={concept.source}
114
+ visualElement={visualElement}
115
+ />
116
+ );
117
+ } else if (embed.embedData.type === 'inline') {
118
+ return (
119
+ <InlineConcept
120
+ title={concept.title.title}
121
+ content={concept.content?.content}
122
+ metaImage={concept.metaImage}
123
+ copyright={concept.copyright}
124
+ source={concept.source}
125
+ visualElement={visualElement}
126
+ linkText={embed.embedData.linkText}
127
+ />
128
+ );
129
+ } else {
130
+ return (
131
+ <ConceptNotionV2
132
+ title={concept.title.title}
133
+ content={concept.content?.content}
134
+ metaImage={concept.metaImage}
135
+ copyright={concept.copyright}
136
+ source={concept.source}
137
+ visualElement={visualElement}
138
+ />
139
+ );
140
+ }
141
+ };
142
+
143
+ interface InlineConceptProps extends ConceptNotionData {
144
+ linkText: string;
145
+ }
146
+
147
+ const BaselineIcon = styled.span`
148
+ display: block;
149
+ border-bottom: 5px double currentColor;
150
+ `;
151
+
152
+ const NotionButton = styled.button`
153
+ background: none;
154
+ border: none;
155
+ font-family: inherit;
156
+ font-style: inherit;
157
+ line-height: 1em;
158
+ padding: 0 0 4px 0;
159
+ margin-bottom: -4px;
160
+ text-decoration: none;
161
+ position: relative;
162
+ text-align: left;
163
+ display: inline;
164
+ color: ${colors.notion.dark};
165
+ cursor: pointer;
166
+ &:focus,
167
+ &:hover {
168
+ background-color: ${colors.notion.dark};
169
+ color: ${colors.white};
170
+ outline: none;
171
+ ${BaselineIcon} {
172
+ border-color: transparent;
173
+ }
174
+ }
175
+
176
+ &:active {
177
+ color: ${colors.notion.dark};
178
+ background-color: ${colors.notion.light};
179
+ ${BaselineIcon} {
180
+ border-color: currentColor;
181
+ }
182
+ }
183
+ `;
184
+
185
+ const StyledAnchor = styled(Anchor)`
186
+ ${mq.range({ until: breakpoints.tablet })} {
187
+ position: fixed;
188
+ top: 0;
189
+ }
190
+ `;
191
+
192
+ const StyledAnchorSpan = styled.span`
193
+ position: absolute;
194
+ left: 50%;
195
+ align-self: center;
196
+ `;
197
+
198
+ const getModalPosition = (anchor: HTMLElement) => {
199
+ const article = anchor.closest('.c-article');
200
+ const articlePos = article?.getBoundingClientRect();
201
+ const anchorPos = anchor.getBoundingClientRect();
202
+ return anchorPos.top - (articlePos?.top || -window.scrollY);
203
+ };
204
+
205
+ const InlineConcept = ({ title, content, copyright, source, visualElement, linkText }: InlineConceptProps) => {
206
+ const { t } = useTranslation();
207
+ const anchorRef = useRef<HTMLDivElement>(null);
208
+ const [modalPos, setModalPos] = useState(-9999);
209
+
210
+ const onOpenChange = useCallback((open: boolean) => {
211
+ if (open) {
212
+ const anchor = anchorRef.current;
213
+ if (anchor) {
214
+ const top = getModalPosition(anchor);
215
+ setModalPos(top);
216
+ }
217
+ } else {
218
+ setModalPos(-9999);
219
+ }
220
+ }, []);
221
+
222
+ return (
223
+ <Root modal={isMobile} onOpenChange={onOpenChange}>
224
+ <StyledAnchor ref={anchorRef} asChild>
225
+ <StyledAnchorSpan />
226
+ </StyledAnchor>
227
+ <Trigger asChild>
228
+ <NotionButton>
229
+ {linkText}
230
+ {<BaselineIcon />}
231
+ </NotionButton>
232
+ </Trigger>
233
+ <Portal container={(anchorRef.current?.closest('.c-article') as HTMLElement | null) || undefined}>
234
+ <PopoverWrapper top={modalPos}>
235
+ <Content avoidCollisions={false} side="bottom" asChild>
236
+ <ConceptNotionV2
237
+ title={title}
238
+ content={content}
239
+ copyright={copyright}
240
+ source={source}
241
+ visualElement={visualElement}
242
+ inPopover
243
+ closeButton={
244
+ <Close asChild>
245
+ <IconButtonV2 aria-label={t('close')} variant="ghost">
246
+ <Cross />
247
+ </IconButtonV2>
248
+ </Close>
249
+ }
250
+ />
251
+ </Content>
252
+ </PopoverWrapper>
253
+ </Portal>
254
+ </Root>
255
+ );
256
+ };
257
+
258
+ interface ConceptProps extends ConceptNotionData {
259
+ fullWidth?: boolean;
260
+ }
261
+
262
+ export const BlockConcept = ({
263
+ title,
264
+ content,
265
+ metaImage,
266
+ copyright,
267
+ source,
268
+ visualElement,
269
+ fullWidth,
270
+ }: ConceptProps) => {
271
+ const { t, i18n } = useTranslation();
272
+ const anchorRef = useRef<HTMLDivElement>(null);
273
+ const [modalPos, setModalPos] = useState(-9999);
274
+
275
+ const [isOpen, setIsOpen] = useState(false);
276
+ const licenseCredits = getLicenseCredits(copyright);
277
+ const { creators, rightsholders, processors } = licenseCredits;
278
+ const authors = creators.length || rightsholders.length ? creators.concat(rightsholders) : processors;
279
+ const visualElementType =
280
+ visualElement?.embedData.resource === 'brightcove' ? 'video' : visualElement?.embedData.resource;
281
+
282
+ const groupedAuthors = getGroupedContributorDescriptionList(licenseCredits, i18n.language).map((item) => ({
283
+ name: item.description,
284
+ type: item.label,
285
+ }));
286
+ const license = copyright?.license && getLicenseByAbbreviation(copyright?.license?.license, i18n.language);
287
+
288
+ const onOpenChange = useCallback((open: boolean) => {
289
+ if (open) {
290
+ const anchor = anchorRef.current;
291
+ if (anchor) {
292
+ const top = getModalPosition(anchor);
293
+ setModalPos(top);
294
+ }
295
+ } else {
296
+ setModalPos(-9999);
297
+ }
298
+ }, []);
299
+
300
+ return (
301
+ <Root modal={isMobile} onOpenChange={onOpenChange}>
302
+ <StyledAnchor ref={anchorRef} />
303
+ <Figure resizeIframe type={fullWidth ? 'full' : 'full-column'}>
304
+ <UINotion
305
+ id=""
306
+ title={title}
307
+ text={content}
308
+ visualElement={
309
+ visualElement?.status === 'success' && (
310
+ <>
311
+ <ImageWrapper>
312
+ <Tooltip tooltip={t('searchPage.resultType.showNotion')}>
313
+ <Trigger asChild>
314
+ <StyledButton type="button" aria-label={t('concept.showDescription', { title: title })}>
315
+ {visualElement.resource === 'image' ? (
316
+ <NotionImage
317
+ type={visualElementType}
318
+ id={''}
319
+ src={visualElement.data.imageUrl}
320
+ alt={visualElement.data.alttext.alttext}
321
+ />
322
+ ) : metaImage ? (
323
+ <NotionImage
324
+ type={visualElementType}
325
+ id={''}
326
+ src={metaImage?.url ?? ''}
327
+ alt={metaImage?.alt ?? ''}
328
+ />
329
+ ) : undefined}
330
+ </StyledButton>
331
+ </Trigger>
332
+ </Tooltip>
333
+ </ImageWrapper>
334
+ <Portal
335
+ container={
336
+ typeof document !== 'undefined'
337
+ ? (document.querySelector('.c-article') as HTMLElement | null) || undefined
338
+ : undefined
339
+ }>
340
+ <PopoverWrapper top={modalPos}>
341
+ <Content avoidCollisions={false} asChild side="bottom">
342
+ <ConceptNotionV2
343
+ title={title}
344
+ content={content}
345
+ copyright={copyright}
346
+ source={source}
347
+ visualElement={visualElement}
348
+ inPopover
349
+ closeButton={
350
+ <Close asChild>
351
+ <IconButtonV2 aria-label={t('close')} variant="ghost">
352
+ <Cross />
353
+ </IconButtonV2>
354
+ </Close>
355
+ }
356
+ />
357
+ </Content>
358
+ </PopoverWrapper>
359
+ </Portal>
360
+ </>
361
+ )
362
+ }
363
+ />
364
+ {copyright?.license && license ? (
365
+ <FigureCaption
366
+ figureId=""
367
+ id=""
368
+ authors={authors}
369
+ licenseRights={license.rights}
370
+ locale={i18n.language}
371
+ hideIconsAndAuthors
372
+ modalButton={
373
+ <ButtonV2 variant="outline" size="small" shape="pill" onClick={() => setIsOpen(true)}>
374
+ {t('concept.reuse')}
375
+ </ButtonV2>
376
+ }>
377
+ <ModalV2
378
+ controlled
379
+ isOpen={isOpen}
380
+ onClose={() => setIsOpen(false)}
381
+ labelledBy="license-dialog-rules-heading">
382
+ {(close) => (
383
+ <FigureLicenseDialogContent
384
+ authors={groupedAuthors}
385
+ locale={i18n.language}
386
+ title={title}
387
+ origin={copyright.origin}
388
+ license={license}
389
+ onClose={close}
390
+ type="concept"
391
+ />
392
+ )}
393
+ </ModalV2>
394
+ </FigureCaption>
395
+ ) : (
396
+ <BottomBorder />
397
+ )}
398
+ </Figure>
399
+ </Root>
400
+ );
401
+ };
402
+
403
+ export default ConceptEmbed;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Copyright (c) 2023-present, NDLA.
3
+ *
4
+ * This source code is licensed under the GPLv3 license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import { useTranslation } from 'react-i18next';
10
+ import styled from '@emotion/styled';
11
+ import { colors } from '@ndla/core';
12
+ import { ConceptListMetaData } from '@ndla/types-embed';
13
+ import { Figure } from '../Figure';
14
+ import { BlockConcept } from './ConceptEmbed';
15
+
16
+ interface Props {
17
+ embed: ConceptListMetaData;
18
+ }
19
+
20
+ const ConceptList = styled.div`
21
+ & > figure:first-of-type {
22
+ margin-top: 32px;
23
+ }
24
+ & li {
25
+ display: block;
26
+ }
27
+ `;
28
+
29
+ const StyledSpan = styled.span`
30
+ color: ${colors.support.red};
31
+ `;
32
+
33
+ const ConceptListEmbed = ({ embed }: Props) => {
34
+ const { t } = useTranslation();
35
+ if (embed.status === 'error') {
36
+ return <StyledSpan>{t('embed.conceptListError')}</StyledSpan>;
37
+ }
38
+ const { embedData, data } = embed;
39
+ return (
40
+ <div>
41
+ <Figure type="full" resizeIframe>
42
+ {embedData.title && <h2>{embedData.title}</h2>}
43
+ <ConceptList>
44
+ <ul>
45
+ {data.concepts.map(({ concept, visualElement }) => (
46
+ <li key={concept.id}>
47
+ <BlockConcept
48
+ title={concept.title.title}
49
+ content={concept.content.content}
50
+ metaImage={concept.metaImage}
51
+ copyright={concept.copyright}
52
+ source={concept.source}
53
+ visualElement={visualElement}
54
+ />
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ </ConceptList>
59
+ </Figure>
60
+ </div>
61
+ );
62
+ };
63
+
64
+ export default ConceptListEmbed;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Copyright (c) 2023-present, NDLA.
3
+ *
4
+ * This source code is licensed under the GPLv3 license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import { useTranslation } from 'react-i18next';
10
+ import styled from '@emotion/styled';
11
+ import { colors } from '@ndla/core';
12
+ import { ContentLinkMetaData } from '@ndla/types-embed';
13
+ interface Props {
14
+ embed: ContentLinkMetaData;
15
+ isOembed?: boolean;
16
+ }
17
+
18
+ const StyledSpan = styled.span`
19
+ color: ${colors.support.red};
20
+ `;
21
+
22
+ const ContentLinkEmbed = ({ embed, isOembed }: Props) => {
23
+ const { t } = useTranslation();
24
+ if (embed.status === 'error') {
25
+ return <StyledSpan>{`${t('embed.linkError')}: ${embed.embedData.linkText}`}</StyledSpan>;
26
+ }
27
+
28
+ const { embedData, data } = embed;
29
+
30
+ if (embedData.openIn === 'new-context' || isOembed) {
31
+ return (
32
+ <a href={embed.data.path} target="_blank" rel="noopener noreferrer">
33
+ {embedData.linkText}
34
+ </a>
35
+ );
36
+ }
37
+
38
+ return <a href={data.path}>{embedData.linkText}</a>;
39
+ };
40
+
41
+ export default ContentLinkEmbed;