Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib/cards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -141,7 +142,8 @@ export const AllCardDefinitions = [
SecretImageCardDefinition,
RPGActorCardDefinition,
ButtondownCardDefinition,
BufoStatusCardDefinition
BufoStatusCardDefinition,
SkyboardCardDefinition
] as const;

export const CardDefinitionsByType = AllCardDefinitions.reduce(
Expand Down
53 changes: 53 additions & 0 deletions src/lib/cards/social/SkyboardCard/CreateSkyboardCardModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import { Button, Input, Subheading } from '@foxui/core';
import Modal from '$lib/components/modal/Modal.svelte';
import type { CreationModalComponentProps } from '../../types';
import { parseSkyboardUrl } from './index';

let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();

let errorMessage = $state('');
</script>

<Modal open={true} closeButton={false}>
<form
onsubmit={() => {
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"
>
<Subheading>Enter a Skyboard board URL</Subheading>
<Input
bind:value={item.cardData.href}
placeholder="https://skyboard.dev/board/did:plc:.../rkey"
class="mt-4"
/>

{#if errorMessage}
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
{/if}

<div class="mt-4 flex justify-end gap-2">
<Button onclick={oncancel} variant="ghost">Cancel</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Modal>
189 changes: 189 additions & 0 deletions src/lib/cards/social/SkyboardCard/SkyboardCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getAdditionalUserData } from '$lib/website/data/context';
import type { ContentComponentProps } from '../../types';
import type { SkyboardBoardData, SkyboardLabel, SkyboardLoadedData } from './types';
import { fetchSkyboardBoard } from './api.remote';

let { item }: ContentComponentProps = $props();

const did = $derived(item.cardData.did as string | undefined);
const rkey = $derived(item.cardData.rkey as string | undefined);
const key = $derived(did && rkey ? `${did}/${rkey}` : undefined);
const href = $derived(
(item.cardData.href as string | undefined) ??
(did && rkey ? `https://skyboard.dev/board/${did}/${rkey}` : 'https://skyboard.dev')
);

const additional = getAdditionalUserData();

// svelte-ignore state_referenced_locally
let board = $state<SkyboardBoardData | undefined>(
key ? (additional[item.cardType] as SkyboardLoadedData)?.[key] : undefined
);
let loading = $state(false);

onMount(async () => {
if (board || !did || !rkey || !key) return;
loading = true;
try {
const data = await fetchSkyboardBoard({ did, rkey });
if (data) {
board = data;
additional[item.cardType] ??= {};
(additional[item.cardType] as SkyboardLoadedData)[key] = data;
}
} catch (error) {
console.error('Failed to fetch skyboard board:', error);
} finally {
loading = false;
}
});

const labelsById = $derived(
new Map<string, SkyboardLabel>((board?.board.labels ?? []).map((l) => [l.id, l]))
);

// Label colors come from arbitrary third-party board data. Only trust 6-digit
// hex so we can safely append an alpha suffix and avoid CSS injection.
function labelColor(color: string | undefined): string {
return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#888888';
}

const columns = $derived(
[...(board?.board.columns ?? [])]
.sort((a, b) => a.order - b.order)
.map((col) => ({
...col,
tasks: (board?.tasks ?? [])
.filter((t) => t.effectiveColumnId === col.id && !t.effectiveParentTaskUri)
// effectivePosition is a fractional-indexing key: compare by code point,
// not locale collation, to match skyboard's own ordering.
.sort((a, b) =>
a.effectivePosition < b.effectivePosition
? -1
: a.effectivePosition > b.effectivePosition
? 1
: 0
)
}))
);
</script>

<div class="@container flex h-full w-full flex-col overflow-hidden">
<!-- Header -->
<div class="flex shrink-0 items-center justify-between gap-2 px-3 pt-3 pb-2">
<div class="flex min-w-0 items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-accent-500 accent:text-white size-4 shrink-0"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M15 3v18" />
</svg>
<span
class="text-base-900 dark:text-base-100 accent:text-white min-w-0 truncate text-sm font-semibold"
>
{board?.board.name ?? 'Skyboard'}
</span>
</div>
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="text-base-500 hover:text-accent-500 dark:text-base-400 accent:text-white/70 accent:hover:text-white flex shrink-0 items-center gap-1 text-xs font-medium transition-colors"
>
Edit
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
>
<path d="M7 17 17 7M7 7h10v10" />
</svg>
</a>
</div>

<!-- Body -->
{#if board}
<div class="flex min-h-0 flex-1 gap-2 overflow-x-auto px-3 pb-3">
{#each columns as col (col.id)}
<div class="flex w-44 shrink-0 flex-col">
<div class="mb-1.5 flex items-center gap-1.5 px-0.5">
<span
class="text-base-700 dark:text-base-300 accent:text-white truncate text-xs font-semibold tracking-wide uppercase"
>
{col.name}
</span>
<span class="text-base-400 dark:text-base-500 accent:text-white/50 text-xs">
{col.tasks.length}
</span>
</div>
<div class="flex min-h-0 flex-col gap-1.5 overflow-y-auto">
{#each col.tasks as task (task.uri)}
<div class="bg-base-100 dark:bg-base-800 accent:bg-white/10 rounded-lg p-2 text-left">
<p
class="text-base-900 dark:text-base-100 accent:text-white text-xs leading-snug font-medium"
>
{task.effectiveTitle}
</p>
{#if task.effectiveDescription}
<p
class="text-base-500 dark:text-base-400 accent:text-white/60 mt-1 line-clamp-2 text-[11px] leading-snug"
>
{task.effectiveDescription}
</p>
{/if}
{#if task.effectiveLabelIds.length > 0}
<div class="mt-1.5 flex flex-wrap gap-1">
{#each task.effectiveLabelIds as labelId (labelId)}
{@const label = labelsById.get(labelId)}
{#if label}
{@const color = labelColor(label.color)}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
style="background: {color}20; color: {color};"
>
{label.name}
</span>
{/if}
{/each}
</div>
{/if}
</div>
{:else}
<div
class="text-base-300 dark:text-base-600 accent:text-white/30 px-0.5 py-1 text-xs"
>
&mdash;
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div
class="text-base-400 dark:text-base-500 accent:text-white/60 flex flex-1 items-center justify-center px-3 pb-3 text-center text-sm"
>
{#if loading}
Loading board…
{:else if did && rkey}
Couldn't load this board.
{:else}
No board selected.
{/if}
</div>
{/if}
</div>
59 changes: 59 additions & 0 deletions src/lib/cards/social/SkyboardCard/api.remote.ts
Original file line number Diff line number Diff line change
@@ -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<SkyboardBoardData | undefined> => {
const { platform } = getRequestEvent();
const cache = createCache(platform);

const cacheKey = `${did}/${rkey}`;
const cached = await cache?.getJSON<SkyboardBoardData>('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;
}
);
Loading
Loading