diff --git a/backend/src/index.ts b/backend/src/index.ts index ee82eb5a..7c820c44 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { handleV2BatchFeedFetch, handleV2FeedDiscover, handleV2BatchDocumentFetch, + handleV2GetDocument, handleV2SocialContext, handleV2Mentions, handleV2MentionLane, @@ -225,6 +226,10 @@ export default { if (!session) return unauthorizedResponse(headers); response = await handleV2BatchDocumentFetch(request, env, session); break; + case url.pathname === '/api/v2/documents/get': + if (!session) return unauthorizedResponse(headers); + response = await handleV2GetDocument(request, env, session); + break; case url.pathname === '/api/v2/social-context': if (!session) return unauthorizedResponse(headers); response = await handleV2SocialContext(request, env); diff --git a/backend/src/routes/feeds-v2.ts b/backend/src/routes/feeds-v2.ts index 1b817120..713b69a7 100644 --- a/backend/src/routes/feeds-v2.ts +++ b/backend/src/routes/feeds-v2.ts @@ -445,6 +445,61 @@ export async function handleV2BatchDocumentFetch( } } +/** + * GET /api/v2/documents/get?uri=at://... + * + * On-demand fetch of a single standard.site document — the in-app reader path + * for a curated Collection piece whose author the user doesn't subscribe to (so + * it's in no batch response). Thin pass-through to the proxy, then the same + * per-user read annotation the batch path applies (item_type 'document'). + */ +export async function handleV2GetDocument( + request: Request, + env: Env, + session: Session +): Promise { + if (request.method !== 'GET') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const uri = new URL(request.url).searchParams.get('uri'); + if (!uri || !uri.startsWith('at://')) { + return new Response(JSON.stringify({ error: 'Missing or invalid uri' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + const client = new FeedProxyClient(env); + const document = await client.fetchDocument(uri); + if (!document) { + return new Response(JSON.stringify({ error: 'Document not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Stamp per-user read state, mirroring the batch path's annotation. + const readUris = await getReadKeys(env, session.did, 'document', [document.recordUri]); + document.read = readUris.has(document.recordUri); + + return new Response(JSON.stringify({ document }), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('V2 get document error:', error); + const message = error instanceof Error ? error.message : 'Proxy fetch failed'; + return new Response(JSON.stringify({ error: message }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + /** * POST /api/v2/social-context * diff --git a/backend/src/services/feed-proxy-client.ts b/backend/src/services/feed-proxy-client.ts index 47df7ff1..a67dc1cb 100644 --- a/backend/src/services/feed-proxy-client.ts +++ b/backend/src/services/feed-proxy-client.ts @@ -96,11 +96,54 @@ export interface ProxyDocument { indexedAt?: string; createdAt: string; siteIcon?: string; + // Present when the document is a Standard Reader "Collection" (curated edition). + // The proxy resolves each curated item to a preview; we forward it untouched. + readerCollection?: ProxyReaderCollection; // Stamped by handleV2BatchDocumentFetch from a per-user read join (item_type // 'document'). Not returned by the proxy itself — annotation only. read?: boolean; } +/** A publication's `basicTheme` palette (RGB triples), used by the magazine view. */ +export interface BasicTheme { + accent?: { r: number; g: number; b: number }; + background?: { r: number; g: number; b: number }; + foreground?: { r: number; g: number; b: number }; + accentForeground?: { r: number; g: number; b: number }; +} + +/** A curated collection item, resolved by the proxy to a renderable preview. */ +export interface ProxyReaderCollectionItem { + document: string; + note?: string; + authorDid?: string; + title?: string; + description?: string; + canonicalUrl?: string; + siteIcon?: string; + sourceName?: string; + publishedAt?: string; +} + +/** Google Font family names for a collections publication's typography. */ +export interface PublicationFonts { + title?: string; + body?: string; +} + +/** A resolved curated edition: editorial/colophon markdown + resolved items. + * `publicationName`/`theme`/`fonts`/`authorHandle` describe the edition's + * publication, consumed by the optional themed magazine view. */ +export interface ProxyReaderCollection { + editorial?: { title?: string; body?: string }; + colophon?: { body?: string }; + items: ProxyReaderCollectionItem[]; + publicationName?: string; + theme?: BasicTheme; + fonts?: PublicationFonts; + authorHandle?: string; +} + export interface ProxyDocumentEntry { did: string; siteUri?: string; @@ -463,6 +506,19 @@ export class FeedProxyClient { return raw.authors; } + /** + * Fetch a single standard.site document by its at:// URI (on-demand read of a + * curated Collection piece). Returns the resolved document, or null when the + * proxy can't resolve it (bad URI / missing record). + */ + async fetchDocument(uri: string): Promise { + const raw = await this.fetch<{ document?: ProxyDocument; error?: string }>( + `/document?uri=${encodeURIComponent(uri)}`, + { method: 'GET' } + ); + return raw.document ?? null; + } + /** * Fetch Constellation social context for a batch of link posts (Phase 3): * recommend/quote counts + "who else linked this article" (with handles + diff --git a/feed-proxy/src/app.ts b/feed-proxy/src/app.ts index 01e6cb9c..98e39c50 100644 --- a/feed-proxy/src/app.ts +++ b/feed-proxy/src/app.ts @@ -5,6 +5,7 @@ import { parseFeed } from './feed-parser'; import type { ParsedFeed, FeedItem } from './types'; import { fetchDocumentsForAuthor, + fetchSingleDocument, filterByPublication, digestScope, MAX_DOCUMENTS_PER_AUTHOR, @@ -786,6 +787,15 @@ export function initDatabase(db: Database): void { cached_at INTEGER NOT NULL ) `); + // Migration: add name + theme + fonts columns, all consumed by the optional + // magazine view of a curated Collection. `name` is the publication title, + // `theme` is the JSON `basicTheme` palette (colors), and `fonts` is the JSON + // `publicationTheme` typography (Google Font family names). + const pubColumns = db.query<{ name: string }, []>(`PRAGMA table_info(publication_cache)`).all(); + const pubColNames = new Set(pubColumns.map((c) => c.name)); + if (!pubColNames.has('name')) db.run(`ALTER TABLE publication_cache ADD COLUMN name TEXT`); + if (!pubColNames.has('theme')) db.run(`ALTER TABLE publication_cache ADD COLUMN theme TEXT`); + if (!pubColNames.has('fonts')) db.run(`ALTER TABLE publication_cache ADD COLUMN fonts TEXT`); // Per-author resolved standard.site documents. Same freshness/backoff shape as // `cache`, keyed by the author DID. Documents are stored unfiltered (full @@ -2164,6 +2174,26 @@ export function createApp(db: Database, config: AppConfig) { // Bulk standard.site document endpoint. Symmetric with /feeds, but keyed by // author DID instead of feed URL. Returns each requested author's documents // (scoped to a publication and trimmed to what the client hasn't seen yet). + // On-demand fetch of a single standard.site document by at:// URI. Serves + // pieces opened from a Collection edition whose authors the reader doesn't + // subscribe to (so they're in no cached author list). Resolved fresh. + app.get('/document', async (c) => { + if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const uri = c.req.query('uri'); + if (!uri || !uri.startsWith('at://')) { + return c.json({ error: 'Missing or invalid uri' }, 400); + } + + const document = await fetchSingleDocument(db, uri); + if (!document) { + return c.json({ error: 'Document not found' }, 404); + } + return c.json({ document }); + }); + app.post('/documents', async (c) => { if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) { return c.json({ error: 'Unauthorized' }, 401); diff --git a/feed-proxy/src/documents.test.ts b/feed-proxy/src/documents.test.ts index decfec89..7be80839 100644 --- a/feed-proxy/src/documents.test.ts +++ b/feed-proxy/src/documents.test.ts @@ -8,6 +8,8 @@ import { digestScope, parseAtUri, resolveSiteMeta, + recordToProxyDocument, + fetchSingleDocument, type ProxyDocument, } from './standard-site'; @@ -653,6 +655,11 @@ describe('POST /documents cache lifecycle', () => { ); } if (url.includes('com.atproto.repo.listRecords')) { + // The collection sidecar listing is a separate call; this test only + // exercises site.standard.document pagination, so ignore the rest. + if (new URL(url).searchParams.get('collection') !== 'site.standard.document') { + return new Response(JSON.stringify({ records: [] })); + } seenCursors.push(new URL(url).searchParams.get('cursor')); listCalls++; if (listCalls === 1) { @@ -765,6 +772,35 @@ describe('resolveSiteMeta caching', () => { expect(meta.baseUrl).toBe('https://blog.example.com'); expect(fetchMock.mock.calls.length).toBeGreaterThan(0); }); + + it('captures the publication name + basicTheme (and round-trips theme through the cache)', async () => { + const db = freshDb(); + const theme = { + accent: { r: 196, g: 33, b: 188 }, + background: { r: 255, g: 240, b: 254 }, + foreground: { r: 38, g: 4, b: 37 }, + accentForeground: { r: 255, g: 255, b: 255 }, + }; + fetchMock = mockAtprotoFetch({ + publication: { + $type: 'site.standard.publication', + url: 'https://blog.example.com', + name: 'Dispatches from the Atmosphere', + basicTheme: theme, + }, + }); + + const meta = await resolveSiteMeta(db, PUB_URI); + expect(meta.name).toBe('Dispatches from the Atmosphere'); + expect(meta.theme).toEqual(theme); + + // Second call hits the SQLite cache (no further fetch) and still parses theme. + fetchMock.mockClear(); + const cached = await resolveSiteMeta(db, PUB_URI); + expect(cached.name).toBe('Dispatches from the Atmosphere'); + expect(cached.theme).toEqual(theme); + expect(fetchMock.mock.calls.length).toBe(0); + }); }); describe('warmStaleDocuments', () => { @@ -856,3 +892,304 @@ describe('cleanupCache (documents)', () => { expect(remaining).toEqual(['recent']); }); }); + +describe('readerCollection resolution', () => { + afterEach(() => { + spyOn(globalThis, 'fetch').mockRestore(); + }); + + // Routes PLC resolution, the publication getRecord, and per-item document + // getRecords. `items` maps an item rkey → its document record value (or null + // for a 404), so a test can mix resolvable and unresolvable curated pieces. + function mockCollectionFetch( + items: Record | null>, + opts: { fonts?: { title?: string; body?: string }; basicTheme?: Record } = {} + ) { + return spyOn(globalThis, 'fetch').mockImplementation((async (input: unknown) => { + const url = String(input); + + if (url.startsWith('https://plc.directory/')) { + return new Response( + JSON.stringify({ + id: AUTHOR, + service: [ + { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: PDS }, + ], + }) + ); + } + + if (url.includes('com.atproto.repo.getRecord')) { + const params = new URLSearchParams(url.split('?')[1]); + const collection = params.get('collection'); + if (collection === 'site.standard.publication') { + return new Response( + JSON.stringify({ + value: { + $type: 'site.standard.publication', + url: 'https://blog.example.com', + name: 'Example Blog', + icon: { ref: { $link: 'iconcid' }, mimeType: 'image/jpeg' }, + ...(opts.basicTheme ? { basicTheme: opts.basicTheme } : {}), + }, + }) + ); + } + // The publication's typography sidecar (paired by rkey). + if (collection === 'app.standard-reader.publicationTheme') { + if (!opts.fonts) return new Response('not found', { status: 404 }); + return new Response( + JSON.stringify({ + value: { + $type: 'app.standard-reader.publicationTheme', + publication: PUB_URI, + fonts: opts.fonts, + }, + }) + ); + } + // A curated item's document record. + const rkey = params.get('rkey') ?? ''; + const value = items[rkey]; + if (!value) return new Response('not found', { status: 404 }); + return new Response(JSON.stringify({ value })); + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as unknown as typeof fetch); + } + + // Build a structured Markpub markdown body, the shape collection notes/editorial + // use on the PDS (the resolver flattens it to plain markdown). + function markpub(markdown: string) { + return { + text: { $type: 'at.markpub.text', markdown }, + $type: 'at.markpub.markdown', + flavor: 'gfm', + }; + } + + function itemUri(rkey: string) { + return `at://${AUTHOR}/site.standard.document/${rkey}`; + } + + it('resolves curated items to previews and carries editorial/colophon', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockCollectionFetch({ + good: { + $type: 'site.standard.document', + site: PUB_URI, + title: 'A Resolved Piece', + description: 'Its excerpt.', + path: '/a-resolved-piece', + publishedAt: '2026-06-01T00:00:00.000Z', + }, + }); + + const resolved = await recordToProxyDocument( + db, + AUTHOR, + itemUri('edition'), + 'cid-edition', + { + $type: 'site.standard.document', + site: PUB_URI, + title: 'My Edition', + publishedAt: '2026-06-10T00:00:00.000Z', + }, + // The paired app.standard-reader.collection sidecar. Notes/editorial are + // structured Markpub; the colophon is a legacy plain string — both flatten. + { + document: itemUri('edition'), + editorial: { title: 'A Word Before', body: markpub('The intro.') }, + colophon: { body: 'The closing.' }, + items: [ + { document: itemUri('good'), note: markpub('Read this one.') }, + { document: itemUri('missing'), note: markpub('Could not resolve.') }, + ], + createdAt: '2026-06-10T00:00:00.000Z', + } + ); + + const rc = resolved.readerCollection; + expect(rc).toBeDefined(); + expect(rc?.editorial?.title).toBe('A Word Before'); + expect(rc?.editorial?.body).toBe('The intro.'); + expect(rc?.colophon?.body).toBe('The closing.'); + expect(rc?.items).toHaveLength(2); + + // Resolved piece carries title + canonical URL + the (flattened) curator note + // + the referenced publication's name (the magazine TOC source label). + const good = rc!.items[0]; + expect(good.title).toBe('A Resolved Piece'); + expect(good.canonicalUrl).toBe('https://blog.example.com/a-resolved-piece'); + expect(good.note).toBe('Read this one.'); + expect(good.authorDid).toBe(AUTHOR); + expect(good.sourceName).toBe('Example Blog'); + + // The edition carries its own publication name (for the magazine masthead). + expect(rc?.publicationName).toBe('Example Blog'); + + // Unresolvable piece degrades to a note-only stub (the URI + note survive). + const missing = rc!.items[1]; + expect(missing.document).toBe(itemUri('missing')); + expect(missing.note).toBe('Could not resolve.'); + expect(missing.title).toBeUndefined(); + expect(missing.canonicalUrl).toBeUndefined(); + }); + + it('carries publication fonts (publicationTheme) onto the edition', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockCollectionFetch( + { + good: { + $type: 'site.standard.document', + site: PUB_URI, + title: 'A Resolved Piece', + path: '/p', + publishedAt: '2026-06-01T00:00:00.000Z', + }, + }, + { fonts: { title: 'Black Ops One', body: 'Space Grotesk' } } + ); + + const resolved = await recordToProxyDocument( + db, + AUTHOR, + itemUri('edition'), + 'cid-edition', + { + $type: 'site.standard.document', + site: PUB_URI, + title: 'My Edition', + publishedAt: '2026-06-10T00:00:00.000Z', + }, + { + document: itemUri('edition'), + items: [{ document: itemUri('good') }], + createdAt: '2026-06-10T00:00:00.000Z', + } + ); + + expect(resolved.readerCollection?.fonts).toEqual({ + title: 'Black Ops One', + body: 'Space Grotesk', + }); + }); + + it('leaves readerCollection undefined for ordinary documents (no paired collection)', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockCollectionFetch({}); + + const resolved = await recordToProxyDocument(db, AUTHOR, itemUri('plain'), 'cid-plain', { + $type: 'site.standard.document', + site: PUB_URI, + title: 'Just an article', + publishedAt: '2026-06-10T00:00:00.000Z', + }); + + expect(resolved.readerCollection).toBeUndefined(); + }); + + it('drops readerCollection when no items resolve (empty edition)', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockCollectionFetch({}); + + // An edition whose only item has no `document` URI resolves to nothing, so the + // collection is dropped and the doc renders as an ordinary body, not an empty + // edition card. + const resolved = await recordToProxyDocument( + db, + AUTHOR, + itemUri('edition'), + 'cid-edition', + { + $type: 'site.standard.document', + site: PUB_URI, + title: 'Empty Edition', + publishedAt: '2026-06-10T00:00:00.000Z', + }, + { + document: itemUri('edition'), + editorial: { body: markpub('Nothing made the cut.') }, + items: [{ note: markpub('A note with no document reference.') }], + createdAt: '2026-06-10T00:00:00.000Z', + } + ); + + expect(resolved.readerCollection).toBeUndefined(); + }); +}); + +describe('fetchSingleDocument', () => { + afterEach(() => { + spyOn(globalThis, 'fetch').mockRestore(); + }); + + function mockSingleFetch(doc: Record | null) { + return spyOn(globalThis, 'fetch').mockImplementation((async (input: unknown) => { + const url = String(input); + if (url.startsWith('https://plc.directory/')) { + return new Response( + JSON.stringify({ + id: AUTHOR, + service: [ + { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: PDS }, + ], + }) + ); + } + if (url.includes('com.atproto.repo.getRecord')) { + const params = new URLSearchParams(url.split('?')[1]); + if (params.get('collection') === 'site.standard.publication') { + return new Response( + JSON.stringify({ value: { url: 'https://blog.example.com', name: 'Example Blog' } }) + ); + } + if (!doc) return new Response('not found', { status: 404 }); + const fullUri = `at://${params.get('repo')}/${params.get('collection')}/${params.get('rkey')}`; + return new Response(JSON.stringify({ uri: fullUri, cid: 'cid-1', value: doc })); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as unknown as typeof fetch); + } + + const DOC_URI = `at://${AUTHOR}/site.standard.document/piece1`; + + it('resolves a document by at:// URI', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockSingleFetch({ + $type: 'site.standard.document', + site: PUB_URI, + title: 'On-demand Piece', + path: '/on-demand-piece', + publishedAt: '2026-06-01T00:00:00.000Z', + }); + + const doc = await fetchSingleDocument(db, DOC_URI); + expect(doc).not.toBeNull(); + expect(doc?.title).toBe('On-demand Piece'); + expect(doc?.recordUri).toBe(DOC_URI); + expect(doc?.canonicalUrl).toBe('https://blog.example.com/on-demand-piece'); + }); + + it('returns null for a non-document collection', async () => { + const db = new Database(':memory:'); + initDatabase(db); + const doc = await fetchSingleDocument(db, `at://${AUTHOR}/site.standard.publication/pub1`); + expect(doc).toBeNull(); + }); + + it('returns null when the record is missing', async () => { + const db = new Database(':memory:'); + initDatabase(db); + mockSingleFetch(null); + const doc = await fetchSingleDocument(db, DOC_URI); + expect(doc).toBeNull(); + }); +}); diff --git a/feed-proxy/src/standard-site.ts b/feed-proxy/src/standard-site.ts index 11c672b7..78550d81 100644 --- a/feed-proxy/src/standard-site.ts +++ b/feed-proxy/src/standard-site.ts @@ -9,7 +9,7 @@ */ import { Database } from 'bun:sqlite'; import { createHash } from 'node:crypto'; -import { resolvePdsUrl } from './did-resolver'; +import { resolvePdsUrl, resolveHandle } from './did-resolver'; import { safeFetch } from './ssrf-guard'; // Publication records (base URL + icon) change rarely; cache for a day. @@ -24,6 +24,15 @@ const PUBLICATION_NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000; export const MAX_DOCUMENTS_PER_AUTHOR = 100; const MAX_LIST_PAGES = 5; // listRecords pages of 100 → up to 500 scanned const FETCH_TIMEOUT_MS = 30 * 1000; +// A Standard Reader "Collection" (a site.standard.document carrying a +// `readerCollection`) curates other documents by at:// URI. We resolve each to a +// title/canonical-URL preview, bounded so a hostile or runaway edition can't fan +// out into unbounded cross-PDS fetches. +const MAX_COLLECTION_ITEMS = 50; +// Curated items can each live on a different PDS, and several editions may resolve +// in one batch fetch — so resolve through a small worker pool rather than firing +// all MAX_COLLECTION_ITEMS at once. +const COLLECTION_RESOLVE_CONCURRENCY = 8; /** * A resolved document in the exact shape the frontend's `SocialDocument` @@ -53,6 +62,84 @@ export interface ProxyDocument { // External resource refs (RFC-8288-style). A linkblog "link post" carries the // shared article's https URL here; the frontend renders it as a link post. links?: Array<{ uri: string; rel?: string }>; + // Standard Reader "Collection": a curated magazine edition. Each item points to + // another document; we resolve them to previews (see `ProxyReaderCollection`). + readerCollection?: ProxyReaderCollection; +} + +/** + * A Markpub markdown body as it lives on a record. The lexicons accept either a + * structured `at.markpub.markdown` object or a legacy plain string "on read + * during migration", so we tolerate both. `markpubToMarkdown` flattens it. + */ +export type MarkpubBody = string | { text?: { markdown?: string } } | undefined; + +/** + * Raw `app.standard-reader.collection` record (the curated-edition sidecar). It + * shares its rkey with the `site.standard.document` named by `document`, and the + * edition title comes from that document (the collection itself carries none). + */ +export interface CollectionRecord { + document?: string; + editorial?: { title?: string; body?: MarkpubBody }; + colophon?: { body?: MarkpubBody }; + items?: Array<{ document?: string; note?: MarkpubBody }>; + createdAt?: string; + updatedAt?: string; +} + +/** Flatten a Markpub body (structured object or legacy string) to markdown text. */ +export function markpubToMarkdown(body: MarkpubBody): string | undefined { + if (!body) return undefined; + if (typeof body === 'string') return body || undefined; + return body.text?.markdown || undefined; +} + +/** Google Font family names for a collections publication's typography. */ +export interface PublicationFonts { + title?: string; + body?: string; +} + +/** A publication's `basicTheme` palette — accent/background/foreground colors as + * raw RGB triples, used to paint the magazine view of a curated Collection. */ +export interface BasicTheme { + accent?: { r: number; g: number; b: number }; + background?: { r: number; g: number; b: number }; + foreground?: { r: number; g: number; b: number }; + accentForeground?: { r: number; g: number; b: number }; +} + +/** A curated item resolved to a preview the frontend can render without a second + * round-trip: the curator's `note` plus the referenced document's metadata. */ +export interface ProxyReaderCollectionItem { + /** The referenced document's at:// URI (or raw https URL for loose links). */ + document: string; + /** The curator's blurb for this piece (markdown). */ + note?: string; + /** Author DID of the referenced document, when it's an at:// URI. */ + authorDid?: string; + title?: string; + description?: string; + canonicalUrl?: string; + siteIcon?: string; + /** The referenced document's publication name (e.g. "Alex's Blog"), shown as + * the source label in the magazine TOC. Falls back to hostname in the UI. */ + sourceName?: string; + publishedAt?: string; +} + +/** Resolved curated edition: editorial/colophon markdown + resolved items. + * `publicationName`/`theme`/`fonts`/`authorHandle` describe the edition's own + * publication, used to render the optional themed magazine masthead. */ +export interface ProxyReaderCollection { + editorial?: { title?: string; body?: string }; + colophon?: { body?: string }; + items: ProxyReaderCollectionItem[]; + publicationName?: string; + theme?: BasicTheme; + fonts?: PublicationFonts; + authorHandle?: string; } interface BlobRef { @@ -83,6 +170,7 @@ interface PublicationRecord { name?: string; description?: string; icon?: BlobRef; + basicTheme?: BasicTheme; } interface ListRecordsResponse { @@ -100,9 +188,19 @@ interface PublicationCacheRow { publication_uri: string; base_url: string | null; icon: string | null; + name: string | null; + theme: string | null; + fonts: string | null; cached_at: number; } +/** A publication's typography record, paired to it by rkey. */ +interface PublicationThemeRecord { + $type?: string; + publication?: string; + fonts?: PublicationFonts; +} + /** Parse `at://did/collection/rkey` into its components, or null if malformed. */ export function parseAtUri(uri: string): ParsedAtUri | null { if (!uri.startsWith('at://')) return null; @@ -153,20 +251,26 @@ async function fetchRecord( * URI, caching the result in SQLite. Returns nulls for non-`at://` sites (loose * https:// documents) — those have no publication record to resolve. */ -export async function resolveSiteMeta( - db: Database, - siteUri: string -): Promise<{ baseUrl: string | null; icon: string | null }> { - if (!siteUri) return { baseUrl: null, icon: null }; +export interface SiteMeta { + baseUrl: string | null; + icon: string | null; + name: string | null; + theme: BasicTheme | null; + fonts: PublicationFonts | null; +} + +export async function resolveSiteMeta(db: Database, siteUri: string): Promise { + const empty: SiteMeta = { baseUrl: null, icon: null, name: null, theme: null, fonts: null }; + if (!siteUri) return empty; // Loose https:// sites are already their own base URL, no record to fetch. if (siteUri.startsWith('http://') || siteUri.startsWith('https://')) { - return { baseUrl: siteUri, icon: null }; + return { ...empty, baseUrl: siteUri }; } const parsed = parseAtUri(siteUri); if (!parsed || parsed.collection !== 'site.standard.publication') { - return { baseUrl: null, icon: null }; + return empty; } const now = Date.now(); @@ -174,18 +278,27 @@ export async function resolveSiteMeta( .query< PublicationCacheRow, [string] - >('SELECT publication_uri, base_url, icon, cached_at FROM publication_cache WHERE publication_uri = ?') + >('SELECT publication_uri, base_url, icon, name, theme, fonts, cached_at FROM publication_cache WHERE publication_uri = ?') .get(siteUri); if (cached) { const ttl = cached.base_url ? PUBLICATION_CACHE_TTL_MS : PUBLICATION_NEGATIVE_CACHE_TTL_MS; if (now - cached.cached_at < ttl) { - return { baseUrl: cached.base_url, icon: cached.icon }; + return { + baseUrl: cached.base_url, + icon: cached.icon, + name: cached.name, + theme: parseJsonColumn(cached.theme), + fonts: parseJsonColumn(cached.fonts), + }; } } const pdsUrl = await resolvePdsUrl(db, parsed.did); let baseUrl: string | null = null; let icon: string | null = null; + let name: string | null = null; + let theme: BasicTheme | null = null; + let fonts: PublicationFonts | null = null; if (pdsUrl) { const record = await fetchRecord( pdsUrl, @@ -195,15 +308,47 @@ export async function resolveSiteMeta( ); baseUrl = record?.url || null; icon = resolveBlobUrl(parsed.did, record?.icon); + name = record?.name || null; + theme = record?.basicTheme ?? null; + // Typography lives on a sidecar `publicationTheme` record paired by rkey. + // Best-effort: a missing record just means the magazine uses default fonts. + const themeRecord = await fetchRecord( + pdsUrl, + parsed.did, + 'app.standard-reader.publicationTheme', + parsed.rkey + ); + const f = themeRecord?.fonts; + if (f && (f.title || f.body)) + fonts = { title: f.title || undefined, body: f.body || undefined }; } db.run( - `INSERT INTO publication_cache (publication_uri, base_url, icon, cached_at) VALUES (?, ?, ?, ?) - ON CONFLICT(publication_uri) DO UPDATE SET base_url = excluded.base_url, icon = excluded.icon, cached_at = excluded.cached_at`, - [siteUri, baseUrl, icon, now] + `INSERT INTO publication_cache (publication_uri, base_url, icon, name, theme, fonts, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(publication_uri) DO UPDATE SET base_url = excluded.base_url, icon = excluded.icon, name = excluded.name, theme = excluded.theme, fonts = excluded.fonts, cached_at = excluded.cached_at`, + [ + siteUri, + baseUrl, + icon, + name, + theme ? JSON.stringify(theme) : null, + fonts ? JSON.stringify(fonts) : null, + now, + ] ); - return { baseUrl, icon }; + return { baseUrl, icon, name, theme, fonts }; +} + +/** Parse a cached JSON column, tolerating null / malformed rows (a bad row + * degrades to null rather than throwing). */ +function parseJsonColumn(raw: string | null): T | null { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } } function toISO(value: string | undefined, fallback: string): string { @@ -212,6 +357,115 @@ function toISO(value: string | undefined, fallback: string): string { return isNaN(ms) ? fallback : new Date(ms).toISOString(); } +/** + * Resolve one curated collection item (an at:// document URI + the curator's + * note) into a preview. Best-effort: a malformed URI, unresolvable PDS, or + * missing record degrades to a note-only stub (the frontend still shows the note + * and the raw link) rather than failing the whole collection. + */ +async function resolveCollectionItem( + db: Database, + item: { document?: string; note?: MarkpubBody } +): Promise { + const uri = item.document; + if (!uri) return null; + + const note = markpubToMarkdown(item.note); + + // Loose https:// references (rare): keep as a bare link, nothing to resolve. + if (uri.startsWith('http://') || uri.startsWith('https://')) { + return { document: uri, note, canonicalUrl: uri }; + } + + const parsed = parseAtUri(uri); + if (!parsed) return { document: uri, note }; + + const stub: ProxyReaderCollectionItem = { document: uri, note, authorDid: parsed.did }; + + try { + const pdsUrl = await resolvePdsUrl(db, parsed.did); + if (!pdsUrl) return stub; + const doc = await fetchRecord( + pdsUrl, + parsed.did, + parsed.collection, + parsed.rkey + ); + if (!doc) return stub; + + const meta = await resolveSiteMeta(db, doc.site || ''); + const canonicalUrl = meta.baseUrl ? buildCanonicalUrl(meta.baseUrl, doc.path || '') : undefined; + + return { + ...stub, + title: doc.title || undefined, + description: doc.description || undefined, + canonicalUrl: canonicalUrl || undefined, + siteIcon: meta.icon || undefined, + sourceName: meta.name || undefined, + publishedAt: doc.publishedAt || undefined, + }; + } catch (error) { + console.error('[standard-site] collection item resolve error:', error); + return stub; + } +} + +/** + * Resolve an `app.standard-reader.collection` record into renderable previews. + * Items are capped at `MAX_COLLECTION_ITEMS` and resolved through a + * `COLLECTION_RESOLVE_CONCURRENCY` worker pool (curated order preserved); each + * resolution is independently fault-tolerant. Returns null when there are no + * resolvable items. `editorial`/`colophon`/`note` bodies are Markpub (structured + * object or legacy string) and are flattened to markdown here. + */ +async function resolveReaderCollection( + db: Database, + raw: CollectionRecord, + edition: { + publicationName?: string; + theme?: BasicTheme; + fonts?: PublicationFonts; + authorHandle?: string; + } = {} +): Promise { + const rawItems = Array.isArray(raw.items) ? raw.items.slice(0, MAX_COLLECTION_ITEMS) : []; + + // Drain a shared cursor through a bounded pool, writing each result back by + // index so the curated order survives out-of-order completion. + const slots: Array = new Array(rawItems.length).fill(null); + let next = 0; + async function worker(): Promise { + for (let i = next++; i < rawItems.length; i = next++) { + slots[i] = await resolveCollectionItem(db, rawItems[i]); + } + } + await Promise.all( + Array.from({ length: Math.min(COLLECTION_RESOLVE_CONCURRENCY, rawItems.length) }, worker) + ); + const resolved = slots.filter((i): i is ProxyReaderCollectionItem => i !== null); + + // No resolvable items → not a renderable edition. Returning null lets the + // caller drop `readerCollection` entirely, so the frontend falls through to the + // ordinary document body instead of rendering an empty edition. + if (resolved.length === 0) return null; + + const editorialBody = markpubToMarkdown(raw.editorial?.body); + const editorialTitle = raw.editorial?.title || undefined; + const colophonBody = markpubToMarkdown(raw.colophon?.body); + + return { + editorial: + editorialBody || editorialTitle ? { title: editorialTitle, body: editorialBody } : undefined, + colophon: colophonBody ? { body: colophonBody } : undefined, + items: resolved, + publicationName: edition.publicationName || undefined, + theme: edition.theme || undefined, + fonts: edition.fonts || undefined, + authorHandle: edition.authorHandle || undefined, + }; +} + /** * Map a single raw `site.standard.document` record into a `ProxyDocument`, * resolving its publication's base URL + icon (SQLite-cached via @@ -224,7 +478,13 @@ export async function recordToProxyDocument( authorDid: string, recordUri: string, recordCid: string, - doc: DocumentRecord + doc: DocumentRecord, + // The paired `app.standard-reader.collection` sidecar (same rkey), when this + // document is a curated magazine edition. Supplied by the list/backfill path; + // the Jetstream splice path omits it (firehose collection enrichment is a + // follow-up), so a spliced edition simply renders without the magazine toggle + // until the next full refresh. + collection?: CollectionRecord | null ): Promise { const fetchedAtISO = new Date().toISOString(); const siteUri = doc.site || ''; @@ -242,6 +502,19 @@ export async function recordToProxyDocument( .map((l) => ({ uri: l.uri, ...(l.rel ? { rel: l.rel } : {}) })) : []; + // Resolve the paired curated edition (if any) to renderable item previews. The + // edition's own publication name + theme (colors) + fonts + author handle drive + // the optional magazine view; the handle is best-effort (cached DID resolution) + // and degrades to absent. + const readerCollection = collection + ? await resolveReaderCollection(db, collection, { + publicationName: meta.name || undefined, + theme: meta.theme || undefined, + fonts: meta.fonts || undefined, + authorHandle: (await resolveHandle(db, authorDid)) || undefined, + }) + : null; + return { authorDid, recordUri, @@ -262,9 +535,83 @@ export async function recordToProxyDocument( createdAt: toISO(doc.createdAt, toISO(doc.publishedAt, fetchedAtISO)), siteIcon: meta.icon || undefined, links: links.length > 0 ? links : undefined, + readerCollection: readerCollection || undefined, }; } +/** + * Fetch and resolve a single `site.standard.document` by its at:// URI. Used for + * on-demand reads — e.g. opening a curated piece from a Collection that the user + * doesn't subscribe to, so it never appears in any author's cached list. Returns + * null for a malformed URI, unresolvable PDS, or missing record. Not cached: a + * click is a one-shot read, and `recordToProxyDocument` still reuses the + * SQLite-cached publication meta. + */ +export async function fetchSingleDocument( + db: Database, + uri: string +): Promise { + const parsed = parseAtUri(uri); + if (!parsed || parsed.collection !== 'site.standard.document') return null; + + const pdsUrl = await resolvePdsUrl(db, parsed.did); + if (!pdsUrl) return null; + + try { + const params = new URLSearchParams({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }); + const res = await safeFetch(`${pdsUrl}/xrpc/com.atproto.repo.getRecord?${params}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + const data = (await res.json()) as { uri?: string; cid?: string; value?: DocumentRecord }; + if (!data.value) return null; + return recordToProxyDocument(db, parsed.did, data.uri || uri, data.cid || '', data.value); + } catch (error) { + console.error('[standard-site] fetchSingleDocument error:', error); + return null; + } +} + +/** + * List an author's `app.standard-reader.collection` sidecars, keyed by rkey. A + * collection shares its rkey with the `site.standard.document` it renders, so the + * map lets the document loop pair each doc with its edition in O(1) without a + * getRecord per document. Best-effort: a fetch failure yields an empty map (no + * magazine enrichment) rather than failing the whole document refresh. Collections + * are few, so a single listRecords page suffices. + */ +async function fetchCollectionsForAuthor( + pdsUrl: string, + authorDid: string +): Promise> { + const byRkey = new Map(); + try { + const params = new URLSearchParams({ + repo: authorDid, + collection: 'app.standard-reader.collection', + limit: '100', + }); + const res = await safeFetch(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return byRkey; + const data = (await res.json()) as { + records?: Array<{ uri: string; value: CollectionRecord }>; + }; + for (const record of data.records ?? []) { + const parsed = parseAtUri(record.uri); + if (parsed) byRkey.set(parsed.rkey, record.value); + } + } catch (error) { + console.error('[standard-site] listCollections error:', error); + } + return byRkey; +} + /** * Fetch a publisher's recent `site.standard.document` records and map them to * `ProxyDocument`s (canonical URL + icon resolved). Returns the full unfiltered @@ -306,13 +653,19 @@ export async function fetchDocumentsForAuthor( cursor = data.cursor; } + // Pair each document with its curated edition (if any), matched by shared rkey. + // One listRecords for the whole author, vs a getRecord per document. + const collectionsByRkey = await fetchCollectionsForAuthor(pdsUrl, authorDid); + // Map each record to a resolved ProxyDocument. resolveSiteMeta (inside // recordToProxyDocument) is SQLite-cached, so repeated publications in the // batch are cheap point-reads after the first. const documents: ProxyDocument[] = []; for (const record of raw.slice(0, MAX_DOCUMENTS_PER_AUTHOR)) { + const rkey = parseAtUri(record.uri)?.rkey; + const collection = rkey ? collectionsByRkey.get(rkey) : undefined; documents.push( - await recordToProxyDocument(db, authorDid, record.uri, record.cid, record.value) + await recordToProxyDocument(db, authorDid, record.uri, record.cid, record.value, collection) ); } diff --git a/frontend/functions/_middleware.ts b/frontend/functions/_middleware.ts index fce2623f..8ec42367 100644 --- a/frontend/functions/_middleware.ts +++ b/frontend/functions/_middleware.ts @@ -11,11 +11,13 @@ export const onRequest: PagesFunction = async (context) => { const csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, - "style-src 'self' 'unsafe-inline'", + // fonts.googleapis.com: collections-publication typography stylesheets (magazine view). + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' https: data:", "media-src 'self' https: data: blob:", "connect-src 'self' https://*.skyreader.app https:", - "font-src 'self' data:", + // fonts.gstatic.com: the actual web-font files for the magazine view. + "font-src 'self' data: https://fonts.gstatic.com", 'frame-src https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com https://youtube-nocookie.com https://player.vimeo.com', "frame-ancestors 'none'", "base-uri 'self'", diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 76e37572..e91c7618 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -9,6 +9,9 @@ declare global { // interface PageData {} interface PageState { readerOpen?: boolean; + // Reader-stack depth for the main feed (FeedListView): each open pushes a + // history entry; Back regresses the depth, popping the stack to match. + readerDepth?: number; } // interface Platform {} } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 31f8ce62..37b28f1f 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -7,11 +7,13 @@ import { dev } from '$app/environment'; const devCsp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", // unsafe-inline OK for local dev - "style-src 'self' 'unsafe-inline'", + // fonts.googleapis.com: collections-publication typography stylesheets (magazine view). + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' https: data:", "media-src 'self' https: data: blob:", "connect-src 'self' http://127.0.0.1:8787 ws://127.0.0.1:5173 https:", - "font-src 'self' data:", + // fonts.gstatic.com: the actual web-font files for the magazine view. + "font-src 'self' data: https://fonts.gstatic.com", 'frame-src https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com https://youtube-nocookie.com https://player.vimeo.com', "frame-ancestors 'none'", "base-uri 'self'", diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index 73a0cd7b..addf85bf 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -14,6 +14,7 @@ OffprintContent, GreengaleContent, MarkpubContent, + ReaderCollectionItem, } from '$lib/types'; import { formatRelativeDate } from '$lib/utils/date'; import { getFaviconUrl } from '$lib/utils/favicon'; @@ -35,6 +36,7 @@ } from '$lib/utils/linkPost'; import { api } from '$lib/services/api'; import { db } from '$lib/services/db'; + import { saveCollectionPiece, isCollectionPieceSaved } from '$lib/utils/collectionPiece'; import { socialStore } from '$lib/stores/social.svelte'; import { linkblogStore } from '$lib/stores/linkblog.svelte'; import { myLinkblogStore } from '$lib/stores/myLinkblog.svelte'; @@ -79,6 +81,7 @@ onSelect, onExpand, onOpenFullscreen, + onOpenCollectionPiece, onSaveToSemble, onSaveToMargin, }: { @@ -102,6 +105,9 @@ onSelect?: () => void; onExpand?: () => void; onOpenFullscreen?: () => void; + /** Open a curated edition piece in the in-app reader. Threaded from the list + * view, which owns the reader stack. */ + onOpenCollectionPiece?: (item: ReaderCollectionItem) => void | Promise; onSaveToSemble?: () => void; onSaveToMargin?: () => void; } = $props(); @@ -660,6 +666,11 @@ let itemTagCount = $derived(itemLabelsStore.getTagsForItem(itemGuid).length); let itemTags = $derived(itemLabelsStore.getTagsForItem(itemGuid)); + // Curated edition (Collection): the count of gathered pieces drives the quiet + // "Edition · N" marker in the title row and keeps the Reader action available. + let collectionPieceCount = $derived(document?.readerCollection?.items.length ?? 0); + let collection = $derived(document?.readerCollection); + // Paragraph tracking for read progress const paragraphTracking = useParagraphTracking({ contentEl: () => bodyEl, @@ -801,6 +812,8 @@ expandedLaneItems={atmosphere.expandedLaneItems} {itemTagCount} {itemTags} + {collectionPieceCount} + {collection} {isRead} {isSaved} {selected} @@ -827,6 +840,9 @@ onRemoveShare={() => removeShare()} onOpenUrl={handleOpenUrl} onOpenFullscreen={() => onOpenFullscreen?.()} + {onOpenCollectionPiece} + onSaveCollectionPiece={saveCollectionPiece} + {isCollectionPieceSaved} onOpenLinkMenu={(rect) => linkInterception.openMenu({ url: itemUrl, linkText: itemTitle, anchorRect: rect })} onExpandToggle={() => onExpand?.()} diff --git a/frontend/src/lib/components/ArticleCardView.svelte b/frontend/src/lib/components/ArticleCardView.svelte index ad5135dd..700cce52 100644 --- a/frontend/src/lib/components/ArticleCardView.svelte +++ b/frontend/src/lib/components/ArticleCardView.svelte @@ -4,6 +4,7 @@ // lives in the container (ArticleCard.svelte). See articleCardView.types.ts. import Icon from './Icon.svelte'; import AtmospherePanel from '$lib/components/feed/AtmospherePanel.svelte'; + import CollectionReader from '$lib/components/feed/CollectionReader.svelte'; import { bskyEmbed } from '$lib/actions/bsky-embed'; import { overlapShadow } from '$lib/actions/overlap-shadow'; import type { ArticleCardViewProps } from './articleCardView.types'; @@ -38,6 +39,8 @@ expandedLaneItems, itemTagCount, itemTags = [], + collectionPieceCount = 0, + collection, // state isRead = false, isSaved = false, @@ -67,6 +70,9 @@ onRemoveShare, onOpenUrl, onOpenFullscreen, + onOpenCollectionPiece, + onSaveCollectionPiece, + isCollectionPieceSaved, onOpenLinkMenu, onExpandToggle, onTagClick, @@ -222,6 +228,16 @@ {authorDisplayName} {/if} + {#if collectionPieceCount > 0} + + Edition · {collectionPieceCount} + + {/if} {#if displayFeedTitle && !isLinkPostMode} {#if feedId} {linkDisplayUrl} + {:else if collection} + +
+ +
+ +
+
{:else if hasContent}
{/if} {/if} - {#if hasOpenFullscreen && hasContent} + {#if hasOpenFullscreen && (hasContent || collectionPieceCount > 0)} {/if} @@ -708,6 +749,43 @@ white-space: nowrap; } + /* A curated edition reads as an ordinary article row — same favicon, title, + meta, and action bar — with one quiet marker that it gathers many pieces. + One Blue + the layers glyph (color never alone) make it the single colour + event on the row; flat, no bespoke card. Aligns to the title's first line + so it rides the meta cluster cleanly past the header's baseline. */ + .edition-tag { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + /* Ride the meta row's shared text baseline (like .via-pill) rather than + centering in the taller title line box — otherwise the pill floats above + the feed/date text beside it. The wash stays optically centered on the + text because the padding is symmetric and the glyph matches the cap height. */ + align-self: baseline; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + line-height: var(--leading-normal, 1.5); + color: var(--color-primary, #0066cc); + background: var(--color-sidebar-active, rgba(0, 102, 204, 0.1)); + white-space: nowrap; + } + + /* Pull the layers glyph onto the text baseline so it centers with the label + inside the pill (svgs have no baseline of their own). */ + .edition-tag :global(.icon) { + vertical-align: -0.15em; + } + + /* A read edition mutes with the rest of the row rather than holding its blue. */ + .article-item.read .edition-tag { + color: var(--color-text-secondary); + background: var(--color-bg-secondary, rgba(128, 128, 128, 0.1)); + } + /* Link-post body: the note as prose, then the article as a tappable card. */ /* The author's note, rendered from Markdown. Block children (paragraphs, lists) collapse their outer margins so the note reads as one tight block. */ @@ -1119,6 +1197,21 @@ } } + /* Host for a curated edition's pieces. Bare on purpose — CollectionReader owns + its own typography, and staying off .article-body avoids the prose globals + bleeding into the card layout. Keeps the collapsed-preview clamp. */ + .collection-host { + position: relative; + } + + .collection-host.truncated { + display: -webkit-box; + -webkit-line-clamp: 8; + line-clamp: 8; + -webkit-box-orient: vertical; + overflow: hidden; + } + .article-body { position: relative; font-family: var(--article-font); @@ -1712,6 +1805,11 @@ overflow: hidden; } + .edition-tag { + order: 1; + font-size: var(--text-xs); + } + .feed-title-link, .feed-title-label { order: 2; diff --git a/frontend/src/lib/components/articleCardView.types.ts b/frontend/src/lib/components/articleCardView.types.ts index d47e5a81..bbdbae26 100644 --- a/frontend/src/lib/components/articleCardView.types.ts +++ b/frontend/src/lib/components/articleCardView.types.ts @@ -1,5 +1,5 @@ import type { IconName } from './Icon.svelte'; -import type { Highlight } from '$lib/types'; +import type { Highlight, ReaderCollection, ReaderCollectionItem } from '$lib/types'; /** * View-model types for the PURE presentational `ArticleCardView.svelte`. @@ -101,6 +101,17 @@ export interface ArticleCardViewProps { itemTagCount: number; itemTags?: string[]; + /** >0 when this document is a curated edition (Collection): the number of + * pieces it gathers. Drives the quiet "Edition · N" marker in the title row + * and keeps the Reader action available even when there's no inline body. */ + collectionPieceCount?: number; + + /** The resolved curated edition, when this document is a Collection. When + * present (and the card is open), the body renders the edition's pieces as + * embedded cards (CollectionReader) instead of the {@html sanitizedContent} + * body — the same treatment the fullscreen reader uses. */ + collection?: ReaderCollection; + // ── State ── isRead?: boolean; isSaved?: boolean; @@ -134,6 +145,12 @@ export interface ArticleCardViewProps { onRemoveShare?: () => void; onOpenUrl?: () => void; onOpenFullscreen?: () => void; + /** Open a curated edition piece in the in-app reader (CollectionReader). */ + onOpenCollectionPiece?: (item: ReaderCollectionItem) => void | Promise; + /** Toggle a curated edition piece into the Saved list. */ + onSaveCollectionPiece?: (item: ReaderCollectionItem) => void; + /** Reactive saved-state predicate for a curated edition piece. */ + isCollectionPieceSaved?: (item: ReaderCollectionItem) => boolean; /** Open the link context menu (open externally / save to reader) for a link * post's URL, anchored to the clicked chip's rect. */ onOpenLinkMenu?: (anchorRect: DOMRect) => void; diff --git a/frontend/src/lib/components/feed/CollectionMagazine.svelte b/frontend/src/lib/components/feed/CollectionMagazine.svelte new file mode 100644 index 00000000..73e1bd46 --- /dev/null +++ b/frontend/src/lib/components/feed/CollectionMagazine.svelte @@ -0,0 +1,558 @@ + + + + {#if fontHref} + + {/if} + + +
+
+ + {#if collection.publicationName} +

{decodeEntities(collection.publicationName)}

+ {/if} +

{issueTitle}

+

+ {#if collection.authorHandle}@{collection.authorHandle} · + {/if} + {collection.items.length} {collection.items.length === 1 ? 'feature' : 'features'} +

+
+ + {#if collection.editorial?.title || editorialHtml} +
+ {#if collection.editorial?.title} +

{decodeEntities(collection.editorial.title)}

+ {/if} + {#if editorialHtml} +
{@html editorialHtml}
+ {/if} +
+ {/if} + + +
+ + +
+ {#each collection.items as item, i (item.document + '#' + i)} + {@const source = sourceLabel(item)} + {@const favicon = itemFavicon(item)} + {@const st = pieces[item.document]} +
+
+ +
+

{pieceTitle(item)}

+ {#if source} + + {#if favicon}{/if} + {source} + + {/if} +
+ {#if item.canonicalUrl} + + + + {/if} +
+ + {#if item.note} +
+ {@html md(item.note, item.canonicalUrl)} +
+ {/if} + + {#if isExternal(item)} +

+ {#if item.canonicalUrl} + + Read the full piece at {source || 'the source'} → + + {:else} + This piece links out and isn't rendered in-app. + {/if} +

+ {:else if !st || st.status === 'loading'} +

Loading…

+ {:else if st.status === 'error'} +

+ Couldn't load this piece. + {#if item.canonicalUrl} + Read the original → + {/if} +

+ {:else} +
{@html st.html}
+ {/if} +
+ {/each} +
+ + {#if colophonHtml} +
{@html colophonHtml}
+ {/if} +
+ + diff --git a/frontend/src/lib/components/feed/CollectionReader.svelte b/frontend/src/lib/components/feed/CollectionReader.svelte new file mode 100644 index 00000000..19943291 --- /dev/null +++ b/frontend/src/lib/components/feed/CollectionReader.svelte @@ -0,0 +1,387 @@ + + +
+ {#if editorialHtml} +
{@html editorialHtml}
+ {/if} + + +
+ + {#each collection.items as item, i (item.document + '#' + i)} + {@const favicon = itemFavicon(item)} + {@const source = sourceLabel(item)} + {@const saved = isPieceSaved?.(item) ?? false} + {@const canOpenViewer = Boolean(onOpenPiece)} +
+ + {#if item.note} +
+ {@html md(item.note, item.canonicalUrl)} +
+ {/if} + + +
+
+ {#if favicon || source} +
+ {#if favicon} + + {/if} + {#if source}{source}{/if} + +
+ {/if} + +

{item.title || item.canonicalUrl || 'Untitled piece'}

+ + {#if item.description} +

{item.description}

+ {/if} +
+ +
+ {#if onSavePiece} + + {/if} + + {#if canOpenViewer} + + {/if} + + {#if item.canonicalUrl} + + + New tab + + {/if} +
+
+
+ {/each} +
+ + {#if colophonHtml} +
{@html colophonHtml}
+ {/if} +
+ + diff --git a/frontend/src/lib/components/feed/FeedListView.svelte b/frontend/src/lib/components/feed/FeedListView.svelte index 6b0201c3..d7ae991d 100644 --- a/frontend/src/lib/components/feed/FeedListView.svelte +++ b/frontend/src/lib/components/feed/FeedListView.svelte @@ -1,7 +1,5 @@ -
+
+
+ +
+ {:else} +
+ {@html sanitizedContent} +
+ {/if}