@flamingo-stack/openframe-frontend-core 0.0.206 → 0.0.207-snapshot.20260526154403
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.
- package/dist/{chunk-OLTGB32E.js → chunk-2HMZSCJY.js} +3179 -2078
- package/dist/chunk-2HMZSCJY.js.map +1 -0
- package/dist/chunk-4XLJWX2N.js +12 -0
- package/dist/chunk-4XLJWX2N.js.map +1 -0
- package/dist/{chunk-YGOJIDL5.cjs → chunk-C5EC5AZM.cjs} +1660 -559
- package/dist/chunk-C5EC5AZM.cjs.map +1 -0
- package/dist/chunk-VFKQMAUF.cjs +12 -0
- package/dist/chunk-VFKQMAUF.cjs.map +1 -0
- package/dist/components/chat/embeddable-chat.d.ts +35 -2
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +3 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-embedded-chat.d.ts +10 -169
- package/dist/components/chat/hooks/use-embedded-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +85 -0
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +124 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-unified-chat.d.ts +33 -0
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +8 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +11 -5
- package/dist/components/chat/types/api.types.d.ts +17 -1
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/index.d.ts +1 -0
- package/dist/components/chat/types/index.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +185 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -0
- package/dist/components/features/index.cjs +3 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -1
- package/dist/components/index.cjs +26 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +27 -3
- package/dist/components/navigation/index.cjs +3 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +2 -1
- package/dist/components/shared/delivery/delivery-lists.d.ts +16 -0
- package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -0
- package/dist/components/shared/delivery/delivery-table.d.ts +12 -0
- package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -0
- package/dist/components/shared/delivery/index.d.ts +3 -0
- package/dist/components/shared/delivery/index.d.ts.map +1 -0
- package/dist/components/shared/dev-section/dev-section-page.d.ts +31 -0
- package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -0
- package/dist/components/shared/dev-section/dev-section-view.d.ts +34 -0
- package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -0
- package/dist/components/shared/dev-section/index.d.ts +3 -0
- package/dist/components/shared/dev-section/index.d.ts.map +1 -0
- package/dist/components/shared/legal-document/index.d.ts +10 -0
- package/dist/components/shared/legal-document/index.d.ts.map +1 -0
- package/dist/components/shared/legal-document/legal-document-page.d.ts +66 -0
- package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -0
- package/dist/components/shared/legal-document/use-legal-docs.d.ts +40 -0
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -0
- package/dist/components/shared/product-release/index.d.ts +2 -1
- package/dist/components/shared/product-release/index.d.ts.map +1 -1
- package/dist/components/shared/product-release/release-detail-page.d.ts +11 -7
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/shared/roadmap/index.d.ts +18 -0
- package/dist/components/shared/roadmap/index.d.ts.map +1 -0
- package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts +24 -0
- package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts.map +1 -0
- package/dist/components/shared/roadmap/roadmap-grid.d.ts +18 -0
- package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -0
- package/dist/components/shared/roadmap/use-roadmap-voting.d.ts +25 -0
- package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +8 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +11 -5
- package/dist/components/ui/release-changelog-section.d.ts +13 -2
- package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
- package/dist/embed-shims/index.cjs +1 -6
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +1 -6
- package/dist/embed-shims/index.js.map +1 -1
- package/dist/index.cjs +18 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -3
- package/dist/types/delivery.d.ts +49 -0
- package/dist/types/delivery.d.ts.map +1 -0
- package/dist/types/index.cjs +13 -0
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +12 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/dev-sections/index.d.ts +11 -0
- package/dist/utils/dev-sections/index.d.ts.map +1 -0
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts +209 -0
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -0
- package/dist/utils/index.cjs +82 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +81 -2
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/embeddable-chat.tsx +123 -8
- package/src/components/chat/hooks/index.ts +9 -2
- package/src/components/chat/hooks/use-embedded-chat.ts +18 -1016
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +372 -0
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +53 -6
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +1058 -0
- package/src/components/chat/hooks/use-unified-chat.ts +171 -0
- package/src/components/chat/types/api.types.ts +23 -1
- package/src/components/chat/types/index.ts +1 -0
- package/src/components/chat/types/unified-chat-state.types.ts +215 -0
- package/src/components/index.ts +8 -0
- package/src/components/shared/delivery/delivery-lists.tsx +199 -0
- package/src/components/shared/delivery/delivery-table.tsx +174 -0
- package/src/components/shared/delivery/index.ts +9 -0
- package/src/components/shared/dev-section/dev-section-page.tsx +72 -0
- package/src/components/shared/dev-section/dev-section-view.tsx +129 -0
- package/src/components/shared/dev-section/index.ts +2 -0
- package/src/components/shared/legal-document/index.ts +19 -0
- package/src/components/shared/legal-document/legal-document-page.tsx +178 -0
- package/src/components/shared/legal-document/use-legal-docs.ts +123 -0
- package/src/components/shared/product-release/index.ts +14 -3
- package/src/components/shared/product-release/release-detail-page.tsx +45 -7
- package/src/components/shared/roadmap/index.ts +23 -0
- package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +74 -0
- package/src/components/shared/roadmap/roadmap-grid.tsx +106 -0
- package/src/components/shared/roadmap/use-roadmap-voting.ts +163 -0
- package/src/components/ui/release-changelog-section.tsx +113 -32
- package/src/stories/EmbeddableChat.stories.tsx +186 -0
- package/src/types/delivery.ts +54 -0
- package/src/types/index.ts +1 -0
- package/src/utils/dev-sections/index.ts +17 -0
- package/src/utils/dev-sections/openframe-dev-sections.ts +148 -0
- package/src/utils/index.ts +6 -1
- package/dist/chunk-OLTGB32E.js.map +0 -1
- package/dist/chunk-YGOJIDL5.cjs.map +0 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useLegalDocs — fetches a legal document (privacy policy, terms of
|
|
5
|
+
* service, or any other markdown-backed legal page) from a hub API.
|
|
6
|
+
*
|
|
7
|
+
* Endpoint configuration — `apiEndpoint`:
|
|
8
|
+
* Default `/api/legal/<docType>`. Reverse-proxy embedders override
|
|
9
|
+
* with their proxied path (e.g. `/proxy/legal/privacy`).
|
|
10
|
+
*
|
|
11
|
+
* Data shape mirrors the hub's `lib/data/legal-utils.ts:LegalDocument`
|
|
12
|
+
* server type. The hook intentionally re-declares the type here so
|
|
13
|
+
* lib consumers don't need to import a server-side type.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
17
|
+
|
|
18
|
+
export interface LegalDocument {
|
|
19
|
+
title: string;
|
|
20
|
+
content: string;
|
|
21
|
+
sourceFile: string;
|
|
22
|
+
lastSynced: string | null;
|
|
23
|
+
githubSha: string | null;
|
|
24
|
+
sections: Array<{ id: string; title: string; level: number }>;
|
|
25
|
+
docType: string;
|
|
26
|
+
meta: {
|
|
27
|
+
sectionsCount: number;
|
|
28
|
+
contentLength: number;
|
|
29
|
+
lastSyncedAgo: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseLegalDocsReturn {
|
|
34
|
+
data: LegalDocument | null;
|
|
35
|
+
isLoading: boolean;
|
|
36
|
+
error: string | null;
|
|
37
|
+
refetch: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseLegalDocsOptions {
|
|
41
|
+
/** Optional pre-fetched payload from server (SSR / RSC). When set,
|
|
42
|
+
* the hook skips the initial client fetch. */
|
|
43
|
+
initialData?: LegalDocument | null;
|
|
44
|
+
/** Full GET endpoint URL. Default `/api/legal/<docType>`. */
|
|
45
|
+
apiEndpoint?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook to fetch a legal document.
|
|
50
|
+
* @param docType — short identifier for the document (drives the
|
|
51
|
+
* default endpoint path AND the error-log prefix). Common values:
|
|
52
|
+
* `'privacy'` (SECURITY.md), `'terms'` (LICENSE). Embedders may use
|
|
53
|
+
* any string — the hook treats it as opaque.
|
|
54
|
+
*/
|
|
55
|
+
export function useLegalDocs(
|
|
56
|
+
docType: string,
|
|
57
|
+
options: UseLegalDocsOptions = {}
|
|
58
|
+
): UseLegalDocsReturn {
|
|
59
|
+
const { initialData = null, apiEndpoint } = options;
|
|
60
|
+
const effectiveEndpoint = apiEndpoint ?? `/api/legal/${docType}`;
|
|
61
|
+
|
|
62
|
+
const [data, setData] = useState<LegalDocument | null>(initialData ?? null);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(!initialData);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
|
|
66
|
+
const fetchDocument = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
setIsLoading(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
|
|
71
|
+
const response = await fetch(effectiveEndpoint);
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Failed to fetch ${docType} document: ${response.status} ${response.statusText}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = await response.json();
|
|
80
|
+
|
|
81
|
+
// Validate the response has required fields
|
|
82
|
+
if (!result.content) {
|
|
83
|
+
throw new Error(`${docType} document content is empty`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setData(result);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
89
|
+
console.error(`Error fetching ${docType} document:`, err);
|
|
90
|
+
setError(errorMessage);
|
|
91
|
+
} finally {
|
|
92
|
+
setIsLoading(false);
|
|
93
|
+
}
|
|
94
|
+
}, [docType, effectiveEndpoint]);
|
|
95
|
+
|
|
96
|
+
// Reset cached data when docType changes — otherwise an embedder using
|
|
97
|
+
// the same hook instance for sequential docTypes (privacy → terms)
|
|
98
|
+
// would briefly render the OLD doc's content while the new fetch is
|
|
99
|
+
// in-flight. Not currently triggered by hub's per-route SSR (each
|
|
100
|
+
// docType mounts in a fresh component), but enforces the contract.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
setData(initialData ?? null);
|
|
103
|
+
setError(null);
|
|
104
|
+
setIsLoading(!initialData);
|
|
105
|
+
}, [docType, initialData]);
|
|
106
|
+
|
|
107
|
+
// Fetch on mount (only if we don't already have server-provided initialData)
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (initialData) return;
|
|
110
|
+
fetchDocument();
|
|
111
|
+
}, [fetchDocument, initialData]);
|
|
112
|
+
|
|
113
|
+
const refetch = () => {
|
|
114
|
+
fetchDocument();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
data,
|
|
119
|
+
isLoading,
|
|
120
|
+
error,
|
|
121
|
+
refetch,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -8,8 +8,19 @@ export {
|
|
|
8
8
|
type VideoDisplaySectionProps,
|
|
9
9
|
type MarkdownRendererProps,
|
|
10
10
|
type RoadmapItem,
|
|
11
|
-
type DeliveryResponse,
|
|
12
|
-
type RoadmapSectionProps,
|
|
13
|
-
type DeliverySectionProps
|
|
14
11
|
} from './release-detail-page'
|
|
12
|
+
// NOTE: `RoadmapSectionProps` / `DeliverySectionProps` (the injectable-
|
|
13
|
+
// component slot types for ReleaseDetailPage) are intentionally NOT
|
|
14
|
+
// re-exported from this barrel — they collide with the prop types of
|
|
15
|
+
// the concrete `<RoadmapGrid>` / `<DeliverySection>` components in
|
|
16
|
+
// `./shared/{roadmap,delivery}` (TS2308 ambiguous re-export at the
|
|
17
|
+
// top-level `components/index.ts` barrel). The slot types remain
|
|
18
|
+
// internal to `release-detail-page.tsx`; consumers needing them can
|
|
19
|
+
// import directly from
|
|
20
|
+
// `@flamingo-stack/openframe-frontend-core/components/shared/product-release/release-detail-page`.
|
|
21
|
+
// DeliveryResponse re-sourced from the canonical types module so the
|
|
22
|
+
// public deep-import path `@flamingo-stack/openframe-frontend-core/components`
|
|
23
|
+
// keeps resolving (hub's components/releases/release-detail-page.tsx
|
|
24
|
+
// imports it through this barrel).
|
|
25
|
+
export type { DeliveryResponse } from '../../../types/delivery'
|
|
15
26
|
export { ReleaseDetailSkeleton } from './release-detail-skeleton'
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, ComponentType } from 'react';
|
|
4
4
|
import Link from '../../../embed-shims/next-link';
|
|
5
|
+
import { useRouter } from '../../../embed-shims/next-navigation';
|
|
5
6
|
import { Card, CardContent } from '../../ui/card';
|
|
6
7
|
import { ArticleDetailLayout } from '../../layout/article-detail-layout';
|
|
8
|
+
import { BackButton } from '../../layout/back-button';
|
|
7
9
|
import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
|
|
8
10
|
import { StatusBadge } from '../../ui/status-badge';
|
|
9
11
|
import { SquareAvatar } from '../../ui/square-avatar';
|
|
@@ -29,13 +31,15 @@ export interface MarkdownRendererProps {
|
|
|
29
31
|
// shape once the entities barrel was added; re-exporting the canonical
|
|
30
32
|
// type fixes the collision while keeping the same import path for
|
|
31
33
|
// downstream consumers of `./release-detail-page`.
|
|
32
|
-
export type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
|
|
33
34
|
import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
|
|
34
|
-
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
import type { DeliveryResponse } from '../../../types/delivery';
|
|
36
|
+
// Re-export both types for source-compat with consumers importing
|
|
37
|
+
// through this module. Canonical sources:
|
|
38
|
+
// - RoadmapItem → `../../chat/types/entities/roadmap-item`
|
|
39
|
+
// - DeliveryResponse → `../../../types/delivery` (single source of
|
|
40
|
+
// truth, shared with the lib `<DeliveryLists>` / `<DeliveryTable>`
|
|
41
|
+
// components and the new types barrel).
|
|
42
|
+
export type { RoadmapItem, DeliveryResponse };
|
|
39
43
|
|
|
40
44
|
export interface RoadmapSectionProps {
|
|
41
45
|
items: RoadmapItem[];
|
|
@@ -91,6 +95,10 @@ export interface ReleaseDetailPageProps {
|
|
|
91
95
|
// API endpoints for fetching linked tasks
|
|
92
96
|
roadmapApiEndpoint?: string;
|
|
93
97
|
deliveryApiEndpoint?: string;
|
|
98
|
+
/** Back-button config — same pattern as `DevSectionPage` /
|
|
99
|
+
* `LegalDocumentPage`. Pass `false` to hide. Default
|
|
100
|
+
* `{ label: 'Back to home', href: '/' }`. */
|
|
101
|
+
backButton?: { label?: string; href?: string } | false;
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
// Default simple markdown renderer (just renders as text)
|
|
@@ -108,14 +116,26 @@ export function ReleaseDetailPage({
|
|
|
108
116
|
VideoSection,
|
|
109
117
|
VideoDisplaySection,
|
|
110
118
|
roadmapApiEndpoint = '/api/roadmap',
|
|
111
|
-
deliveryApiEndpoint = '/api/delivery'
|
|
119
|
+
deliveryApiEndpoint = '/api/delivery',
|
|
120
|
+
backButton
|
|
112
121
|
}: ReleaseDetailPageProps) {
|
|
122
|
+
const router = useRouter();
|
|
113
123
|
// Use pre-fetched data if provided (admin preview), otherwise fetch via hook (public)
|
|
114
124
|
const { data: fetchedRelease, error, isLoading } = useRelease(initialData ? undefined : slug);
|
|
115
125
|
const release = (initialData || fetchedRelease) as Record<string, unknown> | undefined;
|
|
116
126
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
117
127
|
const [galleryIndex, setGalleryIndex] = useState(0);
|
|
118
128
|
|
|
129
|
+
// Back-button config — mirrors DevSectionPage / LegalDocumentPage.
|
|
130
|
+
// Default: { label: 'Back to home', href: '/' }. Pass `false` to hide
|
|
131
|
+
// (e.g. embed-mode where the host owns navigation chrome).
|
|
132
|
+
// Narrowing note: `backButton &&` already eliminates the `false` branch,
|
|
133
|
+
// so the inner expressions are typed as `{ label?, href? } | undefined`.
|
|
134
|
+
// Don't re-compare to `false` here — tsc TS2367s on the dead branch.
|
|
135
|
+
const showBackButton = backButton !== false;
|
|
136
|
+
const backLabel = (backButton ? backButton.label : undefined) ?? 'Back to home';
|
|
137
|
+
const backHref = (backButton ? backButton.href : undefined) ?? '/';
|
|
138
|
+
|
|
119
139
|
// Fetch roadmap and delivery tasks if linked to this release
|
|
120
140
|
const [roadmapTasks, setRoadmapTasks] = useState<RoadmapItem[]>([]);
|
|
121
141
|
const [deliveryData, setDeliveryData] = useState<DeliveryResponse | null>(null);
|
|
@@ -201,6 +221,15 @@ export function ReleaseDetailPage({
|
|
|
201
221
|
|
|
202
222
|
return (
|
|
203
223
|
<ArticleDetailLayout>
|
|
224
|
+
{/* Back button — desktop-only, matches DevSectionPage / LegalDocumentPage
|
|
225
|
+
(TitleBlock renders the same `hidden md:inline-flex` BackButton). */}
|
|
226
|
+
{showBackButton && (
|
|
227
|
+
<BackButton
|
|
228
|
+
label={backLabel}
|
|
229
|
+
onClick={() => router.push(backHref)}
|
|
230
|
+
className="hidden md:inline-flex mb-4"
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
204
233
|
<div className="space-y-6 md:space-y-8">
|
|
205
234
|
{/* Title Block */}
|
|
206
235
|
<div className="flex flex-col md:flex-row md:items-end gap-4 w-full">
|
|
@@ -399,22 +428,31 @@ export function ReleaseDetailPage({
|
|
|
399
428
|
icon={<AlertTriangle className="h-6 w-6" />}
|
|
400
429
|
SimpleMarkdownRenderer={MarkdownRenderer}
|
|
401
430
|
/>
|
|
431
|
+
{/* Features / Bugs / Improvements use `previewFirst` — same
|
|
432
|
+
progressive-disclosure pattern as the investor-update detail
|
|
433
|
+
page's Key Highlights / Financial Notes sections. Shows the
|
|
434
|
+
first entry in full + fade-masks the rest, with a "Show N
|
|
435
|
+
more / Show less" toggle. Breaking Changes (above) stays
|
|
436
|
+
fully expanded — it's critical info, not skim-friendly. */}
|
|
402
437
|
<ReleaseChangelogSection
|
|
403
438
|
title="Features Added"
|
|
404
439
|
entries={featuresAdded || []}
|
|
405
440
|
icon={<Sparkles className="h-6 w-6" />}
|
|
441
|
+
previewFirst
|
|
406
442
|
SimpleMarkdownRenderer={MarkdownRenderer}
|
|
407
443
|
/>
|
|
408
444
|
<ReleaseChangelogSection
|
|
409
445
|
title="Bugs Fixed"
|
|
410
446
|
entries={bugFixed || []}
|
|
411
447
|
icon={<Wrench className="h-6 w-6" />}
|
|
448
|
+
previewFirst
|
|
412
449
|
SimpleMarkdownRenderer={MarkdownRenderer}
|
|
413
450
|
/>
|
|
414
451
|
<ReleaseChangelogSection
|
|
415
452
|
title="Improvements"
|
|
416
453
|
entries={improvements || []}
|
|
417
454
|
icon={<TrendingUp className="h-6 w-6" />}
|
|
455
|
+
previewFirst
|
|
418
456
|
SimpleMarkdownRenderer={MarkdownRenderer}
|
|
419
457
|
/>
|
|
420
458
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared roadmap surface barrel.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: This barrel MUST NOT re-export `RoadmapItem`. The canonical
|
|
5
|
+
* type is exported via two paths already:
|
|
6
|
+
* - `@flamingo-stack/openframe-frontend-core/components/chat`
|
|
7
|
+
* (the source of truth at `chat/types/entities/roadmap-item.ts`)
|
|
8
|
+
* - `@flamingo-stack/openframe-frontend-core/components` (via
|
|
9
|
+
* `./shared/product-release` re-export)
|
|
10
|
+
*
|
|
11
|
+
* Adding a third re-export path here would trigger TypeScript's TS2308
|
|
12
|
+
* ambiguous re-export warning. Consumers needing the type should import
|
|
13
|
+
* it from one of the two existing paths.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { RoadmapGrid, type RoadmapGridProps } from './roadmap-grid';
|
|
17
|
+
export { RoadmapGridSkeleton, type RoadmapGridSkeletonProps } from './roadmap-grid-skeleton';
|
|
18
|
+
// `VoteType` deliberately NOT re-exported — `./chat` already exports
|
|
19
|
+
// the same-shape `VoteType` from `roadmap-card.tsx`; a duplicate path
|
|
20
|
+
// triggers TS2308 ambiguous re-export at the top-level
|
|
21
|
+
// `components/index.ts` barrel. Consumers can import the canonical
|
|
22
|
+
// `VoteType` from `@flamingo-stack/openframe-frontend-core/components/chat`.
|
|
23
|
+
export { useRoadmapVoting, type VoteState, type UseRoadmapVotingOptions } from './use-roadmap-voting';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoadmapGridSkeleton — loading state for the `/roadmap` grid view.
|
|
3
|
+
*
|
|
4
|
+
* Pure JSX (no hooks, no events) — `'use client'` not strictly required
|
|
5
|
+
* here; tsup's client-entry banner injects it automatically when this
|
|
6
|
+
* file is bundled into the client output. We match the playbook's
|
|
7
|
+
* skeleton-file convention (no directive when no hooks).
|
|
8
|
+
*
|
|
9
|
+
* NOTE: lib's `chat/entity-cards/roadmap-card.tsx` also exports a
|
|
10
|
+
* `RoadmapCardSkeleton` — that one is the COMPACT 56px chat-card
|
|
11
|
+
* variant. This file's internal card-skeleton (340px grid card)
|
|
12
|
+
* intentionally stays file-internal to avoid the naming collision;
|
|
13
|
+
* only `RoadmapGridSkeleton` is exported.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function RoadmapCardSkeleton() {
|
|
17
|
+
return (
|
|
18
|
+
<div className="bg-ods-card border border-ods-border rounded-[6px] p-[24px] flex flex-col gap-[16px] min-h-[340px] relative">
|
|
19
|
+
{/* Status Badge Skeleton - Top Right */}
|
|
20
|
+
<div className="absolute top-[24px] right-[24px]">
|
|
21
|
+
<div className="h-[20px] w-[80px] bg-ods-border rounded animate-pulse"></div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
{/* Icon and title skeleton */}
|
|
25
|
+
<div className="flex items-center gap-[16px] pr-[120px]">
|
|
26
|
+
<div className="w-[80px] h-[80px] bg-ods-border rounded-lg flex-shrink-0 animate-pulse"></div>
|
|
27
|
+
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
|
28
|
+
<div className="min-h-[48px] flex items-center">
|
|
29
|
+
<div className="h-[24px] w-full bg-ods-border rounded animate-pulse"></div>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="min-h-[20px] flex items-center">
|
|
32
|
+
<div className="h-[14px] w-1/2 bg-ods-border rounded animate-pulse"></div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Description skeleton - exactly 3 lines */}
|
|
38
|
+
<div className="min-h-[72px] flex items-center">
|
|
39
|
+
<div className="w-full space-y-2">
|
|
40
|
+
<div className="h-[24px] bg-ods-border rounded animate-pulse"></div>
|
|
41
|
+
<div className="h-[24px] bg-ods-border rounded animate-pulse"></div>
|
|
42
|
+
<div className="h-[24px] w-4/5 bg-ods-border rounded animate-pulse"></div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="flex-1"></div>
|
|
47
|
+
|
|
48
|
+
{/* Bottom skeleton */}
|
|
49
|
+
<div className="flex items-center justify-between">
|
|
50
|
+
<div className="h-[48px] w-[120px] bg-ods-border rounded animate-pulse"></div>
|
|
51
|
+
<div className="h-[32px] w-[100px] bg-ods-border rounded animate-pulse"></div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RoadmapGridSkeletonProps {
|
|
58
|
+
/** Number of skeleton cards to show. Default 4. */
|
|
59
|
+
count?: number;
|
|
60
|
+
/** Show the desktop left margin (~120px) that aligns the grid with
|
|
61
|
+
* the page hero's title block. Default `true`. Related-content rails
|
|
62
|
+
* inside narrower surfaces (e.g. the release detail page) pass `false`. */
|
|
63
|
+
showLeftMargin?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function RoadmapGridSkeleton({ count = 4, showLeftMargin = true }: RoadmapGridSkeletonProps) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
|
|
69
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
70
|
+
<RoadmapCardSkeleton key={i} />
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RoadmapGrid — full-page roadmap surface.
|
|
5
|
+
*
|
|
6
|
+
* Renders a responsive 2-col grid of `<RoadmapCard>`s (lib's chat-entity
|
|
7
|
+
* card, default density), threads voting state through `useRoadmapVoting`,
|
|
8
|
+
* and follows up a successful vote with a single-task refresh fetch so
|
|
9
|
+
* the displayed counts stay live.
|
|
10
|
+
*
|
|
11
|
+
* Endpoint configuration — `buildRefreshUrl`:
|
|
12
|
+
* The single-task refresh hits a PATH-based endpoint
|
|
13
|
+
* (`/api/roadmap/<taskId>` by default). A string-concat `refreshEndpoint`
|
|
14
|
+
* would silently break embedders whose by-id route is shaped
|
|
15
|
+
* differently (e.g. `/api/roadmap?id=…`), so this prop is a function
|
|
16
|
+
* builder. The default matches the hub's pre-migration shape.
|
|
17
|
+
*
|
|
18
|
+
* Empty state — uses lib's `<EmptyState>` directly (identical API to
|
|
19
|
+
* hub's, lives in `src/components/empty-state.tsx`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useState } from 'react';
|
|
23
|
+
import { RoadmapCard } from '../../chat/entity-cards/roadmap-card';
|
|
24
|
+
import { useRoadmapVoting, type UseRoadmapVotingOptions } from './use-roadmap-voting';
|
|
25
|
+
import { EmptyState } from '../../empty-state';
|
|
26
|
+
import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_BUILD_REFRESH_URL = (taskId: string) => `/api/roadmap/${taskId}`;
|
|
29
|
+
|
|
30
|
+
export interface RoadmapGridProps {
|
|
31
|
+
items: RoadmapItem[];
|
|
32
|
+
onItemUpdate?: (updatedItem: RoadmapItem) => void;
|
|
33
|
+
/** Show the desktop left margin (~120px) that aligns the grid with
|
|
34
|
+
* the page hero. Default `true`. Related-content rails pass `false`. */
|
|
35
|
+
showLeftMargin?: boolean;
|
|
36
|
+
/** URL builder for the per-task refresh call after a successful vote.
|
|
37
|
+
* Function shape because the taskId sits in the URL path, not a
|
|
38
|
+
* query param. Default `(t) => \`/api/roadmap/${t}\``. */
|
|
39
|
+
buildRefreshUrl?: (taskId: string) => string;
|
|
40
|
+
/** Voting hook options (vote endpoint + storage key) — see
|
|
41
|
+
* `useRoadmapVoting`. */
|
|
42
|
+
votingOptions?: UseRoadmapVotingOptions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function RoadmapGrid({
|
|
46
|
+
items,
|
|
47
|
+
onItemUpdate,
|
|
48
|
+
showLeftMargin = true,
|
|
49
|
+
buildRefreshUrl = DEFAULT_BUILD_REFRESH_URL,
|
|
50
|
+
votingOptions,
|
|
51
|
+
}: RoadmapGridProps) {
|
|
52
|
+
const { getVote, toggleVote } = useRoadmapVoting(votingOptions);
|
|
53
|
+
const [votingTasks, setVotingTasks] = useState<Set<string>>(new Set());
|
|
54
|
+
|
|
55
|
+
const handleVote = async (taskId: string, voteType: 'up' | 'down') => {
|
|
56
|
+
// Prevent double-clicking
|
|
57
|
+
if (votingTasks.has(taskId)) return;
|
|
58
|
+
|
|
59
|
+
setVotingTasks(prev => new Set(prev).add(taskId));
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await toggleVote(taskId, voteType);
|
|
63
|
+
|
|
64
|
+
if (result.success) {
|
|
65
|
+
// Refresh the specific task from server
|
|
66
|
+
const response = await fetch(buildRefreshUrl(taskId));
|
|
67
|
+
if (response.ok) {
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
if (data.item && onItemUpdate) {
|
|
70
|
+
onItemUpdate(data.item);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
setVotingTasks(prev => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
next.delete(taskId);
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (items.length === 0) {
|
|
84
|
+
return (
|
|
85
|
+
<EmptyState
|
|
86
|
+
type="generic"
|
|
87
|
+
title="No roadmap items"
|
|
88
|
+
description="Check back soon for upcoming features and improvements!"
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
|
|
95
|
+
{items.map((item) => (
|
|
96
|
+
<RoadmapCard
|
|
97
|
+
key={item.id}
|
|
98
|
+
item={item}
|
|
99
|
+
userVote={getVote(item.id)}
|
|
100
|
+
onVote={(voteType) => handleVote(item.id, voteType)}
|
|
101
|
+
isVoting={votingTasks.has(item.id)}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useRoadmapVoting — localStorage-backed optimistic voting for roadmap cards.
|
|
5
|
+
*
|
|
6
|
+
* One vote per task per user (storage key scoped per `storageKey` option,
|
|
7
|
+
* default `'roadmap_votes_v1'`). Toggling the same vote removes it;
|
|
8
|
+
* switching directions sends a remove + add pair so the server's running
|
|
9
|
+
* totals stay correct.
|
|
10
|
+
*
|
|
11
|
+
* Endpoint configuration — `voteApiEndpoint`:
|
|
12
|
+
* The hook posts to ONE endpoint (default `/api/roadmap/vote`) for
|
|
13
|
+
* BOTH the optimistic add AND the opposite-vote remove. Reverse-proxy
|
|
14
|
+
* embedders override this with their proxied path; lib otherwise
|
|
15
|
+
* matches the hub's pre-migration call shape.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
19
|
+
|
|
20
|
+
export type VoteType = 'up' | 'down' | null;
|
|
21
|
+
|
|
22
|
+
export interface VoteState {
|
|
23
|
+
[taskId: string]: VoteType;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseRoadmapVotingOptions {
|
|
27
|
+
/** Vote endpoint URL. Default `/api/roadmap/vote`. */
|
|
28
|
+
voteApiEndpoint?: string;
|
|
29
|
+
/** localStorage key. Default `'roadmap_votes_v1'`. Embedders mounting
|
|
30
|
+
* multiple roadmap surfaces in the same origin can scope per-surface
|
|
31
|
+
* (e.g. `'roadmap_votes_v1_main'` vs `'roadmap_votes_v1_admin'`) so
|
|
32
|
+
* votes don't cross-contaminate. */
|
|
33
|
+
storageKey?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_VOTE_ENDPOINT = '/api/roadmap/vote';
|
|
37
|
+
const DEFAULT_STORAGE_KEY = 'roadmap_votes_v1';
|
|
38
|
+
|
|
39
|
+
export function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {
|
|
40
|
+
const voteApiEndpoint = options.voteApiEndpoint ?? DEFAULT_VOTE_ENDPOINT;
|
|
41
|
+
const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
42
|
+
|
|
43
|
+
const [votes, setVotes] = useState<VoteState>({});
|
|
44
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
45
|
+
|
|
46
|
+
// Load votes from localStorage. Runs on mount AND whenever `storageKey`
|
|
47
|
+
// changes — when the key changes mid-lifecycle (e.g. an embedder
|
|
48
|
+
// remounts with a new namespace), we MUST reset state first so the
|
|
49
|
+
// save-effect below doesn't write the old key's data into the new
|
|
50
|
+
// key. We also re-enter the loading phase so the load completes
|
|
51
|
+
// before any save runs.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setIsLoading(true);
|
|
54
|
+
setVotes({});
|
|
55
|
+
try {
|
|
56
|
+
const stored = localStorage.getItem(storageKey);
|
|
57
|
+
if (stored) {
|
|
58
|
+
setVotes(JSON.parse(stored));
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('[Voting] Error loading votes from localStorage:', error);
|
|
62
|
+
} finally {
|
|
63
|
+
setIsLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}, [storageKey]);
|
|
66
|
+
|
|
67
|
+
// Save votes to localStorage whenever they change
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isLoading) {
|
|
70
|
+
try {
|
|
71
|
+
localStorage.setItem(storageKey, JSON.stringify(votes));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('[Voting] Error saving votes to localStorage:', error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, [votes, isLoading, storageKey]);
|
|
77
|
+
|
|
78
|
+
const getVote = useCallback(
|
|
79
|
+
(taskId: string): VoteType => {
|
|
80
|
+
return votes[taskId] || null;
|
|
81
|
+
},
|
|
82
|
+
[votes]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const toggleVote = useCallback(
|
|
86
|
+
async (
|
|
87
|
+
taskId: string,
|
|
88
|
+
voteType: 'up' | 'down'
|
|
89
|
+
): Promise<{ success: boolean; newVote: VoteType; action: 'add' | 'remove' }> => {
|
|
90
|
+
const currentVote = votes[taskId];
|
|
91
|
+
|
|
92
|
+
let newVote: VoteType = null;
|
|
93
|
+
let action: 'add' | 'remove' = 'add';
|
|
94
|
+
|
|
95
|
+
if (currentVote === voteType) {
|
|
96
|
+
// User clicked same vote - remove it
|
|
97
|
+
newVote = null;
|
|
98
|
+
action = 'remove';
|
|
99
|
+
} else {
|
|
100
|
+
// User clicked different vote - set it. If they had an opposite
|
|
101
|
+
// vote, remove that first so the server totals stay consistent.
|
|
102
|
+
if (currentVote) {
|
|
103
|
+
await fetch(voteApiEndpoint, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
taskId,
|
|
108
|
+
voteType: currentVote,
|
|
109
|
+
action: 'remove',
|
|
110
|
+
}),
|
|
111
|
+
}).catch(err => console.error('[Voting] Error removing opposite vote:', err));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
newVote = voteType;
|
|
115
|
+
action = 'add';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Optimistic update
|
|
119
|
+
setVotes(prev => ({
|
|
120
|
+
...prev,
|
|
121
|
+
[taskId]: newVote,
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(voteApiEndpoint, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify({ taskId, voteType, action }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error('Vote API request failed');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { success: true, newVote, action };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error('[Voting] API error:', error);
|
|
138
|
+
|
|
139
|
+
// Revert optimistic update on error
|
|
140
|
+
setVotes(prev => ({
|
|
141
|
+
...prev,
|
|
142
|
+
[taskId]: currentVote,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return { success: false, newVote: currentVote, action };
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[votes, voteApiEndpoint]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const clearVotes = useCallback(() => {
|
|
152
|
+
setVotes({});
|
|
153
|
+
localStorage.removeItem(storageKey);
|
|
154
|
+
}, [storageKey]);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
votes,
|
|
158
|
+
isLoading,
|
|
159
|
+
getVote,
|
|
160
|
+
toggleVote,
|
|
161
|
+
clearVotes,
|
|
162
|
+
};
|
|
163
|
+
}
|