@launch77-shared/app-template-webapp 1.1.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @launch77-shared/app-template-webapp
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 95a40e7: Upgraded default next version from 14 to 15
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 79d55d9: Implemented new libraries and plugins for building marketing apps
14
+
3
15
  ## 1.1.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launch77-shared/app-template-webapp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Launch77 webapp app template",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/package.json.hbs CHANGED
@@ -12,9 +12,11 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "next": "^14.2.0",
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": "^14.2.0",
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"
@@ -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'