sunpeak 0.5.8 → 0.5.9
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 +12 -10
- package/bin/sunpeak.js +3 -3
- package/dist/chatgpt/theme-provider.d.ts +2 -2
- package/dist/index.cjs +5 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -14
- 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/package.json +2 -1
- package/template/README.md +1 -0
- package/template/dev/main.tsx +6 -10
- package/template/package.json +1 -0
- package/template/scripts/build-all.mjs +19 -10
- package/template/scripts/validate.mjs +6 -0
- 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
|
@@ -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');
|
|
@@ -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';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { FullscreenViewer } from './fullscreen-viewer';
|
|
4
|
+
import type { Album } from './albums';
|
|
5
|
+
|
|
6
|
+
// Mock sunpeak hooks
|
|
7
|
+
vi.mock('sunpeak', () => ({
|
|
8
|
+
useMaxHeight: () => 800,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('FullscreenViewer', () => {
|
|
12
|
+
const mockAlbum: Album = {
|
|
13
|
+
id: 'album-1',
|
|
14
|
+
title: 'Test Album',
|
|
15
|
+
cover: 'https://example.com/cover.jpg',
|
|
16
|
+
photos: [
|
|
17
|
+
{ id: '1', title: 'First Photo', url: 'https://example.com/1.jpg' },
|
|
18
|
+
{ id: '2', title: 'Second Photo', url: 'https://example.com/2.jpg' },
|
|
19
|
+
{ id: '3', title: 'Third Photo', url: 'https://example.com/3.jpg' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
it('resets to first photo when album changes', () => {
|
|
24
|
+
const { rerender, container } = render(<FullscreenViewer album={mockAlbum} />);
|
|
25
|
+
|
|
26
|
+
// Get the main photo area (not the film strip)
|
|
27
|
+
const mainPhotoArea = container.querySelector('.flex-1.min-w-0');
|
|
28
|
+
let mainPhoto = mainPhotoArea?.querySelector('img');
|
|
29
|
+
expect(mainPhoto).toHaveAttribute('alt', 'First Photo');
|
|
30
|
+
expect(mainPhoto).toHaveAttribute('src', 'https://example.com/1.jpg');
|
|
31
|
+
|
|
32
|
+
// Create a different album
|
|
33
|
+
const differentAlbum: Album = {
|
|
34
|
+
id: 'album-2',
|
|
35
|
+
title: 'Different Album',
|
|
36
|
+
cover: 'https://example.com/cover2.jpg',
|
|
37
|
+
photos: [
|
|
38
|
+
{ id: '4', title: 'New First Photo', url: 'https://example.com/4.jpg' },
|
|
39
|
+
{ id: '5', title: 'New Second Photo', url: 'https://example.com/5.jpg' },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Rerender with different album
|
|
44
|
+
rerender(<FullscreenViewer album={differentAlbum} />);
|
|
45
|
+
|
|
46
|
+
// Should show the first photo of the new album
|
|
47
|
+
mainPhoto = mainPhotoArea?.querySelector('img');
|
|
48
|
+
expect(mainPhoto).toHaveAttribute('alt', 'New First Photo');
|
|
49
|
+
expect(mainPhoto).toHaveAttribute('src', 'https://example.com/4.jpg');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('displays correct photo based on selected index from FilmStrip', () => {
|
|
53
|
+
const { container } = render(<FullscreenViewer album={mockAlbum} />);
|
|
54
|
+
|
|
55
|
+
// Get the main photo (not from film strip)
|
|
56
|
+
const mainPhotoArea = container.querySelector('.flex-1.min-w-0');
|
|
57
|
+
const firstPhoto = mainPhotoArea?.querySelector('img');
|
|
58
|
+
|
|
59
|
+
expect(firstPhoto).toHaveAttribute('alt', 'First Photo');
|
|
60
|
+
expect(firstPhoto).toHaveAttribute('src', 'https://example.com/1.jpg');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles empty photos array gracefully', () => {
|
|
64
|
+
const emptyAlbum: Album = {
|
|
65
|
+
id: 'empty-album',
|
|
66
|
+
title: 'Empty Album',
|
|
67
|
+
cover: 'https://example.com/cover.jpg',
|
|
68
|
+
photos: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const { container } = render(<FullscreenViewer album={emptyAlbum} />);
|
|
72
|
+
|
|
73
|
+
// Should not render any img element in the main photo area
|
|
74
|
+
const images = container.querySelectorAll('img');
|
|
75
|
+
expect(images.length).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|