A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add CoverImagePicker component

+205
+96
src/components/CoverImagePicker.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + import type { CoverUpload } from '../lib/media/cover'; 5 + import CoverImagePicker from './CoverImagePicker'; 6 + 7 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 8 + 9 + function mount( props: { 10 + cover: CoverUpload | null; 11 + onUpload: ( file: File ) => Promise< CoverUpload >; 12 + onChange: ( cover: CoverUpload | null ) => void; 13 + } ) { 14 + const container = document.createElement( 'div' ); 15 + document.body.appendChild( container ); 16 + const root = createRoot( container ); 17 + act( () => { 18 + root.render( createElement( CoverImagePicker, props ) ); 19 + } ); 20 + return { 21 + container, 22 + cleanup: () => { 23 + root.unmount(); 24 + container.remove(); 25 + }, 26 + }; 27 + } 28 + 29 + function setFiles( input: HTMLInputElement, files: File[] ) { 30 + Object.defineProperty( input, 'files', { value: files, configurable: true } ); 31 + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); 32 + } 33 + 34 + const onUpload = vi.fn< ( file: File ) => Promise< CoverUpload > >(); 35 + const onChange = vi.fn(); 36 + 37 + beforeEach( () => { 38 + onUpload.mockReset(); 39 + onChange.mockReset(); 40 + } ); 41 + 42 + describe( 'CoverImagePicker', () => { 43 + it( 'shows the empty state with the fallback hint and the 1 MB cap', () => { 44 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 45 + expect( container.textContent ).toMatch( /first image in your article/i ); 46 + expect( container.textContent ).toMatch( /1 MB/i ); 47 + cleanup(); 48 + } ); 49 + 50 + it( 'renders a preview and a Remove control when a cover is set', () => { 51 + const cover: CoverUpload = { 52 + ref: { $type: 'blob', ref: { $link: 'bafyc' }, mimeType: 'image/png', size: 10 }, 53 + previewUrl: 'data:image/png;base64,AAA', 54 + }; 55 + const { container, cleanup } = mount( { cover, onUpload, onChange } ); 56 + const img = container.querySelector( 'img' )!; 57 + expect( img.getAttribute( 'src' ) ).toBe( 'data:image/png;base64,AAA' ); 58 + const remove = Array.from( container.querySelectorAll( 'button' ) ).find( 59 + ( b ) => b.textContent === 'Remove' 60 + )!; 61 + act( () => { 62 + remove.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 63 + } ); 64 + expect( onChange ).toHaveBeenCalledWith( null ); 65 + cleanup(); 66 + } ); 67 + 68 + it( 'rejects an oversize file inline without uploading', () => { 69 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 70 + const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement; 71 + const big = new File( [ new Uint8Array( 1_000_001 ) ], 'big.png', { type: 'image/png' } ); 72 + act( () => { 73 + setFiles( input, [ big ] ); 74 + } ); 75 + expect( container.textContent ).toMatch( /too large/i ); 76 + expect( onUpload ).not.toHaveBeenCalled(); 77 + cleanup(); 78 + } ); 79 + 80 + it( 'uploads a valid file and reports the result via onChange', async () => { 81 + const result: CoverUpload = { 82 + ref: { $type: 'blob', ref: { $link: 'bafynew' }, mimeType: 'image/png', size: 20 }, 83 + previewUrl: 'data:image/png;base64,BBB', 84 + }; 85 + onUpload.mockResolvedValue( result ); 86 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 87 + const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement; 88 + const ok = new File( [ 'x' ], 'ok.png', { type: 'image/png' } ); 89 + await act( async () => { 90 + setFiles( input, [ ok ] ); 91 + } ); 92 + expect( onUpload ).toHaveBeenCalledTimes( 1 ); 93 + expect( onChange ).toHaveBeenCalledWith( result ); 94 + cleanup(); 95 + } ); 96 + } );
+109
src/components/CoverImagePicker.tsx
··· 1 + import { useRef, useState } from 'react'; 2 + import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 3 + 4 + interface 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 + */ 19 + export 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 — the first image in your article will be used. PNG, JPG, 88 + 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 + }