From ae055ab41261afd0ba9aca04d5d5d6d295ea401e Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 31 May 2026 01:04:50 -0400 Subject: [PATCH] feat(share): render matching view on /s/[token] (TASK-1680) (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire PublicCollectionView into the public share page so a shared collection renders the owner's chosen view type (board/list/table, falling back to list) instead of a hardcoded flat list. The raw `collection` + `items` branches of the share payload are passed straight through — the component parses settings/schema/fields defensively. expandable={false} for now (inline expand is TASK-1684). Add a typed SharePayload (+ PublicShareCollection/Item/Settings) to $lib/types and annotate api.share.get's return type to reflect the enriched payload (collection.settings, collection.schema, items[].content). Existing share-page chrome (header/footer/password/auth states) is untouched; only the collection BODY rendering swaps. Dead collection row/header CSS removed. Parent: PLAN-1677. --- web/src/lib/api/client.ts | 5 +- web/src/lib/types/index.ts | 50 ++++++++ web/src/routes/s/[token]/+page.svelte | 162 +++----------------------- 3 files changed, 71 insertions(+), 146 deletions(-) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 2486ed2f..0bf0ca10 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -50,6 +50,7 @@ import type { ItemGrant, WorkspaceMembership, ShareLink, + SharePayload, TOTPSetupResponse, TOTPVerifyResponse, TOTPDisableResponse, @@ -1183,7 +1184,7 @@ export const api = { // ── Public Share (no auth) ────────────────────────────────────────────── share: { - get: (token: string, password?: string) => { + get: (token: string, password?: string): Promise => { const headers: Record = {}; if (password) headers['X-Share-Password'] = password; return fetch(`${BASE}/s/${token}`, { credentials: 'same-origin', headers }).then(async (resp) => { @@ -1192,7 +1193,7 @@ export const api = { if (body?.error) throw new PadApiError(body.error); throw new Error(`API error: ${resp.status}`); } - return resp.json(); + return resp.json() as Promise; }); }, }, diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 191a090e..e0cebc4e 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -72,6 +72,56 @@ export interface ShareLink { target_title?: string; } +/** Presentation-only subset of CollectionSettings emitted by the public share + * endpoint (GET /api/v1/s/{token}). Authoring affordances (quick_actions, + * content_template) are deliberately omitted. */ +export type PublicShareSettings = Pick< + CollectionSettings, + 'layout' | 'default_view' | 'board_group_by' | 'list_sort_by' | 'list_group_by' +>; + +/** The `collection` branch of the public share payload (TASK-1678). `settings` + * and `schema` are parsed JSON objects, present only when the source collection + * defined them. */ +export interface PublicShareCollection { + name: string; + icon?: string; + description?: string; + settings?: PublicShareSettings; + schema?: CollectionSchema; +} + +/** One item in the public share payload. `fields` is still a JSON string; + * `content` is the item's markdown body. */ +export interface PublicShareItem { + title: string; + ref?: string; + fields?: string; + content?: string; +} + +/** The shape returned by GET /api/v1/s/{token}. Auth/password gates short-circuit + * with `require_auth` / `require_password`; otherwise `type` discriminates the + * item vs collection payload. */ +export interface SharePayload { + type?: 'item' | 'collection'; + require_auth?: boolean; + require_password?: boolean; + permission?: string; + share_link?: { target_type: string }; + item?: { + title: string; + content?: string; + fields?: string; + ref?: string; + item_ref?: string; + collection_name?: string; + collection_icon?: string; + }; + collection?: PublicShareCollection; + items?: PublicShareItem[]; +} + // ─── Grants ────────────────────────────────────────────────────────────────── export interface CollectionGrant { diff --git a/web/src/routes/s/[token]/+page.svelte b/web/src/routes/s/[token]/+page.svelte index 31ee7000..960e5093 100644 --- a/web/src/routes/s/[token]/+page.svelte +++ b/web/src/routes/s/[token]/+page.svelte @@ -4,6 +4,8 @@ import { api } from '$lib/api/client'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; + import PublicCollectionView from '$lib/components/share/PublicCollectionView.svelte'; + import type { PublicShareCollection, PublicShareItem } from '$lib/types'; let token = $derived(page.params.token ?? ''); @@ -24,11 +26,13 @@ collection_icon?: string; item_ref?: string; } | null>(null); + // Raw `collection` + `items` branches of the share payload, fed straight to + // PublicCollectionView (which parses settings/schema/fields defensively and + // renders the owner's chosen view type). `name` is also used for the page + // title. Null until a collection payload has loaded. let collectionData = $state<{ - name: string; - icon?: string; - description?: string; - items: { title: string; item_ref?: string; status?: string }[]; + collection: PublicShareCollection; + items: PublicShareItem[]; } | null>(null); let renderedContent = $derived.by(() => { @@ -85,25 +89,9 @@ }; } else if (data.type === 'collection') { shareType = 'collection'; - const rawItems = (data.items ?? []).map((item: any) => { - let status = ''; - if (item.fields) { - try { - const fields = typeof item.fields === 'string' ? JSON.parse(item.fields) : item.fields; - status = fields.status ?? ''; - } catch { /* ignore */ } - } - return { - title: item.title ?? 'Untitled', - item_ref: item.ref ?? item.item_ref ?? '', - status - }; - }); collectionData = { - name: data.collection?.name ?? 'Collection', - icon: data.collection?.icon, - description: data.collection?.description, - items: rawItems + collection: data.collection ?? { name: 'Collection' }, + items: data.items ?? [] }; } else { error = 'Unknown share type.'; @@ -166,21 +154,9 @@ }; } else if (data.type === 'collection') { shareType = 'collection'; - const rawItems = (data.items ?? []).map((item: any) => { - let status = ''; - if (item.fields) { - try { - const fields = typeof item.fields === 'string' ? JSON.parse(item.fields) : item.fields; - status = fields.status ?? ''; - } catch { /* ignore */ } - } - return { title: item.title ?? 'Untitled', item_ref: item.ref ?? item.item_ref ?? '', status }; - }); collectionData = { - name: data.collection?.name ?? 'Collection', - icon: data.collection?.icon, - description: data.collection?.description, - items: rawItems + collection: data.collection ?? { name: 'Collection' }, + items: data.items ?? [] }; } } catch (e: any) { @@ -195,7 +171,7 @@ {#if itemData} {itemData.title} - Shared via Pad {:else if collectionData} - {collectionData.name} - Shared via Pad + {collectionData.collection.name ?? 'Collection'} - Shared via Pad {:else} Shared - Pad {/if} @@ -287,36 +263,11 @@ {/if} {:else if shareType === 'collection' && collectionData} - + {/if} @@ -590,83 +541,6 @@ color: var(--accent-blue); } - /* Collection view */ - .share-collection { - display: flex; - flex-direction: column; - gap: var(--space-5); - } - - .collection-header { - display: flex; - align-items: center; - gap: var(--space-3); - } - - .collection-icon-large { - font-size: 1.6em; - } - - .collection-header h1 { - font-size: 1.8em; - font-weight: 700; - letter-spacing: -0.02em; - } - - .collection-description { - color: var(--text-secondary); - font-size: 0.95em; - } - - .collection-items { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - .collection-item-row { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--bg-secondary); - border-radius: var(--radius); - border: 1px solid var(--border-subtle); - } - - .collection-item-title { - flex: 1; - font-weight: 500; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .collection-item-ref { - font-size: 0.8em; - color: var(--text-muted); - font-family: var(--font-mono); - flex-shrink: 0; - } - - .collection-item-status { - font-size: 0.78em; - font-weight: 500; - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 2px 10px; - border-radius: 999px; - flex-shrink: 0; - text-transform: capitalize; - } - - .collection-empty { - color: var(--text-muted); - font-size: 0.9em; - padding: var(--space-4) 0; - } - /* Footer */ .share-footer { text-align: center;