my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: remove local versions

-4363
-87
packages/atproto/README.md
··· 1 - # @ewanc26/atproto 2 - 3 - > **Canonical source:** This package is now maintained in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo under [`packages/atproto`](https://github.com/ewanc26/pkgs/tree/main/packages/atproto). This copy exists for historical context — please open issues and PRs there. 4 - 5 - AT Protocol service layer extracted from [ewancroft.uk](https://ewancroft.uk). Handles identity resolution, record fetching, Bluesky posts, Standard.site documents, music/mood status, and more — with a built-in in-memory cache. 6 - 7 - **Key difference from the app's internal service layer:** all functions accept `did: string` as their first argument rather than reading `PUBLIC_ATPROTO_DID` from the environment. 8 - 9 - ## Installation 10 - 11 - ```bash 12 - pnpm add @ewanc26/atproto 13 - ``` 14 - 15 - Requires `@atproto/api >= 0.13.0` as a peer dependency. 16 - 17 - ## What's Exported 18 - 19 - ### Fetch Functions 20 - 21 - | Function | Description | 22 - |----------|-------------| 23 - | `fetchProfile(did)` | Bluesky profile including pronouns | 24 - | `fetchSiteInfo(did)` | `uk.ewancroft.site.info` record | 25 - | `fetchLinks(did)` | `blue.linkat.board` link cards | 26 - | `fetchMusicStatus(did)` | teal.fm music status with cascading artwork lookup | 27 - | `fetchKibunStatus(did)` | kibun.social mood status | 28 - | `fetchTangledRepos(did)` | Tangled code repositories | 29 - | `fetchPublications(did)` | Standard.site publications | 30 - | `fetchDocuments(did, pubRkey)` | Standard.site documents for a publication | 31 - | `fetchBlogPosts(did, pubRkey)` | Blog posts for a publication | 32 - | `fetchRecentDocuments(did, limit)` | Most recent documents across all publications | 33 - | `fetchLatestBlueskyPost(did)` | Latest non-reply Bluesky post with thread context | 34 - | `fetchPostFromUri(did, uri)` | Single Bluesky post by AT URI | 35 - | `fetchAllEngagement(uris)` | Like/repost counts via Constellation API | 36 - 37 - ### Agents & Identity 38 - 39 - ```typescript 40 - import { resolveIdentity, getPublicAgent, getPDSAgent, createAgent, withFallback, resetAgents } from '@ewanc26/atproto'; 41 - ``` 42 - 43 - ### Pagination 44 - 45 - ```typescript 46 - import { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto'; 47 - ``` 48 - 49 - ### Cache 50 - 51 - ```typescript 52 - import { cache, ATProtoCache, CACHE_TTL } from '@ewanc26/atproto'; 53 - 54 - cache.clear(); 55 - cache.delete('profile:did:plc:…'); 56 - ``` 57 - 58 - ### Music Artwork 59 - 60 - ```typescript 61 - import { findArtwork, searchMusicBrainzRelease, buildCoverArtUrl } from '@ewanc26/atproto'; 62 - // Cascading: MusicBrainz → iTunes → Deezer → Last.fm → PDS blob 63 - ``` 64 - 65 - ### Media 66 - 67 - ```typescript 68 - import { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from '@ewanc26/atproto'; 69 - ``` 70 - 71 - ### Types 72 - 73 - All interfaces (`ProfileData`, `BlueskyPost`, `BlogPost`, `MusicStatusData`, `KibunStatusData`, `TangledRepo`, `StandardSiteDocument`, `StandardSitePublication`, `SiteInfoData`, `LinkData`, `ResolvedIdentity`, …) are exported from the package root. 74 - 75 - ## Build 76 - 77 - Development happens in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo. Local commands (from `packages/atproto`): 78 - 79 - ```bash 80 - pnpm build # tsc 81 - pnpm dev # tsc --watch 82 - pnpm check # tsc --noEmit 83 - ``` 84 - 85 - ## Licence 86 - 87 - AGPL-3.0-only — see the [pkgs monorepo licence](https://github.com/ewanc26/pkgs/blob/main/LICENSE).
-29
packages/atproto/package.json
··· 1 - { 2 - "name": "@ewanc26/atproto", 3 - "version": "0.1.0", 4 - "description": "AT Protocol service layer extracted from ewancroft.uk", 5 - "type": "module", 6 - "exports": { 7 - ".": { 8 - "source": "./src/index.ts", 9 - "types": "./dist/index.d.ts", 10 - "default": "./dist/index.js" 11 - } 12 - }, 13 - "main": "./dist/index.js", 14 - "types": "./dist/index.d.ts", 15 - "publishConfig": { "access": "public" }, 16 - "files": ["dist", "src"], 17 - "scripts": { 18 - "build": "tsc --project tsconfig.json", 19 - "dev": "tsc --project tsconfig.json --watch", 20 - "check": "tsc --noEmit" 21 - }, 22 - "peerDependencies": { 23 - "@atproto/api": ">=0.13.0" 24 - }, 25 - "devDependencies": { 26 - "@atproto/api": "^0.18.1", 27 - "typescript": "^5.9.3" 28 - } 29 - }
-127
packages/atproto/src/agents.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import type { ResolvedIdentity } from './types.js'; 3 - import { cache } from './cache.js'; 4 - 5 - export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { 6 - const wrappedFetch = fetchFn 7 - ? async (url: URL | RequestInfo, init?: RequestInit) => { 8 - const urlStr = url instanceof URL ? url.toString() : url; 9 - const response = await fetchFn(urlStr, init); 10 - const headers = new Headers(response.headers); 11 - if (!headers.has('content-type')) { 12 - headers.set('content-type', 'application/json'); 13 - } 14 - return new Response(response.body, { 15 - status: response.status, 16 - statusText: response.statusText, 17 - headers 18 - }); 19 - } 20 - : undefined; 21 - 22 - return new AtpAgent({ 23 - service, 24 - ...(wrappedFetch && { fetch: wrappedFetch }) 25 - }); 26 - } 27 - 28 - export const constellationAgent = createAgent('https://constellation.microcosm.blue'); 29 - export const defaultAgent = createAgent('https://public.api.bsky.app'); 30 - 31 - let resolvedAgent: AtpAgent | null = null; 32 - let pdsAgent: AtpAgent | null = null; 33 - 34 - export async function resolveIdentity( 35 - did: string, 36 - fetchFn?: typeof fetch 37 - ): Promise<ResolvedIdentity> { 38 - const cacheKey = `identity:${did}`; 39 - const cached = cache.get<ResolvedIdentity>(cacheKey); 40 - if (cached) return cached; 41 - 42 - const _fetch = fetchFn ?? globalThis.fetch; 43 - const response = await _fetch( 44 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}` 45 - ); 46 - 47 - if (!response.ok) { 48 - throw new Error( 49 - `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` 50 - ); 51 - } 52 - 53 - const rawText = await response.text(); 54 - let data: any; 55 - try { 56 - data = JSON.parse(rawText); 57 - } catch (err) { 58 - throw new Error('Failed to parse identity resolver response'); 59 - } 60 - 61 - if (!data.did || !data.pds) { 62 - throw new Error('Invalid response from identity resolver'); 63 - } 64 - 65 - cache.set(cacheKey, data); 66 - return data; 67 - } 68 - 69 - export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 70 - if (resolvedAgent) return resolvedAgent; 71 - 72 - try { 73 - try { 74 - const response = await constellationAgent.getProfile({ actor: did }); 75 - if (response.success) { 76 - resolvedAgent = constellationAgent; 77 - return resolvedAgent; 78 - } 79 - } catch { 80 - // fall through 81 - } 82 - 83 - const resolved = await resolveIdentity(did, fetchFn); 84 - resolvedAgent = createAgent(resolved.pds, fetchFn); 85 - return resolvedAgent; 86 - } catch { 87 - resolvedAgent = defaultAgent; 88 - return resolvedAgent; 89 - } 90 - } 91 - 92 - export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 93 - if (pdsAgent) return pdsAgent; 94 - const resolved = await resolveIdentity(did, fetchFn); 95 - pdsAgent = createAgent(resolved.pds, fetchFn); 96 - return pdsAgent; 97 - } 98 - 99 - export async function withFallback<T>( 100 - did: string, 101 - operation: (agent: AtpAgent) => Promise<T>, 102 - usePDSFirst = false, 103 - fetchFn?: typeof fetch 104 - ): Promise<T> { 105 - const defaultAgentFn = () => 106 - fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); 107 - 108 - const agents = usePDSFirst 109 - ? [() => getPDSAgent(did, fetchFn), defaultAgentFn] 110 - : [defaultAgentFn, () => getPDSAgent(did, fetchFn)]; 111 - 112 - let lastError: any; 113 - for (const getAgent of agents) { 114 - try { 115 - const agent = await getAgent(); 116 - return await operation(agent); 117 - } catch (error) { 118 - lastError = error; 119 - } 120 - } 121 - throw lastError; 122 - } 123 - 124 - export function resetAgents(): void { 125 - resolvedAgent = null; 126 - pdsAgent = null; 127 - }
-63
packages/atproto/src/cache.ts
··· 1 - import type { CacheEntry } from './types.js'; 2 - 3 - /** 4 - * Cache TTL values in milliseconds (production defaults). 5 - * All functions that previously read PUBLIC_ATPROTO_DID from the environment 6 - * now accept `did: string` as their first argument. 7 - */ 8 - export const CACHE_TTL = { 9 - PROFILE: 60 * 60 * 1000, 10 - SITE_INFO: 120 * 60 * 1000, 11 - LINKS: 60 * 60 * 1000, 12 - MUSIC_STATUS: 10 * 60 * 1000, 13 - KIBUN_STATUS: 15 * 60 * 1000, 14 - TANGLED_REPOS: 60 * 60 * 1000, 15 - BLOG_POSTS: 30 * 60 * 1000, 16 - PUBLICATIONS: 60 * 60 * 1000, 17 - INDIVIDUAL_POST: 60 * 60 * 1000, 18 - IDENTITY: 24 * 60 * 60 * 1000 19 - } as const; 20 - 21 - export class ATProtoCache { 22 - private cache = new Map<string, CacheEntry<any>>(); 23 - 24 - private getTTL(key: string): number { 25 - if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 26 - if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; 27 - if (key.startsWith('links:')) return CACHE_TTL.LINKS; 28 - if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 29 - if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 30 - if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 31 - if (key.startsWith('blog-posts:') || key.startsWith('blogposts:')) return CACHE_TTL.BLOG_POSTS; 32 - if (key.startsWith('publications:') || key.startsWith('standard-site:publications:')) 33 - return CACHE_TTL.PUBLICATIONS; 34 - if (key.startsWith('post:') || key.startsWith('blueskypost:')) return CACHE_TTL.INDIVIDUAL_POST; 35 - if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 36 - return 30 * 60 * 1000; 37 - } 38 - 39 - get<T>(key: string): T | null { 40 - const entry = this.cache.get(key); 41 - if (!entry) return null; 42 - const ttl = this.getTTL(key); 43 - if (Date.now() - entry.timestamp > ttl) { 44 - this.cache.delete(key); 45 - return null; 46 - } 47 - return entry.data; 48 - } 49 - 50 - set<T>(key: string, data: T): void { 51 - this.cache.set(key, { data, timestamp: Date.now() }); 52 - } 53 - 54 - delete(key: string): void { 55 - this.cache.delete(key); 56 - } 57 - 58 - clear(): void { 59 - this.cache.clear(); 60 - } 61 - } 62 - 63 - export const cache = new ATProtoCache();
-247
packages/atproto/src/documents.ts
··· 1 - import { cache } from './cache.js'; 2 - import { withFallback, resolveIdentity } from './agents.js'; 3 - import { buildPdsBlobUrl } from './media.js'; 4 - import type { 5 - StandardSitePublication, 6 - StandardSitePublicationsData, 7 - StandardSiteDocument, 8 - StandardSiteDocumentsData, 9 - StandardSiteBasicTheme, 10 - StandardSiteThemeColor, 11 - BlogPost 12 - } from './types.js'; 13 - 14 - interface DocumentRecord { 15 - site: string; 16 - path?: string; 17 - title: string; 18 - description?: string; 19 - coverImage?: unknown; 20 - content?: unknown; 21 - textContent?: string; 22 - bskyPostRef?: { uri: string; cid: string }; 23 - tags?: string[]; 24 - publishedAt: string; 25 - updatedAt?: string; 26 - } 27 - 28 - interface PublicationRecord { 29 - url: string; 30 - name: string; 31 - description?: string; 32 - icon?: unknown; 33 - basicTheme?: { 34 - background: StandardSiteThemeColor; 35 - foreground: StandardSiteThemeColor; 36 - accent: StandardSiteThemeColor; 37 - accentForeground: StandardSiteThemeColor; 38 - }; 39 - preferences?: { showInDiscover?: boolean }; 40 - } 41 - 42 - async function getBlobUrl( 43 - did: string, 44 - blob: any, 45 - fetchFn?: typeof fetch 46 - ): Promise<string | undefined> { 47 - try { 48 - const cid = blob.ref?.$link || blob.cid; 49 - if (!cid) return undefined; 50 - const resolved = await resolveIdentity(did, fetchFn); 51 - return buildPdsBlobUrl(resolved.pds, did, cid); 52 - } catch { 53 - return undefined; 54 - } 55 - } 56 - 57 - function resolveViewUrl( 58 - site: string, 59 - path: string | undefined, 60 - publicationUrl: string | undefined, 61 - rkey: string 62 - ): string { 63 - const docPath = path || `/${rkey}`; 64 - const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`; 65 - 66 - if (site.startsWith('at://')) { 67 - if (!publicationUrl) return `${site}${normalizedPath}`; 68 - const baseUrl = publicationUrl.startsWith('http') ? publicationUrl : `https://${publicationUrl}`; 69 - return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 70 - } else { 71 - const baseUrl = site.startsWith('http') ? site : `https://${site}`; 72 - return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 73 - } 74 - } 75 - 76 - export async function fetchPublications( 77 - did: string, 78 - fetchFn?: typeof fetch 79 - ): Promise<StandardSitePublicationsData> { 80 - const cacheKey = `standard-site:publications:${did}`; 81 - const cached = cache.get<StandardSitePublicationsData>(cacheKey); 82 - if (cached) return cached; 83 - 84 - const publications: StandardSitePublication[] = []; 85 - 86 - try { 87 - const publicationsRecords = await withFallback( 88 - did, 89 - async (agent) => { 90 - const response = await agent.com.atproto.repo.listRecords({ 91 - repo: did, 92 - collection: 'site.standard.publication', 93 - limit: 100 94 - }); 95 - return response.data.records; 96 - }, 97 - true, 98 - fetchFn 99 - ); 100 - 101 - for (const pubRecord of publicationsRecords) { 102 - const pubValue = pubRecord.value as unknown as PublicationRecord; 103 - const rkey = pubRecord.uri.split('/').pop() || ''; 104 - 105 - let basicTheme: StandardSiteBasicTheme | undefined; 106 - if (pubValue.basicTheme) { 107 - basicTheme = { 108 - background: pubValue.basicTheme.background, 109 - foreground: pubValue.basicTheme.foreground, 110 - accent: pubValue.basicTheme.accent, 111 - accentForeground: pubValue.basicTheme.accentForeground 112 - }; 113 - } 114 - 115 - publications.push({ 116 - name: pubValue.name, 117 - rkey, 118 - uri: pubRecord.uri, 119 - url: pubValue.url, 120 - description: pubValue.description, 121 - icon: pubValue.icon ? await getBlobUrl(did, pubValue.icon, fetchFn) : undefined, 122 - basicTheme, 123 - preferences: pubValue.preferences 124 - }); 125 - } 126 - 127 - const data: StandardSitePublicationsData = { publications }; 128 - cache.set(cacheKey, data); 129 - return data; 130 - } catch { 131 - return { publications: [] }; 132 - } 133 - } 134 - 135 - export async function fetchDocuments( 136 - did: string, 137 - fetchFn?: typeof fetch 138 - ): Promise<StandardSiteDocumentsData> { 139 - const cacheKey = `standard-site:documents:${did}`; 140 - const cached = cache.get<StandardSiteDocumentsData>(cacheKey); 141 - if (cached) return cached; 142 - 143 - const documents: StandardSiteDocument[] = []; 144 - 145 - try { 146 - const publicationsData = await fetchPublications(did, fetchFn); 147 - const publicationsMap = new Map<string, StandardSitePublication>(); 148 - for (const pub of publicationsData.publications) { 149 - publicationsMap.set(pub.uri, pub); 150 - } 151 - 152 - const documentsRecords = await withFallback( 153 - did, 154 - async (agent) => { 155 - const response = await agent.com.atproto.repo.listRecords({ 156 - repo: did, 157 - collection: 'site.standard.document', 158 - limit: 100 159 - }); 160 - return response.data.records; 161 - }, 162 - true, 163 - fetchFn 164 - ); 165 - 166 - for (const docRecord of documentsRecords) { 167 - const docValue = docRecord.value as unknown as DocumentRecord; 168 - const rkey = docRecord.uri.split('/').pop() || ''; 169 - 170 - const site = docValue.site; 171 - let publication: StandardSitePublication | undefined; 172 - let publicationRkey: string | undefined; 173 - let pubUrl: string | undefined; 174 - 175 - if (site.startsWith('at://')) { 176 - publication = publicationsMap.get(site); 177 - publicationRkey = site.split('/').pop(); 178 - pubUrl = publication?.url; 179 - } else { 180 - pubUrl = site; 181 - } 182 - 183 - const url = resolveViewUrl(site, docValue.path, pubUrl, rkey); 184 - const coverImage = docValue.coverImage 185 - ? await getBlobUrl(did, docValue.coverImage, fetchFn) 186 - : undefined; 187 - 188 - documents.push({ 189 - title: docValue.title, 190 - rkey, 191 - uri: docRecord.uri, 192 - url, 193 - site, 194 - path: docValue.path, 195 - description: docValue.description, 196 - coverImage, 197 - content: docValue.content, 198 - textContent: docValue.textContent, 199 - bskyPostRef: docValue.bskyPostRef, 200 - tags: docValue.tags, 201 - publishedAt: docValue.publishedAt, 202 - updatedAt: docValue.updatedAt, 203 - publicationName: publication?.name, 204 - publicationRkey 205 - }); 206 - } 207 - 208 - documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); 209 - 210 - const data: StandardSiteDocumentsData = { documents }; 211 - cache.set(cacheKey, data); 212 - return data; 213 - } catch { 214 - return { documents: [] }; 215 - } 216 - } 217 - 218 - export async function fetchRecentDocuments( 219 - did: string, 220 - limit = 5, 221 - fetchFn?: typeof fetch 222 - ): Promise<StandardSiteDocument[]> { 223 - const { documents } = await fetchDocuments(did, fetchFn); 224 - return documents.slice(0, limit); 225 - } 226 - 227 - export async function fetchBlogPosts( 228 - did: string, 229 - fetchFn?: typeof fetch 230 - ): Promise<{ posts: BlogPost[] }> { 231 - const { documents } = await fetchDocuments(did, fetchFn); 232 - const posts: BlogPost[] = documents.map((doc) => ({ 233 - title: doc.title, 234 - url: doc.url, 235 - createdAt: doc.publishedAt, 236 - platform: 'standard.site' as const, 237 - description: doc.description, 238 - rkey: doc.rkey, 239 - publicationName: doc.publicationName, 240 - publicationRkey: doc.publicationRkey, 241 - tags: doc.tags, 242 - coverImage: doc.coverImage, 243 - textContent: doc.textContent, 244 - updatedAt: doc.updatedAt 245 - })); 246 - return { posts }; 247 - }
-56
packages/atproto/src/engagement.ts
··· 1 - import { cache } from './cache.js'; 2 - 3 - export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost'; 4 - 5 - interface EngagementResponse { 6 - dids: string[]; 7 - cursor?: string; 8 - } 9 - 10 - export async function fetchEngagementFromConstellation( 11 - uri: string, 12 - type: EngagementType, 13 - cursor?: string 14 - ): Promise<EngagementResponse> { 15 - const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`; 16 - const cached = cache.get<EngagementResponse>(cacheKey); 17 - if (cached) return cached; 18 - 19 - const url = new URL('https://constellation.microcosm.blue/links/distinct-dids'); 20 - url.searchParams.append('target', uri); 21 - url.searchParams.append('collection', type); 22 - url.searchParams.append('path', ''); 23 - url.searchParams.append('limit', '100'); 24 - if (cursor) url.searchParams.append('cursor', cursor); 25 - 26 - const response = await fetch(url); 27 - if (!response.ok) { 28 - throw new Error(`Constellation HTTP error! Status: ${response.status}`); 29 - } 30 - 31 - const data = await response.json(); 32 - const result: EngagementResponse = { 33 - dids: data.dids || [], 34 - cursor: data.cursor 35 - }; 36 - 37 - cache.set(cacheKey, result); 38 - return result; 39 - } 40 - 41 - export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> { 42 - const allDids: Set<string> = new Set(); 43 - let cursor: string | undefined = undefined; 44 - 45 - try { 46 - do { 47 - const response = await fetchEngagementFromConstellation(uri, type, cursor); 48 - response.dids.forEach((did) => allDids.add(did)); 49 - cursor = response.cursor; 50 - } while (cursor); 51 - } catch { 52 - // return what we have 53 - } 54 - 55 - return Array.from(allDids); 56 - }
-355
packages/atproto/src/fetch.ts
··· 1 - import { cache } from './cache.js'; 2 - import { withFallback, resolveIdentity } from './agents.js'; 3 - import { buildPdsBlobUrl } from './media.js'; 4 - import { findArtwork } from './musicbrainz.js'; 5 - import type { 6 - ProfileData, 7 - SiteInfoData, 8 - LinkData, 9 - MusicStatusData, 10 - KibunStatusData, 11 - TangledRepo, 12 - TangledReposData 13 - } from './types.js'; 14 - 15 - export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise<ProfileData> { 16 - const cacheKey = `profile:${did}`; 17 - const cached = cache.get<ProfileData>(cacheKey); 18 - if (cached) return cached; 19 - 20 - const profile = await withFallback( 21 - did, 22 - async (agent) => { 23 - const response = await agent.getProfile({ actor: did }); 24 - return response.data; 25 - }, 26 - false, 27 - fetchFn 28 - ); 29 - 30 - let pronouns: string | undefined; 31 - try { 32 - const recordResponse = await withFallback( 33 - did, 34 - async (agent) => { 35 - const response = await agent.com.atproto.repo.getRecord({ 36 - repo: did, 37 - collection: 'app.bsky.actor.profile', 38 - rkey: 'self' 39 - }); 40 - return response.data; 41 - }, 42 - false, 43 - fetchFn 44 - ); 45 - pronouns = (recordResponse.value as any).pronouns; 46 - } catch { /* pronouns optional */ } 47 - 48 - const data: ProfileData = { 49 - did: profile.did, 50 - handle: profile.handle, 51 - displayName: profile.displayName, 52 - description: profile.description, 53 - avatar: profile.avatar, 54 - banner: profile.banner, 55 - followersCount: profile.followersCount, 56 - followsCount: profile.followsCount, 57 - postsCount: profile.postsCount, 58 - pronouns 59 - }; 60 - 61 - cache.set(cacheKey, data); 62 - return data; 63 - } 64 - 65 - export async function fetchSiteInfo( 66 - did: string, 67 - fetchFn?: typeof fetch 68 - ): Promise<SiteInfoData | null> { 69 - const cacheKey = `siteinfo:${did}`; 70 - const cached = cache.get<SiteInfoData>(cacheKey); 71 - if (cached) return cached; 72 - 73 - try { 74 - const result = await withFallback( 75 - did, 76 - async (agent) => { 77 - try { 78 - const response = await agent.com.atproto.repo.getRecord({ 79 - repo: did, 80 - collection: 'uk.ewancroft.site.info', 81 - rkey: 'self' 82 - }); 83 - return response.data; 84 - } catch (err: any) { 85 - if (err.error === 'RecordNotFound') return null; 86 - throw err; 87 - } 88 - }, 89 - true, 90 - fetchFn 91 - ); 92 - 93 - if (!result?.value) return null; 94 - const data = result.value as SiteInfoData; 95 - cache.set(cacheKey, data); 96 - return data; 97 - } catch { 98 - return null; 99 - } 100 - } 101 - 102 - export async function fetchLinks( 103 - did: string, 104 - fetchFn?: typeof fetch 105 - ): Promise<LinkData | null> { 106 - const cacheKey = `links:${did}`; 107 - const cached = cache.get<LinkData>(cacheKey); 108 - if (cached) return cached; 109 - 110 - try { 111 - const value = await withFallback( 112 - did, 113 - async (agent) => { 114 - const response = await agent.com.atproto.repo.getRecord({ 115 - repo: did, 116 - collection: 'blue.linkat.board', 117 - rkey: 'self' 118 - }); 119 - return response.data.value; 120 - }, 121 - true, 122 - fetchFn 123 - ); 124 - 125 - if (!value || !Array.isArray((value as any).cards)) return null; 126 - const data: LinkData = { cards: (value as any).cards }; 127 - cache.set(cacheKey, data); 128 - return data; 129 - } catch { 130 - return null; 131 - } 132 - } 133 - 134 - export async function fetchMusicStatus( 135 - did: string, 136 - fetchFn?: typeof fetch 137 - ): Promise<MusicStatusData | null> { 138 - const cacheKey = `music-status:${did}`; 139 - const cached = cache.get<MusicStatusData>(cacheKey); 140 - if (cached) return cached; 141 - 142 - try { 143 - // Try actor status first 144 - try { 145 - const statusRecords = await withFallback( 146 - did, 147 - async (agent) => { 148 - const response = await agent.com.atproto.repo.listRecords({ 149 - repo: did, 150 - collection: 'fm.teal.alpha.actor.status', 151 - limit: 1 152 - }); 153 - return response.data.records; 154 - }, 155 - true, 156 - fetchFn 157 - ); 158 - 159 - if (statusRecords?.length) { 160 - const record = statusRecords[0]; 161 - const value = record.value as any; 162 - if (value.expiry) { 163 - const expiryTime = parseInt(value.expiry) * 1000; 164 - if (Date.now() <= expiryTime) { 165 - const trackName = value.item?.trackName || value.trackName; 166 - const artists = value.item?.artists || value.artists || []; 167 - const releaseName = value.item?.releaseName || value.releaseName; 168 - const artistName = artists[0]?.artistName; 169 - const releaseMbId = value.item?.releaseMbId || value.releaseMbId; 170 - 171 - let artworkUrl: string | undefined; 172 - if (releaseName && artistName) { 173 - artworkUrl = (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 174 - } 175 - if (!artworkUrl && trackName && artistName) { 176 - artworkUrl = (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 177 - } 178 - if (!artworkUrl) { 179 - const artwork = value.item?.artwork || value.artwork; 180 - if (artwork?.ref?.$link) { 181 - const identity = await resolveIdentity(did, fetchFn); 182 - artworkUrl = buildPdsBlobUrl(identity.pds, did, artwork.ref.$link); 183 - } 184 - } 185 - 186 - const data: MusicStatusData = { 187 - trackName, 188 - artists, 189 - releaseName, 190 - playedTime: value.item?.playedTime || value.playedTime, 191 - originUrl: value.item?.originUrl || value.originUrl, 192 - recordingMbId: value.item?.recordingMbId || value.recordingMbId, 193 - releaseMbId, 194 - isrc: value.isrc, 195 - duration: value.duration, 196 - musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 197 - submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent, 198 - $type: 'fm.teal.alpha.actor.status', 199 - expiry: value.expiry, 200 - artwork: value.item?.artwork || value.artwork, 201 - artworkUrl 202 - }; 203 - cache.set(cacheKey, data); 204 - return data; 205 - } 206 - } 207 - } 208 - } catch { /* fall through to feed play */ } 209 - 210 - // Fall back to feed play 211 - const playRecords = await withFallback( 212 - did, 213 - async (agent) => { 214 - const response = await agent.com.atproto.repo.listRecords({ 215 - repo: did, 216 - collection: 'fm.teal.alpha.feed.play', 217 - limit: 1 218 - }); 219 - return response.data.records; 220 - }, 221 - true, 222 - fetchFn 223 - ); 224 - 225 - if (playRecords?.length) { 226 - const record = playRecords[0]; 227 - const value = record.value as any; 228 - const artists = value.artists || []; 229 - const artistName = artists[0]?.artistName; 230 - 231 - let artworkUrl: string | undefined; 232 - if (value.releaseName && artistName) { 233 - artworkUrl = (await findArtwork(value.releaseName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined; 234 - } 235 - if (!artworkUrl && value.trackName && artistName) { 236 - artworkUrl = (await findArtwork(value.trackName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined; 237 - } 238 - if (!artworkUrl && value.artwork?.ref?.$link) { 239 - const identity = await resolveIdentity(did, fetchFn); 240 - artworkUrl = buildPdsBlobUrl(identity.pds, did, value.artwork.ref.$link); 241 - } 242 - 243 - const data: MusicStatusData = { 244 - trackName: value.trackName, 245 - artists, 246 - releaseName: value.releaseName, 247 - playedTime: value.playedTime, 248 - originUrl: value.originUrl, 249 - recordingMbId: value.recordingMbId, 250 - releaseMbId: value.releaseMbId, 251 - isrc: value.isrc, 252 - duration: value.duration, 253 - musicServiceBaseDomain: value.musicServiceBaseDomain, 254 - submissionClientAgent: value.submissionClientAgent, 255 - $type: 'fm.teal.alpha.feed.play', 256 - artwork: value.artwork, 257 - artworkUrl 258 - }; 259 - cache.set(cacheKey, data); 260 - return data; 261 - } 262 - 263 - return null; 264 - } catch { 265 - return null; 266 - } 267 - } 268 - 269 - export async function fetchKibunStatus( 270 - did: string, 271 - fetchFn?: typeof fetch 272 - ): Promise<KibunStatusData | null> { 273 - const cacheKey = `kibun-status:${did}`; 274 - const cached = cache.get<KibunStatusData>(cacheKey); 275 - if (cached) return cached; 276 - 277 - try { 278 - const statusRecords = await withFallback( 279 - did, 280 - async (agent) => { 281 - const response = await agent.com.atproto.repo.listRecords({ 282 - repo: did, 283 - collection: 'social.kibun.status', 284 - limit: 1 285 - }); 286 - return response.data.records; 287 - }, 288 - true, 289 - fetchFn 290 - ); 291 - 292 - if (statusRecords?.length) { 293 - const value = statusRecords[0].value as any; 294 - const data: KibunStatusData = { 295 - text: value.text, 296 - emoji: value.emoji, 297 - createdAt: value.createdAt, 298 - $type: 'social.kibun.status' 299 - }; 300 - cache.set(cacheKey, data); 301 - return data; 302 - } 303 - return null; 304 - } catch { 305 - return null; 306 - } 307 - } 308 - 309 - export async function fetchTangledRepos( 310 - did: string, 311 - fetchFn?: typeof fetch 312 - ): Promise<TangledReposData | null> { 313 - const cacheKey = `tangled:${did}`; 314 - const cached = cache.get<TangledReposData>(cacheKey); 315 - if (cached) return cached; 316 - 317 - try { 318 - const records = await withFallback( 319 - did, 320 - async (agent) => { 321 - const response = await agent.com.atproto.repo.listRecords({ 322 - repo: did, 323 - collection: 'sh.tangled.repo', 324 - limit: 100 325 - }); 326 - return response.data.records; 327 - }, 328 - true, 329 - fetchFn 330 - ); 331 - 332 - if (!records.length) return null; 333 - 334 - const repos: TangledRepo[] = records.map((record) => { 335 - const value = record.value as any; 336 - return { 337 - uri: record.uri, 338 - name: value.name, 339 - description: value.description, 340 - knot: value.knot, 341 - createdAt: value.createdAt, 342 - labels: value.labels, 343 - source: value.source, 344 - spindle: value.spindle 345 - }; 346 - }); 347 - 348 - repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 349 - const data: TangledReposData = { repos }; 350 - cache.set(cacheKey, data); 351 - return data; 352 - } catch { 353 - return null; 354 - } 355 - }
-74
packages/atproto/src/index.ts
··· 1 - // Re-export types and functions from the AT Protocol service layer. 2 - // Key API difference from the app's src/lib/services/atproto: 3 - // All functions that previously read PUBLIC_ATPROTO_DID from the environment 4 - // now accept `did: string` as their first argument. 5 - 6 - export type { 7 - ProfileData, 8 - SiteInfoData, 9 - LinkData, 10 - LinkCard, 11 - BlueskyPost, 12 - BlogPost, 13 - PostAuthor, 14 - ExternalLink, 15 - Facet, 16 - Technology, 17 - License, 18 - BasedOnItem, 19 - RelatedService, 20 - Repository, 21 - Credit, 22 - SectionLicense, 23 - ResolvedIdentity, 24 - CacheEntry, 25 - MusicStatusData, 26 - MusicArtist, 27 - KibunStatusData, 28 - TangledRepo, 29 - TangledReposData, 30 - StandardSitePublication, 31 - StandardSitePublicationsData, 32 - StandardSiteDocument, 33 - StandardSiteDocumentsData, 34 - StandardSiteBasicTheme, 35 - StandardSiteThemeColor 36 - } from './types'; 37 - 38 - export { 39 - fetchProfile, 40 - fetchSiteInfo, 41 - fetchLinks, 42 - fetchMusicStatus, 43 - fetchKibunStatus, 44 - fetchTangledRepos 45 - } from './fetch'; 46 - 47 - export { 48 - fetchPublications, 49 - fetchDocuments, 50 - fetchRecentDocuments, 51 - fetchBlogPosts 52 - } from './documents'; 53 - 54 - export { fetchLatestBlueskyPost, fetchPostFromUri } from './posts'; 55 - 56 - export { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from './media'; 57 - 58 - export { createAgent, constellationAgent, defaultAgent, resolveIdentity, getPublicAgent, getPDSAgent, withFallback, resetAgents } from './agents'; 59 - 60 - export { 61 - searchMusicBrainzRelease, 62 - buildCoverArtUrl, 63 - searchiTunesArtwork, 64 - searchDeezerArtwork, 65 - searchLastFmArtwork, 66 - findArtwork 67 - } from './musicbrainz'; 68 - 69 - export { fetchEngagementFromConstellation, fetchAllEngagement } from './engagement'; 70 - 71 - export { cache, ATProtoCache, CACHE_TTL } from './cache'; 72 - 73 - export { fetchAllRecords, fetchAllUserRecords } from './pagination'; 74 - export type { FetchRecordsConfig, AtProtoRecord } from './pagination';
-106
packages/atproto/src/media.ts
··· 1 - export function buildPdsBlobUrl(pds: string, did: string, cid: string): string { 2 - return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 3 - } 4 - 5 - export function extractCidFromImageObject(img: any): string | null { 6 - if (!img) return null; 7 - if (img.image && img.image.ref && img.image.ref.$link) return img.image.ref.$link as string; 8 - if (img.ref && img.ref.$link) return img.ref.$link as string; 9 - if (img.cid) return img.cid as string; 10 - if (typeof img === 'string') return img; 11 - return null; 12 - } 13 - 14 - export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] { 15 - const urls: string[] = []; 16 - 17 - try { 18 - const embed = (value as any)?.embed ?? null; 19 - 20 - if (embed) { 21 - if (embed.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { 22 - for (const img of embed.images) { 23 - const imageUrl = img.fullsize || img.thumb; 24 - if (imageUrl) urls.push(imageUrl); 25 - else { 26 - const cid = extractCidFromImageObject(img); 27 - if (cid) urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); 28 - } 29 - if (urls.length >= limit) return urls; 30 - } 31 - } 32 - 33 - if (embed.$type === 'app.bsky.embed.video#view' || embed.$type === 'app.bsky.embed.video') { 34 - const videoCid = 35 - (embed as any)?.jobStatus?.blob ?? 36 - (embed as any)?.video?.ref?.$link ?? 37 - (embed as any)?.video?.cid ?? 38 - null; 39 - if (videoCid) { 40 - urls.push(`https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`); 41 - if (urls.length >= limit) return urls; 42 - } 43 - } 44 - 45 - if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { 46 - const media = embed.media; 47 - if ( 48 - media && 49 - media.$type === 'app.bsky.embed.images#view' && 50 - Array.isArray(media.images) 51 - ) { 52 - for (const img of media.images) { 53 - const imageUrl = img.fullsize || img.thumb; 54 - if (imageUrl) urls.push(imageUrl); 55 - else { 56 - const cid = extractCidFromImageObject(img); 57 - if (cid) 58 - urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); 59 - } 60 - if (urls.length >= limit) return urls; 61 - } 62 - } 63 - const quotedRecord = embed.record; 64 - if (quotedRecord) { 65 - const quotedValue = quotedRecord.value ?? quotedRecord.record?.value ?? null; 66 - if (quotedValue) { 67 - const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); 68 - urls.push(...nested); 69 - if (urls.length >= limit) return urls; 70 - } 71 - } 72 - } 73 - 74 - if (embed.$type === 'app.bsky.embed.record#view' && embed.record) { 75 - const quotedValue = 76 - embed.record.value ?? embed.record.record?.value ?? null; 77 - if (quotedValue) { 78 - const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); 79 - urls.push(...nested); 80 - if (urls.length >= limit) return urls; 81 - } 82 - } 83 - } 84 - 85 - if (Array.isArray((value as any).embeds)) { 86 - for (const e of (value as any).embeds) { 87 - if (e.$type === 'app.bsky.embed.images#view' && Array.isArray(e.images)) { 88 - for (const img of e.images) { 89 - const imageUrl = img.fullsize || img.thumb; 90 - if (imageUrl) urls.push(imageUrl); 91 - else { 92 - const cid = extractCidFromImageObject(img); 93 - if (cid) 94 - urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); 95 - } 96 - if (urls.length >= limit) return urls; 97 - } 98 - } 99 - } 100 - } 101 - } catch { 102 - // conservative: return what we have 103 - } 104 - 105 - return urls.slice(0, limit); 106 - }
-188
packages/atproto/src/musicbrainz.ts
··· 1 - import { cache } from './cache.js'; 2 - 3 - interface MusicBrainzRelease { 4 - id: string; 5 - score: number; 6 - title: string; 7 - } 8 - 9 - interface MusicBrainzSearchResponse { 10 - releases: MusicBrainzRelease[]; 11 - } 12 - 13 - interface iTunesSearchResponse { 14 - resultCount: number; 15 - results: Array<{ artworkUrl100?: string }>; 16 - } 17 - 18 - export async function searchMusicBrainzRelease( 19 - trackName: string, 20 - artistName: string, 21 - releaseName?: string 22 - ): Promise<string | null> { 23 - const cacheKey = `mb:release:${trackName}:${artistName}:${releaseName || 'none'}`; 24 - const cached = cache.get<string | null>(cacheKey); 25 - if (cached !== null) return cached; 26 - 27 - try { 28 - if (releaseName) { 29 - const result = await searchByReleaseName(releaseName, artistName); 30 - if (result) { cache.set(cacheKey, result); return result; } 31 - } 32 - const result = await searchByTrackName(trackName, artistName); 33 - if (result) { cache.set(cacheKey, result); return result; } 34 - cache.set(cacheKey, null); 35 - return null; 36 - } catch { 37 - return null; 38 - } 39 - } 40 - 41 - async function searchByReleaseName(releaseName: string, artistName: string): Promise<string | null> { 42 - try { 43 - const query = `release:"${releaseName}" AND artist:"${artistName}"`; 44 - const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 45 - const response = await fetch(url, { 46 - headers: { 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', Accept: 'application/json' } 47 - }); 48 - if (!response.ok) return null; 49 - const data: MusicBrainzSearchResponse = await response.json(); 50 - if (!data.releases?.length) return null; 51 - const best = data.releases[0]; 52 - if (best.score < 80) return null; 53 - return best.id; 54 - } catch { return null; } 55 - } 56 - 57 - async function searchByTrackName(trackName: string, artistName: string): Promise<string | null> { 58 - try { 59 - const query = `recording:"${trackName}" AND artist:"${artistName}"`; 60 - const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 61 - const response = await fetch(url, { 62 - headers: { 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', Accept: 'application/json' } 63 - }); 64 - if (!response.ok) return null; 65 - const data: MusicBrainzSearchResponse = await response.json(); 66 - if (!data.releases?.length) return null; 67 - const best = data.releases[0]; 68 - if (best.score < 75) return null; 69 - return best.id; 70 - } catch { return null; } 71 - } 72 - 73 - export function buildCoverArtUrl(releaseMbId: string, size: 250 | 500 | 1200 = 500): string { 74 - return `https://coverartarchive.org/release/${releaseMbId}/front-${size}`; 75 - } 76 - 77 - export async function searchiTunesArtwork( 78 - trackName: string, 79 - artistName: string, 80 - releaseName?: string 81 - ): Promise<string | null> { 82 - const cacheKey = `itunes:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 83 - const cached = cache.get<string | null>(cacheKey); 84 - if (cached !== null) return cached; 85 - 86 - try { 87 - const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 88 - const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 89 - const response = await fetch(url); 90 - if (!response.ok) { cache.set(cacheKey, null); return null; } 91 - const data: iTunesSearchResponse = await response.json(); 92 - if (!data.results?.length) { cache.set(cacheKey, null); return null; } 93 - let artworkUrl = data.results[0].artworkUrl100; 94 - if (artworkUrl) { 95 - artworkUrl = artworkUrl.replace('100x100', '600x600'); 96 - cache.set(cacheKey, artworkUrl); 97 - return artworkUrl; 98 - } 99 - cache.set(cacheKey, null); 100 - return null; 101 - } catch { 102 - return null; 103 - } 104 - } 105 - 106 - export async function searchDeezerArtwork( 107 - trackName: string, 108 - artistName: string, 109 - releaseName?: string 110 - ): Promise<string | null> { 111 - // Deezer has CORS restrictions in browsers — skip silently 112 - return null; 113 - } 114 - 115 - export async function searchLastFmArtwork( 116 - trackName: string, 117 - artistName: string, 118 - releaseName?: string 119 - ): Promise<string | null> { 120 - if (!releaseName) return null; 121 - 122 - const cacheKey = `lastfm:artwork:${trackName}:${artistName}:${releaseName}`; 123 - const cached = cache.get<string | null>(cacheKey); 124 - if (cached !== null) return cached; 125 - 126 - try { 127 - const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; 128 - const response = await fetch(url); 129 - if (!response.ok) { cache.set(cacheKey, null); return null; } 130 - const data: any = await response.json(); 131 - if (!data.album?.image) { cache.set(cacheKey, null); return null; } 132 - const images = data.album.image; 133 - const largeImage = 134 - images.find((img: any) => img.size === 'extralarge') || 135 - images.find((img: any) => img.size === 'large') || 136 - images.find((img: any) => img.size === 'medium'); 137 - if (largeImage?.['#text']) { 138 - cache.set(cacheKey, largeImage['#text']); 139 - return largeImage['#text']; 140 - } 141 - cache.set(cacheKey, null); 142 - return null; 143 - } catch { 144 - return null; 145 - } 146 - } 147 - 148 - /** 149 - * Cascading artwork search: Cover Art Archive → MusicBrainz+CAA → iTunes → Last.fm 150 - */ 151 - export async function findArtwork( 152 - trackName: string, 153 - artistName: string, 154 - releaseName?: string, 155 - releaseMbId?: string, 156 - fetchFn?: typeof fetch 157 - ): Promise<string | null> { 158 - const _fetch = fetchFn || globalThis.fetch; 159 - 160 - // 1. Try Cover Art Archive with known MBID 161 - if (releaseMbId) { 162 - const caaUrl = buildCoverArtUrl(releaseMbId, 500); 163 - try { 164 - const res = await _fetch(caaUrl, { method: 'HEAD' }); 165 - if (res.ok) return caaUrl; 166 - } catch { /* continue */ } 167 - } 168 - 169 - // 2. Search MusicBrainz for MBID, then try CAA 170 - const mbId = await searchMusicBrainzRelease(trackName, artistName, releaseName); 171 - if (mbId) { 172 - const caaUrl = buildCoverArtUrl(mbId, 500); 173 - try { 174 - const res = await _fetch(caaUrl, { method: 'HEAD' }); 175 - if (res.ok) return caaUrl; 176 - } catch { /* continue */ } 177 - } 178 - 179 - // 3. Try iTunes 180 - const iTunesUrl = await searchiTunesArtwork(trackName, artistName, releaseName); 181 - if (iTunesUrl) return iTunesUrl; 182 - 183 - // 4. Try Last.fm 184 - const lastFmUrl = await searchLastFmArtwork(trackName, artistName, releaseName); 185 - if (lastFmUrl) return lastFmUrl; 186 - 187 - return null; 188 - }
-57
packages/atproto/src/pagination/fetchAllRecords.ts
··· 1 - import { withFallback } from '../agents.js'; 2 - 3 - export interface FetchRecordsConfig { 4 - repo: string; 5 - collection: string; 6 - limit?: number; 7 - fetchFn?: typeof fetch; 8 - } 9 - 10 - export interface AtProtoRecord<T = any> { 11 - uri: string; 12 - value: T; 13 - cid?: string; 14 - } 15 - 16 - export async function fetchAllRecords<T = any>( 17 - config: FetchRecordsConfig 18 - ): Promise<AtProtoRecord<T>[]> { 19 - const { repo, collection, limit = 100, fetchFn } = config; 20 - const allRecords: AtProtoRecord<T>[] = []; 21 - let cursor: string | undefined; 22 - 23 - try { 24 - do { 25 - const records = await withFallback( 26 - repo, 27 - async (agent) => { 28 - const response = await agent.com.atproto.repo.listRecords({ 29 - repo, 30 - collection, 31 - limit, 32 - cursor 33 - }); 34 - cursor = response.data.cursor; 35 - return response.data.records; 36 - }, 37 - true, 38 - fetchFn 39 - ); 40 - allRecords.push(...(records as AtProtoRecord<T>[])); 41 - } while (cursor); 42 - } catch (error) { 43 - console.warn(`Failed to fetch records from ${collection}:`, error); 44 - throw error; 45 - } 46 - 47 - return allRecords; 48 - } 49 - 50 - export async function fetchAllUserRecords<T = any>( 51 - did: string, 52 - collection: string, 53 - fetchFn?: typeof fetch, 54 - limit?: number 55 - ): Promise<AtProtoRecord<T>[]> { 56 - return fetchAllRecords<T>({ repo: did, collection, limit, fetchFn }); 57 - }
-2
packages/atproto/src/pagination/index.ts
··· 1 - export { fetchAllRecords, fetchAllUserRecords } from './fetchAllRecords.js'; 2 - export type { FetchRecordsConfig, AtProtoRecord } from './fetchAllRecords.js';
-197
packages/atproto/src/posts.ts
··· 1 - import { cache } from './cache.js'; 2 - import { withFallback } from './agents.js'; 3 - import type { BlueskyPost, PostAuthor, ExternalLink } from './types.js'; 4 - 5 - export async function fetchLatestBlueskyPost( 6 - did: string, 7 - fetchFn?: typeof fetch 8 - ): Promise<BlueskyPost | null> { 9 - const cacheKey = `blueskypost:latest:${did}`; 10 - const cached = cache.get<BlueskyPost>(cacheKey); 11 - if (cached) return cached; 12 - 13 - try { 14 - const feedResponse = await withFallback( 15 - did, 16 - async (agent) => agent.getAuthorFeed({ actor: did, limit: 5 }), 17 - false, 18 - fetchFn 19 - ); 20 - 21 - const feed = feedResponse.data.feed; 22 - if (!feed.length) return null; 23 - 24 - const latestFeedItem = feed[0]; 25 - const latestPostData = latestFeedItem.post; 26 - 27 - const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 28 - let repostAuthor: PostAuthor | undefined; 29 - let repostCreatedAt: string | undefined; 30 - 31 - if (isRepost && latestFeedItem.reason) { 32 - const reason = latestFeedItem.reason as any; 33 - repostAuthor = { 34 - did: reason.by.did, 35 - handle: reason.by.handle, 36 - displayName: reason.by.displayName, 37 - avatar: reason.by.avatar 38 - }; 39 - repostCreatedAt = reason.indexedAt; 40 - } 41 - 42 - const post = await fetchPostFromUri(did, latestPostData.uri, 0, fetchFn); 43 - if (!post) return null; 44 - 45 - if (isRepost) { 46 - post.isRepost = true; 47 - post.repostAuthor = repostAuthor; 48 - post.repostCreatedAt = repostCreatedAt; 49 - post.originalPost = { ...post }; 50 - } 51 - 52 - cache.set(cacheKey, post); 53 - return post; 54 - } catch { 55 - return null; 56 - } 57 - } 58 - 59 - export async function fetchPostFromUri( 60 - did: string, 61 - uri: string, 62 - depth: number, 63 - fetchFn?: typeof fetch 64 - ): Promise<BlueskyPost | null> { 65 - if (depth >= 3) return null; 66 - 67 - try { 68 - const threadResponse = await withFallback( 69 - did, 70 - async (agent) => agent.getPostThread({ uri, depth: 0 }), 71 - false, 72 - fetchFn 73 - ); 74 - 75 - if (!threadResponse.data.thread || !('post' in threadResponse.data.thread)) return null; 76 - 77 - const postData = threadResponse.data.thread.post; 78 - const value = postData.record as any; 79 - const embed = (postData as any).embed ?? null; 80 - 81 - const author: PostAuthor = { 82 - did: postData.author.did, 83 - handle: postData.author.handle, 84 - displayName: postData.author.displayName, 85 - avatar: postData.author.avatar 86 - }; 87 - 88 - let imageUrls: string[] | undefined; 89 - let imageAlts: string[] | undefined; 90 - let hasImages = false; 91 - let hasVideo = false; 92 - let videoUrl: string | undefined; 93 - let videoThumbnail: string | undefined; 94 - let quotedPost: BlueskyPost | undefined; 95 - let quotedPostUri: string | undefined; 96 - let externalLink: ExternalLink | undefined; 97 - 98 - const facets = value.facets; 99 - 100 - if (embed?.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { 101 - hasImages = true; 102 - imageUrls = []; 103 - imageAlts = []; 104 - for (const img of embed.images) { 105 - const imageUrl = img.fullsize || img.thumb; 106 - if (imageUrl) { imageUrls.push(imageUrl); imageAlts.push(img.alt || ''); } 107 - } 108 - } 109 - 110 - if (embed?.$type === 'app.bsky.embed.video#view') { 111 - if (embed.playlist) { hasVideo = true; videoUrl = embed.playlist; } 112 - if (embed.thumbnail) videoThumbnail = embed.thumbnail; 113 - } 114 - 115 - if (embed?.$type === 'app.bsky.embed.external#view' && embed.external) { 116 - externalLink = { 117 - uri: embed.external.uri, 118 - title: embed.external.title, 119 - description: embed.external.description, 120 - thumb: embed.external.thumb 121 - }; 122 - } 123 - 124 - if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 125 - const media = embed.media; 126 - if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 127 - hasImages = true; 128 - imageUrls = []; 129 - imageAlts = []; 130 - for (const img of media.images) { 131 - const imageUrl = img.fullsize || img.thumb; 132 - if (imageUrl) { imageUrls.push(imageUrl); imageAlts.push(img.alt || ''); } 133 - } 134 - } 135 - if (media?.$type === 'app.bsky.embed.video#view') { 136 - if (media.playlist) { hasVideo = true; videoUrl = media.playlist; } 137 - if (media.thumbnail) videoThumbnail = media.thumbnail; 138 - } 139 - if (media?.$type === 'app.bsky.embed.external#view' && media.external) { 140 - externalLink = { 141 - uri: media.external.uri, 142 - title: media.external.title, 143 - description: media.external.description, 144 - thumb: media.external.thumb 145 - }; 146 - } 147 - const quotedRecord = embed.record?.record || embed.record; 148 - if (quotedRecord?.uri) { 149 - quotedPostUri = quotedRecord.uri as string; 150 - quotedPost = (await fetchPostFromUri(did, quotedPostUri, depth + 1, fetchFn)) ?? undefined; 151 - } 152 - } 153 - 154 - if (embed?.$type === 'app.bsky.embed.record#view') { 155 - const quotedRecord = embed.record?.record || embed.record; 156 - if (quotedRecord?.uri) { 157 - quotedPostUri = quotedRecord.uri as string; 158 - quotedPost = (await fetchPostFromUri(did, quotedPostUri, depth + 1, fetchFn)) ?? undefined; 159 - } 160 - } 161 - 162 - let replyParent: BlueskyPost | undefined; 163 - let replyRoot: BlueskyPost | undefined; 164 - if (value.reply) { 165 - if (value.reply.parent?.uri) { 166 - replyParent = (await fetchPostFromUri(did, value.reply.parent.uri as string, depth + 1, fetchFn)) ?? undefined; 167 - } 168 - if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 169 - replyRoot = (await fetchPostFromUri(did, value.reply.root.uri as string, depth + 1, fetchFn)) ?? undefined; 170 - } 171 - } 172 - 173 - return { 174 - text: value.text, 175 - createdAt: value.createdAt, 176 - uri: postData.uri, 177 - author, 178 - likeCount: postData.likeCount || 0, 179 - repostCount: postData.repostCount || 0, 180 - replyCount: postData.replyCount, 181 - hasImages, 182 - imageUrls, 183 - imageAlts, 184 - hasVideo, 185 - videoUrl, 186 - videoThumbnail, 187 - quotedPostUri, 188 - quotedPost, 189 - facets, 190 - externalLink, 191 - replyParent, 192 - replyRoot 193 - }; 194 - } catch { 195 - return null; 196 - } 197 - }
-272
packages/atproto/src/types.ts
··· 1 - /** 2 - * Type definitions for AT Protocol services 3 - * Identical to src/lib/services/atproto/types.ts — no SvelteKit dependencies. 4 - */ 5 - 6 - export interface ProfileData { 7 - did: string; 8 - handle: string; 9 - displayName?: string; 10 - description?: string; 11 - avatar?: string; 12 - banner?: string; 13 - followersCount?: number; 14 - followsCount?: number; 15 - postsCount?: number; 16 - pronouns?: string; 17 - } 18 - 19 - export interface StatusData { 20 - text: string; 21 - createdAt: string; 22 - } 23 - 24 - export interface Technology { 25 - name: string; 26 - url?: string; 27 - description?: string; 28 - } 29 - 30 - export interface License { 31 - name: string; 32 - url?: string; 33 - } 34 - 35 - export interface BasedOnItem { 36 - section?: string; 37 - name?: string; 38 - url?: string; 39 - description?: string; 40 - type?: string; 41 - } 42 - 43 - export interface RelatedService { 44 - section?: string; 45 - name?: string; 46 - url?: string; 47 - description?: string; 48 - relationship?: string; 49 - } 50 - 51 - export interface Repository { 52 - platform?: string; 53 - url: string; 54 - type?: string; 55 - description?: string; 56 - } 57 - 58 - export interface Credit { 59 - section?: string; 60 - name?: string; 61 - type: string; 62 - url?: string; 63 - author?: string; 64 - license?: License; 65 - description?: string; 66 - } 67 - 68 - export interface SectionLicense { 69 - section?: string; 70 - name?: string; 71 - url?: string; 72 - } 73 - 74 - export interface SiteInfoData { 75 - technologyStack?: Technology[]; 76 - privacyStatement?: string; 77 - openSourceInfo?: { 78 - description?: string; 79 - license?: License; 80 - basedOn?: BasedOnItem[]; 81 - relatedServices?: RelatedService[]; 82 - repositories?: Repository[]; 83 - }; 84 - credits?: Credit[]; 85 - additionalInfo?: { 86 - websiteBirthYear?: number; 87 - purpose?: string; 88 - sectionLicense?: SectionLicense[]; 89 - }; 90 - } 91 - 92 - export interface LinkCard { 93 - url: string; 94 - text: string; 95 - emoji: string; 96 - } 97 - 98 - export interface LinkData { 99 - cards: LinkCard[]; 100 - } 101 - 102 - export interface BlogPost { 103 - title: string; 104 - url: string; 105 - createdAt: string; 106 - platform: 'standard.site'; 107 - description?: string; 108 - rkey: string; 109 - publicationName?: string; 110 - publicationRkey?: string; 111 - tags?: string[]; 112 - coverImage?: string; 113 - textContent?: string; 114 - updatedAt?: string; 115 - } 116 - 117 - export interface BlogPostsData { 118 - posts: BlogPost[]; 119 - } 120 - 121 - export interface Facet { 122 - index: { byteStart: number; byteEnd: number }; 123 - features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 124 - } 125 - 126 - export interface ExternalLink { 127 - uri: string; 128 - title: string; 129 - description?: string; 130 - thumb?: string; 131 - } 132 - 133 - export interface PostAuthor { 134 - did: string; 135 - handle: string; 136 - displayName?: string; 137 - avatar?: string; 138 - pronouns?: string; 139 - } 140 - 141 - export interface BlueskyPost { 142 - text: string; 143 - createdAt: string; 144 - uri: string; 145 - author: PostAuthor; 146 - likeCount?: number; 147 - repostCount?: number; 148 - replyCount?: number; 149 - hasImages: boolean; 150 - imageUrls?: string[]; 151 - imageAlts?: string[]; 152 - hasVideo?: boolean; 153 - videoUrl?: string; 154 - videoThumbnail?: string; 155 - quotedPostUri?: string; 156 - quotedPost?: BlueskyPost; 157 - facets?: Facet[]; 158 - externalLink?: ExternalLink; 159 - replyParent?: BlueskyPost; 160 - replyRoot?: BlueskyPost; 161 - isRepost?: boolean; 162 - repostAuthor?: PostAuthor; 163 - repostCreatedAt?: string; 164 - originalPost?: BlueskyPost; 165 - } 166 - 167 - export interface ResolvedIdentity { 168 - did: string; 169 - pds: string; 170 - } 171 - 172 - export interface CacheEntry<T> { 173 - data: T; 174 - timestamp: number; 175 - } 176 - 177 - export interface MusicArtist { 178 - artistName: string; 179 - artistMbId?: string; 180 - } 181 - 182 - export interface MusicStatusData { 183 - trackName: string; 184 - artists: MusicArtist[]; 185 - releaseName?: string; 186 - playedTime: string; 187 - originUrl?: string; 188 - recordingMbId?: string; 189 - releaseMbId?: string; 190 - isrc?: string; 191 - duration?: number; 192 - musicServiceBaseDomain?: string; 193 - submissionClientAgent?: string; 194 - $type: 'fm.teal.alpha.actor.status' | 'fm.teal.alpha.feed.play'; 195 - expiry?: string; 196 - artwork?: { ref?: { $link: string }; mimeType?: string; size?: number }; 197 - artworkUrl?: string; 198 - } 199 - 200 - export interface KibunStatusData { 201 - text: string; 202 - emoji: string; 203 - createdAt: string; 204 - $type: 'social.kibun.status'; 205 - } 206 - 207 - export interface TangledRepo { 208 - uri: string; 209 - name: string; 210 - description?: string; 211 - knot: string; 212 - createdAt: string; 213 - labels?: string[]; 214 - source?: string; 215 - spindle?: string; 216 - } 217 - 218 - export interface TangledReposData { 219 - repos: TangledRepo[]; 220 - } 221 - 222 - export interface StandardSiteThemeColor { 223 - r: number; 224 - g: number; 225 - b: number; 226 - a?: number; 227 - } 228 - 229 - export interface StandardSiteBasicTheme { 230 - background: StandardSiteThemeColor; 231 - foreground: StandardSiteThemeColor; 232 - accent: StandardSiteThemeColor; 233 - accentForeground: StandardSiteThemeColor; 234 - } 235 - 236 - export interface StandardSitePublication { 237 - name: string; 238 - rkey: string; 239 - uri: string; 240 - url: string; 241 - description?: string; 242 - icon?: string; 243 - basicTheme?: StandardSiteBasicTheme; 244 - preferences?: { showInDiscover?: boolean }; 245 - } 246 - 247 - export interface StandardSitePublicationsData { 248 - publications: StandardSitePublication[]; 249 - } 250 - 251 - export interface StandardSiteDocument { 252 - title: string; 253 - rkey: string; 254 - uri: string; 255 - url: string; 256 - site: string; 257 - path?: string; 258 - description?: string; 259 - coverImage?: string; 260 - content?: any; 261 - textContent?: string; 262 - bskyPostRef?: { uri: string; cid: string }; 263 - tags?: string[]; 264 - publishedAt: string; 265 - updatedAt?: string; 266 - publicationName?: string; 267 - publicationRkey?: string; 268 - } 269 - 270 - export interface StandardSiteDocumentsData { 271 - documents: StandardSiteDocument[]; 272 - }
-13
packages/atproto/tsconfig.json
··· 1 - { 2 - "extends": "../../tsconfig.json", 3 - "compilerOptions": { 4 - "rootDir": "src", 5 - "outDir": "dist", 6 - "declaration": true, 7 - "declarationMap": true, 8 - "moduleResolution": "bundler", 9 - "module": "esnext", 10 - "target": "es2022" 11 - }, 12 - "include": ["src"] 13 - }
-69
packages/ui/README.md
··· 1 - # @ewanc26/ui 2 - 3 - > **Canonical source:** This package is now maintained in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo under [`packages/ui`](https://github.com/ewanc26/pkgs/tree/main/packages/ui). This copy exists for historical context — please open issues and PRs there. 4 - 5 - Svelte UI component library extracted from [ewancroft.uk](https://ewancroft.uk). Provides layout and card components, UI primitives, SEO helpers, Svelte stores, post utilities, and a multi-theme configuration system. 6 - 7 - ## Installation 8 - 9 - ```bash 10 - pnpm add @ewanc26/ui 11 - ``` 12 - 13 - Peer dependencies: `svelte >= 5`, `@sveltejs/kit >= 2`, `tailwindcss >= 4`. Optional: `@ewanc26/atproto` (for AT Protocol card components). 14 - 15 - ## What's Exported 16 - 17 - ### Components 18 - 19 - | Group | Components | 20 - |-------|------------| 21 - | Layout toggles | `ThemeToggle`, `WolfToggle` | 22 - | Layout main | `DynamicLinks`, `ScrollToTop` | 23 - | Cards | `ProfileCard`, `PostCard`, `BlueskyPostCard`, `LinkCard`, `MusicStatusCard`, `KibunStatusCard`, `TangledRepoCard` | 24 - | UI primitives | `Card`, `InternalCard`, `Dropdown`, `Pagination`, `SearchBar`, `Tabs`, `PostsGroupedView`, `DocumentCard`, `BlogPostCard` | 25 - | SEO | `MetaTags` | 26 - 27 - ### Stores 28 - 29 - | Store | Type | Description | 30 - |-------|------|-------------| 31 - | `wolfMode` | `Writable<boolean>` | Wolf mode text transformation | 32 - | `colorTheme` | `Writable<ColorTheme>` | Active colour theme | 33 - | `colorThemeDropdownOpen` | `Writable<boolean>` | Theme picker open state | 34 - | `happyMacStore` | `Writable<boolean>` | Happy Mac easter egg | 35 - 36 - ### Theme Configuration 37 - 38 - 12 themes across four categories (neutral, warm, cool, vibrant) using OKLCH colour values. Default: `slate`. 39 - 40 - ```typescript 41 - import { THEMES, DEFAULT_THEME, getTheme, getThemesByCategory, CATEGORY_LABELS } from '@ewanc26/ui'; 42 - ``` 43 - 44 - ### Helpers 45 - 46 - ```typescript 47 - import { getPostBadges, getBadgeClasses } from '@ewanc26/ui'; 48 - import { filterPosts, groupPostsByDate, getSortedMonths, getSortedYears, getAllTags } from '@ewanc26/ui'; 49 - ``` 50 - 51 - ### Types 52 - 53 - ```typescript 54 - import type { SiteMetadata, NavItem, ColorTheme, ThemeDefinition, PostBadge, MonthData, GroupedPosts } from '@ewanc26/ui'; 55 - ``` 56 - 57 - ## Build 58 - 59 - Development happens in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo. Local commands (from `packages/ui`): 60 - 61 - ```bash 62 - pnpm build # svelte-package 63 - pnpm dev # svelte-package --watch 64 - pnpm check # svelte-check 65 - ``` 66 - 67 - ## Licence 68 - 69 - AGPL-3.0-only — see the [pkgs monorepo licence](https://github.com/ewanc26/pkgs/blob/main/LICENSE).
-45
packages/ui/package.json
··· 1 - { 2 - "name": "@ewanc26/ui", 3 - "version": "0.1.0", 4 - "description": "Svelte UI component library extracted from ewancroft.uk — pluggable layout, UI primitives, stores, and SEO components.", 5 - "type": "module", 6 - "exports": { 7 - ".": { 8 - "source": "./src/lib/index.ts", 9 - "types": "./dist/index.d.ts", 10 - "svelte": "./dist/index.js", 11 - "default": "./dist/index.js" 12 - } 13 - }, 14 - "svelte": "./dist/index.js", 15 - "types": "./dist/index.d.ts", 16 - "publishConfig": { "access": "public" }, 17 - "files": [ 18 - "dist", 19 - "src" 20 - ], 21 - "scripts": { 22 - "build": "svelte-package -i src/lib -o dist", 23 - "dev": "svelte-package -i src/lib -o dist --watch", 24 - "check": "svelte-check --tsconfig ./tsconfig.json" 25 - }, 26 - "peerDependencies": { 27 - "@sveltejs/kit": ">=2.0.0", 28 - "svelte": ">=5.0.0", 29 - "tailwindcss": ">=4.0.0" 30 - }, 31 - "dependencies": { 32 - "@lucide/svelte": "^0.554.0" 33 - }, 34 - "optionalDependencies": { 35 - "@ewanc26/atproto": "workspace:*" 36 - }, 37 - "devDependencies": { 38 - "@sveltejs/kit": "^2.49.0", 39 - "@sveltejs/package": "^2.3.10", 40 - "@sveltejs/vite-plugin-svelte": "^6.2.1", 41 - "svelte": "^5.43.14", 42 - "svelte-check": "^4.3.4", 43 - "typescript": "^5.9.3" 44 - } 45 - }
-50
packages/ui/src/lib/components/layout/ThemeToggle.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { Sun, Moon } from '@lucide/svelte'; 4 - 5 - let isDark = $state(false); 6 - let mounted = $state(false); 7 - 8 - onMount(() => { 9 - const stored = localStorage.getItem('theme'); 10 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 11 - isDark = stored === 'light' || (!stored && !prefersDark); 12 - updateTheme(); 13 - mounted = true; 14 - 15 - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 16 - const handleChange = (e: MediaQueryListEvent) => { 17 - if (!localStorage.getItem('theme')) { isDark = e.matches; updateTheme(); } 18 - }; 19 - mediaQuery.addEventListener('change', handleChange); 20 - return () => mediaQuery.removeEventListener('change', handleChange); 21 - }); 22 - 23 - function updateTheme() { 24 - const html = document.documentElement; 25 - if (isDark) { html.classList.remove('dark'); html.style.colorScheme = 'light'; } 26 - else { html.classList.add('dark'); html.style.colorScheme = 'dark'; } 27 - } 28 - 29 - function toggleTheme() { 30 - isDark = !isDark; 31 - localStorage.setItem('theme', isDark ? 'light' : 'dark'); 32 - updateTheme(); 33 - } 34 - </script> 35 - 36 - <button 37 - onclick={toggleTheme} 38 - class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 39 - aria-label={isDark ? 'Switch to dark mode' : 'Switch to light mode'} 40 - type="button" 41 - > 42 - {#if mounted} 43 - <div class="relative h-5 w-5"> 44 - <Moon class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark ? 'scale-100 rotate-0 opacity-100' : 'scale-0 rotate-90 opacity-0'}" aria-hidden="true" /> 45 - <Sun class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark ? 'scale-0 -rotate-90 opacity-0' : 'scale-100 rotate-0 opacity-100'}" aria-hidden="true" /> 46 - </div> 47 - {:else} 48 - <div class="h-5 w-5 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div> 49 - {/if} 50 - </button>
-19
packages/ui/src/lib/components/layout/WolfToggle.svelte
··· 1 - <script lang="ts"> 2 - import { wolfMode } from '../../stores/wolfMode.js'; 3 - 4 - let isWolfMode = $state(false); 5 - $effect(() => { 6 - const unsubscribe = wolfMode.subscribe((value) => { isWolfMode = value; }); 7 - return unsubscribe; 8 - }); 9 - </script> 10 - 11 - <button 12 - onclick={() => wolfMode.toggle()} 13 - class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 14 - aria-label={isWolfMode ? 'Disable wolf mode' : 'Enable wolf mode'} 15 - type="button" 16 - title={isWolfMode ? 'Return to normal text' : 'Transform to wolf speak - awoo!'} 17 - > 18 - <span class="text-2xl transition-transform duration-300 {isWolfMode ? 'scale-125' : 'scale-100'}" aria-hidden="true">🐺</span> 19 - </button>
-4
packages/ui/src/lib/components/layout/index.ts
··· 1 - export { default as ThemeToggle } from './ThemeToggle.svelte'; 2 - export { default as WolfToggle } from './WolfToggle.svelte'; 3 - export { DynamicLinks, ScrollToTop, TangledRepos } from './main/index.js'; 4 - export { LinkCard, ProfileCard, PostCard, BlueskyPostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard } from './main/card/index.js';
-63
packages/ui/src/lib/components/layout/main/DynamicLinks.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import Card from '../../ui/Card.svelte'; 4 - import LinkCard from './card/LinkCard.svelte'; 5 - import { fetchLinks, type LinkData } from '@ewanc26/atproto'; 6 - 7 - interface Props { did: string; } 8 - let { did }: Props = $props(); 9 - 10 - let links = $state<LinkData | null>(null); 11 - let loading = $state(true); 12 - let error = $state<string | null>(null); 13 - 14 - onMount(async () => { 15 - try { 16 - links = await fetchLinks(did); 17 - } catch (err) { 18 - error = err instanceof Error ? err.message : 'Failed to load links'; 19 - } finally { 20 - loading = false; 21 - } 22 - }); 23 - </script> 24 - 25 - <div class="mx-auto w-full max-w-2xl"> 26 - {#if loading} 27 - <Card loading={true} variant="elevated" padding="md"> 28 - {#snippet skeleton()} 29 - <div class="mb-4 h-6 w-20 rounded bg-canvas-300 dark:bg-canvas-700"></div> 30 - <div class="grid gap-3 sm:grid-cols-2"> 31 - {#each Array(4) as _} 32 - <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 33 - <div class="h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700 mb-2"></div> 34 - <div class="h-4 w-1/2 rounded bg-canvas-300 dark:bg-canvas-700"></div> 35 - </div> 36 - {/each} 37 - </div> 38 - {/snippet} 39 - </Card> 40 - {:else if error} 41 - <Card error={true} errorMessage={error} /> 42 - {:else if links && links.cards.length > 0} 43 - {@const safeLinks = links} 44 - <Card variant="elevated" padding="md"> 45 - {#snippet children()} 46 - <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Links</h2> 47 - <div class="grid gap-3 sm:grid-cols-2"> 48 - {#each safeLinks.cards as link} 49 - <LinkCard url={link.url} title={link.text} emoji={link.emoji} /> 50 - {/each} 51 - </div> 52 - {/snippet} 53 - </Card> 54 - {:else} 55 - <Card variant="flat" padding="lg"> 56 - {#snippet children()} 57 - <div class="text-center"> 58 - <p class="text-ink-700 dark:text-ink-300">No links available. Create a <code class="rounded bg-canvas-200 px-1 dark:bg-canvas-800">blue.linkat.board</code> record at <a href="https://linkat.blue/" class="text-primary-600 hover:underline dark:text-primary-400" target="_blank" rel="noopener noreferrer">https://linkat.blue/</a></p> 59 - </div> 60 - {/snippet} 61 - </Card> 62 - {/if} 63 - </div>
-36
packages/ui/src/lib/components/layout/main/ScrollToTop.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { ChevronUp } from '@lucide/svelte'; 4 - 5 - let scrollY = $state(0); 6 - let isVisible = $derived(scrollY > 300); 7 - 8 - function scrollToTop() { 9 - window.scrollTo({ top: 0, behavior: 'smooth' }); 10 - } 11 - 12 - onMount(() => { 13 - const updateScrollY = () => (scrollY = window.scrollY); 14 - window.addEventListener('scroll', updateScrollY, { passive: true }); 15 - return () => window.removeEventListener('scroll', updateScrollY); 16 - }); 17 - </script> 18 - 19 - <svelte:window bind:scrollY /> 20 - 21 - <div 22 - class="fixed bottom-8 left-8 z-50 transition-opacity duration-300 motion-reduce:transition-none sm:bottom-6 sm:left-6" 23 - class:opacity-100={isVisible} 24 - class:opacity-0={!isVisible} 25 - > 26 - <button 27 - onclick={scrollToTop} 28 - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); scrollToTop(); } }} 29 - aria-label="Scroll to top" 30 - title="Scroll to top" 31 - type="button" 32 - class="flex h-12 w-12 items-center justify-center rounded-full border border-primary-200 bg-canvas-100 text-ink-900 shadow-lg transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-primary-500 hover:text-ink-50 hover:shadow-xl focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:outline-none motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:h-11 sm:w-11 dark:border-primary-800 dark:bg-canvas-900 dark:text-ink-50 dark:hover:bg-primary-600" 33 - > 34 - <ChevronUp width="20" height="20" aria-hidden="true" /> 35 - </button> 36 - </div>
-261
packages/ui/src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 1 - <script lang="ts"> 2 - import Card from '../../../ui/Card.svelte'; 3 - import { fetchLatestBlueskyPost, type BlueskyPost } from '@ewanc26/atproto'; 4 - import { formatRelativeTime } from '../../../../utils/locale.js'; 5 - import { formatCompactNumber } from '../../../../utils/formatNumber.js'; 6 - import { Heart, Repeat2, MessageCircle, ExternalLink, X } from '@lucide/svelte'; 7 - import Hls from 'hls.js'; 8 - 9 - interface Props { 10 - did: string; 11 - post?: BlueskyPost | null; 12 - } 13 - 14 - let { did, post: initialPost = null }: Props = $props(); 15 - 16 - let post = $state<BlueskyPost | null>(null); 17 - let loading = $state(false); 18 - let error = $state<string | null>(null); 19 - let lightboxImage = $state<{ url: string; alt: string } | null>(null); 20 - let videoElements = new Map<string, { element: HTMLVideoElement; hls: Hls | null }>(); 21 - 22 - const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 23 - const POLL_INTERVAL = 2 * 60 * 1000; 24 - 25 - async function loadPost() { 26 - try { 27 - const newPost = await fetchLatestBlueskyPost(did); 28 - if (newPost && (!post || newPost.uri !== post.uri)) post = newPost; 29 - } catch (err) { 30 - error = err instanceof Error ? err.message : 'Failed to load latest post'; 31 - } finally { 32 - loading = false; 33 - } 34 - } 35 - 36 - $effect(() => { 37 - if (initialPost && !post) post = initialPost; 38 - if (!post) { loading = true; loadPost(); } 39 - const pollInterval = setInterval(() => loadPost(), POLL_INTERVAL); 40 - return () => { 41 - clearInterval(pollInterval); 42 - videoElements.forEach(({ hls }) => { if (hls) hls.destroy(); }); 43 - videoElements.clear(); 44 - }; 45 - }); 46 - 47 - function getPostUrl(uri: string): string { 48 - const parts = uri.split('/'); 49 - return `https://witchsky.app/profile/${parts[2]}/post/${parts[4]}`; 50 - } 51 - 52 - function getProfileUrl(handle: string): string { 53 - return `https://witchsky.app/profile/${handle}`; 54 - } 55 - 56 - function openLightbox(url: string, alt: string) { 57 - lightboxImage = { url, alt }; 58 - document.body.style.overflow = 'hidden'; 59 - } 60 - 61 - function closeLightbox() { 62 - lightboxImage = null; 63 - document.body.style.overflow = ''; 64 - } 65 - 66 - function escapeHtml(text: string): string { 67 - const div = document.createElement('div'); 68 - div.textContent = text; 69 - return div.innerHTML; 70 - } 71 - 72 - function renderRichText(text: string, facets?: any[]): string { 73 - if (!facets?.length) return escapeHtml(text); 74 - const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 75 - const encoder = new TextEncoder(); 76 - const decoder = new TextDecoder(); 77 - const bytes = encoder.encode(text); 78 - let result = ''; 79 - let lastByteIndex = 0; 80 - for (const facet of sortedFacets) { 81 - const { byteStart, byteEnd } = facet.index; 82 - if (lastByteIndex < byteStart) result += escapeHtml(decoder.decode(bytes.slice(lastByteIndex, byteStart))); 83 - const facetText = decoder.decode(bytes.slice(byteStart, byteEnd)); 84 - const feature = facet.features?.[0]; 85 - if (feature) { 86 - if (feature.$type === 'app.bsky.richtext.facet#link') { 87 - result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline">${escapeHtml(facetText)}</a>`; 88 - } else if (feature.$type === 'app.bsky.richtext.facet#mention') { 89 - result += `<a href="https://witchsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 90 - } else if (feature.$type === 'app.bsky.richtext.facet#tag') { 91 - result += `<a href="https://witchsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 92 - } else { result += escapeHtml(facetText); } 93 - } else { result += escapeHtml(facetText); } 94 - lastByteIndex = byteEnd; 95 - } 96 - if (lastByteIndex < bytes.length) result += escapeHtml(new TextDecoder().decode(bytes.slice(lastByteIndex))); 97 - return result; 98 - } 99 - 100 - function setupVideo(videoElement: HTMLVideoElement, videoUrl: string) { 101 - if (!videoElement || !videoUrl) return; 102 - const existing = videoElements.get(videoUrl); 103 - if (existing?.hls) existing.hls.destroy(); 104 - let hls: Hls | null = null; 105 - if (videoUrl.includes('.m3u8')) { 106 - if (Hls.isSupported()) { 107 - hls = new Hls({ enableSoftwareAES: true, maxBufferLength: 30, maxMaxBufferLength: 600 }); 108 - hls.loadSource(videoUrl); 109 - hls.attachMedia(videoElement); 110 - videoElements.set(videoUrl, { element: videoElement, hls }); 111 - } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { 112 - videoElement.src = videoUrl; 113 - videoElements.set(videoUrl, { element: videoElement, hls: null }); 114 - } 115 - } else { 116 - videoElement.src = videoUrl; 117 - videoElements.set(videoUrl, { element: videoElement, hls: null }); 118 - } 119 - return { destroy() { if (hls) hls.destroy(); videoElements.delete(videoUrl); } }; 120 - } 121 - </script> 122 - 123 - {#snippet postContent(postData: BlueskyPost, depth: number = 0, isReplyParent: boolean = false)} 124 - <div> 125 - <div class="relative flex gap-3"> 126 - <a href={getProfileUrl(postData.author.handle)} target="_blank" rel="noopener noreferrer" class="shrink-0 transition-opacity hover:opacity-80"> 127 - {#if postData.author.avatar} 128 - <img src={postData.author.avatar} alt={postData.author.displayName || postData.author.handle} class="h-{isReplyParent ? '8' : '10'} w-{isReplyParent ? '8' : '10'} rounded-full object-cover sm:h-{isReplyParent ? '10' : '12'} sm:w-{isReplyParent ? '10' : '12'}" loading="lazy" /> 129 - {:else} 130 - <div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800"> 131 - <span class="text-base font-semibold text-primary-700 dark:text-primary-300">{(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()}</span> 132 - </div> 133 - {/if} 134 - </a> 135 - <div class="min-w-0 flex-1"> 136 - <a href={getProfileUrl(postData.author.handle)} target="_blank" rel="noopener noreferrer" class="mb-2 inline-block transition-opacity hover:opacity-80"> 137 - <div class="flex flex-col"> 138 - <span class="text-base leading-tight font-semibold text-ink-900 dark:text-ink-50">{postData.author.displayName || postData.author.handle}</span> 139 - <span class="text-xs leading-tight text-ink-600 dark:text-ink-400">@{postData.author.handle}</span> 140 - </div> 141 - </a> 142 - <div class="overflow-wrap-anywhere mb-3 text-base leading-relaxed wrap-break-word whitespace-pre-wrap text-ink-900 dark:text-ink-50"> 143 - {@html renderRichText(postData.text, postData.facets)} 144 - </div> 145 - {#if postData.hasVideo && postData.videoUrl} 146 - <div class="mb-3 max-w-full overflow-hidden rounded-xl border border-canvas-300 bg-black dark:border-canvas-700"> 147 - <video use:setupVideo={postData.videoUrl} controls class="w-full max-w-full" preload="metadata" poster={postData.videoThumbnail} playsinline> 148 - <track kind="captions" /> 149 - </video> 150 - </div> 151 - {/if} 152 - {#if postData.hasImages && postData.imageUrls?.length} 153 - <div class="mb-3 grid max-w-full gap-1 {postData.imageUrls.length === 1 ? 'grid-cols-1' : postData.imageUrls.length === 2 ? 'grid-cols-2' : postData.imageUrls.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}"> 154 - {#each postData.imageUrls as imageUrl, index} 155 - <button type="button" onclick={() => openLightbox(imageUrl, postData.imageAlts?.[index] || `Post attachment ${index + 1}`)} class="h-auto w-full max-w-full overflow-hidden rounded-lg border border-canvas-300 transition-opacity hover:opacity-90 dark:border-canvas-700"> 156 - <img src={imageUrl} alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} class="h-auto w-full max-w-full object-cover {postData.imageUrls!.length > 1 ? 'aspect-video' : 'max-h-96'}" loading="lazy" /> 157 - </button> 158 - {/each} 159 - </div> 160 - {/if} 161 - {#if postData.externalLink} 162 - <a href={postData.externalLink.uri} target="_blank" rel="noopener noreferrer" class="mb-3 flex max-w-full flex-col overflow-hidden rounded-xl border border-canvas-300 bg-canvas-200 transition-colors hover:bg-canvas-300 dark:border-canvas-700 dark:bg-canvas-800 dark:hover:bg-canvas-700"> 163 - {#if postData.externalLink.thumb}<img src={postData.externalLink.thumb} alt={postData.externalLink.title} class="h-48 w-full max-w-full object-cover" loading="lazy" />{/if} 164 - <div class="p-3"> 165 - <h3 class="overflow-wrap-anywhere mb-1 line-clamp-2 text-sm font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{postData.externalLink.title}</h3> 166 - {#if postData.externalLink.description}<p class="overflow-wrap-anywhere mb-2 line-clamp-2 text-xs wrap-break-word text-ink-700 dark:text-ink-300">{postData.externalLink.description}</p>{/if} 167 - <p class="overflow-wrap-anywhere text-xs wrap-break-word text-ink-600 dark:text-ink-400">{new URL(postData.externalLink.uri).hostname}</p> 168 - </div> 169 - </a> 170 - {/if} 171 - {#if postData.quotedPost && depth < 3} 172 - <div class="mb-3 rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"> 173 - {@render postContent(postData.quotedPost, depth + 1, depth === 0)} 174 - </div> 175 - {/if} 176 - {#if !isReplyParent} 177 - <div class="flex flex-wrap items-center gap-3 pt-1 text-xs sm:gap-6 sm:text-sm"> 178 - {#if postData.replyCount !== undefined}<div class="flex items-center gap-1 text-ink-600 dark:text-ink-400"><MessageCircle class="h-3.5 w-3.5" aria-hidden="true" /><span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span></div>{/if} 179 - {#if postData.repostCount !== undefined}<div class="flex items-center gap-1 text-ink-600 dark:text-ink-400"><Repeat2 class="h-3.5 w-3.5" aria-hidden="true" /><span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span></div>{/if} 180 - {#if postData.likeCount !== undefined}<div class="flex items-center gap-1 text-ink-600 dark:text-ink-400"><Heart class="h-3.5 w-3.5" aria-hidden="true" /><span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span></div>{/if} 181 - <time datetime={postData.createdAt} class="ml-auto text-xs font-medium text-ink-700 dark:text-ink-300">{formatRelativeTime(postData.createdAt)}</time> 182 - </div> 183 - {/if} 184 - </div> 185 - </div> 186 - </div> 187 - {/snippet} 188 - 189 - <div class="mx-auto w-full max-w-2xl"> 190 - {#if loading} 191 - <Card loading={true} variant="elevated" padding="md"> 192 - {#snippet skeleton()} 193 - <div class="mb-3 flex items-center gap-3"> 194 - <div class="h-12 w-12 rounded-full bg-canvas-300 dark:bg-canvas-700"></div> 195 - <div class="flex-1 space-y-2"> 196 - <div class="h-4 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 197 - <div class="h-3 w-24 rounded bg-canvas-300 dark:bg-canvas-700"></div> 198 - </div> 199 - </div> 200 - <div class="mb-3 space-y-2"> 201 - <div class="h-4 w-full rounded bg-canvas-300 dark:bg-canvas-700"></div> 202 - <div class="h-4 w-5/6 rounded bg-canvas-300 dark:bg-canvas-700"></div> 203 - </div> 204 - {/snippet} 205 - </Card> 206 - {:else if error} 207 - <Card error={true} errorMessage={error} /> 208 - {:else if post} 209 - <article class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 210 - <div class="mb-4 flex items-start justify-between gap-2 sm:items-center"> 211 - <div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2"> 212 - <span class="text-xs font-semibold tracking-wide whitespace-nowrap text-ink-700 uppercase dark:text-ink-300">Latest Bluesky Post</span> 213 - {#if post.isRepost && post.repostAuthor} 214 - <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 215 - <Repeat2 class="h-3 w-3 shrink-0" aria-hidden="true" /> 216 - <a href={getProfileUrl(post.repostAuthor.handle)} target="_blank" rel="noopener noreferrer" class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400"> 217 - {post.repostAuthor.displayName || post.repostAuthor.handle} 218 - </a> 219 - <span class="whitespace-nowrap">reposted</span> 220 - </div> 221 - {:else if post.replyParent} 222 - <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 223 - <MessageCircle class="h-3 w-3 shrink-0" aria-hidden="true" /> 224 - <span class="whitespace-nowrap">Replying to</span> 225 - <a href={getProfileUrl(post.replyParent.author.handle)} target="_blank" rel="noopener noreferrer" class="truncate font-medium text-primary-600 dark:text-primary-400">@{post.replyParent.author.handle}</a> 226 - </div> 227 - {/if} 228 - </div> 229 - <a href={getPostUrl(post.uri)} target="_blank" rel="noopener noreferrer" class="shrink-0 text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400" aria-label="View post on Bluesky"> 230 - <ExternalLink class="h-4 w-4" aria-hidden="true" /> 231 - </a> 232 - </div> 233 - {#if post.replyParent} 234 - <div class="mb-4 rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"> 235 - {@render postContent(post.replyParent, 0, true)} 236 - </div> 237 - {/if} 238 - {@render postContent(post, 0, false)} 239 - </article> 240 - {:else} 241 - <Card variant="flat" padding="lg"> 242 - {#snippet children()} 243 - <div class="text-center"><p class="text-ink-700 dark:text-ink-300">No posts found</p></div> 244 - {/snippet} 245 - </Card> 246 - {/if} 247 - </div> 248 - 249 - {#if lightboxImage} 250 - <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4" onclick={closeLightbox} onkeydown={(e) => e.key === 'Escape' && closeLightbox()} role="button" tabindex="0" aria-label="Close image lightbox"> 251 - <button type="button" onclick={closeLightbox} class="absolute top-4 right-4 z-10 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70" aria-label="Close"> 252 - <X class="h-6 w-6" /> 253 - </button> 254 - <div class="relative flex max-h-[90vh] w-full max-w-[90vw] flex-col items-center"> 255 - <img src={lightboxImage.url} alt={lightboxImage.alt} class="max-h-[80vh] w-full object-contain" loading="lazy" /> 256 - {#if lightboxImage.alt} 257 - <div class="mt-4 w-full rounded-lg bg-black/70 px-4 py-2 text-center text-sm text-white">{lightboxImage.alt}</div> 258 - {/if} 259 - </div> 260 - </div> 261 - {/if}
-48
packages/ui/src/lib/components/layout/main/card/KibunStatusCard.svelte
··· 1 - <script lang="ts"> 2 - import Card from '../../../ui/Card.svelte'; 3 - import type { KibunStatusData } from '@ewanc26/atproto'; 4 - import { formatRelativeTime } from '../../../../utils/locale.js'; 5 - import { Heart } from '@lucide/svelte'; 6 - 7 - interface Props { kibunStatus?: KibunStatusData | null; } 8 - let { kibunStatus = null }: Props = $props(); 9 - </script> 10 - 11 - <div class="mx-auto w-full max-w-2xl"> 12 - {#if !kibunStatus} 13 - <Card loading={true} variant="elevated" padding="md"> 14 - {#snippet skeleton()} 15 - <div class="mb-3"> 16 - <div class="mb-2 flex items-center gap-2"> 17 - <div class="h-4 w-4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 18 - <div class="h-3 w-24 rounded bg-canvas-300 dark:bg-canvas-700"></div> 19 - </div> 20 - <div class="mb-2 flex items-center gap-3"> 21 - <div class="h-12 w-12 rounded bg-canvas-300 dark:bg-canvas-700"></div> 22 - <div class="h-6 w-48 rounded bg-canvas-300 dark:bg-canvas-700"></div> 23 - </div> 24 - <div class="h-3 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 25 - </div> 26 - {/snippet} 27 - </Card> 28 - {:else} 29 - {@const s = kibunStatus} 30 - <Card variant="elevated" padding="md"> 31 - {#snippet children()} 32 - <div> 33 - <div class="mb-4 flex items-center gap-2"> 34 - <Heart class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 35 - <span class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100">Current Mood</span> 36 - </div> 37 - <div class="mb-4 flex items-center gap-3"> 38 - <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-canvas-100 text-3xl dark:bg-canvas-800">{s.emoji}</div> 39 - <p class="flex-1 text-lg font-medium wrap-break-word whitespace-normal text-ink-900 dark:text-ink-50">{s.text}</p> 40 - </div> 41 - <div class="text-xs text-ink-700 dark:text-ink-200"> 42 - <time datetime={s.createdAt}>{formatRelativeTime(s.createdAt)}</time> 43 - </div> 44 - </div> 45 - {/snippet} 46 - </Card> 47 - {/if} 48 - </div>
-63
packages/ui/src/lib/components/layout/main/card/LinkCard.svelte
··· 1 - <script lang="ts"> 2 - import { ExternalLink } from '@lucide/svelte'; 3 - import InternalCard from '../../../ui/InternalCard.svelte'; 4 - 5 - interface Badge { text: string; color?: 'mint' | 'sage'; } 6 - interface Props { 7 - url: string; 8 - title: string; 9 - emoji?: string; 10 - description?: string; 11 - badges?: Badge[]; 12 - meta?: string; 13 - variant?: 'default' | 'button'; 14 - } 15 - 16 - let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props(); 17 - 18 - function getDomain(url: string): string { 19 - try { return new URL(url).hostname.replace('www.', ''); } catch { return ''; } 20 - } 21 - 22 - let displayDescription = $derived(description || getDomain(url)); 23 - </script> 24 - 25 - {#if variant === 'button'} 26 - <InternalCard href={url} class="flex-row! items-center! justify-center! gap-2!"> 27 - {#snippet children()} 28 - <span class="font-medium">{title}</span> 29 - <ExternalLink class="h-4 w-4 shrink-0" aria-hidden="true" /> 30 - {/snippet} 31 - </InternalCard> 32 - {:else} 33 - <InternalCard href={url}> 34 - {#snippet children()} 35 - <div class="min-w-0 flex-1 space-y-2"> 36 - {#if emoji || (badges && badges.length > 0)} 37 - <div class="flex flex-wrap items-center gap-2"> 38 - {#if emoji}<span class="text-lg leading-none">{emoji}</span>{/if} 39 - {#if badges && badges.length > 0} 40 - {#each badges as badge} 41 - {#if badge.color === 'mint'} 42 - <span class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200">{badge.text}</span> 43 - {:else if badge.color === 'sage'} 44 - <span class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200">{badge.text}</span> 45 - {:else} 46 - <span class="text-xs font-semibold text-ink-800 uppercase dark:text-ink-100">{badge.text}</span> 47 - {/if} 48 - {/each} 49 - {/if} 50 - </div> 51 - {/if} 52 - <h3 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{title}</h3> 53 - {#if displayDescription} 54 - <p class="overflow-wrap-anywhere line-clamp-2 text-sm wrap-break-word text-ink-700 dark:text-ink-200">{displayDescription}</p> 55 - {/if} 56 - {#if meta} 57 - <p class="text-xs font-medium text-ink-800 dark:text-ink-100">{meta}</p> 58 - {/if} 59 - </div> 60 - <ExternalLink class="h-4 w-4 shrink-0 text-ink-700 transition-colors dark:text-ink-200" aria-hidden="true" /> 61 - {/snippet} 62 - </InternalCard> 63 - {/if}
-101
packages/ui/src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 1 - <script lang="ts"> 2 - import Card from '../../../ui/Card.svelte'; 3 - import type { MusicStatusData } from '@ewanc26/atproto'; 4 - import { formatRelativeTime } from '../../../../utils/locale.js'; 5 - import { Music, Disc3, Users, Album, Clock, Radio } from '@lucide/svelte'; 6 - 7 - interface Props { musicStatus?: MusicStatusData | null; } 8 - let { musicStatus = null }: Props = $props(); 9 - 10 - let artworkError = $state(false); 11 - 12 - function formatArtists(artists: { artistName: string }[]): string { 13 - if (!artists?.length) return 'Unknown Artist'; 14 - return artists.map((a) => a.artistName).join(', '); 15 - } 16 - 17 - function formatDuration(seconds?: number): string { 18 - if (!seconds) return ''; 19 - const minutes = Math.floor(seconds / 60); 20 - const remainingSeconds = seconds % 60; 21 - return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; 22 - } 23 - 24 - function formatServiceName(domain?: string): string { 25 - if (!domain) return ''; 26 - return domain.replace('lastfm', 'Last.fm').replace('last.fm', 'Last.fm'); 27 - } 28 - </script> 29 - 30 - <div class="mx-auto w-full max-w-2xl"> 31 - {#if !musicStatus} 32 - <Card loading={true} variant="elevated" padding="md"> 33 - {#snippet skeleton()} 34 - <div class="mb-3 flex items-start gap-4"> 35 - <div class="h-20 w-20 shrink-0 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div> 36 - <div class="flex-1"> 37 - <div class="mb-2 flex items-center gap-2"> 38 - <div class="h-4 w-4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 39 - <div class="h-3 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 40 - </div> 41 - <div class="mb-1 h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 42 - <div class="mb-2 h-4 w-1/2 rounded bg-canvas-300 dark:bg-canvas-700"></div> 43 - <div class="h-3 w-40 rounded bg-canvas-300 dark:bg-canvas-700"></div> 44 - </div> 45 - </div> 46 - {/snippet} 47 - </Card> 48 - {:else} 49 - {@const s = musicStatus} 50 - <Card variant="elevated" padding="md"> 51 - {#snippet children()} 52 - <div> 53 - <div class="mb-4 flex items-center gap-2"> 54 - <Music class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 55 - <span class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100"> 56 - {s.$type === 'fm.teal.alpha.actor.status' ? 'Now Listening' : 'Last Played'} 57 - </span> 58 - </div> 59 - <div class="flex items-start gap-3"> 60 - <div class="shrink-0"> 61 - {#if s.artworkUrl && !artworkError} 62 - <img src={s.artworkUrl} alt="Album artwork for {s.releaseName || s.trackName}" class="h-20 w-20 rounded-lg object-cover shadow-md" loading="lazy" onerror={() => (artworkError = true)} /> 63 - {:else} 64 - <div class="flex h-20 w-20 items-center justify-center rounded-lg bg-canvas-200 shadow-md dark:bg-canvas-700"> 65 - <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 66 - </div> 67 - {/if} 68 - </div> 69 - <div class="min-w-0 flex-1"> 70 - <div class="mb-4"> 71 - <a href={s.originUrl || '#'} target="_blank" rel="noopener noreferrer" class="block max-w-full text-lg font-semibold wrap-break-word whitespace-normal text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" class:pointer-events-none={!s.originUrl} class:cursor-default={!s.originUrl}> 72 - {s.trackName} 73 - </a> 74 - <p class="mt-1 flex max-w-full items-start gap-1.5 text-base wrap-break-word whitespace-normal text-ink-800 dark:text-ink-100"> 75 - <Users class="mt-0.5 h-4 w-4 shrink-0 text-ink-600 dark:text-ink-300" /> 76 - {formatArtists(s.artists)} 77 - </p> 78 - {#if s.releaseName} 79 - <p class="mt-1 flex max-w-full items-start gap-1.5 text-sm wrap-break-word whitespace-normal text-ink-700 dark:text-ink-200"> 80 - <Album class="mt-0.5 h-4 w-4 shrink-0 text-ink-500 dark:text-ink-400" /> 81 - <span>{s.releaseName}{#if s.duration}<span class="ml-1 inline-flex items-center gap-1 text-ink-600 dark:text-ink-300">· <Clock class="h-3 w-3" />{formatDuration(s.duration)}</span>{/if}</span> 82 - </p> 83 - {/if} 84 - </div> 85 - <div class="flex items-center gap-2 text-xs text-ink-700 dark:text-ink-200"> 86 - <time datetime={s.playedTime}>{formatRelativeTime(s.playedTime)}</time> 87 - {#if s.musicServiceBaseDomain} 88 - <span class="text-ink-600 dark:text-ink-300">·</span> 89 - <a href="https://teal.fm" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 transition-colors hover:text-primary-600 dark:hover:text-primary-400" title="Powered by teal.fm"> 90 - <Radio class="h-3 w-3" /> 91 - {formatServiceName(s.musicServiceBaseDomain)} via {s.submissionClientAgent} 92 - </a> 93 - {/if} 94 - </div> 95 - </div> 96 - </div> 97 - </div> 98 - {/snippet} 99 - </Card> 100 - {/if} 101 - </div>
-46
packages/ui/src/lib/components/layout/main/card/PostCard.svelte
··· 1 - <script lang="ts"> 2 - import Card from '../../../ui/Card.svelte'; 3 - import DocumentCard from '../../../ui/DocumentCard.svelte'; 4 - import type { StandardSiteDocument } from '@ewanc26/atproto'; 5 - 6 - interface Props { documents?: StandardSiteDocument[] | null; } 7 - let { documents = null }: Props = $props(); 8 - </script> 9 - 10 - <div class="mx-auto w-full max-w-2xl"> 11 - {#if !documents} 12 - <Card loading={true} variant="elevated" padding="md"> 13 - {#snippet skeleton()} 14 - <div class="mb-4 h-6 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 15 - <div class="space-y-3"> 16 - {#each Array(3) as _} 17 - <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 18 - <div class="mb-2 h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 19 - <div class="mb-2 h-4 w-full rounded bg-canvas-300 dark:bg-canvas-700"></div> 20 - <div class="h-3 w-24 rounded bg-canvas-300 dark:bg-canvas-700"></div> 21 - </div> 22 - {/each} 23 - </div> 24 - {/snippet} 25 - </Card> 26 - {:else if documents.length > 0} 27 - <Card variant="elevated" padding="md"> 28 - {#snippet children()} 29 - <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Recent Posts</h2> 30 - <div class="space-y-3"> 31 - {#each documents as document} 32 - <DocumentCard {document} /> 33 - {/each} 34 - </div> 35 - {/snippet} 36 - </Card> 37 - {:else} 38 - <Card variant="flat" padding="lg"> 39 - {#snippet children()} 40 - <div class="text-center"> 41 - <p class="text-ink-700 dark:text-ink-300">No documents available. Start writing on <a href="https://standard.site/" class="text-primary-600 hover:underline dark:text-primary-400" target="_blank" rel="noopener noreferrer">Standard.site</a> to get started!</p> 42 - </div> 43 - {/snippet} 44 - </Card> 45 - {/if} 46 - </div>
-70
packages/ui/src/lib/components/layout/main/card/ProfileCard.svelte
··· 1 - <script lang="ts"> 2 - import Card from '../../../ui/Card.svelte'; 3 - import type { ProfileData } from '@ewanc26/atproto'; 4 - import LinkCard from './LinkCard.svelte'; 5 - import { formatCompactNumber } from '../../../../utils/formatNumber.js'; 6 - 7 - interface Props { profile?: ProfileData | null; } 8 - let { profile = null }: Props = $props(); 9 - 10 - let imageLoaded = $state(false); 11 - let bannerLoaded = $state(false); 12 - const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 13 - </script> 14 - 15 - <div class="mx-auto w-full max-w-2xl"> 16 - {#if !profile} 17 - <Card loading={true} variant="elevated" padding="none" class="overflow-hidden"> 18 - {#snippet skeleton()} 19 - <div class="h-32 w-full rounded-t-xl bg-canvas-300 dark:bg-canvas-700"></div> 20 - <div class="relative -mt-16 flex justify-center sm:ml-6 sm:justify-start"> 21 - <div class="h-32 w-32 rounded-full border-4 border-white bg-canvas-300 dark:border-canvas-900 dark:bg-canvas-700"></div> 22 - </div> 23 - <div class="space-y-2 p-6 pt-2 sm:pt-4"> 24 - <div class="h-6 w-1/2 rounded bg-canvas-300 dark:bg-canvas-700"></div> 25 - <div class="h-4 w-1/3 rounded bg-canvas-300 dark:bg-canvas-700"></div> 26 - <div class="h-4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 27 - <div class="h-4 w-5/6 rounded bg-canvas-300 dark:bg-canvas-700"></div> 28 - </div> 29 - {/snippet} 30 - </Card> 31 - {:else} 32 - {@const p = profile} 33 - <Card variant="elevated" padding="none" ariaLabel="Profile information"> 34 - {#snippet children()} 35 - <div class="relative h-32 w-full overflow-hidden rounded-t-xl"> 36 - {#if p.banner} 37 - <img src={p.banner} alt="" class="h-full w-full object-cover opacity-0 transition-opacity duration-300" class:opacity-100={bannerLoaded} onload={() => (bannerLoaded = true)} loading="lazy" role="presentation" /> 38 - {:else} 39 - <div class="h-full w-full bg-linear-to-r from-primary-400 to-secondary-400" role="presentation"></div> 40 - {/if} 41 - </div> 42 - <div class="relative -mt-16 flex justify-center sm:ml-6 sm:justify-start"> 43 - <div class="h-32 w-32 overflow-hidden rounded-full border-4 border-white bg-canvas-200 dark:border-canvas-900"> 44 - {#if p.avatar} 45 - <img src={p.avatar} alt="{p.displayName || p.handle}'s profile picture" class="h-full w-full object-cover opacity-0 transition-opacity duration-300" class:opacity-100={imageLoaded} onload={() => (imageLoaded = true)} loading="lazy" /> 46 - {:else} 47 - <div class="flex h-full w-full items-center justify-center bg-primary-200 text-3xl font-bold text-primary-800 dark:bg-primary-800 dark:text-primary-200" role="img" aria-label="{p.displayName || p.handle}'s avatar initials"> 48 - {(p.displayName || p.handle).charAt(0).toUpperCase()} 49 - </div> 50 - {/if} 51 - </div> 52 - </div> 53 - <div class="p-6"> 54 - <h2 class="text-2xl font-bold text-ink-900 dark:text-ink-50">{p.displayName || p.handle}</h2> 55 - <p class="font-medium text-ink-700 dark:text-ink-200">@{p.handle}</p> 56 - {#if p.pronouns}<p class="text-sm text-ink-600 italic dark:text-ink-300">{p.pronouns}</p>{/if} 57 - {#if p.description}<p class="wrap-break-words mb-4 break-all whitespace-pre-wrap text-ink-700 dark:text-ink-200">{p.description}</p>{/if} 58 - <div class="flex gap-6 text-sm font-medium" role="list" aria-label="Profile statistics"> 59 - <div class="flex items-center gap-1" role="listitem"><span class="font-bold text-ink-900 dark:text-ink-50">{formatCompactNumber(p.postsCount, locale)}</span><span class="text-ink-700 dark:text-ink-200">Posts</span></div> 60 - <div class="flex items-center gap-1" role="listitem"><span class="font-bold text-ink-900 dark:text-ink-50">{formatCompactNumber(p.followersCount, locale)}</span><span class="text-ink-700 dark:text-ink-200">Followers</span></div> 61 - <div class="flex items-center gap-1" role="listitem"><span class="font-bold text-ink-900 dark:text-ink-50">{formatCompactNumber(p.followsCount, locale)}</span><span class="text-ink-700 dark:text-ink-200">Following</span></div> 62 - </div> 63 - <div class="mt-4"> 64 - <LinkCard url="https://witchsky.app/profile/{p.did}" title="View on Bluesky" variant="button" /> 65 - </div> 66 - </div> 67 - {/snippet} 68 - </Card> 69 - {/if} 70 - </div>
-80
packages/ui/src/lib/components/layout/main/card/TangledRepoCard.svelte
··· 1 - <script lang="ts"> 2 - import { ExternalLink, GitBranch, Server, User } from '@lucide/svelte'; 3 - import Card from '../../../ui/Card.svelte'; 4 - import InternalCard from '../../../ui/InternalCard.svelte'; 5 - import type { TangledReposData, ProfileData } from '@ewanc26/atproto'; 6 - 7 - interface Props { 8 - repos?: TangledReposData | null; 9 - profile?: ProfileData | null; 10 - /** Fallback DID if profile handle is unavailable */ 11 - did?: string; 12 - } 13 - 14 - let { repos = null, profile = null, did = '' }: Props = $props(); 15 - let handle = $derived(profile?.handle || null); 16 - 17 - function buildRepoUrl(repoName: string): string { 18 - const identifier = handle || did; 19 - return `https://tangled.org/${identifier}/${repoName}`; 20 - } 21 - 22 - function getKnotServerName(knot: string): string { 23 - if (knot.startsWith('http')) { 24 - try { return new URL(knot).hostname; } catch { return knot; } 25 - } 26 - return knot; 27 - } 28 - </script> 29 - 30 - <div class="mx-auto w-full max-w-2xl"> 31 - {#if !repos} 32 - <Card loading={true} variant="elevated" padding="md"> 33 - {#snippet skeleton()} 34 - <div class="mb-4 h-6 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 35 - <div class="space-y-3"> 36 - {#each Array(3) as _} 37 - <div class="h-24 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div> 38 - {/each} 39 - </div> 40 - {/snippet} 41 - </Card> 42 - {:else if repos.repos.length > 0} 43 - <Card variant="elevated" padding="md"> 44 - {#snippet children()} 45 - <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2> 46 - <div class="space-y-3"> 47 - {#each repos.repos as repo} 48 - <InternalCard href={buildRepoUrl(repo.name)}> 49 - {#snippet children()} 50 - <GitBranch class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 51 - <div class="min-w-0 flex-1 space-y-2"> 52 - <h3 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{repo.name}</h3> 53 - <div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200"> 54 - <div class="flex min-w-0 items-center gap-1"> 55 - <Server class="h-3 w-3 shrink-0" aria-hidden="true" /> 56 - <span class="truncate">{getKnotServerName(repo.knot)}</span> 57 - </div> 58 - <div class="flex min-w-0 items-center gap-1"> 59 - <User class="h-3 w-3 shrink-0" aria-hidden="true" /> 60 - <span class="truncate">{handle || did}</span> 61 - </div> 62 - </div> 63 - </div> 64 - <ExternalLink class="h-4 w-4 shrink-0 text-ink-700 transition-colors dark:text-ink-200" aria-hidden="true" /> 65 - {/snippet} 66 - </InternalCard> 67 - {/each} 68 - </div> 69 - {/snippet} 70 - </Card> 71 - {:else} 72 - <Card variant="flat" padding="lg"> 73 - {#snippet children()} 74 - <div class="text-center"> 75 - <p class="text-ink-700 dark:text-ink-300">No Tangled repositories found.</p> 76 - </div> 77 - {/snippet} 78 - </Card> 79 - {/if} 80 - </div>
-7
packages/ui/src/lib/components/layout/main/card/index.ts
··· 1 - export { default as LinkCard } from './LinkCard.svelte'; 2 - export { default as ProfileCard } from './ProfileCard.svelte'; 3 - export { default as PostCard } from './PostCard.svelte'; 4 - export { default as BlueskyPostCard } from './BlueskyPostCard.svelte'; 5 - export { default as TangledRepoCard } from './TangledRepoCard.svelte'; 6 - export { default as MusicStatusCard } from './MusicStatusCard.svelte'; 7 - export { default as KibunStatusCard } from './KibunStatusCard.svelte';
-3
packages/ui/src/lib/components/layout/main/index.ts
··· 1 - export { default as DynamicLinks } from './DynamicLinks.svelte'; 2 - export { default as ScrollToTop } from './ScrollToTop.svelte'; 3 - export { default as TangledRepos } from './card/TangledRepoCard.svelte';
-39
packages/ui/src/lib/components/seo/MetaTags.svelte
··· 1 - <script lang="ts"> 2 - import type { SiteMetadata } from '../../types/index.js'; 3 - 4 - interface Props { meta: SiteMetadata; siteMeta: SiteMetadata; } 5 - let { meta, siteMeta }: Props = $props(); 6 - 7 - const finalMeta = $derived({ 8 - title: meta.title || siteMeta.title, 9 - description: meta.description || siteMeta.description, 10 - keywords: meta.keywords || siteMeta.keywords, 11 - url: meta.url || siteMeta.url, 12 - image: meta.image || siteMeta.image, 13 - imageWidth: meta.imageWidth || siteMeta.imageWidth, 14 - imageHeight: meta.imageHeight || siteMeta.imageHeight 15 - }); 16 - </script> 17 - 18 - <svelte:head> 19 - <title>{finalMeta.title}</title> 20 - <meta name="description" content={finalMeta.description} /> 21 - <meta name="keywords" content={finalMeta.keywords} /> 22 - <meta property="og:type" content="website" /> 23 - <meta property="og:url" content={finalMeta.url} /> 24 - <meta property="og:title" content={finalMeta.title} /> 25 - <meta property="og:description" content={finalMeta.description} /> 26 - <meta property="og:site_name" content={siteMeta.title} /> 27 - <meta property="og:image" content={finalMeta.image} /> 28 - {#if finalMeta.imageWidth} 29 - <meta property="og:image:width" content={finalMeta.imageWidth.toString()} /> 30 - {/if} 31 - {#if finalMeta.imageHeight} 32 - <meta property="og:image:height" content={finalMeta.imageHeight.toString()} /> 33 - {/if} 34 - <meta name="twitter:card" content="summary_large_image" /> 35 - <meta name="twitter:url" content={finalMeta.url} /> 36 - <meta name="twitter:title" content={finalMeta.title} /> 37 - <meta name="twitter:description" content={finalMeta.description} /> 38 - <meta name="twitter:image" content={finalMeta.image} /> 39 - </svelte:head>
-1
packages/ui/src/lib/components/seo/index.ts
··· 1 - export { default as MetaTags } from './MetaTags.svelte';
-44
packages/ui/src/lib/components/ui/BlogPostCard.svelte
··· 1 - <script lang="ts"> 2 - import { ExternalLink, Tag } from '@lucide/svelte'; 3 - import type { BlogPost } from '@ewanc26/atproto'; 4 - import InternalCard from './InternalCard.svelte'; 5 - import { getPostBadges, getBadgeClasses } from '../../helper/badges.js'; 6 - import { formatLocalizedDate } from '../../utils/locale.js'; 7 - 8 - interface Props { post: BlogPost; locale?: string; } 9 - let { post, locale }: Props = $props(); 10 - const badges = $derived(getPostBadges(post)); 11 - </script> 12 - 13 - <InternalCard href={post.url}> 14 - {#snippet children()} 15 - {#if post.coverImage} 16 - <div class="mb-3 overflow-hidden rounded-lg"> 17 - <img src={post.coverImage} alt={post.title} class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" /> 18 - </div> 19 - {/if} 20 - <div class="relative min-w-0 flex-1 space-y-2"> 21 - {#if badges.length > 0} 22 - <div class="flex flex-wrap items-center gap-2"> 23 - {#each badges as badge}<span class={getBadgeClasses(badge)}>{badge.text}</span>{/each} 24 - </div> 25 - {/if} 26 - <h4 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{post.title}</h4> 27 - {#if post.description} 28 - <p class="overflow-wrap-anywhere line-clamp-2 text-sm wrap-break-word text-ink-700 dark:text-ink-200">{post.description}</p> 29 - {/if} 30 - <div class="pt-1"> 31 - <p class="text-xs font-medium text-ink-800 dark:text-ink-100">{formatLocalizedDate(post.createdAt, locale)}</p> 32 - </div> 33 - </div> 34 - <div class="flex shrink-0 flex-col items-end justify-between gap-2 self-stretch"> 35 - <ExternalLink class="h-4 w-4 text-ink-700 transition-colors dark:text-ink-200" aria-hidden="true" /> 36 - {#if post.tags && post.tags.length > 0} 37 - <div class="flex items-center gap-1.5 rounded bg-ink-100 px-2 py-0.5 dark:bg-ink-800"> 38 - <Tag class="h-3 w-3 text-ink-700 dark:text-ink-200" aria-hidden="true" /> 39 - <span class="text-xs font-medium text-ink-800 dark:text-ink-100">{post.tags.length}</span> 40 - </div> 41 - {/if} 42 - </div> 43 - {/snippet} 44 - </InternalCard>
-144
packages/ui/src/lib/components/ui/Card.svelte
··· 1 - <script lang="ts"> 2 - import { ExternalLink } from '@lucide/svelte'; 3 - import type { Snippet } from 'svelte'; 4 - 5 - export interface Badge { 6 - text: string; 7 - color?: 'mint' | 'sage' | 'jade' | 'ink'; 8 - variant?: 'solid' | 'soft'; 9 - } 10 - 11 - interface Props { 12 - variant?: 'default' | 'elevated' | 'flat' | 'button' | 'outline'; 13 - padding?: 'sm' | 'md' | 'lg' | 'none'; 14 - interactive?: boolean; 15 - href?: string; 16 - target?: string; 17 - rel?: string; 18 - showExternalIcon?: boolean; 19 - badges?: Badge[]; 20 - loading?: boolean; 21 - error?: boolean; 22 - errorMessage?: string; 23 - class?: string; 24 - ariaLabel?: string; 25 - children?: Snippet; 26 - skeleton?: Snippet; 27 - } 28 - 29 - let { 30 - variant = 'default', 31 - padding = 'md', 32 - interactive = false, 33 - href, 34 - target = '_blank', 35 - rel = 'noopener noreferrer', 36 - showExternalIcon = false, 37 - badges = [], 38 - loading = false, 39 - error = false, 40 - errorMessage = 'Something went wrong', 41 - class: customClass = '', 42 - ariaLabel, 43 - children, 44 - skeleton 45 - }: Props = $props(); 46 - 47 - let isLink = $derived(!!href); 48 - const baseClasses = 'rounded-xl transition-all duration-300'; 49 - const variantClasses = { 50 - default: 'bg-canvas-100 dark:bg-canvas-900 shadow-md', 51 - elevated: 'bg-canvas-100 dark:bg-canvas-900 shadow-lg hover:shadow-xl', 52 - flat: 'bg-canvas-200 dark:bg-canvas-800', 53 - button: 54 - 'bg-canvas-200 dark:bg-canvas-800 hover:bg-canvas-300 dark:hover:bg-canvas-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600', 55 - outline: 56 - 'bg-transparent border-2 border-canvas-300 dark:border-canvas-700 hover:border-primary-400 dark:hover:border-primary-600' 57 - }; 58 - const paddingClasses = { none: '', sm: 'p-4', md: 'p-6', lg: 'p-8' }; 59 - let interactiveClasses = $derived(interactive || isLink ? 'cursor-pointer' : ''); 60 - let cardClasses = $derived( 61 - `${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${interactiveClasses} ${customClass}` 62 - ); 63 - 64 - function getBadgeClasses(badge: Badge): string { 65 - const baseStyle = 66 - badge.variant === 'soft' 67 - ? 'px-2 py-0.5 text-xs font-medium rounded' 68 - : 'px-2 py-0.5 text-xs font-semibold uppercase rounded'; 69 - const colorClasses = { 70 - mint: 71 - badge.variant === 'soft' 72 - ? 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200' 73 - : 'bg-secondary-500 text-white dark:bg-secondary-600', 74 - sage: 75 - badge.variant === 'soft' 76 - ? 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200' 77 - : 'bg-primary-500 text-white dark:bg-primary-600', 78 - jade: 79 - badge.variant === 'soft' 80 - ? 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200' 81 - : 'bg-accent-500 text-white dark:bg-accent-600', 82 - ink: 83 - badge.variant === 'soft' 84 - ? 'bg-ink-100 text-ink-800 dark:bg-ink-800 dark:text-ink-100' 85 - : 'bg-ink-700 text-white dark:bg-ink-300 dark:text-ink-900' 86 - }; 87 - return `${baseStyle} ${colorClasses[badge.color || 'ink']}`; 88 - } 89 - </script> 90 - 91 - {#if loading} 92 - <div class="{cardClasses} animate-pulse" aria-label="Loading content" role="status"> 93 - {#if skeleton} 94 - {@render skeleton()} 95 - {:else} 96 - <div class="space-y-3"> 97 - <div class="h-4 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 98 - <div class="h-4 w-full rounded bg-canvas-300 dark:bg-canvas-700"></div> 99 - <div class="h-4 w-5/6 rounded bg-canvas-300 dark:bg-canvas-700"></div> 100 - </div> 101 - {/if} 102 - <span class="sr-only">Loading...</span> 103 - </div> 104 - {:else if error} 105 - <div 106 - class="{cardClasses} border-2 border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20" 107 - role="alert" 108 - aria-live="polite" 109 - > 110 - <p class="text-red-600 dark:text-red-400">{errorMessage}</p> 111 - </div> 112 - {:else if isLink} 113 - <a {href} {target} {rel} class={cardClasses} aria-label={ariaLabel || `Link to ${href}`}> 114 - {#if badges.length > 0} 115 - <div class="mb-3 flex flex-wrap items-center gap-2"> 116 - {#each badges as badge} 117 - <span class={getBadgeClasses(badge)}>{badge.text}</span> 118 - {/each} 119 - </div> 120 - {/if} 121 - <div class="flex items-start justify-between gap-3"> 122 - <div class="flex-1"> 123 - {#if children}{@render children()}{/if} 124 - </div> 125 - {#if showExternalIcon} 126 - <ExternalLink 127 - class="h-4 w-4 shrink-0 text-ink-700 transition-colors group-hover:text-primary-600 dark:text-ink-200 dark:group-hover:text-primary-400" 128 - aria-hidden="true" 129 - /> 130 - {/if} 131 - </div> 132 - </a> 133 - {:else} 134 - <div class={cardClasses} role={ariaLabel ? 'region' : undefined} aria-label={ariaLabel}> 135 - {#if badges.length > 0} 136 - <div class="mb-3 flex flex-wrap items-center gap-2"> 137 - {#each badges as badge} 138 - <span class={getBadgeClasses(badge)}>{badge.text}</span> 139 - {/each} 140 - </div> 141 - {/if} 142 - {#if children}{@render children()}{/if} 143 - </div> 144 - {/if}
-42
packages/ui/src/lib/components/ui/DocumentCard.svelte
··· 1 - <script lang="ts"> 2 - import { ExternalLink, Tag } from '@lucide/svelte'; 3 - import type { StandardSiteDocument } from '@ewanc26/atproto'; 4 - import InternalCard from './InternalCard.svelte'; 5 - import { formatLocalizedDate } from '../../utils/locale.js'; 6 - 7 - interface Props { document: StandardSiteDocument; locale?: string; } 8 - let { document, locale }: Props = $props(); 9 - </script> 10 - 11 - <InternalCard href={document.url}> 12 - {#snippet children()} 13 - {#if document.coverImage} 14 - <div class="mb-3 overflow-hidden rounded-lg"> 15 - <img src={document.coverImage} alt={document.title} class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" /> 16 - </div> 17 - {/if} 18 - <div class="relative min-w-0 flex-1 space-y-2"> 19 - {#if document.publicationName} 20 - <div class="flex flex-wrap items-center gap-2"> 21 - <span class="rounded bg-accent-500 px-2 py-0.5 text-xs font-semibold text-white uppercase dark:bg-accent-600">{document.publicationName}</span> 22 - </div> 23 - {/if} 24 - <h4 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{document.title}</h4> 25 - {#if document.description} 26 - <p class="overflow-wrap-anywhere line-clamp-2 text-sm wrap-break-word text-ink-700 dark:text-ink-200">{document.description}</p> 27 - {/if} 28 - <div class="pt-1"> 29 - <p class="text-xs font-medium text-ink-800 dark:text-ink-100">{formatLocalizedDate(document.publishedAt, locale)}</p> 30 - </div> 31 - </div> 32 - <div class="flex shrink-0 flex-col items-end justify-between gap-2 self-stretch"> 33 - <ExternalLink class="h-4 w-4 text-ink-700 transition-colors dark:text-ink-200" aria-hidden="true" /> 34 - {#if document.tags && document.tags.length > 0} 35 - <div class="flex items-center gap-1.5 rounded bg-ink-100 px-2 py-0.5 dark:bg-ink-800"> 36 - <Tag class="h-3 w-3 text-ink-700 dark:text-ink-200" aria-hidden="true" /> 37 - <span class="text-xs font-medium text-ink-800 dark:text-ink-100">{document.tags.length}</span> 38 - </div> 39 - {/if} 40 - </div> 41 - {/snippet} 42 - </InternalCard>
-36
packages/ui/src/lib/components/ui/Dropdown.svelte
··· 1 - <script lang="ts"> 2 - import { ChevronDown } from '@lucide/svelte'; 3 - 4 - interface Option { value: string; label: string; } 5 - interface Props { 6 - options: Option[]; 7 - value: string; 8 - label?: string; 9 - placeholder?: string; 10 - id?: string; 11 - } 12 - 13 - let { options, value = $bindable(), label, placeholder = 'Select...', id = 'dropdown' }: Props = $props(); 14 - </script> 15 - 16 - <div class="relative"> 17 - {#if label} 18 - <label for={id} class="mb-2 block text-sm font-medium text-ink-700 dark:text-ink-200">{label}</label> 19 - {/if} 20 - <div class="relative"> 21 - <select 22 - {id} 23 - bind:value 24 - class="w-full appearance-none rounded-lg border-2 border-canvas-300 bg-canvas-100 py-2 pr-10 pl-3 text-ink-900 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-50 dark:focus:border-primary-400" 25 - aria-label={label || 'Select an option'} 26 - > 27 - <option value="" disabled>{placeholder}</option> 28 - {#each options as option} 29 - <option value={option.value}>{option.label}</option> 30 - {/each} 31 - </select> 32 - <div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-ink-500 dark:text-ink-400" aria-hidden="true"> 33 - <ChevronDown class="h-5 w-5" /> 34 - </div> 35 - </div> 36 - </div>
-41
packages/ui/src/lib/components/ui/InternalCard.svelte
··· 1 - <script lang="ts"> 2 - import type { Snippet } from 'svelte'; 3 - 4 - interface Props { 5 - href?: string; 6 - target?: string; 7 - rel?: string; 8 - onclick?: () => void; 9 - class?: string; 10 - ariaLabel?: string; 11 - children?: Snippet; 12 - } 13 - 14 - let { 15 - href, 16 - target = '_blank', 17 - rel = 'noopener noreferrer', 18 - onclick, 19 - class: customClass = '', 20 - ariaLabel, 21 - children 22 - }: Props = $props(); 23 - 24 - const baseClasses = 25 - 'flex items-start gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700 self-start'; 26 - let combinedClasses = $derived(`${baseClasses} ${customClass}`); 27 - </script> 28 - 29 - {#if href} 30 - <a {href} {target} {rel} class={combinedClasses} aria-label={ariaLabel}> 31 - {#if children}{@render children()}{/if} 32 - </a> 33 - {:else if onclick} 34 - <button type="button" {onclick} class={combinedClasses} aria-label={ariaLabel}> 35 - {#if children}{@render children()}{/if} 36 - </button> 37 - {:else} 38 - <div class={combinedClasses} role={ariaLabel ? 'region' : undefined} aria-label={ariaLabel}> 39 - {#if children}{@render children()}{/if} 40 - </div> 41 - {/if}
-74
packages/ui/src/lib/components/ui/Pagination.svelte
··· 1 - <script lang="ts"> 2 - import { ChevronLeft, ChevronRight } from '@lucide/svelte'; 3 - 4 - interface Props { 5 - currentPage: number; 6 - totalPages: number; 7 - totalItems: number; 8 - itemsPerPage: number; 9 - onPageChange: (page: number) => void; 10 - } 11 - 12 - let { currentPage, totalPages, totalItems, itemsPerPage, onPageChange }: Props = $props(); 13 - 14 - function getPageNumbers(current: number, total: number): (number | string)[] { 15 - if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); 16 - const pages: (number | string)[] = [1]; 17 - if (current > 3) pages.push('...'); 18 - const start = Math.max(2, current - 1); 19 - const end = Math.min(total - 1, current + 1); 20 - for (let i = start; i <= end; i++) pages.push(i); 21 - if (current < total - 2) pages.push('...'); 22 - if (total > 1) pages.push(total); 23 - return pages; 24 - } 25 - 26 - const pageNumbers = $derived(getPageNumbers(currentPage, totalPages)); 27 - const startItem = $derived((currentPage - 1) * itemsPerPage + 1); 28 - const endItem = $derived(Math.min(currentPage * itemsPerPage, totalItems)); 29 - </script> 30 - 31 - {#if totalPages > 1} 32 - <nav class="mt-12" aria-label="Pagination navigation"> 33 - <div class="flex items-center justify-center gap-2" role="navigation"> 34 - <button 35 - onclick={() => currentPage > 1 && onPageChange(currentPage - 1)} 36 - disabled={currentPage === 1} 37 - class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800" 38 - aria-label="Go to previous page" 39 - > 40 - <ChevronLeft class="h-5 w-5" aria-hidden="true" /> 41 - </button> 42 - 43 - {#each pageNumbers as page} 44 - {#if page === '...'} 45 - <span class="px-2 text-ink-500 dark:text-ink-400" aria-hidden="true">...</span> 46 - {:else} 47 - <button 48 - onclick={() => onPageChange(page as number)} 49 - class="flex h-10 min-w-[2.5rem] items-center justify-center rounded-lg border-2 px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 {currentPage === page 50 - ? 'border-primary-500 bg-primary-500 text-white dark:border-primary-400 dark:bg-primary-400' 51 - : 'border-canvas-300 bg-canvas-100 text-ink-700 hover:bg-canvas-200 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800'}" 52 - aria-label="Go to page {page}" 53 - aria-current={currentPage === page ? 'page' : undefined} 54 - > 55 - {page} 56 - </button> 57 - {/if} 58 - {/each} 59 - 60 - <button 61 - onclick={() => currentPage < totalPages && onPageChange(currentPage + 1)} 62 - disabled={currentPage === totalPages} 63 - class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800" 64 - aria-label="Go to next page" 65 - > 66 - <ChevronRight class="h-5 w-5" aria-hidden="true" /> 67 - </button> 68 - </div> 69 - <p class="mt-4 text-center text-sm text-ink-600 dark:text-ink-300" role="status" aria-live="polite" aria-atomic="true"> 70 - Page {currentPage} of {totalPages} &middot; Showing {startItem}–{endItem} of {totalItems} 71 - {totalItems === 1 ? 'item' : 'items'} 72 - </p> 73 - </nav> 74 - {/if}
-40
packages/ui/src/lib/components/ui/PostsGroupedView.svelte
··· 1 - <script lang="ts"> 2 - import type { BlogPost } from '@ewanc26/atproto'; 3 - import BlogPostCard from './BlogPostCard.svelte'; 4 - import { getUserLocale } from '../../utils/locale.js'; 5 - import { groupPostsByDate, getSortedMonths, getSortedYears } from '../../helper/posts.js'; 6 - 7 - interface Props { posts: BlogPost[]; locale?: string; filterYear?: string | null; } 8 - let { posts, locale, filterYear }: Props = $props(); 9 - let userLocale = $derived(locale || getUserLocale()); 10 - const groupedPosts = $derived(groupPostsByDate(posts, userLocale)); 11 - const sortedYears = $derived( 12 - filterYear && filterYear !== 'all' 13 - ? [parseInt(filterYear)].filter((year) => groupedPosts.has(year)) 14 - : getSortedYears(groupedPosts) 15 - ); 16 - </script> 17 - 18 - <div class="space-y-12"> 19 - {#each sortedYears as year} 20 - {@const yearGroup = groupedPosts.get(year)} 21 - {#if yearGroup} 22 - {@const sortedMonths = getSortedMonths(yearGroup)} 23 - <section> 24 - <h2 class="mb-6 text-3xl font-bold text-ink-900 dark:text-ink-50">{year}</h2> 25 - <div class="space-y-8"> 26 - {#each sortedMonths as [_, monthData]} 27 - <div> 28 - <h3 class="mb-4 text-xl font-semibold text-ink-800 dark:text-ink-100">{monthData.monthName}</h3> 29 - <div class="space-y-3"> 30 - {#each monthData.posts as post} 31 - <BlogPostCard {post} locale={userLocale} /> 32 - {/each} 33 - </div> 34 - </div> 35 - {/each} 36 - </div> 37 - </section> 38 - {/if} 39 - {/each} 40 - </div>
-26
packages/ui/src/lib/components/ui/SearchBar.svelte
··· 1 - <script lang="ts"> 2 - import { Search } from '@lucide/svelte'; 3 - interface Props { value: string; placeholder?: string; resultCount?: number; } 4 - let { value = $bindable(), placeholder = 'Search...', resultCount }: Props = $props(); 5 - </script> 6 - 7 - <div role="search"> 8 - <label for="search-input" class="sr-only">Search</label> 9 - <div class="relative"> 10 - <Search class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 11 - <input 12 - id="search-input" 13 - type="search" 14 - {placeholder} 15 - bind:value 16 - class="w-full rounded-lg border-2 border-canvas-300 bg-canvas-100 py-3 pr-4 pl-11 text-ink-900 placeholder-ink-500 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-50 dark:placeholder-ink-400 dark:focus:border-primary-400" 17 - aria-label="Search" 18 - autocomplete="off" 19 - /> 20 - </div> 21 - {#if value && resultCount !== undefined} 22 - <p class="mt-2 text-sm text-ink-600 dark:text-ink-300" role="status" aria-live="polite"> 23 - Found {resultCount} {resultCount === 1 ? 'result' : 'results'} 24 - </p> 25 - {/if} 26 - </div>
-25
packages/ui/src/lib/components/ui/Tabs.svelte
··· 1 - <script lang="ts"> 2 - interface Tab { id: string; label: string; } 3 - interface Props { tabs: Tab[]; activeTab: string; onTabChange: (tabId: string) => void; } 4 - let { tabs, activeTab, onTabChange }: Props = $props(); 5 - </script> 6 - 7 - <div class="mb-8" role="tablist" aria-label="Content tabs"> 8 - <div class="flex flex-wrap gap-2"> 9 - {#each tabs as tab} 10 - <button 11 - onclick={() => onTabChange(tab.id)} 12 - role="tab" 13 - aria-selected={activeTab === tab.id} 14 - aria-controls="{tab.id}-panel" 15 - id="{tab.id}-tab" 16 - tabindex={activeTab === tab.id ? 0 : -1} 17 - class="rounded-full px-4 py-2 text-sm font-medium transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 {activeTab === tab.id 18 - ? 'bg-primary-500 text-white shadow-md dark:bg-primary-400' 19 - : 'bg-canvas-200 text-ink-700 hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-200 dark:hover:bg-canvas-700'}" 20 - > 21 - {tab.label} 22 - </button> 23 - {/each} 24 - </div> 25 - </div>
-10
packages/ui/src/lib/components/ui/index.ts
··· 1 - export { default as Card } from './Card.svelte'; 2 - export { default as InternalCard } from './InternalCard.svelte'; 3 - export { default as Dropdown } from './Dropdown.svelte'; 4 - export { default as Pagination } from './Pagination.svelte'; 5 - export { default as SearchBar } from './SearchBar.svelte'; 6 - export { default as Tabs } from './Tabs.svelte'; 7 - export { default as PostsGroupedView } from './PostsGroupedView.svelte'; 8 - export { default as DocumentCard } from './DocumentCard.svelte'; 9 - /** @deprecated Use DocumentCard instead */ 10 - export { default as BlogPostCard } from './BlogPostCard.svelte';
-130
packages/ui/src/lib/config/themes.config.ts
··· 1 - /** 2 - * Central theme configuration 3 - * Add new themes here and they'll automatically appear in the dropdown and type system 4 - */ 5 - 6 - export interface ThemeDefinition { 7 - value: string; 8 - label: string; 9 - description: string; 10 - color: string; 11 - category: 'neutral' | 'warm' | 'cool' | 'vibrant'; 12 - } 13 - 14 - export const THEMES: readonly ThemeDefinition[] = [ 15 - // Neutral themes 16 - { 17 - value: 'sage', 18 - label: 'Sage', 19 - description: 'Calm green-blue', 20 - color: 'oklch(77.77% 0.182 127.42)', 21 - category: 'neutral' 22 - }, 23 - { 24 - value: 'monochrome', 25 - label: 'Monochrome', 26 - description: 'Pure greyscale', 27 - color: 'oklch(78% 0 0)', 28 - category: 'neutral' 29 - }, 30 - { 31 - value: 'slate', 32 - label: 'Slate', 33 - description: 'Blue-grey', 34 - color: 'oklch(78.5% 0.095 230)', 35 - category: 'neutral' 36 - }, 37 - // Warm themes 38 - { 39 - value: 'ruby', 40 - label: 'Ruby', 41 - description: 'Bold red', 42 - color: 'oklch(81.5% 0.228 10)', 43 - category: 'warm' 44 - }, 45 - { 46 - value: 'coral', 47 - label: 'Coral', 48 - description: 'Orange-pink', 49 - color: 'oklch(81.8% 0.212 20)', 50 - category: 'warm' 51 - }, 52 - { 53 - value: 'sunset', 54 - label: 'Sunset', 55 - description: 'Warm orange', 56 - color: 'oklch(80.5% 0.208 45)', 57 - category: 'warm' 58 - }, 59 - { 60 - value: 'amber', 61 - label: 'Amber', 62 - description: 'Bright yellow', 63 - color: 'oklch(82.8% 0.195 85)', 64 - category: 'warm' 65 - }, 66 - // Cool themes 67 - { 68 - value: 'forest', 69 - label: 'Forest', 70 - description: 'Natural green', 71 - color: 'oklch(79.5% 0.195 145)', 72 - category: 'cool' 73 - }, 74 - { 75 - value: 'teal', 76 - label: 'Teal', 77 - description: 'Blue-green', 78 - color: 'oklch(79% 0.205 195)', 79 - category: 'cool' 80 - }, 81 - { 82 - value: 'ocean', 83 - label: 'Ocean', 84 - description: 'Deep blue', 85 - color: 'oklch(78.2% 0.188 240)', 86 - category: 'cool' 87 - }, 88 - // Vibrant themes 89 - { 90 - value: 'lavender', 91 - label: 'Lavender', 92 - description: 'Soft purple', 93 - color: 'oklch(82% 0.215 295)', 94 - category: 'vibrant' 95 - }, 96 - { 97 - value: 'rose', 98 - label: 'Rose', 99 - description: 'Pink-red', 100 - color: 'oklch(83.5% 0.230 350)', 101 - category: 'vibrant' 102 - } 103 - ] as const; 104 - 105 - export type ColorTheme = (typeof THEMES)[number]['value']; 106 - export const DEFAULT_THEME: ColorTheme = 'slate'; 107 - 108 - export const CATEGORY_LABELS = { 109 - neutral: 'Neutral', 110 - warm: 'Warm', 111 - cool: 'Cool', 112 - vibrant: 'Vibrant' 113 - } as const; 114 - 115 - export const getThemesByCategory = () => { 116 - const grouped: Record<ThemeDefinition['category'], ThemeDefinition[]> = { 117 - neutral: [], 118 - warm: [], 119 - cool: [], 120 - vibrant: [] 121 - }; 122 - THEMES.forEach((theme) => { 123 - grouped[theme.category].push(theme); 124 - }); 125 - return grouped; 126 - }; 127 - 128 - export const getTheme = (value: string): ThemeDefinition | undefined => { 129 - return THEMES.find((theme) => theme.value === value); 130 - };
-44
packages/ui/src/lib/helper/badges.ts
··· 1 - import type { BlogPost } from '@ewanc26/atproto'; 2 - 3 - export interface PostBadge { 4 - text: string; 5 - color: 'mint' | 'sage' | 'jade' | 'ink'; 6 - variant: 'soft' | 'solid'; 7 - } 8 - 9 - export function getPostBadges(post: BlogPost): PostBadge[] { 10 - const badges: PostBadge[] = []; 11 - badges.push({ text: 'Standard.site', color: 'jade', variant: 'solid' }); 12 - if (post.publicationName) { 13 - badges.push({ text: post.publicationName, color: 'jade', variant: 'soft' }); 14 - } 15 - return badges; 16 - } 17 - 18 - export function getBadgeClasses(badge: PostBadge): string { 19 - const baseStyle = 20 - badge.variant === 'soft' 21 - ? 'px-2 py-0.5 text-xs font-medium rounded' 22 - : 'px-2 py-0.5 text-xs font-semibold uppercase rounded'; 23 - 24 - const colorClasses = { 25 - mint: 26 - badge.variant === 'soft' 27 - ? 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200' 28 - : 'bg-secondary-500 text-white dark:bg-secondary-600', 29 - sage: 30 - badge.variant === 'soft' 31 - ? 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200' 32 - : 'bg-primary-500 text-white dark:bg-primary-600', 33 - jade: 34 - badge.variant === 'soft' 35 - ? 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200' 36 - : 'bg-accent-500 text-white dark:bg-accent-600', 37 - ink: 38 - badge.variant === 'soft' 39 - ? 'bg-ink-100 text-ink-800 dark:bg-ink-800 dark:text-ink-100' 40 - : 'bg-ink-700 text-white dark:bg-ink-300 dark:text-ink-900' 41 - }; 42 - 43 - return `${baseStyle} ${colorClasses[badge.color]}`; 44 - }
-63
packages/ui/src/lib/helper/posts.ts
··· 1 - import type { BlogPost } from '@ewanc26/atproto'; 2 - import { getUserLocale } from '../utils/locale.js'; 3 - 4 - export interface MonthData { 5 - monthName: string; 6 - posts: BlogPost[]; 7 - } 8 - 9 - export type GroupedPosts = Map<number, Map<number, MonthData>>; 10 - 11 - export function filterPosts(posts: BlogPost[], query: string): BlogPost[] { 12 - if (!query.trim()) return posts; 13 - const lowerQuery = query.toLowerCase(); 14 - return posts.filter((post) => { 15 - const titleMatch = post.title.toLowerCase().includes(lowerQuery); 16 - const descMatch = post.description?.toLowerCase().includes(lowerQuery); 17 - const platformMatch = post.platform.toLowerCase().includes(lowerQuery); 18 - const pubMatch = post.publicationName?.toLowerCase().includes(lowerQuery); 19 - const tagsMatch = post.tags?.some((tag: string) => tag.toLowerCase().includes(lowerQuery)); 20 - return titleMatch || descMatch || platformMatch || pubMatch || tagsMatch; 21 - }); 22 - } 23 - 24 - export function groupPostsByDate(posts: BlogPost[], locale?: string): GroupedPosts { 25 - const userLocale = locale || getUserLocale(); 26 - const grouped: GroupedPosts = new Map(); 27 - 28 - posts.forEach((post) => { 29 - const date = new Date(post.createdAt); 30 - const year = date.getFullYear(); 31 - const month = date.getMonth(); 32 - const monthName = date.toLocaleString(userLocale, { month: 'long' }); 33 - 34 - if (!grouped.has(year)) grouped.set(year, new Map()); 35 - const yearGroup = grouped.get(year)!; 36 - if (!yearGroup.has(month)) yearGroup.set(month, { monthName, posts: [] }); 37 - yearGroup.get(month)!.posts.push(post); 38 - }); 39 - 40 - grouped.forEach((yearGroup) => { 41 - yearGroup.forEach((monthData) => { 42 - monthData.posts.sort( 43 - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 44 - ); 45 - }); 46 - }); 47 - 48 - return grouped; 49 - } 50 - 51 - export function getSortedMonths(yearGroup: Map<number, MonthData>): [number, MonthData][] { 52 - return Array.from(yearGroup.entries()).sort((a, b) => b[0] - a[0]); 53 - } 54 - 55 - export function getSortedYears(groupedPosts: GroupedPosts): number[] { 56 - return Array.from(groupedPosts.keys()).sort((a, b) => b - a); 57 - } 58 - 59 - export function getAllTags(items: Array<{ tags?: string[] }>): string[] { 60 - const tagsSet = new Set<string>(); 61 - items.forEach((item) => item.tags?.forEach((tag) => tagsSet.add(tag.toLowerCase()))); 62 - return Array.from(tagsSet).sort(); 63 - }
-32
packages/ui/src/lib/index.ts
··· 1 - // ─── Stores ─────────────────────────────────────────────────────────────────── 2 - export { wolfMode, colorThemeDropdownOpen, happyMacStore, colorTheme } from './stores/index.js'; 3 - export type { ColorTheme } from './stores/index.js'; 4 - 5 - // ─── Config ─────────────────────────────────────────────────────────────────── 6 - export { THEMES, DEFAULT_THEME, CATEGORY_LABELS, getThemesByCategory, getTheme } from './config/themes.config.js'; 7 - export type { ThemeDefinition } from './config/themes.config.js'; 8 - 9 - // ─── Types ──────────────────────────────────────────────────────────────────── 10 - export type { SiteMetadata, NavItem } from './types/index.js'; 11 - 12 - // ─── Helper ─────────────────────────────────────────────────────────────────── 13 - export { filterPosts, groupPostsByDate, getSortedMonths, getSortedYears, getAllTags } from './helper/posts.js'; 14 - export type { MonthData, GroupedPosts } from './helper/posts.js'; 15 - export { getPostBadges, getBadgeClasses } from './helper/badges.js'; 16 - export type { PostBadge } from './helper/badges.js'; 17 - 18 - // ─── Layout toggles ─────────────────────────────────────────────────────────── 19 - export { default as ThemeToggle } from './components/layout/ThemeToggle.svelte'; 20 - export { default as WolfToggle } from './components/layout/WolfToggle.svelte'; 21 - 22 - // ─── Layout main ────────────────────────────────────────────────────────────── 23 - export { DynamicLinks, ScrollToTop, TangledRepos } from './components/layout/main/index.js'; 24 - 25 - // ─── Cards ──────────────────────────────────────────────────────────────────── 26 - export { LinkCard, ProfileCard, PostCard, BlueskyPostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard } from './components/layout/main/card/index.js'; 27 - 28 - // ─── SEO ────────────────────────────────────────────────────────────────────── 29 - export { MetaTags } from './components/seo/index.js'; 30 - 31 - // ─── UI primitives ──────────────────────────────────────────────────────────── 32 - export { Card, InternalCard, Dropdown, Pagination, SearchBar, Tabs, PostsGroupedView, DocumentCard, BlogPostCard } from './components/ui/index.js';
-44
packages/ui/src/lib/stores/colorTheme.ts
··· 1 - import { writable } from 'svelte/store'; 2 - import { DEFAULT_THEME, type ColorTheme } from '../config/themes.config.js'; 3 - 4 - const browser = typeof window !== 'undefined'; 5 - 6 - interface ColorThemeState { 7 - current: ColorTheme; 8 - mounted: boolean; 9 - } 10 - 11 - const STORAGE_KEY = 'color-theme'; 12 - 13 - function createColorThemeStore() { 14 - const { subscribe, set, update } = writable<ColorThemeState>({ 15 - current: DEFAULT_THEME, 16 - mounted: false 17 - }); 18 - 19 - return { 20 - subscribe, 21 - init: () => { 22 - if (!browser) return; 23 - const stored = localStorage.getItem(STORAGE_KEY) as ColorTheme | null; 24 - const theme = stored || DEFAULT_THEME; 25 - update((state) => ({ ...state, current: theme, mounted: true })); 26 - const currentTheme = document.documentElement.getAttribute('data-color-theme'); 27 - if (currentTheme !== theme) applyTheme(theme); 28 - }, 29 - setTheme: (theme: ColorTheme) => { 30 - if (!browser) return; 31 - localStorage.setItem(STORAGE_KEY, theme); 32 - update((state) => ({ ...state, current: theme })); 33 - applyTheme(theme); 34 - } 35 - }; 36 - } 37 - 38 - function applyTheme(theme: ColorTheme) { 39 - if (!browser) return; 40 - document.documentElement.setAttribute('data-color-theme', theme); 41 - } 42 - 43 - export const colorTheme = createColorThemeStore(); 44 - export type { ColorTheme };
-3
packages/ui/src/lib/stores/dropdownState.ts
··· 1 - import { writable } from 'svelte/store'; 2 - 3 - export const colorThemeDropdownOpen = writable(false);
-28
packages/ui/src/lib/stores/happyMac.ts
··· 1 - import { writable } from 'svelte/store'; 2 - 3 - interface HappyMacState { 4 - clickCount: number; 5 - isTriggered: boolean; 6 - } 7 - 8 - function createHappyMacStore() { 9 - const { subscribe, set, update } = writable<HappyMacState>({ 10 - clickCount: 0, 11 - isTriggered: false 12 - }); 13 - 14 - return { 15 - subscribe, 16 - incrementClick: () => 17 - update((state) => { 18 - const newCount = state.clickCount + 1; 19 - if (newCount === 24) { 20 - return { clickCount: newCount, isTriggered: true }; 21 - } 22 - return { ...state, clickCount: newCount }; 23 - }), 24 - reset: () => set({ clickCount: 0, isTriggered: false }) 25 - }; 26 - } 27 - 28 - export const happyMacStore = createHappyMacStore();
-5
packages/ui/src/lib/stores/index.ts
··· 1 - export { wolfMode } from './wolfMode.js'; 2 - export { colorThemeDropdownOpen } from './dropdownState.js'; 3 - export { happyMacStore } from './happyMac.js'; 4 - export { colorTheme } from './colorTheme.js'; 5 - export type { ColorTheme } from './colorTheme.js';
-127
packages/ui/src/lib/stores/wolfMode.ts
··· 1 - import { writable } from 'svelte/store'; 2 - 3 - const browser = typeof window !== 'undefined'; 4 - 5 - const wolfSounds = [ 6 - 'awoo', 'awooo', 'howl', 'ahroo', 'owww', 'yip', 'yap', 'arf', 'ruff', 'woof', 7 - 'grr', 'grrr', 'growl', 'snarl', 'whine', 'whimper', 'bark', 'yowl', 'yelp', 'huff' 8 - ]; 9 - 10 - let originalTexts = new Map<Node, string>(); 11 - let wordCounter = 0; 12 - let wordToSoundMap = new Map<string, string>(); 13 - 14 - function createWolfModeStore() { 15 - const { subscribe, set, update } = writable(false); 16 - return { 17 - subscribe, 18 - toggle: () => { 19 - update((value) => { 20 - const newValue = !value; 21 - if (browser) { 22 - if (newValue) enableWolfMode(); 23 - else disableWolfMode(); 24 - } 25 - return newValue; 26 - }); 27 - }, 28 - enable: () => { set(true); if (browser) enableWolfMode(); }, 29 - disable: () => { set(false); if (browser) disableWolfMode(); } 30 - }; 31 - } 32 - 33 - function getWolfSoundByPosition(position: number): string { 34 - return wolfSounds[position % wolfSounds.length]; 35 - } 36 - 37 - function getWolfSoundForWord(word: string, position: number): string { 38 - const normalizedWord = word.toLowerCase(); 39 - if (wordToSoundMap.has(normalizedWord)) return wordToSoundMap.get(normalizedWord)!; 40 - const wolfSound = getWolfSoundByPosition(position); 41 - wordToSoundMap.set(normalizedWord, wolfSound); 42 - return wolfSound; 43 - } 44 - 45 - function isNumberAbbreviation(text: string): boolean { 46 - return /^\d+\.?\d*[a-zA-Z]+$/.test(text); 47 - } 48 - 49 - function hasAlphabeticalCharacters(text: string): boolean { 50 - return /[a-zA-Z]/.test(text); 51 - } 52 - 53 - function shouldTransform(word: string): boolean { 54 - if (!hasAlphabeticalCharacters(word)) return false; 55 - if (isNumberAbbreviation(word)) return false; 56 - return true; 57 - } 58 - 59 - function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 60 - const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 61 - if (match) return { prefix: match[1], word: match[2], suffix: match[3] }; 62 - return { prefix: '', word: token, suffix: '' }; 63 - } 64 - 65 - function convertToWolfSpeak(text: string, startPosition: number): string { 66 - const words = text.split(/(\s+)/); 67 - let currentPosition = startPosition; 68 - return words.map((token) => { 69 - if (token.trim().length === 0) return token; 70 - const { prefix, word, suffix } = splitWordAndPunctuation(token); 71 - if (!shouldTransform(word)) return token; 72 - const wolfSound = getWolfSoundForWord(word, currentPosition); 73 - currentPosition++; 74 - let transformedWord = wolfSound; 75 - if (word === word.toUpperCase() && word.length > 1) transformedWord = wolfSound.toUpperCase(); 76 - else if (word[0] === word[0].toUpperCase()) transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 77 - return prefix + transformedWord + suffix; 78 - }).join(''); 79 - } 80 - 81 - function shouldSkipElement(element: Element): boolean { 82 - if (element.hasAttribute('aria-label')) { 83 - const label = element.getAttribute('aria-label') || ''; 84 - if (label.includes('wolf mode') || label.includes('theme') || label.includes('mode')) return true; 85 - } 86 - if (element.closest('header button')) return true; 87 - if (element.tagName === 'NAV' || element.closest('nav')) return true; 88 - return false; 89 - } 90 - 91 - function walkTextNodes(node: Node, callback: (textNode: Text) => void) { 92 - if (node.nodeType === Node.TEXT_NODE) { 93 - callback(node as Text); 94 - } else if (node.nodeType === Node.ELEMENT_NODE) { 95 - const element = node as Element; 96 - if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) return; 97 - for (const child of Array.from(node.childNodes)) walkTextNodes(child, callback); 98 - } 99 - } 100 - 101 - function enableWolfMode() { 102 - originalTexts.clear(); 103 - wordToSoundMap.clear(); 104 - wordCounter = 0; 105 - walkTextNodes(document.body, (textNode) => { 106 - const originalText = textNode.textContent || ''; 107 - if (originalText.trim().length > 0) { 108 - originalTexts.set(textNode, originalText); 109 - textNode.textContent = convertToWolfSpeak(originalText, wordCounter); 110 - wordCounter += originalText.split(/\s+/).filter((w) => { 111 - const { word } = splitWordAndPunctuation(w); 112 - return shouldTransform(word); 113 - }).length; 114 - } 115 - }); 116 - } 117 - 118 - function disableWolfMode() { 119 - originalTexts.forEach((originalText, textNode) => { 120 - if (textNode.parentNode) textNode.textContent = originalText; 121 - }); 122 - originalTexts.clear(); 123 - wordToSoundMap.clear(); 124 - wordCounter = 0; 125 - } 126 - 127 - export const wolfMode = createWolfModeStore();
-19
packages/ui/src/lib/types/index.ts
··· 1 - export interface SiteMetadata { 2 - title: string; 3 - description: string; 4 - keywords: string; 5 - url: string; 6 - image: string; 7 - imageWidth?: number; 8 - imageHeight?: number; 9 - } 10 - 11 - /** 12 - * A nav item used by Header and NavLinks. 13 - * The `iconPath` is a Lucide icon component name (e.g. 'Home', 'Archive'). 14 - */ 15 - export interface NavItem { 16 - href: string; 17 - label: string; 18 - iconPath: string; 19 - }
-27
packages/ui/src/lib/utils/formatNumber.ts
··· 1 - function getLocale(locale?: string): string { 2 - return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; 3 - } 4 - 5 - export function formatCompactNumber(num?: number, locale?: string): string { 6 - if (num === undefined || num === null) return '0'; 7 - const effectiveLocale = getLocale(locale); 8 - if (num >= 1000) { 9 - const divisor = num >= 1000000000 ? 1000000000 : num >= 1000000 ? 1000000 : 1000; 10 - const roundedDown = Math.floor((num / divisor) * 10) / 10; 11 - const adjustedNum = roundedDown * divisor; 12 - return new Intl.NumberFormat(effectiveLocale, { 13 - notation: 'compact', 14 - compactDisplay: 'short', 15 - maximumFractionDigits: 1 16 - }).format(adjustedNum); 17 - } 18 - return new Intl.NumberFormat(effectiveLocale, { 19 - notation: 'compact', 20 - compactDisplay: 'short', 21 - maximumFractionDigits: 1 22 - }).format(num); 23 - } 24 - 25 - export function formatNumber(num: number, locale?: string): string { 26 - return new Intl.NumberFormat(getLocale(locale)).format(num); 27 - }
-29
packages/ui/src/lib/utils/locale.ts
··· 1 - export function getUserLocale(): string { 2 - if (typeof navigator !== 'undefined') return navigator.language || 'en-GB'; 3 - return 'en-GB'; 4 - } 5 - 6 - export function formatLocalizedDate(dateString: string, locale?: string): string { 7 - const date = new Date(dateString); 8 - const userLocale = locale || getUserLocale(); 9 - return date.toLocaleDateString(userLocale, { month: 'short', day: 'numeric', year: 'numeric' }); 10 - } 11 - 12 - export function formatRelativeTime(dateString: string): string { 13 - const date = new Date(dateString); 14 - const now = new Date(); 15 - const diffMs = now.getTime() - date.getTime(); 16 - const diffMins = Math.floor(diffMs / 60000); 17 - const diffHours = Math.floor(diffMins / 60); 18 - const diffDays = Math.floor(diffHours / 24); 19 - if (diffMins < 1) return 'just now'; 20 - if (diffMins < 60) return `${diffMins}m ago`; 21 - if (diffHours < 24) return `${diffHours}h ago`; 22 - if (diffDays < 7) return `${diffDays}d ago`; 23 - const userLocale = getUserLocale(); 24 - return date.toLocaleDateString(userLocale, { 25 - day: 'numeric', 26 - month: 'short', 27 - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 28 - }); 29 - }
-7
packages/ui/svelte.config.js
··· 1 - import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 2 - 3 - const config = { 4 - preprocess: vitePreprocess() 5 - }; 6 - 7 - export default config;
-23
packages/ui/tsconfig.json
··· 1 - { 2 - "compilerOptions": { 3 - "moduleResolution": "bundler", 4 - "target": "es2022", 5 - "module": "esnext", 6 - "lib": ["es2022", "DOM", "DOM.Iterable"], 7 - "allowJs": true, 8 - "checkJs": true, 9 - "esModuleInterop": true, 10 - "forceConsistentCasingInFileNames": true, 11 - "resolveJsonModule": true, 12 - "skipLibCheck": true, 13 - "sourceMap": true, 14 - "strict": true, 15 - "declaration": true, 16 - "declarationMap": true, 17 - "paths": { 18 - "$app/stores": ["./node_modules/@sveltejs/kit/src/runtime/app/stores.js"] 19 - } 20 - }, 21 - "include": ["src/**/*.ts", "src/**/*.svelte"], 22 - "exclude": ["node_modules", "dist"] 23 - }
-48
packages/utils/README.md
··· 1 - # @ewanc26/utils 2 - 3 - > **Canonical source:** This package is now maintained in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo under [`packages/utils`](https://github.com/ewanc26/pkgs/tree/main/packages/utils). This copy exists for historical context — please open issues and PRs there. 4 - 5 - Shared utility functions extracted from [ewancroft.uk](https://ewancroft.uk). Zero runtime dependencies. 6 - 7 - ## Modules 8 - 9 - - **Date & Locale** — `formatRelativeTime`, `formatLocalizedDate`, `getUserLocale` 10 - - **Number Formatting** — `formatCompactNumber`, `formatNumber` 11 - - **URL Utilities** — `getDomain`, `atUriToBlueskyUrl`, `getBlueskyProfileUrl`, `isExternalUrl` 12 - - **Validators & Text** — `isValidTid`, `isValidDid`, `truncateText`, `escapeHtml`, `getInitials`, `debounce`, `throttle` 13 - - **RSS Generation** — `generateRSSFeed`, `generateRSSItem`, `createRSSResponse`, `escapeXml`, `normalizeCharacters`, `formatRSSDate` 14 - 15 - ## Installation 16 - 17 - ```bash 18 - pnpm add @ewanc26/utils 19 - ``` 20 - 21 - ## Quick Examples 22 - 23 - ```typescript 24 - import { formatRelativeTime, formatCompactNumber, getDomain, isValidDid, generateRSSFeed } from '@ewanc26/utils'; 25 - 26 - formatRelativeTime('2025-11-13T00:00:00Z'); // '3d ago' 27 - formatCompactNumber(1500); // '1.5K' 28 - getDomain('https://www.example.com/path'); // 'example.com' 29 - isValidDid('did:plc:abc123'); // true 30 - 31 - const xml = generateRSSFeed({ title: 'My Blog', link: 'https://mysite.com', description: '…' }, items); 32 - ``` 33 - 34 - All functions are SSR-safe and fall back to `en-GB` when `navigator` / `window` are unavailable. 35 - 36 - ## Build 37 - 38 - Development happens in the [`@ewanc26/pkgs`](https://github.com/ewanc26/pkgs) monorepo. Local commands (from `packages/utils`): 39 - 40 - ```bash 41 - pnpm build # tsc 42 - pnpm dev # tsc --watch 43 - pnpm check # tsc --noEmit 44 - ``` 45 - 46 - ## Licence 47 - 48 - AGPL-3.0-only — see the [pkgs monorepo licence](https://github.com/ewanc26/pkgs/blob/main/LICENSE).
-25
packages/utils/package.json
··· 1 - { 2 - "name": "@ewanc26/utils", 3 - "version": "0.1.0", 4 - "description": "Shared utility functions extracted from ewancroft.uk", 5 - "type": "module", 6 - "exports": { 7 - ".": { 8 - "source": "./src/index.ts", 9 - "types": "./dist/index.d.ts", 10 - "default": "./dist/index.js" 11 - } 12 - }, 13 - "main": "./dist/index.js", 14 - "types": "./dist/index.d.ts", 15 - "publishConfig": { "access": "public" }, 16 - "files": ["dist", "src"], 17 - "scripts": { 18 - "build": "tsc --project tsconfig.json", 19 - "dev": "tsc --project tsconfig.json --watch", 20 - "check": "tsc --noEmit" 21 - }, 22 - "devDependencies": { 23 - "typescript": "^5.9.3" 24 - } 25 - }
-25
packages/utils/src/formatDate.ts
··· 1 - /** 2 - * Formats a date string into a relative, human-readable time. 3 - * Uses the user's system locale where possible, with a fallback to en-GB. 4 - */ 5 - export function formatRelativeTime(dateString: string): string { 6 - const date = new Date(dateString); 7 - const now = new Date(); 8 - const diffMs = now.getTime() - date.getTime(); 9 - const diffMins = Math.floor(diffMs / 60000); 10 - const diffHours = Math.floor(diffMins / 60); 11 - const diffDays = Math.floor(diffHours / 24); 12 - 13 - if (diffMins < 1) return 'just now'; 14 - if (diffMins < 60) return `${diffMins}m ago`; 15 - if (diffHours < 24) return `${diffHours}h ago`; 16 - if (diffDays < 7) return `${diffDays}d ago`; 17 - 18 - const userLocale = typeof navigator !== 'undefined' ? navigator.language : 'en-GB'; 19 - 20 - return date.toLocaleDateString(userLocale, { 21 - day: 'numeric', 22 - month: 'short', 23 - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 24 - }); 25 - }
-35
packages/utils/src/formatNumber.ts
··· 1 - /** 2 - * Number formatting utilities 3 - */ 4 - 5 - function getLocale(locale?: string): string { 6 - return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; 7 - } 8 - 9 - export function formatCompactNumber(num?: number, locale?: string): string { 10 - if (num === undefined || num === null) return '0'; 11 - const effectiveLocale = getLocale(locale); 12 - 13 - if (num >= 1000) { 14 - const divisor = num >= 1000000000 ? 1000000000 : num >= 1000000 ? 1000000 : 1000; 15 - const roundedDown = Math.floor((num / divisor) * 10) / 10; 16 - const adjustedNum = roundedDown * divisor; 17 - 18 - return new Intl.NumberFormat(effectiveLocale, { 19 - notation: 'compact', 20 - compactDisplay: 'short', 21 - maximumFractionDigits: 1 22 - }).format(adjustedNum); 23 - } 24 - 25 - return new Intl.NumberFormat(effectiveLocale, { 26 - notation: 'compact', 27 - compactDisplay: 'short', 28 - maximumFractionDigits: 1 29 - }).format(num); 30 - } 31 - 32 - export function formatNumber(num: number, locale?: string): string { 33 - const effectiveLocale = getLocale(locale); 34 - return new Intl.NumberFormat(effectiveLocale).format(num); 35 - }
-6
packages/utils/src/index.ts
··· 1 - export * from './formatDate'; 2 - export * from './formatNumber'; 3 - export * from './url'; 4 - export * from './validators'; 5 - export * from './rss'; 6 - export * from './locale';
-16
packages/utils/src/locale.ts
··· 1 - export function getUserLocale(): string { 2 - if (typeof navigator !== 'undefined') { 3 - return navigator.language || 'en-GB'; 4 - } 5 - return 'en-GB'; 6 - } 7 - 8 - export function formatLocalizedDate(dateString: string, locale?: string): string { 9 - const date = new Date(dateString); 10 - const userLocale = locale || getUserLocale(); 11 - return date.toLocaleDateString(userLocale, { 12 - month: 'short', 13 - day: 'numeric', 14 - year: 'numeric' 15 - }); 16 - }
-166
packages/utils/src/rss.ts
··· 1 - /** 2 - * RSS Feed Generation Utilities 3 - */ 4 - 5 - export interface RSSChannelConfig { 6 - title: string; 7 - link: string; 8 - description: string; 9 - language?: string; 10 - selfLink?: string; 11 - copyright?: string; 12 - managingEditor?: string; 13 - webMaster?: string; 14 - generator?: string; 15 - ttl?: number; 16 - } 17 - 18 - export interface RSSItem { 19 - title: string; 20 - link: string; 21 - guid?: string; 22 - pubDate: Date | string; 23 - description?: string; 24 - content?: string; 25 - author?: string; 26 - categories?: string[]; 27 - enclosure?: { 28 - url: string; 29 - length?: number; 30 - type?: string; 31 - }; 32 - comments?: string; 33 - source?: { 34 - url: string; 35 - title: string; 36 - }; 37 - } 38 - 39 - export function escapeXml(unsafe: string): string { 40 - return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 41 - } 42 - 43 - export function escapeXmlAttribute(unsafe: string): string { 44 - return unsafe 45 - .replace(/&/g, '&amp;') 46 - .replace(/</g, '&lt;') 47 - .replace(/>/g, '&gt;') 48 - .replace(/"/g, '&quot;'); 49 - } 50 - 51 - export function normalizeCharacters(text: string): string { 52 - return text 53 - .replace(/\u2018|\u2019|\u201A|\u201B/g, "'") 54 - .replace(/\u201C|\u201D|\u201E|\u201F/g, '"') 55 - .replace(/\u2013/g, '-') 56 - .replace(/\u2014/g, '--') 57 - .replace(/\u00A0/g, ' ') 58 - .replace(/\u2026/g, '...') 59 - .replace(/\u2022/g, '*') 60 - .replace(/&apos;/g, "'") 61 - .replace(/&quot;/g, '"') 62 - .replace(/&nbsp;/g, ' ') 63 - .replace(/&mdash;/g, '--') 64 - .replace(/&ndash;/g, '-') 65 - .replace(/&hellip;/g, '...') 66 - .replace(/&rsquo;/g, "'") 67 - .replace(/&lsquo;/g, "'") 68 - .replace(/&rdquo;/g, '"') 69 - .replace(/&ldquo;/g, '"'); 70 - } 71 - 72 - export function formatRSSDate(date: Date | string): string { 73 - const d = typeof date === 'string' ? new Date(date) : date; 74 - return d.toUTCString(); 75 - } 76 - 77 - export function generateRSSItem(item: RSSItem): string { 78 - const guid = item.guid || item.link; 79 - const pubDate = formatRSSDate(item.pubDate); 80 - const title = escapeXml(normalizeCharacters(item.title)); 81 - const description = item.description ? escapeXml(normalizeCharacters(item.description)) : ''; 82 - const content = item.content ? normalizeCharacters(item.content) : ''; 83 - const author = item.author ? escapeXml(normalizeCharacters(item.author)) : ''; 84 - const categories = 85 - item.categories 86 - ?.map((cat) => ` <category>${escapeXml(normalizeCharacters(cat))}</category>`) 87 - .join('\n') || ''; 88 - 89 - let enclosure = ''; 90 - if (item.enclosure) { 91 - const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : ''; 92 - const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : ''; 93 - enclosure = ` <enclosure url="${escapeXmlAttribute(item.enclosure.url)}"${length}${type} />`; 94 - } 95 - 96 - let source = ''; 97 - if (item.source) { 98 - source = ` <source url="${escapeXmlAttribute(item.source.url)}">${escapeXml(normalizeCharacters(item.source.title))}</source>`; 99 - } 100 - 101 - return ` <item> 102 - <title>${title}</title> 103 - <link>${escapeXmlAttribute(item.link)}</link> 104 - <guid isPermaLink="true">${escapeXmlAttribute(guid)}</guid> 105 - <pubDate>${pubDate}</pubDate>${description ? `\n <description>${description}</description>` : ''}${content ? `\n <content:encoded><![CDATA[${content}]]></content:encoded>` : ''}${author ? `\n <author>${author}</author>` : ''}${item.comments ? `\n <comments>${escapeXmlAttribute(item.comments)}</comments>` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''} 106 - </item>`; 107 - } 108 - 109 - export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string { 110 - const language = config.language || 'en'; 111 - const generator = config.generator || 'SvelteKit with AT Protocol'; 112 - const lastBuildDate = formatRSSDate(new Date()); 113 - const title = escapeXml(normalizeCharacters(config.title)); 114 - const link = escapeXmlAttribute(config.link); 115 - const description = escapeXml(normalizeCharacters(config.description)); 116 - const generatorText = escapeXml(normalizeCharacters(generator)); 117 - 118 - const atomLink = config.selfLink 119 - ? ` <atom:link href="${escapeXmlAttribute(config.selfLink)}" rel="self" type="application/rss+xml" />` 120 - : ''; 121 - 122 - const optionalFields = []; 123 - if (config.copyright) 124 - optionalFields.push( 125 - ` <copyright>${escapeXml(normalizeCharacters(config.copyright))}</copyright>` 126 - ); 127 - if (config.managingEditor) 128 - optionalFields.push( 129 - ` <managingEditor>${escapeXml(normalizeCharacters(config.managingEditor))}</managingEditor>` 130 - ); 131 - if (config.webMaster) 132 - optionalFields.push( 133 - ` <webMaster>${escapeXml(normalizeCharacters(config.webMaster))}</webMaster>` 134 - ); 135 - if (config.ttl) optionalFields.push(` <ttl>${config.ttl}</ttl>`); 136 - 137 - const itemsXml = items.map((item) => generateRSSItem(item)).join('\n'); 138 - 139 - return `<?xml version="1.0" encoding="UTF-8"?> 140 - <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 141 - <channel> 142 - <title>${title}</title> 143 - <link>${link}</link> 144 - <description>${description}</description> 145 - <language>${language}</language>${atomLink ? `\n${atomLink}` : ''} 146 - <lastBuildDate>${lastBuildDate}</lastBuildDate> 147 - <generator>${generatorText}</generator>${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''} 148 - ${itemsXml} 149 - </channel> 150 - </rss>`; 151 - } 152 - 153 - export function createRSSResponse( 154 - feed: string, 155 - options?: { cacheMaxAge?: number; status?: number } 156 - ): Response { 157 - const cacheMaxAge = options?.cacheMaxAge ?? 3600; 158 - const status = options?.status ?? 200; 159 - return new Response(feed, { 160 - status, 161 - headers: { 162 - 'Content-Type': 'application/rss+xml; charset=utf-8', 163 - 'Cache-Control': `public, max-age=${cacheMaxAge}` 164 - } 165 - }); 166 - }
-29
packages/utils/src/url.ts
··· 1 - export function getDomain(url: string): string { 2 - try { 3 - const urlObj = new URL(url); 4 - return urlObj.hostname.replace('www.', ''); 5 - } catch { 6 - return ''; 7 - } 8 - } 9 - 10 - export function atUriToBlueskyUrl(uri: string): string { 11 - const parts = uri.split('/'); 12 - const did = parts[2]; 13 - const rkey = parts[4]; 14 - return `https://witchsky.app/profile/${did}/post/${rkey}`; 15 - } 16 - 17 - export function getBlueskyProfileUrl(actor: string): string { 18 - return `https://witchsky.app/profile/${actor}`; 19 - } 20 - 21 - export function isExternalUrl(url: string): boolean { 22 - if (typeof window === 'undefined') return true; 23 - try { 24 - const urlObj = new URL(url, window.location.href); 25 - return urlObj.origin !== window.location.origin; 26 - } catch { 27 - return false; 28 - } 29 - }
-59
packages/utils/src/validators.ts
··· 1 - export function isValidTid(tid: string): boolean { 2 - const tidPattern = /^[a-zA-Z0-9]{12,16}$/; 3 - return tidPattern.test(tid); 4 - } 5 - 6 - export function isValidDid(did: string): boolean { 7 - const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:-]+$/; 8 - return didPattern.test(did); 9 - } 10 - 11 - export function truncateText(text: string, maxLength: number, ellipsis = '...'): string { 12 - if (text.length <= maxLength) return text; 13 - return text.slice(0, maxLength - ellipsis.length).trim() + ellipsis; 14 - } 15 - 16 - export function escapeHtml(text: string): string { 17 - const div = typeof document !== 'undefined' ? document.createElement('div') : null; 18 - if (div) { 19 - div.textContent = text; 20 - return div.innerHTML; 21 - } 22 - return text 23 - .replace(/&/g, '&amp;') 24 - .replace(/</g, '&lt;') 25 - .replace(/>/g, '&gt;') 26 - .replace(/"/g, '&quot;') 27 - .replace(/'/g, '&#039;'); 28 - } 29 - 30 - export function getInitials(name: string): string { 31 - const words = name.trim().split(/\s+/); 32 - if (words.length === 1) return words[0].charAt(0).toUpperCase(); 33 - return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase(); 34 - } 35 - 36 - export function debounce<T extends (...args: any[]) => any>( 37 - func: T, 38 - delay: number 39 - ): (...args: Parameters<T>) => void { 40 - let timeoutId: ReturnType<typeof setTimeout>; 41 - return (...args: Parameters<T>) => { 42 - clearTimeout(timeoutId); 43 - timeoutId = setTimeout(() => func(...args), delay); 44 - }; 45 - } 46 - 47 - export function throttle<T extends (...args: any[]) => any>( 48 - func: T, 49 - limit: number 50 - ): (...args: Parameters<T>) => void { 51 - let inThrottle: boolean; 52 - return (...args: Parameters<T>) => { 53 - if (!inThrottle) { 54 - func(...args); 55 - inThrottle = true; 56 - setTimeout(() => (inThrottle = false), limit); 57 - } 58 - }; 59 - }
-13
packages/utils/tsconfig.json
··· 1 - { 2 - "extends": "../../tsconfig.json", 3 - "compilerOptions": { 4 - "rootDir": "src", 5 - "outDir": "dist", 6 - "declaration": true, 7 - "declarationMap": true, 8 - "moduleResolution": "bundler", 9 - "module": "esnext", 10 - "target": "es2022" 11 - }, 12 - "include": ["src"] 13 - }