@memori.ai/memori-react 8.15.1 → 8.16.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 (166) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/components/Chat/Chat.css +2 -1
  3. package/dist/components/Chat/Chat.js +17 -17
  4. package/dist/components/Chat/Chat.js.map +1 -1
  5. package/dist/components/ChatBubble/ChatBubble.css +1 -1
  6. package/dist/components/ContentPreviewModal/ContentPreviewModal.css +114 -0
  7. package/dist/components/ContentPreviewModal/ContentPreviewModal.d.ts +14 -0
  8. package/dist/components/ContentPreviewModal/ContentPreviewModal.js +18 -0
  9. package/dist/components/ContentPreviewModal/ContentPreviewModal.js.map +1 -0
  10. package/dist/components/ContentPreviewModal/index.d.ts +2 -0
  11. package/dist/components/ContentPreviewModal/index.js +9 -0
  12. package/dist/components/ContentPreviewModal/index.js.map +1 -0
  13. package/dist/components/FilePreview/FilePreview.css +1 -1
  14. package/dist/components/FilePreview/FilePreview.js +43 -13
  15. package/dist/components/FilePreview/FilePreview.js.map +1 -1
  16. package/dist/components/MediaWidget/DocumentCard.d.ts +3 -0
  17. package/dist/components/MediaWidget/DocumentCard.js +9 -0
  18. package/dist/components/MediaWidget/DocumentCard.js.map +1 -0
  19. package/dist/components/MediaWidget/MediaItemWidget.css +946 -19
  20. package/dist/components/MediaWidget/MediaItemWidget.d.ts +5 -36
  21. package/dist/components/MediaWidget/MediaItemWidget.js +295 -198
  22. package/dist/components/MediaWidget/MediaItemWidget.js.map +1 -1
  23. package/dist/components/MediaWidget/MediaItemWidget.types.d.ts +62 -0
  24. package/dist/components/MediaWidget/MediaItemWidget.types.js +3 -0
  25. package/dist/components/MediaWidget/MediaItemWidget.types.js.map +1 -0
  26. package/dist/components/MediaWidget/MediaItemWidget.utils.d.ts +23 -0
  27. package/dist/components/MediaWidget/MediaItemWidget.utils.js +162 -0
  28. package/dist/components/MediaWidget/MediaItemWidget.utils.js.map +1 -0
  29. package/dist/components/MediaWidget/MediaPreviewModal.d.ts +15 -0
  30. package/dist/components/MediaWidget/MediaPreviewModal.js +162 -0
  31. package/dist/components/MediaWidget/MediaPreviewModal.js.map +1 -0
  32. package/dist/components/MediaWidget/MediaWidget.js +1 -2
  33. package/dist/components/MediaWidget/MediaWidget.js.map +1 -1
  34. package/dist/components/Snippet/Snippet.css +64 -33
  35. package/dist/components/Snippet/Snippet.js +17 -4
  36. package/dist/components/Snippet/Snippet.js.map +1 -1
  37. package/dist/components/StartPanel/StartPanel.js +1 -2
  38. package/dist/components/StartPanel/StartPanel.js.map +1 -1
  39. package/dist/components/UploadButton/UploadButton.css +0 -5
  40. package/dist/components/layouts/WebsiteAssistant.js +8 -8
  41. package/dist/components/layouts/WebsiteAssistant.js.map +1 -1
  42. package/dist/components/layouts/chat.css +1 -1
  43. package/dist/components/layouts/website-assistant.css +405 -197
  44. package/dist/helpers/constants.js +0 -7
  45. package/dist/helpers/constants.js.map +1 -1
  46. package/dist/helpers/utils.d.ts +1 -0
  47. package/dist/helpers/utils.js +3 -1
  48. package/dist/helpers/utils.js.map +1 -1
  49. package/dist/index.js +43 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/styles.css +0 -2
  52. package/dist/version.d.ts +1 -0
  53. package/dist/version.js +5 -0
  54. package/dist/version.js.map +1 -0
  55. package/esm/components/Chat/Chat.css +2 -1
  56. package/esm/components/Chat/Chat.js +17 -17
  57. package/esm/components/Chat/Chat.js.map +1 -1
  58. package/esm/components/ChatBubble/ChatBubble.css +1 -1
  59. package/esm/components/ContentPreviewModal/ContentPreviewModal.css +114 -0
  60. package/esm/components/ContentPreviewModal/ContentPreviewModal.d.ts +14 -0
  61. package/esm/components/ContentPreviewModal/ContentPreviewModal.js +15 -0
  62. package/esm/components/ContentPreviewModal/ContentPreviewModal.js.map +1 -0
  63. package/esm/components/ContentPreviewModal/index.d.ts +2 -0
  64. package/esm/components/ContentPreviewModal/index.js +2 -0
  65. package/esm/components/ContentPreviewModal/index.js.map +1 -0
  66. package/esm/components/FilePreview/FilePreview.css +1 -1
  67. package/esm/components/FilePreview/FilePreview.js +44 -14
  68. package/esm/components/FilePreview/FilePreview.js.map +1 -1
  69. package/esm/components/MediaWidget/DocumentCard.d.ts +3 -0
  70. package/esm/components/MediaWidget/DocumentCard.js +5 -0
  71. package/esm/components/MediaWidget/DocumentCard.js.map +1 -0
  72. package/esm/components/MediaWidget/MediaItemWidget.css +946 -19
  73. package/esm/components/MediaWidget/MediaItemWidget.d.ts +5 -36
  74. package/esm/components/MediaWidget/MediaItemWidget.js +296 -197
  75. package/esm/components/MediaWidget/MediaItemWidget.js.map +1 -1
  76. package/esm/components/MediaWidget/MediaItemWidget.types.d.ts +62 -0
  77. package/esm/components/MediaWidget/MediaItemWidget.types.js +2 -0
  78. package/esm/components/MediaWidget/MediaItemWidget.types.js.map +1 -0
  79. package/esm/components/MediaWidget/MediaItemWidget.utils.d.ts +23 -0
  80. package/esm/components/MediaWidget/MediaItemWidget.utils.js +149 -0
  81. package/esm/components/MediaWidget/MediaItemWidget.utils.js.map +1 -0
  82. package/esm/components/MediaWidget/MediaPreviewModal.d.ts +15 -0
  83. package/esm/components/MediaWidget/MediaPreviewModal.js +157 -0
  84. package/esm/components/MediaWidget/MediaPreviewModal.js.map +1 -0
  85. package/esm/components/MediaWidget/MediaWidget.js +1 -2
  86. package/esm/components/MediaWidget/MediaWidget.js.map +1 -1
  87. package/esm/components/Snippet/Snippet.css +64 -33
  88. package/esm/components/Snippet/Snippet.js +18 -5
  89. package/esm/components/Snippet/Snippet.js.map +1 -1
  90. package/esm/components/StartPanel/StartPanel.js +1 -2
  91. package/esm/components/StartPanel/StartPanel.js.map +1 -1
  92. package/esm/components/UploadButton/UploadButton.css +0 -5
  93. package/esm/components/layouts/WebsiteAssistant.js +8 -8
  94. package/esm/components/layouts/WebsiteAssistant.js.map +1 -1
  95. package/esm/components/layouts/chat.css +1 -1
  96. package/esm/components/layouts/website-assistant.css +405 -197
  97. package/esm/helpers/constants.js +0 -7
  98. package/esm/helpers/constants.js.map +1 -1
  99. package/esm/helpers/utils.d.ts +1 -0
  100. package/esm/helpers/utils.js +1 -0
  101. package/esm/helpers/utils.js.map +1 -1
  102. package/esm/index.js +43 -1
  103. package/esm/index.js.map +1 -1
  104. package/esm/styles.css +0 -2
  105. package/esm/version.d.ts +1 -0
  106. package/esm/version.js +2 -0
  107. package/esm/version.js.map +1 -0
  108. package/package.json +5 -3
  109. package/src/components/Chat/Chat.css +2 -1
  110. package/src/components/Chat/Chat.stories.tsx +124 -0
  111. package/src/components/Chat/Chat.tsx +72 -71
  112. package/src/components/Chat/__snapshots__/Chat.test.tsx.snap +567 -1034
  113. package/src/components/ChatBubble/ChatBubble.css +1 -1
  114. package/src/components/ContentPreviewModal/ContentPreviewModal.css +114 -0
  115. package/src/components/ContentPreviewModal/ContentPreviewModal.tsx +69 -0
  116. package/src/components/ContentPreviewModal/index.ts +2 -0
  117. package/src/components/FilePreview/FilePreview.css +1 -1
  118. package/src/components/FilePreview/FilePreview.tsx +60 -37
  119. package/src/components/FilePreview/__snapshots__/FilePreview.test.tsx.snap +15 -105
  120. package/src/components/MediaWidget/DocumentCard.test.tsx +45 -0
  121. package/src/components/MediaWidget/DocumentCard.tsx +19 -0
  122. package/src/components/MediaWidget/MediaItemWidget.css +946 -19
  123. package/src/components/MediaWidget/MediaItemWidget.test.tsx +89 -1
  124. package/src/components/MediaWidget/MediaItemWidget.tsx +734 -461
  125. package/src/components/MediaWidget/MediaItemWidget.types.ts +65 -0
  126. package/src/components/MediaWidget/MediaItemWidget.utils.test.ts +324 -0
  127. package/src/components/MediaWidget/MediaItemWidget.utils.ts +194 -0
  128. package/src/components/MediaWidget/MediaPreviewModal.test.tsx +271 -0
  129. package/src/components/MediaWidget/MediaPreviewModal.tsx +423 -0
  130. package/src/components/MediaWidget/MediaWidget.stories.tsx +193 -0
  131. package/src/components/MediaWidget/MediaWidget.tsx +2 -4
  132. package/src/components/MediaWidget/__snapshots__/DocumentCard.test.tsx.snap +24 -0
  133. package/src/components/MediaWidget/__snapshots__/MediaItemWidget.test.tsx.snap +162 -170
  134. package/src/components/MediaWidget/__snapshots__/MediaWidget.test.tsx.snap +21 -63
  135. package/src/components/Snippet/Snippet.css +64 -33
  136. package/src/components/Snippet/Snippet.tsx +30 -21
  137. package/src/components/Snippet/__snapshots__/Snippet.test.tsx.snap +314 -297
  138. package/src/components/StartPanel/StartPanel.tsx +0 -9
  139. package/src/components/StartPanel/__snapshots__/StartPanel.test.tsx.snap +12 -636
  140. package/src/components/UploadButton/UploadButton.css +0 -5
  141. package/src/components/layouts/WebsiteAssistant.tsx +66 -62
  142. package/src/components/layouts/__snapshots__/Chat.test.tsx.snap +1 -53
  143. package/src/components/layouts/__snapshots__/FullPage.test.tsx.snap +2 -106
  144. package/src/components/layouts/__snapshots__/HiddenChat.test.tsx.snap +1 -53
  145. package/src/components/layouts/__snapshots__/Totem.test.tsx.snap +1 -53
  146. package/src/components/layouts/__snapshots__/WebsiteAssistant.test.tsx.snap +32 -33
  147. package/src/components/layouts/__snapshots__/ZoomedFullBody.test.tsx.snap +1 -53
  148. package/src/components/layouts/chat.css +1 -1
  149. package/src/components/layouts/layouts.stories.tsx +68 -0
  150. package/src/components/layouts/website-assistant.css +405 -197
  151. package/src/helpers/constants.ts +0 -7
  152. package/src/helpers/utils.ts +4 -0
  153. package/src/index.test.tsx +8 -0
  154. package/src/index.tsx +51 -1
  155. package/src/styles.css +0 -2
  156. package/src/version.ts +2 -0
  157. package/src/components/AttachmentLinkModal/AttachmentLinkModal.css +0 -68
  158. package/src/components/AttachmentLinkModal/AttachmentLinkModal.stories.tsx +0 -32
  159. package/src/components/AttachmentLinkModal/AttachmentLinkModal.test.tsx +0 -10
  160. package/src/components/AttachmentLinkModal/AttachmentLinkModal.tsx +0 -131
  161. package/src/components/AttachmentLinkModal/__snapshots__/AttachmentLinkModal.test.tsx.snap +0 -9
  162. package/src/components/MediaWidget/LinkItemWidget.css +0 -46
  163. package/src/components/MediaWidget/LinkItemWidget.stories.tsx +0 -61
  164. package/src/components/MediaWidget/LinkItemWidget.test.tsx +0 -33
  165. package/src/components/MediaWidget/LinkItemWidget.tsx +0 -204
  166. package/src/components/MediaWidget/__snapshots__/LinkItemWidget.test.tsx.snap +0 -253
