my website at ewancroft.uk
6
fork

Configure Feed

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

chore: restructure into monorepo, bump to v11.0.0

Extracts the core logic from the SvelteKit app into three independently
publishable workspace packages under packages/:

- @ewanc26/atproto — AT Protocol service layer; all functions now accept
`did` as an explicit argument rather than reading PUBLIC_ATPROTO_DID
- @ewanc26/utils — shared utility and formatting functions
- @ewanc26/ui — Svelte component library (cards, UI primitives, stores,
SEO, theme config, helpers)

src/lib is now a thin shim layer: app-specific components (Header, Footer,
ColorThemeToggle) and DID-bound service wrappers remain local; everything
else re-exports from the packages above.

Build changes:
- pnpm workspace configured for all packages
- vite.config.ts uses resolve.alias to point package names directly at
TypeScript source, so no pre-build step is needed for pnpm dev
- prebuild hook runs pnpm --filter '@ewanc26/*' build before vite build
- packages build with tsc (dropped tsup to avoid esbuild postinstall issues)
- server.fs.allow extended to cover packages/ for Vite's strict mode

Fixes along the way:
- Corrected relative import depth in packages/ui LinkCard.svelte
- Added $state() to DynamicLinks reactive variables (Svelte 5 runes)
- Added `as string` casts for optional URI fields in atproto/posts.ts
- Kept local DynamicLinks and BlueskyPostCard (use DID-bound wrappers)

Closes #5

