mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
feat(web): public read-only collection view renderers (TASK-1679) (#679)
* feat(web): public read-only collection view renderers (TASK-1679)
Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.
New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
fields tolerated as JSON string OR object, since TASK-1678 ships in
parallel) + label/status/priority color + grouping helpers lifted
from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
renderers; board groups by board_group_by|status, list optionally
groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
and switches on settings.default_view, delegating to a leaf renderer.
Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.
Not wired into any route yet; npm run build passes.
Parent: PLAN-1677.
* fix(web): stable unique each-keys for public renderers per Codex review (round 1)
PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.
Parent: PLAN-1677.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
// Read-only kanban renderer for the public share page (TASK-1679).
|
||||
//
|
||||
// Groups items into columns by the collection's `board_group_by` (or
|
||||
// `status`) and renders each column as a static stack of PublicItemCards.
|
||||
// No drag/drop, no column reorder, no add/draft, no status mutation — just
|
||||
// the owner's columns, presented for an anonymous audience. Mirrors the
|
||||
// in-app BoardView's column layout + header accents without its coupling.
|
||||
import type { PublicCollection, PublicItem } from './shareView';
|
||||
import {
|
||||
findField,
|
||||
resolveGroupField,
|
||||
groupItems,
|
||||
formatLabel,
|
||||
columnAccentClass
|
||||
} from './shareView';
|
||||
import PublicItemCard from './PublicItemCard.svelte';
|
||||
|
||||
interface Props {
|
||||
collection: PublicCollection;
|
||||
items: PublicItem[];
|
||||
/** Forwarded to each card for the deferred inline-expand (TASK-1684). */
|
||||
expandable?: boolean;
|
||||
onactivate?: (item: PublicItem) => void;
|
||||
}
|
||||
|
||||
let { collection, items, expandable = false, onactivate }: Props = $props();
|
||||
|
||||
let groupField = $derived(resolveGroupField(collection));
|
||||
let optionOrder = $derived(findField(collection.fields, groupField)?.options ?? []);
|
||||
let columns = $derived(groupItems(items, groupField, optionOrder));
|
||||
</script>
|
||||
|
||||
<div class="public-board">
|
||||
{#each columns as column (column.value)}
|
||||
<section class="board-column" aria-label="{formatLabel(column.value) || 'Ungrouped'} column">
|
||||
<header class="column-header {columnAccentClass(column.value)}">
|
||||
<span class="column-name">{formatLabel(column.value) || 'Ungrouped'}</span>
|
||||
<span class="column-count">{column.items.length}</span>
|
||||
</header>
|
||||
<div class="column-cards">
|
||||
{#each column.items as item (item.key)}
|
||||
<PublicItemCard {item} fields={collection.fields} {expandable} {onactivate} />
|
||||
{/each}
|
||||
{#if column.items.length === 0}
|
||||
<p class="column-empty">No {(formatLabel(column.value) || 'ungrouped').toLowerCase()} items</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-board {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.board-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 0;
|
||||
min-width: 240px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 2px solid var(--text-secondary);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.column-header.col-in-progress {
|
||||
border-bottom-color: var(--accent-amber);
|
||||
}
|
||||
.column-header.col-done {
|
||||
border-bottom-color: var(--accent-green);
|
||||
}
|
||||
.column-header.col-blocked {
|
||||
border-bottom-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.column-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.column-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.column-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.public-board {
|
||||
scroll-snap-type: x proximity;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.board-column {
|
||||
min-width: 80vw;
|
||||
max-width: 80vw;
|
||||
scroll-snap-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
// Public read-only collection view (TASK-1679 / PLAN-1677).
|
||||
//
|
||||
// The single entry point for rendering a shared collection on the `/s/[token]`
|
||||
// page (wired by TASK-1680). Takes the RAW share-link payload — `collection`
|
||||
// and `items` exactly as the backend (TASK-1678) sends them — parses
|
||||
// defensively (settings/schema/fields may be JSON strings OR objects), and
|
||||
// delegates to the board / list / table renderer matching the owner's
|
||||
// `settings.default_view`.
|
||||
//
|
||||
// Either pass raw payload pieces (`collection` + `items`) for the switcher to
|
||||
// normalize, OR pre-parsed `parsedCollection` + `parsedItems` if the caller
|
||||
// already normalized. The raw path is the common case.
|
||||
//
|
||||
// Read-only: no chrome, no edit/drag/create, no internal links. An optional
|
||||
// `view` prop overrides the default view (the read-only view switcher,
|
||||
// Phase 2 / TASK-1680, will drive this); `onactivate` + `expandable` are
|
||||
// forwarded to the leaf renderers for the deferred inline expand (TASK-1684).
|
||||
import type { PublicCollection, PublicItem } from './shareView';
|
||||
import { parsePublicCollection, parsePublicItems } from './shareView';
|
||||
import PublicBoardView from './PublicBoardView.svelte';
|
||||
import PublicListView from './PublicListView.svelte';
|
||||
import PublicTableView from './PublicTableView.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Raw `collection` branch of the share payload (string/object tolerant). */
|
||||
collection?: unknown;
|
||||
/** Raw `items` array of the share payload. */
|
||||
items?: unknown;
|
||||
/** Pre-parsed collection — supply instead of `collection` to skip parsing. */
|
||||
parsedCollection?: PublicCollection;
|
||||
/** Pre-parsed items — supply instead of `items` to skip parsing. */
|
||||
parsedItems?: PublicItem[];
|
||||
/** Override the rendered view; defaults to settings.default_view. */
|
||||
view?: 'list' | 'board' | 'table';
|
||||
/** Forwarded to leaf renderers for the deferred inline expand (TASK-1684). */
|
||||
expandable?: boolean;
|
||||
onactivate?: (item: PublicItem) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
collection,
|
||||
items,
|
||||
parsedCollection,
|
||||
parsedItems,
|
||||
view,
|
||||
expandable = false,
|
||||
onactivate
|
||||
}: Props = $props();
|
||||
|
||||
let coll = $derived<PublicCollection>(parsedCollection ?? parsePublicCollection(collection));
|
||||
let list = $derived<PublicItem[]>(parsedItems ?? parsePublicItems(items));
|
||||
let activeView = $derived(view ?? coll.settings.default_view);
|
||||
</script>
|
||||
|
||||
<div class="public-collection">
|
||||
<header class="collection-header">
|
||||
{#if coll.icon}<span class="collection-icon">{coll.icon}</span>{/if}
|
||||
<h1 class="collection-name">{coll.name}</h1>
|
||||
</header>
|
||||
{#if coll.description}
|
||||
<p class="collection-description">{coll.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if list.length === 0}
|
||||
<p class="collection-empty">No items in this collection.</p>
|
||||
{:else if activeView === 'board'}
|
||||
<PublicBoardView collection={coll} items={list} {expandable} {onactivate} />
|
||||
{:else if activeView === 'table'}
|
||||
<PublicTableView collection={coll} items={list} {expandable} {onactivate} />
|
||||
{:else}
|
||||
<PublicListView collection={coll} items={list} {expandable} {onactivate} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-collection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.collection-icon {
|
||||
font-size: 1.6em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
font-size: 1.8em;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collection-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collection-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
padding: var(--space-4) 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
// Read-only item card for the public board/list renderers (TASK-1679).
|
||||
//
|
||||
// Strictly presentational: no links, no star, no status cycling, no drag.
|
||||
// Renders the title, ref, and a small meta row (status + priority) styled
|
||||
// for an anonymous audience. Mirrors the in-app ItemCard's visual language
|
||||
// (status/priority colors, uppercase status) without any of its coupling to
|
||||
// page.params / mutation handlers.
|
||||
//
|
||||
// Designed for a future inline read-only expand (TASK-1684): the optional
|
||||
// `onactivate` callback + `expandable` flag turn the card into a button-like
|
||||
// affordance WITHOUT changing the markup contract. When omitted (the
|
||||
// default), the card is an inert <div> — no interactivity is implied.
|
||||
import type { FieldDef } from '$lib/types';
|
||||
import type { PublicItem } from './shareView';
|
||||
import { findField, formatLabel, statusColor, priorityColor } from './shareView';
|
||||
|
||||
interface Props {
|
||||
item: PublicItem;
|
||||
fields: FieldDef[];
|
||||
/** When true (and onactivate is set), the card advertises itself as an
|
||||
* expand affordance. Wiring deferred to TASK-1684. */
|
||||
expandable?: boolean;
|
||||
/** Fired when an expandable card is activated (click / Enter / Space).
|
||||
* Deferred wiring — TASK-1684. */
|
||||
onactivate?: (item: PublicItem) => void;
|
||||
}
|
||||
|
||||
let { item, fields, expandable = false, onactivate }: Props = $props();
|
||||
|
||||
let interactive = $derived(expandable && !!onactivate);
|
||||
|
||||
let statusFieldDef = $derived(findField(fields, 'status'));
|
||||
let priorityFieldDef = $derived(findField(fields, 'priority'));
|
||||
let status = $derived(
|
||||
typeof item.fields.status === 'string' ? (item.fields.status as string) : ''
|
||||
);
|
||||
let priority = $derived(
|
||||
typeof item.fields.priority === 'string' ? (item.fields.priority as string) : ''
|
||||
);
|
||||
|
||||
function activate() {
|
||||
if (interactive) onactivate?.(item);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!interactive) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onactivate?.(item);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- `role` and `tabindex` are correlated at runtime: when tabindex is 0 the
|
||||
role is always 'button' (both gated by `interactive`), so the element is
|
||||
focusable only when it is genuinely interactive. The analyzer can't see
|
||||
that correlation. Wiring lands in TASK-1684. -->
|
||||
<div
|
||||
class="public-card"
|
||||
class:interactive
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={interactive ? activate : undefined}
|
||||
onkeydown={interactive ? onKey : undefined}
|
||||
>
|
||||
{#if item.ref}
|
||||
<div class="card-top">
|
||||
<span class="card-ref">{item.ref}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-title">{item.title}</div>
|
||||
|
||||
{#if (statusFieldDef && status) || (priorityFieldDef && priority)}
|
||||
<div class="card-meta">
|
||||
{#if statusFieldDef && status}
|
||||
<span class="meta-status" style:color={statusColor(status)}>
|
||||
{formatLabel(status).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if priorityFieldDef && priority}
|
||||
{#if statusFieldDef && status}<span class="meta-sep">·</span>{/if}
|
||||
<span class="meta-priority" style:color={priorityColor(priority)}>
|
||||
{formatLabel(priority)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.public-card.interactive {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.public-card.interactive:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-tertiary, var(--text-secondary));
|
||||
}
|
||||
|
||||
.public-card.interactive:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card-ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.92em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-status {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-priority {
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
// Read-only list renderer for the public share page (TASK-1679).
|
||||
//
|
||||
// Renders items as a vertical stack of rows. When the collection's
|
||||
// `list_group_by` is set (and resolves to a real field), rows are split
|
||||
// into labeled groups in option order — mirroring the in-app ListView's
|
||||
// grouping — otherwise it's a single flat list. No drag, no collapse
|
||||
// toggles, no mutation; rows are inert (or expand-only once TASK-1684 wires
|
||||
// `onactivate`).
|
||||
import type { FieldDef } from '$lib/types';
|
||||
import type { PublicCollection, PublicItem } from './shareView';
|
||||
import {
|
||||
findField,
|
||||
groupItems,
|
||||
formatLabel,
|
||||
statusColor,
|
||||
priorityColor
|
||||
} from './shareView';
|
||||
|
||||
interface Props {
|
||||
collection: PublicCollection;
|
||||
items: PublicItem[];
|
||||
/** Deferred inline-expand affordance (TASK-1684). */
|
||||
expandable?: boolean;
|
||||
onactivate?: (item: PublicItem) => void;
|
||||
}
|
||||
|
||||
let { collection, items, expandable = false, onactivate }: Props = $props();
|
||||
|
||||
let interactive = $derived(expandable && !!onactivate);
|
||||
|
||||
let groupField = $derived.by(() => {
|
||||
const key = collection.settings.list_group_by;
|
||||
return key && findField(collection.fields, key) ? key : '';
|
||||
});
|
||||
|
||||
let statusFieldDef = $derived<FieldDef | undefined>(findField(collection.fields, 'status'));
|
||||
let priorityFieldDef = $derived<FieldDef | undefined>(findField(collection.fields, 'priority'));
|
||||
|
||||
let groups = $derived.by(() => {
|
||||
if (!groupField) return [{ value: '', items }];
|
||||
const optionOrder = findField(collection.fields, groupField)?.options ?? [];
|
||||
return groupItems(items, groupField, optionOrder);
|
||||
});
|
||||
|
||||
function statusOf(item: PublicItem): string {
|
||||
return typeof item.fields.status === 'string' ? (item.fields.status as string) : '';
|
||||
}
|
||||
function priorityOf(item: PublicItem): string {
|
||||
return typeof item.fields.priority === 'string' ? (item.fields.priority as string) : '';
|
||||
}
|
||||
|
||||
function activate(item: PublicItem) {
|
||||
if (interactive) onactivate?.(item);
|
||||
}
|
||||
function onKey(e: KeyboardEvent, item: PublicItem) {
|
||||
if (!interactive) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onactivate?.(item);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="public-list">
|
||||
{#each groups as group (group.value)}
|
||||
{#if groupField}
|
||||
<h3 class="group-heading">
|
||||
<span>{formatLabel(group.value) || 'Ungrouped'}</span>
|
||||
<span class="group-count">{group.items.length}</span>
|
||||
</h3>
|
||||
{/if}
|
||||
<div class="group-rows">
|
||||
{#each group.items as item (item.key)}
|
||||
{@const status = statusOf(item)}
|
||||
{@const priority = priorityOf(item)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- role + tabindex are runtime-correlated (both gated by
|
||||
`interactive`): focusable only when genuinely a button.
|
||||
Wiring lands in TASK-1684. -->
|
||||
<div
|
||||
class="list-row"
|
||||
class:interactive
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={interactive ? () => activate(item) : undefined}
|
||||
onkeydown={interactive ? (e) => onKey(e, item) : undefined}
|
||||
>
|
||||
{#if item.ref}<span class="row-ref">{item.ref}</span>{/if}
|
||||
<span class="row-title">{item.title}</span>
|
||||
<span class="row-meta">
|
||||
{#if priorityFieldDef && priority}
|
||||
<span class="row-priority" style:color={priorityColor(priority)}
|
||||
>{formatLabel(priority)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if statusFieldDef && status}
|
||||
<span class="row-status" style:color={statusColor(status)}
|
||||
>{formatLabel(status).toUpperCase()}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if group.items.length === 0}
|
||||
<p class="group-empty">No items</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 0.9em;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.group-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle, var(--border));
|
||||
border-radius: var(--radius);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row.interactive {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.list-row.interactive:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.list-row.interactive:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.row-ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-priority {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
// Read-only table renderer for the public share page (TASK-1679).
|
||||
//
|
||||
// Renders Ref + Title + one column per (non-computed) schema field as a CSS
|
||||
// grid (role="table"/"row"/"cell"), matching the in-app TableView's layout
|
||||
// and accessibility semantics. Read-only: no sortable headers that mutate,
|
||||
// no row links, no status cycling. Status/priority cells get the shared
|
||||
// color vocabulary; rows are inert (or expand-only once TASK-1684 wires
|
||||
// `onactivate`).
|
||||
import type { PublicCollection, PublicItem } from './shareView';
|
||||
import {
|
||||
visibleFields,
|
||||
formatLabel,
|
||||
formatFieldValue,
|
||||
statusColor,
|
||||
priorityColor
|
||||
} from './shareView';
|
||||
|
||||
interface Props {
|
||||
collection: PublicCollection;
|
||||
items: PublicItem[];
|
||||
/** Deferred inline-expand affordance (TASK-1684). */
|
||||
expandable?: boolean;
|
||||
onactivate?: (item: PublicItem) => void;
|
||||
}
|
||||
|
||||
let { collection, items, expandable = false, onactivate }: Props = $props();
|
||||
|
||||
let interactive = $derived(expandable && !!onactivate);
|
||||
let columns = $derived(visibleFields(collection.fields));
|
||||
let hasRefs = $derived(items.some((i) => !!i.ref));
|
||||
|
||||
let gridTemplate = $derived(
|
||||
[
|
||||
...(hasRefs ? ['70px'] : []),
|
||||
'minmax(200px, 1fr)',
|
||||
...columns.map(() => 'auto')
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
function cellColor(key: string, value: string): string | undefined {
|
||||
if (key === 'status') return statusColor(value);
|
||||
if (key === 'priority') return priorityColor(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function activate(item: PublicItem) {
|
||||
if (interactive) onactivate?.(item);
|
||||
}
|
||||
function onKey(e: KeyboardEvent, item: PublicItem) {
|
||||
if (!interactive) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onactivate?.(item);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="table-scroll">
|
||||
<div class="public-table" role="table" style:grid-template-columns={gridTemplate}>
|
||||
<div class="table-row table-header" role="row">
|
||||
{#if hasRefs}<div class="table-cell col-ref" role="columnheader">Ref</div>{/if}
|
||||
<div class="table-cell" role="columnheader">Title</div>
|
||||
{#each columns as field (field.key)}
|
||||
<div class="table-cell" role="columnheader">{field.label || formatLabel(field.key)}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each items as item (item.key)}
|
||||
<div
|
||||
class="table-row"
|
||||
class:interactive
|
||||
role="row"
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={interactive ? () => activate(item) : undefined}
|
||||
onkeydown={interactive ? (e) => onKey(e, item) : undefined}
|
||||
>
|
||||
{#if hasRefs}
|
||||
<div class="table-cell col-ref" role="cell"><span class="ref">{item.ref}</span></div>
|
||||
{/if}
|
||||
<div class="table-cell col-title" role="cell">
|
||||
<span class="title">{item.title}</span>
|
||||
</div>
|
||||
{#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}
|
||||
<div class="table-cell" role="cell">
|
||||
{#if field.key === 'status' && text}
|
||||
<span class="cell-status" style:color>{formatLabel(text).toUpperCase()}</span>
|
||||
{:else if color && text}
|
||||
<span class="cell-value" style:color>{formatLabel(text)}</span>
|
||||
{:else}
|
||||
<span class="cell-value">{text}{field.suffix && text ? ` ${field.suffix}` : ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.public-table {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
border-bottom: 1px solid var(--border-subtle, var(--border));
|
||||
}
|
||||
|
||||
@supports not (grid-template-columns: subgrid) {
|
||||
.table-row {
|
||||
grid-template-columns: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.table-row.table-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-primary);
|
||||
z-index: 1;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.table-row:not(.table-header) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 36px;
|
||||
}
|
||||
|
||||
.table-row.interactive:not(.table-header) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.table-row.interactive:not(.table-header):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.table-row.interactive:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-header .table-cell {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-status {
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
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<string, unknown>).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<string, PublicItem[]>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user