@flamingo-stack/openframe-frontend-core 0.0.285 → 0.0.286

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 (211) hide show
  1. package/dist/chunk-3MCHAFHB.js +89 -0
  2. package/dist/chunk-3MCHAFHB.js.map +1 -0
  3. package/dist/{chunk-IMNGGLKL.js → chunk-3ZXUQQL4.js} +2 -2
  4. package/dist/{chunk-44ZFLMYC.cjs → chunk-4WACBTZU.cjs} +30 -30
  5. package/dist/{chunk-44ZFLMYC.cjs.map → chunk-4WACBTZU.cjs.map} +1 -1
  6. package/dist/{chunk-23UOJUDF.js → chunk-5BTZOVDQ.js} +4 -4
  7. package/dist/chunk-5KD3S25X.cjs +312 -0
  8. package/dist/chunk-5KD3S25X.cjs.map +1 -0
  9. package/dist/chunk-6N26CURS.cjs +2417 -0
  10. package/dist/chunk-6N26CURS.cjs.map +1 -0
  11. package/dist/{chunk-XX7P4IL2.js → chunk-7NM7DEUK.js} +4 -143
  12. package/dist/chunk-7NM7DEUK.js.map +1 -0
  13. package/dist/chunk-BBZ7AX5H.cjs +290 -0
  14. package/dist/chunk-BBZ7AX5H.cjs.map +1 -0
  15. package/dist/chunk-C6SCWXDP.js +290 -0
  16. package/dist/chunk-C6SCWXDP.js.map +1 -0
  17. package/dist/{chunk-GVSTRN36.cjs → chunk-D3LEFMOA.cjs} +3 -3
  18. package/dist/{chunk-GVSTRN36.cjs.map → chunk-D3LEFMOA.cjs.map} +1 -1
  19. package/dist/{chunk-P45Q4QCU.cjs → chunk-D5YY5U6J.cjs} +19 -15
  20. package/dist/chunk-D5YY5U6J.cjs.map +1 -0
  21. package/dist/chunk-DDAT4RKX.js +2417 -0
  22. package/dist/chunk-DDAT4RKX.js.map +1 -0
  23. package/dist/{chunk-PVXCVPJD.cjs → chunk-EI4WALN2.cjs} +67 -1626
  24. package/dist/chunk-EI4WALN2.cjs.map +1 -0
  25. package/dist/{chunk-ZIODAAFY.cjs → chunk-EL5GCMPU.cjs} +20 -159
  26. package/dist/chunk-EL5GCMPU.cjs.map +1 -0
  27. package/dist/{chunk-VQW7CEGS.js → chunk-FSBDVT6R.js} +1831 -3672
  28. package/dist/chunk-FSBDVT6R.js.map +1 -0
  29. package/dist/{chunk-DPKUSBQH.cjs → chunk-GDF2R2ER.cjs} +1073 -2914
  30. package/dist/chunk-GDF2R2ER.cjs.map +1 -0
  31. package/dist/chunk-HLQW7MWJ.cjs +1594 -0
  32. package/dist/chunk-HLQW7MWJ.cjs.map +1 -0
  33. package/dist/chunk-I6ZPGKZ2.cjs +151 -0
  34. package/dist/chunk-I6ZPGKZ2.cjs.map +1 -0
  35. package/dist/{chunk-J66U3LPZ.cjs → chunk-KBKZYJRI.cjs} +33 -332
  36. package/dist/chunk-KBKZYJRI.cjs.map +1 -0
  37. package/dist/{chunk-USPATRSC.js → chunk-N6ZM5PYZ.js} +45 -344
  38. package/dist/chunk-N6ZM5PYZ.js.map +1 -0
  39. package/dist/{chunk-RGALV4UX.js → chunk-POKKCWKF.js} +3 -82
  40. package/dist/chunk-POKKCWKF.js.map +1 -0
  41. package/dist/{chunk-XDI5JSWB.cjs → chunk-QCKN37OP.cjs} +45 -41
  42. package/dist/chunk-QCKN37OP.cjs.map +1 -0
  43. package/dist/{chunk-KNCDUWY2.js → chunk-QF2X6PTD.js} +10 -6
  44. package/dist/{chunk-KNCDUWY2.js.map → chunk-QF2X6PTD.js.map} +1 -1
  45. package/dist/{chunk-Z3V2SGQZ.js → chunk-R2KT5GDD.js} +324 -1883
  46. package/dist/chunk-R2KT5GDD.js.map +1 -0
  47. package/dist/{chunk-RJUGNL2A.cjs → chunk-RQ6RTBKF.cjs} +16 -12
  48. package/dist/chunk-RQ6RTBKF.cjs.map +1 -0
  49. package/dist/chunk-TFSYSWPS.cjs +89 -0
  50. package/dist/chunk-TFSYSWPS.cjs.map +1 -0
  51. package/dist/chunk-TYZEMPPH.js +312 -0
  52. package/dist/chunk-TYZEMPPH.js.map +1 -0
  53. package/dist/{chunk-O5N57CRV.js → chunk-VCQ3CTYK.js} +12 -8
  54. package/dist/{chunk-O5N57CRV.js.map → chunk-VCQ3CTYK.js.map} +1 -1
  55. package/dist/chunk-VRSXJ5QJ.js +1599 -0
  56. package/dist/chunk-VRSXJ5QJ.js.map +1 -0
  57. package/dist/{chunk-53CFL7PH.js → chunk-XQL4WDML.js} +11 -7
  58. package/dist/{chunk-53CFL7PH.js.map → chunk-XQL4WDML.js.map} +1 -1
  59. package/dist/chunk-Y4JNA4W6.cjs +1599 -0
  60. package/dist/chunk-Y4JNA4W6.cjs.map +1 -0
  61. package/dist/{chunk-ZSUNQJRO.cjs → chunk-YETA25JW.cjs} +2 -81
  62. package/dist/chunk-YETA25JW.cjs.map +1 -0
  63. package/dist/chunk-YVB3VDIQ.js +1594 -0
  64. package/dist/chunk-YVB3VDIQ.js.map +1 -0
  65. package/dist/chunk-ZLN6SM2U.js +151 -0
  66. package/dist/chunk-ZLN6SM2U.js.map +1 -0
  67. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +12 -6
  69. package/dist/components/chat/index.cjs.map +1 -1
  70. package/dist/components/chat/index.js +45 -39
  71. package/dist/components/contact/index.cjs +11 -7
  72. package/dist/components/contact/index.cjs.map +1 -1
  73. package/dist/components/contact/index.js +10 -6
  74. package/dist/components/docs/doc-navigation-context.d.ts +39 -0
  75. package/dist/components/docs/doc-navigation-context.d.ts.map +1 -0
  76. package/dist/components/docs/doc-viewer.d.ts +66 -0
  77. package/dist/components/docs/doc-viewer.d.ts.map +1 -0
  78. package/dist/components/docs/index.cjs +34 -0
  79. package/dist/components/docs/index.cjs.map +1 -0
  80. package/dist/components/docs/index.d.ts +8 -0
  81. package/dist/components/docs/index.d.ts.map +1 -0
  82. package/dist/components/docs/index.js +34 -0
  83. package/dist/components/docs/index.js.map +1 -0
  84. package/dist/components/docs/use-document-tree.d.ts +41 -0
  85. package/dist/components/docs/use-document-tree.d.ts.map +1 -0
  86. package/dist/components/docs/use-scroll-spy.d.ts +16 -0
  87. package/dist/components/docs/use-scroll-spy.d.ts.map +1 -0
  88. package/dist/components/embeds/embed-iframe.d.ts +29 -0
  89. package/dist/components/embeds/embed-iframe.d.ts.map +1 -0
  90. package/dist/components/embeds/figma-embed.d.ts +27 -0
  91. package/dist/components/embeds/figma-embed.d.ts.map +1 -0
  92. package/dist/components/embeds/google-sheets-viewer.d.ts +8 -0
  93. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -0
  94. package/dist/components/embeds/index.cjs +37 -0
  95. package/dist/components/embeds/index.cjs.map +1 -0
  96. package/dist/components/embeds/index.d.ts +9 -0
  97. package/dist/components/embeds/index.d.ts.map +1 -0
  98. package/dist/components/embeds/index.js +37 -0
  99. package/dist/components/embeds/index.js.map +1 -0
  100. package/dist/components/embeds/pdf-viewer.d.ts +10 -0
  101. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -0
  102. package/dist/components/faq/index.cjs +12 -8
  103. package/dist/components/faq/index.cjs.map +1 -1
  104. package/dist/components/faq/index.js +11 -7
  105. package/dist/components/features/index.cjs +10 -6
  106. package/dist/components/features/index.cjs.map +1 -1
  107. package/dist/components/features/index.js +9 -5
  108. package/dist/components/index.cjs +247 -1120
  109. package/dist/components/index.cjs.map +1 -1
  110. package/dist/components/index.d.ts +2 -0
  111. package/dist/components/index.d.ts.map +1 -1
  112. package/dist/components/index.js +645 -1518
  113. package/dist/components/index.js.map +1 -1
  114. package/dist/components/navigation/index.cjs +15 -6
  115. package/dist/components/navigation/index.cjs.map +1 -1
  116. package/dist/components/navigation/index.d.ts +2 -0
  117. package/dist/components/navigation/index.d.ts.map +1 -1
  118. package/dist/components/navigation/index.js +15 -6
  119. package/dist/components/navigation/multi-level-navigation.d.ts +28 -0
  120. package/dist/components/navigation/multi-level-navigation.d.ts.map +1 -0
  121. package/dist/components/onboarding-guides/index.cjs +34 -29
  122. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  123. package/dist/components/onboarding-guides/index.js +13 -8
  124. package/dist/components/onboarding-guides/index.js.map +1 -1
  125. package/dist/components/related-content/index.cjs +12 -8
  126. package/dist/components/related-content/index.cjs.map +1 -1
  127. package/dist/components/related-content/index.js +11 -7
  128. package/dist/components/tickets/index.cjs +75 -70
  129. package/dist/components/tickets/index.cjs.map +1 -1
  130. package/dist/components/tickets/index.js +16 -11
  131. package/dist/components/tickets/index.js.map +1 -1
  132. package/dist/components/ui/index.cjs +12 -6
  133. package/dist/components/ui/index.cjs.map +1 -1
  134. package/dist/components/ui/index.js +65 -59
  135. package/dist/embed-shims/index.cjs +3 -3
  136. package/dist/embed-shims/index.cjs.map +1 -1
  137. package/dist/embed-shims/index.js +8 -8
  138. package/dist/hooks/index.cjs +6 -3
  139. package/dist/hooks/index.cjs.map +1 -1
  140. package/dist/hooks/index.js +7 -4
  141. package/dist/index.cjs +62 -6
  142. package/dist/index.cjs.map +1 -1
  143. package/dist/index.js +257 -201
  144. package/dist/types/doc-source.cjs +4 -0
  145. package/dist/types/doc-source.cjs.map +1 -0
  146. package/dist/types/doc-source.d.ts +86 -0
  147. package/dist/types/doc-source.d.ts.map +1 -0
  148. package/dist/types/doc-source.js +3 -0
  149. package/dist/types/doc-source.js.map +1 -0
  150. package/dist/types/index.d.ts +1 -0
  151. package/dist/types/index.d.ts.map +1 -1
  152. package/dist/utils/doc-path-utils.d.ts +24 -0
  153. package/dist/utils/doc-path-utils.d.ts.map +1 -0
  154. package/dist/utils/doc-tree-nav.d.ts +35 -0
  155. package/dist/utils/doc-tree-nav.d.ts.map +1 -0
  156. package/dist/utils/embed-url-converters.d.ts +16 -0
  157. package/dist/utils/embed-url-converters.d.ts.map +1 -0
  158. package/dist/utils/format.d.ts +9 -0
  159. package/dist/utils/format.d.ts.map +1 -1
  160. package/dist/utils/index.cjs +297 -0
  161. package/dist/utils/index.cjs.map +1 -1
  162. package/dist/utils/index.d.ts +7 -1
  163. package/dist/utils/index.d.ts.map +1 -1
  164. package/dist/utils/index.js +276 -1
  165. package/dist/utils/index.js.map +1 -1
  166. package/dist/utils/markdown-section-extractor.d.ts +23 -0
  167. package/dist/utils/markdown-section-extractor.d.ts.map +1 -0
  168. package/dist/utils/markdown-to-plain.d.ts +32 -0
  169. package/dist/utils/markdown-to-plain.d.ts.map +1 -0
  170. package/dist/utils/tree-builder.d.ts +30 -0
  171. package/dist/utils/tree-builder.d.ts.map +1 -0
  172. package/package.json +19 -1
  173. package/src/components/chat/chat-attachment-bar.tsx +6 -11
  174. package/src/components/docs/doc-navigation-context.tsx +123 -0
  175. package/src/components/docs/doc-viewer.tsx +345 -0
  176. package/src/components/docs/index.ts +12 -0
  177. package/src/components/docs/use-document-tree.ts +427 -0
  178. package/src/components/docs/use-scroll-spy.ts +93 -0
  179. package/src/components/embeds/embed-iframe.tsx +103 -0
  180. package/src/components/embeds/figma-embed.tsx +147 -0
  181. package/src/components/embeds/google-sheets-viewer.tsx +54 -0
  182. package/src/components/embeds/index.ts +11 -0
  183. package/src/components/embeds/pdf-viewer.tsx +64 -0
  184. package/src/components/index.ts +2 -0
  185. package/src/components/navigation/index.ts +5 -0
  186. package/src/components/navigation/multi-level-navigation.tsx +358 -0
  187. package/src/types/doc-source.ts +88 -0
  188. package/src/types/index.ts +4 -0
  189. package/src/utils/doc-path-utils.ts +47 -0
  190. package/src/utils/doc-tree-nav.ts +111 -0
  191. package/src/utils/embed-url-converters.ts +77 -0
  192. package/src/utils/format.ts +16 -0
  193. package/src/utils/index.ts +11 -0
  194. package/src/utils/markdown-section-extractor.ts +106 -0
  195. package/src/utils/markdown-to-plain.ts +69 -0
  196. package/src/utils/tree-builder.ts +114 -0
  197. package/dist/chunk-DPKUSBQH.cjs.map +0 -1
  198. package/dist/chunk-J66U3LPZ.cjs.map +0 -1
  199. package/dist/chunk-P45Q4QCU.cjs.map +0 -1
  200. package/dist/chunk-PVXCVPJD.cjs.map +0 -1
  201. package/dist/chunk-RGALV4UX.js.map +0 -1
  202. package/dist/chunk-RJUGNL2A.cjs.map +0 -1
  203. package/dist/chunk-USPATRSC.js.map +0 -1
  204. package/dist/chunk-VQW7CEGS.js.map +0 -1
  205. package/dist/chunk-XDI5JSWB.cjs.map +0 -1
  206. package/dist/chunk-XX7P4IL2.js.map +0 -1
  207. package/dist/chunk-Z3V2SGQZ.js.map +0 -1
  208. package/dist/chunk-ZIODAAFY.cjs.map +0 -1
  209. package/dist/chunk-ZSUNQJRO.cjs.map +0 -1
  210. /package/dist/{chunk-IMNGGLKL.js.map → chunk-3ZXUQQL4.js.map} +0 -0
  211. /package/dist/{chunk-23UOJUDF.js.map → chunk-5BTZOVDQ.js.map} +0 -0
