diff --git a/web/src/lib/components/share/PublicBoardView.svelte b/web/src/lib/components/share/PublicBoardView.svelte new file mode 100644 index 00000000..de94fa2d --- /dev/null +++ b/web/src/lib/components/share/PublicBoardView.svelte @@ -0,0 +1,135 @@ + + +
+ {#each columns as column (column.value)} +
+
+ {formatLabel(column.value) || 'Ungrouped'} + {column.items.length} +
+
+ {#each column.items as item (item.key)} + + {/each} + {#if column.items.length === 0} +

No {(formatLabel(column.value) || 'ungrouped').toLowerCase()} items

+ {/if} +
+
+ {/each} +
+ + diff --git a/web/src/lib/components/share/PublicCollectionView.svelte b/web/src/lib/components/share/PublicCollectionView.svelte new file mode 100644 index 00000000..f567a8cf --- /dev/null +++ b/web/src/lib/components/share/PublicCollectionView.svelte @@ -0,0 +1,114 @@ + + +
+
+ {#if coll.icon}{coll.icon}{/if} +

{coll.name}

+
+ {#if coll.description} +

{coll.description}

+ {/if} + + {#if list.length === 0} +

No items in this collection.

+ {:else if activeView === 'board'} + + {:else if activeView === 'table'} + + {:else} + + {/if} +
+ + diff --git a/web/src/lib/components/share/PublicItemCard.svelte b/web/src/lib/components/share/PublicItemCard.svelte new file mode 100644 index 00000000..e9a85735 --- /dev/null +++ b/web/src/lib/components/share/PublicItemCard.svelte @@ -0,0 +1,169 @@ + + + + +
+ {#if item.ref} +
+ {item.ref} +
+ {/if} + +
{item.title}
+ + {#if (statusFieldDef && status) || (priorityFieldDef && priority)} +
+ {#if statusFieldDef && status} + + {formatLabel(status).toUpperCase()} + + {/if} + {#if priorityFieldDef && priority} + {#if statusFieldDef && status}·{/if} + + {formatLabel(priority)} + + {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/components/share/PublicListView.svelte b/web/src/lib/components/share/PublicListView.svelte new file mode 100644 index 00000000..e832dee8 --- /dev/null +++ b/web/src/lib/components/share/PublicListView.svelte @@ -0,0 +1,212 @@ + + +
+ {#each groups as group (group.value)} + {#if groupField} +

+ {formatLabel(group.value) || 'Ungrouped'} + {group.items.length} +

+ {/if} +
+ {#each group.items as item (item.key)} + {@const status = statusOf(item)} + {@const priority = priorityOf(item)} + + +
activate(item) : undefined} + onkeydown={interactive ? (e) => onKey(e, item) : undefined} + > + {#if item.ref}{item.ref}{/if} + {item.title} + + {#if priorityFieldDef && priority} + {formatLabel(priority)} + {/if} + {#if statusFieldDef && status} + {formatLabel(status).toUpperCase()} + {/if} + +
+ {/each} + {#if group.items.length === 0} +

No items

+ {/if} +
+ {/each} +
+ + diff --git a/web/src/lib/components/share/PublicTableView.svelte b/web/src/lib/components/share/PublicTableView.svelte new file mode 100644 index 00000000..d6d38211 --- /dev/null +++ b/web/src/lib/components/share/PublicTableView.svelte @@ -0,0 +1,199 @@ + + +
+
+
+ {#if hasRefs}
Ref
{/if} +
Title
+ {#each columns as field (field.key)} +
{field.label || formatLabel(field.key)}
+ {/each} +
+ {#each items as item (item.key)} +
activate(item) : undefined} + onkeydown={interactive ? (e) => onKey(e, item) : undefined} + > + {#if hasRefs} +
{item.ref}
+ {/if} +
+ {item.title} +
+ {#each columns as field (field.key)} + {@const raw = item.fields[field.key]} + {@const text = formatFieldValue(raw)} + {@const color = typeof raw === 'string' ? cellColor(field.key, raw) : undefined} +
+ {#if field.key === 'status' && text} + {formatLabel(text).toUpperCase()} + {:else if color && text} + {formatLabel(text)} + {:else} + {text}{field.suffix && text ? ` ${field.suffix}` : ''} + {/if} +
+ {/each} +
+ {/each} +
+
+ + diff --git a/web/src/lib/components/share/shareView.ts b/web/src/lib/components/share/shareView.ts new file mode 100644 index 00000000..7ae3c769 --- /dev/null +++ b/web/src/lib/components/share/shareView.ts @@ -0,0 +1,293 @@ +// Shared types + helpers for the public read-only collection view renderers +// (TASK-1679 / PLAN-1677). +// +// These renderers consume the share-link payload produced by TASK-1678 (which +// ships in parallel), so EVERYTHING here parses defensively: `settings`, +// `schema`, and per-item `fields` may arrive as a JSON string OR an +// already-parsed object. We never assume; we coerce. TASK-1680 reconciles +// against the final wire shape — until then these helpers are the single +// choke-point for tolerating either form. +// +// Deliberately decoupled from `$lib/types`' `Item`/`Collection` DB row shapes +// and from the interactive BoardView/ListView/TableView (which depend on +// `page.params`, internal links, and mutation handlers — exactly what a +// logged-out, read-only audience must not see). The color/label vocabularies +// mirror the in-app helpers so a shared kanban looks like the owner's kanban. + +import type { FieldDef } from '$lib/types'; + +/** Collection settings as understood by the public renderers. View-type + + * grouping/sort knobs only — interactive-only settings are ignored. */ +export interface PublicViewSettings { + default_view: 'list' | 'board' | 'table'; + board_group_by?: string; + list_sort_by?: string; + list_group_by?: string; + layout?: string; +} + +/** The collection branch of the share payload. `settings`/`schema` are + * parsed defensively from string-or-object by `parsePublicCollection`. */ +export interface PublicCollection { + name: string; + icon?: string; + description?: string; + settings: PublicViewSettings; + fields: FieldDef[]; +} + +/** One item in the share payload, normalized. `fields` is parsed from + * string-or-object; `content` is the raw markdown body (rendering is the + * renderer's concern — kept here so a future inline expand (TASK-1684) has + * the body without a re-fetch). `key` is a stable, unique identity assigned + * at parse time (the item's position in the payload) — refs may be empty and + * titles aren't unique, so renderers key `{#each}` blocks on this instead. */ +export interface PublicItem { + key: string; + title: string; + ref: string; + fields: Record; + content: string; +} + +// ── Defensive parsing ─────────────────────────────────────────────────────── + +/** Accept a value that may be a JSON string, an object, or null/undefined and + * return a plain record. Never throws. */ +export function coerceObject(value: unknown): Record { + if (value == null) return {}; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + if (typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +/** Pull a `fields` array out of a schema that may be a JSON string, an object + * with a `fields` array, or already an array of FieldDef. Returns a clean + * FieldDef[] (entries missing a `key` are dropped). */ +export function coerceFields(schema: unknown): FieldDef[] { + let candidate: unknown = schema; + if (typeof schema === 'string') { + const trimmed = schema.trim(); + if (!trimmed) return []; + try { + candidate = JSON.parse(trimmed); + } catch { + return []; + } + } + let arr: unknown; + if (Array.isArray(candidate)) { + arr = candidate; + } else if (candidate && typeof candidate === 'object') { + arr = (candidate as Record).fields; + } + if (!Array.isArray(arr)) return []; + return arr.filter( + (f): f is FieldDef => !!f && typeof f === 'object' && typeof (f as FieldDef).key === 'string' + ); +} + +const DEFAULT_SETTINGS: PublicViewSettings = { default_view: 'list' }; + +/** Normalize raw `settings` (string-or-object) into PublicViewSettings, + * validating `default_view` against the known set and defaulting to 'list'. */ +export function coerceSettings(settings: unknown): PublicViewSettings { + const obj = coerceObject(settings); + const view = obj.default_view; + const default_view: PublicViewSettings['default_view'] = + view === 'board' || view === 'table' || view === 'list' ? view : 'list'; + return { + default_view, + board_group_by: typeof obj.board_group_by === 'string' ? obj.board_group_by : undefined, + list_sort_by: typeof obj.list_sort_by === 'string' ? obj.list_sort_by : undefined, + list_group_by: typeof obj.list_group_by === 'string' ? obj.list_group_by : undefined, + layout: typeof obj.layout === 'string' ? obj.layout : undefined + }; +} + +/** Normalize the raw collection branch of the share payload into a + * PublicCollection. Tolerates `settings` and `schema` arriving as either a + * JSON string or an object (TASK-1678 ships in parallel). */ +export function parsePublicCollection(raw: unknown): PublicCollection { + const obj = coerceObject(raw); + return { + name: typeof obj.name === 'string' && obj.name ? obj.name : 'Collection', + icon: typeof obj.icon === 'string' ? obj.icon : undefined, + description: typeof obj.description === 'string' ? obj.description : undefined, + settings: coerceSettings(obj.settings), + fields: coerceFields(obj.schema) + }; +} + +/** Normalize one raw item into a PublicItem. `fields` parsed defensively; + * `ref` falls back across `ref` / `item_ref`. `index` is the item's position + * in the payload, used to derive a stable unique `key`. */ +export function parsePublicItem(raw: unknown, index = 0): PublicItem { + const obj = coerceObject(raw); + const ref = + typeof obj.ref === 'string' && obj.ref + ? obj.ref + : typeof obj.item_ref === 'string' + ? obj.item_ref + : ''; + return { + // Refs can be empty and titles aren't unique, so anchor the key on the + // payload position; prefix the ref when present for readable debugging. + key: ref ? `${ref}#${index}` : `idx#${index}`, + title: typeof obj.title === 'string' && obj.title ? obj.title : 'Untitled', + ref, + fields: coerceObject(obj.fields), + content: typeof obj.content === 'string' ? obj.content : '' + }; +} + +export function parsePublicItems(raw: unknown): PublicItem[] { + if (!Array.isArray(raw)) return []; + return raw.map((item, i) => parsePublicItem(item, i)); +} + +// ── Field lookup ────────────────────────────────────────────────────────── + +export function findField(fields: FieldDef[], key: string): FieldDef | undefined { + return fields.find((f) => f.key === key); +} + +/** Fields a table/list should show: drop computed fields (they aren't part of + * the shared snapshot's meaningful columns). */ +export function visibleFields(fields: FieldDef[]): FieldDef[] { + return fields.filter((f) => !f.computed); +} + +/** Resolve the field key to group a board by: explicit `board_group_by`, + * else `status` if the schema has one, else the first select field, else ''. */ +export function resolveGroupField(collection: PublicCollection): string { + const explicit = collection.settings.board_group_by; + if (explicit && findField(collection.fields, explicit)) return explicit; + if (findField(collection.fields, 'status')) return 'status'; + const firstSelect = collection.fields.find((f) => f.type === 'select'); + return firstSelect?.key ?? ''; +} + +// ── Presentation helpers (mirror the in-app vocabularies) ─────────────────── + +/** Title-case a snake/kebab field key or value for display. */ +export function formatLabel(value: string): string { + return value.replace(/[_-]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Stringify any field value for read-only display: arrays join, objects + * JSON-stringify, primitives coerce, null/undefined → ''. */ +export function formatFieldValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map((v) => formatFieldValue(v)).join(', '); + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return ''; + } + } + return String(value); +} + +/** Status color — mirrors ItemCard.statusColor so a shared board matches the + * owner's palette. Returns a CSS custom-property reference. */ +export function statusColor(status: string): string { + switch (status) { + case 'open': + return 'var(--text-secondary)'; + case 'in_progress': + return 'var(--accent-amber)'; + case 'done': + return 'var(--accent-green)'; + case 'blocked': + return 'var(--accent-orange)'; + default: + return 'var(--text-muted)'; + } +} + +/** Priority color — mirrors ItemCard.priorityColor. */ +export function priorityColor(priority: string): string { + switch (priority) { + case 'critical': + return 'var(--accent-orange)'; + case 'high': + return 'var(--accent-amber)'; + case 'medium': + return 'var(--text-secondary)'; + case 'low': + return 'var(--text-muted)'; + default: + return 'var(--text-muted)'; + } +} + +/** A board column's accent class, mirroring BoardView.columnCssClass. */ +export function columnAccentClass(value: string): string { + switch (value) { + case 'in_progress': + return 'col-in-progress'; + case 'done': + return 'col-done'; + case 'blocked': + return 'col-blocked'; + default: + return ''; + } +} + +/** Group `items` by `groupField` value, in option order with any extra values + * appended (sorted), and ungrouped ('') last when present. Mirrors + * ListView/BoardView grouping so a shared view preserves the owner's columns. */ +export function groupItems( + items: PublicItem[], + groupField: string, + optionOrder: string[] +): { value: string; items: PublicItem[] }[] { + const buckets = new Map(); + for (const opt of optionOrder) buckets.set(opt, []); + + const extras: string[] = []; + let hasUngrouped = false; + for (const item of items) { + const raw = item.fields[groupField]; + const value = typeof raw === 'string' ? raw : raw == null ? '' : String(raw); + if (!value) { + hasUngrouped = true; + if (!buckets.has('')) buckets.set('', []); + buckets.get('')!.push(item); + continue; + } + if (!buckets.has(value)) { + buckets.set(value, []); + extras.push(value); + } + buckets.get(value)!.push(item); + } + + const order = [...optionOrder, ...extras.sort()]; + if (hasUngrouped) order.push(''); + // Dedupe while preserving order (an option could also appear in extras edge cases). + const seen = new Set(); + const result: { value: string; items: PublicItem[] }[] = []; + for (const v of order) { + if (seen.has(v)) continue; + seen.add(v); + result.push({ value: v, items: buckets.get(v) ?? [] }); + } + return result; +}