A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { useRef, useState } from 'react';
2import { validateCoverFile, type CoverUpload } from '../lib/media/cover';
3
4interface Props {
5 /** The currently chosen cover, or `null` when none is set. */
6 cover: CoverUpload | null;
7 /** Upload a chosen file to the PDS; resolves with the persistable ref + a preview URL. */
8 onUpload: ( file: File ) => Promise< CoverUpload >;
9 /** Report the new cover (or `null` when removed) to the parent. */
10 onChange: ( cover: CoverUpload | null ) => void;
11}
12
13/**
14 * The per-article cover image picker, shown below the editor (outside the block canvas).
15 * Reuses the same PDS upload path as content images via `onUpload`. Surfaces the 1 MB cap as
16 * helper text AND as the rejection message, and — when no cover is set — makes the
17 * "first body image" fallback visible (design 2026-06-10).
18 */
19export default function CoverImagePicker( { cover, onUpload, onChange }: Props ) {
20 const inputRef = useRef< HTMLInputElement >( null );
21 const [ uploading, setUploading ] = useState( false );
22 const [ error, setError ] = useState< string | null >( null );
23
24 async function handleFiles( files: FileList | null ) {
25 const file = files?.[ 0 ];
26 if ( ! file ) {
27 return;
28 }
29 const validationError = validateCoverFile( file );
30 if ( validationError ) {
31 setError( validationError );
32 return;
33 }
34 setError( null );
35 setUploading( true );
36 try {
37 const next = await onUpload( file );
38 onChange( next );
39 } catch ( err ) {
40 setError( err instanceof Error ? err.message : String( err ) );
41 } finally {
42 setUploading( false );
43 // Let the writer re-select the same file after a remove/replace.
44 if ( inputRef.current ) {
45 inputRef.current.value = '';
46 }
47 }
48 }
49
50 return (
51 <section className="studio__cover" aria-label="Cover image">
52 <span className="studio__cover-label">Cover image</span>
53
54 { cover ? (
55 <div className="studio__cover-preview">
56 <img className="studio__cover-image" src={ cover.previewUrl } alt="" />
57 <div className="studio__cover-actions">
58 <button
59 type="button"
60 onClick={ () => inputRef.current?.click() }
61 disabled={ uploading }
62 >
63 { uploading ? 'Uploading…' : 'Replace' }
64 </button>
65 <button
66 type="button"
67 onClick={ () => {
68 setError( null );
69 onChange( null );
70 } }
71 disabled={ uploading }
72 >
73 Remove
74 </button>
75 </div>
76 </div>
77 ) : (
78 <div className="studio__cover-empty">
79 <button
80 type="button"
81 onClick={ () => inputRef.current?.click() }
82 disabled={ uploading }
83 >
84 { uploading ? 'Uploading…' : 'Upload cover image' }
85 </button>
86 <p className="studio__cover-hint">
87 No cover set. If you don't add one, the first image in your
88 article will be used. PNG, JPG, or GIF, max 1 MB.
89 </p>
90 </div>
91 ) }
92
93 <input
94 ref={ inputRef }
95 className="studio__cover-input"
96 type="file"
97 accept="image/*"
98 hidden
99 onChange={ ( event ) => void handleFiles( event.target.files ) }
100 />
101
102 { error && (
103 <p className="studio__cover-error" role="alert">
104 { error }
105 </p>
106 ) }
107 </section>
108 );
109}