@@ -1,58 +1,104 @@
1
- import { Medium } from '@memori.ai/memori-api-client/dist/types';
2
- import React, { useCallback, useEffect, useState, memo } from 'react';
1
+ // MediaItemWidget.tsx Main Media Item Widget file for rendering various types of media inside Memori
2
+ import type { Medium } from '@memori.ai/memori-api-client/dist/types';
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ memo,
9
+ useRef,
10
+ } from 'react';
3
11
  import { getResourceUrl } from '../../helpers/media';
12
+ import {
13
+ withLinksOpenInNewTab,
14
+ stripDocumentAttachmentTags,
15
+ } from '../../helpers/utils';
4
16
  import { getTranslation } from '../../helpers/translations';
5
17
  import { prismSyntaxLangs } from '../../helpers/constants';
6
18
  import ModelViewer from '../CustomGLBModelViewer/ModelViewer';
7
19
  import Snippet from '../Snippet/Snippet';
8
20
  import Card from '../ui/Card';
9
21
  import Modal from '../ui/Modal';
10
- import Button from '../ui/Button';
11
22
  import File from '../icons/File';
12
- import FilePdf from '../icons/FilePdf';
13
- import FileExcel from '../icons/FileExcel';
14
- import FileWord from '../icons/FileWord';
15
- import Copy from '../icons/Copy';
16
23
  import { Transition } from '@headlessui/react';
17
- import { stripHTML } from '../../helpers/utils';
18
24
  import cx from 'classnames';
