@djangocfg/ui-tools 2.1.430 → 2.1.432

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 (188) hide show
  1. package/dist/tree/index.cjs +43 -8
  2. package/dist/tree/index.cjs.map +1 -1
  3. package/dist/tree/index.d.cts +31 -1
  4. package/dist/tree/index.d.ts +31 -1
  5. package/dist/tree/index.mjs +43 -8
  6. package/dist/tree/index.mjs.map +1 -1
  7. package/package.json +28 -14
  8. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  9. package/src/common/CodePanelHeader/index.ts +1 -0
  10. package/src/common/blocks/BlockError.tsx +46 -0
  11. package/src/common/blocks/index.ts +34 -0
  12. package/src/common/blocks/schemas.ts +143 -0
  13. package/src/common/blocks/types.ts +76 -0
  14. package/src/common/chips/README.md +67 -0
  15. package/src/common/chips/UrlChip.tsx +105 -0
  16. package/src/common/chips/index.ts +20 -0
  17. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  18. package/src/common/link-preview/index.ts +12 -0
  19. package/src/common/link-preview/types.ts +44 -0
  20. package/src/common/tiptap/index.ts +10 -0
  21. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  22. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  23. package/src/tools/chat/README.md +109 -2
  24. package/src/tools/chat/composer/Composer.tsx +45 -12
  25. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  26. package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
  27. package/src/tools/chat/composer/index.ts +9 -0
  28. package/src/tools/chat/composer/textToAttachment.ts +69 -0
  29. package/src/tools/chat/composer/types.ts +13 -0
  30. package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
  31. package/src/tools/chat/constants.ts +9 -2
  32. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  33. package/src/tools/chat/core/reducer.ts +32 -8
  34. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  35. package/src/tools/chat/core/transport/mock.ts +4 -1
  36. package/src/tools/chat/hooks/useChat.ts +20 -20
  37. package/src/tools/chat/lazy.tsx +10 -0
  38. package/src/tools/chat/messages/Attachments.tsx +335 -46
  39. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  40. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  41. package/src/tools/chat/messages/MessageList.tsx +107 -21
  42. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  43. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  44. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  45. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  46. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  47. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  48. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  49. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  50. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  51. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  52. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  53. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  54. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  55. package/src/tools/chat/messages/index.ts +13 -0
  56. package/src/tools/chat/public.ts +5 -1
  57. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  58. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  59. package/src/tools/chat/styles/index.ts +1 -0
  60. package/src/tools/chat/types/attachment.ts +38 -3
  61. package/src/tools/chat/types/block.ts +56 -9
  62. package/src/tools/chat/types/blockSchemas.ts +197 -0
  63. package/src/tools/chat/types/config.ts +33 -0
  64. package/src/tools/chat/types/events.ts +14 -7
  65. package/src/tools/chat/types/index.ts +4 -2
  66. package/src/tools/chat/types/message.ts +7 -0
  67. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  68. package/src/tools/data/Tree/README.md +30 -0
  69. package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
  70. package/src/tools/data/Tree/context/menu/render.tsx +56 -27
  71. package/src/tools/data/Tree/data/appearance.ts +65 -3
  72. package/src/tools/data/Tree/data/index.ts +2 -0
  73. package/src/tools/data/Tree/index.tsx +2 -0
  74. package/src/tools/dev/Map/README.md +30 -345
  75. package/src/tools/dev/Map/cards/index.ts +6 -0
  76. package/src/tools/dev/Map/cards/types.ts +67 -0
  77. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  78. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  79. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  80. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  81. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  82. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  83. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  84. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  85. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  86. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  87. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  88. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  89. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  90. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  91. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  92. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  93. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  94. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  95. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  96. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  97. package/src/tools/dev/Map/components/index.ts +35 -1
  98. package/src/tools/dev/Map/geocode/index.ts +2 -0
  99. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  100. package/src/tools/dev/Map/hooks/index.ts +37 -0
  101. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  102. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  103. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  104. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  105. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  106. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  107. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  108. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  109. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  110. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  111. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  112. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  113. package/src/tools/dev/Map/index.ts +59 -2
  114. package/src/tools/dev/Map/layers/index.ts +1 -0
  115. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  116. package/src/tools/dev/Map/lazy.tsx +120 -0
  117. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  118. package/src/tools/dev/Map/styles/index.ts +62 -0
  119. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  120. package/src/tools/dev/Map/types.ts +31 -3
  121. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  122. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  123. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  124. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  125. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  126. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  127. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  128. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  129. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  130. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  131. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  132. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  133. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  134. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  135. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  136. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  137. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  138. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  139. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  140. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  141. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  142. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  143. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  144. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  145. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  146. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  147. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  148. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  149. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  150. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  151. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  152. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  153. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  154. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  155. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  156. package/src/tools/forms/NotionEditor/README.md +109 -188
  157. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  158. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  159. package/src/tools/forms/NotionEditor/styles.css +25 -0
  160. package/src/tools/forms/NotionEditor/types.ts +9 -0
  161. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  162. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  163. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  164. package/src/tools/media/ImageViewer/README.md +9 -0
  165. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  166. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  167. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  168. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  169. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  170. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  171. package/src/tools/media/ImageViewer/types.ts +10 -0
  172. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  173. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  174. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  175. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  176. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  177. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  178. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  179. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  180. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  181. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  182. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  183. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  184. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  185. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  186. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  187. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  188. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -11,8 +11,10 @@ import { Component, type ReactNode } from 'react';
