sunpeak 0.5.8 → 0.5.10
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/README.md +13 -11
- package/bin/sunpeak.js +3 -3
- package/dist/chatgpt/mock-openai.d.ts +7 -0
- package/dist/chatgpt/simple-sidebar.d.ts +38 -0
- package/dist/chatgpt/theme-provider.d.ts +2 -2
- package/dist/index.cjs +7733 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7734 -201
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.cjs +80 -106
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +80 -106
- package/dist/mcp/index.js.map +1 -1
- package/dist/style.css +2890 -315
- package/package.json +6 -5
- package/template/README.md +1 -0
- package/template/dev/main.tsx +6 -10
- package/template/package.json +5 -4
- package/template/scripts/build-all.mjs +19 -10
- package/template/scripts/validate.mjs +8 -2
- package/template/src/components/album/album-card.test.tsx +62 -0
- package/template/src/components/album/album-card.tsx +14 -16
- package/template/src/components/album/albums.test.tsx +88 -0
- package/template/src/components/album/albums.tsx +50 -64
- package/template/src/components/album/film-strip.test.tsx +64 -0
- package/template/src/components/album/film-strip.tsx +16 -16
- package/template/src/components/album/fullscreen-viewer.test.tsx +77 -0
- package/template/src/components/album/fullscreen-viewer.tsx +45 -50
- package/template/src/components/card/card.test.tsx +1 -4
- package/template/src/components/card/card.tsx +38 -46
- package/template/src/components/carousel/carousel.tsx +57 -67
- package/template/src/components/resources/{AlbumsResource.tsx → albums-resource.tsx} +5 -5
- package/template/src/components/resources/{CarouselResource.tsx → carousel-resource.tsx} +18 -18
- package/template/src/components/resources/{CounterResource.tsx → counter-resource.tsx} +11 -31
- package/template/src/components/resources/index.ts +3 -3
- package/template/src/simulations/albums-simulation.ts +71 -71
- package/template/src/simulations/carousel-simulation.ts +34 -34
- package/template/src/simulations/counter-simulation.ts +2 -2
- package/template/vite.config.build.ts +2 -2
- package/template/vite.config.ts +1 -1
- package/template/vitest.config.ts +1 -1
- package/dist/runtime/index.d.ts +0 -7
- /package/dist/{runtime → providers}/provider-detection.d.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sunpeak",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"description": "The MCP App SDK. Quickstart, build, & test your ChatGPT App locally!",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"@testing-library/react": "^16.3.0",
|
|
74
74
|
"@testing-library/user-event": "^14.6.1",
|
|
75
75
|
"@types/node": "^24.10.1",
|
|
76
|
-
"@types/react": "^
|
|
77
|
-
"@types/react-dom": "^
|
|
76
|
+
"@types/react": "^19.2.7",
|
|
77
|
+
"@types/react-dom": "^19.2.3",
|
|
78
78
|
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
|
79
79
|
"@typescript-eslint/parser": "^8.47.0",
|
|
80
80
|
"@vitejs/plugin-react": "^4.3.4",
|
|
@@ -84,8 +84,8 @@
|
|
|
84
84
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
85
85
|
"jsdom": "^27.2.0",
|
|
86
86
|
"prettier": "^3.6.2",
|
|
87
|
-
"react": "^
|
|
88
|
-
"react-dom": "^
|
|
87
|
+
"react": "^19.2.0",
|
|
88
|
+
"react-dom": "^19.2.0",
|
|
89
89
|
"tailwindcss": "^4.1.17",
|
|
90
90
|
"ts-node": "^10.9.2",
|
|
91
91
|
"tsx": "^4.20.6",
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"scripts": {
|
|
106
106
|
"build": "vite build",
|
|
107
107
|
"dev": "pnpm --filter my-sunpeak-app dev",
|
|
108
|
+
"format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
108
109
|
"lint": "eslint . --ext .ts,.tsx --fix",
|
|
109
110
|
"typecheck": "tsc --noEmit",
|
|
110
111
|
"test": "vitest run",
|
package/template/README.md
CHANGED
package/template/dev/main.tsx
CHANGED
|
@@ -8,13 +8,13 @@ import * as Resources from '../src/components/resources';
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Extract the resource component name from a URI
|
|
11
|
-
* Example: 'ui://CounterResource
|
|
11
|
+
* Example: 'ui://CounterResource' -> 'CounterResource'
|
|
12
12
|
*/
|
|
13
13
|
function getResourceComponentFromURI(uri: string): React.ComponentType {
|
|
14
|
-
// Extract component name from URI pattern: ui://ComponentName
|
|
15
|
-
const match = uri.match(/^ui:\/\/(.+)
|
|
14
|
+
// Extract component name from URI pattern: ui://ComponentName
|
|
15
|
+
const match = uri.match(/^ui:\/\/(.+)$/);
|
|
16
16
|
if (!match) {
|
|
17
|
-
throw new Error(`Invalid resource URI format: ${uri}. Expected format: ui://ComponentName
|
|
17
|
+
throw new Error(`Invalid resource URI format: ${uri}. Expected format: ui://ComponentName`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const componentName = match[1];
|
|
@@ -23,7 +23,7 @@ function getResourceComponentFromURI(uri: string): React.ComponentType {
|
|
|
23
23
|
if (!component) {
|
|
24
24
|
throw new Error(
|
|
25
25
|
`Resource component "${componentName}" not found. ` +
|
|
26
|
-
|
|
26
|
+
`Make sure it's exported from src/components/resources/index.ts`
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -38,10 +38,6 @@ const simulations: Simulation[] = Object.values(SIMULATIONS).map((simulation) =>
|
|
|
38
38
|
|
|
39
39
|
createRoot(document.getElementById('root')!).render(
|
|
40
40
|
<StrictMode>
|
|
41
|
-
<ChatGPTSimulator
|
|
42
|
-
simulations={simulations}
|
|
43
|
-
appName="Sunpeak App"
|
|
44
|
-
appIcon="🌄"
|
|
45
|
-
/>
|
|
41
|
+
<ChatGPTSimulator simulations={simulations} appName="Sunpeak App" appIcon="🌄" />
|
|
46
42
|
</StrictMode>
|
|
47
43
|
);
|
package/template/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "node scripts/build-all.mjs",
|
|
8
8
|
"dev": "vite --port ${PORT:-6767}",
|
|
9
|
+
"format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
9
10
|
"mcp": "nodemon",
|
|
10
11
|
"mcp:serve": "tsx mcp/server.ts",
|
|
11
12
|
"lint": "eslint . --ext .ts,.tsx --fix",
|
|
@@ -27,8 +28,8 @@
|
|
|
27
28
|
"@testing-library/react": "^16.3.0",
|
|
28
29
|
"@testing-library/user-event": "^14.6.1",
|
|
29
30
|
"@types/node": "^24.10.1",
|
|
30
|
-
"@types/react": "^
|
|
31
|
-
"@types/react-dom": "^
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
32
33
|
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
|
33
34
|
"@typescript-eslint/parser": "^8.47.0",
|
|
34
35
|
"@vitejs/plugin-react": "^4.3.4",
|
|
@@ -40,8 +41,8 @@
|
|
|
40
41
|
"nodemon": "^3.1.11",
|
|
41
42
|
"postcss": "^8.4.49",
|
|
42
43
|
"prettier": "^3.6.2",
|
|
43
|
-
"react": "^
|
|
44
|
-
"react-dom": "^
|
|
44
|
+
"react": "^19.0.0",
|
|
45
|
+
"react-dom": "^19.0.0",
|
|
45
46
|
"tailwindcss": "^4.1.17",
|
|
46
47
|
"ts-node": "^10.9.2",
|
|
47
48
|
"tsx": "^4.20.6",
|
|
@@ -23,24 +23,33 @@ mkdirSync(tempDir, { recursive: true });
|
|
|
23
23
|
|
|
24
24
|
// Auto-discover all resources
|
|
25
25
|
const resourceFiles = readdirSync(resourcesDir)
|
|
26
|
-
.filter(file => file.endsWith('
|
|
26
|
+
.filter(file => file.endsWith('-resource.tsx'))
|
|
27
27
|
.map(file => {
|
|
28
|
-
|
|
28
|
+
// Extract kebab-case name: 'counter-resource.tsx' -> 'counter'
|
|
29
|
+
const kebabName = file.replace('-resource.tsx', '');
|
|
30
|
+
|
|
31
|
+
// Convert kebab-case to PascalCase: 'counter' -> 'Counter', 'my-widget' -> 'MyWidget'
|
|
32
|
+
const pascalName = kebabName
|
|
33
|
+
.split('-')
|
|
34
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
35
|
+
.join('');
|
|
36
|
+
|
|
29
37
|
return {
|
|
30
|
-
componentName: `${
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
componentName: `${pascalName}Resource`,
|
|
39
|
+
componentFile: file.replace('.tsx', ''),
|
|
40
|
+
entry: `.tmp/index-${kebabName}.tsx`,
|
|
41
|
+
output: `${kebabName}.js`,
|
|
42
|
+
buildOutDir: path.join(buildDir, kebabName),
|
|
34
43
|
};
|
|
35
44
|
});
|
|
36
45
|
|
|
37
|
-
console.log('Building all
|
|
46
|
+
console.log('Building all resources...\n');
|
|
38
47
|
|
|
39
48
|
// Read the template
|
|
40
49
|
const template = readFileSync(templateFile, 'utf-8');
|
|
41
50
|
|
|
42
51
|
// Build all resources (but don't copy yet)
|
|
43
|
-
resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) => {
|
|
52
|
+
resourceFiles.forEach(({ componentName, componentFile, entry, output, buildOutDir }, index) => {
|
|
44
53
|
console.log(`[${index + 1}/${resourceFiles.length}] Building ${output}...`);
|
|
45
54
|
|
|
46
55
|
try {
|
|
@@ -51,7 +60,7 @@ resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) =>
|
|
|
51
60
|
|
|
52
61
|
// Create entry file from template in temp directory
|
|
53
62
|
const entryContent = template
|
|
54
|
-
.replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${
|
|
63
|
+
.replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${componentFile}';`)
|
|
55
64
|
.replace('// RESOURCE_MOUNT', `createRoot(root).render(<${componentName} />);`);
|
|
56
65
|
|
|
57
66
|
const entryPath = path.join(__dirname, '..', entry);
|
|
@@ -104,5 +113,5 @@ if (existsSync(buildDir)) {
|
|
|
104
113
|
rmSync(buildDir, { recursive: true });
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
console.log('\n✓ All
|
|
116
|
+
console.log('\n✓ All resources built successfully!');
|
|
108
117
|
console.log(`\nBuilt files:`, readdirSync(distDir));
|
|
@@ -77,6 +77,12 @@ try {
|
|
|
77
77
|
console.log()
|
|
78
78
|
printSuccess('pnpm install');
|
|
79
79
|
|
|
80
|
+
console.log('\nRunning: pnpm format');
|
|
81
|
+
if (!runCommand('pnpm format', PROJECT_ROOT)) {
|
|
82
|
+
throw new Error('pnpm format failed');
|
|
83
|
+
}
|
|
84
|
+
printSuccess('pnpm format');
|
|
85
|
+
|
|
80
86
|
console.log('\nRunning: pnpm lint');
|
|
81
87
|
if (!runCommand('pnpm lint', PROJECT_ROOT)) {
|
|
82
88
|
throw new Error('pnpm lint failed');
|
|
@@ -124,8 +130,8 @@ try {
|
|
|
124
130
|
printSuccess('pnpm build');
|
|
125
131
|
|
|
126
132
|
// MCP Server Check
|
|
127
|
-
console.log('\nRunning: pnpm mcp');
|
|
128
|
-
const mcpProcess = spawn('pnpm', ['mcp'], {
|
|
133
|
+
console.log('\nRunning: pnpm mcp:serve');
|
|
134
|
+
const mcpProcess = spawn('pnpm', ['mcp:serve'], {
|
|
129
135
|
cwd: PROJECT_ROOT,
|
|
130
136
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
137
|
env: { ...process.env, FORCE_COLOR: '1' },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { AlbumCard } from './album-card';
|
|
4
|
+
import type { Album } from './albums';
|
|
5
|
+
|
|
6
|
+
describe('AlbumCard', () => {
|
|
7
|
+
const mockAlbum: Album = {
|
|
8
|
+
id: 'test-album',
|
|
9
|
+
title: 'Test Album',
|
|
10
|
+
cover: 'https://example.com/cover.jpg',
|
|
11
|
+
photos: [
|
|
12
|
+
{ id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' },
|
|
13
|
+
{ id: '2', title: 'Photo 2', url: 'https://example.com/2.jpg' },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
it('correctly pluralizes photo count', () => {
|
|
18
|
+
// Test plural (2 photos)
|
|
19
|
+
const { rerender } = render(<AlbumCard album={mockAlbum} />);
|
|
20
|
+
expect(screen.getByText('2 photos')).toBeInTheDocument();
|
|
21
|
+
|
|
22
|
+
// Test singular (1 photo)
|
|
23
|
+
const singlePhotoAlbum: Album = {
|
|
24
|
+
...mockAlbum,
|
|
25
|
+
photos: [{ id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' }],
|
|
26
|
+
};
|
|
27
|
+
rerender(<AlbumCard album={singlePhotoAlbum} />);
|
|
28
|
+
expect(screen.getByText('1 photo')).toBeInTheDocument();
|
|
29
|
+
|
|
30
|
+
// Test zero photos
|
|
31
|
+
const emptyAlbum: Album = {
|
|
32
|
+
...mockAlbum,
|
|
33
|
+
photos: [],
|
|
34
|
+
};
|
|
35
|
+
rerender(<AlbumCard album={emptyAlbum} />);
|
|
36
|
+
expect(screen.getByText('0 photos')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('calls onSelect with correct album when clicked', () => {
|
|
40
|
+
const onSelect = vi.fn();
|
|
41
|
+
render(<AlbumCard album={mockAlbum} onSelect={onSelect} />);
|
|
42
|
+
|
|
43
|
+
const card = screen.getByRole('button');
|
|
44
|
+
fireEvent.click(card);
|
|
45
|
+
|
|
46
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
47
|
+
expect(onSelect).toHaveBeenCalledWith(mockAlbum);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders album title and cover image with correct attributes', () => {
|
|
51
|
+
render(<AlbumCard album={mockAlbum} />);
|
|
52
|
+
|
|
53
|
+
// Check title is displayed
|
|
54
|
+
expect(screen.getByText('Test Album')).toBeInTheDocument();
|
|
55
|
+
|
|
56
|
+
// Check image has correct src and alt
|
|
57
|
+
const image = screen.getByRole('img');
|
|
58
|
+
expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
|
|
59
|
+
expect(image).toHaveAttribute('alt', 'Test Album');
|
|
60
|
+
expect(image).toHaveAttribute('loading', 'lazy');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import * as React from
|
|
2
|
-
import { Button } from
|
|
3
|
-
import { cn } from
|
|
4
|
-
import type { Album } from
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
3
|
+
import { cn } from '../../lib/index';
|
|
4
|
+
import type { Album } from './albums';
|
|
5
5
|
|
|
6
6
|
export type AlbumCardProps = {
|
|
7
|
-
album: Album
|
|
8
|
-
onSelect?: (album: Album) => void
|
|
9
|
-
className?: string
|
|
10
|
-
}
|
|
7
|
+
album: Album;
|
|
8
|
+
onSelect?: (album: Album) => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
11
|
|
|
12
12
|
export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
|
|
13
13
|
({ album, onSelect, className }, ref) => {
|
|
@@ -17,7 +17,7 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
|
|
|
17
17
|
variant="ghost"
|
|
18
18
|
color="secondary"
|
|
19
19
|
className={cn(
|
|
20
|
-
|
|
20
|
+
'rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer',
|
|
21
21
|
className
|
|
22
22
|
)}
|
|
23
23
|
onClick={() => onSelect?.(album)}
|
|
@@ -31,15 +31,13 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
|
|
|
31
31
|
/>
|
|
32
32
|
</div>
|
|
33
33
|
<div className="flex-shrink-0 w-full p-2">
|
|
34
|
-
<div className="text-base font-normal text-primary">
|
|
35
|
-
{album.title}
|
|
36
|
-
</div>
|
|
34
|
+
<div className="text-base font-normal text-primary">{album.title}</div>
|
|
37
35
|
<div className="text-sm text-secondary">
|
|
38
|
-
{album.photos.length} {album.photos.length === 1 ?
|
|
36
|
+
{album.photos.length} {album.photos.length === 1 ? 'photo' : 'photos'}
|
|
39
37
|
</div>
|
|
40
38
|
</div>
|
|
41
39
|
</Button>
|
|
42
|
-
)
|
|
40
|
+
);
|
|
43
41
|
}
|
|
44
|
-
)
|
|
45
|
-
AlbumCard.displayName =
|
|
42
|
+
);
|
|
43
|
+
AlbumCard.displayName = 'AlbumCard';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { Albums, type AlbumsData } from './albums';
|
|
4
|
+
|
|
5
|
+
// Mock sunpeak hooks
|
|
6
|
+
const mockSetWidgetState = vi.fn();
|
|
7
|
+
const mockRequestDisplayMode = vi.fn();
|
|
8
|
+
let mockWidgetData: AlbumsData = { albums: [] };
|
|
9
|
+
|
|
10
|
+
vi.mock('sunpeak', () => ({
|
|
11
|
+
useWidgetProps: () => mockWidgetData,
|
|
12
|
+
useWidgetState: () => [{ selectedAlbumId: null }, mockSetWidgetState],
|
|
13
|
+
useDisplayMode: () => 'default',
|
|
14
|
+
useWidgetAPI: () => ({ requestDisplayMode: mockRequestDisplayMode }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock child components to simplify testing
|
|
18
|
+
vi.mock('./fullscreen-viewer', () => ({
|
|
19
|
+
FullscreenViewer: ({ album }: { album: { title: string } }) => (
|
|
20
|
+
<div data-testid="fullscreen-viewer">{album.title}</div>
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../carousel', () => ({
|
|
25
|
+
Carousel: ({ children }: { children: React.ReactNode }) => (
|
|
26
|
+
<div data-testid="carousel">{children}</div>
|
|
27
|
+
),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
describe('Albums', () => {
|
|
31
|
+
const mockAlbums = [
|
|
32
|
+
{
|
|
33
|
+
id: 'album-1',
|
|
34
|
+
title: 'Summer Vacation',
|
|
35
|
+
cover: 'https://example.com/1.jpg',
|
|
36
|
+
photos: [{ id: 'p1', title: 'Beach', url: 'https://example.com/p1.jpg' }],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'album-2',
|
|
40
|
+
title: 'City Trip',
|
|
41
|
+
cover: 'https://example.com/2.jpg',
|
|
42
|
+
photos: [{ id: 'p2', title: 'Downtown', url: 'https://example.com/p2.jpg' }],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
mockWidgetData = { albums: mockAlbums };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders Carousel with all albums in default mode', () => {
|
|
52
|
+
render(<Albums />);
|
|
53
|
+
|
|
54
|
+
// Should render carousel
|
|
55
|
+
expect(screen.getByTestId('carousel')).toBeInTheDocument();
|
|
56
|
+
|
|
57
|
+
// Should render both album titles
|
|
58
|
+
expect(screen.getByText('Summer Vacation')).toBeInTheDocument();
|
|
59
|
+
expect(screen.getByText('City Trip')).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('calls setWidgetState and requestDisplayMode when album is selected', () => {
|
|
63
|
+
render(<Albums />);
|
|
64
|
+
|
|
65
|
+
// Find and click the first album
|
|
66
|
+
const firstAlbum = screen.getByText('Summer Vacation').closest('button')!;
|
|
67
|
+
fireEvent.click(firstAlbum);
|
|
68
|
+
|
|
69
|
+
// Should update widget state with selected album ID
|
|
70
|
+
expect(mockSetWidgetState).toHaveBeenCalledWith({ selectedAlbumId: 'album-1' });
|
|
71
|
+
|
|
72
|
+
// Should request fullscreen mode
|
|
73
|
+
expect(mockRequestDisplayMode).toHaveBeenCalledWith({ mode: 'fullscreen' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders empty carousel when no albums provided', () => {
|
|
77
|
+
mockWidgetData = { albums: [] };
|
|
78
|
+
|
|
79
|
+
const { container } = render(<Albums />);
|
|
80
|
+
|
|
81
|
+
// Should render carousel (even if empty)
|
|
82
|
+
expect(screen.getByTestId('carousel')).toBeInTheDocument();
|
|
83
|
+
|
|
84
|
+
// Should not render any album cards
|
|
85
|
+
const buttons = container.querySelectorAll('button');
|
|
86
|
+
expect(buttons.length).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -1,77 +1,63 @@
|
|
|
1
|
-
import * as React from
|
|
2
|
-
import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from
|
|
3
|
-
import { Carousel } from
|
|
4
|
-
import { AlbumCard } from
|
|
5
|
-
import { FullscreenViewer } from
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from 'sunpeak';
|
|
3
|
+
import { Carousel } from '../carousel';
|
|
4
|
+
import { AlbumCard } from './album-card';
|
|
5
|
+
import { FullscreenViewer } from './fullscreen-viewer';
|
|
6
6
|
|
|
7
7
|
export interface Album {
|
|
8
|
-
id: string
|
|
9
|
-
title: string
|
|
10
|
-
cover: string
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
cover: string;
|
|
11
11
|
photos: Array<{
|
|
12
|
-
id: string
|
|
13
|
-
title: string
|
|
14
|
-
url: string
|
|
15
|
-
}
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
url: string;
|
|
15
|
+
}>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface AlbumsData extends Record<string, unknown> {
|
|
19
|
-
albums: Album[]
|
|
19
|
+
albums: Album[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface AlbumsState extends Record<string, unknown> {
|
|
23
|
-
selectedAlbumId?: string | null
|
|
23
|
+
selectedAlbumId?: string | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export type AlbumsProps = {
|
|
27
|
-
className?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (displayMode === "fullscreen" && selectedAlbum) {
|
|
53
|
-
return (
|
|
54
|
-
<FullscreenViewer
|
|
55
|
-
ref={ref}
|
|
56
|
-
album={selectedAlbum}
|
|
57
|
-
/>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<div ref={ref} className={className}>
|
|
63
|
-
<Carousel
|
|
64
|
-
gap={20}
|
|
65
|
-
showArrows={false}
|
|
66
|
-
showEdgeGradients={false}
|
|
67
|
-
cardWidth={272}
|
|
68
|
-
>
|
|
69
|
-
{albums.map((album) => (
|
|
70
|
-
<AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
|
|
71
|
-
))}
|
|
72
|
-
</Carousel>
|
|
73
|
-
</div>
|
|
74
|
-
)
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className }, ref) => {
|
|
31
|
+
const data = useWidgetProps<AlbumsData>(() => ({ albums: [] }));
|
|
32
|
+
const [widgetState, setWidgetState] = useWidgetState<AlbumsState>(() => ({
|
|
33
|
+
selectedAlbumId: null,
|
|
34
|
+
}));
|
|
35
|
+
const displayMode = useDisplayMode();
|
|
36
|
+
const api = useWidgetAPI();
|
|
37
|
+
|
|
38
|
+
const albums = data.albums || [];
|
|
39
|
+
const selectedAlbum = albums.find((album) => album.id === widgetState?.selectedAlbumId);
|
|
40
|
+
|
|
41
|
+
const handleSelectAlbum = React.useCallback(
|
|
42
|
+
(album: Album) => {
|
|
43
|
+
setWidgetState({ selectedAlbumId: album.id });
|
|
44
|
+
api?.requestDisplayMode?.({ mode: 'fullscreen' });
|
|
45
|
+
},
|
|
46
|
+
[setWidgetState, api]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (displayMode === 'fullscreen' && selectedAlbum) {
|
|
50
|
+
return <FullscreenViewer ref={ref} album={selectedAlbum} />;
|
|
75
51
|
}
|
|
76
|
-
|
|
77
|
-
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div ref={ref} className={className}>
|
|
55
|
+
<Carousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
|
|
56
|
+
{albums.map((album) => (
|
|
57
|
+
<AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
|
|
58
|
+
))}
|
|
59
|
+
</Carousel>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
Albums.displayName = 'Albums';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { FilmStrip } from './film-strip';
|
|
4
|
+
import type { Album } from './albums';
|
|
5
|
+
|
|
6
|
+
describe('FilmStrip', () => {
|
|
7
|
+
const mockAlbum: Album = {
|
|
8
|
+
id: 'test-album',
|
|
9
|
+
title: 'Test Album',
|
|
10
|
+
cover: 'https://example.com/cover.jpg',
|
|
11
|
+
photos: [
|
|
12
|
+
{ id: '1', title: 'Sunset', url: 'https://example.com/1.jpg' },
|
|
13
|
+
{ id: '2', title: '', url: 'https://example.com/2.jpg' },
|
|
14
|
+
{ id: '3', title: 'Mountains', url: 'https://example.com/3.jpg' },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it('applies correct styling to selected photo', () => {
|
|
19
|
+
const { container } = render(<FilmStrip album={mockAlbum} selectedIndex={1} />);
|
|
20
|
+
|
|
21
|
+
const buttons = container.querySelectorAll('button');
|
|
22
|
+
|
|
23
|
+
// Selected photo (index 1) should have border-primary
|
|
24
|
+
expect(buttons[1].className).toContain('border-primary');
|
|
25
|
+
expect(buttons[1].className).toContain('shadow-md');
|
|
26
|
+
|
|
27
|
+
// Non-selected photos should have border-transparent
|
|
28
|
+
expect(buttons[0].className).toContain('border-transparent');
|
|
29
|
+
expect(buttons[0].className).toContain('opacity-60');
|
|
30
|
+
expect(buttons[2].className).toContain('border-transparent');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('calls onSelect with correct index when photo is clicked', () => {
|
|
34
|
+
const onSelect = vi.fn();
|
|
35
|
+
render(<FilmStrip album={mockAlbum} selectedIndex={0} onSelect={onSelect} />);
|
|
36
|
+
|
|
37
|
+
const images = screen.getAllByRole('img');
|
|
38
|
+
|
|
39
|
+
// Click on the second photo (index 1)
|
|
40
|
+
fireEvent.click(images[1].closest('button')!);
|
|
41
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(onSelect).toHaveBeenCalledWith(1);
|
|
43
|
+
|
|
44
|
+
// Click on the third photo (index 2)
|
|
45
|
+
fireEvent.click(images[2].closest('button')!);
|
|
46
|
+
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
47
|
+
expect(onSelect).toHaveBeenCalledWith(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders alt text with fallback for photos without titles', () => {
|
|
51
|
+
render(<FilmStrip album={mockAlbum} selectedIndex={0} />);
|
|
52
|
+
|
|
53
|
+
const images = screen.getAllByRole('img');
|
|
54
|
+
|
|
55
|
+
// Photo with title
|
|
56
|
+
expect(images[0]).toHaveAttribute('alt', 'Sunset');
|
|
57
|
+
|
|
58
|
+
// Photo without title should fall back to "Photo N"
|
|
59
|
+
expect(images[1]).toHaveAttribute('alt', 'Photo 2');
|
|
60
|
+
|
|
61
|
+
// Photo with title
|
|
62
|
+
expect(images[2]).toHaveAttribute('alt', 'Mountains');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import * as React from
|
|
2
|
-
import { Button } from
|
|
3
|
-
import { cn } from
|
|
4
|
-
import type { Album } from
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
3
|
+
import { cn } from '../../lib/index';
|
|
4
|
+
import type { Album } from './albums';
|
|
5
5
|
|
|
6
6
|
export type FilmStripProps = {
|
|
7
|
-
album: Album
|
|
8
|
-
selectedIndex: number
|
|
9
|
-
onSelect?: (index: number) => void
|
|
10
|
-
className?: string
|
|
11
|
-
}
|
|
7
|
+
album: Album;
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
onSelect?: (index: number) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
12
|
|
|
13
13
|
export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
|
|
14
14
|
({ album, selectedIndex, onSelect, className }, ref) => {
|
|
@@ -16,7 +16,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
|
|
|
16
16
|
<div
|
|
17
17
|
ref={ref}
|
|
18
18
|
className={cn(
|
|
19
|
-
|
|
19
|
+
'h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5',
|
|
20
20
|
className
|
|
21
21
|
)}
|
|
22
22
|
>
|
|
@@ -27,10 +27,10 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
|
|
|
27
27
|
color="secondary"
|
|
28
28
|
onClick={() => onSelect?.(idx)}
|
|
29
29
|
className={cn(
|
|
30
|
-
|
|
30
|
+
'block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all',
|
|
31
31
|
idx === selectedIndex
|
|
32
|
-
?
|
|
33
|
-
:
|
|
32
|
+
? 'border-primary shadow-md'
|
|
33
|
+
: 'border-transparent hover:border-primary/30 opacity-60 hover:opacity-100'
|
|
34
34
|
)}
|
|
35
35
|
>
|
|
36
36
|
<div className="aspect-[5/3] rounded-lg overflow-hidden w-full">
|
|
@@ -44,7 +44,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
|
|
|
44
44
|
</Button>
|
|
45
45
|
))}
|
|
46
46
|
</div>
|
|
47
|
-
)
|
|
47
|
+
);
|
|
48
48
|
}
|
|
49
|
-
)
|
|
50
|
-
FilmStrip.displayName =
|
|
49
|
+
);
|
|
50
|
+
FilmStrip.displayName = 'FilmStrip';
|