@launch77-shared/app-template-webapp 1.2.0 → 1.2.1
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/package.json.hbs +5 -3
- package/src/app/page.tsx.hbs +6 -0
- package/src/app/plugins/page.tsx +78 -0
- package/src/modules/plugins/utils.test.ts +335 -0
- package/src/modules/plugins/utils.ts +124 -0
- package/src/modules/showcase/components/BackToTop.tsx +47 -0
- package/src/modules/showcase/components/CodeBlock.tsx +76 -0
- package/src/modules/showcase/components/MobileMenu.tsx +154 -0
- package/src/modules/showcase/components/NavigationSidebar.tsx +120 -0
- package/src/modules/showcase/components/ShowcaseLayout.tsx +118 -0
- package/src/modules/showcase/hooks/useScrollSpy.ts +98 -0
- package/src/modules/showcase/index.ts +12 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/package.json.hbs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"
|
|
15
|
+
"dotenv": "^16.4.5",
|
|
16
|
+
"next": "^15.0.0",
|
|
16
17
|
"react": "^18.3.1",
|
|
17
|
-
"react-dom": "^18.3.1"
|
|
18
|
+
"react-dom": "^18.3.1",
|
|
19
|
+
"@launch77/plugin-runtime": "^0.3.2"
|
|
18
20
|
},
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"@types/node": "^20.0.0",
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"@types/react-dom": "^18.2.25",
|
|
23
25
|
"autoprefixer": "^10.4.21",
|
|
24
26
|
"eslint": "^8.56.0",
|
|
25
|
-
"eslint-config-next": "^
|
|
27
|
+
"eslint-config-next": "^15.0.0",
|
|
26
28
|
"postcss": "^8.5.6",
|
|
27
29
|
"tailwindcss": "^3.4.17",
|
|
28
30
|
"typescript": "^5.3.0"
|
package/src/app/page.tsx.hbs
CHANGED
|
@@ -17,6 +17,12 @@ export default function HomePage() {
|
|
|
17
17
|
>
|
|
18
18
|
Go to Dashboard
|
|
19
19
|
</Link>
|
|
20
|
+
<Link
|
|
21
|
+
href="/plugins"
|
|
22
|
+
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors"
|
|
23
|
+
>
|
|
24
|
+
View Plugins
|
|
25
|
+
</Link>
|
|
20
26
|
<Link
|
|
21
27
|
href="/about"
|
|
22
28
|
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import { getInstalledPlugins } from '@/modules/plugins/utils'
|
|
3
|
+
|
|
4
|
+
export default async function PluginsPage() {
|
|
5
|
+
const plugins = await getInstalledPlugins()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="container mx-auto py-8 px-4">
|
|
9
|
+
<div>
|
|
10
|
+
<h1 className="text-3xl font-bold mb-2">Installed Plugins</h1>
|
|
11
|
+
<p className="text-muted-foreground mb-8">Manage and explore your Launch77 plugins</p>
|
|
12
|
+
|
|
13
|
+
{plugins.length === 0 ? (
|
|
14
|
+
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
|
15
|
+
<p className="text-muted-foreground mb-4">No plugins installed yet.</p>
|
|
16
|
+
<p className="text-sm text-muted-foreground">Install plugins using the Launch77 CLI:</p>
|
|
17
|
+
<code className="mt-2 inline-block rounded bg-muted px-3 py-1 text-sm">launch77 plugin:install [plugin-name]</code>
|
|
18
|
+
</div>
|
|
19
|
+
) : (
|
|
20
|
+
<div className="overflow-x-auto">
|
|
21
|
+
<table className="w-full border-collapse">
|
|
22
|
+
<thead>
|
|
23
|
+
<tr className="border-b">
|
|
24
|
+
<th className="text-left p-3 font-semibold">Plugin</th>
|
|
25
|
+
<th className="text-left p-3 font-semibold">Version</th>
|
|
26
|
+
<th className="text-left p-3 font-semibold">Description</th>
|
|
27
|
+
<th className="text-left p-3 font-semibold">Installed</th>
|
|
28
|
+
<th className="text-left p-3 font-semibold">Showcase</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
{plugins.map((plugin) => (
|
|
33
|
+
<tr key={plugin.name} className="border-b hover:bg-muted/50 transition-colors">
|
|
34
|
+
<td className="p-3">
|
|
35
|
+
<span className="font-mono text-sm">{plugin.name}</span>
|
|
36
|
+
</td>
|
|
37
|
+
<td className="p-3">
|
|
38
|
+
<span className="text-sm text-muted-foreground">{plugin.version}</span>
|
|
39
|
+
</td>
|
|
40
|
+
<td className="p-3">
|
|
41
|
+
<span className="text-sm">{plugin.description || '—'}</span>
|
|
42
|
+
</td>
|
|
43
|
+
<td className="p-3">
|
|
44
|
+
<span className="text-sm text-muted-foreground">
|
|
45
|
+
{new Date(plugin.installedAt).toLocaleDateString('en-US', {
|
|
46
|
+
year: 'numeric',
|
|
47
|
+
month: 'short',
|
|
48
|
+
day: 'numeric',
|
|
49
|
+
hour: '2-digit',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
second: '2-digit',
|
|
52
|
+
})}
|
|
53
|
+
</span>
|
|
54
|
+
</td>
|
|
55
|
+
<td className="p-3">
|
|
56
|
+
{plugin.showcaseUrl ? (
|
|
57
|
+
<Link href={plugin.showcaseUrl} className="text-sm text-primary hover:underline inline-flex items-center gap-1">
|
|
58
|
+
View
|
|
59
|
+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
60
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
61
|
+
</svg>
|
|
62
|
+
</Link>
|
|
63
|
+
) : (
|
|
64
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
65
|
+
)}
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
))}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{plugins.length > 0 && <div className="mt-8 text-sm text-muted-foreground">Total plugins installed: {plugins.length}</div>}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { getInstalledPlugins } from './utils'
|
|
5
|
+
|
|
6
|
+
// Mock fs and path modules
|
|
7
|
+
vi.mock('fs')
|
|
8
|
+
vi.mock('path', async () => {
|
|
9
|
+
const actual = await vi.importActual('path')
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
join: vi.fn((...args) => args.join('/')),
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('getInstalledPlugins', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
// Mock process.cwd()
|
|
20
|
+
vi.spyOn(process, 'cwd').mockReturnValue('/test/project')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should return empty array when no plugins are installed', async () => {
|
|
24
|
+
const mockPackageJson = {
|
|
25
|
+
dependencies: {
|
|
26
|
+
react: '^18.0.0',
|
|
27
|
+
next: '^14.0.0',
|
|
28
|
+
},
|
|
29
|
+
launch77: {
|
|
30
|
+
// No installedPlugins
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
35
|
+
if (filePath === '/test/project/package.json') {
|
|
36
|
+
return JSON.stringify(mockPackageJson)
|
|
37
|
+
}
|
|
38
|
+
throw new Error('File not found')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const plugins = await getInstalledPlugins()
|
|
42
|
+
expect(plugins).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should extract plugin metadata from launch77.installedPlugins', async () => {
|
|
46
|
+
const mockPackageJson = {
|
|
47
|
+
dependencies: {
|
|
48
|
+
react: '^18.0.0',
|
|
49
|
+
},
|
|
50
|
+
launch77: {
|
|
51
|
+
installedPlugins: {
|
|
52
|
+
ui: {
|
|
53
|
+
package: '@launch77-shared/plugin-ui',
|
|
54
|
+
version: '0.3.1',
|
|
55
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
56
|
+
source: 'npm',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mockPluginPackageJson = {
|
|
63
|
+
name: '@launch77-shared/plugin-ui',
|
|
64
|
+
version: '0.3.1',
|
|
65
|
+
description: 'UI components library',
|
|
66
|
+
author: 'Launch77 Team',
|
|
67
|
+
repository: { url: 'https://github.com/launch77/ui' },
|
|
68
|
+
keywords: ['launch77', 'ui', 'components'],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mockPluginJson = {
|
|
72
|
+
targets: ['app'],
|
|
73
|
+
showcaseUrl: '/ui-examples',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
77
|
+
if (filePath === '/test/project/package.json') {
|
|
78
|
+
return JSON.stringify(mockPackageJson)
|
|
79
|
+
}
|
|
80
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-ui/package.json') {
|
|
81
|
+
return JSON.stringify(mockPluginPackageJson)
|
|
82
|
+
}
|
|
83
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-ui/plugin.json') {
|
|
84
|
+
return JSON.stringify(mockPluginJson)
|
|
85
|
+
}
|
|
86
|
+
throw new Error('File not found')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
|
90
|
+
return filePath.includes('plugin-ui')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const plugins = await getInstalledPlugins()
|
|
94
|
+
expect(plugins).toHaveLength(1)
|
|
95
|
+
expect(plugins[0]).toEqual({
|
|
96
|
+
name: '@launch77-shared/plugin-ui',
|
|
97
|
+
version: '0.3.1',
|
|
98
|
+
description: 'UI components library',
|
|
99
|
+
author: 'Launch77 Team',
|
|
100
|
+
repository: 'https://github.com/launch77/ui',
|
|
101
|
+
keywords: ['launch77', 'ui', 'components'],
|
|
102
|
+
showcaseUrl: '/ui-examples',
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should handle plugins without showcaseUrl', async () => {
|
|
107
|
+
const mockPackageJson = {
|
|
108
|
+
launch77: {
|
|
109
|
+
installedPlugins: {
|
|
110
|
+
release: {
|
|
111
|
+
package: '@launch77-shared/plugin-release',
|
|
112
|
+
version: '1.0.0',
|
|
113
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
114
|
+
source: 'npm',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mockPluginPackageJson = {
|
|
121
|
+
name: '@launch77-shared/plugin-release',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
description: 'Release automation',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const mockPluginJson = {
|
|
127
|
+
targets: ['app', 'library'],
|
|
128
|
+
// No showcaseUrl
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
132
|
+
if (filePath === '/test/project/package.json') {
|
|
133
|
+
return JSON.stringify(mockPackageJson)
|
|
134
|
+
}
|
|
135
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-release/package.json') {
|
|
136
|
+
return JSON.stringify(mockPluginPackageJson)
|
|
137
|
+
}
|
|
138
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-release/plugin.json') {
|
|
139
|
+
return JSON.stringify(mockPluginJson)
|
|
140
|
+
}
|
|
141
|
+
throw new Error('File not found')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
|
145
|
+
return filePath.includes('plugin-release')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const plugins = await getInstalledPlugins()
|
|
149
|
+
expect(plugins).toHaveLength(1)
|
|
150
|
+
expect(plugins[0].showcaseUrl).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should sort plugins alphabetically by name', async () => {
|
|
154
|
+
const mockPackageJson = {
|
|
155
|
+
launch77: {
|
|
156
|
+
installedPlugins: {
|
|
157
|
+
ui: {
|
|
158
|
+
package: '@launch77-shared/plugin-ui',
|
|
159
|
+
version: '0.3.1',
|
|
160
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
161
|
+
source: 'npm',
|
|
162
|
+
},
|
|
163
|
+
'analytics-web': {
|
|
164
|
+
package: '@launch77-shared/plugin-analytics-web',
|
|
165
|
+
version: '1.0.0',
|
|
166
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
167
|
+
source: 'npm',
|
|
168
|
+
},
|
|
169
|
+
marketing: {
|
|
170
|
+
package: '@launch77-shared/plugin-marketing',
|
|
171
|
+
version: '0.5.0',
|
|
172
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
173
|
+
source: 'npm',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
180
|
+
if (filePath === '/test/project/package.json') {
|
|
181
|
+
return JSON.stringify(mockPackageJson)
|
|
182
|
+
}
|
|
183
|
+
// Return minimal package.json for each plugin
|
|
184
|
+
if (filePath.includes('/package.json') && filePath.includes('node_modules')) {
|
|
185
|
+
const name = filePath.match(/plugin-[^/]+/)?.[0] || 'unknown'
|
|
186
|
+
return JSON.stringify({
|
|
187
|
+
name: `@launch77-shared/${name}`,
|
|
188
|
+
version: '1.0.0',
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return '{}'
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
195
|
+
|
|
196
|
+
const plugins = await getInstalledPlugins()
|
|
197
|
+
expect(plugins).toHaveLength(3)
|
|
198
|
+
expect(plugins[0].name).toBe('@launch77-shared/plugin-analytics-web')
|
|
199
|
+
expect(plugins[1].name).toBe('@launch77-shared/plugin-marketing')
|
|
200
|
+
expect(plugins[2].name).toBe('@launch77-shared/plugin-ui')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should handle malformed plugin.json gracefully', async () => {
|
|
204
|
+
const mockPackageJson = {
|
|
205
|
+
launch77: {
|
|
206
|
+
installedPlugins: {
|
|
207
|
+
broken: {
|
|
208
|
+
package: '@launch77-shared/plugin-broken',
|
|
209
|
+
version: '1.0.0',
|
|
210
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
211
|
+
source: 'npm',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const mockPluginPackageJson = {
|
|
218
|
+
name: '@launch77-shared/plugin-broken',
|
|
219
|
+
version: '1.0.0',
|
|
220
|
+
description: 'Plugin with broken config',
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
224
|
+
if (filePath === '/test/project/package.json') {
|
|
225
|
+
return JSON.stringify(mockPackageJson)
|
|
226
|
+
}
|
|
227
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-broken/package.json') {
|
|
228
|
+
return JSON.stringify(mockPluginPackageJson)
|
|
229
|
+
}
|
|
230
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-broken/plugin.json') {
|
|
231
|
+
return 'invalid json {' // Malformed JSON
|
|
232
|
+
}
|
|
233
|
+
throw new Error('File not found')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
|
237
|
+
return filePath.includes('plugin-broken')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const plugins = await getInstalledPlugins()
|
|
241
|
+
expect(plugins).toHaveLength(1)
|
|
242
|
+
expect(plugins[0].showcaseUrl).toBeNull() // Should handle error and continue
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should return empty array when package.json cannot be read', async () => {
|
|
246
|
+
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
247
|
+
throw new Error('Cannot read file')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const plugins = await getInstalledPlugins()
|
|
251
|
+
expect(plugins).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should handle multiple installed plugins', async () => {
|
|
255
|
+
const mockPackageJson = {
|
|
256
|
+
launch77: {
|
|
257
|
+
installedPlugins: {
|
|
258
|
+
ui: {
|
|
259
|
+
package: '@launch77-shared/plugin-ui',
|
|
260
|
+
version: '0.3.1',
|
|
261
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
262
|
+
source: 'npm',
|
|
263
|
+
},
|
|
264
|
+
test: {
|
|
265
|
+
package: '@launch77-shared/plugin-test',
|
|
266
|
+
version: '1.0.0',
|
|
267
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
268
|
+
source: 'npm',
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
275
|
+
if (filePath === '/test/project/package.json') {
|
|
276
|
+
return JSON.stringify(mockPackageJson)
|
|
277
|
+
}
|
|
278
|
+
if (filePath.includes('/package.json') && filePath.includes('node_modules')) {
|
|
279
|
+
const name = filePath.match(/plugin-[^/]+/)?.[0] || 'unknown'
|
|
280
|
+
return JSON.stringify({
|
|
281
|
+
name: `@launch77-shared/${name}`,
|
|
282
|
+
version: '1.0.0',
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
return '{}'
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
289
|
+
|
|
290
|
+
const plugins = await getInstalledPlugins()
|
|
291
|
+
expect(plugins).toHaveLength(2)
|
|
292
|
+
expect(plugins.some((p) => p.name === '@launch77-shared/plugin-ui')).toBe(true)
|
|
293
|
+
expect(plugins.some((p) => p.name === '@launch77-shared/plugin-test')).toBe(true)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('should handle author as object', async () => {
|
|
297
|
+
const mockPackageJson = {
|
|
298
|
+
launch77: {
|
|
299
|
+
installedPlugins: {
|
|
300
|
+
ui: {
|
|
301
|
+
package: '@launch77-shared/plugin-ui',
|
|
302
|
+
version: '0.3.1',
|
|
303
|
+
installedAt: '2024-01-01T00:00:00.000Z',
|
|
304
|
+
source: 'npm',
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const mockPluginPackageJson = {
|
|
311
|
+
name: '@launch77-shared/plugin-ui',
|
|
312
|
+
version: '0.3.1',
|
|
313
|
+
author: {
|
|
314
|
+
name: 'John Doe',
|
|
315
|
+
email: 'john@example.com',
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
320
|
+
if (filePath === '/test/project/package.json') {
|
|
321
|
+
return JSON.stringify(mockPackageJson)
|
|
322
|
+
}
|
|
323
|
+
if (filePath === '/test/project/node_modules/@launch77-shared/plugin-ui/package.json') {
|
|
324
|
+
return JSON.stringify(mockPluginPackageJson)
|
|
325
|
+
}
|
|
326
|
+
return '{}'
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
330
|
+
|
|
331
|
+
const plugins = await getInstalledPlugins()
|
|
332
|
+
expect(plugins).toHaveLength(1)
|
|
333
|
+
expect(plugins[0].author).toBe('John Doe')
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import { PackageManifestService } from '@launch77/plugin-runtime'
|
|
4
|
+
import { MetadataService } from '@launch77/plugin-runtime'
|
|
5
|
+
import type { InstalledPluginMetadata } from '@launch77/plugin-runtime'
|
|
6
|
+
|
|
7
|
+
export interface InstalledPlugin {
|
|
8
|
+
name: string
|
|
9
|
+
version: string
|
|
10
|
+
description: string
|
|
11
|
+
author: string
|
|
12
|
+
repository: string
|
|
13
|
+
keywords: string[]
|
|
14
|
+
showcaseUrl: string | null
|
|
15
|
+
installedAt: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the actual npm package name for a plugin
|
|
20
|
+
*/
|
|
21
|
+
function resolvePluginPackageName(pluginKey: string, metadata: InstalledPluginMetadata): string {
|
|
22
|
+
// For local plugins, we need to construct the proper package name
|
|
23
|
+
// The metadata.package field might just be the plugin name without the scope
|
|
24
|
+
if (metadata.source === 'local') {
|
|
25
|
+
// Check if it already has a scope
|
|
26
|
+
if (metadata.package.startsWith('@')) {
|
|
27
|
+
return metadata.package
|
|
28
|
+
}
|
|
29
|
+
// Add the workspace scope for local plugins
|
|
30
|
+
return `@launch77-shared/plugin-${metadata.package}`
|
|
31
|
+
}
|
|
32
|
+
// For npm plugins, use the package as-is
|
|
33
|
+
return metadata.package
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Try to find a module by checking multiple locations
|
|
38
|
+
* This handles workspace hoisting and different module resolution scenarios
|
|
39
|
+
*/
|
|
40
|
+
function findModulePath(moduleName: string, startPath: string): string | null {
|
|
41
|
+
const attempts = [
|
|
42
|
+
// Direct path in current directory's node_modules
|
|
43
|
+
path.join(startPath, 'node_modules', moduleName),
|
|
44
|
+
// Check parent directories (for workspace hoisting)
|
|
45
|
+
path.join(startPath, '..', 'node_modules', moduleName),
|
|
46
|
+
path.join(startPath, '..', '..', 'node_modules', moduleName),
|
|
47
|
+
path.join(startPath, '..', '..', '..', 'node_modules', moduleName),
|
|
48
|
+
// Check workspace root directly for launch77-shared workspace
|
|
49
|
+
path.join(startPath, '..', '..', '..', 'launch77-shared', 'node_modules', moduleName),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
for (const attemptPath of attempts) {
|
|
53
|
+
if (fs.existsSync(attemptPath)) {
|
|
54
|
+
return attemptPath
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all installed Launch77 plugins using the official PackageManifestService
|
|
63
|
+
*/
|
|
64
|
+
export async function getInstalledPlugins(): Promise<InstalledPlugin[]> {
|
|
65
|
+
const manifestService = new PackageManifestService()
|
|
66
|
+
const metadataService = new MetadataService()
|
|
67
|
+
const currentPath = process.cwd()
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Get installed plugins from the manifest service
|
|
71
|
+
const installedPlugins: Record<string, InstalledPluginMetadata> = await manifestService.getInstalledPlugins(currentPath)
|
|
72
|
+
const plugins: InstalledPlugin[] = []
|
|
73
|
+
|
|
74
|
+
for (const [pluginKey, pluginMeta] of Object.entries(installedPlugins)) {
|
|
75
|
+
try {
|
|
76
|
+
// Resolve the actual package name
|
|
77
|
+
const packageName = resolvePluginPackageName(pluginKey, pluginMeta)
|
|
78
|
+
|
|
79
|
+
// Find the plugin module path
|
|
80
|
+
const pluginPath = findModulePath(packageName, currentPath)
|
|
81
|
+
|
|
82
|
+
if (!pluginPath) {
|
|
83
|
+
// Plugin not found in node_modules - skip silently
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Read plugin.json for showcaseUrl
|
|
88
|
+
let showcaseUrl: string | null = null
|
|
89
|
+
try {
|
|
90
|
+
const pluginMetadata = await metadataService.readPluginMetadata(pluginPath)
|
|
91
|
+
showcaseUrl = pluginMetadata.showcaseUrl || null
|
|
92
|
+
} catch {
|
|
93
|
+
// Plugin.json might not exist or be invalid - that's ok
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Read package.json for additional metadata
|
|
97
|
+
const pluginPackageJsonPath = path.join(pluginPath, 'package.json')
|
|
98
|
+
if (fs.existsSync(pluginPackageJsonPath)) {
|
|
99
|
+
const pluginPackageJson = JSON.parse(fs.readFileSync(pluginPackageJsonPath, 'utf-8'))
|
|
100
|
+
|
|
101
|
+
plugins.push({
|
|
102
|
+
name: pluginPackageJson.name || packageName,
|
|
103
|
+
version: pluginMeta.version,
|
|
104
|
+
description: pluginPackageJson.description || '',
|
|
105
|
+
author: typeof pluginPackageJson.author === 'string' ? pluginPackageJson.author : pluginPackageJson.author?.name || '',
|
|
106
|
+
repository: pluginPackageJson.repository?.url || pluginPackageJson.repository || '',
|
|
107
|
+
keywords: pluginPackageJson.keywords || [],
|
|
108
|
+
showcaseUrl,
|
|
109
|
+
installedAt: pluginMeta.installedAt,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Error processing this plugin - skip silently
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Sort by installation date descending (newest first)
|
|
119
|
+
return plugins.sort((a, b) => new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime())
|
|
120
|
+
} catch {
|
|
121
|
+
// Error reading manifest
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
export function BackToTop() {
|
|
6
|
+
const [isVisible, setIsVisible] = useState(false)
|
|
7
|
+
|
|
8
|
+
// Show button when page is scrolled down
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const toggleVisibility = () => {
|
|
11
|
+
if (window.pageYOffset > 300) {
|
|
12
|
+
setIsVisible(true)
|
|
13
|
+
} else {
|
|
14
|
+
setIsVisible(false)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
window.addEventListener('scroll', toggleVisibility)
|
|
19
|
+
return () => window.removeEventListener('scroll', toggleVisibility)
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
const scrollToTop = () => {
|
|
23
|
+
window.scrollTo({
|
|
24
|
+
top: 0,
|
|
25
|
+
behavior: 'smooth',
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
onClick={scrollToTop}
|
|
32
|
+
className={`
|
|
33
|
+
fixed bottom-6 right-6 z-40
|
|
34
|
+
p-3 bg-white text-gray-700 border border-gray-200
|
|
35
|
+
rounded-lg shadow-lg
|
|
36
|
+
hover:bg-gray-50 hover:shadow-xl
|
|
37
|
+
transition-all duration-300 ease-in-out
|
|
38
|
+
${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10 pointer-events-none'}
|
|
39
|
+
`}
|
|
40
|
+
aria-label="Back to top"
|
|
41
|
+
>
|
|
42
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
43
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
44
|
+
</svg>
|
|
45
|
+
</button>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
interface CodeBlockProps {
|
|
6
|
+
code: string
|
|
7
|
+
language?: string
|
|
8
|
+
title?: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CodeBlock({ code, language = 'typescript', title, className = '' }: CodeBlockProps) {
|
|
13
|
+
const [copied, setCopied] = useState(false)
|
|
14
|
+
|
|
15
|
+
const handleCopy = async () => {
|
|
16
|
+
try {
|
|
17
|
+
await navigator.clipboard.writeText(code)
|
|
18
|
+
setCopied(true)
|
|
19
|
+
setTimeout(() => setCopied(false), 2000)
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error('Failed to copy code:', err)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={`relative group ${className}`}>
|
|
27
|
+
{title && (
|
|
28
|
+
<div className="flex items-center justify-between px-4 py-2 bg-gray-100 border border-gray-200 border-b-0 rounded-t-lg">
|
|
29
|
+
<span className="text-sm font-medium text-gray-700">{title}</span>
|
|
30
|
+
<span className="text-xs text-gray-500">{language}</span>
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
|
|
34
|
+
<div className={`relative bg-gray-900 ${title ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}>
|
|
35
|
+
{/* Copy button */}
|
|
36
|
+
<button
|
|
37
|
+
onClick={handleCopy}
|
|
38
|
+
className={`
|
|
39
|
+
absolute top-2 right-2 px-3 py-1.5 text-xs font-medium
|
|
40
|
+
bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white
|
|
41
|
+
rounded-md transition-all duration-200
|
|
42
|
+
opacity-0 group-hover:opacity-100 focus:opacity-100
|
|
43
|
+
${copied ? 'bg-green-600 hover:bg-green-600 text-white' : ''}
|
|
44
|
+
`}
|
|
45
|
+
aria-label={copied ? 'Copied!' : 'Copy code'}
|
|
46
|
+
>
|
|
47
|
+
{copied ? (
|
|
48
|
+
<span className="flex items-center gap-1">
|
|
49
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
50
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
51
|
+
</svg>
|
|
52
|
+
Copied
|
|
53
|
+
</span>
|
|
54
|
+
) : (
|
|
55
|
+
<span className="flex items-center gap-1">
|
|
56
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
57
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
58
|
+
</svg>
|
|
59
|
+
Copy
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
{/* Code content */}
|
|
65
|
+
<pre className="p-4 overflow-x-auto">
|
|
66
|
+
<code className="text-sm text-gray-100 font-mono">{code}</code>
|
|
67
|
+
</pre>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Export a simple inline code component too
|
|
74
|
+
export function InlineCode({ children }: { children: React.ReactNode }) {
|
|
75
|
+
return <code className="px-1.5 py-0.5 text-sm bg-gray-100 text-gray-900 rounded font-mono">{children}</code>
|
|
76
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react'
|
|
4
|
+
import type { ShowcaseSection } from './ShowcaseLayout'
|
|
5
|
+
|
|
6
|
+
interface MobileMenuProps {
|
|
7
|
+
isOpen: boolean
|
|
8
|
+
onClose: () => void
|
|
9
|
+
sections: ShowcaseSection[]
|
|
10
|
+
activeSection: string
|
|
11
|
+
title: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function MobileMenu({ isOpen, onClose, sections, activeSection, title }: MobileMenuProps) {
|
|
15
|
+
const [expandedSections, setExpandedSections] = useState<string[]>(sections.map((s) => s.id))
|
|
16
|
+
|
|
17
|
+
// Handle escape key
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
20
|
+
if (e.key === 'Escape') {
|
|
21
|
+
onClose()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isOpen) {
|
|
26
|
+
document.addEventListener('keydown', handleEscape)
|
|
27
|
+
return () => document.removeEventListener('keydown', handleEscape)
|
|
28
|
+
}
|
|
29
|
+
}, [isOpen, onClose])
|
|
30
|
+
|
|
31
|
+
const toggleSection = (sectionId: string) => {
|
|
32
|
+
setExpandedSections((prev) => (prev.includes(sectionId) ? prev.filter((id) => id !== sectionId) : [...prev, sectionId]))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const scrollToSection = (id: string) => {
|
|
36
|
+
const element = document.getElementById(id)
|
|
37
|
+
if (element) {
|
|
38
|
+
const offset = 80
|
|
39
|
+
const elementPosition = element.getBoundingClientRect().top
|
|
40
|
+
const offsetPosition = elementPosition + window.pageYOffset - offset
|
|
41
|
+
|
|
42
|
+
window.scrollTo({
|
|
43
|
+
top: offsetPosition,
|
|
44
|
+
behavior: 'smooth',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
onClose() // Close menu after navigation
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{/* Backdrop */}
|
|
54
|
+
{isOpen && <div className="fixed inset-0 bg-black/50 z-40 md:hidden transition-opacity" onClick={onClose} aria-hidden="true" />}
|
|
55
|
+
|
|
56
|
+
{/* Drawer */}
|
|
57
|
+
<aside
|
|
58
|
+
className={`
|
|
59
|
+
fixed inset-y-0 left-0 z-50 w-72 bg-white
|
|
60
|
+
transform transition-transform duration-300 ease-in-out
|
|
61
|
+
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
62
|
+
md:hidden
|
|
63
|
+
shadow-xl
|
|
64
|
+
`}
|
|
65
|
+
aria-label="Mobile navigation"
|
|
66
|
+
>
|
|
67
|
+
<div className="flex flex-col h-full">
|
|
68
|
+
{/* Header */}
|
|
69
|
+
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-200">
|
|
70
|
+
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">{title}</h2>
|
|
71
|
+
<button onClick={onClose} className="p-1 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors" aria-label="Close navigation menu">
|
|
72
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
73
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
74
|
+
</svg>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Navigation */}
|
|
79
|
+
<nav className="flex-1 overflow-y-auto px-4 py-4">
|
|
80
|
+
<div className="space-y-1">
|
|
81
|
+
{sections.map((section) => {
|
|
82
|
+
const hasItems = section.items && section.items.length > 0
|
|
83
|
+
const isExpanded = expandedSections.includes(section.id)
|
|
84
|
+
const isSectionActive = activeSection === section.id
|
|
85
|
+
const hasActiveChild = section.items?.some((item) => item.id === activeSection)
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div key={section.id}>
|
|
89
|
+
{/* Section header */}
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => {
|
|
92
|
+
if (hasItems) {
|
|
93
|
+
toggleSection(section.id)
|
|
94
|
+
}
|
|
95
|
+
scrollToSection(section.id)
|
|
96
|
+
}}
|
|
97
|
+
className={`
|
|
98
|
+
w-full flex items-center justify-between px-3 py-2 text-sm font-medium rounded-md transition-colors
|
|
99
|
+
${isSectionActive || hasActiveChild ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-100'}
|
|
100
|
+
`}
|
|
101
|
+
>
|
|
102
|
+
<span>{section.title}</span>
|
|
103
|
+
{hasItems && (
|
|
104
|
+
<svg
|
|
105
|
+
className={`
|
|
106
|
+
w-4 h-4 transition-transform duration-200
|
|
107
|
+
${isExpanded ? 'rotate-90' : ''}
|
|
108
|
+
`}
|
|
109
|
+
fill="currentColor"
|
|
110
|
+
viewBox="0 0 20 20"
|
|
111
|
+
>
|
|
112
|
+
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
|
113
|
+
</svg>
|
|
114
|
+
)}
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
{/* Section items */}
|
|
118
|
+
{hasItems && (
|
|
119
|
+
<div
|
|
120
|
+
className={`
|
|
121
|
+
overflow-hidden transition-all duration-200
|
|
122
|
+
${isExpanded ? 'max-h-96' : 'max-h-0'}
|
|
123
|
+
`}
|
|
124
|
+
>
|
|
125
|
+
<div className="ml-4 mt-1 space-y-1">
|
|
126
|
+
{section.items.map((item) => {
|
|
127
|
+
const isItemActive = activeSection === item.id
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
key={item.id}
|
|
132
|
+
onClick={() => scrollToSection(item.id)}
|
|
133
|
+
className={`
|
|
134
|
+
w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors
|
|
135
|
+
${isItemActive ? 'bg-blue-100 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}
|
|
136
|
+
`}
|
|
137
|
+
>
|
|
138
|
+
{item.title}
|
|
139
|
+
</button>
|
|
140
|
+
)
|
|
141
|
+
})}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
</nav>
|
|
150
|
+
</div>
|
|
151
|
+
</aside>
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import type { ShowcaseSection } from './ShowcaseLayout'
|
|
5
|
+
|
|
6
|
+
interface NavigationSidebarProps {
|
|
7
|
+
sections: ShowcaseSection[]
|
|
8
|
+
activeSection: string
|
|
9
|
+
title: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function NavigationSidebar({ sections, activeSection, title }: NavigationSidebarProps) {
|
|
13
|
+
const [expandedSections, setExpandedSections] = useState<string[]>(
|
|
14
|
+
// Start with all sections expanded
|
|
15
|
+
sections.map((s) => s.id)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const toggleSection = (sectionId: string) => {
|
|
19
|
+
setExpandedSections((prev) => (prev.includes(sectionId) ? prev.filter((id) => id !== sectionId) : [...prev, sectionId]))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scrollToSection = (id: string) => {
|
|
23
|
+
const element = document.getElementById(id)
|
|
24
|
+
if (element) {
|
|
25
|
+
const offset = 80 // Account for any fixed headers
|
|
26
|
+
const elementPosition = element.getBoundingClientRect().top
|
|
27
|
+
const offsetPosition = elementPosition + window.pageYOffset - offset
|
|
28
|
+
|
|
29
|
+
window.scrollTo({
|
|
30
|
+
top: offsetPosition,
|
|
31
|
+
behavior: 'smooth',
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<aside className="sticky top-0 h-screen w-64 overflow-y-auto border-r border-gray-200 bg-gray-50">
|
|
38
|
+
<div className="px-4 py-6">
|
|
39
|
+
{/* Sidebar header */}
|
|
40
|
+
<div className="mb-6 pb-6 border-b border-gray-200">
|
|
41
|
+
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">{title}</h2>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Navigation sections */}
|
|
45
|
+
<nav className="space-y-1" aria-label="Showcase navigation">
|
|
46
|
+
{sections.map((section) => {
|
|
47
|
+
const hasItems = section.items && section.items.length > 0
|
|
48
|
+
const isExpanded = expandedSections.includes(section.id)
|
|
49
|
+
const isSectionActive = activeSection === section.id
|
|
50
|
+
const hasActiveChild = section.items?.some((item) => item.id === activeSection)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div key={section.id}>
|
|
54
|
+
{/* Section header */}
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => {
|
|
57
|
+
if (hasItems) {
|
|
58
|
+
toggleSection(section.id)
|
|
59
|
+
}
|
|
60
|
+
scrollToSection(section.id)
|
|
61
|
+
}}
|
|
62
|
+
className={`
|
|
63
|
+
w-full flex items-center justify-between px-3 py-2 text-sm font-medium rounded-md transition-colors
|
|
64
|
+
${isSectionActive || hasActiveChild ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-100'}
|
|
65
|
+
`}
|
|
66
|
+
aria-expanded={hasItems ? isExpanded : undefined}
|
|
67
|
+
aria-controls={hasItems ? `section-${section.id}` : undefined}
|
|
68
|
+
>
|
|
69
|
+
<span>{section.title}</span>
|
|
70
|
+
{hasItems && (
|
|
71
|
+
<svg
|
|
72
|
+
className={`
|
|
73
|
+
w-4 h-4 transition-transform duration-200
|
|
74
|
+
${isExpanded ? 'rotate-90' : ''}
|
|
75
|
+
`}
|
|
76
|
+
fill="currentColor"
|
|
77
|
+
viewBox="0 0 20 20"
|
|
78
|
+
>
|
|
79
|
+
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
|
80
|
+
</svg>
|
|
81
|
+
)}
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{/* Section items (collapsible) */}
|
|
85
|
+
{hasItems && (
|
|
86
|
+
<div
|
|
87
|
+
id={`section-${section.id}`}
|
|
88
|
+
className={`
|
|
89
|
+
overflow-hidden transition-all duration-200
|
|
90
|
+
${isExpanded ? 'max-h-96' : 'max-h-0'}
|
|
91
|
+
`}
|
|
92
|
+
>
|
|
93
|
+
<div className="ml-4 mt-1 space-y-1">
|
|
94
|
+
{section.items.map((item) => {
|
|
95
|
+
const isItemActive = activeSection === item.id
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
key={item.id}
|
|
100
|
+
onClick={() => scrollToSection(item.id)}
|
|
101
|
+
className={`
|
|
102
|
+
w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors
|
|
103
|
+
${isItemActive ? 'bg-blue-100 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}
|
|
104
|
+
`}
|
|
105
|
+
>
|
|
106
|
+
{item.title}
|
|
107
|
+
</button>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</nav>
|
|
117
|
+
</div>
|
|
118
|
+
</aside>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react'
|
|
4
|
+
import { NavigationSidebar } from './NavigationSidebar'
|
|
5
|
+
import { MobileMenu } from './MobileMenu'
|
|
6
|
+
import { BackToTop } from './BackToTop'
|
|
7
|
+
import { useScrollSpy } from '../hooks/useScrollSpy'
|
|
8
|
+
|
|
9
|
+
export interface ShowcaseSection {
|
|
10
|
+
id: string
|
|
11
|
+
title: string
|
|
12
|
+
items?: Array<{
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
}>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ShowcaseLayoutProps {
|
|
19
|
+
title: string
|
|
20
|
+
description?: string
|
|
21
|
+
sections: ShowcaseSection[]
|
|
22
|
+
children: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ShowcaseLayout({ title, description, sections, children }: ShowcaseLayoutProps) {
|
|
26
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
27
|
+
const [mounted, setMounted] = useState(false)
|
|
28
|
+
|
|
29
|
+
// Get all section IDs for scroll spy
|
|
30
|
+
const sectionIds = sections.flatMap((section) => [section.id, ...(section.items?.map((item) => item.id) || [])])
|
|
31
|
+
|
|
32
|
+
const activeSection = useScrollSpy(sectionIds)
|
|
33
|
+
|
|
34
|
+
// Handle mounting to avoid hydration issues
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setMounted(true)
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
// Close mobile menu on window resize
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handleResize = () => {
|
|
42
|
+
if (window.innerWidth >= 768) {
|
|
43
|
+
setMobileMenuOpen(false)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
window.addEventListener('resize', handleResize)
|
|
48
|
+
return () => window.removeEventListener('resize', handleResize)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
// Prevent body scroll when mobile menu is open
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (mobileMenuOpen) {
|
|
54
|
+
document.body.style.overflow = 'hidden'
|
|
55
|
+
} else {
|
|
56
|
+
document.body.style.overflow = ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
document.body.style.overflow = ''
|
|
61
|
+
}
|
|
62
|
+
}, [mobileMenuOpen])
|
|
63
|
+
|
|
64
|
+
if (!mounted) {
|
|
65
|
+
// Return a simplified version during SSR to avoid hydration mismatch
|
|
66
|
+
return (
|
|
67
|
+
<div className="min-h-screen bg-white">
|
|
68
|
+
<div className="mx-auto max-w-7xl">
|
|
69
|
+
<main className="px-4 py-12 sm:px-6 lg:px-8">
|
|
70
|
+
<header className="mb-12">
|
|
71
|
+
<h1 className="text-4xl font-bold tracking-tight text-gray-900">{title}</h1>
|
|
72
|
+
{description && <p className="mt-4 text-lg text-gray-600">{description}</p>}
|
|
73
|
+
</header>
|
|
74
|
+
{children}
|
|
75
|
+
</main>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="min-h-screen bg-white">
|
|
83
|
+
{/* Mobile menu button */}
|
|
84
|
+
<button onClick={() => setMobileMenuOpen(true)} className="fixed top-4 left-4 z-50 flex items-center justify-center w-10 h-10 bg-white border border-gray-200 rounded-lg shadow-sm md:hidden hover:bg-gray-50 transition-colors" aria-label="Open navigation menu">
|
|
85
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
86
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
87
|
+
</svg>
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
{/* Mobile menu */}
|
|
91
|
+
<MobileMenu isOpen={mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} sections={sections} activeSection={activeSection} title={title} />
|
|
92
|
+
|
|
93
|
+
{/* Desktop layout */}
|
|
94
|
+
<div className="flex">
|
|
95
|
+
{/* Desktop sidebar */}
|
|
96
|
+
<div className="hidden md:block">
|
|
97
|
+
<NavigationSidebar sections={sections} activeSection={activeSection} title={title} />
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Main content */}
|
|
101
|
+
<main className="flex-1 px-4 py-12 sm:px-6 lg:px-8">
|
|
102
|
+
<div className="mx-auto max-w-4xl">
|
|
103
|
+
<header className="mb-12">
|
|
104
|
+
<h1 className="text-4xl font-bold tracking-tight text-gray-900">{title}</h1>
|
|
105
|
+
{description && <p className="mt-4 text-lg text-gray-600">{description}</p>}
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
{/* Content with proper spacing */}
|
|
109
|
+
<div className="space-y-16">{children}</div>
|
|
110
|
+
</div>
|
|
111
|
+
</main>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Back to top button */}
|
|
115
|
+
<BackToTop />
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook that tracks which section is currently visible in the viewport
|
|
7
|
+
* Used to highlight the active section in the navigation
|
|
8
|
+
*/
|
|
9
|
+
export function useScrollSpy(sectionIds: string[]): string {
|
|
10
|
+
const [activeSection, setActiveSection] = useState<string>('')
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (typeof window === 'undefined' || sectionIds.length === 0) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Options for the Intersection Observer
|
|
18
|
+
// rootMargin adjusts the observation area:
|
|
19
|
+
// - negative top margin accounts for any fixed headers
|
|
20
|
+
// - negative bottom margin ensures we switch sections before the bottom is reached
|
|
21
|
+
const observerOptions: IntersectionObserverInit = {
|
|
22
|
+
root: null,
|
|
23
|
+
rootMargin: '-80px 0px -80% 0px',
|
|
24
|
+
threshold: 0,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Callback for when sections enter/leave the viewport
|
|
28
|
+
const observerCallback: IntersectionObserverCallback = (entries) => {
|
|
29
|
+
entries.forEach((entry) => {
|
|
30
|
+
if (entry.isIntersecting) {
|
|
31
|
+
setActiveSection(entry.target.id)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create the observer
|
|
37
|
+
const observer = new IntersectionObserver(observerCallback, observerOptions)
|
|
38
|
+
|
|
39
|
+
// Observe all sections
|
|
40
|
+
const elements: Element[] = []
|
|
41
|
+
sectionIds.forEach((id) => {
|
|
42
|
+
const element = document.getElementById(id)
|
|
43
|
+
if (element) {
|
|
44
|
+
observer.observe(element)
|
|
45
|
+
elements.push(element)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Cleanup function
|
|
50
|
+
return () => {
|
|
51
|
+
elements.forEach((element) => {
|
|
52
|
+
observer.unobserve(element)
|
|
53
|
+
})
|
|
54
|
+
observer.disconnect()
|
|
55
|
+
}
|
|
56
|
+
}, [sectionIds])
|
|
57
|
+
|
|
58
|
+
return activeSection
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hook for managing mobile menu state
|
|
63
|
+
*/
|
|
64
|
+
export function useMobileMenu() {
|
|
65
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
66
|
+
|
|
67
|
+
// Close menu when window is resized to desktop size
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const handleResize = () => {
|
|
70
|
+
if (window.innerWidth >= 768) {
|
|
71
|
+
setIsOpen(false)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
window.addEventListener('resize', handleResize)
|
|
76
|
+
return () => window.removeEventListener('resize', handleResize)
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
// Prevent body scroll when menu is open
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isOpen) {
|
|
82
|
+
document.body.style.overflow = 'hidden'
|
|
83
|
+
} else {
|
|
84
|
+
document.body.style.overflow = ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
document.body.style.overflow = ''
|
|
89
|
+
}
|
|
90
|
+
}, [isOpen])
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
isOpen,
|
|
94
|
+
open: () => setIsOpen(true),
|
|
95
|
+
close: () => setIsOpen(false),
|
|
96
|
+
toggle: () => setIsOpen((prev) => !prev),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Main showcase layout component
|
|
2
|
+
export { ShowcaseLayout } from './components/ShowcaseLayout'
|
|
3
|
+
export type { ShowcaseLayoutProps, ShowcaseSection } from './components/ShowcaseLayout'
|
|
4
|
+
|
|
5
|
+
// Additional components
|
|
6
|
+
export { CodeBlock, InlineCode } from './components/CodeBlock'
|
|
7
|
+
export { NavigationSidebar } from './components/NavigationSidebar'
|
|
8
|
+
export { MobileMenu } from './components/MobileMenu'
|
|
9
|
+
export { BackToTop } from './components/BackToTop'
|
|
10
|
+
|
|
11
|
+
// Hooks
|
|
12
|
+
export { useScrollSpy, useMobileMenu } from './hooks/useScrollSpy'
|