@flamingo-stack/openframe-frontend-core 0.0.255 → 0.0.256

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 (147) hide show
  1. package/dist/{chunk-GYKDSPC4.cjs → chunk-3DC6MHW6.cjs} +31 -31
  2. package/dist/{chunk-GYKDSPC4.cjs.map → chunk-3DC6MHW6.cjs.map} +1 -1
  3. package/dist/{chunk-EFIEQ4XD.js → chunk-46DHPQY6.js} +4 -4
  4. package/dist/{chunk-RCW2IL4K.cjs → chunk-6F5CGBVT.cjs} +692 -728
  5. package/dist/chunk-6F5CGBVT.cjs.map +1 -0
  6. package/dist/{chunk-HMFBMVJC.js → chunk-6GBA6YJN.js} +2 -2
  7. package/dist/{chunk-4HHYUEMD.js → chunk-7RLV3U5C.js} +2 -2
  8. package/dist/{chunk-SGJBUL2Y.cjs → chunk-7VMEOFEA.cjs} +45 -2
  9. package/dist/chunk-7VMEOFEA.cjs.map +1 -0
  10. package/dist/{chunk-UGFSMA7K.cjs → chunk-CMWRZECW.cjs} +77 -42
  11. package/dist/chunk-CMWRZECW.cjs.map +1 -0
  12. package/dist/{chunk-D524LJYO.js → chunk-HCMVLEC3.js} +4 -4
  13. package/dist/{chunk-LZL6P7SN.cjs → chunk-IG4QRNYZ.cjs} +10 -10
  14. package/dist/{chunk-LZL6P7SN.cjs.map → chunk-IG4QRNYZ.cjs.map} +1 -1
  15. package/dist/{chunk-CVGZJBHT.cjs → chunk-ISALYOFX.cjs} +19 -19
  16. package/dist/{chunk-CVGZJBHT.cjs.map → chunk-ISALYOFX.cjs.map} +1 -1
  17. package/dist/{chunk-MKVKVJQQ.js → chunk-J3QMPOS6.js} +5 -4
  18. package/dist/chunk-J3QMPOS6.js.map +1 -0
  19. package/dist/{chunk-LHR7MBUY.js → chunk-JK25REDL.js} +48 -13
  20. package/dist/chunk-JK25REDL.js.map +1 -0
  21. package/dist/{chunk-Z4IBEIWD.cjs → chunk-LGLPNWS6.cjs} +3 -3
  22. package/dist/{chunk-Z4IBEIWD.cjs.map → chunk-LGLPNWS6.cjs.map} +1 -1
  23. package/dist/{chunk-VAUI3CND.js → chunk-NR535XIY.js} +44 -1
  24. package/dist/chunk-NR535XIY.js.map +1 -0
  25. package/dist/{chunk-2FCXJTOV.js → chunk-QXSRQVRO.js} +3 -1
  26. package/dist/chunk-QXSRQVRO.js.map +1 -0
  27. package/dist/{chunk-6TB24AUH.js → chunk-S7IX7YU4.js} +154 -190
  28. package/dist/chunk-S7IX7YU4.js.map +1 -0
  29. package/dist/{chunk-6LK4VEXE.cjs → chunk-TSZBJVYK.cjs} +35 -31
  30. package/dist/chunk-TSZBJVYK.cjs.map +1 -0
  31. package/dist/{chunk-TU26IZPO.cjs → chunk-U6UNAC32.cjs} +3 -1
  32. package/dist/chunk-U6UNAC32.cjs.map +1 -0
  33. package/dist/{chunk-265KKBV2.js → chunk-V4IIBNTA.js} +3 -3
  34. package/dist/chunk-V4IIBNTA.js.map +1 -0
  35. package/dist/{chunk-2FAX6LZB.cjs → chunk-V4Q5PIBV.cjs} +30 -30
  36. package/dist/{chunk-2FAX6LZB.cjs.map → chunk-V4Q5PIBV.cjs.map} +1 -1
  37. package/dist/{chunk-LKI3YCAX.js → chunk-V5L4D454.js} +10 -6
  38. package/dist/chunk-V5L4D454.js.map +1 -0
  39. package/dist/{chunk-3TDLFCCS.cjs → chunk-ZJP2BB3E.cjs} +6 -5
  40. package/dist/chunk-ZJP2BB3E.cjs.map +1 -0
  41. package/dist/components/chat/entity-cards/entity-author-card.d.ts +13 -9
  42. package/dist/components/chat/entity-cards/entity-author-card.d.ts.map +1 -1
  43. package/dist/components/chat/index.cjs +9 -7
  44. package/dist/components/chat/index.cjs.map +1 -1
  45. package/dist/components/chat/index.d.ts +1 -0
  46. package/dist/components/chat/index.d.ts.map +1 -1
  47. package/dist/components/chat/index.js +8 -6
  48. package/dist/components/chat/types/entities/onboarding-guide.d.ts +2 -7
  49. package/dist/components/chat/types/entities/onboarding-guide.d.ts.map +1 -1
  50. package/dist/components/contact/index.cjs +8 -8
  51. package/dist/components/contact/index.js +7 -7
  52. package/dist/components/faq/faq-section.d.ts.map +1 -1
  53. package/dist/components/faq/index.cjs +3 -3
  54. package/dist/components/faq/index.js +2 -2
  55. package/dist/components/features/index.cjs +7 -7
  56. package/dist/components/features/index.js +6 -6
  57. package/dist/components/icons/index.cjs +2 -2
  58. package/dist/components/icons/index.js +1 -1
  59. package/dist/components/index.cjs +166 -156
  60. package/dist/components/index.cjs.map +1 -1
  61. package/dist/components/index.js +54 -44
  62. package/dist/components/index.js.map +1 -1
  63. package/dist/components/navigation/index.cjs +7 -7
  64. package/dist/components/navigation/index.js +6 -6
  65. package/dist/components/onboarding-guides/index.cjs +25 -23
  66. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  67. package/dist/components/onboarding-guides/index.js +6 -4
  68. package/dist/components/onboarding-guides/index.js.map +1 -1
  69. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts +4 -1
  70. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  71. package/dist/components/related-content/index.cjs +10 -8
  72. package/dist/components/related-content/index.cjs.map +1 -1
  73. package/dist/components/related-content/index.d.ts +1 -1
  74. package/dist/components/related-content/index.d.ts.map +1 -1
  75. package/dist/components/related-content/index.js +9 -7
  76. package/dist/components/related-content/related-content-section.d.ts +16 -1
  77. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  78. package/dist/components/shared/product-release/release-detail-page.d.ts +4 -1
  79. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  80. package/dist/components/social-icon-row.d.ts +17 -4
  81. package/dist/components/social-icon-row.d.ts.map +1 -1
  82. package/dist/components/tickets/index.cjs +80 -80
  83. package/dist/components/tickets/index.js +8 -8
  84. package/dist/components/ui/button/button.d.ts.map +1 -1
  85. package/dist/components/ui/file-manager/index.cjs +20 -20
  86. package/dist/components/ui/file-manager/index.js +1 -1
  87. package/dist/components/ui/index.cjs +9 -7
  88. package/dist/components/ui/index.cjs.map +1 -1
  89. package/dist/components/ui/index.js +8 -6
  90. package/dist/hooks/index.cjs +3 -3
  91. package/dist/hooks/index.js +2 -2
  92. package/dist/index.cjs +12 -8
  93. package/dist/index.cjs.map +1 -1
  94. package/dist/index.js +16 -12
  95. package/dist/types/blog.d.ts +7 -0
  96. package/dist/types/blog.d.ts.map +1 -1
  97. package/dist/types/entity-author.d.ts +18 -0
  98. package/dist/types/entity-author.d.ts.map +1 -0
  99. package/dist/types/index.cjs.map +1 -1
  100. package/dist/types/index.d.ts +1 -0
  101. package/dist/types/index.d.ts.map +1 -1
  102. package/dist/types/index.js.map +1 -1
  103. package/dist/types/product-release.d.ts +2 -7
  104. package/dist/types/product-release.d.ts.map +1 -1
  105. package/dist/utils/common.d.ts +9 -0
  106. package/dist/utils/common.d.ts.map +1 -1
  107. package/dist/utils/index.cjs +6 -2
  108. package/dist/utils/index.cjs.map +1 -1
  109. package/dist/utils/index.d.ts +1 -1
  110. package/dist/utils/index.d.ts.map +1 -1
  111. package/dist/utils/index.js +6 -3
  112. package/dist/utils/index.js.map +1 -1
  113. package/package.json +1 -1
  114. package/src/components/chat/entity-cards/entity-author-card.tsx +30 -11
  115. package/src/components/chat/index.ts +1 -0
  116. package/src/components/chat/types/entities/onboarding-guide.ts +3 -7
  117. package/src/components/faq/faq-section.tsx +4 -1
  118. package/src/components/icons/x-logo.tsx +2 -2
  119. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +5 -0
  120. package/src/components/related-content/index.ts +1 -0
  121. package/src/components/related-content/related-content-section.tsx +84 -8
  122. package/src/components/shared/product-release/release-detail-page.tsx +18 -21
  123. package/src/components/social-icon-row.tsx +51 -22
  124. package/src/components/ui/button/button.tsx +7 -0
  125. package/src/types/blog.ts +9 -1
  126. package/src/types/entity-author.ts +17 -0
  127. package/src/types/index.ts +1 -0
  128. package/src/types/product-release.ts +2 -7
  129. package/src/utils/common.ts +12 -0
  130. package/src/utils/index.ts +1 -1
  131. package/dist/chunk-265KKBV2.js.map +0 -1
  132. package/dist/chunk-2FCXJTOV.js.map +0 -1
  133. package/dist/chunk-3TDLFCCS.cjs.map +0 -1
  134. package/dist/chunk-6LK4VEXE.cjs.map +0 -1
  135. package/dist/chunk-6TB24AUH.js.map +0 -1
  136. package/dist/chunk-LHR7MBUY.js.map +0 -1
  137. package/dist/chunk-LKI3YCAX.js.map +0 -1
  138. package/dist/chunk-MKVKVJQQ.js.map +0 -1
  139. package/dist/chunk-RCW2IL4K.cjs.map +0 -1
  140. package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
  141. package/dist/chunk-TU26IZPO.cjs.map +0 -1
  142. package/dist/chunk-UGFSMA7K.cjs.map +0 -1
  143. package/dist/chunk-VAUI3CND.js.map +0 -1
  144. /package/dist/{chunk-EFIEQ4XD.js.map → chunk-46DHPQY6.js.map} +0 -0
  145. /package/dist/{chunk-HMFBMVJC.js.map → chunk-6GBA6YJN.js.map} +0 -0
  146. /package/dist/{chunk-4HHYUEMD.js.map → chunk-7RLV3U5C.js.map} +0 -0
  147. /package/dist/{chunk-D524LJYO.js.map → chunk-HCMVLEC3.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.255",
3
+ "version": "0.0.256",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,3 +1,5 @@
1
+ import Link from '../../../embed-shims/next-link'
2
+ import type { EntityAuthor } from '../../../types/entity-author'
1
3
  import { SquareAvatar } from '../../ui/square-avatar'
2
4
  import { formatDate, nameInitials } from '../../../utils/format'
3
5
 
@@ -23,8 +25,10 @@ import { formatDate, nameInitials } from '../../../utils/format'
23
25
  * Renders null when `author` is null/empty unless `renderEmptyAuthor` is set.
24
26
  */
25
27
  /**
26
- * Documented empty-author placeholder. Single source for the visual
27
- * shape of "author cell with no author".
28
+ * Documented empty-author placeholder for CHAT CARD surfaces ("—" name,
29
+ * "Unknown" role). The release detail page deliberately keeps its legacy
30
+ * empty rendering instead ("Unknown Author"/"Author" via a null-field stub
31
+ * — see release-detail-page.tsx) — two documented empty styles, one cell.
28
32
  */
29
33
  export const EMPTY_AUTHOR_PLACEHOLDER = {
30
34
  full_name: '—',
@@ -33,14 +37,14 @@ export const EMPTY_AUTHOR_PLACEHOLDER = {
33
37
  } as const
34
38
 
35
39
  export interface EntityAuthorCardProps {
36
- author: {
37
- full_name: string | null
38
- avatar_url: string | null
39
- job_title: string | null
40
- } | null | undefined
40
+ author: EntityAuthor | null | undefined
41
41
  /** Role label rendered under the name. Defaults to "Author". Override
42
42
  * to e.g. "Presenter" / "Contributor" if semantics differ. */
43
43
  roleLabel?: string
44
+ /** Link target for the author name (e.g. the public author page). The
45
+ * HOST computes it (route availability is host knowledge) — absent ⇒
46
+ * plain text, exactly the prior render. */
47
+ authorHref?: string
44
48
  /** Optional publication date. When provided, the component renders as a
45
49
  * 2-cell grid (Published | Author). When omitted, only the Author cell
46
50
  * renders inside the same bordered container. */
@@ -102,13 +106,19 @@ export function EntityMetadataValueCell({
102
106
  export function EntityMetadataAuthorCell({
103
107
  author,
104
108
  roleLabel = 'Author',
109
+ authorHref,
105
110
  className,
106
111
  }: {
107
112
  author: NonNullable<EntityAuthorCardProps['author']>
108
113
  roleLabel?: string
114
+ authorHref?: string
109
115
  className?: string
110
116
  }) {
111
- const fullName = author.full_name || 'Unknown Author'
117
+ // Trimmed real name gates the LINK — a placeholder/unknown label must
118
+ // never render as a clickable author (an authorHref passed alongside an
119
+ // empty name would link "Unknown Author").
120
+ const trimmedName = typeof author.full_name === 'string' ? author.full_name.trim() : ''
121
+ const fullName = trimmedName || 'Unknown Author'
112
122
  return (
113
123
  <div className={`bg-ods-card p-4 flex items-center gap-3 ${className ?? ''}`}>
114
124
  <SquareAvatar
@@ -119,8 +129,16 @@ export function EntityMetadataAuthorCell({
119
129
  variant="round"
120
130
  />
121
131
  <div className="flex flex-col gap-0 flex-1 min-w-0">
122
- <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate">
123
- {fullName}
132
+ {/* title = full-name tooltip on truncation (carried over from the
133
+ release page's cell when it adopted this shared one). */}
134
+ <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate" title={fullName}>
135
+ {authorHref && trimmedName ? (
136
+ <Link href={authorHref} className="hover:text-ods-accent transition-colors">
137
+ {fullName}
138
+ </Link>
139
+ ) : (
140
+ fullName
141
+ )}
124
142
  </p>
125
143
  <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
126
144
  {author.job_title || roleLabel}
@@ -133,6 +151,7 @@ export function EntityMetadataAuthorCell({
133
151
  export function EntityAuthorCard({
134
152
  author,
135
153
  roleLabel = 'Author',
154
+ authorHref,
136
155
  publishedAt,
137
156
  publishedLabel = 'Published',
138
157
  extraCells,
@@ -187,7 +206,7 @@ export function EntityAuthorCard({
187
206
  className={dividerClass}
188
207
  />
189
208
  )}
190
- <EntityMetadataAuthorCell author={effectiveAuthor} roleLabel={roleLabel} />
209
+ <EntityMetadataAuthorCell author={effectiveAuthor} roleLabel={roleLabel} authorHref={authorHref} />
191
210
  </div>
192
211
  )
193
212
  }
@@ -16,6 +16,7 @@ export * from './chat-message-row'
16
16
  export * from './chat-message-skeleton'
17
17
 
18
18
  export * from './chat-quick-action'
19
+ export * from './chat-quick-action-row'
19
20
  export * from './chat-ticket-list'
20
21
  export * from './chat-typing-indicator'
21
22
  export * from './tool-execution-display'
@@ -1,3 +1,5 @@
1
+ import type { EntityAuthor } from '../../../../types/entity-author';
2
+
1
3
  /**
2
4
  * Onboarding Guide entity — wire shape for the chat onboarding-guide
3
5
  * card + the `/api/onboarding-guides` reads.
@@ -52,13 +54,7 @@ export interface OnboardingGuide {
52
54
  status: string;
53
55
  published_at: string | null;
54
56
  author_id: string | null;
55
- author?: {
56
- id: string;
57
- full_name: string;
58
- avatar_url: string | null;
59
- job_title: string | null;
60
- email?: string | null;
61
- };
57
+ author?: EntityAuthor;
62
58
  custom_instructions: string | null;
63
59
  config: Record<string, unknown> | null;
64
60
  ai_effort_score: number | null;
@@ -5,6 +5,7 @@ import type { Faq } from '../../types/faq'
5
5
  import { FaqAccordion, type FaqItem } from '../faq-accordion'
6
6
  import { useSelfFetch } from '../../hooks/use-self-fetch'
7
7
  import { buildSuggestionUrl } from '../../utils/suggestion-url'
8
+ import { serializeJsonLd } from '../../utils/common'
8
9
  import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
9
10
 
10
11
  export interface FaqSectionProps {
@@ -202,7 +203,9 @@ export function FaqSection({
202
203
  <script
203
204
  type="application/ld+json"
204
205
  // eslint-disable-next-line react/no-danger
205
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
206
+ // serializeJsonLd, NOT raw JSON.stringify — FAQ answers are
207
+ // admin-entered; an embedded "</script>" must not break the tag.
208
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(schema) }}
206
209
  />
207
210
  )}
208
211
  </>
@@ -16,8 +16,8 @@ export function XLogo({ className = '', color = 'white', ...props }: XLogoProps)
16
16
  className={className}
17
17
  {...props}
18
18
  >
19
- <path d="M9.75292 12.6562C10.1923 12.2169 10.9044 12.2169 11.3437 12.6562C11.7831 13.0956 11.7831 13.8077 11.3437 14.2471L3.7954 21.7954C3.35606 22.2348 2.64392 22.2348 2.20458 21.7954C1.76524 21.3561 1.76524 20.6439 2.20458 20.2046L9.75292 12.6562ZM20.2046 2.20459C20.6439 1.76525 21.3561 1.76525 21.7954 2.20459C22.2347 2.64393 22.2347 3.35607 21.7954 3.79541L14.2471 11.3438C13.8077 11.7831 13.0956 11.7831 12.6562 11.3438C12.2169 10.9044 12.2169 10.1923 12.6562 9.75293L20.2046 2.20459Z" fill="#FAFAFA" />
20
- <path d="M7.9995 1.875L8.13426 1.88232C8.444 1.91951 8.72702 2.08473 8.9121 2.34082L21.9126 20.3408C22.1599 20.6833 22.1943 21.1365 22.0019 21.5127C21.8096 21.8887 21.4223 22.125 21 22.125H16.0005C15.6393 22.125 15.2994 21.9519 15.0879 21.6592L2.08739 3.65918C1.84004 3.31669 1.8057 2.86346 1.99803 2.4873C2.19042 2.11133 2.57763 1.875 2.99999 1.875H7.9995ZM16.5747 19.875H18.7983L7.42528 4.125H5.20165L16.5747 19.875Z" fill="#FAFAFA" />
19
+ <path d="M9.75292 12.6562C10.1923 12.2169 10.9044 12.2169 11.3437 12.6562C11.7831 13.0956 11.7831 13.8077 11.3437 14.2471L3.7954 21.7954C3.35606 22.2348 2.64392 22.2348 2.20458 21.7954C1.76524 21.3561 1.76524 20.6439 2.20458 20.2046L9.75292 12.6562ZM20.2046 2.20459C20.6439 1.76525 21.3561 1.76525 21.7954 2.20459C22.2347 2.64393 22.2347 3.35607 21.7954 3.79541L14.2471 11.3438C13.8077 11.7831 13.0956 11.7831 12.6562 11.3438C12.2169 10.9044 12.2169 10.1923 12.6562 9.75293L20.2046 2.20459Z" fill={color} />
20
+ <path d="M7.9995 1.875L8.13426 1.88232C8.444 1.91951 8.72702 2.08473 8.9121 2.34082L21.9126 20.3408C22.1599 20.6833 22.1943 21.1365 22.0019 21.5127C21.8096 21.8887 21.4223 22.125 21 22.125H16.0005C15.6393 22.125 15.2994 21.9519 15.0879 21.6592L2.08739 3.65918C1.84004 3.31669 1.8057 2.86346 1.99803 2.4873C2.19042 2.11133 2.57763 1.875 2.99999 1.875H7.9995ZM16.5747 19.875H18.7983L7.42528 4.125H5.20165L16.5747 19.875Z" fill={color} />
21
21
  </svg>
22
22
  );
23
23
  }
@@ -50,6 +50,9 @@ export interface OnboardingGuideDetailViewProps {
50
50
  * e.g. `(s) => \`/content/api/onboarding-guides/${s}\``. */
51
51
  guideEndpoint?: (slug: string) => string
52
52
  related?: OnboardingGuide[]
53
+ /** Link target for the author name in the metadata grid — the host
54
+ * computes it (public author page; absent ⇒ plain text). */
55
+ authorHref?: string
53
56
  /** Optional markdown renderer override. Defaults to lib
54
57
  * `<SimpleMarkdownRenderer>`. */
55
58
  MarkdownRenderer?: ComponentType<{ content: string }>
@@ -65,6 +68,7 @@ export interface OnboardingGuideDetailViewProps {
65
68
  }
66
69
 
67
70
  export function OnboardingGuideDetailView({
71
+ authorHref,
68
72
  initialData,
69
73
  slug,
70
74
  guideEndpoint,
@@ -139,6 +143,7 @@ export function OnboardingGuideDetailView({
139
143
  {/* Metadata grid — Section · Step | Published | Author. */}
140
144
  <EntityAuthorCard
141
145
  author={guide.author}
146
+ authorHref={authorHref}
142
147
  publishedAt={guide.published_at}
143
148
  extraCells={[
144
149
  {
@@ -1,6 +1,7 @@
1
1
  // CLIENT subpath barrel — `@flamingo-stack/openframe-frontend-core/components/related-content`
2
2
  export {
3
3
  RelatedContentSection,
4
+ GROUP_PAGE_SIZE,
4
5
  type RelatedContentSectionProps,
5
6
  type CardLinkProvider,
6
7
  type CardLinkProviderProps,
@@ -42,7 +42,7 @@
42
42
  * content type, add it BOTH there and here (cards + skeleton + list URL).
43
43
  */
44
44
 
45
- import React, { useMemo } from 'react';
45
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
46
46
  import {
47
47
  CONTENT_REF_GROUPS,
48
48
  getContentRefLabelOrTitleCase,
@@ -51,6 +51,7 @@ import {
51
51
  } from '../../utils/content-ref-groups';
52
52
  import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
53
53
  import { useSelfFetch } from '../../hooks/use-self-fetch';
54
+ import { Pagination } from '../pagination';
54
55
  import { extractItems, extractItemId } from '../../utils/extract-items';
55
56
  import { buildListUrl as libBuildListUrl, canonicalContentRefType } from '../../utils/list-url';
56
57
  import { buildSuggestionUrl } from '../../utils/suggestion-url';
@@ -342,6 +343,16 @@ function resolveGroupConfig(type: string): ContentRefGroupConfig {
342
343
  };
343
344
  }
344
345
 
346
+ /** Items per page within one type group. Groups larger than this paginate
347
+ * with the standard Pagination control (NO nested scrolling — a bounded
348
+ * scrollbox inside the page traps wheel events and hides the sections
349
+ * below it). MUST stay at or above the largest suggestion fill
350
+ * (RELATED_SAME_TYPE_COUNT in the hub's lib/constants/suggestions.ts) so
351
+ * current rails never paginate — only genuinely big groups (author pages)
352
+ * do. Exported through the subpath barrel for the hub's module-load
353
+ * assertion of that relation (entity-suggestion-sections.tsx). */
354
+ export const GROUP_PAGE_SIZE = 12;
355
+
345
356
  function ContentGroup({
346
357
  type,
347
358
  refs,
@@ -371,11 +382,38 @@ function ContentGroup({
371
382
  const isListLayout = config.layout === 'list';
372
383
  const cardSize = config.gridSize;
373
384
 
385
+ // Per-group pagination for big groups (author pages): GROUP_PAGE_SIZE items
386
+ // per page with the standard Pagination control below the group. Client-side
387
+ // slicing — useGroupItems already fetched every row in one batched call, so
388
+ // page flips are instant. Hooks live above every early return (file
389
+ // convention). Page is clamped so a shrinking refs array (suggestion
390
+ // refetch) can never strand the view past the last page, and RESET when the
391
+ // ref set actually changes (shrink→grow must not return to a stale page).
392
+ const [page, setPage] = useState(1);
393
+ const refsKey = refs.map((r) => r.id).join('|');
394
+ const prevRefsKeyRef = useRef(refsKey);
395
+ useEffect(() => {
396
+ if (prevRefsKeyRef.current !== refsKey) {
397
+ prevRefsKeyRef.current = refsKey;
398
+ setPage(1);
399
+ }
400
+ }, [refsKey]);
401
+ const totalGroupPages = Math.max(1, Math.ceil(refs.length / GROUP_PAGE_SIZE));
402
+ const safePage = Math.min(page, totalGroupPages);
403
+ const visibleGroupRefs =
404
+ refs.length > GROUP_PAGE_SIZE
405
+ ? refs.slice((safePage - 1) * GROUP_PAGE_SIZE, safePage * GROUP_PAGE_SIZE)
406
+ : refs;
407
+ const groupPagination =
408
+ totalGroupPages > 1 ? (
409
+ <Pagination currentPage={safePage} totalPages={totalGroupPages} onPageChange={setPage} />
410
+ ) : null;
411
+
374
412
  // Skeleton gate: `isLoading && !items` — SSR HTML and the client's first
375
413
  // paint render identical skeletons (useSelfFetch starts isLoading=true on
376
414
  // both sides), and once items exist they are never replaced by skeletons.
377
415
  if (isLoading && !items) {
378
- const skeletons = refs.map((r) => (
416
+ const skeletons = visibleGroupRefs.map((r) => (
379
417
  <div key={r.id}>{renderSkeletonForType(type, cardSize, adminCampaignCard)}</div>
380
418
  ));
381
419
  return (
@@ -404,7 +442,7 @@ function ContentGroup({
404
442
  (items as any[]).map((it) => [extractItemId(type, it) ?? String((it as any)?.id), it]),
405
443
  );
406
444
 
407
- const cards = refs
445
+ const cards = visibleGroupRefs
408
446
  .map((contentRef) => {
409
447
  const itemId = String(contentRef.id);
410
448
  const item = itemById.get(itemId);
@@ -436,7 +474,22 @@ function ContentGroup({
436
474
  })
437
475
  .filter(Boolean);
438
476
 
439
- if (cards.length === 0) return null;
477
+ if (cards.length === 0) {
478
+ // Current PAGE resolved zero cards (rows deleted between the ref fetch
479
+ // and the group fetch, or a stricter list-API gate dropped them). When a
480
+ // pager exists the user must keep the controls to navigate back —
481
+ // dropping the whole group would strand them. A genuinely empty group
482
+ // (no pager) still vanishes with its heading.
483
+ if (groupPagination) {
484
+ return (
485
+ <div className="space-y-4">
486
+ {heading}
487
+ {groupPagination}
488
+ </div>
489
+ );
490
+ }
491
+ return null;
492
+ }
440
493
 
441
494
  return (
442
495
  <div className="space-y-4">
@@ -446,6 +499,7 @@ function ContentGroup({
446
499
  ) : (
447
500
  <div className={gridClassFor(columns)}>{cards}</div>
448
501
  )}
502
+ {groupPagination}
449
503
  </div>
450
504
  );
451
505
  }
@@ -467,6 +521,12 @@ export interface RelatedContentSectionProps {
467
521
  * `contentRefs` is provided. */
468
522
  entityType?: string;
469
523
  entityId?: number | string;
524
+ /** AUTHOR mode: self-fetch ALL published content authored by this profile
525
+ * from `{apiBaseUrl}/api/related-content?authorId=…` (grouped per type,
526
+ * endless within each group). Ignored when `contentRefs` is provided;
527
+ * takes precedence over the entityType/entityId suggestion scope.
528
+ * SSR-hydrate via `initialItems`, same as suggestion mode. */
529
+ authorId?: string;
470
530
  /** Maps to the suggestion API's `count` param — the PER-TYPE fill target
471
531
  * for every candidate type EXCEPT the host's own. Absent → param not sent
472
532
  * (server default applies). */
@@ -532,6 +592,7 @@ export function RelatedContentSection({
532
592
  contentRefs,
533
593
  entityType,
534
594
  entityId,
595
+ authorId,
535
596
  minResults,
536
597
  sameTypeMinResults,
537
598
  includeTypes,
@@ -555,8 +616,24 @@ export function RelatedContentSection({
555
616
  // entirely (an empty-string `types=` param would be dropped by the URL
556
617
  // builder and read server-side as "all candidates") AND ignore SSR refs.
557
618
  const suggestionsDisabled = includeTypes?.length === 0;
619
+ // Shared type-filter params — one spelling for both fetch modes so a future
620
+ // normalization (trim/dedupe) can't diverge between them.
621
+ const typeFilterParams = {
622
+ types: includeTypes !== undefined ? includeTypes.join(',') : undefined,
623
+ excludeTypes: excludeTypes && excludeTypes.length > 0 ? excludeTypes.join(',') : undefined,
624
+ };
625
+ // AUTHOR mode beats suggestion mode: when `authorId` is set the rail lists
626
+ // everything that profile authored (the server returns ALL, no count).
627
+ const authorUrl =
628
+ contentRefs === undefined && authorId && !suggestionsDisabled
629
+ ? buildSuggestionUrl('/api/related-content', {
630
+ apiBaseUrl,
631
+ extraParams: { authorId, ...typeFilterParams },
632
+ })
633
+ : null;
558
634
  const suggestUrl =
559
- contentRefs === undefined &&
635
+ authorUrl ??
636
+ (contentRefs === undefined &&
560
637
  entityType &&
561
638
  entityId !== undefined &&
562
639
  entityId !== null &&
@@ -569,11 +646,10 @@ export function RelatedContentSection({
569
646
  count: minResults,
570
647
  extraParams: {
571
648
  sameTypeCount: sameTypeMinResults !== undefined ? String(sameTypeMinResults) : undefined,
572
- types: includeTypes !== undefined ? includeTypes.join(',') : undefined,
573
- excludeTypes: excludeTypes && excludeTypes.length > 0 ? excludeTypes.join(',') : undefined,
649
+ ...typeFilterParams,
574
650
  },
575
651
  })
576
- : null;
652
+ : null);
577
653
  // Memoize the initialData wrapper — useSelfFetch re-syncs on [initialData],
578
654
  // and a fresh per-render object would loop setState under re-rendering
579
655
  // parents (the latent FaqSection bug, fixed there in the same change).
@@ -11,12 +11,12 @@ import { PageShell } from '../../layout/article-detail-layout';
11
11
  import { BackButton } from '../../layout/back-button';
12
12
  import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
13
13
  import { EntityTagBadges } from '../../features/entity-tag-badges';
14
- import { SquareAvatar } from '../../ui/square-avatar';
14
+ import { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';
15
+ import type { EntityAuthor } from '../../../types/entity-author';
15
16
  import { ImageGalleryModal } from '../../ui/image-gallery-modal';
16
17
  import { GitHubIcon } from '../../icons/github-icon';
17
18
  import { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react';
18
19
  import { formatReleaseDate } from '../../../utils/date-formatters';
19
- import { nameInitials } from '../../../utils/format';
20
20
  import { Video } from '../../features/video';
21
21
  import { DetailPageSkeleton } from '../detail-page-skeleton';
22
22
  import type { ChangelogEntry } from '../../../types/product-release';
@@ -103,6 +103,9 @@ export interface ReleaseDetailPageProps {
103
103
  * `LegalDocumentPage`. Pass `false` to hide. Default
104
104
  * `{ label: 'Back to home', href: '/' }`. */
105
105
  backButton?: { label?: string; href?: string } | false;
106
+ /** Link target for the author name in the metadata grid — the host
107
+ * computes it (public author page; absent ⇒ plain text). */
108
+ authorHref?: string;
106
109
  }
107
110
 
108
111
  // Default simple markdown renderer (just renders as text)
@@ -111,6 +114,7 @@ function DefaultMarkdownRenderer({ content }: MarkdownRendererProps) {
111
114
  }
112
115
 
113
116
  export function ReleaseDetailPage({
117
+ authorHref,
114
118
  slug,
115
119
  initialData,
116
120
  useRelease,
@@ -207,7 +211,9 @@ export function ReleaseDetailPage({
207
211
  const releaseType = release.release_type as string;
208
212
  const releaseStatus = release.release_status as string;
209
213
  const releaseMedia = release.release_media as Array<{ id?: string; media_type: string; media_url: string; title?: string }> | undefined;
210
- const author = release.author as { avatar_url?: string; full_name?: string } | undefined;
214
+ // Field-cast per this file's loose-release idiom (release_type etc. above)
215
+ // — but to the SHARED EntityAuthor, never an inline shadow author shape.
216
+ const author = release.author as EntityAuthor | undefined;
211
217
  const githubReleases = release.github_releases as Array<{ id: string; github_release_url: string }> | undefined;
212
218
  const knowledgeBaseLinks = release.knowledge_base_links as Array<{ id?: string; kb_article_path: string }> | string[] | undefined;
213
219
  const migrationGuideUrl = release.migration_guide_url as string | undefined;
@@ -290,24 +296,15 @@ export function ReleaseDetailPage({
290
296
  </div>
291
297
  </div>
292
298
 
293
- {/* Author */}
294
- <div className="bg-ods-card p-4 flex items-center gap-3">
295
- <SquareAvatar
296
- src={author?.avatar_url || ''}
297
- alt={author?.full_name || 'Author'}
298
- fallback={nameInitials(author?.full_name || 'Unknown', '')}
299
- size="md"
300
- variant="round"
301
- />
302
- <div className="flex flex-col gap-0 flex-1 min-w-0">
303
- <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate" title={author?.full_name || 'Unknown Author'}>
304
- {author?.full_name || 'Unknown Author'}
305
- </p>
306
- <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
307
- Author
308
- </p>
309
- </div>
310
- </div>
299
+ {/* Author — the shared metadata-grid author cell (it was extracted
300
+ FROM this page; rendering the export instead of an inline copy
301
+ keeps the two in lockstep). The stub author preserves the legacy
302
+ "Unknown Author" rendering for author-less releases; the
303
+ job_title-over-roleLabel rule matches the guide detail page. */}
304
+ <EntityMetadataAuthorCell
305
+ author={author ?? { full_name: null, avatar_url: null }}
306
+ authorHref={author?.full_name ? authorHref : undefined}
307
+ />
311
308
  </div>
312
309
 
313
310
  {/* Image Gallery - Horizontal Scrolling */}
@@ -1,18 +1,28 @@
1
1
  "use client"
2
2
 
3
3
  import { Button } from './ui/button';
4
- import { GitHubIcon, RedditIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon } from './icons';
4
+ import { GitHubIcon, RedditIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon, CopyIcon } from './icons';
5
5
 
6
- interface SocialLink {
6
+ /** Exactly ONE of `href` (anchor, target _blank) or `onClick` (action
7
+ * button — share popups via window.open inside the click gesture,
8
+ * copy-to-clipboard) — the discriminated union makes a dead no-action
9
+ * entry unrepresentable. */
10
+ type SocialLink = {
7
11
  platform: string;
8
- href: string;
9
12
  label?: string;
10
- }
13
+ } & (
14
+ | { href: string; onClick?: never }
15
+ | { onClick: () => void; href?: never }
16
+ );
11
17
 
12
18
  interface SocialIconRowProps {
13
19
  className?: string;
14
20
  links?: SocialLink[];
15
21
  variant?: "accent" | "outline" | "transparent" | "destructive" | null | undefined;
22
+ /** Natural icon-button sizing (w-fit container, no flex-1 stretch) for
23
+ * page-level rows. Default false: buttons stretch across the container —
24
+ * the original card-width behavior (TMCG member cards, footers). */
25
+ compact?: boolean;
16
26
  }
17
27
 
18
28
  const defaultLinks: SocialLink[] = [
@@ -57,32 +67,51 @@ function renderSocialIcon(platform: string) {
57
67
  case 'facebook':
58
68
  case 'fb':
59
69
  return <FacebookIcon className="w-5 h-5" />;
70
+ case 'copy':
71
+ // CopyIcon's default fill is grey and would mismatch its row-mates —
72
+ // force the themed foreground via the ODS token (white on the dark
73
+ // theme, tracking the theme unlike the literal the reddit/slack cases
74
+ // still carry).
75
+ return <CopyIcon className="w-5 h-5" color="var(--ods-text-primary)" />;
60
76
  default:
61
77
  return <GlobeIcon className="w-5 h-5" />;
62
78
  }
63
79
  }
64
80
 
65
- export function SocialIconRow({ className = '', links = defaultLinks, variant = 'outline' }: SocialIconRowProps) {
81
+ export function SocialIconRow({ className = '', links = defaultLinks, variant = 'outline', compact = false }: SocialIconRowProps) {
66
82
  return (
67
- <div className={`flex flex-row gap-3 w-full ${className}`}>
68
- {links.map((link, index) => (
69
- <Button
70
- key={index}
71
- asChild
72
- variant={variant}
73
- size="icon"
74
- className="flex-1"
75
- >
76
- <a
83
+ <div className={`flex flex-row gap-3 ${compact ? 'w-fit' : 'w-full'} ${className}`}>
84
+ {links.map((link, index) => {
85
+ const ariaLabel = link.label || link.platform;
86
+ return link.onClick ? (
87
+ <Button
88
+ key={index}
89
+ type="button"
90
+ variant={variant}
91
+ size="icon"
92
+ className={compact ? undefined : 'flex-1'}
93
+ aria-label={ariaLabel}
94
+ onClick={link.onClick}
95
+ >
96
+ {renderSocialIcon(link.platform)}
97
+ </Button>
98
+ ) : (
99
+ // Props-based linking — Button renders the anchor itself
100
+ // (openInNewTab carries target="_blank" + rel="noopener noreferrer");
101
+ // no asChild/<a> nesting.
102
+ <Button
103
+ key={index}
104
+ variant={variant}
105
+ size="icon"
106
+ className={compact ? undefined : 'flex-1'}
77
107
  href={link.href}
78
- target="_blank"
79
- rel="noopener noreferrer"
80
- aria-label={link.label || link.platform}
108
+ openInNewTab
109
+ aria-label={ariaLabel}
81
110
  >
82
111
  {renderSocialIcon(link.platform)}
83
- </a>
84
- </Button>
85
- ))}
112
+ </Button>
113
+ );
114
+ })}
86
115
  </div>
87
116
  );
88
- }
117
+ }
@@ -235,6 +235,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
235
235
  prefetch={prefetch}
236
236
  target={splitAnchor.target}
237
237
  rel={splitAnchor.rel}
238
+ // The Link branches don't spread {...props} (button attrs don't
239
+ // belong on an anchor), but the accessible name MUST survive —
240
+ // icon-only link buttons have no text content.
241
+ aria-label={props['aria-label']}
238
242
  aria-disabled={isDisabled || undefined}
239
243
  tabIndex={isDisabled ? -1 : undefined}
240
244
  className={cn(shellClasses, isDisabled && "pointer-events-none")}
@@ -300,6 +304,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
300
304
  prefetch={prefetch}
301
305
  target={anchor.target}
302
306
  rel={anchor.rel}
307
+ // See the splitAnchor branch — keep the accessible name on the
308
+ // anchor render (icon-only link buttons have no text content).
309
+ aria-label={props['aria-label']}
303
310
  aria-disabled={isDisabled || undefined}
304
311
  tabIndex={isDisabled ? -1 : undefined}
305
312
  className={cn(classes, isDisabled && "pointer-events-none")}
package/src/types/blog.ts CHANGED
@@ -4,12 +4,20 @@ import type { UserProfile } from "./user"
4
4
  export type Platform = string;
5
5
  export type BlogStatus = 'draft' | 'ai_drafted' | 'published' | 'ai_published' | 'scheduled' | 'archived';
6
6
 
7
- // Author interface for blog posts
7
+ // Author interface for blog posts (camelCase — the blog contract's own
8
+ // shape; detail-row authors elsewhere use the shared EntityAuthor).
8
9
  export interface BlogAuthor {
9
10
  id: number;
10
11
  name?: string;
11
12
  avatar?: string;
12
13
  bio?: string;
14
+ jobTitle?: string;
15
+ /** Public author-page slug — present only when the hub's public-author
16
+ * gate passes (is_real profiles only). */
17
+ slug?: string;
18
+ /** Author's LinkedIn from profile_social_links — Article schema sameAs
19
+ * + the author.url fallback for slug-less authors. */
20
+ linkedinUrl?: string;
13
21
  }
14
22
 
15
23
  // Database Models
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Hydrated content author — THE shared author shape attached to detail rows
3
+ * by the hub's `hydrateAuthor` (product releases, onboarding guides) and the
4
+ * per-entity author joins. One type for every author surface: metadata-grid
5
+ * author cells, bylines, author-page hrefs.
6
+ */
7
+ export interface EntityAuthor {
8
+ id?: string
9
+ full_name: string | null
10
+ avatar_url: string | null
11
+ job_title?: string | null
12
+ /** Public author-page slug — present only when the hub's public-author
13
+ * gate passes (`is_real` profiles only). Feeds /authors/[slug] links. */
14
+ slug?: string
15
+ /** Present only on server/admin payloads — public DALs strip it. */
16
+ email?: string | null
17
+ }
@@ -16,6 +16,7 @@ export * from './auth'
16
16
 
17
17
  // Content types
18
18
  export * from './blog'
19
+ export * from './entity-author'
19
20
  export * from './case-study'
20
21
  export * from './customer-interview'
21
22
  export * from './customer-interview-ai.types'
@@ -2,6 +2,7 @@
2
2
  // Following the blog.ts pattern for consistency
3
3
 
4
4
  import type { PlatformRecord } from './platform'
5
+ import type { EntityAuthor } from './entity-author'
5
6
  import type { BlogTag, TagAssoc } from './blog'
6
7
  import type { VideoTeaser, TranscriptWord, SpeakerMapping } from './video-processing'
7
8
 
@@ -128,13 +129,7 @@ export interface ProductRelease {
128
129
  * `hydrateAuthor` in the hub DAL. Optional so admin form + every existing
129
130
  * consumer that doesn't read author stay backward-compatible.
130
131
  */
131
- author?: {
132
- id: string
133
- full_name: string
134
- avatar_url: string | null
135
- job_title: string | null
136
- email?: string | null
137
- }
132
+ author?: EntityAuthor
138
133
 
139
134
  // Timestamps
140
135
  created_at: string