From 0d44aa2ecfe2460c5e280cd58f6aa4986fa551f8 Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Tue, 12 May 2026 03:02:51 +0200
Subject: [PATCH] test-cards
---
src/lib/cards/_base/Card/Card.svelte | 9 +-
src/lib/cards/_base/Card/EditingCard.svelte | 17 +-
src/lib/website/CardSettingsSidebar.svelte | 252 ++++++++------
src/routes/test-cards/+layout.ts | 2 +
src/routes/test-cards/+page.svelte | 17 +
src/routes/test-cards/build-data.ts | 346 ++++++++++++++++++++
src/routes/test-cards/edit/+page.svelte | 17 +
7 files changed, 550 insertions(+), 110 deletions(-)
create mode 100644 src/routes/test-cards/+layout.ts
create mode 100644 src/routes/test-cards/+page.svelte
create mode 100644 src/routes/test-cards/build-data.ts
create mode 100644 src/routes/test-cards/edit/+page.svelte
diff --git a/src/lib/cards/_base/Card/Card.svelte b/src/lib/cards/_base/Card/Card.svelte
index 2bbcc6b..123fba5 100644
--- a/src/lib/cards/_base/Card/Card.svelte
+++ b/src/lib/cards/_base/Card/Card.svelte
@@ -7,7 +7,14 @@
{#if CardDefinitionsByType[item.cardType]}
{@const cardDef = CardDefinitionsByType[item.cardType]}
-
+
+
+ {#snippet failed(error)}
+
+ Failed to render {item.cardType}: {(error as Error)?.message ?? String(error)}
+
+ {/snippet}
+
{:else}
Unsupported card type: {item.cardType}
{/if}
diff --git a/src/lib/cards/_base/Card/EditingCard.svelte b/src/lib/cards/_base/Card/EditingCard.svelte
index 36b9727..2cb3fee 100644
--- a/src/lib/cards/_base/Card/EditingCard.svelte
+++ b/src/lib/cards/_base/Card/EditingCard.svelte
@@ -7,11 +7,18 @@
{#if CardDefinitionsByType[item.cardType]}
{@const cardDef = CardDefinitionsByType[item.cardType]}
- {#if cardDef.editingContentComponent}
-
- {:else}
-
- {/if}
+
+ {#if cardDef.editingContentComponent}
+
+ {:else}
+
+ {/if}
+ {#snippet failed(error)}
+
+ Failed to render {item.cardType}: {(error as Error)?.message ?? String(error)}
+
+ {/snippet}
+
{:else}
Unsupported card type: {item.cardType}
{/if}
diff --git a/src/lib/website/CardSettingsSidebar.svelte b/src/lib/website/CardSettingsSidebar.svelte
index d62120e..a6adc13 100644
--- a/src/lib/website/CardSettingsSidebar.svelte
+++ b/src/lib/website/CardSettingsSidebar.svelte
@@ -3,6 +3,7 @@
import { CardDefinitionsByType, getColor } from '$lib/cards';
import type { Item } from '$lib/types';
import { ColorSelect } from '@foxui/colors';
+ import { dev } from '$app/environment';
let {
open,
@@ -95,6 +96,19 @@
const selectedCustomColor = $derived(
colorMode === 'custom' ? allColorChoices.find((c) => c.label === currentColor) : undefined
);
+
+ type TabId = 'content' | 'design';
+ const allTabs: { id: TabId; label: string }[] = [
+ { id: 'content', label: 'Content' },
+ { id: 'design', label: 'Design' }
+ ];
+ const tabs = $derived(allTabs.filter((t) => t.id !== 'content' || cardDef?.settingsComponent));
+ let activeTab: TabId = $state('content');
+ $effect(() => {
+ // When the selected card changes, default to Content if available, else Design.
+ item?.id;
+ activeTab = cardDef?.settingsComponent ? 'content' : 'design';
+ });
diff --git a/src/routes/test-cards/+layout.ts b/src/routes/test-cards/+layout.ts
new file mode 100644
index 0000000..83addb7
--- /dev/null
+++ b/src/routes/test-cards/+layout.ts
@@ -0,0 +1,2 @@
+export const ssr = false;
+export const prerender = false;
diff --git a/src/routes/test-cards/+page.svelte b/src/routes/test-cards/+page.svelte
new file mode 100644
index 0000000..c69d991
--- /dev/null
+++ b/src/routes/test-cards/+page.svelte
@@ -0,0 +1,17 @@
+
+
+
+ Card test · view
+
+
+{#await ready}
+ Loading card data…
+{:then loaded}
+
+{/await}
diff --git a/src/routes/test-cards/build-data.ts b/src/routes/test-cards/build-data.ts
new file mode 100644
index 0000000..b2ff78f
--- /dev/null
+++ b/src/routes/test-cards/build-data.ts
@@ -0,0 +1,346 @@
+import { COLUMNS } from '$lib';
+import { AllCardDefinitions, CardDefinitionsByType } from '$lib/cards';
+import { fixAllCollisions, compactItems } from '$lib/layout';
+import type { Item, SectionRecord, WebsiteData } from '$lib/types';
+import { createEmptyCard } from '$lib/helper';
+import * as TID from '@atcute/tid';
+import type { Did } from '@atcute/lexicons';
+import type { AppBskyActorDefs } from '@atcute/bluesky';
+
+// All cards point at this real PDS so any card that pulls data from the
+// page's DID (atprotocollections, npmxLikes, margin, rpgActor, kichRecipe…)
+// shows real data without hand-fed records.
+const TEST_DID = 'did:plc:257wekqxg4hyapkq6k47igmp' as Did;
+const TEST_HANDLE = 'flo-bit.dev';
+
+// Categories rendered top-to-bottom. Cards without a group fall into "Other".
+const CATEGORY_ORDER = [
+ 'Core',
+ 'Sections',
+ 'Content',
+ 'Utilities',
+ 'Visual',
+ 'Games',
+ 'Media',
+ 'Social',
+ 'Other'
+] as const;
+
+// Hand-picked test data for card types where the default is not enough.
+// Anything not listed falls back to whatever `createNew` produces.
+const CARD_DATA_PRESETS: Record) => void> = {
+ link: (d) => {
+ d.href = 'https://blento.app';
+ d.domain = 'blento.app';
+ d.label = 'Blento';
+ d.title = 'Blento';
+ d.description = 'Bluesky-powered bento grid websites.';
+ d.hasFetched = true;
+ },
+ image: (d) => {
+ d.image = 'https://picsum.photos/seed/blento/800/800';
+ d.alt = 'Random sample image';
+ },
+ mapLocation: (d) => {
+ // Berlin
+ d.lat = '52.5200';
+ d.lon = '13.4050';
+ d.name = 'Berlin';
+ d.type = 'city';
+ d.zoom = 10;
+ },
+ button: (d) => {
+ d.text = 'Click me';
+ d.href = 'https://blento.app';
+ },
+ countdown: (d) => {
+ const year = new Date().getUTCFullYear() + 1;
+ d.targetDate = new Date(Date.UTC(year, 0, 1, 0, 0, 0)).toISOString();
+ d.label = `New Year ${year}`;
+ },
+ statusphere: (d) => {
+ d.mode = 'emoji';
+ d.emoji = '👋';
+ },
+ 'fluid-text': (d) => {
+ d.text = 'FLUID';
+ },
+ githubProfile: (d) => {
+ d.user = 'flo-bit';
+ d.href = 'https://github.com/flo-bit';
+ },
+ githubContributors: (d) => {
+ d.owner = 'sveltejs';
+ d.repo = 'svelte';
+ },
+ bigsocial: (d) => {
+ d.platform = 'bluesky';
+ d.href = 'https://bsky.app';
+ },
+ blueskyPost: (d) => {
+ d.uri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6oveex3ii2l';
+ d.href = 'https://bsky.app/profile/bsky.app/post/3l6oveex3ii2l';
+ },
+ blueskyProfile: (d) => {
+ d.handle = 'bsky.app';
+ d.displayName = 'Bluesky';
+ d.avatar =
+ 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreih6cqcioehosbf2pikllesmegjxopcljpwcvwsskosygi6umm6kuq@jpeg';
+ },
+ blueskyFeed: (d) => {
+ d.handle = 'bsky.app';
+ d.did = 'did:plc:z72i7hdynmk6r22z27h6tvur';
+ },
+ latestPost: (d) => {
+ d.label = 'Latest bluesky post';
+ },
+ friends: (d) => {
+ d.friends = ['did:plc:z72i7hdynmk6r22z27h6tvur'];
+ },
+ blueskyMedia: (d) => {
+ d.image = {
+ fullsize: 'https://picsum.photos/seed/blueskymedia/800/800',
+ thumbnail: 'https://picsum.photos/seed/blueskymedia/400/400',
+ alt: 'Bluesky media placeholder'
+ };
+ d.href = 'https://bsky.app/profile/flo-bit.dev/post/3mlf4zqebms2t';
+ },
+ 'grain-gallery': (d) => {
+ d.galleryUri = 'at://did:plc:z5efhtji4vfiutzqquvx3w7o/social.grain.gallery/3mkstfoquxv2m';
+ d.href = 'https://grain.social/profile/did:plc:z5efhtji4vfiutzqquvx3w7o/gallery/3mkstfoquxv2m';
+ },
+ latestLivestream: (d) => {
+ d.href = 'https://stream.place/rtareview.stream';
+ },
+ livestreamEmbed: (d) => {
+ d.href = 'https://stream.place/rtareview.stream';
+ d.embed = 'https://stream.place/embed/rtareview.stream';
+ },
+ producthunt: (d) => {
+ d.imageSrc =
+ 'https://api.producthunt.com/widgets/embed-image/v1/product_rating.svg?product_id=1097839&theme=light';
+ d.linkHref =
+ 'https://www.producthunt.com/products/graphbit/reviews?utm_source=badge-product_rating&utm_medium=badge&utm_source=badge-graphbit';
+ },
+ kickstarter: (d) => {
+ d.src = 'https://www.kickstarter.com/projects/wraithmarked/hitchhikers/widget/card.html?v=2';
+ d.widgetType = 'card';
+ },
+ event: (d) => {
+ d.uri = 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mlloxhxtmcxh';
+ },
+ 'plyr-fm': (d) => {
+ d.href = 'https://plyr.fm/embed/track/976';
+ },
+ 'plyr-fm-collection': (d) => {
+ // User gave a profile URL, not an album/playlist. Use the user's profile URL anyway —
+ // PlyrFM will likely render an error iframe, but the card itself shouldn't crash.
+ d.href = 'https://plyr.fm/u/eurocity.band';
+ },
+ recentTealFMPlays: (d) => {
+ d.handle = 'flo-bit.dev';
+ },
+ recentRockskyPlays: (d) => {
+ d.handle = 'flo-bit.dev';
+ },
+ vcard: (d) => {
+ d.vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'N:Tester;Card;;;',
+ 'FN:Card Tester',
+ 'ORG:Blento',
+ 'TITLE:Test Page',
+ 'URL:https://blento.app',
+ 'END:VCARD'
+ ].join('\n');
+ },
+ youtubeVideo: (d) => {
+ d.youtubeId = 'dQw4w9WgXcQ';
+ d.href = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
+ d.poster = 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg';
+ d.showInline = false;
+ },
+ 'spotify-list-embed': (d) => {
+ d.spotifyType = 'track';
+ d.spotifyId = '4cOdK2wGLETKBW3PvgPWqT';
+ d.href = 'https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT';
+ },
+ 'apple-music-embed': (d) => {
+ d.appleMusicStorefront = 'us';
+ d.appleMusicType = 'album';
+ d.appleMusicId = '1440857781';
+ d.href = 'https://music.apple.com/us/album/1440857781';
+ },
+ 'soundcloud-embed': (d) => {
+ d.href = 'https://soundcloud.com/forss/flickermood';
+ },
+ gif: (d) => {
+ d.url = 'https://media.giphy.com/media/dzaUX7CAG0Ihi/giphy.mp4';
+ d.alt = 'Test GIF';
+ },
+ lastfmRecentTracks: (d) => {
+ d.lastfmUsername = 'rj';
+ },
+ lastfmTopTracks: (d) => {
+ d.lastfmUsername = 'rj';
+ d.period = '7day';
+ },
+ lastfmTopAlbums: (d) => {
+ d.lastfmUsername = 'rj';
+ d.period = '7day';
+ },
+ lastfmProfile: (d) => {
+ d.lastfmUsername = 'rj';
+ },
+ listenbrainzRecentListens: (d) => {
+ d.username = 'rob';
+ },
+ listenbrainzTopArtists: (d) => {
+ d.username = 'rob';
+ },
+ listenbrainzTopAlbums: (d) => {
+ d.username = 'rob';
+ },
+ listenbrainzTopSongs: (d) => {
+ d.username = 'rob';
+ },
+ listenbrainzNowPlaying: (d) => {
+ d.username = 'rob';
+ }
+};
+
+function buildHeadingCard(page: string, sectionId: string, title: string): Item {
+ const card = createEmptyCard(page, sectionId);
+ card.cardType = 'section';
+ card.cardData = {
+ text: title,
+ verticalAlign: 'bottom',
+ textSize: 1
+ };
+ card.w = COLUMNS;
+ card.h = 1;
+ card.mobileW = COLUMNS;
+ card.mobileH = 1;
+ card.x = 0;
+ card.y = 0;
+ card.mobileX = 0;
+ card.mobileY = 0;
+ card.version = 2;
+ return card;
+}
+
+export function buildTestData(): WebsiteData {
+ const page = 'blento.self';
+ const sectionId = TID.now();
+
+ const section: SectionRecord = {
+ id: sectionId,
+ sectionType: 'grid',
+ page,
+ index: 0,
+ sectionData: {},
+ version: 1
+ };
+
+ // Bucket cards by category, preserving order from AllCardDefinitions.
+ const buckets = new Map();
+ for (const def of AllCardDefinitions) {
+ if (def.type === 'section') continue;
+ const category = (def.groups?.[0] as string | undefined) ?? 'Other';
+ const bucket = buckets.get(category) ?? [];
+ bucket.push(def);
+ buckets.set(category, bucket);
+ }
+
+ const orderedCategories = [
+ ...CATEGORY_ORDER.filter((c) => buckets.has(c)),
+ ...[...buckets.keys()].filter((c) => !(CATEGORY_ORDER as readonly string[]).includes(c))
+ ];
+
+ const cards: Item[] = [];
+
+ for (const category of orderedCategories) {
+ const defs = buckets.get(category);
+ if (!defs?.length) continue;
+
+ cards.push(buildHeadingCard(page, sectionId, category));
+
+ for (const def of defs) {
+ const card = createEmptyCard(page, sectionId);
+ card.cardType = def.type;
+ def.createNew?.(card);
+
+ const preset = CARD_DATA_PRESETS[def.type];
+ if (preset) preset(card.cardData ?? (card.cardData = {}));
+
+ const w = Math.max(card.w, def.minW ?? 2);
+ const h = Math.max(card.h, def.minH ?? 2);
+ card.w = Math.min(w, def.maxW ?? COLUMNS);
+ card.h = Math.min(h, def.maxH ?? 16);
+ card.mobileW = Math.max(card.mobileW, Math.min(card.w * 2, COLUMNS));
+ card.mobileH = Math.max(card.mobileH, card.h);
+
+ card.x = 0;
+ card.y = 0;
+ card.mobileX = 0;
+ card.mobileY = 0;
+ card.version = 2;
+
+ cards.push(card);
+ }
+ }
+
+ fixAllCollisions(cards, false);
+ fixAllCollisions(cards, true);
+ compactItems(cards, false);
+ compactItems(cards, true);
+
+ const profile: AppBskyActorDefs.ProfileViewDetailed = {
+ did: TEST_DID,
+ handle: TEST_HANDLE as `${string}.${string}`,
+ displayName: 'Card Test Page',
+ description: 'Every card type, rendered against flo-bit.dev for live data.'
+ };
+
+ return {
+ page,
+ did: TEST_DID,
+ handle: TEST_HANDLE,
+ cards,
+ sections: [section],
+ publication: {
+ name: 'Card Test Page',
+ description: 'Every card type, rendered against flo-bit.dev for live data.'
+ },
+ additionalData: {},
+ profile,
+ updatedAt: Date.now(),
+ version: 1
+ };
+}
+
+/**
+ * Populate `data.additionalData` by invoking each card type's `loadData`
+ * client-side. We can't run `loadDataServer` here (needs platform/env), so
+ * cards that only define server loaders will appear empty.
+ */
+export async function loadAllAdditionalData(data: WebsiteData): Promise {
+ const cardTypes = new Set(data.cards.map((c) => c.cardType));
+ await Promise.all(
+ [...cardTypes].map(async (cardType) => {
+ const cardDef = CardDefinitionsByType[cardType];
+ if (!cardDef?.loadData) return;
+ const items = data.cards.filter((c) => c.cardType === cardType);
+ try {
+ const result = await cardDef.loadData(items, {
+ did: data.did as Did,
+ handle: data.handle
+ });
+ data.additionalData[cardType] = result;
+ } catch (err) {
+ console.error('test-cards: loadData failed for', cardType, err);
+ }
+ })
+ );
+}
diff --git a/src/routes/test-cards/edit/+page.svelte b/src/routes/test-cards/edit/+page.svelte
new file mode 100644
index 0000000..caa3c45
--- /dev/null
+++ b/src/routes/test-cards/edit/+page.svelte
@@ -0,0 +1,17 @@
+
+
+
+ Card test · edit
+
+
+{#await ready}
+ Loading card data…
+{:then loaded}
+
+{/await}
--
2.51.2