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 draft store that survives the OAuth redirect

+137
+54
src/lib/write/draft-store.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { createDraftStore, type WriteDraft } from './draft-store'; 3 + import { createMemoryAssetStore } from './asset-store'; 4 + import type { BlockNode } from '../blocks/render'; 5 + 6 + const DATA = 'data:image/png;base64,QUFB'; 7 + 8 + function draft(): WriteDraft { 9 + return { 10 + title: 'Hello', 11 + lede: 'A lede', 12 + blocks: [ 13 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 14 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 15 + ] as BlockNode[], 16 + coverDataUrl: DATA, 17 + }; 18 + } 19 + 20 + beforeEach( () => window.localStorage.clear() ); 21 + 22 + function newStore() { 23 + return createDraftStore( { assets: createMemoryAssetStore(), storage: window.localStorage } ); 24 + } 25 + 26 + describe( 'draft-store', () => { 27 + it( 'round-trips a draft including held image + cover bytes', async () => { 28 + const store = newStore(); 29 + await store.save( draft() ); 30 + // Image bytes must NOT sit in localStorage (only the token skeleton). 31 + expect( window.localStorage.getItem( 'skypress:write:draft' ) ).not.toContain( 'base64' ); 32 + const loaded = await store.load(); 33 + expect( loaded ).toEqual( draft() ); 34 + } ); 35 + 36 + it( 'returns null when nothing is saved', async () => { 37 + expect( await newStore().load() ).toBe( null ); 38 + } ); 39 + 40 + it( 'clear() removes the draft and its assets', async () => { 41 + const store = newStore(); 42 + await store.save( draft() ); 43 + await store.clear(); 44 + expect( await store.load() ).toBe( null ); 45 + } ); 46 + 47 + it( 'publish intent is one-shot (set, then consumed once)', () => { 48 + const store = newStore(); 49 + expect( store.consumePublishIntent() ).toBe( false ); 50 + store.setPublishIntent(); 51 + expect( store.consumePublishIntent() ).toBe( true ); 52 + expect( store.consumePublishIntent() ).toBe( false ); 53 + } ); 54 + } );
+83
src/lib/write/draft-store.ts
··· 1 + import type { BlockNode } from '../blocks/render'; 2 + import { splitAssets, mergeAssets, type AssetSkeleton } from './held-assets'; 3 + import { 4 + createIndexedDbAssetStore, 5 + createMemoryAssetStore, 6 + type AssetStore, 7 + } from './asset-store'; 8 + 9 + const DRAFT_KEY = 'skypress:write:draft'; 10 + const INTENT_KEY = 'skypress:write:publish-intent'; 11 + 12 + /** The editor content the writing-first flow persists across the OAuth redirect. */ 13 + export interface WriteDraft { 14 + title: string; 15 + lede: string; 16 + blocks: BlockNode[]; 17 + coverDataUrl: string | null; 18 + } 19 + 20 + interface StoredMeta { 21 + title: string; 22 + lede: string; 23 + skeleton: AssetSkeleton; 24 + } 25 + 26 + export interface DraftStore { 27 + save( draft: WriteDraft ): Promise< void >; 28 + load(): Promise< WriteDraft | null >; 29 + clear(): Promise< void >; 30 + setPublishIntent(): void; 31 + /** Reads the intent flag and clears it — true at most once per set. */ 32 + consumePublishIntent(): boolean; 33 + } 34 + 35 + /** 36 + * Persist the writing-first draft so it survives the full-page OAuth redirect. Light metadata 37 + * and the token-skeletoned block tree go in `localStorage`; the heavy image bytes (data: URLs) 38 + * go in the injected `AssetStore` (IndexedDB in the browser). `setPublishIntent` records that 39 + * the writer hit Publish before the redirect, so the flow auto-resumes on return. 40 + */ 41 + export function createDraftStore( opts: { assets?: AssetStore; storage?: Storage } = {} ): DraftStore { 42 + const storage = opts.storage ?? window.localStorage; 43 + const assets = opts.assets ?? createIndexedDbAssetStore(); 44 + 45 + return { 46 + async save( draft ) { 47 + const { skeleton, assets: bytes } = splitAssets( draft.blocks, draft.coverDataUrl ); 48 + await assets.clear(); 49 + await assets.put( bytes ); 50 + const meta: StoredMeta = { title: draft.title, lede: draft.lede, skeleton }; 51 + storage.setItem( DRAFT_KEY, JSON.stringify( meta ) ); 52 + }, 53 + async load() { 54 + const raw = storage.getItem( DRAFT_KEY ); 55 + if ( ! raw ) { 56 + return null; 57 + } 58 + let meta: StoredMeta; 59 + try { 60 + meta = JSON.parse( raw ) as StoredMeta; 61 + } catch { 62 + return null; 63 + } 64 + const bytes = await assets.getAll(); 65 + const { blocks, coverDataUrl } = mergeAssets( meta.skeleton, bytes ); 66 + return { title: meta.title, lede: meta.lede, blocks, coverDataUrl }; 67 + }, 68 + async clear() { 69 + storage.removeItem( DRAFT_KEY ); 70 + await assets.clear(); 71 + }, 72 + setPublishIntent() { 73 + storage.setItem( INTENT_KEY, '1' ); 74 + }, 75 + consumePublishIntent() { 76 + const had = storage.getItem( INTENT_KEY ) === '1'; 77 + storage.removeItem( INTENT_KEY ); 78 + return had; 79 + }, 80 + }; 81 + } 82 + 83 + export { createMemoryAssetStore };