@@ -0,0 +1,147 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button, ToggleGroup, ToggleGroupItem } from '../ui'
5
+ import { FigmaIcon } from '../icons-v2-generated'
6
+ import { ExternalLink, Play, LayoutGrid } from 'lucide-react'
7
+ import { toFigmaEmbedUrl, toFigmaOriginalUrl, isFigmaSlidesUrl } from '../../utils/embed-url-converters'
8
+ import { EmbedIframe } from './embed-iframe'
9
+
10
+ export interface FigmaEmbedProps {
11
+ /** Any Figma URL (design/file/proto/board/slides/deck) or an already-resolved embed URL. */
12
+ url: string
13
+ /** Heading shown above the embed. Defaults to "Figma Design". */
14
+ title?: string
15
+ /**
16
+ * iframe height (CSS value). The data-room document viewer omits it (full
17
+ * height, `calc(100vh - 250px)`); inline markdown passes e.g. `"70vh"` so the
18
+ * embed sits naturally inside article content.
19
+ */
20
+ height?: string
21
+ /** iframe loading strategy. Defaults to `"lazy"`; the data-room viewer passes `"eager"`. */
22
+ loading?: 'eager' | 'lazy'
23
+ }
24
+
25
+ type SlidesView = 'present' | 'browse'
26
+
27
+ /**
28
+ * Two-state present/browse toggle for Figma Slides. `present` (default) uses
29
+ * Figma's deck viewer (full-bleed slide + `‹ n/N ›` nav bar + keyboard nav);
30
+ * `browse` uses the thumbnail-rail + zoom viewer.
31
+ */
32
+ function SlidesViewToggle({
33
+ view,
34
+ onChange,
35
+ }: {
36
+ view: SlidesView
37
+ onChange: (v: SlidesView) => void
38
+ }) {
39
+ const options: { key: SlidesView; label: string; Icon: typeof Play }[] = [
40
+ { key: 'present', label: 'Present', Icon: Play },
41
+ { key: 'browse', label: 'Browse', Icon: LayoutGrid },
42
+ ]
43
+ return (
44
+ <ToggleGroup
45
+ type="single"
46
+ value={view}
47
+ onValueChange={(v: string) => {
48
+ if (v && v !== view) onChange(v as SlidesView)
49
+ }}
50
+ aria-label="Figma slides view mode"
51
+ className="flex shrink-0 items-center gap-0.5 rounded-lg border border-ods-border bg-ods-card p-0.5"
52
+ >
53
+ {options.map(({ key, label, Icon }) => {
54
+ const active = view === key
55
+ return (
56
+ <ToggleGroupItem
57
+ key={key}
58
+ value={key}
59
+ aria-label={label}
60
+ className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-colors ${
61
+ active
62
+ ? 'bg-ods-accent text-ods-text-on-accent'
63
+ : 'text-ods-text-secondary hover:text-ods-text-primary hover:bg-ods-bg-hover'
64
+ }`}
65
+ >
66
+ <Icon className="h-4 w-4 shrink-0" />
67
+ {label}
68
+ </ToggleGroupItem>
69
+ )
70
+ })}
71
+ </ToggleGroup>
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Single source of truth for every Figma surface — the data-room document viewer
77
+ * and in-article markdown both render this. A header (icon + title + "Open in
78
+ * Figma") over an interactive Figma iframe, built from the canonical
79
+ * `toFigmaEmbedUrl` / `toFigmaOriginalUrl` converters + the shared `<EmbedIframe>`.
80
+ * Only height/loading differ per surface.
81
+ *
82
+ * For Slides decks, a present/browse toggle (default = present) lets viewers flip
83
+ * slides with Figma's native nav bar + keyboard, or switch to the thumbnail-rail
84
+ * browse view.
85
+ */
86
+ export function FigmaEmbed({ url, title, height, loading = 'lazy' }: FigmaEmbedProps) {
87
+ const [view, setView] = useState<SlidesView>('present')
88
+ const isSlides = url ? isFigmaSlidesUrl(url) : false
89
+ const embedSrc = url ? toFigmaEmbedUrl(url, { slidesView: view }) : null
90
+ const originalUrl = (() => {
91
+ if (!url) return null
92
+ try {
93
+ const parsed = new URL(toFigmaOriginalUrl(url))
94
+ const host = parsed.hostname.toLowerCase()
95
+ const okHost = host === 'figma.com' || host.endsWith('.figma.com')
96
+ const okProtocol = parsed.protocol === 'https:' || parsed.protocol === 'http:'
97
+ return okHost && okProtocol ? parsed.toString() : null
98
+ } catch {
99
+ return null
100
+ }
101
+ })()
102
+ const heading = title || 'Figma Design'
103
+
104
+ return (
105
+ <div className="my-6 space-y-3">
106
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
107
+ <div className="flex items-center gap-2 min-w-0">
108
+ <FigmaIcon className="w-5 h-5 shrink-0" />
109
+ <span className="font-sans text-base font-semibold text-ods-text-primary truncate">
110
+ {heading}
111
+ </span>
112
+ </div>
113
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
114
+ {isSlides && embedSrc && <SlidesViewToggle view={view} onChange={setView} />}
115
+ {originalUrl && (
116
+ <Button
117
+ variant="outline"
118
+ size="small-legacy"
119
+ href={originalUrl}
120
+ openInNewTab
121
+ leftIcon={<FigmaIcon className="w-4 h-4" />}
122
+ rightIcon={<ExternalLink className="w-4 h-4" />}
123
+ className="w-full sm:w-auto"
124
+ >
125
+ Open in Figma
126
+ </Button>
127
+ )}
128
+ </div>
129
+ </div>
130
+ {embedSrc ? (
131
+ <EmbedIframe
132
+ src={embedSrc}
133
+ title={heading}
134
+ allow="clipboard-write; clipboard-read; fullscreen"
135
+ loading={loading}
136
+ height={height}
137
+ allowFullScreen
138
+ />
139
+ ) : (
140
+ <div className="flex flex-col items-center justify-center py-16 text-center">
141
+ <FigmaIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
142
+ <p className="text-ods-text-secondary">Figma URL not configured</p>
143
+ </div>
144
+ )}
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,54 @@
1
+ "use client"
2
+
3
+ import React from 'react'
4
+ import { Button } from '../ui'
5
+ import { ExternalLink } from 'lucide-react'
6
+ import { GoogleSheetsIcon } from '../icons-v2-generated'
7
+ import { EmbedIframe } from './embed-iframe'
8
+ import { toGoogleSheetsEmbedUrl, toGoogleSheetsOriginalUrl } from '../../utils/embed-url-converters'
9
+
10
+ export interface GoogleSheetsViewerProps {
11
+ externalUrl: string
12
+ fileName?: string
13
+ height?: string
14
+ }
15
+
16
+ export function GoogleSheetsViewer({ externalUrl, fileName, height }: GoogleSheetsViewerProps) {
17
+ const displayName = fileName || 'Google Sheet'
18
+
19
+ if (!externalUrl) {
20
+ return (
21
+ <div className="flex flex-col items-center justify-center py-16 text-center">
22
+ <GoogleSheetsIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
23
+ <p className="text-ods-text-secondary">Google Sheet URL not configured</p>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ return (
29
+ <div className="space-y-4">
30
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
31
+ <div className="flex items-center gap-2 min-w-0">
32
+ <GoogleSheetsIcon className="w-5 h-5 shrink-0" />
33
+ <h2 className="text-xl font-semibold text-ods-text-primary truncate">{displayName}</h2>
34
+ </div>
35
+ <Button
36
+ variant="outline"
37
+ size="small-legacy"
38
+ href={toGoogleSheetsOriginalUrl(externalUrl)}
39
+ openInNewTab
40
+ leftIcon={<GoogleSheetsIcon className="w-4 h-4" />}
41
+ rightIcon={<ExternalLink className="w-4 h-4" />}
42
+ className="w-full sm:w-auto"
43
+ >
44
+ Open in Google Sheets
45
+ </Button>
46
+ </div>
47
+ <EmbedIframe
48
+ src={toGoogleSheetsEmbedUrl(externalUrl)}
49
+ title={displayName}
50
+ height={height}
51
+ />
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,11 @@
1
+ export { EmbedIframe } from './embed-iframe'
2
+ export type { EmbedIframeProps } from './embed-iframe'
3
+
4
+ export { PdfViewer } from './pdf-viewer'
5
+ export type { PdfViewerProps } from './pdf-viewer'
6
+
7
+ export { GoogleSheetsViewer } from './google-sheets-viewer'
8
+ export type { GoogleSheetsViewerProps } from './google-sheets-viewer'
9
+
10
+ export { FigmaEmbed } from './figma-embed'
11
+ export type { FigmaEmbedProps } from './figma-embed'
@@ -0,0 +1,64 @@
1
+ "use client"
2
+
3
+ import React from 'react'
4
+ import { Button } from '../ui'
5
+ import { Download, Eye } from 'lucide-react'
6
+ import { AdobePdfIcon } from '../icons-v2-generated'
7
+ import { EmbedIframe } from './embed-iframe'
8
+
9
+ export interface PdfViewerProps {
10
+ src: string
11
+ fileName?: string
12
+ onPreview?: () => void
13
+ onDownload?: () => void
14
+ height?: string
15
+ }
16
+
17
+ export function PdfViewer({ src, fileName, onPreview, onDownload, height }: PdfViewerProps) {
18
+ const displayName = fileName || 'PDF Document'
19
+
20
+ if (!src) {
21
+ return (
22
+ <div className="flex flex-col items-center justify-center py-16 text-center">
23
+ <AdobePdfIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
24
+ <p className="text-ods-text-secondary">PDF file not available</p>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <div className="space-y-4">
31
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
32
+ <div className="flex items-center gap-2 min-w-0">
33
+ <AdobePdfIcon className="w-5 h-5 shrink-0" />
34
+ <h2 className="text-xl font-semibold text-ods-text-primary truncate">{displayName}</h2>
35
+ </div>
36
+ <div className="flex items-center gap-2 w-full sm:w-auto">
37
+ <Button
38
+ variant="outline"
39
+ size="small-legacy"
40
+ href={onPreview ? undefined : src}
41
+ openInNewTab={!onPreview}
42
+ onClick={onPreview}
43
+ leftIcon={<Eye className="w-4 h-4" />}
44
+ className="flex-1 sm:flex-initial"
45
+ >
46
+ Preview
47
+ </Button>
48
+ <Button
49
+ variant="outline"
50
+ size="small-legacy"
51
+ href={onDownload ? undefined : src}
52
+ openInNewTab={!onDownload}
53
+ onClick={onDownload}
54
+ leftIcon={<Download className="w-4 h-4" />}
55
+ className="flex-1 sm:flex-initial"
56
+ >
57
+ Download
58
+ </Button>
59
+ </div>
60
+ </div>
61
+ <EmbedIframe src={src} title={displayName} height={height} />
62
+ </div>
63
+ )
64
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Components exports
4
4
  export * from './chat'
5
+ export * from './docs'
6
+ export * from './embeds'
5
7
  export * from './features'
6
8
  export * from './icons'
7
9
  export * from './navigation'
@@ -56,6 +56,11 @@ export type { HeaderGlobalSearchProps } from './header-global-search'
56
56
  export { HeaderOrganizationFilter } from './header-organization-filter'
57
57
  export type { HeaderOrganizationFilterOrganization, HeaderOrganizationFilterProps } from './header-organization-filter'
58
58
 
59
+ // Multi-level navigation — sidebar + mobile dropdown for the doc-viewer
60
+ // (DocViewer / DocSourceViewer) and any other tree-shaped navigation surface.
61
+ export { MultiLevelNavigation, MobileNavigationDropdown } from './multi-level-navigation'
62
+ export type { NavigationNode } from './multi-level-navigation'
63
+
59
64
  // Re-export types from navigation types
60
65
  export type {
61
66
  MobileNavConfig, NavigationItem, NavigationSidebarConfig,
@@ -0,0 +1,358 @@
1
+ "use client"
2
+
3
+ import { ChevronRight, ChevronDown, ChevronUp, FileText, Folder, FolderOpen } from 'lucide-react'
4
+ import { cn } from '../../utils/cn'
5
+ import { DEFAULT_FOLDER_INDEX_FILE as CANONICAL_FOLDER_INDEX_FILE } from '../../utils/doc-tree-nav'
6
+
7
+ export interface NavigationNode {
8
+ id: string
9
+ name: string
10
+ path: string
11
+ type: 'file' | 'folder'
12
+ hasReadme?: boolean
13
+ children?: NavigationNode[]
14
+ slug?: string
15
+ // Mirror DocNode's extra optional fields so a DocNode tree is structurally
16
+ // assignable to NavigationNode[] without an `as` cast. NavigationNode
17
+ // intentionally never reads these — they're carried for type-compat only.
18
+ documentType?: 'markdown' | 'pdf' | 'google_sheet' | 'figma' | 'file'
19
+ sortOrder?: number
20
+ }
21
+
22
+ interface MultiLevelNavigationProps {
23
+ nodes: NavigationNode[]
24
+ selectedPath: string
25
+ expandedNodes: Set<string>
26
+ onNodeClick: (node: NavigationNode) => void
27
+ onToggleExpand?: (nodeId: string) => void
28
+ isLoading?: boolean
29
+ className?: string
30
+ /** Folder-index filename (default `'README.md'`, case-insensitive). When the
31
+ * selectedPath is a folder with this file, the visual ribbon moves to the
32
+ * child file. */
33
+ folderIndexFile?: string
34
+ }
35
+
36
+ // SSOT lives in `doc-tree-nav` (canonical case `'README.md'`). The visual
37
+ // comparator below lowercases both sides, so re-exporting the canonical
38
+ // constant under the same local name preserves call-site syntax without
39
+ // drifting from the SSOT.
40
+ const DEFAULT_FOLDER_INDEX_FILE = CANONICAL_FOLDER_INDEX_FILE
41
+
42
+ /**
43
+ * Compute the visual "selected" state for a navigation node.
44
+ *
45
+ * Folder-with-README convention: when `selectedPath` is a folder that has a
46
+ * README, the FOLDER doesn't get the ribbon — its README child does. The folder
47
+ * is just a container; the README is the actual document being viewed.
48
+ *
49
+ * Mirror case: a README file whose parent folder is the `selectedPath` gets the
50
+ * visual selection ribbon (implicit). This handles both the auto-select-first-
51
+ * folder landing path AND the user-clicks-folder case.
52
+ */
53
+ function isNodeVisuallySelected(
54
+ node: NavigationNode,
55
+ selectedPath: string,
56
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX_FILE,
57
+ ): boolean {
58
+ const FOLDER_INDEX_FILE = folderIndexFile.toLowerCase()
59
+ // Path comparisons keyed on `node.path` (raw, includes `.md`). Do NOT use
60
+ // `node.name` — the tree-builder runs it through `formatDocName` which strips
61
+ // the `.md` extension, so `node.name.toLowerCase() === 'readme.md'` is always
62
+ // false.
63
+ if (selectedPath === node.path) {
64
+ // Explicit match — but folder-with-README defers to its README child.
65
+ return !(node.type === 'folder' && node.hasReadme)
66
+ }
67
+ if (node.type !== 'file') return false
68
+ const pathLower = node.path.toLowerCase()
69
+ // Implicit: README child whose parent folder is the selectedPath.
70
+ if (
71
+ selectedPath !== '' &&
72
+ pathLower === `${selectedPath.toLowerCase()}/${FOLDER_INDEX_FILE}`
73
+ ) {
74
+ return true
75
+ }
76
+ // Implicit: ROOT README on the landing page (selectedPath === '' means
77
+ // "no explicit doc selected" — the viewer falls back to the root folder-
78
+ // index file, so that file IS the active document).
79
+ if (selectedPath === '' && pathLower === FOLDER_INDEX_FILE) {
80
+ return true
81
+ }
82
+ return false
83
+ }
84
+
85
+ export function MultiLevelNavigation({
86
+ nodes,
87
+ selectedPath,
88
+ expandedNodes,
89
+ onNodeClick,
90
+ onToggleExpand,
91
+ isLoading,
92
+ className,
93
+ folderIndexFile = DEFAULT_FOLDER_INDEX_FILE,
94
+ }: MultiLevelNavigationProps) {
95
+ if (isLoading) {
96
+ return (
97
+ <div className={cn("space-y-2", className)}>
98
+ {[1, 2, 3, 4].map((i) => (
99
+ <div key={i} className="p-3 bg-ods-skeleton rounded-lg animate-pulse h-12" />
100
+ ))}
101
+ </div>
102
+ )
103
+ }
104
+
105
+ return (
106
+ <div className={cn("space-y-2", className)} role="list" aria-label="Navigation list">
107
+ {nodes.map(node => (
108
+ <NavigationItem
109
+ key={node.id}
110
+ node={node}
111
+ selectedPath={selectedPath}
112
+ expandedNodes={expandedNodes}
113
+ onNodeClick={onNodeClick}
114
+ onToggleExpand={onToggleExpand}
115
+ level={0}
116
+ folderIndexFile={folderIndexFile}
117
+ />
118
+ ))}
119
+ </div>
120
+ )
121
+ }
122
+
123
+ interface NavigationItemProps {
124
+ node: NavigationNode
125
+ selectedPath: string
126
+ expandedNodes: Set<string>
127
+ onNodeClick: (node: NavigationNode) => void
128
+ onToggleExpand?: (nodeId: string) => void
129
+ level: number
130
+ folderIndexFile: string
131
+ }
132
+
133
+ function NavigationItem({
134
+ node,
135
+ selectedPath,
136
+ expandedNodes,
137
+ onNodeClick,
138
+ onToggleExpand,
139
+ level,
140
+ folderIndexFile,
141
+ }: NavigationItemProps) {
142
+ const isExpanded = expandedNodes.has(node.id)
143
+ const isSelected = isNodeVisuallySelected(node, selectedPath, folderIndexFile)
144
+ const hasChildren = node.children && node.children.length > 0
145
+
146
+ return (
147
+ <div className="space-y-1" role="listitem">
148
+ <div className={cn("px-2", level > 0 && "ml-4")}>
149
+ <div
150
+ className={cn(
151
+ "w-full rounded-lg transition-all duration-150 border relative",
152
+ isSelected
153
+ ? "bg-ods-border border-ods-border"
154
+ : "bg-ods-card border-ods-border"
155
+ )}
156
+ >
157
+ <div className="flex items-center relative">
158
+ <button
159
+ className={cn(
160
+ "w-full flex items-center h-12 px-2 rounded-lg text-[16px] font-medium font-['DM_Sans'] transition-all duration-150 leading-[1.33em] text-ods-text-primary",
161
+ !isSelected && "hover:bg-ods-bg-secondary",
162
+ hasChildren && "pr-12"
163
+ )}
164
+ onClick={() => onNodeClick(node)}
165
+ aria-label={`${isSelected ? 'Selected' : 'Select'} ${node.name}`}
166
+ >
167
+ <span className="flex-shrink-0 mr-2">
168
+ {node.type === 'folder' ? (
169
+ isExpanded ? <FolderOpen className="h-4 w-4" /> : <Folder className="h-4 w-4" />
170
+ ) : (
171
+ <FileText className="h-4 w-4" />
172
+ )}
173
+ </span>
174
+ <span className="text-left truncate flex-1 min-w-0">
175
+ {node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name}
176
+ </span>
177
+ {node.type === 'folder' && node.hasReadme && (
178
+ <span className="text-[10px] bg-ods-bg-secondary text-ods-text-tertiary px-1.5 py-0.5 rounded mr-2">
179
+ README
180
+ </span>
181
+ )}
182
+ </button>
183
+
184
+ {hasChildren && (
185
+ <button
186
+ className="absolute right-0 top-0 flex items-center justify-center w-12 h-12 text-ods-text-secondary hover:text-ods-text-primary transition-colors duration-150"
187
+ onClick={(e) => {
188
+ e.stopPropagation()
189
+ if (onToggleExpand) {
190
+ onToggleExpand(node.id)
191
+ } else {
192
+ onNodeClick(node)
193
+ }
194
+ }}
195
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${node.name}`}
196
+ >
197
+ {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
198
+ </button>
199
+ )}
200
+ </div>
201
+
202
+ {isSelected && (
203
+ <div className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-[calc(100%-8px)] bg-ods-accent rounded-l" />
204
+ )}
205
+ </div>
206
+ </div>
207
+
208
+ {hasChildren && isExpanded && (
209
+ <div className="space-y-1">
210
+ {node.children!.map(child => (
211
+ <NavigationItem
212
+ key={child.id}
213
+ node={child}
214
+ selectedPath={selectedPath}
215
+ expandedNodes={expandedNodes}
216
+ onNodeClick={onNodeClick}
217
+ onToggleExpand={onToggleExpand}
218
+ level={level + 1}
219
+ folderIndexFile={folderIndexFile}
220
+ />
221
+ ))}
222
+ </div>
223
+ )}
224
+ </div>
225
+ )
226
+ }
227
+
228
+ export function MobileNavigationDropdown({
229
+ nodes,
230
+ selectedPath,
231
+ expandedNodes,
232
+ onNodeClick,
233
+ onToggleExpand,
234
+ isLoading,
235
+ className,
236
+ folderIndexFile = DEFAULT_FOLDER_INDEX_FILE,
237
+ }: MultiLevelNavigationProps) {
238
+ if (isLoading) {
239
+ return (
240
+ <div className={cn("space-y-2", className)}>
241
+ {[1, 2, 3].map((i) => (
242
+ <div key={i} className="p-3 bg-ods-skeleton rounded-lg animate-pulse h-10" />
243
+ ))}
244
+ </div>
245
+ )
246
+ }
247
+
248
+ return (
249
+ <div className={cn("space-y-2", className)}>
250
+ {nodes.map(node => (
251
+ <MobileNavigationItem
252
+ key={node.id}
253
+ node={node}
254
+ selectedPath={selectedPath}
255
+ expandedNodes={expandedNodes}
256
+ onNodeClick={onNodeClick}
257
+ onToggleExpand={onToggleExpand}
258
+ level={0}
259
+ folderIndexFile={folderIndexFile}
260
+ />
261
+ ))}
262
+ </div>
263
+ )
264
+ }
265
+
266
+ function MobileNavigationItem({
267
+ node,
268
+ selectedPath,
269
+ expandedNodes,
270
+ onNodeClick,
271
+ onToggleExpand,
272
+ level,
273
+ folderIndexFile,
274
+ }: NavigationItemProps) {
275
+ const isExpanded = expandedNodes.has(node.id)
276
+ const isSelected = isNodeVisuallySelected(node, selectedPath, folderIndexFile)
277
+ const hasChildren = node.children && node.children.length > 0
278
+
279
+ return (
280
+ <div className="space-y-1">
281
+ <div className={cn("px-2", level > 0 && "ml-3")}>
282
+ <div
283
+ className={cn(
284
+ "w-full rounded-lg transition-all duration-150 border relative",
285
+ isSelected
286
+ ? "bg-ods-border border-ods-border"
287
+ : "bg-ods-card border-ods-border"
288
+ )}
289
+ >
290
+ <div className="flex items-center relative">
291
+ <button
292
+ className={cn(
293
+ "w-full flex items-center h-11 px-2 rounded-lg text-[15px] font-medium font-['DM_Sans'] transition-all duration-150 leading-[1.33em] text-ods-text-primary",
294
+ !isSelected && "hover:bg-ods-bg-secondary",
295
+ hasChildren && "pr-11"
296
+ )}
297
+ onClick={() => onNodeClick(node)}
298
+ >
299
+ <span className="flex-shrink-0 mr-2">
300
+ {node.type === 'folder' ? (
301
+ isExpanded ? <FolderOpen className="h-4 w-4" /> : <Folder className="h-4 w-4" />
302
+ ) : (
303
+ <FileText className="h-4 w-4" />
304
+ )}
305
+ </span>
306
+ <span className="text-left truncate flex-1 min-w-0 text-sm">
307
+ {node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name}
308
+ </span>
309
+ {node.type === 'folder' && node.hasReadme && (
310
+ <span className="text-[10px] bg-ods-bg-secondary text-ods-text-tertiary px-1.5 py-0.5 rounded mr-2">
311
+ README
312
+ </span>
313
+ )}
314
+ </button>
315
+
316
+ {hasChildren && (
317
+ <button
318
+ className="absolute right-0 top-0 flex items-center justify-center w-11 h-11 text-ods-text-secondary hover:text-ods-text-primary transition-colors duration-150"
319
+ onClick={(e) => {
320
+ e.stopPropagation()
321
+ if (onToggleExpand) {
322
+ onToggleExpand(node.id)
323
+ } else {
324
+ onNodeClick(node)
325
+ }
326
+ }}
327
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${node.name}`}
328
+ >
329
+ {isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
330
+ </button>
331
+ )}
332
+ </div>
333
+
334
+ {isSelected && (
335
+ <div className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-[calc(100%-8px)] bg-ods-accent rounded-l" />
336
+ )}
337
+ </div>
338
+ </div>
339
+
340
+ {hasChildren && isExpanded && (
341
+ <div className="space-y-1">
342
+ {node.children!.map(child => (
343
+ <MobileNavigationItem
344
+ key={child.id}
345
+ node={child}
346
+ selectedPath={selectedPath}
347
+ expandedNodes={expandedNodes}
348
+ onNodeClick={onNodeClick}
349
+ onToggleExpand={onToggleExpand}
350
+ level={level + 1}
351
+ folderIndexFile={folderIndexFile}
352
+ />
353
+ ))}
354
+ </div>
355
+ )}
356
+ </div>
357
+ )
358
+ }