11
11
 
12
12
  import { cn } from '@djangocfg/ui-core/lib';
13
13
 
14
+ import { BlockError, safeParseBlock } from '../../../../common/blocks';
14
15
  import type { MessageBlock } from '../../types/block';
15
- import { useChatBubbleStyles } from '../../styles';
16
+ import { BLOCK_SCHEMAS } from '../../types/blockSchemas';
17
+ import { useChatBubbleStyles, BLOCK_SURFACE } from '../../styles';
16
18
  import { BUILTIN_BLOCK_REGISTRY, type BlockRegistry } from './registry';
17
19
 
18
20
  export interface MessageBlocksProps {
@@ -29,9 +31,30 @@ export interface MessageBlocksProps {
29
31
  strict?: boolean;
30
32
  }
31
33
 
32
- /** Block kinds rendered inside a chat-bubble surface, not full-width. */
34
+ /** Block kinds rendered inside a chat-bubble surface (bubble-tinted, inline). */
33
35
  const PROSE_KINDS = new Set<MessageBlock['kind']>(['text', 'markdown']);
34
36
 
37
+ /**
38
+ * Block kinds wrapped in the shared media-card surface (`BLOCK_SURFACE`) so
39
+ * every media block shares one radius / border / shadow. `prose` kinds get
40
+ * the bubble surface instead (above).
41
+ *
42
+ * Bare kinds skip the light card and own their frame:
43
+ * - `custom` — host-owned chrome.
44
+ * - `link` — the LinkPreviewCard ships its own frame.
45
+ * - `audio` — the player draws its own card AND a scrub-time tooltip that
46
+ * sits ABOVE the bar; a clipping card would crop that tooltip, so the
47
+ * player rounds itself to the shared 2xl radius instead (see AudioBlock).
48
+ * - `diff` — the DiffViewer ships its own bordered panel + a floating
49
+ * copy button that a clip would crop; it re-rounds to 2xl (see DiffBlock).
50
+ *
51
+ * `code` is NOT bare: its dark editor panel can't be reliably re-rounded
52
+ * from outside (PrettyCode rounds its inner layers to `rounded-lg`), so we
53
+ * let the shared `overflow-hidden rounded-2xl` card CLIP it to the uniform
54
+ * corner instead. (Code has no overflowing tooltip, so clipping is safe.)
55
+ */
56
+ const BARE_KINDS = new Set<MessageBlock['kind']>(['custom', 'link', 'audio', 'diff']);
57
+
35
58
  interface BoundaryProps {
36
59
  kind: string;
37
60
  children: ReactNode;
@@ -91,8 +114,28 @@ export function MessageBlocks({
91
114
  | ((b: MessageBlock, c: { appearance: 'compact' | 'full'; isUser: boolean }) => ReactNode)
92
115
  | undefined;
93
116
 
117
+ // Central payload validation — every block flows through here, so a
118
+ // malformed payload (wrong-typed `src`, non-array gallery `items`, bad
119
+ // map coords) renders a safe `<BlockError>` instead of throwing inside
120
+ // its renderer and blanking the transcript. Kinds without a schema
121
+ // (e.g. host-defined `custom`) skip this and render as-is. The renderer
122
+ // can therefore trust its input. The error boundary below stays as a
123
+ // last-resort runtime guard.
124
+ const schema = BLOCK_SCHEMAS[block.kind];
125
+
94
126
  let body: ReactNode;
95
- if (renderer) {
127
+ if (schema) {
128
+ const parsed = safeParseBlock(schema, block);
129
+ if (!parsed.ok) {
130
+ body = <BlockError label={block.kind} reason={parsed.error} />;
131
+ } else if (renderer) {
132
+ body = renderer(block, { appearance, isUser });
133
+ } else if (strict) {
134
+ body = <UnknownBlock kind={block.kind} />;
135
+ } else {
136
+ return null;
137
+ }
138
+ } else if (renderer) {
96
139
  body = renderer(block, { appearance, isUser });
97
140
  } else if (strict) {
98
141
  body = <UnknownBlock kind={block.kind} />;
@@ -101,6 +144,29 @@ export function MessageBlocks({
101
144
  }
102
145
 
103
146
  const isProse = PROSE_KINDS.has(block.kind);
147
+ const isBare = BARE_KINDS.has(block.kind);
148
+
149
+ // Surface selection — one consistent frame per category:
150
+ // prose → tinted chat bubble (inline)
151
+ // bare → no frame (host-owned `custom` block)
152
+ // media → shared `BLOCK_SURFACE` card (image/video/map/code/…)
153
+ let framed: ReactNode;
154
+ if (isProse) {
155
+ framed = (
156
+ <div
157
+ className={cn(
158
+ 'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
159
+ bubbleSurface,
160
+ )}
161
+ >
162
+ {body}
163
+ </div>
164
+ );
165
+ } else if (isBare) {
166
+ framed = body;
167
+ } else {
168
+ framed = <div className={cn('w-full', BLOCK_SURFACE)}>{body}</div>;
169
+ }
104
170
 
105
171
  return (
106
172
  <div key={block.id} data-block-kind={block.kind} className="w-full">
@@ -109,20 +175,7 @@ export function MessageBlocks({
109
175
  {block.caption}
110
176
  </div>
111
177
  ) : null}
112
- <BlockErrorBoundary kind={block.kind}>
113
- {isProse ? (
114
- <div
115
- className={cn(
116
- 'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
117
- bubbleSurface,
118
- )}
119
- >
120
- {body}
121
- </div>
122
- ) : (
123
- body
124
- )}
125
- </BlockErrorBoundary>
178
+ <BlockErrorBoundary kind={block.kind}>{framed}</BlockErrorBoundary>
126
179
  </div>
127
180
  );
128
181
  })}
@@ -16,10 +16,11 @@ import { MarkdownMessage } from '../../../dev/code/MarkdownMessage';
16
16
  import type {
17
17
  AudioBlock,
18
18
  CodeBlock,
19
+ DiffBlock,
19
20
  GalleryBlock,
20
21
  ImageBlock,
21
22
  JsonBlock,
22
- LottieBlock,
23
+ LinkBlock,
23
24
  MapBlock,
24
25
  MarkdownBlock,
25
26
  MermaidBlock,
@@ -63,7 +64,8 @@ const MapBlockRenderer = lazyBlock<MapBlock>(() => import('./renderers/MapBlock'
63
64
  const JsonBlockRenderer = lazyBlock<JsonBlock>(() => import('./renderers/JsonBlock'));
64
65
  const MermaidBlockRenderer = lazyBlock<MermaidBlock>(() => import('./renderers/MermaidBlock'));
65
66
  const CodeBlockRenderer = lazyBlock<CodeBlock>(() => import('./renderers/CodeBlock'));
66
- const LottieBlockRenderer = lazyBlock<LottieBlock>(() => import('./renderers/LottieBlock'));
67
+ const DiffBlockRenderer = lazyBlock<DiffBlock>(() => import('./renderers/DiffBlock'));
68
+ const LinkBlockRenderer = lazyBlock<LinkBlock>(() => import('./renderers/LinkBlock'));
67
69
 
68
70
  /** Render a `text` / `markdown` block as bubble-styled prose. */
69
71
  function renderProse(content: string, isUser: boolean) {
@@ -85,7 +87,8 @@ export const BUILTIN_BLOCK_REGISTRY: BlockRegistry = {
85
87
  json: (block, ctx) => <JsonBlockRenderer block={block} ctx={ctx} />,
86
88
  mermaid: (block, ctx) => <MermaidBlockRenderer block={block} ctx={ctx} />,
87
89
  code: (block, ctx) => <CodeBlockRenderer block={block} ctx={ctx} />,
88
- lottie: (block, ctx) => <LottieBlockRenderer block={block} ctx={ctx} />,
90
+ diff: (block, ctx) => <DiffBlockRenderer block={block} ctx={ctx} />,
91
+ link: (block, ctx) => <LinkBlockRenderer block={block} ctx={ctx} />,
89
92
  // `custom` has no built-in renderer — it falls through to the
90
93
  // unknown-kind fallback unless the host registers one.
91
94
  };
@@ -8,6 +8,11 @@ import type { BlockRendererProps } from './types';
8
8
 
9
9
  export default function AudioBlockRenderer({ block, ctx }: BlockRendererProps<AudioBlock>) {
10
10
  const variant = block.variant ?? (ctx.appearance === 'compact' ? 'compact' : 'default');
11
+ // `audio` is a "bare" block (no clipping card) because the player renders
12
+ // a scrub-time tooltip ABOVE the bar that a clip would crop. So the player
13
+ // owns its frame — aligned to the shared 2xl radius + soft shadow.
14
+ // `!`-utilities win over PlayerShell's template-string `rounded-lg`
15
+ // regardless of stylesheet order.
11
16
  return (
12
17
  <LazyPlayer
13
18
  src={block.src}
@@ -15,6 +20,7 @@ export default function AudioBlockRenderer({ block, ctx }: BlockRendererProps<Au
15
20
  artist={block.artist}
16
21
  cover={block.cover}
17
22
  variant={variant}
23
+ className="!rounded-2xl shadow-sm"
18
24
  />
19
25
  );
20
26
  }
@@ -0,0 +1,154 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Chat image album — a Telegram/WhatsApp-style adaptive collage for 1–N
5
+ * images. NOT a true masonry: messengers use a small set of deterministic
6
+ * album layouts (no JS height measuring, no extra deps), which is the right
7
+ * call for a chat where albums are a small, bounded case.
8
+ *
9
+ * 1 → single image 2 → two equal columns
10
+ * 3 → one tall + two stacked 4 → 2×2 grid
11
+ * 5+ → 2×2 grid, the last visible tile shows a "+N more" overlay
12
+ *
13
+ * Clicking any tile opens the shared zoom/pan `ImageViewer` lightbox in a
14
+ * dialog, seeded at the clicked index, with prev/next across the whole set.
15
+ */
16
+
17
+ import { useCallback, useState } from 'react';
18
+
19
+ import { Dialog, DialogContent, DialogTitle } from '@djangocfg/ui-core/components';
20
+ import { cn } from '@djangocfg/ui-core/lib';
21
+
22
+ import { LazyImageViewer } from '../../../../media/ImageViewer/lazy';
23
+
24
+ export interface ChatAlbumImage {
25
+ id: string;
26
+ src: string;
27
+ thumbnail?: string;
28
+ alt?: string;
29
+ }
30
+
31
+ export interface ChatImageAlbumProps {
32
+ images: ChatAlbumImage[];
33
+ /** Max tiles shown before collapsing the rest into a "+N" overlay. */
34
+ maxVisible?: number;
35
+ }
36
+
37
+ /** Tile — a single clickable image cell. */
38
+ function Tile({
39
+ image,
40
+ onClick,
41
+ className,
42
+ overlayCount,
43
+ }: {
44
+ image: ChatAlbumImage;
45
+ onClick: () => void;
46
+ className?: string;
47
+ overlayCount?: number;
48
+ }) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ onClick={onClick}
53
+ aria-label={`Open image${image.alt ? `: ${image.alt}` : ''}`}
54
+ className={cn(
55
+ 'group relative block overflow-hidden bg-muted',
56
+ 'cursor-zoom-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
57
+ className,
58
+ )}
59
+ >
60
+ {/* eslint-disable-next-line @next/next/no-img-element */}
61
+ <img
62
+ src={image.thumbnail ?? image.src}
63
+ alt={image.alt ?? ''}
64
+ loading="lazy"
65
+ className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
66
+ />
67
+ {overlayCount ? (
68
+ <span className="absolute inset-0 flex items-center justify-center bg-black/50 text-xl font-semibold text-white">
69
+ +{overlayCount}
70
+ </span>
71
+ ) : null}
72
+ </button>
73
+ );
74
+ }
75
+
76
+ export function ChatImageAlbum({ images, maxVisible = 4 }: ChatImageAlbumProps) {
77
+ const [open, setOpen] = useState(false);
78
+ const [index, setIndex] = useState(0);
79
+
80
+ const openAt = useCallback((i: number) => {
81
+ setIndex(i);
82
+ setOpen(true);
83
+ }, []);
84
+
85
+ const count = images.length;
86
+ const visible = images.slice(0, Math.min(count, maxVisible));
87
+ const hiddenCount = count - visible.length;
88
+
89
+ // Layout per count. `gap-0.5` mimics the tight messenger seam; the outer
90
+ // BLOCK_SURFACE provides the rounded clip + border.
91
+ let grid: React.ReactNode;
92
+ if (count === 1) {
93
+ const img = images[0];
94
+ grid = (
95
+ <Tile image={img} onClick={() => openAt(0)} className="max-h-96 w-full" />
96
+ );
97
+ } else if (count === 2) {
98
+ grid = (
99
+ <div className="grid aspect-[2/1] grid-cols-2 gap-0.5">
100
+ {visible.map((img, i) => (
101
+ <Tile key={img.id} image={img} onClick={() => openAt(i)} />
102
+ ))}
103
+ </div>
104
+ );
105
+ } else if (count === 3) {
106
+ grid = (
107
+ <div className="grid aspect-[3/2] grid-cols-2 grid-rows-2 gap-0.5">
108
+ <Tile image={visible[0]} onClick={() => openAt(0)} className="row-span-2" />
109
+ <Tile image={visible[1]} onClick={() => openAt(1)} />
110
+ <Tile image={visible[2]} onClick={() => openAt(2)} />
111
+ </div>
112
+ );
113
+ } else {
114
+ // 4+ → 2×2, last visible tile carries the "+N" overlay when collapsed.
115
+ grid = (
116
+ <div className="grid aspect-square grid-cols-2 grid-rows-2 gap-0.5">
117
+ {visible.map((img, i) => {
118
+ const isLast = i === visible.length - 1;
119
+ return (
120
+ <Tile
121
+ key={img.id}
122
+ image={img}
123
+ onClick={() => openAt(i)}
124
+ overlayCount={isLast && hiddenCount > 0 ? hiddenCount : undefined}
125
+ />
126
+ );
127
+ })}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <>
134
+ {grid}
135
+
136
+ <Dialog open={open} onOpenChange={setOpen}>
137
+ <DialogContent className="h-[90vh] w-[90vw] max-w-[1400px] overflow-hidden p-0 sm:rounded-xl">
138
+ <DialogTitle className="sr-only">Image {index + 1} of {count}</DialogTitle>
139
+ {open && (
140
+ <LazyImageViewer
141
+ images={images.map((img) => ({
142
+ file: { name: img.alt ?? 'image', path: img.src },
143
+ src: img.src,
144
+ }))}
145
+ initialIndex={index}
146
+ inDialog
147
+ autoFocus
148
+ />
149
+ )}
150
+ </DialogContent>
151
+ </Dialog>
152
+ </>
153
+ );
154
+ }
@@ -1,19 +1,107 @@
1
1
  'use client';
2
2
 
3
- /** Code block — read-only syntax highlighting via `PrettyCode`. */
3
+ /**
4
+ * Code block — read-only syntax highlighting via `PrettyCode`.
5
+ *
6
+ * Inline, the panel is height-capped (`maxLines`) so a long snippet never
7
+ * blows up the transcript — beyond the cap PrettyCode scrolls internally.
8
+ * When capped, a header expand (⤢) button opens the FULL code in a large
9
+ * dialog (same pattern as the map / image blocks), where it reads/scrolls
10
+ * comfortably and copies in one shot.
11
+ */
12
+
13
+ import { useCallback, useState } from 'react';
14
+
15
+ import { Check, Copy, X } from 'lucide-react';
16
+ import { cn } from '@djangocfg/ui-core/lib';
17
+ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@djangocfg/ui-core/components';
4
18
 
5
19
  import { LazyPrettyCode } from '../../../../dev/code/PrettyCode/lazy';
6
20
  import type { Language } from '../../../../dev/code/PrettyCode/lazy';
7
21
  import type { CodeBlock } from '../../../types/block';
8
22
  import type { BlockRendererProps } from './types';
9
23
 
24
+ /** Roomy on-dark button for the expanded dialog header. */
25
+ const DIALOG_BTN =
26
+ 'inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium ' +
27
+ 'text-white/70 transition-colors hover:bg-white/10 hover:text-white ' +
28
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40';
29
+
10
30
  export default function CodeBlockRenderer({ block, ctx }: BlockRendererProps<CodeBlock>) {
11
31
  const compact = ctx.appearance === 'compact';
32
+ const [open, setOpen] = useState(false);
33
+ const [copied, setCopied] = useState(false);
34
+ const language = block.language as Language;
35
+
36
+ const copyAll = useCallback(() => {
37
+ void navigator.clipboard?.writeText(block.code).then(() => {
38
+ setCopied(true);
39
+ setTimeout(() => setCopied(false), 1400);
40
+ });
41
+ }, [block.code]);
42
+
43
+ // 16 lines in the narrow chat, 24 in a full-page chat. Beyond this the
44
+ // inline panel scrolls and the header shows the expand button.
45
+ const maxLines = compact ? 16 : 24;
46
+
12
47
  return (
13
- <LazyPrettyCode
14
- data={block.code}
15
- language={block.language as Language}
16
- isCompact={compact}
17
- />
48
+ <>
49
+ {/* Inline — capped + internally scrollable; expand opens the dialog.
50
+ The dark panel is clipped to the shared 2xl corner by BLOCK_SURFACE. */}
51
+ <LazyPrettyCode
52
+ data={block.code}
53
+ language={language}
54
+ isCompact={compact}
55
+ maxLines={maxLines}
56
+ onExpand={() => setOpen(true)}
57
+ />
58
+
59
+ {/* Full code, uncapped, in a large dialog (map/image-block pattern).
60
+ ONE header (language + copy + close) over a dark panel; the body
61
+ renders PrettyCode in `plain` (chrome-less) so there's no second
62
+ header to collide with — the dialog owns the chrome and scroll. */}
63
+ <Dialog open={open} onOpenChange={setOpen}>
64
+ <DialogContent
65
+ className="flex h-[85vh] w-[90vw] max-w-[1100px] flex-col overflow-hidden border-0 p-0 sm:rounded-xl"
66
+ // Fixed GitHub-dark surface (matches PrettyCode's own panel) — the
67
+ // code theme is always dark regardless of the app theme, so a
68
+ // theme-driven `bg-code` (light in light mode) would be unreadable.
69
+ style={{ backgroundColor: '#0d1117' }}
70
+ // We render our own close inside the single header, so suppress the
71
+ // default X (it would collide with the header buttons).
72
+ closeButton={false}
73
+ >
74
+ <DialogTitle className="sr-only">{block.caption ?? language ?? 'Code'}</DialogTitle>
75
+ {open && (
76
+ <>
77
+ {/* Roomy dialog header: language left; actions right in the
78
+ conventional order — primary "Copy" then "Close" (X) at the
79
+ far edge. Taller + larger than the inline header to suit the
80
+ big surface. */}
81
+ <div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-white/[0.08] px-4">
82
+ <span className="min-w-0 truncate font-mono text-sm lowercase tracking-wide text-white/55">
83
+ {language}
84
+ </span>
85
+ <div className="flex shrink-0 items-center gap-1">
86
+ <button type="button" onClick={copyAll} aria-label="Copy code" className={DIALOG_BTN}>
87
+ {copied ? <Check className="size-3.5" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
88
+ <span>{copied ? 'Copied' : 'Copy'}</span>
89
+ </button>
90
+ <DialogClose
91
+ aria-label="Close"
92
+ className={cn(DIALOG_BTN, 'w-8 px-0')}
93
+ >
94
+ <X className="size-4" aria-hidden />
95
+ </DialogClose>
96
+ </div>
97
+ </div>
98
+ <div className="min-h-0 flex-1 overflow-auto">
99
+ <LazyPrettyCode data={block.code} language={language} variant="plain" />
100
+ </div>
101
+ </>
102
+ )}
103
+ </DialogContent>
104
+ </Dialog>
105
+ </>
18
106
  );
19
107
  }
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+
3
+ /** Diff block — thin chat wrapper around the lazy `DiffViewer`. */
4
+
5
+ import { LazyDiffViewer } from '../../../../dev/code/DiffViewer/lazy';
6
+ import type { DiffBlock } from '../../../types/block';
7
+ import type { BlockRendererProps } from './types';
8
+
9
+ export default function DiffBlockRenderer({ block, ctx }: BlockRendererProps<DiffBlock>) {
10
+ // Compact chat is narrow → default to single-column `unified`; full
11
+ // bubbles have room for `split`. A pinned `layout` always wins.
12
+ const layout = block.layout ?? (ctx.appearance === 'compact' ? 'unified' : 'split');
13
+ const common = {
14
+ layout,
15
+ language: block.language,
16
+ // `diff` is a "bare" block (no clipping card) because the viewer draws
17
+ // its own bordered panel + a floating copy button that a clip would
18
+ // crop. So it owns its frame — re-rounded to the shared 2xl radius.
19
+ // `!`-utilities win over the viewer's template-string `rounded-xl`
20
+ // regardless of stylesheet order.
21
+ className: '!rounded-2xl',
22
+ };
23
+
24
+ return block.patch !== undefined ? (
25
+ <LazyDiffViewer {...common} patch={block.patch} />
26
+ ) : (
27
+ <LazyDiffViewer {...common} oldCode={block.oldCode ?? ''} newCode={block.newCode ?? ''} />
28
+ );
29
+ }
@@ -1,26 +1,33 @@
1
1
  'use client';
2
2
 
3
- /** Gallery block — thin chat wrapper around `Gallery`. */
3
+ /**
4
+ * Gallery block.
5
+ *
6
+ * - Compact chat (the common case) → a lightweight Telegram-style
7
+ * `ChatImageAlbum` collage: deterministic 1–N layouts, lazy thumbnails,
8
+ * tap-to-lightbox. No carousel chrome, no extra deps.
9
+ * - Full appearance → the richer `Gallery` (carousel + thumbnail strip +
10
+ * lightbox) for a roomy, full-page chat.
11
+ *
12
+ * Frame (radius / border / clip) comes from the shared `BLOCK_SURFACE`.
13
+ */
4
14
 
5
15
  import { Gallery } from '../../../../media/Gallery';
6
16
  import type { GalleryMediaItem } from '../../../../media/Gallery';
7
17
  import type { GalleryBlock } from '../../../types/block';
8
18
  import type { BlockRendererProps } from './types';
19
+ import { ChatImageAlbum } from './ChatImageAlbum';
9
20
 
10
21
  export default function GalleryBlockRenderer({ block, ctx }: BlockRendererProps<GalleryBlock>) {
22
+ if (ctx.appearance === 'compact') {
23
+ return <ChatImageAlbum images={block.items} />;
24
+ }
25
+
11
26
  const images: GalleryMediaItem[] = block.items.map((item) => ({
12
27
  id: item.id,
13
28
  src: item.src,
14
29
  thumbnail: item.thumbnail,
15
30
  alt: item.alt,
16
31
  }));
17
- return (
18
- <div className="overflow-hidden rounded-xl border border-border">
19
- <Gallery
20
- images={images}
21
- showThumbnails={ctx.appearance !== 'compact'}
22
- enableLightbox
23
- />
24
- </div>
25
- );
32
+ return <Gallery images={images} showThumbnails enableLightbox />;
26
33
  }
@@ -1,27 +1,48 @@
1
1
  'use client';
2
2
 
3
- /** Image block — interactive → `ImageViewer`, otherwise a plain `<img>`. */
3
+ /**
4
+ * Image block — a thumbnail that opens a fullscreen zoom/pan lightbox on
5
+ * click (Telegram / WhatsApp style). The outer frame (radius / border /
6
+ * shadow) is owned by the shared `BLOCK_SURFACE` in MessageBlocks; here we
7
+ * only render the image and the lightbox dialog.
8
+ */
9
+
10
+ import { useState } from 'react';
11
+
12
+ import { Dialog, DialogContent, DialogTitle } from '@djangocfg/ui-core/components';
4
13
 
5
14
  import { LazyImageViewer } from '../../../../media/ImageViewer/lazy';
6
15
  import type { ImageBlock } from '../../../types/block';
7
16
  import type { BlockRendererProps } from './types';
8
17
 
9
18
  export default function ImageBlockRenderer({ block }: BlockRendererProps<ImageBlock>) {
10
- if (block.interactive) {
11
- return (
12
- <div className="overflow-hidden rounded-xl border border-border">
13
- <LazyImageViewer
14
- images={[{ file: { name: block.alt ?? 'image', path: block.src }, src: block.src }]}
15
- />
16
- </div>
17
- );
18
- }
19
+ const [open, setOpen] = useState(false);
20
+ const alt = block.alt ?? 'image';
21
+
19
22
  return (
20
- // eslint-disable-next-line @next/next/no-img-element
21
- <img
22
- src={block.src}
23
- alt={block.alt ?? ''}
24
- className="max-h-96 w-auto max-w-full rounded-xl border border-border object-contain"
25
- />
23
+ <>
24
+ <button
25
+ type="button"
26
+ onClick={() => setOpen(true)}
27
+ aria-label={`Open image${block.alt ? `: ${block.alt}` : ''}`}
28
+ className="block w-full cursor-zoom-in transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
29
+ >
30
+ {/* eslint-disable-next-line @next/next/no-img-element */}
31
+ <img src={block.src} alt={alt} className="max-h-96 w-full object-cover" />
32
+ </button>
33
+
34
+ <Dialog open={open} onOpenChange={setOpen}>
35
+ <DialogContent className="h-[90vh] w-[90vw] max-w-[1400px] overflow-hidden p-0 sm:rounded-xl">
36
+ <DialogTitle className="sr-only">{alt}</DialogTitle>
37
+ {open && (
38
+ <LazyImageViewer
39
+ images={[{ file: { name: alt, path: block.src }, src: block.src }]}
40
+ inDialog
41
+ autoFocus
42
+ />
43
+ )}
44
+ </DialogContent>
45
+ </Dialog>
46
+ </>
26
47
  );
27
48
  }
@@ -12,12 +12,17 @@ export default function JsonBlockRenderer({ block, ctx }: BlockRendererProps<Jso
12
12
  // hover-revealed toolbar + card.
13
13
  const mode = block.mode ?? (ctx.appearance === 'compact' ? 'compact' : 'full');
14
14
  const compact = mode !== 'full';
15
+ // The outer card (radius / border / shadow) comes from the shared
16
+ // `BLOCK_SURFACE`; the tree itself is unbordered with a little padding so
17
+ // it doesn't sit flush against the frame.
15
18
  return (
16
- <LazyJsonTree
17
- data={block.data}
18
- toolbar={compact ? 'never' : 'auto'}
19
- bordered={!compact}
20
- defaultExpandedDepth={compact ? 1 : 2}
21
- />
19
+ <div className="p-2">
20
+ <LazyJsonTree
21
+ data={block.data}
22
+ toolbar={compact ? 'never' : 'auto'}
23
+ bordered={false}
24
+ defaultExpandedDepth={compact ? 1 : 2}
25
+ />
26
+ </div>
22
27
  );
23
28
  }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Link block — renders a `LinkPreviewCard`. If the block carries resolved
5
+ * `data` it shows immediately; otherwise it pulls the host resolver from
6
+ * chat context (`config.linkPreview.resolve`) and unfurls lazily.
7
+ */
8
+
9
+ import { LinkPreviewCard } from '../../../../../common/link-preview';
10
+ import { useChatContextOptional } from '../../../context';
11
+ import type { LinkBlock } from '../../../types/block';
12
+ import type { BlockRendererProps } from './types';
13
+
14
+ export default function LinkBlockRenderer({ block, ctx }: BlockRendererProps<LinkBlock>) {
15
+ const chat = useChatContextOptional();
16
+ const resolver = chat?.config.linkPreview?.resolve;
17
+
18
+ // No data and no resolver → nothing to render (card returns null too).
19
+ if (!block.data && !resolver) return null;
20
+
21
+ // The link payload (`url` + optional pre-resolved `data`) is validated
22
+ // centrally at the registry dispatch point (MessageBlocks) before this
23
+ // renderer runs, so we can trust it here.
24
+ return (
25
+ <LinkPreviewCard
26
+ url={block.url}
27
+ data={block.data}
28
+ resolver={resolver}
29
+ appearance={ctx.appearance}
30
+ />
31
+ );
32
+ }