+4507 -3916
+1 -1
README.md
··· 811 811 812 812 Built with ❤️ using SvelteKit, AT Protocol, and open-source tools 813 813 814 - **Version**: 10.7.1 814 + **Version**: 11.0.0
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "10.7.1", 3 + "version": "11.0.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "10.7.1", 9 + "version": "11.0.0", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.1", 12 12 "@lucide/svelte": "^0.554.0",
+6 -1
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "10.7.1", 4 + "version": "11.0.0", 5 5 "type": "module", 6 6 "scripts": { 7 + "build:packages": "pnpm --filter '@ewanc26/*' build", 8 + "prebuild": "pnpm build:packages", 7 9 "dev": "vite dev", 8 10 "build": "vite build", 9 11 "preview": "vite preview", ··· 30 32 }, 31 33 "dependencies": { 32 34 "@atproto/api": "^0.18.1", 35 + "@ewanc26/atproto": "workspace:*", 36 + "@ewanc26/ui": "workspace:*", 37 + "@ewanc26/utils": "workspace:*", 33 38 "@lucide/svelte": "^0.554.0", 34 39 "hls.js": "^1.6.15" 35 40 }
+28
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 + "files": ["dist", "src"], 16 + "scripts": { 17 + "build": "tsc --project tsconfig.json", 18 + "dev": "tsc --project tsconfig.json --watch", 19 + "check": "tsc --noEmit" 20 + }, 21 + "peerDependencies": { 22 + "@atproto/api": ">=0.13.0" 23 + }, 24 + "devDependencies": { 25 + "@atproto/api": "^0.18.1", 26 + "typescript": "^5.9.3" 27 + } 28 + }
+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 + }
+44
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 + "files": [ 17 + "dist", 18 + "src" 19 + ], 20 + "scripts": { 21 + "build": "svelte-package -i src/lib -o dist", 22 + "dev": "svelte-package -i src/lib -o dist --watch", 23 + "check": "svelte-check --tsconfig ./tsconfig.json" 24 + }, 25 + "peerDependencies": { 26 + "@sveltejs/kit": ">=2.0.0", 27 + "svelte": ">=5.0.0", 28 + "tailwindcss": ">=4.0.0" 29 + }, 30 + "dependencies": { 31 + "@lucide/svelte": "^0.554.0" 32 + }, 33 + "optionalDependencies": { 34 + "@ewanc26/atproto": "workspace:*" 35 + }, 36 + "devDependencies": { 37 + "@sveltejs/kit": "^2.49.0", 38 + "@sveltejs/package": "^2.3.10", 39 + "@sveltejs/vite-plugin-svelte": "^6.2.1", 40 + "svelte": "^5.43.14", 41 + "svelte-check": "^4.3.4", 42 + "typescript": "^5.9.3" 43 + } 44 + }
+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';
+43
packages/ui/src/lib/stores/colorTheme.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { browser } from '$app/environment'; 3 + import { DEFAULT_THEME, type ColorTheme } from '../config/themes.config.js'; 4 + 5 + interface ColorThemeState { 6 + current: ColorTheme; 7 + mounted: boolean; 8 + } 9 + 10 + const STORAGE_KEY = 'color-theme'; 11 + 12 + function createColorThemeStore() { 13 + const { subscribe, set, update } = writable<ColorThemeState>({ 14 + current: DEFAULT_THEME, 15 + mounted: false 16 + }); 17 + 18 + return { 19 + subscribe, 20 + init: () => { 21 + if (!browser) return; 22 + const stored = localStorage.getItem(STORAGE_KEY) as ColorTheme | null; 23 + const theme = stored || DEFAULT_THEME; 24 + update((state) => ({ ...state, current: theme, mounted: true })); 25 + const currentTheme = document.documentElement.getAttribute('data-color-theme'); 26 + if (currentTheme !== theme) applyTheme(theme); 27 + }, 28 + setTheme: (theme: ColorTheme) => { 29 + if (!browser) return; 30 + localStorage.setItem(STORAGE_KEY, theme); 31 + update((state) => ({ ...state, current: theme })); 32 + applyTheme(theme); 33 + } 34 + }; 35 + } 36 + 37 + function applyTheme(theme: ColorTheme) { 38 + if (!browser) return; 39 + document.documentElement.setAttribute('data-color-theme', theme); 40 + } 41 + 42 + export const colorTheme = createColorThemeStore(); 43 + 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';
+126
packages/ui/src/lib/stores/wolfMode.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { browser } from '$app/environment'; 3 + 4 + const wolfSounds = [ 5 + 'awoo', 'awooo', 'howl', 'ahroo', 'owww', 'yip', 'yap', 'arf', 'ruff', 'woof', 6 + 'grr', 'grrr', 'growl', 'snarl', 'whine', 'whimper', 'bark', 'yowl', 'yelp', 'huff' 7 + ]; 8 + 9 + let originalTexts = new Map<Node, string>(); 10 + let wordCounter = 0; 11 + let wordToSoundMap = new Map<string, string>(); 12 + 13 + function createWolfModeStore() { 14 + const { subscribe, set, update } = writable(false); 15 + return { 16 + subscribe, 17 + toggle: () => { 18 + update((value) => { 19 + const newValue = !value; 20 + if (browser) { 21 + if (newValue) enableWolfMode(); 22 + else disableWolfMode(); 23 + } 24 + return newValue; 25 + }); 26 + }, 27 + enable: () => { set(true); if (browser) enableWolfMode(); }, 28 + disable: () => { set(false); if (browser) disableWolfMode(); } 29 + }; 30 + } 31 + 32 + function getWolfSoundByPosition(position: number): string { 33 + return wolfSounds[position % wolfSounds.length]; 34 + } 35 + 36 + function getWolfSoundForWord(word: string, position: number): string { 37 + const normalizedWord = word.toLowerCase(); 38 + if (wordToSoundMap.has(normalizedWord)) return wordToSoundMap.get(normalizedWord)!; 39 + const wolfSound = getWolfSoundByPosition(position); 40 + wordToSoundMap.set(normalizedWord, wolfSound); 41 + return wolfSound; 42 + } 43 + 44 + function isNumberAbbreviation(text: string): boolean { 45 + return /^\d+\.?\d*[a-zA-Z]+$/.test(text); 46 + } 47 + 48 + function hasAlphabeticalCharacters(text: string): boolean { 49 + return /[a-zA-Z]/.test(text); 50 + } 51 + 52 + function shouldTransform(word: string): boolean { 53 + if (!hasAlphabeticalCharacters(word)) return false; 54 + if (isNumberAbbreviation(word)) return false; 55 + return true; 56 + } 57 + 58 + function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 59 + const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 60 + if (match) return { prefix: match[1], word: match[2], suffix: match[3] }; 61 + return { prefix: '', word: token, suffix: '' }; 62 + } 63 + 64 + function convertToWolfSpeak(text: string, startPosition: number): string { 65 + const words = text.split(/(\s+)/); 66 + let currentPosition = startPosition; 67 + return words.map((token) => { 68 + if (token.trim().length === 0) return token; 69 + const { prefix, word, suffix } = splitWordAndPunctuation(token); 70 + if (!shouldTransform(word)) return token; 71 + const wolfSound = getWolfSoundForWord(word, currentPosition); 72 + currentPosition++; 73 + let transformedWord = wolfSound; 74 + if (word === word.toUpperCase() && word.length > 1) transformedWord = wolfSound.toUpperCase(); 75 + else if (word[0] === word[0].toUpperCase()) transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 76 + return prefix + transformedWord + suffix; 77 + }).join(''); 78 + } 79 + 80 + function shouldSkipElement(element: Element): boolean { 81 + if (element.hasAttribute('aria-label')) { 82 + const label = element.getAttribute('aria-label') || ''; 83 + if (label.includes('wolf mode') || label.includes('theme') || label.includes('mode')) return true; 84 + } 85 + if (element.closest('header button')) return true; 86 + if (element.tagName === 'NAV' || element.closest('nav')) return true; 87 + return false; 88 + } 89 + 90 + function walkTextNodes(node: Node, callback: (textNode: Text) => void) { 91 + if (node.nodeType === Node.TEXT_NODE) { 92 + callback(node as Text); 93 + } else if (node.nodeType === Node.ELEMENT_NODE) { 94 + const element = node as Element; 95 + if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) return; 96 + for (const child of Array.from(node.childNodes)) walkTextNodes(child, callback); 97 + } 98 + } 99 + 100 + function enableWolfMode() { 101 + originalTexts.clear(); 102 + wordToSoundMap.clear(); 103 + wordCounter = 0; 104 + walkTextNodes(document.body, (textNode) => { 105 + const originalText = textNode.textContent || ''; 106 + if (originalText.trim().length > 0) { 107 + originalTexts.set(textNode, originalText); 108 + textNode.textContent = convertToWolfSpeak(originalText, wordCounter); 109 + wordCounter += originalText.split(/\s+/).filter((w) => { 110 + const { word } = splitWordAndPunctuation(w); 111 + return shouldTransform(word); 112 + }).length; 113 + } 114 + }); 115 + } 116 + 117 + function disableWolfMode() { 118 + originalTexts.forEach((originalText, textNode) => { 119 + if (textNode.parentNode) textNode.textContent = originalText; 120 + }); 121 + originalTexts.clear(); 122 + wordToSoundMap.clear(); 123 + wordCounter = 0; 124 + } 125 + 126 + 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;
+24
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/environment": ["./node_modules/@sveltejs/kit/src/runtime/app/environment.js"], 19 + "$app/stores": ["./node_modules/@sveltejs/kit/src/runtime/app/stores.js"] 20 + } 21 + }, 22 + "include": ["src/**/*.ts", "src/**/*.svelte"], 23 + "exclude": ["node_modules", "dist"] 24 + }
+24
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 + "files": ["dist", "src"], 16 + "scripts": { 17 + "build": "tsc --project tsconfig.json", 18 + "dev": "tsc --project tsconfig.json --watch", 19 + "check": "tsc --noEmit" 20 + }, 21 + "devDependencies": { 22 + "typescript": "^5.9.3" 23 + } 24 + }
+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 + }
+111
pnpm-lock.yaml
··· 11 11 '@atproto/api': 12 12 specifier: ^0.18.1 13 13 version: 0.18.16 14 + '@ewanc26/atproto': 15 + specifier: workspace:* 16 + version: link:packages/atproto 17 + '@ewanc26/ui': 18 + specifier: workspace:* 19 + version: link:packages/ui 20 + '@ewanc26/utils': 21 + specifier: workspace:* 22 + version: link:packages/utils 14 23 '@lucide/svelte': 15 24 specifier: ^0.554.0 16 25 version: 0.554.0(svelte@5.47.1) ··· 57 66 vite: 58 67 specifier: ^7.2.4 59 68 version: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) 69 + 70 + packages/atproto: 71 + devDependencies: 72 + '@atproto/api': 73 + specifier: ^0.18.1 74 + version: 0.18.16 75 + typescript: 76 + specifier: ^5.9.3 77 + version: 5.9.3 78 + 79 + packages/ui: 80 + dependencies: 81 + '@lucide/svelte': 82 + specifier: ^0.554.0 83 + version: 0.554.0(svelte@5.47.1) 84 + tailwindcss: 85 + specifier: '>=4.0.0' 86 + version: 4.1.18 87 + devDependencies: 88 + '@sveltejs/kit': 89 + specifier: ^2.49.0 90 + version: 2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.47.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.47.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) 91 + '@sveltejs/package': 92 + specifier: ^2.3.10 93 + version: 2.5.7(svelte@5.47.1)(typescript@5.9.3) 94 + '@sveltejs/vite-plugin-svelte': 95 + specifier: ^6.2.1 96 + version: 6.2.4(svelte@5.47.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) 97 + svelte: 98 + specifier: ^5.43.14 99 + version: 5.47.1 100 + svelte-check: 101 + specifier: ^4.3.4 102 + version: 4.3.5(picomatch@4.0.3)(svelte@5.47.1)(typescript@5.9.3) 103 + typescript: 104 + specifier: ^5.9.3 105 + version: 5.9.3 106 + optionalDependencies: 107 + '@ewanc26/atproto': 108 + specifier: workspace:* 109 + version: link:../atproto 110 + 111 + packages/utils: 112 + devDependencies: 113 + typescript: 114 + specifier: ^5.9.3 115 + version: 5.9.3 60 116 61 117 packages: 62 118 ··· 598 654 typescript: 599 655 optional: true 600 656 657 + '@sveltejs/package@2.5.7': 658 + resolution: {integrity: sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ==} 659 + engines: {node: ^16.14 || >=18} 660 + hasBin: true 661 + peerDependencies: 662 + svelte: ^3.44.0 || ^4.0.0 || ^5.0.0-next.1 663 + 601 664 '@sveltejs/vite-plugin-svelte-inspector@5.0.2': 602 665 resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} 603 666 engines: {node: ^20.19 || ^22.12 || >=24} ··· 758 821 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 759 822 engines: {node: '>= 14.16.0'} 760 823 824 + chokidar@5.0.0: 825 + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} 826 + engines: {node: '>= 20.19.0'} 827 + 761 828 chownr@3.0.0: 762 829 resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} 763 830 engines: {node: '>=18'} ··· 787 854 peerDependenciesMeta: 788 855 supports-color: 789 856 optional: true 857 + 858 + dedent-js@1.0.1: 859 + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} 790 860 791 861 deepmerge@4.3.1: 792 862 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} ··· 1088 1158 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 1089 1159 engines: {node: '>= 14.18.0'} 1090 1160 1161 + readdirp@5.0.0: 1162 + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} 1163 + engines: {node: '>= 20.19.0'} 1164 + 1091 1165 resolve-from@5.0.0: 1092 1166 resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} 1093 1167 engines: {node: '>=8'} ··· 1100 1174 sade@1.8.1: 1101 1175 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 1102 1176 engines: {node: '>=6'} 1177 + 1178 + scule@1.3.0: 1179 + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} 1103 1180 1104 1181 semver@7.7.3: 1105 1182 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} ··· 1124 1201 peerDependencies: 1125 1202 svelte: ^4.0.0 || ^5.0.0-next.0 1126 1203 typescript: '>=5.0.0' 1204 + 1205 + svelte2tsx@0.7.51: 1206 + resolution: {integrity: sha512-YbVMQi5LtQkVGOMdATTY8v3SMtkNjzYtrVDGaN3Bv+0LQ47tGXu/Oc8ryTkcYuEJWTZFJ8G2+2I8ORcQVGt9Ag==} 1207 + peerDependencies: 1208 + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 1209 + typescript: ^4.9.4 || ^5.0.0 1127 1210 1128 1211 svelte@5.47.1: 1129 1212 resolution: {integrity: sha512-MhSWfWEpG5T57z0Oyfk9D1GhAz/KTZKZZlWtGEsy9zNk2fafpuU7sJQlXNSA8HtvwKxVC9XlDyl5YovXUXjjHA==} ··· 1607 1690 optionalDependencies: 1608 1691 typescript: 5.9.3 1609 1692 1693 + '@sveltejs/package@2.5.7(svelte@5.47.1)(typescript@5.9.3)': 1694 + dependencies: 1695 + chokidar: 5.0.0 1696 + kleur: 4.1.5 1697 + sade: 1.8.1 1698 + semver: 7.7.3 1699 + svelte: 5.47.1 1700 + svelte2tsx: 0.7.51(svelte@5.47.1)(typescript@5.9.3) 1701 + transitivePeerDependencies: 1702 + - typescript 1703 + 1610 1704 '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.47.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.47.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': 1611 1705 dependencies: 1612 1706 '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.47.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) ··· 1745 1839 chokidar@4.0.3: 1746 1840 dependencies: 1747 1841 readdirp: 4.1.2 1842 + 1843 + chokidar@5.0.0: 1844 + dependencies: 1845 + readdirp: 5.0.0 1748 1846 1749 1847 chownr@3.0.0: {} 1750 1848 ··· 1760 1858 dependencies: 1761 1859 ms: 2.1.3 1762 1860 1861 + dedent-js@1.0.1: {} 1862 + 1763 1863 deepmerge@4.3.1: {} 1764 1864 1765 1865 detect-libc@2.1.2: {} ··· 1997 2097 1998 2098 readdirp@4.1.2: {} 1999 2099 2100 + readdirp@5.0.0: {} 2101 + 2000 2102 resolve-from@5.0.0: {} 2001 2103 2002 2104 rollup@4.55.2: ··· 2034 2136 dependencies: 2035 2137 mri: 1.2.0 2036 2138 2139 + scule@1.3.0: {} 2140 + 2037 2141 semver@7.7.3: {} 2038 2142 2039 2143 set-cookie-parser@2.7.2: {} ··· 2057 2161 typescript: 5.9.3 2058 2162 transitivePeerDependencies: 2059 2163 - picomatch 2164 + 2165 + svelte2tsx@0.7.51(svelte@5.47.1)(typescript@5.9.3): 2166 + dependencies: 2167 + dedent-js: 1.0.1 2168 + scule: 1.3.0 2169 + svelte: 5.47.1 2170 + typescript: 5.9.3 2060 2171 2061 2172 svelte@5.47.1: 2062 2173 dependencies:
+2
pnpm-workspace.yaml
··· 1 + packages: 2 + - packages/*
+2 -2
src/lib/components/layout/Footer.svelte
··· 104 104 type="button" 105 105 onclick={() => happyMacStore.incrementClick()} 106 106 class="cursor-default transition-colors select-none hover:text-ink-600 dark:hover:text-ink-300" 107 - aria-label="Version 10.7.1{showHint 107 + aria-label="Version 11.0.0{showHint 108 108 ? ` - ${$happyMacStore.clickCount} of 24 clicks` 109 109 : ''}" 110 110 title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 111 111 > 112 - v10.7.1{#if showHint}<span class="ml-1 text-xs opacity-60" 112 + v11.0.0{#if showHint}<span class="ml-1 text-xs opacity-60" 113 113 >({$happyMacStore.clickCount}/24)</span 114 114 >{/if} 115 115 </button>
+8 -5
src/lib/components/layout/index.ts
··· 1 + // App-specific layout components stay local. 1 2 export { default as Header } from './Header.svelte'; 2 3 export { default as Footer } from './Footer.svelte'; 3 - export { default as ThemeToggle } from './ThemeToggle.svelte'; 4 - export { default as WolfToggle } from './WolfToggle.svelte'; 5 - export { default as LinkCard } from './main/card/LinkCard.svelte'; 6 - export { default as ProfileCard } from './main/card/ProfileCard.svelte'; 4 + export { default as ColorThemeToggle } from './ColorThemeToggle.svelte'; 5 + 6 + // DynamicLinks stays local — it uses the DID-bound service wrapper. 7 7 export { default as DynamicLinks } from './main/DynamicLinks.svelte'; 8 - export { default as ScrollToTop } from './main/ScrollToTop.svelte'; 8 + 9 + // These are shared and prop-only — re-export from the package. 10 + export { ThemeToggle, WolfToggle, ScrollToTop } from '@ewanc26/ui'; 11 + export { LinkCard, ProfileCard } from '@ewanc26/ui';
+3 -6
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'; 1 + // BlueskyPostCard uses the app's DID-bound fetchLatestBlueskyPost wrapper — keep it local. 4 2 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 + // The rest are data-in, presentation-only — re-export from the package. 4 + export { LinkCard, ProfileCard, PostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard } from '@ewanc26/ui';
+1
src/lib/components/layout/main/index.ts
··· 1 + // DynamicLinks uses the app's DID-bound fetchLinks wrapper — keep it local. 1 2 export { default as DynamicLinks } from './DynamicLinks.svelte'; 2 3 export { default as ScrollToTop } from './ScrollToTop.svelte'; 3 4 export { default as TangledRepos } from './card/TangledRepoCard.svelte';
+1 -1
src/lib/components/seo/index.ts
··· 1 - export { default as MetaTags } from './MetaTags.svelte'; 1 + export { MetaTags } from '@ewanc26/ui';
+1 -15
src/lib/components/ui/index.ts
··· 1 - /** 2 - * UI Component exports 3 - */ 4 - 5 - export { default as Card } from './Card.svelte'; 6 - export { default as InternalCard } from './InternalCard.svelte'; 7 - export { default as Dropdown } from './Dropdown.svelte'; 8 - export { default as Pagination } from './Pagination.svelte'; 9 - export { default as SearchBar } from './SearchBar.svelte'; 10 - export { default as Tabs } from './Tabs.svelte'; 11 - export { default as PostsGroupedView } from './PostsGroupedView.svelte'; 12 - export { default as DocumentCard } from './DocumentCard.svelte'; 13 - 14 - // Deprecated: Use DocumentCard instead 15 - export { default as BlogPostCard } from './BlogPostCard.svelte'; 1 + export { Card, InternalCard, Dropdown, Pagination, SearchBar, Tabs, PostsGroupedView, DocumentCard, BlogPostCard } from '@ewanc26/ui';
+2 -138
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 - // Extract theme values for type safety 106 - export type ColorTheme = (typeof THEMES)[number]['value']; 107 - 108 - // Default theme 109 - export const DEFAULT_THEME: ColorTheme = 'slate'; 110 - 111 - // Category labels 112 - export const CATEGORY_LABELS = { 113 - neutral: 'Neutral', 114 - warm: 'Warm', 115 - cool: 'Cool', 116 - vibrant: 'Vibrant' 117 - } as const; 118 - 119 - // Group themes by category (for UI organization) 120 - export const getThemesByCategory = () => { 121 - const grouped: Record<ThemeDefinition['category'], ThemeDefinition[]> = { 122 - neutral: [], 123 - warm: [], 124 - cool: [], 125 - vibrant: [] 126 - }; 127 - 128 - THEMES.forEach((theme) => { 129 - grouped[theme.category].push(theme); 130 - }); 131 - 132 - return grouped; 133 - }; 134 - 135 - // Utility to get a specific theme by value 136 - export const getTheme = (value: string): ThemeDefinition | undefined => { 137 - return THEMES.find((theme) => theme.value === value); 138 - }; 1 + export { THEMES, DEFAULT_THEME, CATEGORY_LABELS, getThemesByCategory, getTheme } from '@ewanc26/ui'; 2 + export type { ThemeDefinition, ColorTheme } from '@ewanc26/ui';
+2 -56
src/lib/helper/badges.ts
··· 1 - import type { BlogPost } from '$lib/services/atproto'; 2 - 3 - export interface PostBadge { 4 - text: string; 5 - color: 'mint' | 'sage' | 'jade' | 'ink'; 6 - variant: 'soft' | 'solid'; 7 - } 8 - 9 - /** 10 - * Get badge configuration for a post based on platform and publication 11 - * Standard.site posts get jade color styling 12 - */ 13 - export function getPostBadges(post: BlogPost): PostBadge[] { 14 - const badges: PostBadge[] = []; 15 - 16 - // Platform badge - Standard.site 17 - badges.push({ text: 'Standard.site', color: 'jade', variant: 'solid' }); 18 - 19 - // Publication name badge 20 - if (post.publicationName) { 21 - badges.push({ text: post.publicationName, color: 'jade', variant: 'soft' }); 22 - } 23 - 24 - return badges; 25 - } 26 - 27 - /** 28 - * Get badge CSS classes 29 - */ 30 - export function getBadgeClasses(badge: PostBadge): string { 31 - const baseStyle = 32 - badge.variant === 'soft' 33 - ? 'px-2 py-0.5 text-xs font-medium rounded' 34 - : 'px-2 py-0.5 text-xs font-semibold uppercase rounded'; 35 - 36 - const colorClasses = { 37 - mint: 38 - badge.variant === 'soft' 39 - ? 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200' 40 - : 'bg-secondary-500 text-white dark:bg-secondary-600', 41 - sage: 42 - badge.variant === 'soft' 43 - ? 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200' 44 - : 'bg-primary-500 text-white dark:bg-primary-600', 45 - jade: 46 - badge.variant === 'soft' 47 - ? 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200' 48 - : 'bg-accent-500 text-white dark:bg-accent-600', 49 - ink: 50 - badge.variant === 'soft' 51 - ? 'bg-ink-100 text-ink-800 dark:bg-ink-800 dark:text-ink-100' 52 - : 'bg-ink-700 text-white dark:bg-ink-300 dark:text-ink-900' 53 - }; 54 - 55 - return `${baseStyle} ${colorClasses[badge.color]}`; 56 - } 1 + export { getPostBadges, getBadgeClasses } from '@ewanc26/ui'; 2 + export type { PostBadge } from '@ewanc26/ui';
+2 -88
src/lib/helper/posts.ts
··· 1 - import type { BlogPost } from '$lib/services/atproto'; 2 - import { getUserLocale } from '$lib/utils/locale'; 3 - 4 - export interface MonthData { 5 - monthName: string; 6 - posts: BlogPost[]; 7 - } 8 - 9 - export type GroupedPosts = Map<number, Map<number, MonthData>>; 10 - 11 - /** 12 - * Filter posts based on search query 13 - */ 14 - export function filterPosts(posts: BlogPost[], query: string): BlogPost[] { 15 - if (!query.trim()) return posts; 16 - 17 - const lowerQuery = query.toLowerCase(); 18 - return posts.filter((post) => { 19 - const titleMatch = post.title.toLowerCase().includes(lowerQuery); 20 - const descMatch = post.description?.toLowerCase().includes(lowerQuery); 21 - const platformMatch = post.platform.toLowerCase().includes(lowerQuery); 22 - const pubMatch = post.publicationName?.toLowerCase().includes(lowerQuery); 23 - const tagsMatch = post.tags?.some((tag: string) => tag.toLowerCase().includes(lowerQuery)); 24 - return titleMatch || descMatch || platformMatch || pubMatch || tagsMatch; 25 - }); 26 - } 27 - 28 - /** 29 - * Groups blog posts by year and month 30 - */ 31 - export function groupPostsByDate(posts: BlogPost[], locale?: string): GroupedPosts { 32 - const userLocale = locale || getUserLocale(); 33 - const grouped: GroupedPosts = new Map(); 34 - 35 - posts.forEach((post) => { 36 - const date = new Date(post.createdAt); 37 - const year = date.getFullYear(); 38 - const month = date.getMonth(); 39 - const monthName = date.toLocaleString(userLocale, { month: 'long' }); 40 - 41 - if (!grouped.has(year)) { 42 - grouped.set(year, new Map()); 43 - } 44 - 45 - const yearGroup = grouped.get(year)!; 46 - if (!yearGroup.has(month)) { 47 - yearGroup.set(month, { monthName, posts: [] }); 48 - } 49 - 50 - yearGroup.get(month)!.posts.push(post); 51 - }); 52 - 53 - // Sort posts within each month by date (newest first) 54 - grouped.forEach((yearGroup) => { 55 - yearGroup.forEach((monthData) => { 56 - monthData.posts.sort( 57 - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 58 - ); 59 - }); 60 - }); 61 - 62 - return grouped; 63 - } 64 - 65 - /** 66 - * Get sorted months for a year group 67 - */ 68 - export function getSortedMonths(yearGroup: Map<number, MonthData>): [number, MonthData][] { 69 - return Array.from(yearGroup.entries()).sort((a, b) => b[0] - a[0]); 70 - } 71 - 72 - /** 73 - * Get sorted years from grouped posts 74 - */ 75 - export function getSortedYears(groupedPosts: GroupedPosts): number[] { 76 - return Array.from(groupedPosts.keys()).sort((a, b) => b - a); 77 - } 78 - 79 - /** 80 - * Extract all unique tags from posts or documents (normalized to lowercase) 81 - */ 82 - export function getAllTags(items: Array<{ tags?: string[] }>): string[] { 83 - const tagsSet = new Set<string>(); 84 - items.forEach((item) => { 85 - item.tags?.forEach((tag: string) => tagsSet.add(tag.toLowerCase())); 86 - }); 87 - return Array.from(tagsSet).sort(); 88 - } 1 + export { filterPosts, groupPostsByDate, getSortedMonths, getSortedYears, getAllTags } from '@ewanc26/ui'; 2 + export type { MonthData, GroupedPosts } from '@ewanc26/ui';
+4 -4
src/lib/index.ts
··· 1 + // App-specific components stay local. 1 2 export { default as Header } from './components/layout/Header.svelte'; 2 3 export { default as Footer } from './components/layout/Footer.svelte'; 3 - export { default as DynamicLinks } from './components/layout/main/DynamicLinks.svelte'; 4 - export { default as ScrollToTop } from './components/layout/main/ScrollToTop.svelte'; 5 - export { default as LinkCard } from './components/layout/main/card/LinkCard.svelte'; 6 - export { default as ProfileCard } from './components/layout/main/card/ProfileCard.svelte'; 4 + 5 + // Shared components delegated to @ewanc26/ui. 6 + export { DynamicLinks, ScrollToTop, LinkCard, ProfileCard } from '@ewanc26/ui'; 7 7 8 8 export * from './services/atproto'; 9 9 export * from './helper/siteMeta';
+11 -196
src/lib/services/atproto/agents.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import type { ResolvedIdentity } from './types'; 3 - import { cache } from './cache'; 4 - 5 - /** 6 - * Creates an AtpAgent with optional fetch function injection 7 - */ 8 - export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { 9 - // If we have an injected fetch, wrap it to ensure we handle headers correctly 10 - const wrappedFetch = fetchFn 11 - ? async (url: URL | RequestInfo, init?: RequestInit) => { 12 - // Convert URL to string if needed 13 - const urlStr = url instanceof URL ? url.toString() : url; 14 - 15 - // Make the request with the injected fetch 16 - const response = await fetchFn(urlStr, init); 17 - 18 - // Create a new response with the same body but add content-type if missing 19 - const headers = new Headers(response.headers); 20 - if (!headers.has('content-type')) { 21 - headers.set('content-type', 'application/json'); 22 - } 23 - 24 - return new Response(response.body, { 25 - status: response.status, 26 - statusText: response.statusText, 27 - headers 28 - }); 29 - } 30 - : undefined; 31 - 32 - return new AtpAgent({ 33 - service, 34 - ...(wrappedFetch && { fetch: wrappedFetch }) 35 - }); 36 - } 37 - 38 - // Primary Microcosm Constellation endpoint 39 - export const constellationAgent = createAgent('https://constellation.microcosm.blue'); 40 - 41 - // Default fallback agent for public Bluesky API calls 42 - export const defaultAgent = createAgent('https://public.api.bsky.app'); 43 - 44 - // Cached agents 45 - let resolvedAgent: AtpAgent | null = null; 46 - let pdsAgent: AtpAgent | null = null; 47 - 48 - /** 49 - * Resolves a DID to find its PDS endpoint using Slingshot. 50 - * Results are cached to reduce resolution calls. 51 - */ 52 - export async function resolveIdentity( 53 - did: string, 54 - fetchFn?: typeof fetch 55 - ): Promise<ResolvedIdentity> { 56 - console.info(`[Identity] Resolving DID: ${did}`); 57 - 58 - // Check cache first 59 - const cacheKey = `identity:${did}`; 60 - const cached = cache.get<ResolvedIdentity>(cacheKey); 61 - if (cached) { 62 - console.info('[Identity] Using cached identity resolution'); 63 - return cached; 64 - } 65 - 66 - // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 67 - const _fetch = fetchFn ?? globalThis.fetch; 68 - 69 - const response = await _fetch( 70 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent( 71 - did 72 - )}` 73 - ); 74 - 75 - if (!response.ok) { 76 - console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`); 77 - throw new Error( 78 - `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` 79 - ); 80 - } 81 - 82 - // Some fetch implementations in Node (undici wrappers) can throw when calling Response.clone(). 83 - // Read the text once and parse it instead of cloning to avoid private field access errors. 84 - const rawText = await response.text(); 85 - console.debug(`[Identity] Raw response:`, rawText); 86 - let data: any; 87 - try { 88 - data = JSON.parse(rawText); 89 - } catch (err) { 90 - console.error('[Identity] Failed to parse identity resolver response as JSON', err); 91 - throw err; 92 - } 93 - 94 - if (!data.did || !data.pds) { 95 - throw new Error('Invalid response from identity resolver'); 96 - } 97 - 98 - // Cache the resolved identity 99 - console.info('[Identity] Caching resolved identity'); 100 - cache.set(cacheKey, data); 101 - 102 - return data; 103 - } 104 - 105 - /** 106 - * Gets or creates an agent for the public Bluesky API with PDS fallback 107 - */ 108 - export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 109 - console.info(`[Agent] Getting public agent for DID: ${did}`); 110 - if (resolvedAgent) { 111 - console.debug('[Agent] Using cached agent'); 112 - return resolvedAgent; 113 - } 114 - 115 - try { 116 - // Try Constellation first 117 - try { 118 - console.info('[Agent] Attempting Constellation endpoint'); 119 - const response = await constellationAgent.getProfile({ actor: did }); 120 - if (response.success) { 121 - console.info('[Agent] Successfully connected to Constellation'); 122 - resolvedAgent = constellationAgent; 123 - return resolvedAgent; 124 - } 125 - } catch (constellationErr) { 126 - console.warn('[Agent] Constellation endpoint unreachable:', constellationErr); 127 - } 128 - 129 - // Then try Slingshot for PDS resolution 130 - console.info('[Agent] Attempting Slingshot resolution'); 131 - const resolved = await resolveIdentity(did, fetchFn); 132 - console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`); 133 - resolvedAgent = createAgent(resolved.pds, fetchFn); 134 - return resolvedAgent; 135 - } catch (err) { 136 - console.error('[Agent] All Microcosm endpoints failed, falling back to Bluesky:', err); 137 - resolvedAgent = defaultAgent; 138 - return resolvedAgent; 139 - } 140 - } /** 141 - * Gets or creates a PDS-specific agent 142 - */ 143 - export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 144 - if (pdsAgent) return pdsAgent; 145 - 146 - try { 147 - const resolved = await resolveIdentity(did, fetchFn); 148 - pdsAgent = createAgent(resolved.pds, fetchFn); 149 - return pdsAgent; 150 - } catch (err) { 151 - console.error('Failed to resolve PDS for DID:', err); 152 - throw err; 153 - } 154 - } 155 - 156 - /** 157 - * Executes a function with automatic fallback from Bluesky public API to user's PDS 158 - * @param did - The DID to resolve 159 - * @param operation - The operation to execute 160 - * @param usePDSFirst - If true, tries PDS first before public API 161 - */ 162 - export async function withFallback<T>( 163 - did: string, 164 - operation: (agent: AtpAgent) => Promise<T>, 165 - usePDSFirst = false, 166 - fetchFn?: typeof fetch 167 - ): Promise<T> { 168 - const defaultAgentFn = () => 169 - fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); 170 - 171 - const agents = usePDSFirst 172 - ? [() => getPDSAgent(did, fetchFn), defaultAgentFn] 173 - : [defaultAgentFn, () => getPDSAgent(did, fetchFn)]; 174 - 175 - let lastError: any; 176 - 177 - for (const getAgent of agents) { 178 - try { 179 - const agent = await getAgent(); 180 - return await operation(agent); 181 - } catch (error) { 182 - console.warn('Operation failed, trying next agent:', error); 183 - lastError = error; 184 - } 185 - } 186 - 187 - throw lastError; 188 - } 189 - 190 - /** 191 - * Resets cached agents (useful for testing or when identity changes) 192 - */ 193 - export function resetAgents(): void { 194 - resolvedAgent = null; 195 - pdsAgent = null; 196 - } 1 + // Re-export the agent utilities from @ewanc26/atproto. 2 + export { 3 + createAgent, 4 + constellationAgent, 5 + defaultAgent, 6 + resolveIdentity, 7 + getPublicAgent, 8 + getPDSAgent, 9 + withFallback, 10 + resetAgents 11 + } from '@ewanc26/atproto';
+16 -37
src/lib/services/atproto/cache.ts
··· 1 - import type { CacheEntry } from './types'; 1 + import type { CacheEntry } from '@ewanc26/atproto'; 2 2 import { CACHE_TTL } from '$lib/config/cache.config'; 3 3 4 4 /** 5 - * Simple in-memory cache with configurable TTL support 6 - * 7 - * TTL values are configured per data type in cache.config.ts 8 - * and can be overridden via environment variables 5 + * App-level in-memory cache with dev-aware TTLs from cache.config.ts. 6 + * This wraps the same interface as @ewanc26/atproto's ATProtoCache but 7 + * uses environment-sensitive TTLs (shorter in dev, longer in prod). 9 8 */ 10 9 export class ATProtoCache { 11 10 private cache = new Map<string, CacheEntry<any>>(); 12 11 13 - /** 14 - * Get TTL for a cache key based on its prefix 15 - */ 16 12 private getTTL(key: string): number { 17 13 if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 18 14 if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; ··· 20 16 if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 21 17 if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 22 18 if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 23 - if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS; 24 - if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS; 25 - if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST; 19 + if (key.startsWith('blog-posts:') || key.startsWith('blogposts:')) return CACHE_TTL.BLOG_POSTS; 20 + if ( 21 + key.startsWith('publications:') || 22 + key.startsWith('standard-site:publications:') 23 + ) 24 + return CACHE_TTL.PUBLICATIONS; 25 + if (key.startsWith('post:') || key.startsWith('blueskypost:')) 26 + return CACHE_TTL.INDIVIDUAL_POST; 26 27 if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 27 - 28 - // Default fallback (30 minutes) 29 28 return 30 * 60 * 1000; 30 29 } 31 30 32 31 get<T>(key: string): T | null { 33 - console.info(`[Cache] Getting key: ${key}`); 34 32 const entry = this.cache.get(key); 35 - if (!entry) { 36 - console.info(`[Cache] Cache miss for key: ${key}`); 37 - return null; 38 - } 39 - 40 - const ttl = this.getTTL(key); 41 - const age = Date.now() - entry.timestamp; 42 - 43 - if (age > ttl) { 44 - console.info( 45 - `[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)` 46 - ); 33 + if (!entry) return null; 34 + if (Date.now() - entry.timestamp > this.getTTL(key)) { 47 35 this.cache.delete(key); 48 36 return null; 49 37 } 50 - 51 - console.info( 52 - `[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)` 53 - ); 54 38 return entry.data; 55 39 } 56 40 57 41 set<T>(key: string, data: T): void { 58 - const ttl = this.getTTL(key); 59 - console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`); 60 - this.cache.set(key, { 61 - data, 62 - timestamp: Date.now() 63 - }); 42 + this.cache.set(key, { data, timestamp: Date.now() }); 64 43 } 65 44 66 45 delete(key: string): void { 67 - console.info(`[Cache] Deleting key: ${key}`); 68 46 this.cache.delete(key); 69 47 } 70 48 ··· 74 52 } 75 53 76 54 export const cache = new ATProtoCache(); 55 + export { CACHE_TTL };
+19 -392
src/lib/services/atproto/documents.ts
··· 1 - /** 2 - * Standard.site Document Service 3 - * 4 - * Based on: /Volumes/Storage/Developer/clones/docs.surf/packages/server/src/utils/document.ts 5 - * 6 - * This service handles fetching, resolving, and caching Standard.site documents and publications. 7 - * All legacy platform support (WhiteWind, Leaflet) has been removed. 8 - */ 9 - 1 + // Thin wrappers over @ewanc26/atproto that bind PUBLIC_ATPROTO_DID. 10 2 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 11 - import { cache } from './cache'; 12 - import { withFallback, resolveIdentity } from './agents'; 13 - import { buildPdsBlobUrl } from './media'; 14 - import type { 3 + import { 4 + fetchPublications as _fetchPublications, 5 + fetchDocuments as _fetchDocuments, 6 + fetchRecentDocuments as _fetchRecentDocuments, 7 + fetchBlogPosts as _fetchBlogPosts 8 + } from '@ewanc26/atproto'; 9 + 10 + export type { 15 11 StandardSitePublication, 16 12 StandardSitePublicationsData, 17 13 StandardSiteDocument, 18 - StandardSiteDocumentsData, 19 - StandardSiteBasicTheme, 20 - StandardSiteThemeColor 21 - } from './types'; 22 - 23 - /** 24 - * Raw document record from PDS (matches docs.surf pattern) 25 - */ 26 - interface DocumentRecord { 27 - site: string; 28 - path?: string; 29 - title: string; 30 - description?: string; 31 - coverImage?: unknown; 32 - content?: unknown; 33 - textContent?: string; 34 - bskyPostRef?: { uri: string; cid: string }; 35 - tags?: string[]; 36 - publishedAt: string; 37 - updatedAt?: string; 38 - } 39 - 40 - /** 41 - * Raw publication record from PDS (matches docs.surf pattern) 42 - */ 43 - interface PublicationRecord { 44 - url: string; 45 - name: string; 46 - description?: string; 47 - icon?: unknown; 48 - basicTheme?: { 49 - background: StandardSiteThemeColor; 50 - foreground: StandardSiteThemeColor; 51 - accent: StandardSiteThemeColor; 52 - accentForeground: StandardSiteThemeColor; 53 - }; 54 - preferences?: { 55 - showInDiscover?: boolean; 56 - }; 57 - } 58 - 59 - /** 60 - * Fetches a single publication record from an at:// URI 61 - * Based on docs.surf fetchPublication() 62 - */ 63 - async function fetchPublicationByUri( 64 - publicationUri: string, 65 - fetchFn?: typeof fetch 66 - ): Promise<StandardSitePublication | null> { 67 - // Extract rkey from URI 68 - const rkey = publicationUri.split('/').pop(); 69 - if (!rkey) return null; 70 - 71 - try { 72 - const record = await withFallback( 73 - PUBLIC_ATPROTO_DID, 74 - async (agent) => { 75 - const response = await agent.com.atproto.repo.getRecord({ 76 - repo: PUBLIC_ATPROTO_DID, 77 - collection: 'site.standard.publication', 78 - rkey 79 - }); 80 - return response.data; 81 - }, 82 - true, 83 - fetchFn 84 - ); 85 - 86 - if (!record) return null; 87 - 88 - const pubValue = record.value as unknown as PublicationRecord; 89 - if (!pubValue.url || !pubValue.name) return null; 90 - 91 - // Resolve icon blob URL if present 92 - const icon = pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined; 93 - 94 - // Extract basic theme if present 95 - let basicTheme: StandardSiteBasicTheme | undefined; 96 - if (pubValue.basicTheme) { 97 - basicTheme = { 98 - background: pubValue.basicTheme.background, 99 - foreground: pubValue.basicTheme.foreground, 100 - accent: pubValue.basicTheme.accent, 101 - accentForeground: pubValue.basicTheme.accentForeground 102 - }; 103 - } 14 + StandardSiteDocumentsData 15 + } from '@ewanc26/atproto'; 104 16 105 - return { 106 - name: pubValue.name, 107 - rkey, 108 - uri: publicationUri, 109 - url: pubValue.url, 110 - description: pubValue.description, 111 - icon, 112 - basicTheme, 113 - preferences: pubValue.preferences 114 - }; 115 - } catch (error) { 116 - console.warn(`Failed to fetch publication ${publicationUri}:`, error); 117 - return null; 118 - } 17 + export async function fetchPublications(fetchFn?: typeof fetch) { 18 + return _fetchPublications(PUBLIC_ATPROTO_DID, fetchFn); 119 19 } 120 20 121 - /** 122 - * Resolves the canonical view URL for a document 123 - * Always uses external publication URLs 124 - */ 125 - function resolveViewUrl( 126 - site: string, 127 - path: string | undefined, 128 - publicationUrl: string | undefined, 129 - rkey: string 130 - ): string { 131 - // Determine document path (use path if provided, otherwise fallback to /rkey) 132 - const docPath = path || `/${rkey}`; 133 - 134 - // Ensure path starts with / 135 - const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`; 136 - 137 - // Check if site is a publication URI (at://) or direct URL 138 - if (site.startsWith('at://')) { 139 - // Publication-based document 140 - if (!publicationUrl) { 141 - // Shouldn't happen, but fallback to using the URI 142 - console.warn(`Missing publication URL for document with site: ${site}`); 143 - return `${site}${normalizedPath}`; 144 - } 145 - 146 - // Use the publication's external URL 147 - const baseUrl = publicationUrl.startsWith('http') 148 - ? publicationUrl 149 - : `https://${publicationUrl}`; 150 - 151 - // Remove trailing slash and construct URL 152 - return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 153 - } else { 154 - // Loose document with direct URL 155 - const baseUrl = site.startsWith('http') ? site : `https://${site}`; 156 - 157 - // Remove trailing slash and construct URL 158 - return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 159 - } 21 + export async function fetchDocuments(fetchFn?: typeof fetch) { 22 + return _fetchDocuments(PUBLIC_ATPROTO_DID, fetchFn); 160 23 } 161 24 162 - /** 163 - * Helper function to get a blob URL 164 - * Based on docs.surf buildBlobUrl() 165 - */ 166 - async function getBlobUrl(blob: any, fetchFn?: typeof fetch): Promise<string | undefined> { 167 - try { 168 - const cid = blob.ref?.$link || blob.cid; 169 - if (!cid) return undefined; 170 - 171 - const resolved = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 172 - return buildPdsBlobUrl(resolved.pds, PUBLIC_ATPROTO_DID, cid); 173 - } catch (error) { 174 - console.warn('Failed to resolve blob URL:', error); 175 - return undefined; 176 - } 25 + export async function fetchRecentDocuments(limit = 5, fetchFn?: typeof fetch) { 26 + return _fetchRecentDocuments(PUBLIC_ATPROTO_DID, limit, fetchFn); 177 27 } 178 28 179 - /** 180 - * Fetches all Standard.site publications for a user 181 - */ 182 - export async function fetchPublications( 183 - fetchFn?: typeof fetch 184 - ): Promise<StandardSitePublicationsData> { 185 - console.info('[Standard.site] Fetching publications'); 186 - const cacheKey = `standard-site:publications:${PUBLIC_ATPROTO_DID}`; 187 - const cached = cache.get<StandardSitePublicationsData>(cacheKey); 188 - 189 - if (cached) { 190 - console.debug('[Standard.site] Returning cached publications'); 191 - return cached; 192 - } 193 - 194 - const publications: StandardSitePublication[] = []; 195 - console.info('[Standard.site] Cache miss, fetching from network'); 196 - 197 - try { 198 - console.debug('[Standard.site] Querying publication records'); 199 - const publicationsRecords = await withFallback( 200 - PUBLIC_ATPROTO_DID, 201 - async (agent) => { 202 - const response = await agent.com.atproto.repo.listRecords({ 203 - repo: PUBLIC_ATPROTO_DID, 204 - collection: 'site.standard.publication', 205 - limit: 100 206 - }); 207 - return response.data.records; 208 - }, 209 - true, 210 - fetchFn 211 - ); 212 - 213 - for (const pubRecord of publicationsRecords) { 214 - const pubValue = pubRecord.value as unknown as PublicationRecord; 215 - const rkey = pubRecord.uri.split('/').pop() || ''; 216 - 217 - // Resolve icon blob URL if present 218 - const icon = pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined; 219 - 220 - // Extract basic theme if present 221 - let basicTheme: StandardSiteBasicTheme | undefined; 222 - if (pubValue.basicTheme) { 223 - basicTheme = { 224 - background: pubValue.basicTheme.background, 225 - foreground: pubValue.basicTheme.foreground, 226 - accent: pubValue.basicTheme.accent, 227 - accentForeground: pubValue.basicTheme.accentForeground 228 - }; 229 - } 230 - 231 - publications.push({ 232 - name: pubValue.name, 233 - rkey, 234 - uri: pubRecord.uri, 235 - url: pubValue.url, 236 - description: pubValue.description, 237 - icon, 238 - basicTheme, 239 - preferences: pubValue.preferences 240 - }); 241 - } 242 - 243 - const data: StandardSitePublicationsData = { publications }; 244 - cache.set(cacheKey, data); 245 - return data; 246 - } catch (error) { 247 - console.warn('Failed to fetch Standard.site publications:', error); 248 - return { publications: [] }; 249 - } 250 - } 251 - 252 - /** 253 - * Fetches all Standard.site documents for a user 254 - * Based on docs.surf processDocument() pattern 255 - */ 256 - export async function fetchDocuments(fetchFn?: typeof fetch): Promise<StandardSiteDocumentsData> { 257 - console.info('[Standard.site] Fetching documents'); 258 - const cacheKey = `standard-site:documents:${PUBLIC_ATPROTO_DID}`; 259 - const cached = cache.get<StandardSiteDocumentsData>(cacheKey); 260 - 261 - if (cached) { 262 - console.debug('[Standard.site] Returning cached documents'); 263 - return cached; 264 - } 265 - 266 - const documents: StandardSiteDocument[] = []; 267 - console.info('[Standard.site] Cache miss, fetching from network'); 268 - 269 - try { 270 - // Fetch all publications first to map URIs to publication data 271 - const publicationsData = await fetchPublications(fetchFn); 272 - const publicationsMap = new Map<string, StandardSitePublication>(); 273 - 274 - for (const pub of publicationsData.publications) { 275 - publicationsMap.set(pub.uri, pub); 276 - } 277 - 278 - console.debug('[Standard.site] Querying document records'); 279 - const documentsRecords = await withFallback( 280 - PUBLIC_ATPROTO_DID, 281 - async (agent) => { 282 - const response = await agent.com.atproto.repo.listRecords({ 283 - repo: PUBLIC_ATPROTO_DID, 284 - collection: 'site.standard.document', 285 - limit: 100 286 - }); 287 - return response.data.records; 288 - }, 289 - true, 290 - fetchFn 291 - ); 292 - 293 - for (const docRecord of documentsRecords) { 294 - const docValue = docRecord.value as unknown as DocumentRecord; 295 - const rkey = docRecord.uri.split('/').pop() || ''; 296 - 297 - // Extract fields from document record 298 - const site = docValue.site; 299 - const path = docValue.path; 300 - const title = docValue.title; 301 - const description = docValue.description; 302 - const textContent = docValue.textContent; 303 - const content = docValue.content; 304 - const bskyPostRef = docValue.bskyPostRef; 305 - const tags = docValue.tags; 306 - const publishedAt = docValue.publishedAt; 307 - const updatedAt = docValue.updatedAt; 308 - 309 - // Resolve publication if site is at:// URI 310 - let publication: StandardSitePublication | undefined; 311 - let publicationRkey: string | undefined; 312 - let pubUrl: string | undefined; 313 - 314 - if (site.startsWith('at://')) { 315 - // Publication-based document 316 - publication = publicationsMap.get(site); 317 - publicationRkey = site.split('/').pop(); 318 - pubUrl = publication?.url; 319 - } else { 320 - // Loose document - site is the base URL 321 - pubUrl = site; 322 - } 323 - 324 - // Construct canonical view URL 325 - const url = resolveViewUrl(site, path, pubUrl, rkey); 326 - 327 - // Resolve cover image blob URL if present 328 - const coverImage = docValue.coverImage 329 - ? await getBlobUrl(docValue.coverImage, fetchFn) 330 - : undefined; 331 - 332 - documents.push({ 333 - title, 334 - rkey, 335 - uri: docRecord.uri, 336 - url, 337 - site, 338 - path, 339 - description, 340 - coverImage, 341 - content, 342 - textContent, 343 - bskyPostRef, 344 - tags, 345 - publishedAt, 346 - updatedAt, 347 - publicationName: publication?.name, 348 - publicationRkey 349 - }); 350 - } 351 - 352 - // Sort by publishedAt (newest first) 353 - documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); 354 - 355 - const data: StandardSiteDocumentsData = { documents }; 356 - cache.set(cacheKey, data); 357 - return data; 358 - } catch (error) { 359 - console.warn('Failed to fetch Standard.site documents:', error); 360 - return { documents: [] }; 361 - } 362 - } 363 - 364 - /** 365 - * Fetches recent documents (top N) 366 - */ 367 - export async function fetchRecentDocuments( 368 - limit: number = 5, 369 - fetchFn?: typeof fetch 370 - ): Promise<StandardSiteDocument[]> { 371 - const { documents } = await fetchDocuments(fetchFn); 372 - return documents.slice(0, limit); 373 - } 374 - 375 - /** 376 - * Converts Standard.site documents to BlogPost format 377 - */ 378 - function convertDocumentToBlogPost(doc: StandardSiteDocument): import('./types').BlogPost { 379 - return { 380 - title: doc.title, 381 - url: doc.url, 382 - createdAt: doc.publishedAt, 383 - platform: 'standard.site', 384 - description: doc.description, 385 - rkey: doc.rkey, 386 - publicationName: doc.publicationName, 387 - publicationRkey: doc.publicationRkey, 388 - tags: doc.tags, 389 - coverImage: doc.coverImage, 390 - textContent: doc.textContent, 391 - updatedAt: doc.updatedAt 392 - }; 393 - } 394 - 395 - /** 396 - * Fetches blog posts from Standard.site documents 397 - */ 398 - export async function fetchBlogPosts( 399 - fetchFn?: typeof fetch 400 - ): Promise<{ posts: import('./types').BlogPost[] }> { 401 - const { documents } = await fetchDocuments(fetchFn); 402 - const posts = documents.map(convertDocumentToBlogPost); 403 - return { posts }; 29 + export async function fetchBlogPosts(fetchFn?: typeof fetch) { 30 + return _fetchBlogPosts(PUBLIC_ATPROTO_DID, fetchFn); 404 31 }
+2 -82
src/lib/services/atproto/engagement.ts
··· 1 - import { cache } from './cache'; 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 - /** 11 - * Fetches engagement data (likes/reposts) for a post from Constellation as a fallback 12 - */ 13 - export async function fetchEngagementFromConstellation( 14 - uri: string, 15 - type: EngagementType, 16 - cursor?: string 17 - ): Promise<EngagementResponse> { 18 - console.info(`[Constellation] Fetching ${type} data for ${uri}`); 19 - 20 - const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`; 21 - const cached = cache.get<EngagementResponse>(cacheKey); 22 - if (cached) { 23 - console.debug('[Constellation] Returning cached engagement data'); 24 - return cached; 25 - } 26 - 27 - try { 28 - const url = new URL('https://constellation.microcosm.blue/links/distinct-dids'); 29 - url.searchParams.append('target', uri); 30 - url.searchParams.append('collection', type); 31 - url.searchParams.append('path', ''); 32 - url.searchParams.append('limit', '100'); 33 - if (cursor) { 34 - url.searchParams.append('cursor', cursor); 35 - } 36 - 37 - console.debug(`[Constellation] Requesting: ${url.toString()}`); 38 - const response = await fetch(url); 39 - 40 - if (!response.ok) { 41 - throw new Error(`Constellation HTTP error! Status: ${response.status}`); 42 - } 43 - 44 - const data = await response.json(); 45 - console.debug('[Constellation] Response received:', data); 46 - 47 - const result: EngagementResponse = { 48 - dids: data.dids || [], 49 - cursor: data.cursor 50 - }; 51 - 52 - // Cache the results 53 - cache.set(cacheKey, result); 54 - return result; 55 - } catch (error) { 56 - console.error('[Constellation] Failed to fetch engagement data:', error); 57 - throw error; 58 - } 59 - } 60 - 61 - /** 62 - * Fetches all engagement data by paginating through results 63 - */ 64 - export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> { 65 - console.info(`[Constellation] Fetching all ${type} data for ${uri}`); 66 - 67 - const allDids: Set<string> = new Set(); 68 - let cursor: string | undefined = undefined; 69 - 70 - try { 71 - do { 72 - const response = await fetchEngagementFromConstellation(uri, type, cursor); 73 - response.dids.forEach((did) => allDids.add(did)); 74 - cursor = response.cursor; 75 - } while (cursor); 76 - 77 - return Array.from(allDids); 78 - } catch (error) { 79 - console.error('[Constellation] Failed to fetch all engagement:', error); 80 - return Array.from(allDids); // Return what we have so far 81 - } 82 - } 1 + export type { EngagementType } from '@ewanc26/atproto'; 2 + export { fetchEngagementFromConstellation, fetchAllEngagement } from '@ewanc26/atproto';
+22 -471
src/lib/services/atproto/fetch.ts
··· 1 + // Thin wrappers over @ewanc26/atproto that bind PUBLIC_ATPROTO_DID so callers 2 + // don't need to pass it explicitly — matching the original app API exactly. 1 3 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 - import { cache } from './cache'; 3 - import { withFallback, resolveIdentity } from './agents'; 4 - import type { 5 - ProfileData, 6 - StatusData, 7 - SiteInfoData, 8 - LinkData, 9 - MusicStatusData, 10 - KibunStatusData, 11 - TangledRepo, 12 - TangledReposData 13 - } from './types'; 14 - import { buildPdsBlobUrl } from './media'; 15 - import { findArtwork } from './musicbrainz'; 16 - 17 - /** 18 - * Fetches user profile from AT Protocol 19 - */ 20 - export async function fetchProfile(fetchFn?: typeof fetch): Promise<ProfileData> { 21 - console.info('[Profile] Fetching profile data'); 22 - const cacheKey = `profile:${PUBLIC_ATPROTO_DID}`; 23 - const cached = cache.get<ProfileData>(cacheKey); 24 - if (cached) { 25 - console.debug('[Profile] Returning cached profile data'); 26 - return cached; 27 - } 28 - 29 - try { 30 - console.info('[Profile] Cache miss, fetching from network'); 31 - // Profile data is public, try Bluesky API first, then PDS 32 - const profile = await withFallback( 33 - PUBLIC_ATPROTO_DID, 34 - async (agent) => { 35 - console.debug('[Profile] Attempting profile fetch with agent:', agent.service.toString()); 36 - const response = await agent.getProfile({ actor: PUBLIC_ATPROTO_DID }); 37 - return response.data; 38 - }, 39 - false, 40 - fetchFn 41 - ); 42 - 43 - // Fetch the actual profile record to get pronouns and other fields 44 - // The profile view doesn't include pronouns, so we need the record 45 - let pronouns: string | undefined; 46 - try { 47 - console.debug('[Profile] Attempting to fetch profile record for pronouns'); 48 - const recordResponse = await withFallback( 49 - PUBLIC_ATPROTO_DID, 50 - async (agent) => { 51 - const response = await agent.com.atproto.repo.getRecord({ 52 - repo: PUBLIC_ATPROTO_DID, 53 - collection: 'app.bsky.actor.profile', 54 - rkey: 'self' 55 - }); 56 - return response.data; 57 - }, 58 - false, 59 - fetchFn 60 - ); 61 - pronouns = (recordResponse.value as any).pronouns; 62 - console.debug('[Profile] Successfully fetched pronouns:', pronouns); 63 - } catch (error) { 64 - console.debug('[Profile] Could not fetch profile record for pronouns:', error); 65 - // Continue without pronouns if record fetch fails 66 - } 4 + import { 5 + fetchProfile as _fetchProfile, 6 + fetchSiteInfo as _fetchSiteInfo, 7 + fetchLinks as _fetchLinks, 8 + fetchMusicStatus as _fetchMusicStatus, 9 + fetchKibunStatus as _fetchKibunStatus, 10 + fetchTangledRepos as _fetchTangledRepos 11 + } from '@ewanc26/atproto'; 67 12 68 - const data: ProfileData = { 69 - did: profile.did, 70 - handle: profile.handle, 71 - displayName: profile.displayName, 72 - description: profile.description, 73 - avatar: profile.avatar, 74 - banner: profile.banner, 75 - followersCount: profile.followersCount, 76 - followsCount: profile.followsCount, 77 - postsCount: profile.postsCount, 78 - pronouns: pronouns 79 - }; 80 - 81 - console.info('[Profile] Successfully fetched profile data'); 82 - console.debug('[Profile] Profile data:', data); 83 - cache.set(cacheKey, data); 84 - return data; 85 - } catch (error) { 86 - console.error('[Profile] Failed to fetch profile from all sources:', error); 87 - throw error; 88 - } 13 + export async function fetchProfile(fetchFn?: typeof fetch) { 14 + return _fetchProfile(PUBLIC_ATPROTO_DID, fetchFn); 89 15 } 90 16 91 - /** 92 - * Fetches site information from custom lexicon 93 - */ 94 - export async function fetchSiteInfo(fetchFn?: typeof fetch): Promise<SiteInfoData | null> { 95 - const cacheKey = `siteinfo:${PUBLIC_ATPROTO_DID}`; 96 - const cached = cache.get<SiteInfoData>(cacheKey); 97 - if (cached) return cached; 98 - 99 - try { 100 - // Custom collection, prefer PDS first 101 - const result = await withFallback( 102 - PUBLIC_ATPROTO_DID, 103 - async (agent) => { 104 - try { 105 - const response = await agent.com.atproto.repo.getRecord({ 106 - repo: PUBLIC_ATPROTO_DID, 107 - collection: 'uk.ewancroft.site.info', 108 - rkey: 'self' 109 - }); 110 - return response.data; 111 - } catch (err: any) { 112 - // If record not found, return null instead of throwing 113 - if (err.error === 'RecordNotFound') { 114 - return null; 115 - } 116 - throw err; 117 - } 118 - }, 119 - true, 120 - fetchFn 121 - ); // usePDSFirst = true 122 - 123 - if (!result || !result.value) { 124 - return null; 125 - } 126 - 127 - const data = result.value as SiteInfoData; 128 - cache.set(cacheKey, data); 129 - return data; 130 - } catch (error) { 131 - console.error('Failed to fetch site info from all sources:', error); 132 - return null; 133 - } 17 + export async function fetchSiteInfo(fetchFn?: typeof fetch) { 18 + return _fetchSiteInfo(PUBLIC_ATPROTO_DID, fetchFn); 134 19 } 135 20 136 - /** 137 - * Fetches links from Linkat board 138 - */ 139 - export async function fetchLinks(fetchFn?: typeof fetch): Promise<LinkData | null> { 140 - const cacheKey = `links:${PUBLIC_ATPROTO_DID}`; 141 - const cached = cache.get<LinkData>(cacheKey); 142 - if (cached) return cached; 143 - 144 - try { 145 - // Custom collection, prefer PDS first 146 - const value = await withFallback( 147 - PUBLIC_ATPROTO_DID, 148 - async (agent) => { 149 - const response = await agent.com.atproto.repo.getRecord({ 150 - repo: PUBLIC_ATPROTO_DID, 151 - collection: 'blue.linkat.board', 152 - rkey: 'self' 153 - }); 154 - return response.data.value; 155 - }, 156 - true, 157 - fetchFn 158 - ); // usePDSFirst = true 159 - 160 - // Validate the response has the expected structure 161 - if (!value || !Array.isArray((value as any).cards)) { 162 - return null; 163 - } 164 - 165 - const data: LinkData = { 166 - cards: (value as any).cards 167 - }; 168 - 169 - cache.set(cacheKey, data); 170 - return data; 171 - } catch (error) { 172 - console.error('Failed to fetch links from all sources:', error); 173 - return null; 174 - } 21 + export async function fetchLinks(fetchFn?: typeof fetch) { 22 + return _fetchLinks(PUBLIC_ATPROTO_DID, fetchFn); 175 23 } 176 24 177 - /** 178 - * Fetches music listening status from custom lexicons 179 - * Checks both fm.teal.alpha.actor.status and fm.teal.alpha.feed.play collections 180 - */ 181 - export async function fetchMusicStatus(fetchFn?: typeof fetch): Promise<MusicStatusData | null> { 182 - console.info('[MusicStatus] Fetching music status data'); 183 - const cacheKey = `music-status:${PUBLIC_ATPROTO_DID}`; 184 - const cached = cache.get<MusicStatusData>(cacheKey); 185 - if (cached) { 186 - console.debug('[MusicStatus] Returning cached music status data'); 187 - return cached; 188 - } 189 - 190 - try { 191 - console.info('[MusicStatus] Cache miss, fetching from network'); 192 - 193 - // Try the actor status collection first (shorter-lived status) 194 - try { 195 - const statusRecords = await withFallback( 196 - PUBLIC_ATPROTO_DID, 197 - async (agent) => { 198 - const response = await agent.com.atproto.repo.listRecords({ 199 - repo: PUBLIC_ATPROTO_DID, 200 - collection: 'fm.teal.alpha.actor.status', 201 - limit: 1 202 - }); 203 - return response.data.records; 204 - }, 205 - true, 206 - fetchFn 207 - ); 208 - 209 - if (statusRecords && statusRecords.length > 0) { 210 - const record = statusRecords[0]; 211 - const value = record.value as any; 212 - 213 - // Check if status is still valid (not expired) 214 - if (value.expiry) { 215 - const expiryTime = parseInt(value.expiry) * 1000; 216 - if (Date.now() > expiryTime) { 217 - console.debug('[MusicStatus] Actor status expired, falling back to feed play'); 218 - } else { 219 - // Build artwork URL - prioritize album art over individual track art 220 - let artworkUrl: string | undefined; 221 - const trackName = value.item?.trackName || value.trackName; 222 - const artists = value.item?.artists || value.artists || []; 223 - const releaseName = value.item?.releaseName || value.releaseName; 224 - const artistName = artists[0]?.artistName; 225 - const releaseMbId = value.item?.releaseMbId || value.releaseMbId; 226 - 227 - console.debug('[MusicStatus] Looking for artwork:', { 228 - trackName, 229 - artistName, 230 - releaseName, 231 - releaseMbId 232 - }); 233 - 234 - // Priority 1: If we have album info, search for album art (more accurate) 235 - if (releaseName && artistName) { 236 - console.info('[MusicStatus] Prioritizing album artwork search'); 237 - artworkUrl = 238 - (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || 239 - undefined; 240 - } 241 - 242 - // Priority 2: Fall back to track-based search if album search failed 243 - if (!artworkUrl && trackName && artistName) { 244 - console.info('[MusicStatus] Falling back to track-based artwork search'); 245 - artworkUrl = 246 - (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || 247 - undefined; 248 - } 249 - 250 - // Priority 3: Final fallback to atproto blob if no external artwork found 251 - if (!artworkUrl) { 252 - const artwork = value.item?.artwork || value.artwork; 253 - console.debug( 254 - '[MusicStatus] No external artwork found, checking atproto blob:', 255 - artwork 256 - ); 257 - if (artwork?.ref?.$link) { 258 - const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 259 - artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 260 - console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 261 - } 262 - } 263 - 264 - const data: MusicStatusData = { 265 - trackName: value.item?.trackName || value.trackName, 266 - artists: value.item?.artists || value.artists || [], 267 - releaseName: value.item?.releaseName || value.releaseName, 268 - playedTime: value.item?.playedTime || value.playedTime, 269 - originUrl: value.item?.originUrl || value.originUrl, 270 - recordingMbId: value.item?.recordingMbId || value.recordingMbId, 271 - releaseMbId: value.item?.releaseMbId || value.releaseMbId, 272 - isrc: value.isrc, 273 - duration: value.duration, 274 - musicServiceBaseDomain: 275 - value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 276 - submissionClientAgent: 277 - value.item?.submissionClientAgent || value.submissionClientAgent, 278 - $type: 'fm.teal.alpha.actor.status', 279 - expiry: value.expiry, 280 - artwork: value.item?.artwork || value.artwork, 281 - artworkUrl 282 - }; 283 - console.info('[MusicStatus] Successfully fetched actor status'); 284 - cache.set(cacheKey, data); 285 - return data; 286 - } 287 - } 288 - } 289 - } catch (err) { 290 - console.debug('[MusicStatus] Actor status not found or error, trying feed play:', err); 291 - } 292 - 293 - // Fall back to feed play collection 294 - const playRecords = await withFallback( 295 - PUBLIC_ATPROTO_DID, 296 - async (agent) => { 297 - const response = await agent.com.atproto.repo.listRecords({ 298 - repo: PUBLIC_ATPROTO_DID, 299 - collection: 'fm.teal.alpha.feed.play', 300 - limit: 1 301 - }); 302 - return response.data.records; 303 - }, 304 - true, 305 - fetchFn 306 - ); 307 - 308 - if (playRecords && playRecords.length > 0) { 309 - const record = playRecords[0]; 310 - const value = record.value as any; 311 - 312 - // Build artwork URL - prioritize album art over individual track art 313 - let artworkUrl: string | undefined; 314 - const trackName = value.trackName; 315 - const artists = value.artists || []; 316 - const releaseName = value.releaseName; 317 - const artistName = artists[0]?.artistName; 318 - const releaseMbId = value.releaseMbId; 319 - 320 - console.debug('[MusicStatus] Looking for artwork:', { 321 - trackName, 322 - artistName, 323 - releaseName, 324 - releaseMbId 325 - }); 326 - 327 - // Priority 1: If we have album info, search for album art (more accurate) 328 - if (releaseName && artistName) { 329 - console.info('[MusicStatus] Prioritizing album artwork search'); 330 - artworkUrl = 331 - (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || 332 - undefined; 333 - } 334 - 335 - // Priority 2: Fall back to track-based search if album search failed 336 - if (!artworkUrl && trackName && artistName) { 337 - console.info('[MusicStatus] Falling back to track-based artwork search'); 338 - artworkUrl = 339 - (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || 340 - undefined; 341 - } 342 - 343 - // Priority 3: Final fallback to atproto blob if no external artwork found 344 - if (!artworkUrl) { 345 - const artwork = value.artwork; 346 - console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork); 347 - if (artwork?.ref?.$link) { 348 - const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 349 - artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 350 - console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 351 - } 352 - } 353 - 354 - const data: MusicStatusData = { 355 - trackName: value.trackName, 356 - artists: value.artists || [], 357 - releaseName: value.releaseName, 358 - playedTime: value.playedTime, 359 - originUrl: value.originUrl, 360 - recordingMbId: value.recordingMbId, 361 - releaseMbId: value.releaseMbId, 362 - isrc: value.isrc, 363 - duration: value.duration, 364 - musicServiceBaseDomain: value.musicServiceBaseDomain, 365 - submissionClientAgent: value.submissionClientAgent, 366 - $type: 'fm.teal.alpha.feed.play', 367 - artwork: value.artwork, 368 - artworkUrl 369 - }; 370 - console.info('[MusicStatus] Successfully fetched feed play'); 371 - cache.set(cacheKey, data); 372 - return data; 373 - } 374 - 375 - return null; 376 - } catch (error) { 377 - console.error('[MusicStatus] Failed to fetch music status from all sources:', error); 378 - return null; 379 - } 25 + export async function fetchMusicStatus(fetchFn?: typeof fetch) { 26 + return _fetchMusicStatus(PUBLIC_ATPROTO_DID, fetchFn); 380 27 } 381 28 382 - /** 383 - * Fetches Kibun status from social.kibun.status collection 384 - */ 385 - export async function fetchKibunStatus(fetchFn?: typeof fetch): Promise<KibunStatusData | null> { 386 - console.info('[KibunStatus] Fetching kibun status data'); 387 - const cacheKey = `kibun-status:${PUBLIC_ATPROTO_DID}`; 388 - const cached = cache.get<KibunStatusData>(cacheKey); 389 - if (cached) { 390 - console.debug('[KibunStatus] Returning cached kibun status data'); 391 - return cached; 392 - } 393 - 394 - try { 395 - console.info('[KibunStatus] Cache miss, fetching from network'); 396 - 397 - const statusRecords = await withFallback( 398 - PUBLIC_ATPROTO_DID, 399 - async (agent) => { 400 - const response = await agent.com.atproto.repo.listRecords({ 401 - repo: PUBLIC_ATPROTO_DID, 402 - collection: 'social.kibun.status', 403 - limit: 1 404 - }); 405 - return response.data.records; 406 - }, 407 - true, 408 - fetchFn 409 - ); 410 - 411 - if (statusRecords && statusRecords.length > 0) { 412 - const record = statusRecords[0]; 413 - const value = record.value as any; 414 - 415 - const data: KibunStatusData = { 416 - text: value.text, 417 - emoji: value.emoji, 418 - createdAt: value.createdAt, 419 - $type: 'social.kibun.status' 420 - }; 421 - 422 - console.info('[KibunStatus] Successfully fetched kibun status'); 423 - cache.set(cacheKey, data); 424 - return data; 425 - } 426 - 427 - return null; 428 - } catch (error) { 429 - console.error('[KibunStatus] Failed to fetch kibun status from all sources:', error); 430 - return null; 431 - } 29 + export async function fetchKibunStatus(fetchFn?: typeof fetch) { 30 + return _fetchKibunStatus(PUBLIC_ATPROTO_DID, fetchFn); 432 31 } 433 32 434 - /** 435 - * Fetches Tangled repositories from AT Protocol 436 - */ 437 - export async function fetchTangledRepos(fetchFn?: typeof fetch): Promise<TangledReposData | null> { 438 - const cacheKey = `tangled:${PUBLIC_ATPROTO_DID}`; 439 - const cached = cache.get<TangledReposData>(cacheKey); 440 - if (cached) return cached; 441 - 442 - try { 443 - // Custom collection, prefer PDS first 444 - const records = await withFallback( 445 - PUBLIC_ATPROTO_DID, 446 - async (agent) => { 447 - const response = await agent.com.atproto.repo.listRecords({ 448 - repo: PUBLIC_ATPROTO_DID, 449 - collection: 'sh.tangled.repo', 450 - limit: 100 451 - }); 452 - return response.data.records; 453 - }, 454 - true, 455 - fetchFn 456 - ); // usePDSFirst = true 457 - 458 - if (records.length === 0) return null; 459 - 460 - const repos: TangledRepo[] = records.map((record) => { 461 - const value = record.value as any; 462 - return { 463 - uri: record.uri, 464 - name: value.name, 465 - description: value.description, 466 - knot: value.knot, 467 - createdAt: value.createdAt, 468 - labels: value.labels, 469 - source: value.source, 470 - spindle: value.spindle 471 - }; 472 - }); 473 - 474 - // Sort by creation date, newest first 475 - repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 476 - 477 - const data: TangledReposData = { repos }; 478 - cache.set(cacheKey, data); 479 - return data; 480 - } catch (error) { 481 - console.error('Failed to fetch Tangled repos from all sources:', error); 482 - return null; 483 - } 33 + export async function fetchTangledRepos(fetchFn?: typeof fetch) { 34 + return _fetchTangledRepos(PUBLIC_ATPROTO_DID, fetchFn); 484 35 }
+5 -241
src/lib/services/atproto/media.ts
··· 1 - import type { ResolvedIdentity } from './types'; 2 - import { resolveIdentity } from './agents'; 3 - 4 - /** 5 - * Builds a direct blob URL for a PDS 6 - */ 7 - export function buildPdsBlobUrl(pds: string, did: string, cid: string): string { 8 - return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 9 - } 10 - 11 - /** 12 - * Robustly extracts a CID / blob reference from various image/video shapes 13 - */ 14 - export function extractCidFromImageObject(img: any): string | null { 15 - if (!img) return null; 16 - // Common shapes: img.image.ref.$link, img.ref.$link, img.cid 17 - if (img.image && img.image.ref && img.image.ref.$link) return img.image.ref.$link as string; 18 - if (img.ref && img.ref.$link) return img.ref.$link as string; 19 - if (img.cid) return img.cid as string; 20 - if (typeof img === 'string') return img; // sometimes it's just a cid string 21 - return null; 22 - } 23 - 24 - /** 25 - * Robust extractor: hunts through `value` shapes for images (and video blobs). 26 - * - returns up to `limit` URLs (built using PDS when DID is available) 27 - * - supports value.embed (images, recordWithMedia, record), value.embeds arrays, 28 - * and nested structures. 29 - * - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first 30 - */ 31 - export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] { 32 - const urls: string[] = []; 33 - 34 - try { 35 - const embed = (value as any)?.embed ?? null; 36 - 37 - if (embed) { 38 - // images view 39 - if (embed.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { 40 - for (const img of embed.images) { 41 - // Use fullsize or thumb from view if available 42 - const imageUrl = img.fullsize || img.thumb; 43 - if (imageUrl) { 44 - urls.push(imageUrl); 45 - } else { 46 - // Fallback: construct URL from CID 47 - const cid = extractCidFromImageObject(img); 48 - if (cid) { 49 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 50 - urls.push(cdnUrl); 51 - } 52 - } 53 - if (urls.length >= limit) return urls; 54 - } 55 - } 56 - 57 - // video embed 58 - if (embed.$type === 'app.bsky.embed.video#view' || embed.$type === 'app.bsky.embed.video') { 59 - const videoCid = 60 - (embed as any)?.jobStatus?.blob ?? 61 - (embed as any)?.video?.ref?.$link ?? 62 - (embed as any)?.video?.cid ?? 63 - null; 64 - if (videoCid) { 65 - // Use CDN for video blobs 66 - const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 67 - urls.push(videoUrl); 68 - if (urls.length >= limit) return urls; 69 - } 70 - } 71 - 72 - // recordWithMedia with embedded media.images 73 - if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { 74 - const media = embed.media; 75 - if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 76 - for (const img of media.images) { 77 - const imageUrl = img.fullsize || img.thumb; 78 - if (imageUrl) { 79 - urls.push(imageUrl); 80 - } else { 81 - const cid = extractCidFromImageObject(img); 82 - if (cid) { 83 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 84 - urls.push(cdnUrl); 85 - } 86 - } 87 - if (urls.length >= limit) return urls; 88 - } 89 - } 90 - 91 - // Video in recordWithMedia 92 - if ( 93 - media && 94 - (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video') 95 - ) { 96 - const videoCid = (media as any)?.video?.ref?.$link ?? (media as any)?.video?.cid ?? null; 97 - if (videoCid) { 98 - const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 99 - urls.push(videoUrl); 100 - if (urls.length >= limit) return urls; 101 - } 102 - } 103 - 104 - // The quoted record itself may contain images in record.value or embeds 105 - const quotedRecord = embed.record; 106 - if (quotedRecord) { 107 - const quotedValue = quotedRecord.value ?? quotedRecord.record?.value ?? null; 108 - if (quotedValue) { 109 - const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); 110 - urls.push(...nested); 111 - if (urls.length >= limit) return urls; 112 - } 113 - } 114 - } 115 - 116 - // record#view where embed.record may contain value or embeds 117 - if (embed.$type === 'app.bsky.embed.record#view' && embed.record) { 118 - const quoted = embed.record; 119 - const quotedValue = quoted.value ?? quoted.record?.value ?? null; 120 - if (quotedValue) { 121 - const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); 122 - urls.push(...nested); 123 - if (urls.length >= limit) return urls; 124 - } 125 - } 126 - } 127 - 128 - // embeds array (older/newer shapes can place embeds here) 129 - if (Array.isArray((value as any).embeds)) { 130 - for (const e of (value as any).embeds) { 131 - if (e.$type === 'app.bsky.embed.images#view' && Array.isArray(e.images)) { 132 - for (const img of e.images) { 133 - const imageUrl = img.fullsize || img.thumb; 134 - if (imageUrl) { 135 - urls.push(imageUrl); 136 - } else { 137 - const cid = extractCidFromImageObject(img); 138 - if (cid) { 139 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 140 - urls.push(cdnUrl); 141 - } 142 - } 143 - if (urls.length >= limit) return urls; 144 - } 145 - } 146 - 147 - if (e.$type === 'app.bsky.embed.video#view' || e.$type === 'app.bsky.embed.video') { 148 - const videoCid = 149 - (e as any)?.jobStatus?.blob ?? 150 - (e as any)?.video?.ref?.$link ?? 151 - (e as any)?.video?.cid ?? 152 - null; 153 - if (videoCid) { 154 - const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 155 - urls.push(videoUrl); 156 - if (urls.length >= limit) return urls; 157 - } 158 - } 159 - 160 - if (e.$type === 'app.bsky.embed.recordWithMedia#view') { 161 - const media = e.media; 162 - if ( 163 - media && 164 - media.$type === 'app.bsky.embed.images#view' && 165 - Array.isArray(media.images) 166 - ) { 167 - for (const img of media.images) { 168 - const imageUrl = img.fullsize || img.thumb; 169 - if (imageUrl) { 170 - urls.push(imageUrl); 171 - } else { 172 - const cid = extractCidFromImageObject(img); 173 - if (cid) { 174 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 175 - urls.push(cdnUrl); 176 - } 177 - } 178 - if (urls.length >= limit) return urls; 179 - } 180 - } 181 - 182 - const quotedRec = e.record ?? e.record?.record ?? null; 183 - const quotedValue = quotedRec?.value ?? null; 184 - if (quotedValue) { 185 - const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); 186 - urls.push(...nested); 187 - if (urls.length >= limit) return urls; 188 - } 189 - } 190 - } 191 - } 192 - 193 - // (value as any).embed?.images shape 194 - if ((value as any)?.embed?.images && Array.isArray((value as any).embed.images)) { 195 - for (const img of (value as any).embed.images) { 196 - const imageUrl = img.fullsize || img.thumb; 197 - if (imageUrl) { 198 - urls.push(imageUrl); 199 - } else { 200 - const cid = extractCidFromImageObject(img); 201 - if (cid) { 202 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 203 - urls.push(cdnUrl); 204 - } 205 - } 206 - if (urls.length >= limit) return urls; 207 - } 208 - } 209 - 210 - // deep search fallback for any 'images' arrays or cid-like strings 211 - const stack = [value]; 212 - while (stack.length && urls.length < limit) { 213 - const node = stack.pop(); 214 - if (!node || typeof node !== 'object') continue; 215 - if (Array.isArray(node.images)) { 216 - for (const img of node.images) { 217 - const imageUrl = img.fullsize || img.thumb; 218 - if (imageUrl) { 219 - urls.push(imageUrl); 220 - } else { 221 - const cid = extractCidFromImageObject(img); 222 - if (cid) { 223 - const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`; 224 - urls.push(cdnUrl); 225 - } 226 - } 227 - if (urls.length >= limit) break; 228 - } 229 - } 230 - for (const k of Object.keys(node)) { 231 - const v = node[k]; 232 - if (v && typeof v === 'object') stack.push(v); 233 - } 234 - } 235 - } catch (err) { 236 - // be conservative: if anything goes wrong here, just return what we have 237 - console.warn('Error extracting image/video URLs from value:', err); 238 - } 239 - 240 - return urls.slice(0, limit); 241 - } 1 + export { 2 + buildPdsBlobUrl, 3 + extractCidFromImageObject, 4 + extractImageUrlsFromValue 5 + } from '@ewanc26/atproto';
+8 -403
src/lib/services/atproto/musicbrainz.ts
··· 1 - /** 2 - * Music artwork fetching with multiple API-free sources 3 - * Cascading fallback: MusicBrainz → iTunes → Deezer → Spotify 4 - */ 5 - 6 - import { cache } from './cache'; 7 - 8 - interface MusicBrainzRelease { 9 - id: string; 10 - score: number; 11 - title: string; 12 - 'artist-credit'?: Array<{ name: string }>; 13 - } 14 - 15 - interface MusicBrainzSearchResponse { 16 - releases: MusicBrainzRelease[]; 17 - } 18 - 19 - interface iTunesResult { 20 - artworkUrl100?: string; 21 - artworkUrl60?: string; 22 - collectionId?: number; 23 - } 24 - 25 - interface iTunesSearchResponse { 26 - resultCount: number; 27 - results: iTunesResult[]; 28 - } 29 - 30 - interface DeezerAlbum { 31 - id: number; 32 - title: string; 33 - cover_medium?: string; 34 - cover_big?: string; 35 - cover_xl?: string; 36 - } 37 - 38 - interface DeezerSearchResponse { 39 - data: DeezerAlbum[]; 40 - } 41 - 42 - /** 43 - * Search MusicBrainz for a release by track name and artist 44 - * Now tries both track-based and album-based searches 45 - */ 46 - export async function searchMusicBrainzRelease( 47 - trackName: string, 48 - artistName: string, 49 - releaseName?: string 50 - ): Promise<string | null> { 51 - const cacheKey = `mb:release:${trackName}:${artistName}:${releaseName || 'none'}`; 52 - const cached = cache.get<string | null>(cacheKey); 53 - if (cached !== null) { 54 - console.debug('[MusicBrainz] Returning cached release ID:', cached); 55 - return cached; 56 - } 57 - 58 - try { 59 - // Strategy 1: Search by release name if available (most accurate) 60 - if (releaseName) { 61 - const releaseResult = await searchByReleaseName(releaseName, artistName); 62 - if (releaseResult) { 63 - cache.set(cacheKey, releaseResult); 64 - return releaseResult; 65 - } 66 - } 67 - 68 - // Strategy 2: Search by track name 69 - const trackResult = await searchByTrackName(trackName, artistName); 70 - if (trackResult) { 71 - cache.set(cacheKey, trackResult); 72 - return trackResult; 73 - } 74 - 75 - // Cache null result to avoid repeated failed lookups 76 - console.debug('[MusicBrainz] No release found for:', { trackName, artistName, releaseName }); 77 - cache.set(cacheKey, null); 78 - return null; 79 - } catch (error) { 80 - console.error('[MusicBrainz] Search error:', error); 81 - return null; 82 - } 83 - } 84 - 85 - async function searchByReleaseName( 86 - releaseName: string, 87 - artistName: string 88 - ): Promise<string | null> { 89 - try { 90 - const query = `release:"${releaseName}" AND artist:"${artistName}"`; 91 - const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 92 - 93 - console.info('[MusicBrainz] Searching by release name:', { releaseName, artistName }); 94 - 95 - const response = await fetch(url, { 96 - headers: { 97 - 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 98 - Accept: 'application/json' 99 - } 100 - }); 101 - 102 - if (!response.ok) return null; 103 - 104 - const data: MusicBrainzSearchResponse = await response.json(); 105 - 106 - if (!data.releases || data.releases.length === 0) return null; 107 - 108 - const bestMatch = data.releases[0]; 109 - if (bestMatch.score < 80) { 110 - console.debug('[MusicBrainz] Release search score too low:', bestMatch.score); 111 - return null; 112 - } 113 - 114 - console.info('[MusicBrainz] Found release by album:', { 115 - id: bestMatch.id, 116 - title: bestMatch.title, 117 - score: bestMatch.score 118 - }); 119 - 120 - return bestMatch.id; 121 - } catch (error) { 122 - console.debug('[MusicBrainz] Release name search failed:', error); 123 - return null; 124 - } 125 - } 126 - 127 - async function searchByTrackName(trackName: string, artistName: string): Promise<string | null> { 128 - try { 129 - const query = `recording:"${trackName}" AND artist:"${artistName}"`; 130 - const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 131 - 132 - console.info('[MusicBrainz] Searching by track name:', { trackName, artistName }); 133 - 134 - const response = await fetch(url, { 135 - headers: { 136 - 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 137 - Accept: 'application/json' 138 - } 139 - }); 140 - 141 - if (!response.ok) return null; 142 - 143 - const data: MusicBrainzSearchResponse = await response.json(); 144 - 145 - if (!data.releases || data.releases.length === 0) return null; 146 - 147 - const bestMatch = data.releases[0]; 148 - if (bestMatch.score < 75) { 149 - console.debug('[MusicBrainz] Track search score too low:', bestMatch.score); 150 - return null; 151 - } 152 - 153 - console.info('[MusicBrainz] Found release by track:', { 154 - id: bestMatch.id, 155 - title: bestMatch.title, 156 - score: bestMatch.score 157 - }); 158 - 159 - return bestMatch.id; 160 - } catch (error) { 161 - console.debug('[MusicBrainz] Track name search failed:', error); 162 - return null; 163 - } 164 - } 165 - 166 - /** 167 - * Search iTunes for album artwork (no API key required) 168 - */ 169 - export async function searchiTunesArtwork( 170 - trackName: string, 171 - artistName: string, 172 - releaseName?: string 173 - ): Promise<string | null> { 174 - const cacheKey = `itunes:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 175 - const cached = cache.get<string | null>(cacheKey); 176 - if (cached !== null) { 177 - console.debug('[iTunes] Returning cached artwork URL:', cached); 178 - return cached; 179 - } 180 - 181 - try { 182 - // Prefer searching by album + artist for better accuracy 183 - const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 184 - 185 - const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 186 - 187 - console.info('[iTunes] Searching for artwork:', { searchTerm }); 188 - 189 - const response = await fetch(url); 190 - if (!response.ok) { 191 - cache.set(cacheKey, null); 192 - return null; 193 - } 194 - 195 - const data: iTunesSearchResponse = await response.json(); 196 - 197 - if (!data.results || data.results.length === 0) { 198 - console.debug('[iTunes] No results found'); 199 - cache.set(cacheKey, null); 200 - return null; 201 - } 202 - 203 - // Get the highest resolution artwork available 204 - const result = data.results[0]; 205 - let artworkUrl = result.artworkUrl100; 206 - 207 - if (artworkUrl) { 208 - // iTunes allows upsizing artwork by modifying the URL 209 - // Replace 100x100 with 600x600 for better quality 210 - artworkUrl = artworkUrl.replace('100x100', '600x600'); 211 - console.info('[iTunes] Found artwork:', artworkUrl); 212 - cache.set(cacheKey, artworkUrl); 213 - return artworkUrl; 214 - } 215 - 216 - cache.set(cacheKey, null); 217 - return null; 218 - } catch (error) { 219 - console.error('[iTunes] Search error:', error); 220 - return null; 221 - } 222 - } 223 - 224 - /** 225 - * Search Deezer for album artwork (no API key required) 226 - * Note: Deezer API has CORS restrictions, so this may not work in all browsers 227 - */ 228 - export async function searchDeezerArtwork( 229 - trackName: string, 230 - artistName: string, 231 - releaseName?: string 232 - ): Promise<string | null> { 233 - const cacheKey = `deezer:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 234 - const cached = cache.get<string | null>(cacheKey); 235 - if (cached !== null) { 236 - console.debug('[Deezer] Returning cached artwork URL:', cached); 237 - return cached; 238 - } 239 - 240 - try { 241 - // Prefer album search if available 242 - const searchTerm = releaseName || trackName; 243 - // Use CORS proxy or skip Deezer due to CORS restrictions 244 - const url = `https://api.deezer.com/search/album?q=artist:"${encodeURIComponent(artistName)}" album:"${encodeURIComponent(searchTerm)}"&limit=5&output=jsonp`; 245 - 246 - console.info('[Deezer] Searching for artwork:', { searchTerm, artistName }); 247 - 248 - const response = await fetch(url); 249 - if (!response.ok) { 250 - cache.set(cacheKey, null); 251 - return null; 252 - } 253 - 254 - const data: DeezerSearchResponse = await response.json(); 255 - 256 - if (!data.data || data.data.length === 0) { 257 - console.debug('[Deezer] No results found'); 258 - cache.set(cacheKey, null); 259 - return null; 260 - } 261 - 262 - // Use the highest quality artwork available 263 - const result = data.data[0]; 264 - const artworkUrl = result.cover_xl || result.cover_big || result.cover_medium; 265 - 266 - if (artworkUrl) { 267 - console.info('[Deezer] Found artwork:', artworkUrl); 268 - cache.set(cacheKey, artworkUrl); 269 - return artworkUrl; 270 - } 271 - 272 - cache.set(cacheKey, null); 273 - return null; 274 - } catch (error) { 275 - // Deezer has CORS issues, so we'll skip it silently 276 - console.debug('[Deezer] Skipped due to CORS restrictions'); 277 - cache.set(cacheKey, null); 278 - return null; 279 - } 280 - } 281 - 282 - /** 283 - * Build MusicBrainz Cover Art Archive URL 284 - */ 285 - export function buildCoverArtUrl(releaseMbId: string, size: 250 | 500 | 1200 = 500): string { 286 - return `https://coverartarchive.org/release/${releaseMbId}/front-${size}`; 287 - } 288 - 289 - /** 290 - * Search Last.fm for album artwork (no API key required for album art) 291 - * Uses Last.fm's direct image URLs based on artist and album 292 - */ 293 - export async function searchLastFmArtwork( 294 - trackName: string, 295 - artistName: string, 296 - releaseName?: string 297 - ): Promise<string | null> { 298 - const cacheKey = `lastfm:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 299 - const cached = cache.get<string | null>(cacheKey); 300 - if (cached !== null) { 301 - console.debug('[Last.fm] Returning cached artwork URL:', cached); 302 - return cached; 303 - } 304 - 305 - if (!releaseName) { 306 - return null; // Last.fm method needs album name 307 - } 308 - 309 - try { 310 - // Last.fm has a public API for album info without authentication 311 - const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; 312 - 313 - console.info('[Last.fm] Searching for artwork:', { artistName, releaseName }); 314 - 315 - const response = await fetch(url); 316 - if (!response.ok) { 317 - cache.set(cacheKey, null); 318 - return null; 319 - } 320 - 321 - const data: any = await response.json(); 322 - 323 - if (!data.album?.image) { 324 - console.debug('[Last.fm] No artwork found'); 325 - cache.set(cacheKey, null); 326 - return null; 327 - } 328 - 329 - // Get the largest image available 330 - const images = data.album.image; 331 - const largeImage = 332 - images.find((img: any) => img.size === 'extralarge') || 333 - images.find((img: any) => img.size === 'large') || 334 - images.find((img: any) => img.size === 'medium'); 335 - 336 - if (largeImage?.['#text']) { 337 - const artworkUrl = largeImage['#text']; 338 - console.info('[Last.fm] Found artwork:', artworkUrl); 339 - cache.set(cacheKey, artworkUrl); 340 - return artworkUrl; 341 - } 342 - 343 - cache.set(cacheKey, null); 344 - return null; 345 - } catch (error) { 346 - console.debug('[Last.fm] Search error:', error); 347 - cache.set(cacheKey, null); 348 - return null; 349 - } 350 - } 351 - 352 - /** 353 - * Cascading artwork search using server-side API endpoint 354 - * This solves CORS issues by proxying requests through our server 355 - * Tries: MusicBrainz → iTunes → Deezer → Last.fm 356 - */ 357 - export async function findArtwork( 358 - trackName: string, 359 - artistName: string, 360 - releaseName?: string, 361 - releaseMbId?: string, 362 - fetchFn?: typeof fetch 363 - ): Promise<string | null> { 364 - try { 365 - // Build query parameters 366 - const params = new URLSearchParams({ 367 - trackName, 368 - artistName 369 - }); 370 - 371 - if (releaseName) params.set('releaseName', releaseName); 372 - if (releaseMbId) params.set('releaseMbId', releaseMbId); 373 - 374 - console.info('[Artwork] Fetching via server API:', { 375 - trackName, 376 - artistName, 377 - releaseName, 378 - releaseMbId 379 - }); 380 - 381 - // Call our server-side API endpoint 382 - const fetchFunc = fetchFn || fetch; 383 - const response = await fetchFunc(`/api/artwork?${params.toString()}`); 384 - 385 - if (!response.ok) { 386 - console.error('[Artwork] API request failed:', response.status); 387 - return null; 388 - } 389 - 390 - const data = await response.json(); 391 - 392 - if (data.artworkUrl) { 393 - console.info('[Artwork] Found via', data.source, ':', data.artworkUrl); 394 - return data.artworkUrl; 395 - } 396 - 397 - console.warn('[Artwork] No artwork found from any source'); 398 - return null; 399 - } catch (error) { 400 - console.error('[Artwork] Server API error:', error); 401 - return null; 402 - } 403 - } 1 + export { 2 + searchMusicBrainzRelease, 3 + buildCoverArtUrl, 4 + searchiTunesArtwork, 5 + searchDeezerArtwork, 6 + searchLastFmArtwork, 7 + findArtwork 8 + } from '@ewanc26/atproto';
+3 -92
src/lib/services/atproto/pagination/fetchAllRecords.ts
··· 1 - import { withFallback } from '$lib/services/atproto/agents'; 2 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 3 - 4 - /** 5 - * Configuration for fetching paginated records 6 - */ 7 - export interface FetchRecordsConfig { 8 - /** The repository DID to fetch from */ 9 - repo: string; 10 - /** The AT Protocol collection to fetch from */ 11 - collection: string; 12 - /** Number of records to fetch per page (max 100) */ 13 - limit?: number; 14 - /** Optional fetch function for SSR */ 15 - fetchFn?: typeof fetch; 16 - } 17 - 18 - /** 19 - * Type for AT Protocol record response 20 - */ 21 - export interface AtProtoRecord<T = any> { 22 - uri: string; 23 - value: T; 24 - cid?: string; 25 - } 26 - 27 - /** 28 - * Generic function to fetch all records from an AT Protocol collection with automatic pagination. 29 - * 30 - * @param config - Configuration object for the fetch operation 31 - * @returns Promise resolving to array of all records 32 - * 33 - * @example 34 - * ```ts 35 - * const posts = await fetchAllRecords({ 36 - * repo: PUBLIC_ATPROTO_DID, 37 - * collection: 'com.whtwnd.blog.entry', 38 - * fetchFn: fetch 39 - * }); 40 - * ``` 41 - */ 42 - export async function fetchAllRecords<T = any>( 43 - config: FetchRecordsConfig 44 - ): Promise<AtProtoRecord<T>[]> { 45 - const { repo, collection, limit = 100, fetchFn } = config; 46 - const allRecords: AtProtoRecord<T>[] = []; 47 - 48 - let cursor: string | undefined; 49 - 50 - try { 51 - do { 52 - const records = await withFallback( 53 - repo, 54 - async (agent) => { 55 - const response = await agent.com.atproto.repo.listRecords({ 56 - repo, 57 - collection, 58 - limit, 59 - cursor 60 - }); 61 - cursor = response.data.cursor; 62 - return response.data.records; 63 - }, 64 - true, 65 - fetchFn 66 - ); 67 - 68 - allRecords.push(...(records as AtProtoRecord<T>[])); 69 - } while (cursor); 70 - } catch (error) { 71 - console.warn(`Failed to fetch records from ${collection}:`, error); 72 - throw error; 73 - } 74 - 75 - return allRecords; 76 - } 77 - 78 - /** 79 - * Convenience function to fetch all records from the configured user's repository 80 - */ 81 - export async function fetchAllUserRecords<T = any>( 82 - collection: string, 83 - fetchFn?: typeof fetch, 84 - limit?: number 85 - ): Promise<AtProtoRecord<T>[]> { 86 - return fetchAllRecords<T>({ 87 - repo: PUBLIC_ATPROTO_DID, 88 - collection, 89 - limit, 90 - fetchFn 91 - }); 92 - } 1 + // Delegated to @ewanc26/atproto — see pagination/index.ts 2 + export { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto'; 3 + export type { FetchRecordsConfig, AtProtoRecord } from '@ewanc26/atproto';
+2 -2
src/lib/services/atproto/pagination/index.ts
··· 1 - export { fetchAllRecords, fetchAllUserRecords } from './fetchAllRecords'; 2 - export type { FetchRecordsConfig, AtProtoRecord } from './fetchAllRecords'; 1 + export { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto'; 2 + export type { FetchRecordsConfig, AtProtoRecord } from '@ewanc26/atproto';
+24 -378
src/lib/services/atproto/posts.ts
··· 1 + // Thin wrappers over @ewanc26/atproto that bind PUBLIC_ATPROTO_DID. 1 2 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 3 import { cache } from './cache'; 3 - import { withFallback } from './agents'; 4 - import type { BlogPost, BlogPostsData, BlueskyPost, PostAuthor, ExternalLink } from './types'; 5 - import { fetchStandardSiteDocuments } from './standard'; 6 - 7 - /** 8 - * Fetches blog posts from Standard.site only 9 - * @param fetchFn - Optional fetch function for SSR 10 - */ 11 - export async function fetchBlogPosts(fetchFn?: typeof fetch): Promise<BlogPostsData> { 12 - const cacheKey = `blogposts:${PUBLIC_ATPROTO_DID}`; 13 - const cached = cache.get<BlogPostsData>(cacheKey); 14 - if (cached) return cached; 4 + import { 5 + fetchLatestBlueskyPost as _fetchLatestBlueskyPost, 6 + fetchPostFromUri as _fetchPostFromUri, 7 + fetchBlogPosts as _fetchBlogPosts 8 + } from '@ewanc26/atproto'; 9 + import type { BlogPost, BlogPostsData, BlueskyPost } from './types'; 15 10 16 - const posts: BlogPost[] = []; 17 - 18 - // Fetch Standard.site documents 19 - try { 20 - const standardDocumentsData = await fetchStandardSiteDocuments(fetchFn); 21 - 22 - for (const doc of standardDocumentsData.documents) { 23 - posts.push({ 24 - title: doc.title, 25 - url: doc.url, 26 - createdAt: doc.publishedAt, 27 - platform: 'standard.site', 28 - description: doc.description, 29 - rkey: doc.rkey, 30 - publicationName: doc.publicationName, 31 - publicationRkey: doc.publicationRkey, 32 - coverImage: doc.coverImage, 33 - textContent: doc.textContent, 34 - updatedAt: doc.updatedAt, 35 - tags: doc.tags 36 - }); 37 - } 38 - } catch (error) { 39 - console.warn('Failed to fetch Standard.site documents:', error); 40 - } 41 - 42 - // Sort by date (newest first) 43 - posts.sort((a, b) => { 44 - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); 45 - }); 46 - 47 - const topPosts = posts.slice(0, 5); 48 - 49 - const data: BlogPostsData = { posts: topPosts }; 50 - cache.set(cacheKey, data); 51 - return data; 52 - } 53 - 54 - /** 55 - * Fetches the latest Bluesky post (including replies and reposts) 56 - */ 57 11 export async function fetchLatestBlueskyPost(fetchFn?: typeof fetch): Promise<BlueskyPost | null> { 58 - console.log('[fetchLatestBlueskyPost] Starting fetch...'); 59 - const cacheKey = `blueskypost:latest:${PUBLIC_ATPROTO_DID}`; 60 - const cached = cache.get<BlueskyPost>(cacheKey); 61 - if (cached) { 62 - console.log('[fetchLatestBlueskyPost] Returning cached post'); 63 - return cached; 64 - } 65 - 66 - try { 67 - console.log('[fetchLatestBlueskyPost] Fetching author feed...'); 68 - // Use withFallback to get posts AND reposts in chronological order 69 - const feedResponse = await withFallback( 70 - PUBLIC_ATPROTO_DID, 71 - async (agent) => { 72 - return agent.getAuthorFeed({ 73 - actor: PUBLIC_ATPROTO_DID, 74 - limit: 5 75 - }); 76 - }, 77 - false, 78 - fetchFn 79 - ); 80 - 81 - const feed = feedResponse.data.feed; 82 - console.log('[fetchLatestBlueskyPost] Feed items fetched:', feed.length); 83 - 84 - if (feed.length === 0) { 85 - console.warn('[fetchLatestBlueskyPost] No feed items found'); 86 - return null; 87 - } 88 - 89 - // Take the latest feed item (first in array) 90 - const latestFeedItem = feed[0]; 91 - const latestPostData = latestFeedItem.post; 92 - console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri); 93 - 94 - // Check if this is a repost 95 - const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 96 - let repostAuthor: PostAuthor | undefined; 97 - let repostCreatedAt: string | undefined; 98 - 99 - if (isRepost && latestFeedItem.reason) { 100 - const reason = latestFeedItem.reason as any; 101 - repostAuthor = { 102 - did: reason.by.did, 103 - handle: reason.by.handle, 104 - displayName: reason.by.displayName, 105 - avatar: reason.by.avatar 106 - }; 107 - repostCreatedAt = reason.indexedAt; 108 - console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle); 109 - } 110 - 111 - // Fetch the full post data 112 - const post = await fetchPostFromUri(latestPostData.uri, 0, fetchFn); 113 - 114 - if (!post) { 115 - console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null'); 116 - return null; 117 - } 118 - 119 - // Add repost context if applicable 120 - if (isRepost) { 121 - post.isRepost = true; 122 - post.repostAuthor = repostAuthor; 123 - post.repostCreatedAt = repostCreatedAt; 124 - // Store the original post data 125 - post.originalPost = { ...post }; 126 - } 127 - 128 - console.log('[fetchLatestBlueskyPost] Post fetched successfully, caching...'); 129 - cache.set(cacheKey, post); 130 - return post; 131 - } catch (error) { 132 - console.error('[fetchLatestBlueskyPost] Failed to fetch latest Bluesky post:', error); 133 - return null; 134 - } 12 + return _fetchLatestBlueskyPost(PUBLIC_ATPROTO_DID, fetchFn); 135 13 } 136 14 137 - /** 138 - * Recursively fetches a Bluesky post by URI, supporting quoted posts up to 2 levels deep 139 - */ 140 15 export async function fetchPostFromUri( 141 16 uri: string, 142 17 depth: number, 143 18 fetchFn?: typeof fetch 144 19 ): Promise<BlueskyPost | null> { 145 - console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri); 146 - 147 - if (depth >= 3) { 148 - console.log(`[fetchPostFromUri] Max depth reached (${depth}), stopping recursion`); 149 - return null; 150 - } 151 - 152 - try { 153 - console.log(`[fetchPostFromUri] Fetching post thread from Bluesky API...`); 154 - const threadResponse = await withFallback( 155 - PUBLIC_ATPROTO_DID, 156 - async (agent) => { 157 - return agent.getPostThread({ uri, depth: 0 }); 158 - }, 159 - false, 160 - fetchFn 161 - ); 162 - 163 - if (!threadResponse.data.thread || !('post' in threadResponse.data.thread)) { 164 - console.warn(`[fetchPostFromUri] No valid thread data found for URI:`, uri); 165 - return null; 166 - } 167 - 168 - const postData = threadResponse.data.thread.post; 169 - console.log(`[fetchPostFromUri] Post data received:`, { 170 - uri: postData.uri, 171 - author: postData.author.handle, 172 - hasEmbed: !!postData.embed, 173 - embedType: postData.embed?.$type 174 - }); 175 - 176 - const value = postData.record as any; 177 - const embed = (postData as any).embed ?? null; 178 - 179 - // Extract author information 180 - const author: PostAuthor = { 181 - did: postData.author.did, 182 - handle: postData.author.handle, 183 - displayName: postData.author.displayName, 184 - avatar: postData.author.avatar 185 - }; 186 - console.log(`[fetchPostFromUri] Author extracted:`, author); 187 - 188 - // Get the author's DID for blob resolution 189 - const authorDid = postData.author.did; 190 - 191 - let imageUrls: string[] | undefined; 192 - let imageAlts: string[] | undefined; 193 - let hasImages = false; 194 - let hasVideo = false; 195 - let videoUrl: string | undefined; 196 - let videoThumbnail: string | undefined; 197 - let quotedPost: BlueskyPost | undefined; 198 - let quotedPostUri: string | undefined; 199 - let externalLink: ExternalLink | undefined; 200 - 201 - // Extract facets from the record 202 - const facets = value.facets; 203 - console.log(`[fetchPostFromUri] Facets found:`, facets?.length || 0); 204 - 205 - // Process images 206 - if (embed?.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { 207 - console.log(`[fetchPostFromUri] Processing images embed, count:`, embed.images.length); 208 - hasImages = true; 209 - imageUrls = []; 210 - imageAlts = []; 211 - for (const img of embed.images) { 212 - const imageUrl = img.fullsize || img.thumb; 213 - if (imageUrl) { 214 - imageUrls.push(imageUrl); 215 - imageAlts.push(img.alt || ''); 216 - } 217 - } 218 - console.log(`[fetchPostFromUri] Final image URLs:`, imageUrls); 219 - } 20 + return _fetchPostFromUri(PUBLIC_ATPROTO_DID, uri, depth, fetchFn); 21 + } 220 22 221 - // Process video 222 - if (embed?.$type === 'app.bsky.embed.video#view') { 223 - console.log(`[fetchPostFromUri] Processing video embed`); 224 - const videoCid = embed.playlist; 225 - if (videoCid) { 226 - hasVideo = true; 227 - videoUrl = videoCid; 228 - console.log(`[fetchPostFromUri] Video URL:`, videoUrl); 229 - } 230 - const thumbnailUrl = embed.thumbnail; 231 - if (thumbnailUrl) { 232 - videoThumbnail = thumbnailUrl; 233 - console.log(`[fetchPostFromUri] Video thumbnail URL:`, videoThumbnail); 234 - } 235 - } 23 + /** 24 + * Fetches blog posts (top 5) from Standard.site documents, using the app cache. 25 + */ 26 + export async function fetchBlogPosts(fetchFn?: typeof fetch): Promise<BlogPostsData> { 27 + const cacheKey = `blogposts:${PUBLIC_ATPROTO_DID}`; 28 + const cached = cache.get<BlogPostsData>(cacheKey); 29 + if (cached) return cached; 236 30 237 - // Process external link 238 - if (embed?.$type === 'app.bsky.embed.external#view') { 239 - console.log(`[fetchPostFromUri] Processing external link embed`); 240 - const external = embed.external; 241 - if (external) { 242 - externalLink = { 243 - uri: external.uri, 244 - title: external.title, 245 - description: external.description, 246 - thumb: external.thumb 247 - }; 248 - console.log(`[fetchPostFromUri] External link:`, externalLink.uri); 249 - } 250 - } 31 + const { posts } = await _fetchBlogPosts(PUBLIC_ATPROTO_DID, fetchFn); 251 32 252 - // Process recordWithMedia (quoted post with media) 253 - if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 254 - console.log(`[fetchPostFromUri] Processing recordWithMedia embed`); 255 - const media = embed.media; 256 - 257 - // Extract images from media 258 - if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 259 - console.log( 260 - `[fetchPostFromUri] Processing images in recordWithMedia, count:`, 261 - media.images.length 262 - ); 263 - hasImages = true; 264 - imageUrls = []; 265 - imageAlts = []; 266 - for (const img of media.images) { 267 - const imageUrl = img.fullsize || img.thumb; 268 - if (imageUrl) { 269 - imageUrls.push(imageUrl); 270 - imageAlts.push(img.alt || ''); 271 - } 272 - } 273 - console.log(`[fetchPostFromUri] Final media image URLs:`, imageUrls); 274 - } 33 + const sorted = [...posts].sort( 34 + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 35 + ); 275 36 276 - // Extract video from media 277 - if (media?.$type === 'app.bsky.embed.video#view') { 278 - console.log(`[fetchPostFromUri] Processing video in recordWithMedia`); 279 - const videoCid = media.playlist; 280 - if (videoCid) { 281 - hasVideo = true; 282 - videoUrl = videoCid; 283 - console.log(`[fetchPostFromUri] Media video URL:`, videoUrl); 284 - } 285 - const thumbnailUrl = media.thumbnail; 286 - if (thumbnailUrl) { 287 - videoThumbnail = thumbnailUrl; 288 - console.log(`[fetchPostFromUri] Media video thumbnail:`, videoThumbnail); 289 - } 290 - } 291 - 292 - // Extract external link from media 293 - if (media?.$type === 'app.bsky.embed.external#view') { 294 - console.log(`[fetchPostFromUri] Processing external link in recordWithMedia`); 295 - const external = media.external; 296 - if (external) { 297 - externalLink = { 298 - uri: external.uri, 299 - title: external.title, 300 - description: external.description, 301 - thumb: external.thumb 302 - }; 303 - console.log(`[fetchPostFromUri] Media external link:`, externalLink.uri); 304 - } 305 - } 306 - 307 - // Extract quoted record 308 - const quotedRecord = embed.record?.record || embed.record; 309 - console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri); 310 - if (quotedRecord && typeof quotedRecord.uri === 'string') { 311 - quotedPostUri = quotedRecord.uri; 312 - console.log( 313 - `[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, 314 - quotedPostUri 315 - ); 316 - if (quotedPostUri) { 317 - quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined; 318 - console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed'); 319 - } 320 - } 321 - } 322 - 323 - // Process simple quoted post (without media) 324 - if (embed?.$type === 'app.bsky.embed.record#view') { 325 - console.log(`[fetchPostFromUri] Processing simple record embed (quoted post)`); 326 - const quotedRecord = embed.record?.record || embed.record; 327 - console.log(`[fetchPostFromUri] Quoted record:`, quotedRecord?.uri); 328 - if (quotedRecord && typeof quotedRecord.uri === 'string') { 329 - quotedPostUri = quotedRecord.uri; 330 - console.log( 331 - `[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, 332 - quotedPostUri 333 - ); 334 - if (quotedPostUri) { 335 - quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined; 336 - console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed'); 337 - } 338 - } 339 - } 340 - 341 - // Handle reply context 342 - let replyParent: BlueskyPost | undefined; 343 - let replyRoot: BlueskyPost | undefined; 344 - if (value.reply) { 345 - console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`); 346 - if (value.reply.parent?.uri) { 347 - replyParent = 348 - (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined; 349 - } 350 - if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 351 - replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1, fetchFn)) ?? undefined; 352 - } 353 - } 354 - 355 - // Get engagement data (like/repost counts) from the post data 356 - const finalLikeCount = postData.likeCount || 0; 357 - const finalRepostCount = postData.repostCount || 0; 358 - 359 - const post: BlueskyPost = { 360 - text: value.text, 361 - createdAt: value.createdAt, 362 - uri: postData.uri, 363 - author, 364 - likeCount: finalLikeCount, 365 - repostCount: finalRepostCount, 366 - replyCount: postData.replyCount, 367 - hasImages, 368 - imageUrls, 369 - imageAlts, 370 - hasVideo, 371 - videoUrl, 372 - videoThumbnail, 373 - quotedPostUri, 374 - quotedPost, 375 - facets, 376 - externalLink, 377 - replyParent, 378 - replyRoot 379 - }; 380 - 381 - console.log(`[fetchPostFromUri] Post construction complete at depth ${depth}:`, { 382 - hasImages, 383 - imageCount: imageUrls?.length, 384 - hasVideo, 385 - hasQuotedPost: !!quotedPost, 386 - hasExternalLink: !!externalLink 387 - }); 388 - 389 - return post; 390 - } catch (err) { 391 - console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err); 392 - return null; 393 - } 37 + const data: BlogPostsData = { posts: sorted.slice(0, 5) }; 38 + cache.set(cacheKey, data); 39 + return data; 394 40 }
+8 -206
src/lib/services/atproto/standard.ts
··· 1 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 - import { cache } from './cache'; 3 - import { withFallback, resolveIdentity } from './agents'; 4 - import { buildPdsBlobUrl } from './media'; 5 - import type { 6 - StandardSitePublication, 7 - StandardSitePublicationsData, 8 - StandardSiteDocument, 9 - StandardSiteDocumentsData, 10 - StandardSiteBasicTheme, 11 - StandardSiteThemeColor 12 - } from './types'; 13 - 14 - /** 15 - * Fetches all Standard.site publications for a user 16 - */ 17 - export async function fetchStandardSitePublications( 18 - fetchFn?: typeof fetch 19 - ): Promise<StandardSitePublicationsData> { 20 - console.info('[Standard.site] Fetching publications'); 21 - const cacheKey = `standard-site:publications:${PUBLIC_ATPROTO_DID}`; 22 - const cached = cache.get<StandardSitePublicationsData>(cacheKey); 23 - if (cached) { 24 - console.debug('[Standard.site] Returning cached publications'); 25 - return cached; 26 - } 27 - 28 - const publications: StandardSitePublication[] = []; 29 - console.info('[Standard.site] Cache miss, fetching from network'); 30 - 31 - try { 32 - console.debug('[Standard.site] Querying publications records'); 33 - const publicationsRecords = await withFallback( 34 - PUBLIC_ATPROTO_DID, 35 - async (agent) => { 36 - const response = await agent.com.atproto.repo.listRecords({ 37 - repo: PUBLIC_ATPROTO_DID, 38 - collection: 'site.standard.publication', 39 - limit: 100 40 - }); 41 - return response.data.records; 42 - }, 43 - true, 44 - fetchFn 45 - ); 46 - 47 - for (const pubRecord of publicationsRecords) { 48 - const pubValue = pubRecord.value as any; 49 - const rkey = pubRecord.uri.split('/').pop() || ''; 50 - 51 - // Extract basic theme if present 52 - let basicTheme: StandardSiteBasicTheme | undefined; 53 - if (pubValue.basicTheme) { 54 - const theme = pubValue.basicTheme; 55 - basicTheme = { 56 - background: theme.background as StandardSiteThemeColor, 57 - foreground: theme.foreground as StandardSiteThemeColor, 58 - accent: theme.accent as StandardSiteThemeColor, 59 - accentForeground: theme.accentForeground as StandardSiteThemeColor 60 - }; 61 - } 62 - 63 - publications.push({ 64 - name: pubValue.name || 'Untitled Publication', 65 - rkey, 66 - uri: pubRecord.uri, 67 - url: pubValue.url, 68 - description: pubValue.description, 69 - icon: pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined, 70 - basicTheme, 71 - preferences: pubValue.preferences 72 - }); 73 - } 74 - 75 - const data: StandardSitePublicationsData = { publications }; 76 - cache.set(cacheKey, data); 77 - return data; 78 - } catch (error) { 79 - console.warn('Failed to fetch Standard.site publications:', error); 80 - return { publications: [] }; 81 - } 82 - } 83 - 84 - /** 85 - * Fetches all Standard.site documents for a user 86 - */ 87 - export async function fetchStandardSiteDocuments( 88 - fetchFn?: typeof fetch 89 - ): Promise<StandardSiteDocumentsData> { 90 - console.info('[Standard.site] Fetching documents'); 91 - const cacheKey = `standard-site:documents:${PUBLIC_ATPROTO_DID}`; 92 - const cached = cache.get<StandardSiteDocumentsData>(cacheKey); 93 - if (cached) { 94 - console.debug('[Standard.site] Returning cached documents'); 95 - return cached; 96 - } 97 - 98 - const documents: StandardSiteDocument[] = []; 99 - console.info('[Standard.site] Cache miss, fetching from network'); 100 - 101 - try { 102 - // Get all publications first to map URIs to publication data 103 - const publicationsData = await fetchStandardSitePublications(fetchFn); 104 - const publicationsMap = new Map<string, StandardSitePublication>(); 105 - for (const pub of publicationsData.publications) { 106 - publicationsMap.set(pub.uri, pub); 107 - } 108 - 109 - console.debug('[Standard.site] Querying documents records'); 110 - const documentsRecords = await withFallback( 111 - PUBLIC_ATPROTO_DID, 112 - async (agent) => { 113 - const response = await agent.com.atproto.repo.listRecords({ 114 - repo: PUBLIC_ATPROTO_DID, 115 - collection: 'site.standard.document', 116 - limit: 100 117 - }); 118 - return response.data.records; 119 - }, 120 - true, 121 - fetchFn 122 - ); 123 - 124 - for (const docRecord of documentsRecords) { 125 - const docValue = docRecord.value as any; 126 - const rkey = docRecord.uri.split('/').pop() || ''; 127 - 128 - // Determine the publication info 129 - const siteValue = docValue.site; 130 - let publication: StandardSitePublication | undefined; 131 - let publicationRkey: string | undefined; 132 - let url: string; 133 - 134 - // Check if site points to a publication record (at://) or a URL (https://) 135 - if (siteValue.startsWith('at://')) { 136 - // It's a publication URI 137 - publication = publicationsMap.get(siteValue); 138 - publicationRkey = siteValue.split('/').pop(); 139 - 140 - // Build URL from publication base URL + document path 141 - if (publication) { 142 - const basePath = publication.url.endsWith('/') 143 - ? publication.url.slice(0, -1) 144 - : publication.url; 145 - const docPath = docValue.path || `/${rkey}`; 146 - url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 147 - } else { 148 - // Fallback if publication not found 149 - url = `${siteValue}${docValue.path || '/' + rkey}`; 150 - } 151 - } else { 152 - // It's a loose document with a direct URL 153 - const basePath = siteValue.endsWith('/') ? siteValue.slice(0, -1) : siteValue; 154 - const docPath = docValue.path || `/${rkey}`; 155 - url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 156 - } 157 - 158 - documents.push({ 159 - title: docValue.title || 'Untitled Document', 160 - rkey, 161 - uri: docRecord.uri, 162 - url, 163 - site: docValue.site, 164 - path: docValue.path, 165 - description: docValue.description, 166 - coverImage: docValue.coverImage 167 - ? await getBlobUrl(docValue.coverImage, fetchFn) 168 - : undefined, 169 - content: docValue.content, 170 - textContent: docValue.textContent, 171 - bskyPostRef: docValue.bskyPostRef, 172 - tags: docValue.tags, 173 - publishedAt: docValue.publishedAt, 174 - updatedAt: docValue.updatedAt, 175 - publicationName: publication?.name, 176 - publicationRkey 177 - }); 178 - } 179 - 180 - // Sort by publishedAt (newest first) 181 - documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); 182 - 183 - const data: StandardSiteDocumentsData = { documents }; 184 - cache.set(cacheKey, data); 185 - return data; 186 - } catch (error) { 187 - console.warn('Failed to fetch Standard.site documents:', error); 188 - return { documents: [] }; 189 - } 190 - } 191 - 192 - /** 193 - * Helper function to get a blob URL for Standard.site publication icons and document cover images 194 - */ 195 - async function getBlobUrl(blob: any, fetchFn?: typeof fetch): Promise<string | undefined> { 196 - try { 197 - const cid = blob.ref?.$link || blob.cid; 198 - if (!cid) return undefined; 199 - 200 - const resolved = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 201 - return buildPdsBlobUrl(resolved.pds, PUBLIC_ATPROTO_DID, cid); 202 - } catch (error) { 203 - console.warn('Failed to resolve blob URL:', error); 204 - return undefined; 205 - } 206 - } 1 + // standard.ts is superseded by documents.ts — re-export everything for backwards compat. 2 + export { 3 + fetchPublications as fetchStandardSitePublications, 4 + fetchDocuments as fetchStandardSiteDocuments, 5 + fetchRecentDocuments as fetchRecentStandardSiteDocuments, 6 + fetchBlogPosts 7 + } from './documents'; 8 + export type { StandardSitePublication, StandardSitePublicationsData, StandardSiteDocument, StandardSiteDocumentsData } from './documents';
+36 -284
src/lib/services/atproto/types.ts
··· 1 - /** 2 - * Type definitions for AT Protocol services 3 - */ 4 - 5 - export interface ProfileData { 6 - did: string; 7 - handle: string; 8 - displayName?: string; 9 - description?: string; 10 - avatar?: string; 11 - banner?: string; 12 - followersCount?: number; 13 - followsCount?: number; 14 - postsCount?: number; 15 - pronouns?: string; 16 - } 1 + // Re-export all types from @ewanc26/atproto. 2 + // The app's service wrappers use these directly. 3 + export type { 4 + ProfileData, 5 + SiteInfoData, 6 + LinkData, 7 + LinkCard, 8 + BlueskyPost, 9 + BlogPost, 10 + PostAuthor, 11 + ExternalLink, 12 + Facet, 13 + Technology, 14 + License, 15 + BasedOnItem, 16 + RelatedService, 17 + Repository, 18 + Credit, 19 + SectionLicense, 20 + ResolvedIdentity, 21 + CacheEntry, 22 + MusicStatusData, 23 + MusicArtist, 24 + KibunStatusData, 25 + TangledRepo, 26 + TangledReposData, 27 + StandardSitePublication, 28 + StandardSitePublicationsData, 29 + StandardSiteDocument, 30 + StandardSiteDocumentsData, 31 + StandardSiteBasicTheme, 32 + StandardSiteThemeColor 33 + } from '@ewanc26/atproto'; 17 34 35 + // StatusData is app-local (not in the package) — keep it here. 18 36 export interface StatusData { 19 37 text: string; 20 38 createdAt: string; 21 39 } 22 40 23 - export interface Technology { 24 - name: string; 25 - url?: string; 26 - description?: string; 27 - } 28 - 29 - export interface License { 30 - name: string; 31 - url?: string; 32 - } 33 - 34 - export interface BasedOnItem { 35 - section?: string; 36 - name?: string; 37 - url?: string; 38 - description?: string; 39 - type?: string; 40 - } 41 - 42 - export interface RelatedService { 43 - section?: string; 44 - name?: string; 45 - url?: string; 46 - description?: string; 47 - relationship?: string; 48 - } 49 - 50 - export interface Repository { 51 - platform?: string; 52 - url: string; 53 - type?: string; 54 - description?: string; 55 - } 56 - 57 - export interface Credit { 58 - section?: string; 59 - name?: string; 60 - type: string; 61 - url?: string; 62 - author?: string; 63 - license?: License; 64 - description?: string; 65 - } 66 - 67 - export interface SectionLicense { 68 - section?: string; 69 - name?: string; 70 - url?: string; 71 - } 72 - 73 - export interface SiteInfoData { 74 - technologyStack?: Technology[]; 75 - privacyStatement?: string; 76 - openSourceInfo?: { 77 - description?: string; 78 - license?: License; 79 - basedOn?: BasedOnItem[]; 80 - relatedServices?: RelatedService[]; 81 - repositories?: Repository[]; 82 - }; 83 - credits?: Credit[]; 84 - additionalInfo?: { 85 - websiteBirthYear?: number; 86 - purpose?: string; 87 - sectionLicense?: SectionLicense[]; 88 - }; 89 - } 90 - 91 - export interface LinkCard { 92 - url: string; 93 - text: string; 94 - emoji: string; 95 - } 96 - 97 - export interface LinkData { 98 - cards: LinkCard[]; 99 - } 100 - 101 - export interface BlogPost { 102 - title: string; 103 - url: string; 104 - createdAt: string; 105 - platform: 'standard.site'; 106 - description?: string; 107 - rkey: string; 108 - publicationName?: string; 109 - publicationRkey?: string; 110 - tags?: string[]; 111 - // Standard.site specific fields 112 - coverImage?: string; 113 - textContent?: string; 114 - updatedAt?: string; 115 - } 116 - 41 + // BlogPostsData is also app-local. 117 42 export interface BlogPostsData { 118 - posts: BlogPost[]; 119 - } 120 - 121 - export interface Facet { 122 - index: { 123 - byteStart: number; 124 - byteEnd: number; 125 - }; 126 - features: Array<{ 127 - $type: string; 128 - uri?: string; 129 - did?: string; 130 - tag?: string; 131 - }>; 132 - } 133 - 134 - export interface ExternalLink { 135 - uri: string; 136 - title: string; 137 - description?: string; 138 - thumb?: string; 139 - } 140 - 141 - export interface PostAuthor { 142 - did: string; 143 - handle: string; 144 - displayName?: string; 145 - avatar?: string; 146 - pronouns?: string; 147 - } 148 - 149 - export interface BlueskyPost { 150 - text: string; 151 - createdAt: string; 152 - uri: string; 153 - author: PostAuthor; 154 - likeCount?: number; 155 - repostCount?: number; 156 - replyCount?: number; 157 - hasImages: boolean; 158 - imageUrls?: string[]; 159 - imageAlts?: string[]; 160 - hasVideo?: boolean; 161 - videoUrl?: string; 162 - videoThumbnail?: string; 163 - quotedPostUri?: string; 164 - quotedPost?: BlueskyPost; 165 - facets?: Facet[]; 166 - externalLink?: ExternalLink; 167 - // Reply context 168 - replyParent?: BlueskyPost; 169 - replyRoot?: BlueskyPost; 170 - // Repost context 171 - isRepost?: boolean; 172 - repostAuthor?: PostAuthor; 173 - repostCreatedAt?: string; 174 - originalPost?: BlueskyPost; 175 - } 176 - 177 - export interface ResolvedIdentity { 178 - did: string; 179 - pds: string; 180 - } 181 - 182 - export interface CacheEntry<T> { 183 - data: T; 184 - timestamp: number; 185 - } 186 - 187 - export interface MusicArtist { 188 - artistName: string; 189 - artistMbId?: string; 190 - } 191 - 192 - export interface MusicStatusData { 193 - trackName: string; 194 - artists: MusicArtist[]; 195 - releaseName?: string; 196 - playedTime: string; 197 - originUrl?: string; 198 - recordingMbId?: string; 199 - releaseMbId?: string; 200 - isrc?: string; 201 - duration?: number; 202 - musicServiceBaseDomain?: string; 203 - submissionClientAgent?: string; 204 - $type: 'fm.teal.alpha.actor.status' | 'fm.teal.alpha.feed.play'; 205 - expiry?: string; 206 - artwork?: { 207 - ref?: { $link: string }; 208 - mimeType?: string; 209 - size?: number; 210 - }; 211 - artworkUrl?: string; // Computed URL for display 212 - } 213 - 214 - export interface KibunStatusData { 215 - text: string; 216 - emoji: string; 217 - createdAt: string; 218 - $type: 'social.kibun.status'; 219 - } 220 - 221 - export interface TangledRepo { 222 - uri: string; 223 - name: string; 224 - description?: string; 225 - knot: string; 226 - createdAt: string; 227 - labels?: string[]; 228 - source?: string; 229 - spindle?: string; 230 - } 231 - 232 - export interface TangledReposData { 233 - repos: TangledRepo[]; 234 - } 235 - 236 - // Standard.site types 237 - export interface StandardSiteThemeColor { 238 - r: number; 239 - g: number; 240 - b: number; 241 - a?: number; 242 - } 243 - 244 - export interface StandardSiteBasicTheme { 245 - background: StandardSiteThemeColor; 246 - foreground: StandardSiteThemeColor; 247 - accent: StandardSiteThemeColor; 248 - accentForeground: StandardSiteThemeColor; 249 - } 250 - 251 - export interface StandardSitePublication { 252 - name: string; 253 - rkey: string; 254 - uri: string; 255 - url: string; 256 - description?: string; 257 - icon?: string; 258 - basicTheme?: StandardSiteBasicTheme; 259 - preferences?: { 260 - showInDiscover?: boolean; 261 - }; 262 - } 263 - 264 - export interface StandardSitePublicationsData { 265 - publications: StandardSitePublication[]; 266 - } 267 - 268 - export interface StandardSiteDocument { 269 - title: string; 270 - rkey: string; 271 - uri: string; 272 - url: string; 273 - site: string; 274 - path?: string; 275 - description?: string; 276 - coverImage?: string; 277 - content?: any; 278 - textContent?: string; 279 - bskyPostRef?: { 280 - uri: string; 281 - cid: string; 282 - }; 283 - tags?: string[]; 284 - publishedAt: string; 285 - updatedAt?: string; 286 - publicationName?: string; 287 - publicationRkey?: string; 288 - } 289 - 290 - export interface StandardSiteDocumentsData { 291 - documents: StandardSiteDocument[]; 43 + posts: import('@ewanc26/atproto').BlogPost[]; 292 44 }
+2 -52
src/lib/stores/colorTheme.ts
··· 1 - import { writable } from 'svelte/store'; 2 - import { browser } from '$app/environment'; 3 - import { DEFAULT_THEME, type ColorTheme } from '$lib/config/themes.config'; 4 - 5 - interface ColorThemeState { 6 - current: ColorTheme; 7 - mounted: boolean; 8 - } 9 - 10 - const STORAGE_KEY = 'color-theme'; 11 - 12 - function createColorThemeStore() { 13 - const { subscribe, set, update } = writable<ColorThemeState>({ 14 - current: DEFAULT_THEME, 15 - mounted: false 16 - }); 17 - 18 - return { 19 - subscribe, 20 - init: () => { 21 - if (!browser) return; 22 - 23 - const stored = localStorage.getItem(STORAGE_KEY) as ColorTheme | null; 24 - const theme = stored || DEFAULT_THEME; 25 - 26 - update((state) => ({ ...state, current: theme, mounted: true })); 27 - 28 - // Only apply theme if not already applied (to prevent flash) 29 - const currentTheme = document.documentElement.getAttribute('data-color-theme'); 30 - if (currentTheme !== theme) { 31 - applyTheme(theme); 32 - } 33 - }, 34 - setTheme: (theme: ColorTheme) => { 35 - if (!browser) return; 36 - 37 - localStorage.setItem(STORAGE_KEY, theme); 38 - update((state) => ({ ...state, current: theme })); 39 - applyTheme(theme); 40 - } 41 - }; 42 - } 43 - 44 - function applyTheme(theme: ColorTheme) { 45 - if (!browser) return; 46 - 47 - const root = document.documentElement; 48 - root.setAttribute('data-color-theme', theme); 49 - } 50 - 51 - export const colorTheme = createColorThemeStore(); 52 - export type { ColorTheme }; 1 + export { colorTheme } from '@ewanc26/ui'; 2 + export type { ColorTheme } from '@ewanc26/ui';
+1 -3
src/lib/stores/dropdownState.ts
··· 1 - import { writable } from 'svelte/store'; 2 - 3 - export const colorThemeDropdownOpen = writable(false); 1 + export { colorThemeDropdownOpen } from '@ewanc26/ui';
+1 -29
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 - // Trigger when reaching 24 clicks (Mac announcement date: 24/01/1984) 20 - if (newCount === 24) { 21 - return { clickCount: newCount, isTriggered: true }; 22 - } 23 - return { ...state, clickCount: newCount }; 24 - }), 25 - reset: () => set({ clickCount: 0, isTriggered: false }) 26 - }; 27 - } 28 - 29 - export const happyMacStore = createHappyMacStore(); 1 + export { happyMacStore } from '@ewanc26/ui';
+2
src/lib/stores/index.ts
··· 1 1 export { wolfMode } from './wolfMode'; 2 2 export { colorThemeDropdownOpen } from './dropdownState'; 3 3 export { happyMacStore } from './happyMac'; 4 + export { colorTheme } from './colorTheme'; 5 + export type { ColorTheme } from './colorTheme';
+1 -232
src/lib/stores/wolfMode.ts
··· 1 - import { writable } from 'svelte/store'; 2 - import { browser } from '$app/environment'; 3 - 4 - // Refined English onomatopoeic wolf/canine sounds 5 - const wolfSounds = [ 6 - 'awoo', 7 - 'awooo', 8 - 'howl', 9 - 'ahroo', 10 - 'owww', 11 - 'yip', 12 - 'yap', 13 - 'arf', 14 - 'ruff', 15 - 'woof', 16 - 'grr', 17 - 'grrr', 18 - 'growl', 19 - 'snarl', 20 - 'whine', 21 - 'whimper', 22 - 'bark', 23 - 'yowl', 24 - 'yelp', 25 - 'huff' 26 - ]; 27 - 28 - // Store original text content 29 - let originalTexts = new Map<Node, string>(); 30 - let wordCounter = 0; 31 - let wordToSoundMap = new Map<string, string>(); 32 - 33 - function createWolfModeStore() { 34 - const { subscribe, set, update } = writable(false); 35 - 36 - return { 37 - subscribe, 38 - toggle: () => { 39 - update((value) => { 40 - const newValue = !value; 41 - if (browser) { 42 - if (newValue) { 43 - enableWolfMode(); 44 - } else { 45 - disableWolfMode(); 46 - } 47 - } 48 - return newValue; 49 - }); 50 - }, 51 - enable: () => { 52 - set(true); 53 - if (browser) enableWolfMode(); 54 - }, 55 - disable: () => { 56 - set(false); 57 - if (browser) disableWolfMode(); 58 - } 59 - }; 60 - } 61 - 62 - function getWolfSoundByPosition(position: number): string { 63 - // Use modulo to cycle through wolf sounds based on word position 64 - return wolfSounds[position % wolfSounds.length]; 65 - } 66 - 67 - function getWolfSoundForWord(word: string, position: number): string { 68 - // Normalize the word to lowercase for consistent mapping 69 - const normalizedWord = word.toLowerCase(); 70 - 71 - // If we've seen this word before, return the same sound 72 - if (wordToSoundMap.has(normalizedWord)) { 73 - return wordToSoundMap.get(normalizedWord)!; 74 - } 75 - 76 - // Otherwise, assign a new sound based on position and store it 77 - const wolfSound = getWolfSoundByPosition(position); 78 - wordToSoundMap.set(normalizedWord, wolfSound); 79 - return wolfSound; 80 - } 81 - 82 - function isNumberAbbreviation(text: string): boolean { 83 - // Check for number abbreviations like 1K, 2M, 3B, 1d, 30s, 2h, etc. 84 - // Pattern: starts with digits, optionally has decimals, ends with letter abbreviation 85 - return /^\d+\.?\d*[a-zA-Z]+$/.test(text); 86 - } 87 - 88 - function hasAlphabeticalCharacters(text: string): boolean { 89 - return /[a-zA-Z]/.test(text); 90 - } 91 - 92 - function shouldTransform(word: string): boolean { 93 - // Don't transform if it's purely non-alphabetical 94 - if (!hasAlphabeticalCharacters(word)) { 95 - return false; 96 - } 97 - 98 - // Don't transform if it's a number abbreviation 99 - if (isNumberAbbreviation(word)) { 100 - return false; 101 - } 102 - 103 - return true; 104 - } 105 - 106 - function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 107 - // Match leading punctuation, word, and trailing punctuation 108 - const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 109 - 110 - if (match) { 111 - return { 112 - prefix: match[1], 113 - word: match[2], 114 - suffix: match[3] 115 - }; 116 - } 117 - 118 - // If no match, treat entire token as word 119 - return { 120 - prefix: '', 121 - word: token, 122 - suffix: '' 123 - }; 124 - } 125 - 126 - function convertToWolfSpeak(text: string, startPosition: number): string { 127 - // Split by words and replace each with a wolf sound 128 - const words = text.split(/(\s+)/); // Keep whitespace 129 - let currentPosition = startPosition; 130 - 131 - return words 132 - .map((token) => { 133 - if (token.trim().length === 0) { 134 - return token; // Preserve whitespace 135 - } 136 - 137 - // Split word from surrounding punctuation 138 - const { prefix, word, suffix } = splitWordAndPunctuation(token); 139 - 140 - // Only transform words that should be transformed 141 - if (!shouldTransform(word)) { 142 - return token; // Keep numbers, abbreviations, punctuation, etc. as-is 143 - } 144 - 145 - const wolfSound = getWolfSoundForWord(word, currentPosition); 146 - currentPosition++; 147 - 148 - // Apply capitalization pattern to the wolf sound 149 - let transformedWord = wolfSound; 150 - if (word === word.toUpperCase() && word.length > 1) { 151 - transformedWord = wolfSound.toUpperCase(); 152 - } else if (word[0] === word[0].toUpperCase()) { 153 - transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 154 - } 155 - 156 - // Reconstruct with original punctuation 157 - return prefix + transformedWord + suffix; 158 - }) 159 - .join(''); 160 - } 161 - 162 - function shouldSkipElement(element: Element): boolean { 163 - // Skip navigation buttons, specifically the wolf and theme toggles 164 - if (element.hasAttribute('aria-label')) { 165 - const label = element.getAttribute('aria-label') || ''; 166 - if (label.includes('wolf mode') || label.includes('theme') || label.includes('mode')) { 167 - return true; 168 - } 169 - } 170 - 171 - // Skip buttons in the header navigation 172 - if (element.closest('header button')) { 173 - return true; 174 - } 175 - 176 - // Skip nav elements 177 - if (element.tagName === 'NAV' || element.closest('nav')) { 178 - return true; 179 - } 180 - 181 - return false; 182 - } 183 - 184 - function walkTextNodes(node: Node, callback: (textNode: Text) => void) { 185 - if (node.nodeType === Node.TEXT_NODE) { 186 - callback(node as Text); 187 - } else if (node.nodeType === Node.ELEMENT_NODE) { 188 - const element = node as Element; 189 - 190 - // Skip script, style tags, and navigation elements 191 - if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) { 192 - return; 193 - } 194 - 195 - for (const child of Array.from(node.childNodes)) { 196 - walkTextNodes(child, callback); 197 - } 198 - } 199 - } 200 - 201 - function enableWolfMode() { 202 - originalTexts.clear(); 203 - wordToSoundMap.clear(); 204 - wordCounter = 0; 205 - 206 - walkTextNodes(document.body, (textNode) => { 207 - const originalText = textNode.textContent || ''; 208 - if (originalText.trim().length > 0) { 209 - originalTexts.set(textNode, originalText); 210 - const transformedText = convertToWolfSpeak(originalText, wordCounter); 211 - textNode.textContent = transformedText; 212 - // Update counter based on number of transformable words processed 213 - wordCounter += originalText.split(/\s+/).filter((w) => { 214 - const { word } = splitWordAndPunctuation(w); 215 - return shouldTransform(word); 216 - }).length; 217 - } 218 - }); 219 - } 220 - 221 - function disableWolfMode() { 222 - originalTexts.forEach((originalText, textNode) => { 223 - if (textNode.parentNode) { 224 - textNode.textContent = originalText; 225 - } 226 - }); 227 - originalTexts.clear(); 228 - wordToSoundMap.clear(); 229 - wordCounter = 0; 230 - } 231 - 232 - export const wolfMode = createWolfModeStore(); 1 + export { wolfMode } from '@ewanc26/ui';
+1 -26
src/lib/utils/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 - // Prefer system locale, fallback to en-GB 19 - const userLocale = typeof navigator !== 'undefined' ? navigator.language : 'en-GB'; 20 - 21 - return date.toLocaleDateString(userLocale, { 22 - day: 'numeric', 23 - month: 'short', 24 - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 25 - }); 26 - } 1 + export { formatRelativeTime } from '@ewanc26/utils';
+1 -58
src/lib/utils/formatNumber.ts
··· 1 - /** 2 - * Number formatting utilities 3 - */ 4 - 5 - /** 6 - * Determines the effective locale, preferring system locale with fallback to 'en-GB'. 7 - */ 8 - function getLocale(locale?: string): string { 9 - return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; 10 - } 11 - 12 - /** 13 - * Formats large numbers into a compact, human-readable format. 14 - * Automatically adapts to the given or system locale. 15 - * Numbers are rounded DOWN to ensure stats don't appear inflated. 16 - * @param num - The number to format 17 - * @param locale - Optional locale string (defaults to system or 'en-GB') 18 - * @returns Formatted string (e.g., "1.2K", "3.4M") 19 - */ 20 - export function formatCompactNumber(num?: number, locale?: string): string { 21 - if (num === undefined || num === null) return '0'; 22 - const effectiveLocale = getLocale(locale); 23 - 24 - // For numbers >= 1000, round down to one decimal place for the compact format 25 - if (num >= 1000) { 26 - // Determine the divisor (1000 for K, 1000000 for M, etc.) 27 - const divisor = num >= 1000000000 ? 1000000000 : num >= 1000000 ? 1000000 : 1000; 28 - // Floor to one decimal place: floor(num / divisor * 10) / 10 29 - const roundedDown = Math.floor((num / divisor) * 10) / 10; 30 - // Re-multiply to get the actual number to format 31 - const adjustedNum = roundedDown * divisor; 32 - 33 - return new Intl.NumberFormat(effectiveLocale, { 34 - notation: 'compact', 35 - compactDisplay: 'short', 36 - maximumFractionDigits: 1 37 - }).format(adjustedNum); 38 - } 39 - 40 - // For numbers < 1000, just return as-is 41 - return new Intl.NumberFormat(effectiveLocale, { 42 - notation: 'compact', 43 - compactDisplay: 'short', 44 - maximumFractionDigits: 1 45 - }).format(num); 46 - } 47 - 48 - /** 49 - * Formats a number with thousand separators. 50 - * Automatically adapts to the given or system locale. 51 - * @param num - The number to format 52 - * @param locale - Optional locale string (defaults to system or 'en-GB') 53 - * @returns Formatted string (e.g., "1,234,567") 54 - */ 55 - export function formatNumber(num: number, locale?: string): string { 56 - const effectiveLocale = getLocale(locale); 57 - return new Intl.NumberFormat(effectiveLocale).format(num); 58 - } 1 + export { formatCompactNumber, formatNumber } from '@ewanc26/utils';
+1 -50
src/lib/utils/locale.ts
··· 1 - /** 2 - * Gets the user's locale with fallback to en-GB 3 - */ 4 - export function getUserLocale(): string { 5 - if (typeof navigator !== 'undefined') { 6 - return navigator.language || 'en-GB'; 7 - } 8 - return 'en-GB'; 9 - } 10 - 11 - /** 12 - * Formats a date string into a localized date format 13 - */ 14 - export function formatLocalizedDate(dateString: string, locale?: string): string { 15 - const date = new Date(dateString); 16 - const userLocale = locale || getUserLocale(); 17 - 18 - return date.toLocaleDateString(userLocale, { 19 - month: 'short', 20 - day: 'numeric', 21 - year: 'numeric' 22 - }); 23 - } 24 - 25 - /** 26 - * Formats a date string into a relative, human-readable time. 27 - * Uses the user's system locale where possible, with a fallback to en-GB. 28 - */ 29 - export function formatRelativeTime(dateString: string): string { 30 - const date = new Date(dateString); 31 - const now = new Date(); 32 - const diffMs = now.getTime() - date.getTime(); 33 - const diffMins = Math.floor(diffMs / 60000); 34 - const diffHours = Math.floor(diffMins / 60); 35 - const diffDays = Math.floor(diffHours / 24); 36 - 37 - if (diffMins < 1) return 'just now'; 38 - if (diffMins < 60) return `${diffMins}m ago`; 39 - if (diffHours < 24) return `${diffHours}h ago`; 40 - if (diffDays < 7) return `${diffDays}d ago`; 41 - 42 - // Prefer system locale, fallback to en-GB 43 - const userLocale = getUserLocale(); 44 - 45 - return date.toLocaleDateString(userLocale, { 46 - day: 'numeric', 47 - month: 'short', 48 - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 49 - }); 50 - } 1 + export { getUserLocale, formatLocalizedDate, formatRelativeTime } from '@ewanc26/utils';
+10 -201
src/lib/utils/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 - /** 40 - * Escape XML special characters (minimal escaping for UTF-8 RSS feeds) 41 - * Only escapes characters that MUST be escaped in XML 42 - */ 43 - export function escapeXml(unsafe: string): string { 44 - return unsafe 45 - .replace(/&/g, '&amp;') 46 - .replace(/</g, '&lt;') 47 - .replace(/>/g, '&gt;'); 48 - } 49 - 50 - /** 51 - * Escape XML attributes (includes quotes) 52 - */ 53 - export function escapeXmlAttribute(unsafe: string): string { 54 - return unsafe 55 - .replace(/&/g, '&amp;') 56 - .replace(/</g, '&lt;') 57 - .replace(/>/g, '&gt;') 58 - .replace(/"/g, '&quot;'); 59 - } 60 - 61 - /** 62 - * Normalize special characters to their UTF-8 equivalents 63 - */ 64 - export function normalizeCharacters(text: string): string { 65 - return text 66 - // Smart quotes 67 - .replace(/\u2018|\u2019|\u201A|\u201B/g, "'") 68 - .replace(/\u201C|\u201D|\u201E|\u201F/g, '"') 69 - // Em and en dashes 70 - .replace(/\u2013/g, '-') 71 - .replace(/\u2014/g, '--') 72 - // Other special spaces and characters 73 - .replace(/\u00A0/g, ' ') // non-breaking space 74 - .replace(/\u2026/g, '...') // ellipsis 75 - .replace(/\u2022/g, '*') // bullet 76 - // HTML entities that might have been left in 77 - .replace(/&apos;/g, "'") 78 - .replace(/&quot;/g, '"') 79 - .replace(/&nbsp;/g, ' ') 80 - .replace(/&mdash;/g, '--') 81 - .replace(/&ndash;/g, '-') 82 - .replace(/&hellip;/g, '...') 83 - .replace(/&rsquo;/g, "'") 84 - .replace(/&lsquo;/g, "'") 85 - .replace(/&rdquo;/g, '"') 86 - .replace(/&ldquo;/g, '"'); 87 - } 88 - 89 - /** 90 - * Format a date for RSS (RFC 822 format) 91 - */ 92 - export function formatRSSDate(date: Date | string): string { 93 - const d = typeof date === 'string' ? new Date(date) : date; 94 - return d.toUTCString(); 95 - } 96 - 97 - /** 98 - * Generate an RSS item XML string 99 - */ 100 - export function generateRSSItem(item: RSSItem): string { 101 - const guid = item.guid || item.link; 102 - const pubDate = formatRSSDate(item.pubDate); 103 - 104 - // Normalize and escape text content 105 - const title = escapeXml(normalizeCharacters(item.title)); 106 - const description = item.description ? escapeXml(normalizeCharacters(item.description)) : ''; 107 - const content = item.content ? normalizeCharacters(item.content) : ''; 108 - const author = item.author ? escapeXml(normalizeCharacters(item.author)) : ''; 109 - 110 - const categories = 111 - item.categories?.map((cat) => ` <category>${escapeXml(normalizeCharacters(cat))}</category>`).join('\n') || ''; 112 - 113 - let enclosure = ''; 114 - if (item.enclosure) { 115 - const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : ''; 116 - const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : ''; 117 - enclosure = ` <enclosure url="${escapeXmlAttribute(item.enclosure.url)}"${length}${type} />`; 118 - } 119 - 120 - let source = ''; 121 - if (item.source) { 122 - source = ` <source url="${escapeXmlAttribute(item.source.url)}">${escapeXml(normalizeCharacters(item.source.title))}</source>`; 123 - } 124 - 125 - return ` <item> 126 - <title>${title}</title> 127 - <link>${escapeXmlAttribute(item.link)}</link> 128 - <guid isPermaLink="true">${escapeXmlAttribute(guid)}</guid> 129 - <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}` : ''} 130 - </item>`; 131 - } 132 - 133 - /** 134 - * Generate a complete RSS 2.0 feed 135 - */ 136 - export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string { 137 - const language = config.language || 'en'; 138 - const generator = config.generator || 'SvelteKit with AT Protocol'; 139 - const lastBuildDate = formatRSSDate(new Date()); 140 - 141 - // Normalize and escape channel data 142 - const title = escapeXml(normalizeCharacters(config.title)); 143 - const link = escapeXmlAttribute(config.link); 144 - const description = escapeXml(normalizeCharacters(config.description)); 145 - const generatorText = escapeXml(normalizeCharacters(generator)); 146 - 147 - const atomLink = config.selfLink 148 - ? ` <atom:link href="${escapeXmlAttribute(config.selfLink)}" rel="self" type="application/rss+xml" />` 149 - : ''; 150 - 151 - const optionalFields = []; 152 - if (config.copyright) { 153 - optionalFields.push(` <copyright>${escapeXml(normalizeCharacters(config.copyright))}</copyright>`); 154 - } 155 - if (config.managingEditor) { 156 - optionalFields.push(` <managingEditor>${escapeXml(normalizeCharacters(config.managingEditor))}</managingEditor>`); 157 - } 158 - if (config.webMaster) { 159 - optionalFields.push(` <webMaster>${escapeXml(normalizeCharacters(config.webMaster))}</webMaster>`); 160 - } 161 - if (config.ttl) { 162 - optionalFields.push(` <ttl>${config.ttl}</ttl>`); 163 - } 164 - 165 - const itemsXml = items.map((item) => generateRSSItem(item)).join('\n'); 166 - 167 - return `<?xml version="1.0" encoding="UTF-8"?> 168 - <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 169 - <channel> 170 - <title>${title}</title> 171 - <link>${link}</link> 172 - <description>${description}</description> 173 - <language>${language}</language>${atomLink ? `\n${atomLink}` : ''} 174 - <lastBuildDate>${lastBuildDate}</lastBuildDate> 175 - <generator>${generatorText}</generator>${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''} 176 - ${itemsXml} 177 - </channel> 178 - </rss>`; 179 - } 180 - 181 - /** 182 - * Create an RSS Response object ready to be returned from a SvelteKit endpoint 183 - */ 184 - export function createRSSResponse( 185 - feed: string, 186 - options?: { 187 - cacheMaxAge?: number; 188 - status?: number; 189 - } 190 - ): Response { 191 - const cacheMaxAge = options?.cacheMaxAge ?? 3600; 192 - const status = options?.status ?? 200; 193 - 194 - return new Response(feed, { 195 - status, 196 - headers: { 197 - 'Content-Type': 'application/rss+xml; charset=utf-8', 198 - 'Cache-Control': `public, max-age=${cacheMaxAge}` 199 - } 200 - }); 201 - } 1 + export type { RSSChannelConfig, RSSItem } from '@ewanc26/utils'; 2 + export { 3 + escapeXml, 4 + escapeXmlAttribute, 5 + normalizeCharacters, 6 + formatRSSDate, 7 + generateRSSItem, 8 + generateRSSFeed, 9 + createRSSResponse 10 + } from '@ewanc26/utils';
+1 -54
src/lib/utils/url.ts
··· 1 - /** 2 - * URL and domain utilities 3 - */ 4 - 5 - /** 6 - * Extracts the domain name from a URL 7 - * @param url - The URL to extract from 8 - * @returns The domain name without www prefix 9 - */ 10 - export function getDomain(url: string): string { 11 - try { 12 - const urlObj = new URL(url); 13 - return urlObj.hostname.replace('www.', ''); 14 - } catch { 15 - return ''; 16 - } 17 - } 18 - 19 - /** 20 - * Converts an AT Protocol URI to a Bluesky web URL 21 - * @param uri - AT URI format (at://did:plc:xxx/app.bsky.feed.post/rkey) 22 - * @returns Bluesky web URL 23 - */ 24 - export function atUriToBlueskyUrl(uri: string): string { 25 - const parts = uri.split('/'); 26 - const did = parts[2]; 27 - const rkey = parts[4]; 28 - return `https://witchsky.app/profile/${did}/post/${rkey}`; 29 - } 30 - 31 - /** 32 - * Gets a Bluesky profile URL from a handle or DID 33 - * @param actor - Handle (e.g., "user.bsky.social") or DID 34 - * @returns Bluesky profile URL 35 - */ 36 - export function getBlueskyProfileUrl(actor: string): string { 37 - return `https://witchsky.app/profile/${actor}`; 38 - } 39 - 40 - /** 41 - * Checks if a URL is external (not same origin) 42 - * @param url - The URL to check 43 - * @returns True if external, false otherwise 44 - */ 45 - export function isExternalUrl(url: string): boolean { 46 - if (typeof window === 'undefined') return true; 47 - 48 - try { 49 - const urlObj = new URL(url, window.location.href); 50 - return urlObj.origin !== window.location.origin; 51 - } catch { 52 - return false; 53 - } 54 - } 1 + export { getDomain, atUriToBlueskyUrl, getBlueskyProfileUrl, isExternalUrl } from '@ewanc26/utils';
+9 -107
src/lib/utils/validators.ts
··· 1 - /** 2 - * Validation and text processing utilities 3 - */ 4 - 5 - /** 6 - * Validates AT Protocol TID (Timestamp Identifier) format 7 - * @param tid - The TID to validate 8 - * @returns True if valid TID format 9 - */ 10 - export function isValidTid(tid: string): boolean { 11 - // TIDs are base32-encoded timestamps, 12-16 alphanumeric characters 12 - const tidPattern = /^[a-zA-Z0-9]{12,16}$/; 13 - return tidPattern.test(tid); 14 - } 15 - 16 - /** 17 - * Validates AT Protocol DID format 18 - * @param did - The DID to validate 19 - * @returns True if valid DID format 20 - */ 21 - export function isValidDid(did: string): boolean { 22 - // DID format: did:method:identifier 23 - const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:-]+$/; 24 - return didPattern.test(did); 25 - } 26 - 27 - /** 28 - * Truncates text to a specified length with ellipsis 29 - * @param text - The text to truncate 30 - * @param maxLength - Maximum length before truncation 31 - * @param ellipsis - String to append when truncated (default: "...") 32 - * @returns Truncated text 33 - */ 34 - export function truncateText(text: string, maxLength: number, ellipsis = '...'): string { 35 - if (text.length <= maxLength) return text; 36 - return text.slice(0, maxLength - ellipsis.length).trim() + ellipsis; 37 - } 38 - 39 - /** 40 - * Safely escapes HTML to prevent XSS attacks 41 - * @param text - The text to escape 42 - * @returns HTML-safe text 43 - */ 44 - export function escapeHtml(text: string): string { 45 - const div = typeof document !== 'undefined' ? document.createElement('div') : null; 46 - if (div) { 47 - div.textContent = text; 48 - return div.innerHTML; 49 - } 50 - // Server-side fallback 51 - return text 52 - .replace(/&/g, '&amp;') 53 - .replace(/</g, '&lt;') 54 - .replace(/>/g, '&gt;') 55 - .replace(/"/g, '&quot;') 56 - .replace(/'/g, '&#039;'); 57 - } 58 - 59 - /** 60 - * Generates initials from a name (max 2 characters) 61 - * @param name - The name to generate initials from 62 - * @returns Uppercase initials 63 - */ 64 - export function getInitials(name: string): string { 65 - const words = name.trim().split(/\s+/); 66 - if (words.length === 1) { 67 - return words[0].charAt(0).toUpperCase(); 68 - } 69 - return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase(); 70 - } 71 - 72 - /** 73 - * Debounces a function call 74 - * @param func - The function to debounce 75 - * @param delay - Delay in milliseconds 76 - * @returns Debounced function 77 - */ 78 - export function debounce<T extends (...args: any[]) => any>( 79 - func: T, 80 - delay: number 81 - ): (...args: Parameters<T>) => void { 82 - let timeoutId: ReturnType<typeof setTimeout>; 83 - return (...args: Parameters<T>) => { 84 - clearTimeout(timeoutId); 85 - timeoutId = setTimeout(() => func(...args), delay); 86 - }; 87 - } 88 - 89 - /** 90 - * Throttles a function call 91 - * @param func - The function to throttle 92 - * @param limit - Time limit in milliseconds 93 - * @returns Throttled function 94 - */ 95 - export function throttle<T extends (...args: any[]) => any>( 96 - func: T, 97 - limit: number 98 - ): (...args: Parameters<T>) => void { 99 - let inThrottle: boolean; 100 - return (...args: Parameters<T>) => { 101 - if (!inThrottle) { 102 - func(...args); 103 - inThrottle = true; 104 - setTimeout(() => (inThrottle = false), limit); 105 - } 106 - }; 107 - } 1 + export { 2 + isValidTid, 3 + isValidDid, 4 + truncateText, 5 + escapeHtml, 6 + getInitials, 7 + debounce, 8 + throttle 9 + } from '@ewanc26/utils';
+18 -1
vite.config.ts
··· 1 1 import tailwindcss from '@tailwindcss/vite'; 2 2 import { sveltekit } from '@sveltejs/kit/vite'; 3 3 import { defineConfig } from 'vite'; 4 + import path from 'path'; 5 + import { fileURLToPath } from 'url'; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const pkg = (p: string) => path.resolve(__dirname, 'packages', p); 4 9 5 10 export default defineConfig({ 6 11 plugins: [tailwindcss(), sveltekit()], 12 + 13 + resolve: { 14 + // Point workspace packages directly at source — applies to both client and SSR, 15 + // so no pre-build step is needed for `pnpm dev`. 16 + alias: { 17 + '@ewanc26/atproto': pkg('atproto/src/index.ts'), 18 + '@ewanc26/utils': pkg('utils/src/index.ts'), 19 + '@ewanc26/ui': pkg('ui/src/lib/index.ts') 20 + } 21 + }, 7 22 8 23 build: { 9 24 // Optimize chunk splitting for better caching ··· 45 60 server: { 46 61 // Development server configuration 47 62 fs: { 48 - strict: true 63 + strict: true, 64 + // Allow Vite to serve workspace package source files resolved via alias. 65 + allow: ['packages', 'src', 'node_modules'] 49 66 } 50 67 }, 51 68