diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 1425a9b..c1db2f7 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'; @@ -100,6 +106,10 @@ export const AllCardDefinitions = [ PopfeedReviewsCardDefinition, TealFMPlaysCardDefinition, RockskyPlaysCardDefinition, + DerakkumaProfileCardDefinition, + DerakkumaCircleCardDefinition, + DerakkumaRecentPlaysCardDefinition, + DerakkumaBestsCardDefinition, PhotoGalleryCardDefinition, StandardSiteDocumentListCardDefinition, StatusphereCardDefinition, diff --git a/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte b/src/lib/cards/media/DerakkumaCards/DerakkumaCircleCard.svelte new file mode 100644 index 0000000..163c7f9 --- /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..d87f124 --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaProfileCard.svelte @@ -0,0 +1,106 @@ + + +
+ {#if value} +
+
+ {#if profileImage && !profileImageHasError} + {value.playerName { + profileImageHasError = true; + }} + /> + {:else} +
+ 🐻 +
+ {/if} +
+
+
+ {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..e967bd1 --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/DerakkumaScoresCard.svelte @@ -0,0 +1,84 @@ + + +{#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..50c654d --- /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..87daeae --- /dev/null +++ b/src/lib/cards/media/DerakkumaCards/shared.ts @@ -0,0 +1,252 @@ +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; + songName?: string; + chartSongName?: string; + chartSongId?: string; + chartDifficulty?: string; + chartLevel?: string; + chartType?: string; + achievement?: string | number; + scoreRank?: string; + fcStatus?: string; + syncStatus?: string; + dxScore?: DerakkumaDxScoreValue; + dxStar?: number; + playedAt?: string; + lastPlayed?: 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 = { + songId?: string; + title?: string; + artist?: string; + category?: string; + bpm?: number; + imageName?: string; + coverArt?: BlobRef; + charts?: string[]; +}; + +type DerakkumaChartValue = { + song?: StrongRef | Record; + songId?: string; + songName?: string; + type?: string; + difficulty?: string; + level?: string; + levelValue?: string; + internalLevel?: string | null; + internalLevelValue?: string | null; +}; + +type DerakkumaDxScoreValue = { + achieved?: number; + total?: number; +}; + +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 ?? + score.value.songName ?? + score.value.chartSongName ?? + score.value.chartSongId ?? + strongRefUri(score.value.chart) ?? + 'Unknown song' + ); +} + +export function scoreSubtitle(score: EnrichedDerakkumaScore): string { + return [ + score.chart?.value.difficulty ?? score.value.chartDifficulty, + score.chart?.value.level ?? score.value.chartLevel, + score.value.achievement + ] + .filter(Boolean) + .join(' · '); +} + +function dxScoreString(score?: DerakkumaDxScoreValue): string | undefined { + if (!score || (!score.achieved && !score.total)) return; + return `DX ${score.achieved ?? 0}/${score.total ?? 0}`; +} + +export function scoreMeta(score: EnrichedDerakkumaScore): string { + return [ + score.song?.value.artist, + score.value.scoreRank?.toUpperCase(), + score.value.fcStatus?.toUpperCase(), + score.value.syncStatus?.toUpperCase(), + dxScoreString(score.value.dxScore), + score.value.dxStar ? `DX ✦${score.value.dxStar}` : undefined + ] + .filter(Boolean) + .join(' · '); +}