From f534f058a50c57317dd5974af1e1452af7286cb9 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Fri, 5 Jun 2026 09:45:04 -0700 Subject: [PATCH] add skyboard card --- src/lib/cards/index.ts | 4 +- .../CreateSkyboardCardModal.svelte | 53 +++++ .../social/SkyboardCard/SkyboardCard.svelte | 189 ++++++++++++++++++ .../cards/social/SkyboardCard/api.remote.ts | 59 ++++++ src/lib/cards/social/SkyboardCard/index.ts | 116 +++++++++++ src/lib/cards/social/SkyboardCard/types.ts | 48 +++++ src/lib/helpers/cache.ts | 1 + 7 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src/lib/cards/social/SkyboardCard/CreateSkyboardCardModal.svelte create mode 100644 src/lib/cards/social/SkyboardCard/SkyboardCard.svelte create mode 100644 src/lib/cards/social/SkyboardCard/api.remote.ts create mode 100644 src/lib/cards/social/SkyboardCard/index.ts create mode 100644 src/lib/cards/social/SkyboardCard/types.ts diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index c6489a4..1425a9b 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -68,6 +68,7 @@ import { RPGActorCardDefinition } from './social/RPGActorCard'; import { ButtondownCardDefinition } from './social/ButtondownCard'; import { BufoStatusCardDefinition } from './social/BufoStatusCard'; import { VideoCardDefinition } from './media/VideoCard'; +import { SkyboardCardDefinition } from './social/SkyboardCard'; // import { Model3DCardDefinition } from './visual/Model3DCard'; export const AllCardDefinitions = [ @@ -141,7 +142,8 @@ export const AllCardDefinitions = [ SecretImageCardDefinition, RPGActorCardDefinition, ButtondownCardDefinition, - BufoStatusCardDefinition + BufoStatusCardDefinition, + SkyboardCardDefinition ] as const; export const CardDefinitionsByType = AllCardDefinitions.reduce( diff --git a/src/lib/cards/social/SkyboardCard/CreateSkyboardCardModal.svelte b/src/lib/cards/social/SkyboardCard/CreateSkyboardCardModal.svelte new file mode 100644 index 0000000..5e50329 --- /dev/null +++ b/src/lib/cards/social/SkyboardCard/CreateSkyboardCardModal.svelte @@ -0,0 +1,53 @@ + + + +
{ + const input = item.cardData.href?.trim(); + if (!input) return; + + const parsed = parseSkyboardUrl(input); + if (!parsed) { + errorMessage = 'Please enter a valid Skyboard board URL'; + return; + } + + item.cardData.href = input; + item.cardData.did = parsed.did; + item.cardData.rkey = parsed.rkey; + + item.w = 6; + item.h = 4; + item.mobileW = 8; + item.mobileH = 8; + + oncreate?.(); + }} + class="flex flex-col gap-2" + > + Enter a Skyboard board URL + + + {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ + +
+
+
diff --git a/src/lib/cards/social/SkyboardCard/SkyboardCard.svelte b/src/lib/cards/social/SkyboardCard/SkyboardCard.svelte new file mode 100644 index 0000000..cfea0e6 --- /dev/null +++ b/src/lib/cards/social/SkyboardCard/SkyboardCard.svelte @@ -0,0 +1,189 @@ + + +
+ +
+
+ + + + + + {board?.board.name ?? 'Skyboard'} + +
+ + Edit + + + + +
+ + + {#if board} +
+ {#each columns as col (col.id)} +
+
+ + {col.name} + + + {col.tasks.length} + +
+
+ {#each col.tasks as task (task.uri)} +
+

+ {task.effectiveTitle} +

+ {#if task.effectiveDescription} +

+ {task.effectiveDescription} +

+ {/if} + {#if task.effectiveLabelIds.length > 0} +
+ {#each task.effectiveLabelIds as labelId (labelId)} + {@const label = labelsById.get(labelId)} + {#if label} + {@const color = labelColor(label.color)} + + {label.name} + + {/if} + {/each} +
+ {/if} +
+ {:else} +
+ — +
+ {/each} +
+
+ {/each} +
+ {:else} +
+ {#if loading} + Loading board… + {:else if did && rkey} + Couldn't load this board. + {:else} + No board selected. + {/if} +
+ {/if} +
diff --git a/src/lib/cards/social/SkyboardCard/api.remote.ts b/src/lib/cards/social/SkyboardCard/api.remote.ts new file mode 100644 index 0000000..2de69c0 --- /dev/null +++ b/src/lib/cards/social/SkyboardCard/api.remote.ts @@ -0,0 +1,59 @@ +import { query, getRequestEvent } from '$app/server'; +import { createCache } from '$lib/helpers/cache'; +import type { SkyboardBoardData } from './types'; + +const APPVIEW_URL = 'https://appview.skyboard.dev'; + +/** + * Fetch a readonly, fully-materialized skyboard board from the appview. + * The appview applies LWW op resolution and task ordering, so we only render. + */ +export const fetchSkyboardBoard = query( + 'unchecked', + async ({ did, rkey }: { did: string; rkey: string }): Promise => { + const { platform } = getRequestEvent(); + const cache = createCache(platform); + + const cacheKey = `${did}/${rkey}`; + const cached = await cache?.getJSON('skyboard', cacheKey); + if (cached) return cached; + + const response = await fetch( + `${APPVIEW_URL}/board/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}` + ); + if (!response.ok) return undefined; + + const data = await response.json(); + if (!data?.board) return undefined; + + // Trim to what the card renders before caching. + const trimmed: SkyboardBoardData = { + board: { + uri: data.board.uri, + did: data.board.did, + rkey: data.board.rkey, + name: data.board.name, + description: data.board.description ?? null, + columns: data.board.columns ?? [], + labels: data.board.labels ?? [], + open: Boolean(data.board.open), + createdAt: data.board.createdAt + }, + tasks: (data.tasks ?? []).map((t: SkyboardBoardData['tasks'][number]) => ({ + uri: t.uri, + did: t.did, + rkey: t.rkey, + effectiveTitle: t.effectiveTitle, + effectiveDescription: t.effectiveDescription, + effectiveColumnId: t.effectiveColumnId, + effectiveParentTaskUri: t.effectiveParentTaskUri, + effectivePosition: t.effectivePosition, + effectiveLabelIds: t.effectiveLabelIds ?? [], + createdAt: t.createdAt + })) + }; + + await cache?.putJSON('skyboard', cacheKey, trimmed); + return trimmed; + } +); diff --git a/src/lib/cards/social/SkyboardCard/index.ts b/src/lib/cards/social/SkyboardCard/index.ts new file mode 100644 index 0000000..037fccc --- /dev/null +++ b/src/lib/cards/social/SkyboardCard/index.ts @@ -0,0 +1,116 @@ +import type { CardDefinition } from '../../types'; +import CreateSkyboardCardModal from './CreateSkyboardCardModal.svelte'; +import SkyboardCard from './SkyboardCard.svelte'; +import SourceSettings from '../../_settings/SourceSettings.svelte'; +import { fetchSkyboardBoard } from './api.remote'; +import type { SkyboardBoardData, SkyboardLoadedData } from './types'; + +export type { SkyboardLoadedData }; + +async function loadBoards(items: { cardData: Record }[]) { + const boards: Record = {}; + for (const item of items) { + const did = item.cardData.did as string | undefined; + const rkey = item.cardData.rkey as string | undefined; + if (!did || !rkey) continue; + try { + const data = await fetchSkyboardBoard({ did, rkey }); + if (data) boards[`${did}/${rkey}`] = data; + } catch (error) { + console.error('Failed to fetch skyboard board:', error); + } + } + return boards; +} + +export const SkyboardCardDefinition = { + type: 'skyboard', + contentComponent: SkyboardCard, + creationModalComponent: CreateSkyboardCardModal, + settingsComponent: SourceSettings, + source: { + label: 'Board URL', + placeholder: 'skyboard.dev/board/did:plc:.../rkey', + errorMessage: "That doesn't look like a Skyboard board link" + }, + + // loadDataServer takes precedence on SSR; fetchSkyboardBoard does its own KV + // caching (`skyboard` namespace), so cacheLoadData/SWR isn't needed here. + loadData: loadBoards, + loadDataServer: loadBoards, + + createNew: (card) => { + card.cardData = {}; + card.w = 6; + card.h = 4; + card.mobileW = 8; + card.mobileH = 8; + }, + + onUrlHandler: (url, item) => { + const parsed = parseSkyboardUrl(url); + if (!parsed) return null; + + item.cardData.href = url; + item.cardData.did = parsed.did; + item.cardData.rkey = parsed.rkey; + + item.w = 6; + item.h = 4; + item.mobileW = 8; + item.mobileH = 8; + return item; + }, + urlHandlerPriority: 5, + + canChange: (item) => Boolean(parseSkyboardUrl(item.cardData.href)), + change: (item) => { + const parsed = parseSkyboardUrl(item.cardData.href); + if (parsed) { + item.cardData.did = parsed.did; + item.cardData.rkey = parsed.rkey; + } + return item; + }, + + minW: 2, + minH: 2, + + name: 'Skyboard', + keywords: ['kanban', 'board', 'tasks', 'todo', 'project'], + groups: ['Social'], + icon: `` +} as CardDefinition & { type: 'skyboard' }; + +/** + * Parse a skyboard board reference into { did, rkey }. + * Accepts: + * https://skyboard.dev/board/{did}/{rkey} + * at://{did}/dev.skyboard.board/{rkey} + */ +export function parseSkyboardUrl( + url: string | undefined +): { did: string; rkey: string } | undefined { + if (!url) return; + const trimmed = url.trim(); + + // at:// URI form + const atMatch = trimmed.match(/^at:\/\/(did:[^/]+)\/dev\.skyboard\.board\/([^/?#]+)/); + if (atMatch) { + return { did: atMatch[1], rkey: atMatch[2] }; + } + + try { + const parsed = new URL(trimmed); + if (!/^(www\.)?skyboard\.dev$/.test(parsed.hostname)) return; + + const segments = parsed.pathname.split('/').filter(Boolean); + // /board/{did}/{rkey} + if (segments.length === 3 && segments[0] === 'board' && segments[1].startsWith('did:')) { + return { did: segments[1], rkey: segments[2] }; + } + return; + } catch { + return; + } +} diff --git a/src/lib/cards/social/SkyboardCard/types.ts b/src/lib/cards/social/SkyboardCard/types.ts new file mode 100644 index 0000000..f13a0d5 --- /dev/null +++ b/src/lib/cards/social/SkyboardCard/types.ts @@ -0,0 +1,48 @@ +// Subset of skyboard's appview `BoardResponse` that this readonly card renders. +// Source of truth: skyboard/appview/src/api/get-board.ts + +export interface SkyboardColumn { + id: string; + name: string; + order: number; +} + +export interface SkyboardLabel { + id: string; + name: string; + color: string; // CSS color string (usually hex) + description?: string; +} + +export interface SkyboardTask { + uri: string; + did: string; + rkey: string; + effectiveTitle: string; + effectiveDescription?: string; + effectiveColumnId: string; + effectiveParentTaskUri?: string; + effectivePosition: string; + effectiveLabelIds: string[]; + createdAt: string; +} + +export interface SkyboardBoard { + uri: string; + did: string; + rkey: string; + name: string; + description: string | null; + columns: SkyboardColumn[]; + labels: SkyboardLabel[]; + open: boolean; + createdAt: string; +} + +export interface SkyboardBoardData { + board: SkyboardBoard; + tasks: SkyboardTask[]; +} + +// Loaded data is keyed by `${did}/${rkey}`. +export type SkyboardLoadedData = Record; diff --git a/src/lib/helpers/cache.ts b/src/lib/helpers/cache.ts index e5cf608..5f3f1e6 100644 --- a/src/lib/helpers/cache.ts +++ b/src/lib/helpers/cache.ts @@ -9,6 +9,7 @@ const NAMESPACE_TTL = { lastfm: 60 * 60, // 1 hour (default, overridable per-put) listenbrainz: 60 * 60, // 1 hour (default, overridable per-put) npmx: 60 * 60 * 12, // 12 hours + skyboard: 60 * 5, // 5 minutes (collaborative boards change often) og: 60 * 60 * 24 * 30, // 30 days profile: 60 * 60 * 24, // 24 hours ical: 60 * 60 * 2, // 2 hours