19
- export interface Props {
20
- items: (Medium & { type?: string })[];
21
- sessionID?: string;
22
- tenantID?: string;
23
- translateTo?: string;
24
- baseURL?: string;
25
- apiURL?: string;
26
- customMediaRenderer?: (mimeType: string) => JSX.Element | null;
27
- fromUser?: boolean;
25
+ import Sound from '../icons/Sound';
26
+ import Link from '../icons/Link';
27
+ import { ellipsis } from 'ellipsed';
28
+
29
+ import type {
30
+ MediaItemWidgetProps as Props,
31
+ MediaItem,
32
+ RenderMediaItemProps,
33
+ RenderSnippetItemProps,
34
+ LinkPreviewInfo,
35
+ } from './MediaItemWidget.types';
36
+ import {
37
+ formatBytes,
38
+ getFileExtensionFromUrl,
39
+ getFileExtensionFromMime,
40
+ countLines,
41
+ shouldUseDarkFileCard,
42
+ fetchLinkPreview,
43
+ getContentSize,
44
+ normalizeUrl,
45
+ getImageDisplaySource,
46
+ FALLBACK_IMAGE_BASE64,
47
+ TEXT_FILE_EXTENSIONS,
48
+ IMAGE_MIME_TYPES,
49
+ } from './MediaItemWidget.utils';
50
+ import { DocumentCard } from './DocumentCard';
51
+ import { MediaPreviewModal } from './MediaPreviewModal';
52
+
53
+ export type {
54
+ LinkPreviewInfo,
55
+ ILinkPreviewInfo,
56
+ } from './MediaItemWidget.types';
57
+ export type { Props };
58
+
59
+ // List of code mime types from Prism's available languages
60
+ const CODE_MIME_TYPES = prismSyntaxLangs.map(l => l.mimeType);
61
+
62
+ /** Image MIME types that open in the preview modal on click (when mediumID + onClick are set) */
63
+ const IMAGE_MODAL_MIME_TYPES = [
64
+ 'image/jpeg',
65
+ 'image/png',
66
+ 'image/jpg',
67
+ 'image/gif',
68
+ ] as const;
69
+
70
+ function isImageMime(mimeType: string): boolean {
71
+ return (IMAGE_MODAL_MIME_TYPES as readonly string[]).includes(mimeType);
28
72
  }
29
73
 
30
- export const RenderMediaItem = ({
31
- isChild = false,
74
+ // RenderMediaItem Renders the suitable content for a Medium (images, files, html, code, audio, video…)
75
+ export const RenderMediaItem = memo(function RenderMediaItem({
76
+ isChild: _isChild = false,
32
77
  item,
33
78
  sessionID,
34
79
  tenantID,
35
80
  preview = false,
36
81
  baseURL,
37
82
  apiURL,
38
- onClick,
83
+ onClick: _onClick,
39
84
  customMediaRenderer,
40
- }: {
41
- isChild?: boolean;
42
- item: Medium & { type: string };
43
- sessionID?: string;
44
- tenantID?: string;
45
- preview?: boolean;
46
- baseURL?: string;
47
- apiURL?: string;
48
- onClick?: (mediumID: string) => void;
49
- customMediaRenderer?: (mimeType: string) => JSX.Element | null;
50
- }) => {
51
- const [modalOpen, setModalOpen] = useState(false);
85
+ descriptionOneLine = false,
86
+ onLinkPreviewInfo,
87
+ }: RenderMediaItemProps): React.ReactElement | null {
88
+ // State for "copy to clipboard" notification for snippets
52
89
  const [copyNotification, setCopyNotification] = useState(false);
90
+ // State for fallback image
53
91
  const [imageError, setImageError] = useState(false);
54
-
55
- const url = getResourceUrl({
92
+ // Link preview info (site title, description, image, etc)
93
+ const [link, setLink] = useState<
94
+ (LinkPreviewInfo & { urlKey: string }) | null
95
+ >(null);
96
+ // Persistent ref for onLinkPreviewInfo callback
97
+ const onLinkPreviewInfoRef = useRef(onLinkPreviewInfo);
98
+ onLinkPreviewInfoRef.current = onLinkPreviewInfo;
99
+
100
+ // Get URL with possible session/tenant/base/api
101
+ const resourceUrl = getResourceUrl({
56
102
  resourceURI: item.url,
57
103
  sessionID,
58
104
  tenantID,
@@ -60,300 +106,487 @@ export const RenderMediaItem = ({
60
106
  apiURL,
61
107
  });
62
108
 
63
- const customRenderer = customMediaRenderer?.(item.mimeType);
109
+ // Normalize URL (strip protocol, etc)
110
+ const normURL = normalizeUrl(item.url);
111
+
112
+ // Fetch link preview info for HTML links, only if relevant and not already loaded
113
+ useEffect(() => {
114
+ if (
115
+ item.mimeType !== 'text/html' ||
116
+ !normURL ||
117
+ normURL === link?.urlKey ||
118
+ !baseURL
119
+ ) {
120
+ return;
121
+ }
122
+ let cancelled = false;
123
+ fetchLinkPreview(normURL, baseURL).then(siteInfo => {
124
+ if (cancelled) return;
125
+ setLink(
126
+ siteInfo
127
+ ? ({ ...siteInfo, urlKey: normURL } as LinkPreviewInfo & {
128
+ urlKey: string;
129
+ })
130
+ : null
131
+ );
132
+ if (siteInfo && onLinkPreviewInfoRef.current) {
133
+ onLinkPreviewInfoRef.current(siteInfo);
134
+ }
135
+ });
136
+ return () => {
137
+ cancelled = true;
138
+ };
139
+ }, [item?.url, baseURL, item.mimeType, normURL, link?.urlKey]);
64
140
 
141
+ // Custom renderer for media type, overrides our logic
142
+ const customRenderer = customMediaRenderer?.(item.mimeType);
65
143
  if (customRenderer) {
66
144
  return customRenderer;
67
145
  }
68
146
 
69
- const isCodeSnippet = prismSyntaxLangs
70
- .map(l => l.mimeType)
71
- .includes(item.mimeType);
147
+ // Media type detection flags
148
+ const isCodeSnippet = CODE_MIME_TYPES.includes(item.mimeType);
72
149
  const isHTML = item.mimeType === 'text/html';
73
- const isImageRGB =
74
- item.url?.startsWith('rgb(') || item.url?.startsWith('rgba(');
75
-
76
- // Helper to validate if a string is a valid URL
77
- const isValidUrl = (urlString: string | undefined): boolean => {
78
- if (!urlString) return false;
79
- try {
80
- new URL(urlString);
81
- return true;
82
- } catch {
83
- return false;
84
- }
85
- };
86
-
87
- // Helper to get valid image src
88
- const getImageSrc = (): string | undefined => {
89
- // If url is valid, use it
90
- if (isValidUrl(url) || isValidUrl(item.url)) {
91
- return url || item.url;
92
- } else if (item.url?.startsWith('rgb(') || item.url?.startsWith('rgba(')) {
93
- return item.url;
94
- } else if (item.content) {
95
- return `data:${item.mimeType};base64,${item.content}`;
96
- }
97
-
98
- // Return undefined if no valid source
99
- return undefined;
100
- };
101
-
102
- const imageSrc = getImageSrc();
103
- const fallbackImage =
104
- 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM5OTk5OTkiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JbWFnZSBub3QgYXZhaWxhYmxlPC90ZXh0Pjwvc3ZnPg==';
105
-
106
- const renderMediaContent = (item: Medium) => {
107
- switch (item.mimeType) {
108
- case 'image/jpeg':
109
- case 'image/png':
110
- case 'image/jpg':
111
- case 'image/gif':
112
- return isImageRGB ? (
113
- <picture className="memori-media-item--figure">
114
- <div
115
- className="memori-media-item--rgb-item"
116
- style={{
117
- backgroundColor: item.url,
118
- }}
119
- />
120
- {item.title && (
121
- <figcaption className="memori-media-item--figure-caption">
122
- {item.title}
123
- </figcaption>
124
- )}
125
- </picture>
126
- ) : (
127
- <picture className="memori-media-item--figure">
128
- {!preview && imageSrc && (
129
- <source
130
- srcSet={imageSrc}
131
- type={item.mimeType}
150
+ const isDocumentAttachment = item.properties?.isDocumentAttachment === true;
151
+ const isAttachedFile = item.properties?.isAttachedFile === true;
152
+
153
+ // Single source of truth for image display (resource URL, base64, or rgb/rgba)
154
+ const imageDisplay = getImageDisplaySource(item, resourceUrl);
155
+ const { src: imageSrc, isRgb: isImageRGB } = imageDisplay;
156
+
157
+ // Link preview fields (title, description, video, image)
158
+ const linkTitle =
159
+ item.title && item.title.length > 0 ? item.title : link?.title;
160
+ const linkDescription = link?.description;
161
+ const linkVideo = link?.video;
162
+ const linkImage = link?.image ?? link?.images?.[0];
163
+
164
+ /**
165
+ * Render the actual content for a media item based on its MIME type
166
+ */
167
+ const renderMediaContent = useCallback(
168
+ (medium: Medium & { type?: string }) => {
169
+ // Get resource url for this medium
170
+ const url = getResourceUrl({
171
+ resourceURI: medium.url,
172
+ sessionID,
173
+ tenantID,
174
+ baseURL,
175
+ apiURL,
176
+ });
177
+
178
+ switch (medium.mimeType) {
179
+ // Render images (RGB images as colored swatches)
180
+ case 'image/jpeg':
181
+ case 'image/png':
182
+ case 'image/jpg':
183
+ case 'image/gif':
184
+ return isImageRGB ? (
185
+ <picture className="memori-media-item--figure">
186
+ <div
187
+ className="memori-media-item--rgb-item"
188
+ style={{ backgroundColor: medium.url }}
132
189
  />
133
- )}
134
- <img
135
- alt={item.title}
136
- src={imageError || !imageSrc ? fallbackImage : imageSrc}
137
- onError={() => setImageError(true)}
190
+ </picture>
191
+ ) : (
192
+ <picture className="memori-media-item--figure">
193
+ {!preview && imageSrc && (
194
+ <source srcSet={imageSrc} type={medium.mimeType} />
195
+ )}
196
+ <img
197
+ alt={medium.title}
198
+ src={imageError || !imageSrc ? FALLBACK_IMAGE_BASE64 : imageSrc}
199
+ onError={() => setImageError(true)}
200
+ />
201
+ </picture>
202
+ );
203
+
204
+ // Render video types
205
+ case 'video/mp4':
206
+ case 'video/quicktime':
207
+ case 'video/avi':
208
+ case 'video/mpeg':
209
+ return (
210
+ <div className="memori-media-item--video-container">
211
+ <video
212
+ className="memori-media-item--video-player"
213
+ controls
214
+ src={url}
215
+ title={medium.title}
216
+ >
217
+ {/* Quicktime special fallback for .mp4 */}
218
+ {medium.mimeType === 'video/quicktime' && (
219
+ <source src={medium.url} type="video/mp4" />
220
+ )}
221
+ <source src={medium.url} type={medium.mimeType} />
222
+ Your browser does not support this video format.
223
+ </video>
224
+ {/* Play overlay icon (hidden by default) */}
225
+ <div className="memori-media-item--video-overlay hidden">
226
+ <svg
227
+ className="memori-media-item--play-icon"
228
+ viewBox="0 0 24 24"
229
+ fill="currentColor"
230
+ >
231
+ <path d="M8 5v14l11-7z" />
232
+ </svg>
233
+ </div>
234
+ </div>
235
+ );
236
+
237
+ // Render audio types (shows a sound icon + audio controls)
238
+ case 'audio/mpeg3':
239
+ case 'audio/wav':
240
+ case 'audio/mpeg':
241
+ return (
242
+ <div className="memori-media-item--audio-container">
243
+ <div className="memori-media-item--audio-icon">
244
+ <Sound />
245
+ </div>
246
+ <audio
247
+ className="memori-media-item--audio-player"
248
+ controls
249
+ src={url}
250
+ />
251
+ </div>
252
+ );
253
+
254
+ // Render 3D models (GLB only)
255
+ case 'model/gltf-binary':
256
+ return (
257
+ <div className="memori-media-item--model-container">
258
+ <div className="memori-media-item--model-viewer">
259
+ <ModelViewer
260
+ src={url}
261
+ alt=""
262
+ poster="data:image/png;base64,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"
263
+ />
264
+ </div>
265
+ </div>
266
+ );
267
+
268
+ // HTML files are now handled as file cards (rendered above in the isFile check)
269
+ // This case is kept for backwards compatibility but should not be reached
270
+ case 'text/html':
271
+ // Fallback to file card - HTML files are handled in the file card section above
272
+ return (
273
+ <DocumentCard
274
+ title={medium.title || 'File'}
275
+ badge={getFileExtensionFromMime(medium.mimeType)}
276
+ meta={(() => {
277
+ const size = getContentSize(medium);
278
+ return size != null && size > 0 ? formatBytes(size) : null;
279
+ })()}
280
+ icon={<Link className="memori-media-item--document-icon-svg" />}
138
281
  />
139
- {item.title && (
140
- <figcaption className="memori-media-item--figure-caption">
141
- {item.title}
142
- </figcaption>
143
- )}
144
- </picture>
145
- );
146
-
147
- case 'application/msword':
148
- case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
149
- return <FileWord className="memori-media-item--icon" />;
150
-
151
- case 'application/vnd.ms-excel':
152
- case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
153
- return <FileExcel className="memori-media-item--icon" />;
154
-
155
- case 'application/pdf':
156
- return <FilePdf className="memori-media-item--icon" />;
282
+ );
283
+
284
+ // Generic fallback: Render a document card for unknown types
285
+ default:
286
+ return (
287
+ <DocumentCard
288
+ title={medium.title || 'File'}
289
+ badge={getFileExtensionFromMime(medium.mimeType)}
290
+ meta={(() => {
291
+ const size = getContentSize(medium);
292
+ return size != null && size > 0 ? formatBytes(size) : null;
293
+ })()}
294
+ icon={<File className="memori-media-item--document-icon-svg" />}
295
+ />
296
+ );
297
+ }
298
+ },
299
+ [
300
+ sessionID,
301
+ tenantID,
302
+ baseURL,
303
+ apiURL,
304
+ preview,
305
+ imageSrc,
306
+ imageError,
307
+ isImageRGB,
308
+ linkImage,
309
+ linkVideo,
310
+ ]
311
+ );
157
312
 
158
- case 'video/mp4':
159
- case 'video/quicktime':
160
- case 'video/avi':
161
- case 'video/mpeg':
162
- return (
163
- <video
164
- style={{ width: '100%', height: '100%' }}
165
- controls
166
- src={url}
167
- title={item.title}
168
- >
169
- {item.mimeType === 'video/quicktime' && (
170
- <source src={item.url} type="video/mp4" />
171
- )}
172
- <source src={item.url} type={item.mimeType} />
173
- Your browser does not support this video format.
174
- <br />
175
- <a href={item.url} target="_blank" rel="noopener noreferrer">
176
- Download the video
177
- </a>
178
- </video>
179
- );
313
+ // Extension and file detection helpers
314
+ const fileExtensionFromUrl = getFileExtensionFromUrl(normURL || item.url);
315
+ const fileExtensionFromMime = getFileExtensionFromMime(item.mimeType);
316
+ const fileExtension = fileExtensionFromUrl || fileExtensionFromMime;
317
+ const isFile = shouldUseDarkFileCard(
318
+ item,
319
+ fileExtensionFromUrl,
320
+ item.mimeType
321
+ );
322
+ // Text file detection for line counting
323
+ const isTextFile = (TEXT_FILE_EXTENSIONS as readonly string[]).includes(
324
+ fileExtension || ''
325
+ );
180
326
 
181
- case 'audio/mpeg3':
182
- case 'audio/wav':
183
- case 'audio/mpeg':
184
- return (
185
- <audio style={{ width: '100%', height: '100%' }} controls src={url} />
186
- );
327
+ // Derive line count and line label for text files
328
+ const lineCount =
329
+ isTextFile && item.content ? countLines(item.content) : null;
330
+ const lineText =
331
+ lineCount !== null
332
+ ? lineCount === 1
333
+ ? '1 line'
334
+ : `${lineCount} lines`
335
+ : null;
336
+
337
+ // File-like cards that are NOT code: render as clickable file cards
338
+ if (isFile && !isCodeSnippet) {
339
+ const contentSize = getContentSize(item);
340
+ const sizeText =
341
+ contentSize != null && contentSize > 0 ? formatBytes(contentSize) : null;
342
+ const displayName = item.title || linkTitle || 'File';
343
+ const metaParts = [lineText, sizeText].filter(Boolean);
344
+ const metaLine = metaParts.length > 0 ? metaParts.join(' · ') : null;
345
+
346
+ // Document attachments and attached files should open in modal, not as links
347
+ if ((isDocumentAttachment || isAttachedFile) && item.mediumID && _onClick) {
348
+ return (
349
+ <div
350
+ onClick={() => _onClick(item)}
351
+ className="memori-media-item--link memori-media-item--document-link"
352
+ style={{ cursor: 'pointer' }}
353
+ title={displayName}
354
+ role="button"
355
+ tabIndex={0}
356
+ onKeyDown={e => {
357
+ if (e.key === 'Enter' || e.key === ' ') {
358
+ e.preventDefault();
359
+ _onClick(item);
360
+ }
361
+ }}
362
+ >
363
+ <DocumentCard
364
+ title={displayName}
365
+ badge={
366
+ item.mimeType === 'text/html' && !!item.url
367
+ ? 'Link'
368
+ : fileExtension
369
+ }
370
+ meta={metaLine}
371
+ icon={
372
+ item.mimeType === 'text/html' ? (
373
+ <Link className="memori-media-item--document-icon-svg" />
374
+ ) : (
375
+ <File className="memori-media-item--document-icon-svg" />
376
+ )
377
+ }
378
+ />
379
+ </div>
380
+ );
381
+ }
187
382
 
188
- case 'model/gltf-binary':
383
+ // Build href: open in new tab (never modal). Use URL, or blob for content-only items.
384
+ const getFileCardHref = (): string => {
385
+ if (item.url) {
189
386
  return (
190
- <ModelViewer
191
- src={url}
192
- alt=""
193
- poster="data:image/png;base64,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"
194
- />
387
+ getResourceUrl({
388
+ resourceURI: item.url,
389
+ sessionID,
390
+ tenantID,
391
+ baseURL,
392
+ apiURL,
393
+ }) ||
394
+ item.url ||
395
+ '#'
195
396
  );
397
+ }
398
+ if (isHTML && item.content) {
399
+ let htmlContent = item.content;
400
+ if (
401
+ item.properties?.isDocumentAttachment ||
402
+ htmlContent.includes('document_attachment') ||
403
+ htmlContent.includes('<document_attachment')
404
+ ) {
405
+ if (htmlContent.includes('&lt;') || htmlContent.includes('&quot;')) {
406
+ const div = document.createElement('div');
407
+ div.innerHTML = htmlContent;
408
+ htmlContent = div.textContent || div.innerText || htmlContent;
409
+ }
410
+ htmlContent = stripDocumentAttachmentTags(htmlContent);
411
+ }
412
+ const blob = new Blob([htmlContent], { type: 'text/html' });
413
+ return URL.createObjectURL(blob);
414
+ }
415
+ if (item.content) {
416
+ const blob = new Blob([item.content], {
417
+ type: item.mimeType || 'text/plain',
418
+ });
419
+ return URL.createObjectURL(blob);
420
+ }
421
+ return '#';
422
+ };
423
+
424
+ const hrefUrl = getFileCardHref();
196
425
 
197
- default:
198
- return <File className="memori-media-item--icon" />;
199
- }
200
- };
201
-
202
- // Handle code snippets with modal
203
- if ((isCodeSnippet && item.content) || isHTML) {
204
426
  return (
205
- <>
206
- <a
207
- className="memori-media-item--link"
208
- href="#"
209
- onClick={e => {
210
- e.preventDefault();
211
- setModalOpen(true);
212
- }}
213
- title={item.title}
214
- >
215
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
216
- </a>
217
-
218
- <Modal
219
- open={modalOpen}
220
- onClose={() => setModalOpen(false)}
221
- title={item.title}
222
- className="memori-media-item-preview--modal"
223
- width="80%"
224
- widthMd="90%"
225
- >
226
- <div className="memori-media-item-preview--content">
227
- <Snippet medium={item} showCopyButton={true} />
228
- </div>
229
- </Modal>
230
- </>
427
+ <a
428
+ href={hrefUrl}
429
+ target="_blank"
430
+ rel="noopener noreferrer"
431
+ className="memori-media-item--link memori-media-item--document-link"
432
+ title={displayName}
433
+ >
434
+ <DocumentCard
435
+ title={displayName}
436
+ badge={
437
+ item.mimeType === 'text/html' && !!item.url ? 'Link' : fileExtension
438
+ }
439
+ meta={metaLine}
440
+ icon={
441
+ item.mimeType === 'text/html' ? (
442
+ <Link className="memori-media-item--document-icon-svg" />
443
+ ) : (
444
+ <File className="memori-media-item--document-icon-svg" />
445
+ )
446
+ }
447
+ />
448
+ </a>
231
449
  );
232
450
  }
233
451
 
234
- if (
235
- !item.url &&
236
- item?.type === 'document' &&
237
- item.content &&
238
- !['image/jpeg', 'image/png', 'image/jpg', 'image/gif'].includes(
239
- item.mimeType
240
- )
241
- ) {
452
+ // Inline previews for code snippets: open in new tab (blob URL if no resource URL)
453
+ if (isCodeSnippet && item.content && item.mediumID && _onClick) {
242
454
  return (
243
- <>
244
- <a
245
- className="memori-media-item--link"
246
- href="#"
247
- onClick={e => {
455
+ <div
456
+ className="memori-media-item--link"
457
+ style={{ cursor: 'pointer' }}
458
+ onClick={() => _onClick(item)}
459
+ title={item.title}
460
+ role="button"
461
+ tabIndex={0}
462
+ onKeyDown={e => {
463
+ if (e.key === 'Enter' || e.key === ' ') {
248
464
  e.preventDefault();
249
- setModalOpen(true);
250
- }}
251
- title={item.title}
252
- >
253
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
254
- </a>
465
+ _onClick(item);
466
+ }
467
+ }}
468
+ >
469
+ <Card hoverable cover={renderMediaContent(item)} title={item.title} />
470
+ </div>
471
+ );
472
+ }
255
473
 
256
- <Modal
257
- open={modalOpen}
258
- onClose={() => setModalOpen(false)}
259
- title={item.title}
260
- className="memori-media-item-preview--modal"
261
- width="60%"
262
- widthMd="70%"
263
- footer={
264
- <div
265
- style={{
266
- display: 'flex',
267
- justifyContent: 'flex-end',
268
- gap: '0.5rem',
269
- }}
270
- >
271
- <Button
272
- onClick={async () => {
273
- try {
274
- await navigator.clipboard.writeText(item.content || '');
275
- setCopyNotification(true);
276
- setTimeout(() => setCopyNotification(false), 2000);
277
- } catch (err) {
278
- console.error('Failed to copy content:', err);
279
- }
280
- }}
281
- icon={<Copy />}
282
- >
283
- {copyNotification ? 'Copied!' : 'Copy Content'}
284
- </Button>
285
- </div>
474
+ // HTML file with link info / preview or video/image: render card with link preview (image, video, description)
475
+ if (isHTML && (linkImage || linkVideo || linkDescription)) {
476
+ // Compute card cover image/video src
477
+ const coverSrc =
478
+ linkImage?.includes('data:image') === true
479
+ ? undefined
480
+ : linkImage?.startsWith('https')
481
+ ? linkImage
482
+ : linkImage
483
+ ? `https://${linkImage.replace('http://', '')}`
484
+ : undefined;
485
+
486
+ return (
487
+ <a
488
+ href={item.url || '#'}
489
+ target="_blank"
490
+ rel="noopener noreferrer"
491
+ className="memori-media-item--link"
492
+ title={linkTitle}
493
+ >
494
+ <Card
495
+ hoverable
496
+ className={cx('memori-media-item--card', {
497
+ 'memori-media-item--card-description-oneline': descriptionOneLine,
498
+ 'memori-media-item--card-has-image': !!linkImage,
499
+ 'memori-media-item--card-has-video': !!linkVideo,
500
+ })}
501
+ cover={
502
+ linkVideo ? (
503
+ <iframe
504
+ width="100%"
505
+ height="100%"
506
+ src={linkVideo}
507
+ title="Video player"
508
+ frameBorder="0"
509
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
510
+ allowFullScreen
511
+ />
512
+ ) : linkImage ? (
513
+ <img
514
+ className="memori-media-item--card-cover-img"
515
+ src={coverSrc}
516
+ alt={linkTitle}
517
+ />
518
+ ) : (
519
+ <div className="memori-media-item--card-cover-icon">
520
+ <Link className="memori-media-item--icon" />
521
+ </div>
522
+ )
286
523
  }
287
- >
288
- <div className="memori-media-item-preview--content">
289
- <div
290
- className="memori-media-item-preview--text"
291
- dangerouslySetInnerHTML={{
292
- __html: stripHTML(item.content || ''),
293
- }}
294
- />
295
- </div>
296
- </Modal>
297
- </>
524
+ title={linkTitle}
525
+ description={linkDescription}
526
+ />
527
+ </a>
298
528
  );
299
529
  }
300
530
 
301
- switch (item.mimeType) {
302
- case 'image/jpeg':
303
- case 'image/png':
304
- case 'image/jpg':
305
- case 'image/gif':
306
- return isImageRGB ? (
531
+ // Run ellipsed.js to clamp link card description, only when a linkDescription is present
532
+ useEffect(() => {
533
+ if (!linkDescription) return;
534
+ const t = setTimeout(() => {
535
+ ellipsis('.memori-media-item--card .memori-card--description', 3, {
536
+ responsive: true,
537
+ });
538
+ }, 300);
539
+ return () => clearTimeout(t);
540
+ }, [linkDescription, item.mediumID]);
541
+
542
+ // -------------------------------------------------------------------------
543
+ // Image link flow: images with mediumID open in preview modal on click
544
+ // -------------------------------------------------------------------------
545
+ if (isImageMime(item.mimeType)) {
546
+ if (isImageRGB) {
547
+ return (
307
548
  <Card
308
549
  hoverable
309
550
  className="memori-media-item--card memori-media-item--image"
310
551
  cover={renderMediaContent(item)}
311
552
  />
312
- ) : (
313
- <a
314
- className="memori-media-item--link"
315
- href={imageSrc || '#'}
316
- onClick={e => {
317
- if (isChild) {
318
- e.preventDefault();
319
- }
320
- if (onClick) {
321
- e.preventDefault();
322
- onClick(item.mediumID);
323
- }
324
- if (!imageSrc || imageError) {
553
+ );
554
+ }
555
+ if (item.mediumID && _onClick) {
556
+ return (
557
+ <div
558
+ onClick={() => _onClick(item)}
559
+ className="memori-media-item--link memori-media-item--image-link"
560
+ style={{ cursor: 'pointer' }}
561
+ title={item.title}
562
+ role="button"
563
+ tabIndex={0}
564
+ onKeyDown={e => {
565
+ if (e.key === 'Enter' || e.key === ' ') {
325
566
  e.preventDefault();
567
+ _onClick(item);
326
568
  }
327
569
  }}
328
- target="_blank"
329
- rel="noopener noreferrer"
330
- title={item.title}
331
570
  >
332
571
  <Card
333
572
  hoverable
334
573
  className="memori-media-item--card memori-media-item--image"
335
574
  cover={renderMediaContent(item)}
336
575
  />
337
- </a>
338
- );
339
-
340
- case 'application/msword':
341
- case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
342
- case 'application/vnd.ms-excel':
343
- case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
344
- case 'application/pdf':
345
- return (
346
- <a
347
- className="memori-media-item--link"
348
- href={url}
349
- target="_blank"
350
- rel="noopener noreferrer"
351
- title={item.title}
352
- >
353
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
354
- </a>
576
+ </div>
355
577
  );
578
+ }
579
+ return (
580
+ <Card
581
+ hoverable
582
+ className="memori-media-item--card memori-media-item--image"
583
+ cover={renderMediaContent(item)}
584
+ />
585
+ );
586
+ }
356
587
 
588
+ // Video, audio, 3D, and other types: open in new tab (never modal)
589
+ switch (item.mimeType) {
357
590
  case 'video/mp4':
358
591
  case 'video/quicktime':
359
592
  case 'video/avi':
@@ -361,129 +594,148 @@ export const RenderMediaItem = ({
361
594
  return (
362
595
  <a
363
596
  className="memori-media-item--link"
364
- href={url}
365
- onClick={e => {
366
- if (isChild) {
367
- e.preventDefault();
368
- }
369
- if (onClick) {
370
- e.preventDefault();
371
- onClick(item.mediumID);
372
- }
373
- }}
597
+ href={resourceUrl || '#'}
374
598
  target="_blank"
375
599
  rel="noopener noreferrer"
376
600
  title={item.title}
377
601
  >
378
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
602
+ {renderMediaContent(item)}
379
603
  </a>
380
604
  );
381
605
 
606
+ // Audio and 3D models: open URL in new tab when available
382
607
  case 'audio/mpeg3':
383
608
  case 'audio/wav':
384
609
  case 'audio/mpeg':
385
- return (
386
- <a
387
- className="memori-media-item--link"
388
- href={url}
389
- target="_blank"
390
- rel="noopener noreferrer"
391
- title={item.title}
392
- >
393
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
394
- </a>
395
- );
396
-
397
610
  case 'model/gltf-binary':
398
- return <Card hoverable cover={renderMediaContent(item)} />;
611
+ if (resourceUrl) {
612
+ return (
613
+ <a
614
+ className="memori-media-item--link"
615
+ href={resourceUrl}
616
+ target="_blank"
617
+ rel="noopener noreferrer"
618
+ title={item.title}
619
+ >
620
+ {renderMediaContent(item)}
621
+ </a>
622
+ );
623
+ }
624
+ return renderMediaContent(item);
399
625
 
626
+ // All other files: open URL in new tab (never modal)
400
627
  default:
401
628
  return (
402
629
  <a
403
630
  className="memori-media-item--link"
404
- href={url}
631
+ href={resourceUrl || '#'}
405
632
  target="_blank"
406
633
  rel="noopener noreferrer"
407
634
  title={item.title}
408
635
  >
409
- <Card hoverable cover={renderMediaContent(item)} title={item.title} />
636
+ {renderMediaContent(item)}
410
637
  </a>
411
638
  );
412
639
  }
413
- };
640
+ });
414
641
 
415
- // Helper function to count lines in content
416
- const countLines = (content: string | undefined): number => {
417
- if (!content) return 0;
418
- // Support all common newline conventions:
419
- // - Unix: \n
420
- // - Windows: \r\n
421
- // - Classic Mac: \r
422
- return content.split(/\r\n|\r|\n/).length;
423
- };
424
-
425
- export const RenderSnippetItem = ({
642
+ // RenderSnippetItem: Renders a single code snippet (opens in new tab via href)
643
+ export const RenderSnippetItem = memo(function RenderSnippetItem({
426
644
  item,
427
- sessionID: _sessionID,
428
- tenantID: _tenantID,
429
- baseURL: _baseURL,
430
- apiURL: _apiURL,
431
- onClick,
432
- }: {
433
- item: Medium & { type: string };
434
- sessionID?: string;
435
- tenantID?: string;
436
- baseURL?: string;
437
- apiURL?: string;
438
- onClick?: (mediumID: string) => void;
439
- }) => {
645
+ onClick: _onClick, // kept for API compatibility; links open in new tab, not modal
646
+ sessionID,
647
+ tenantID,
648
+ baseURL,
649
+ apiURL,
650
+ }: RenderSnippetItemProps): React.ReactElement {
651
+ void _onClick;
652
+ const resourceUrl = getResourceUrl({
653
+ resourceURI: item.url,
654
+ sessionID,
655
+ tenantID,
656
+ baseURL,
657
+ apiURL,
658
+ });
659
+ const hasUrl = !!(resourceUrl && resourceUrl !== '#');
660
+
661
+ // Count lines, chars, determine "short" snippet
440
662
  const lineCount = countLines(item.content);
441
663
  const contentLength = item.content?.length ?? 0;
442
- // Treat very long single-line snippets as "long" so we show a preview,
443
- // otherwise plain-text uploads with only '\r' (or no '\n') would render full.
444
664
  const isShortSnippet = lineCount <= 5 && contentLength <= 200;
665
+ const lineText = lineCount === 1 ? '1 riga' : `${lineCount} righe`;
445
666
 
446
- // For short snippets, show them directly without the clickable link
447
667
  if (isShortSnippet) {
448
668
  return (
449
669
  <div className="memori-media-item--snippet-direct">
450
670
  <Card className="memori-media-item--card memori-media-item--snippet">
451
- <div className="memori-media-item--snippet-preview">
452
- <Snippet showCopyButton={true} preview={false} medium={item} />
671
+ <div className="memori-media-item--snippet-body">
672
+ <div className="memori-media-item--snippet-title">{item.title}</div>
673
+ <div className="memori-media-item--snippet-preview">
674
+ <Snippet showCopyButton preview={false} medium={item} />
675
+ </div>
676
+ <div className="memori-media-item--snippet-header">
677
+ <span className="memori-media-item--snippet-meta">
678
+ {lineText}
679
+ </span>
680
+ </div>
453
681
  </div>
454
682
  </Card>
455
683
  </div>
456
684
  );
457
685
  }
458
686
 
459
- // For longer snippets, show preview with click to open modal
687
+ // Long snippet: open in new tab (resource URL or blob URL from content)
688
+ const snippetHref = hasUrl
689
+ ? resourceUrl
690
+ : item.content
691
+ ? (() => {
692
+ const blob = new Blob([item.content], {
693
+ type: item.mimeType || 'text/plain',
694
+ });
695
+ return URL.createObjectURL(blob);
696
+ })()
697
+ : '#';
698
+
460
699
  return (
461
- <>
462
- <a
463
- href="#"
464
- onClick={e => {
700
+ <div
701
+ onClick={() => {
702
+ if (item.mediumID && _onClick) {
703
+ _onClick(item);
704
+ }
705
+ }}
706
+ style={{ cursor: 'pointer' }}
707
+ role="button"
708
+ tabIndex={0}
709
+ onKeyDown={e => {
710
+ if (e.key === 'Enter' || e.key === ' ') {
465
711
  e.preventDefault();
466
- if (onClick) {
467
- onClick(item.mediumID);
712
+ if (item.mediumID && _onClick) {
713
+ _onClick(item);
468
714
  }
469
- }}
470
- className="memori-media-item--link"
471
- title={item.title}
715
+ }
716
+ }}
717
+ className="memori-media-item--link"
718
+ title={item.title}
719
+ >
720
+ <Card
721
+ hoverable
722
+ className="memori-media-item--card memori-media-item--snippet"
472
723
  >
473
- <Card
474
- hoverable
475
- // onClick={() => setModalOpen(true)}
476
- className="memori-media-item--card memori-media-item--snippet"
477
- >
724
+ <div className="memori-media-item--snippet-body">
725
+ <div className="memori-media-item--snippet-title">{item.title}</div>
478
726
  <div className="memori-media-item--snippet-preview">
479
- <Snippet showCopyButton={false} preview={true} medium={item} />
727
+ <Snippet showCopyButton={false} preview medium={item} />
480
728
  </div>
481
- </Card>
482
- </a>
483
- </>
729
+ <div className="memori-media-item--snippet-header">
730
+ <span className="memori-media-item--snippet-meta">{lineText}</span>
731
+ </div>
732
+ </div>
733
+ </Card>
734
+ </div>
484
735
  );
485
- };
736
+ });
486
737
 
738
+ // Main MediaItemWidget component: renders all media, overlays, and preview modals
487
739
  const MediaItemWidget: React.FC<Props> = ({
488
740
  items,
489
741
  sessionID,
@@ -493,79 +745,128 @@ const MediaItemWidget: React.FC<Props> = ({
493
745
  apiURL,
494
746
  customMediaRenderer,
495
747
  fromUser = false,
496
- }: Props) => {
748
+ descriptionOneLine = false,
749
+ onLinkPreviewInfo,
750
+ }) => {
751
+ // Internal tracked media set (may be translated versions)
497
752
  const [media, setMedia] = useState(items);
498
- const [openModalMedium, setOpenModalMedium] = useState<Medium>();
753
+ // Modal state (holds currently open medium)
754
+ const [openModalMedium, setOpenModalMedium] = useState<Medium | undefined>();
499
755
 
500
- // Sync items prop with media state
756
+ // Update component-local media when prop changes (reset modal to current items)
501
757
  useEffect(() => {
502
758
  setMedia(items);
503
759
  }, [items]);
504
760
 
761
+ // Optional: Translate all media captions/titles if translateTo provided
505
762
  const translateMediaCaptions = useCallback(async () => {
506
763
  if (!translateTo) return;
507
-
508
- const translatedMedia = await Promise.all(
509
- (items ?? []).map(async media => {
510
- if (media.title) {
511
- try {
512
- const tTitle = await getTranslation(media.title, translateTo);
513
-
514
- return { ...media, title: tTitle.text ?? media.title };
515
- } catch (e) {
516
- console.error(e);
517
- return media;
518
- }
519
- } else {
520
- return media;
764
+ const translated = await Promise.all(
765
+ (items ?? []).map(async m => {
766
+ if (!m.title) return m;
767
+ try {
768
+ const t = await getTranslation(m.title, translateTo);
769
+ return { ...m, title: t.text ?? m.title };
770
+ } catch (e) {
771
+ console.error(e);
772
+ return m;
521
773
  }
522
774
  })
523
775
  );
524
-
525
- setMedia(translatedMedia);
776
+ setMedia(translated);
526
777
  }, [translateTo, items]);
778
+
527
779
  useEffect(() => {
528
780
  if (translateTo) translateMediaCaptions();
529
781
  }, [translateTo, translateMediaCaptions]);
530
782
 
531
- const nonCodeDisplayMedia = media
532
- .filter(
533
- m =>
534
- !m.properties?.executable &&
535
- !prismSyntaxLangs.map(l => l.mimeType).includes(m.mimeType)
536
- )
537
- .sort((a, b) => {
538
- return a.creationTimestamp! > b.creationTimestamp!
539
- ? 1
540
- : a.creationTimestamp! < b.creationTimestamp!
541
- ? -1
542
- : 0;
543
- });
783
+ // Derive top-level "display" lists:
784
+ // 1. All non-code, non-executable media sorted by timestamp (displayed as document, images, video, etc)
785
+ const nonCodeDisplayMedia = useMemo(
786
+ () =>
787
+ media
788
+ .filter(
789
+ m =>
790
+ !m.properties?.executable && !CODE_MIME_TYPES.includes(m.mimeType)
791
+ )
792
+ .sort((a, b) => {
793
+ const at = a.creationTimestamp ?? 0;
794
+ const bt = b.creationTimestamp ?? 0;
795
+ return at > bt ? 1 : at < bt ? -1 : 0;
796
+ }),
797
+ [media]
798
+ );
799
+
800
+ // 2. Only code snippets (unless marked as executable)
801
+ const codeSnippets = useMemo(
802
+ () =>
803
+ media.filter(
804
+ m => !m.properties?.executable && CODE_MIME_TYPES.includes(m.mimeType)
805
+ ),
806
+ [media]
807
+ );
544
808
 
545
- const codeSnippets = media.filter(
546
- m =>
547
- !m.properties?.executable &&
548
- prismSyntaxLangs.map(l => l.mimeType).includes(m.mimeType)
809
+ // 3. Only CSS code marked as executable (to inject as <style>)
810
+ const cssExecutableCode = useMemo(
811
+ () =>
812
+ media.filter(
813
+ m => m.mimeType === 'text/css' && !!m.properties?.executable
814
+ ),
815
+ [media]
549
816
  );
550
817
 
551
- const cssExecutableCode = media.filter(
552
- m => m.mimeType === 'text/css' && !!m.properties?.executable
818
+ // How many images are present for determining layout
819
+ const imageCount = useMemo(
820
+ () =>
821
+ nonCodeDisplayMedia.filter(m =>
822
+ (IMAGE_MIME_TYPES as readonly string[]).includes(m.mimeType)
823
+ ).length,
824
+ [nonCodeDisplayMedia]
553
825
  );
554
826
 
827
+ // Media "card open"/preview modal: pass the clicked item so the correct one opens
828
+ // (avoids wrong image when multiple items share the same mediumID)
829
+ const handleMediaItemClick = useCallback((item: MediaItem) => {
830
+ setOpenModalMedium(item);
831
+ }, []);
832
+
833
+ // Modal for code snippets
834
+ const handleSnippetClick = useCallback((item: Medium & { type?: string }) => {
835
+ setOpenModalMedium(item);
836
+ }, []);
837
+
838
+ // Simple close modal action callback
839
+ const handleCloseModal = useCallback(() => {
840
+ setOpenModalMedium(undefined);
841
+ }, []);
842
+
843
+ // Navigate to another media in modal (by mediumID) — used by modal carousel/nav
844
+ const handleModalNavigate = useCallback(
845
+ (mediumID: string) => {
846
+ setOpenModalMedium(media.find(m => m.mediumID === mediumID));
847
+ },
848
+ [media]
849
+ );
850
+
851
+ // Render transitions and the main grid layouts for media
555
852
  return (
556
853
  <Transition appear show as="div" className="memori-media-items">
557
- {!!nonCodeDisplayMedia.length && (
854
+ {/* Main media grid: non-code media (images, files, html, video, etc) */}
855
+ {nonCodeDisplayMedia.length > 0 && (
558
856
  <div
559
857
  className={cx('memori-media-items--grid memori-chat-scroll-item', {
560
858
  'memori-media-items--user': fromUser,
561
859
  'memori-media-items--agent': !fromUser,
860
+ 'memori-media-items--single': imageCount === 1,
861
+ 'memori-media-items--few': imageCount >= 2 && imageCount <= 4,
862
+ 'memori-media-items--many': imageCount >= 5,
562
863
  })}
563
864
  >
564
- {nonCodeDisplayMedia.map((item: Medium, index: number) => (
865
+ {nonCodeDisplayMedia.map((item, index) => (
565
866
  <Transition.Child
867
+ key={`media-${index}-${item.mediumID ?? item.url ?? 'n'}`}
566
868
  as="div"
567
869
  className="memori-media-item"
568
- key={item.url + '&index=' + index}
569
870
  enter={`ease-out duration-500 delay-${index * 100}`}
570
871
  enterFrom="opacity-0 scale-95"
571
872
  enterTo="opacity-1 scale-100"
@@ -579,11 +880,7 @@ const MediaItemWidget: React.FC<Props> = ({
579
880
  tenantID={tenantID}
580
881
  baseURL={baseURL}
581
882
  apiURL={apiURL}
582
- onClick={mediumID => {
583
- setOpenModalMedium(
584
- nonCodeDisplayMedia.find(m => m.mediumID === mediumID)
585
- );
586
- }}
883
+ onClick={handleMediaItemClick}
587
884
  item={{
588
885
  ...item,
589
886
  title: item.title,
@@ -592,23 +889,27 @@ const MediaItemWidget: React.FC<Props> = ({
592
889
  type: 'document',
593
890
  }}
594
891
  customMediaRenderer={customMediaRenderer}
892
+ descriptionOneLine={descriptionOneLine}
893
+ onLinkPreviewInfo={onLinkPreviewInfo}
595
894
  />
596
895
  </Transition.Child>
597
896
  ))}
598
897
  </div>
599
898
  )}
600
- {!!codeSnippets.length && (
899
+
900
+ {/* Grid of pure code snippets, shown as cards or compact snippets */}
901
+ {codeSnippets.length > 0 && (
601
902
  <div
602
903
  className={cx('memori-media-items--grid memori-chat-scroll-item', {
603
904
  'memori-media-items--user': fromUser,
604
905
  'memori-media-items--agent': !fromUser,
605
906
  })}
606
907
  >
607
- {codeSnippets.map((item: Medium, index: number) => (
908
+ {codeSnippets.map((item, index) => (
608
909
  <Transition.Child
910
+ key={`snippet-${index}-${item.mediumID ?? item.url ?? 'n'}`}
609
911
  as="div"
610
912
  className="memori-media-item"
611
- key={item.mediumID + '&index=' + index}
612
913
  enter={`ease-out duration-500 delay-${index * 100}`}
613
914
  enterFrom="opacity-0 scale-95"
614
915
  enterTo="opacity-1 scale-100"
@@ -621,12 +922,7 @@ const MediaItemWidget: React.FC<Props> = ({
621
922
  tenantID={tenantID}
622
923
  baseURL={baseURL}
623
924
  apiURL={apiURL}
624
- onClick={mediumID => {
625
- const foundMedium = codeSnippets.find(
626
- m => m.mediumID === mediumID
627
- );
628
- setOpenModalMedium(foundMedium);
629
- }}
925
+ onClick={handleSnippetClick}
630
926
  item={{
631
927
  ...item,
632
928
  title: item.title,
@@ -639,55 +935,32 @@ const MediaItemWidget: React.FC<Props> = ({
639
935
  ))}
640
936
  </div>
641
937
  )}
642
- {cssExecutableCode.map(medium => (
938
+
939
+ {/* CSS executables: Inject as <style> (only shared to DOM, not shown visually) */}
940
+ {cssExecutableCode.map(m => (
643
941
  <style
644
- key={medium.mediumID}
645
- dangerouslySetInnerHTML={{ __html: medium.content || '' }}
646
- ></style>
942
+ key={m.mediumID}
943
+ dangerouslySetInnerHTML={{ __html: m.content || '' }}
944
+ />
647
945
  ))}
648
946
 
947
+ {/* Modal preview: shows when openModalMedium set */}
649
948
  {openModalMedium && (
650
- <Modal
651
- width="100%"
652
- widthMd="100%"
653
- className="memori-media-item--modal"
654
- open={!!openModalMedium}
655
- onClose={() => setOpenModalMedium(undefined)}
656
- footer={null}
657
- >
658
- {prismSyntaxLangs
659
- .map(l => l.mimeType)
660
- .includes(openModalMedium.mimeType) ? (
661
- <div
662
- style={{
663
- minHeight: '100%',
664
- background: 'none',
665
- }}
666
- className="memori-media-item-preview--content"
667
- >
668
- <Snippet preview={false} medium={openModalMedium} />
669
- </div>
670
- ) : (
671
- <RenderMediaItem
672
- isChild
673
- sessionID={sessionID}
674
- tenantID={tenantID}
675
- baseURL={baseURL}
676
- apiURL={apiURL}
677
- item={{
678
- ...openModalMedium,
679
- title: openModalMedium.title,
680
- url: openModalMedium.url,
681
- content: openModalMedium.content,
682
- type: 'document',
683
- }}
684
- customMediaRenderer={customMediaRenderer}
685
- />
686
- )}
687
- </Modal>
949
+ <MediaPreviewModal
950
+ medium={openModalMedium}
951
+ onClose={handleCloseModal}
952
+ sessionID={sessionID}
953
+ tenantID={tenantID}
954
+ baseURL={baseURL}
955
+ apiURL={apiURL}
956
+ customMediaRenderer={customMediaRenderer}
957
+ descriptionOneLine={descriptionOneLine}
958
+ onLinkPreviewInfo={onLinkPreviewInfo}
959
+ onMediumClick={handleModalNavigate}
960
+ />
688
961
  )}
689
962
  </Transition>
690
963
  );
691
964
  };
692
965
 
693
- export default memo(MediaItemWidget);
966
+ export default memo(MediaItemWidget);