alpha
Login
or
Join now
jeremy.herve.bzh
/
skypress
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
A calm place to write long-form, and publish it to the open social web.
skypress.blog/
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
Add CoverImagePicker component
author
Jeremy Herve
date
2 weeks ago
(Jun 10, 2026, 9:12 AM +0200)
commit
741da3ef
741da3efc64feb65ac97cebf8173892dd2a598fa
parent
268aad89
268aad893a6455970b4705fa89b3aca5246e7e8e
+205
2 changed files
Expand all
Collapse all
Unified
Split
src
components
CoverImagePicker.test.tsx
CoverImagePicker.tsx
+96
src/components/CoverImagePicker.test.tsx
Reviewed
···
1
1
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
+
import { act, createElement } from 'react';
3
3
+
import { createRoot } from 'react-dom/client';
4
4
+
import type { CoverUpload } from '../lib/media/cover';
5
5
+
import CoverImagePicker from './CoverImagePicker';
6
6
+
7
7
+
( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true;
8
8
+
9
9
+
function mount( props: {
10
10
+
cover: CoverUpload | null;
11
11
+
onUpload: ( file: File ) => Promise< CoverUpload >;
12
12
+
onChange: ( cover: CoverUpload | null ) => void;
13
13
+
} ) {
14
14
+
const container = document.createElement( 'div' );
15
15
+
document.body.appendChild( container );
16
16
+
const root = createRoot( container );
17
17
+
act( () => {
18
18
+
root.render( createElement( CoverImagePicker, props ) );
19
19
+
} );
20
20
+
return {
21
21
+
container,
22
22
+
cleanup: () => {
23
23
+
root.unmount();
24
24
+
container.remove();
25
25
+
},
26
26
+
};
27
27
+
}
28
28
+
29
29
+
function setFiles( input: HTMLInputElement, files: File[] ) {
30
30
+
Object.defineProperty( input, 'files', { value: files, configurable: true } );
31
31
+
input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
32
32
+
}
33
33
+
34
34
+
const onUpload = vi.fn< ( file: File ) => Promise< CoverUpload > >();
35
35
+
const onChange = vi.fn();
36
36
+
37
37
+
beforeEach( () => {
38
38
+
onUpload.mockReset();
39
39
+
onChange.mockReset();
40
40
+
} );
41
41
+
42
42
+
describe( 'CoverImagePicker', () => {
43
43
+
it( 'shows the empty state with the fallback hint and the 1 MB cap', () => {
44
44
+
const { container, cleanup } = mount( { cover: null, onUpload, onChange } );
45
45
+
expect( container.textContent ).toMatch( /first image in your article/i );
46
46
+
expect( container.textContent ).toMatch( /1 MB/i );
47
47
+
cleanup();
48
48
+
} );
49
49
+
50
50
+
it( 'renders a preview and a Remove control when a cover is set', () => {
51
51
+
const cover: CoverUpload = {
52
52
+
ref: { $type: 'blob', ref: { $link: 'bafyc' }, mimeType: 'image/png', size: 10 },
53
53
+
previewUrl: 'data:image/png;base64,AAA',
54
54
+
};
55
55
+
const { container, cleanup } = mount( { cover, onUpload, onChange } );
56
56
+
const img = container.querySelector( 'img' )!;
57
57
+
expect( img.getAttribute( 'src' ) ).toBe( 'data:image/png;base64,AAA' );
58
58
+
const remove = Array.from( container.querySelectorAll( 'button' ) ).find(
59
59
+
( b ) => b.textContent === 'Remove'
60
60
+
)!;
61
61
+
act( () => {
62
62
+
remove.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) );
63
63
+
} );
64
64
+
expect( onChange ).toHaveBeenCalledWith( null );
65
65
+
cleanup();
66
66
+
} );
67
67
+
68
68
+
it( 'rejects an oversize file inline without uploading', () => {
69
69
+
const { container, cleanup } = mount( { cover: null, onUpload, onChange } );
70
70
+
const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement;
71
71
+
const big = new File( [ new Uint8Array( 1_000_001 ) ], 'big.png', { type: 'image/png' } );
72
72
+
act( () => {
73
73
+
setFiles( input, [ big ] );
74
74
+
} );
75
75
+
expect( container.textContent ).toMatch( /too large/i );
76
76
+
expect( onUpload ).not.toHaveBeenCalled();
77
77
+
cleanup();
78
78
+
} );
79
79
+
80
80
+
it( 'uploads a valid file and reports the result via onChange', async () => {
81
81
+
const result: CoverUpload = {
82
82
+
ref: { $type: 'blob', ref: { $link: 'bafynew' }, mimeType: 'image/png', size: 20 },
83
83
+
previewUrl: 'data:image/png;base64,BBB',
84
84
+
};
85
85
+
onUpload.mockResolvedValue( result );
86
86
+
const { container, cleanup } = mount( { cover: null, onUpload, onChange } );
87
87
+
const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement;
88
88
+
const ok = new File( [ 'x' ], 'ok.png', { type: 'image/png' } );
89
89
+
await act( async () => {
90
90
+
setFiles( input, [ ok ] );
91
91
+
} );
92
92
+
expect( onUpload ).toHaveBeenCalledTimes( 1 );
93
93
+
expect( onChange ).toHaveBeenCalledWith( result );
94
94
+
cleanup();
95
95
+
} );
96
96
+
} );
+109
src/components/CoverImagePicker.tsx
Reviewed
···
1
1
+
import { useRef, useState } from 'react';
2
2
+
import { validateCoverFile, type CoverUpload } from '../lib/media/cover';
3
3
+
4
4
+
interface Props {
5
5
+
/** The currently chosen cover, or `null` when none is set. */
6
6
+
cover: CoverUpload | null;
7
7
+
/** Upload a chosen file to the PDS; resolves with the persistable ref + a preview URL. */
8
8
+
onUpload: ( file: File ) => Promise< CoverUpload >;
9
9
+
/** Report the new cover (or `null` when removed) to the parent. */
10
10
+
onChange: ( cover: CoverUpload | null ) => void;
11
11
+
}
12
12
+
13
13
+
/**
14
14
+
* The per-article cover image picker, shown below the editor (outside the block canvas).
15
15
+
* Reuses the same PDS upload path as content images via `onUpload`. Surfaces the 1 MB cap as
16
16
+
* helper text AND as the rejection message, and — when no cover is set — makes the
17
17
+
* "first body image" fallback visible (design 2026-06-10).
18
18
+
*/
19
19
+
export default function CoverImagePicker( { cover, onUpload, onChange }: Props ) {
20
20
+
const inputRef = useRef< HTMLInputElement >( null );
21
21
+
const [ uploading, setUploading ] = useState( false );
22
22
+
const [ error, setError ] = useState< string | null >( null );
23
23
+
24
24
+
async function handleFiles( files: FileList | null ) {
25
25
+
const file = files?.[ 0 ];
26
26
+
if ( ! file ) {
27
27
+
return;
28
28
+
}
29
29
+
const validationError = validateCoverFile( file );
30
30
+
if ( validationError ) {
31
31
+
setError( validationError );
32
32
+
return;
33
33
+
}
34
34
+
setError( null );
35
35
+
setUploading( true );
36
36
+
try {
37
37
+
const next = await onUpload( file );
38
38
+
onChange( next );
39
39
+
} catch ( err ) {
40
40
+
setError( err instanceof Error ? err.message : String( err ) );
41
41
+
} finally {
42
42
+
setUploading( false );
43
43
+
// Let the writer re-select the same file after a remove/replace.
44
44
+
if ( inputRef.current ) {
45
45
+
inputRef.current.value = '';
46
46
+
}
47
47
+
}
48
48
+
}
49
49
+
50
50
+
return (
51
51
+
<section className="studio__cover" aria-label="Cover image">
52
52
+
<span className="studio__cover-label">Cover image</span>
53
53
+
54
54
+
{ cover ? (
55
55
+
<div className="studio__cover-preview">
56
56
+
<img className="studio__cover-image" src={ cover.previewUrl } alt="" />
57
57
+
<div className="studio__cover-actions">
58
58
+
<button
59
59
+
type="button"
60
60
+
onClick={ () => inputRef.current?.click() }
61
61
+
disabled={ uploading }
62
62
+
>
63
63
+
{ uploading ? 'Uploading…' : 'Replace' }
64
64
+
</button>
65
65
+
<button
66
66
+
type="button"
67
67
+
onClick={ () => {
68
68
+
setError( null );
69
69
+
onChange( null );
70
70
+
} }
71
71
+
disabled={ uploading }
72
72
+
>
73
73
+
Remove
74
74
+
</button>
75
75
+
</div>
76
76
+
</div>
77
77
+
) : (
78
78
+
<div className="studio__cover-empty">
79
79
+
<button
80
80
+
type="button"
81
81
+
onClick={ () => inputRef.current?.click() }
82
82
+
disabled={ uploading }
83
83
+
>
84
84
+
{ uploading ? 'Uploading…' : 'Upload cover image' }
85
85
+
</button>
86
86
+
<p className="studio__cover-hint">
87
87
+
No cover set — the first image in your article will be used. PNG, JPG,
88
88
+
or GIF, max 1 MB.
89
89
+
</p>
90
90
+
</div>
91
91
+
) }
92
92
+
93
93
+
<input
94
94
+
ref={ inputRef }
95
95
+
className="studio__cover-input"
96
96
+
type="file"
97
97
+
accept="image/*"
98
98
+
hidden
99
99
+
onChange={ ( event ) => void handleFiles( event.target.files ) }
100
100
+
/>
101
101
+
102
102
+
{ error && (
103
103
+
<p className="studio__cover-error" role="alert">
104
104
+
{ error }
105
105
+
</p>
106
106
+
) }
107
107
+
</section>
108
108
+
);
109
109
+
}