mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
feat(share): render matching view on /s/[token] (TASK-1680) (#681)
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.
This commit is contained in:
@@ -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<SharePayload> => {
|
||||
const headers: Record<string, string> = {};
|
||||
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<SharePayload>;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
<title>{itemData.title} - Shared via Pad</title>
|
||||
{:else if collectionData}
|
||||
<title>{collectionData.name} - Shared via Pad</title>
|
||||
<title>{collectionData.collection.name ?? 'Collection'} - Shared via Pad</title>
|
||||
{:else}
|
||||
<title>Shared - Pad</title>
|
||||
{/if}
|
||||
@@ -287,36 +263,11 @@
|
||||
{/if}
|
||||
</article>
|
||||
{:else if shareType === 'collection' && collectionData}
|
||||
<div class="share-collection">
|
||||
<div class="collection-header">
|
||||
{#if collectionData.icon}
|
||||
<span class="collection-icon-large">{collectionData.icon}</span>
|
||||
{/if}
|
||||
<h1>{collectionData.name}</h1>
|
||||
</div>
|
||||
|
||||
{#if collectionData.description}
|
||||
<p class="collection-description">{collectionData.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if collectionData.items.length > 0}
|
||||
<div class="collection-items">
|
||||
{#each collectionData.items as item, i (i)}
|
||||
<div class="collection-item-row">
|
||||
<span class="collection-item-title">{item.title}</span>
|
||||
{#if item.item_ref}
|
||||
<span class="collection-item-ref">{item.item_ref}</span>
|
||||
{/if}
|
||||
{#if item.status}
|
||||
<span class="collection-item-status">{item.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="collection-empty">No items in this collection.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<PublicCollectionView
|
||||
collection={collectionData.collection}
|
||||
items={collectionData.items}
|
||||
expandable={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user