From 4af6c197bbdd1f8e04d9e6759c7cad16b89c3f50 Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Wed, 27 May 2026 23:02:06 -0700 Subject: [PATCH 1/4] feat: derakkuma cards --- src/lib/cards/index.ts | 10 + .../DerakkumaCards/DerakkumaAvatar.svelte | 21 ++ .../DerakkumaCards/DerakkumaCircleCard.svelte | 101 +++++++++ .../DerakkumaProfileCard.svelte | 91 ++++++++ .../DerakkumaCards/DerakkumaScoresCard.svelte | 83 +++++++ src/lib/cards/media/DerakkumaCards/index.ts | 95 ++++++++ src/lib/cards/media/DerakkumaCards/shared.ts | 214 ++++++++++++++++++ 7 files changed, 615 insertions(+) create mode 100644 src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte create mode 100644 src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte create mode 100644 src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte create mode 100644 src/lib/cards/media/DerakkumaCards/DerakkumaScoresCard.svelte create mode 100644 src/lib/cards/media/DerakkumaCards/index.ts create mode 100644 src/lib/cards/media/DerakkumaCards/shared.ts diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 7c9b775..b7cc343 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -24,6 +24,12 @@ import { GifCardDefinition } from './media/GIFCard'; import { PopfeedReviewsCardDefinition } from './media/PopfeedReviews'; import { TealFMPlaysCardDefinition } from './media/TealFMPlaysCard'; import { RockskyPlaysCardDefinition } from './media/RockskyPlaysCard'; +import { + DerakkumaBestsCardDefinition, + DerakkumaCircleCardDefinition, + DerakkumaProfileCardDefinition, + DerakkumaRecentPlaysCardDefinition +} from './media/DerakkumaCards'; import { PhotoGalleryCardDefinition } from './media/PhotoGalleryCard'; import { StandardSiteDocumentListCardDefinition } from './content/StandardSiteDocumentListCard'; import { StatusphereCardDefinition } from './media/StatusphereCard'; @@ -95,6 +101,10 @@ export const AllCardDefinitions = [ PopfeedReviewsCardDefinition, TealFMPlaysCardDefinition, RockskyPlaysCardDefinition, + DerakkumaProfileCardDefinition, + DerakkumaCircleCardDefinition, + DerakkumaRecentPlaysCardDefinition, + DerakkumaBestsCardDefinition, PhotoGalleryCardDefinition, StandardSiteDocumentListCardDefinition, StatusphereCardDefinition, diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte new file mode 100644 index 0000000..983feb6 --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte @@ -0,0 +1,21 @@ + + +{#if src && !hasError} + { + hasError = true; + }} + /> +{:else} +
+ 🐻 +
+{/if} diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte new file mode 100644 index 0000000..612918c --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte @@ -0,0 +1,101 @@ + + +
+ {#if value} + {#if backgroundImage} + +
+ {/if} + +
+
+
+ {value.name || 'Derakkuma Circle'} +
+ +
+ {#if value.rank} + + Rank {value.rank} + + {/if} + {#if value.totalPoints !== undefined} + + {value.totalPoints} pts + + {/if} +
+ + {#if value.comment} +
+ {value.comment} +
+ {/if} + +
+ {#if value.ownerName} +
Owner {value.ownerName}
+ {/if} + {#if value.circleCode} +
Circle code {value.circleCode}
+ {/if} + {#if value.daysUntilReset && value.daysUntilReset > 0} +
{value.daysUntilReset} days left
+ {/if} + {#if value.nextRewardPoints && value.nextRewardPoints > 0} +
{value.nextRewardPoints} pts to next reward
+ {/if} +
+
+ + {#if characterImage} + + {/if} +
+ {:else} +
+ Loading Derakkuma circle... +
+ {/if} +
diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte new file mode 100644 index 0000000..140a388 --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte @@ -0,0 +1,91 @@ + + +
+ {#if value} +
+
+ +
+
+
+ {value.playerName || 'Derakkuma'} +
+ {#if value.friendCode} +
+ Friend code {value.friendCode} +
+ {/if} +
+ {#if trophyPlate} +
+ +
+ {value.title} +
+
+ {/if} +
+ {#if ratingPlate} +
+ +
+ {value.rating ?? 0} +
+
+ {/if} + {#if course}{/if} + {#if value.stars}⭐×{value.stars}{/if} + {#if classImage}{/if} +
+
+
+ {#if partner} + + {/if} +
+ {:else} +
+ Loading Derakkuma profile... +
+ {/if} +
diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaScoresCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaScoresCard.svelte new file mode 100644 index 0000000..cd13a6e --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaScoresCard.svelte @@ -0,0 +1,83 @@ + + +{#snippet fallbackArt()} +
+ ♪ +
+{/snippet} + +
+ {#if feed && feed.length > 0} + {#each feed as score (score.uri)} +
+
+ {#if score.coverArtUrl} + + {:else} + {@render fallbackArt()} + {/if} +
+
+
+
+ {scoreTitle(score)} +
+ {#if dateFor(score)} +
+ ago +
+ {/if} +
+
{scoreSubtitle(score)}
+
+ {scoreMeta(score)} +
+
+
+ {/each} + {:else if feed} +
+ No Derakkuma records found. +
+ {:else} +
+ Loading Derakkuma records... +
+ {/if} +
diff --git a/src/lib/cards/media/DerakkumaCards/index.ts b/src/lib/cards/media/DerakkumaCards/index.ts new file mode 100644 index 0000000..13c3ae7 --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/index.ts @@ -0,0 +1,95 @@ +import type { CardDefinition } from '../../types'; +import DerakkumaCircleCard from './DerakkumaCircleCard.svelte'; +import DerakkumaProfileCard from './DerakkumaProfileCard.svelte'; +import DerakkumaScoresCard from './DerakkumaScoresCard.svelte'; +import { + DERAKKUMA_BEST, + DERAKKUMA_CIRCLE, + DERAKKUMA_PLAY, + DERAKKUMA_PROFILE, + loadDerakkumaCircle, + loadDerakkumaProfile, + loadDerakkumaScores +} from './shared'; + +const icon = ``; + +export const DerakkumaProfileCardDefinition = { + type: 'derakkumaProfile', + contentComponent: DerakkumaProfileCard, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 5; + }, + loadData: async (items, { did }) => loadDerakkumaProfile(did), + cacheLoadData: true, + minW: 4, + canHaveLabel: true, + canAdd: ({ collections }) => collections.includes(DERAKKUMA_PROFILE), + keywords: ['maimai', 'derakkuma', 'sega', 'arcade', 'profile'], + name: 'Derakkuma Profile', + groups: ['Media'], + icon +} as CardDefinition & { type: 'derakkumaProfile' }; + +export const DerakkumaCircleCardDefinition = { + type: 'derakkumaCircle', + contentComponent: DerakkumaCircleCard, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 5; + }, + loadData: async (items, { did }) => loadDerakkumaCircle(did), + cacheLoadData: true, + minW: 4, + canHaveLabel: true, + canAdd: ({ collections }) => collections.includes(DERAKKUMA_CIRCLE), + keywords: ['maimai', 'derakkuma', 'sega', 'arcade', 'circle', 'team'], + name: 'Derakkuma Circle', + groups: ['Media'], + icon +} as CardDefinition & { type: 'derakkumaCircle' }; + +export const DerakkumaRecentPlaysCardDefinition = { + type: 'recentDerakkumaPlays', + contentComponent: DerakkumaScoresCard, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 6; + }, + loadData: async (items, { did }) => loadDerakkumaScores(did, DERAKKUMA_PLAY, 20), + cacheLoadData: true, + minW: 4, + canHaveLabel: true, + canAdd: ({ collections }) => collections.includes(DERAKKUMA_PLAY), + keywords: ['maimai', 'derakkuma', 'arcade', 'plays', 'scores'], + name: 'Derakkuma Plays', + groups: ['Media'], + icon +} as CardDefinition & { type: 'recentDerakkumaPlays' }; + +export const DerakkumaBestsCardDefinition = { + type: 'derakkumaBests', + contentComponent: DerakkumaScoresCard, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 6; + }, + loadData: async (items, { did }) => loadDerakkumaScores(did, DERAKKUMA_BEST, 20), + cacheLoadData: true, + minW: 4, + canHaveLabel: true, + canAdd: ({ collections }) => collections.includes(DERAKKUMA_BEST), + keywords: ['maimai', 'derakkuma', 'arcade', 'bests', 'scores'], + name: 'Derakkuma Bests', + groups: ['Media'], + icon +} as CardDefinition & { type: 'derakkumaBests' }; diff --git a/src/lib/cards/media/DerakkumaCards/shared.ts b/src/lib/cards/media/DerakkumaCards/shared.ts new file mode 100644 index 0000000..171257e --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/shared.ts @@ -0,0 +1,214 @@ +import { getRecord, listRecords, parseUri } from '$lib/atproto'; +import { getCDNImageBlobUrl } from '$lib/atproto/methods'; +import type { Did } from '@atcute/lexicons'; + +export const DERAKKUMA_PROFILE = 'com.derakkuma.profile'; +export const DERAKKUMA_PLAY = 'com.derakkuma.play'; +export const DERAKKUMA_BEST = 'com.derakkuma.best'; +export const DERAKKUMA_CIRCLE = 'com.derakkuma.circle'; + +type BlobRef = { + $type?: 'blob'; + ref?: { $link?: string } | string; + cid?: string; +}; + +type StrongRef = { + uri?: string; + cid?: string; +}; + +export type RepoRecord> = { + uri: string; + cid?: string; + value: T; +}; + +export type DerakkumaProfileValue = { + playerName?: string; + title?: string; + rating?: number; + stars?: number; + friendCode?: string; + profileImage?: BlobRef; + ratingPlateImage?: BlobRef; + trophyPlateImage?: BlobRef; + partnerImage?: BlobRef; + courseImage?: BlobRef; + classImage?: BlobRef; +}; + +export type DerakkumaScoreValue = { + chart?: StrongRef | Record; + achievement?: string | number; + scoreRank?: string; + fcStatus?: string; + syncStatus?: string; + playedAt?: string; + createdAt?: string; + updatedAt?: string; + playCount?: number; +}; + +export type DerakkumaCircleValue = { + name?: string; + comment?: string; + rank?: number; + totalPoints?: number; + circleCode?: string; + ownerName?: string; + month?: string; + daysUntilReset?: number; + nextRewardPoints?: number; + characterImage?: BlobRef; + backgroundImage?: BlobRef; + updatedAt?: string; + createdAt?: string; +}; + +type DerakkumaSongValue = { + title?: string; + artist?: string; + coverArt?: BlobRef; +}; + +type DerakkumaChartValue = { + song?: StrongRef | Record; + songId?: string; + songName?: string; + difficulty?: string; + level?: string; +}; + +export type EnrichedDerakkumaScore = RepoRecord & { + chart?: RepoRecord; + song?: RepoRecord; + coverArtUrl?: string; +}; + +export function blobUrl(did: Did | string, blob?: BlobRef): string | undefined { + if (!blob) return; + const ref = typeof blob.ref === 'string' ? blob.ref : blob.ref?.$link; + const normalized = ref + ? ({ $type: 'blob', ref: { $link: ref } } as const) + : blob.cid + ? ({ $type: 'blob', ref: { $link: blob.cid } } as const) + : undefined; + if (!normalized) return; + return getCDNImageBlobUrl({ did, blob: normalized }); +} + +function valueObject(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined; +} + +function strongRefUri(ref: unknown): string | undefined { + return valueObject(ref)?.uri as string | undefined; +} + +async function resolveRef( + ref: unknown, + cache: Map> +): Promise { + const uri = strongRefUri(ref); + if (!uri) return; + const cached = cache.get(uri); + if (cached) return cached; + + const promise = (async () => { + const parsed = parseUri(uri); + if (!parsed?.collection || !parsed.rkey) return; + try { + const record = await getRecord({ + did: parsed.repo as Did, + collection: parsed.collection, + rkey: parsed.rkey + }); + return { uri, cid: record.cid, value: record.value as Record }; + } catch { + return; + } + })(); + + cache.set(uri, promise); + return promise; +} + +export async function loadDerakkumaProfile( + did: Did +): Promise | undefined> { + const records = (await listRecords({ + did, + collection: DERAKKUMA_PROFILE, + limit: 1 + })) as RepoRecord[]; + return records[0]; +} + +export async function loadDerakkumaCircle( + did: Did +): Promise | undefined> { + const records = (await listRecords({ + did, + collection: DERAKKUMA_CIRCLE, + limit: 1 + })) as RepoRecord[]; + return records[0]; +} + +export async function loadDerakkumaScores( + did: Did, + collection: typeof DERAKKUMA_PLAY | typeof DERAKKUMA_BEST, + limit = 20 +): Promise { + const records = (await listRecords({ + did, + collection, + limit + })) as RepoRecord[]; + const cache = new Map>(); + + return Promise.all( + records.map(async (record) => { + const chart = (await resolveRef(record.value.chart, cache)) as + | RepoRecord + | undefined; + const song = (await resolveRef(chart?.value.song, cache)) as + | RepoRecord + | undefined; + const songRepo = song?.uri ? parseUri(song.uri)?.repo : undefined; + return { + ...record, + chart, + song, + coverArtUrl: songRepo ? blobUrl(songRepo, song?.value.coverArt) : undefined + }; + }) + ); +} + +export function scoreTitle(score: EnrichedDerakkumaScore): string { + return ( + score.song?.value.title ?? + score.chart?.value.songName ?? + score.chart?.value.songId ?? + 'Unknown song' + ); +} + +export function scoreSubtitle(score: EnrichedDerakkumaScore): string { + return [score.chart?.value.difficulty, score.chart?.value.level, score.value.achievement] + .filter(Boolean) + .join(' · '); +} + +export function scoreMeta(score: EnrichedDerakkumaScore): string { + return [ + score.song?.value.artist, + score.value.scoreRank?.toUpperCase(), + score.value.fcStatus?.toUpperCase(), + score.value.syncStatus?.toUpperCase() + ] + .filter(Boolean) + .join(' · '); +} From ac7f264c9b33089b33533d4f1da8af92ac627dae Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Wed, 27 May 2026 23:07:27 -0700 Subject: [PATCH 2/4] Update derakkuma icon --- src/lib/cards/media/DerakkumaCards/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/cards/media/DerakkumaCards/index.ts b/src/lib/cards/media/DerakkumaCards/index.ts index 13c3ae7..50c654d 100644 --- a/src/lib/cards/media/DerakkumaCards/index.ts +++ b/src/lib/cards/media/DerakkumaCards/index.ts @@ -12,7 +12,7 @@ import { loadDerakkumaScores } from './shared'; -const icon = ``; +const icon = ``; export const DerakkumaProfileCardDefinition = { type: 'derakkumaProfile', From 6953009c5c3194c77b35069cdabac15b5213d475 Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Wed, 27 May 2026 23:09:09 -0700 Subject: [PATCH 3/4] refactor: inline DerakkumaAvatar --- .../DerakkumaCards/DerakkumaAvatar.svelte | 21 ------------------- .../DerakkumaProfileCard.svelte | 19 +++++++++++++++-- 2 files changed, 17 insertions(+), 23 deletions(-) delete mode 100644 src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte deleted file mode 100644 index 983feb6..0000000 --- a/src/lib/cards/media/DerakkumaCards/DerakkumaAvatar.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -{#if src && !hasError} - { - hasError = true; - }} - /> -{:else} -
- 🐻 -
-{/if} diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte index 140a388..106a695 100644 --- a/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte @@ -3,7 +3,6 @@ import { onMount } from 'svelte'; import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; import { CardDefinitionsByType } from '../..'; - import DerakkumaAvatar from './DerakkumaAvatar.svelte'; import { blobUrl, type DerakkumaProfileValue, type RepoRecord } from './shared'; let { item }: { item: Item } = $props(); @@ -28,13 +27,29 @@ let partner = $derived(blobUrl(did, value?.partnerImage)); let course = $derived(blobUrl(did, value?.courseImage)); let classImage = $derived(blobUrl(did, value?.classImage)); + let profileImageHasError = $state(false);
{#if value}
- + {#if profileImage && !profileImageHasError} + {value.playerName { + profileImageHasError = true; + }} + /> + {:else} +
+ 🐻 +
+ {/if}
From ca9dd29320ce7b1d9b56723c25b13dbfc787021a Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Thu, 11 Jun 2026 16:34:12 -0700 Subject: [PATCH 4/4] update derakkuma for new lexicons --- .../DerakkumaCards/DerakkumaCircleCard.svelte | 2 +- .../DerakkumaProfileCard.svelte | 2 +- .../DerakkumaCards/DerakkumaScoresCard.svelte | 5 ++- src/lib/cards/media/DerakkumaCards/shared.ts | 42 ++++++++++++++++++- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte index 612918c..163c7f9 100644 --- a/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte @@ -1,7 +1,7 @@