static site frontend for mapped.at mapped.at
3
fork

Configure Feed

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

Refactor frontend data fetching

+962 -859
+506 -536
frontend/src/api.ts
··· 6 6 } from "@atcute/identity-resolver"; 7 7 import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 8 8 9 - // ── Constants ───────────────────────────────────────────────────── 10 - const MAPPED_AT_DID = "did:plc:l5m5nuh5cvdatyn5fjxar2sh"; 11 - const MAPPED_AT_PDS = "https://leccinum.us-west.host.bsky.network"; 9 + const CONSTELLATION_URL = "https://constellation.microcosm.blue"; 10 + export const MAPPED_AT_DID = "did:plc:l5m5nuh5cvdatyn5fjxar2sh"; 12 11 const MAPPED_AT_BACKEND_URL = "https://mapped-at-backend.val.run"; 13 12 14 - // ── decodePolyline ───────────────────────────────────────────────── 15 - // Decodes a Google encoded polyline string into a GeoJSON Feature. 16 - // GeoJSON uses [longitude, latitude] order; Leaflet's GeoJSON layer expects this. 17 - function decodePolyline(encoded: string) { 18 - const coords = []; 19 - let index = 0, 20 - lat = 0, 21 - lng = 0; 22 - while (index < encoded.length) { 23 - let b, 24 - shift = 0, 25 - result = 0; 26 - do { 27 - b = encoded.charCodeAt(index++) - 63; 28 - result |= (b & 0x1f) << shift; 29 - shift += 5; 30 - } while (b >= 0x20); 31 - lat += result & 1 ? ~(result >> 1) : result >> 1; 32 - 33 - shift = 0; 34 - result = 0; 35 - do { 36 - b = encoded.charCodeAt(index++) - 63; 37 - result |= (b & 0x1f) << shift; 38 - shift += 5; 39 - } while (b >= 0x20); 40 - lng += result & 1 ? ~(result >> 1) : result >> 1; 41 - 42 - coords.push([lat / 1e5, lng / 1e5]); 43 - } 44 - return { 45 - type: "Feature", 46 - geometry: { 47 - type: "LineString", 48 - coordinates: coords.map(([la, lo]) => [lo, la]), 49 - }, 50 - }; 51 - } 52 - 53 - // ── normaliseStats ───────────────────────────────────────────────── 54 - // Converts wire units (metres/seconds) to display units (km/minutes). 55 - function normaliseStats( 56 - stats: { distance?: number; duration?: number; elevation?: number } | null, 57 - ) { 58 - if (!stats) return null; 59 - return { 60 - distance: 61 - stats.distance != null 62 - ? Math.round(stats.distance / 100) / 10 63 - : undefined, 64 - duration: 65 - stats.duration != null ? Math.round(stats.duration / 60) : undefined, 66 - elevation: stats.elevation ?? undefined, 67 - }; 68 - } 69 - 70 - // ── Author helpers ───────────────────────────────────────────────── 71 - function toInitials(str: string) { 72 - const parts = str.split(/[.\-]/); 73 - return parts 74 - .slice(0, 2) 75 - .map((p) => (p[0] ?? "").toUpperCase()) 76 - .join(""); 77 - } 78 - 79 - // Extracts { handle, initials } from a resolved DID document. 80 - // Falls back to a truncated DID if alsoKnownAs is absent. 81 - function authorFromDidDoc(did: string, doc: { alsoKnownAs?: string[] }) { 82 - const raw = doc?.alsoKnownAs?.[0]; 83 - if (raw) { 84 - const handle = raw.replace(/^at:\/\//, ""); 85 - return { handle, initials: toInitials(handle) }; 86 - } 87 - // fallback: first 4 chars of the method-specific part 88 - const short = did.split(":").slice(2).join(":").slice(0, 4); 89 - return { handle: did, initials: short.slice(0, 2).toUpperCase() }; 90 - } 91 - 92 - // Extracts the PDS endpoint URL from a DID document. 93 - function pdsFromDidDoc(doc: { 94 - service?: { type: string; serviceEndpoint: string }[]; 95 - }) { 96 - const svc = doc?.service?.find((s) => s.type === "AtprotoPersonalDataServer"); 97 - return svc?.serviceEndpoint ?? null; 98 - } 99 - 100 - // ── listRecords ──────────────────────────────────────────────────── 101 - // Fetches all records in a collection from the mapped.at service account PDS. 102 - async function listRecords(collection: string) { 103 - const url = 104 - `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 105 - `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 106 - const res = await fetch(url); 107 - if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 108 - return (await res.json()).records ?? []; 109 - } 110 - 111 - // ── fetchServiceData ─────────────────────────────────────────────── 112 - // Fetches trails, locations, activities from the service account and returns 113 - // URI→record lookup maps plus normalised arrays for public consumption. 114 - let _cachedServiceData: ReturnType<typeof _buildFromRecs> | null = null; 115 - let _serviceInflight: Promise<ReturnType<typeof _buildFromRecs>> | null = null; 116 - 117 - export function fetchServiceData() { 118 - if (_cachedServiceData) return Promise.resolve(_cachedServiceData); 119 - if (!_serviceInflight) _serviceInflight = _fetchLiveServiceData(); 120 - return _serviceInflight; 121 - } 122 - 123 - // Builds the service data object from raw PDS record arrays. 124 - // Each record is { uri, value } as returned by com.atproto.repo.listRecords. 125 - function _buildFromRecs( 126 - trailRecs: any[], 127 - locationRecs: any[], 128 - activityRecs: any[], 129 - memberRecs: any[], 130 - ) { 131 - const activityMap = new Map(); 132 - for (const { uri, cid, value } of activityRecs) { 133 - const rkey = uri.split("/").pop(); 134 - activityMap.set(uri, { uri, cid, rkey, name: value.name }); 135 - } 136 - 137 - const locationMap = new Map(); 138 - for (const { uri, value } of locationRecs) { 139 - const rkey = uri.split("/").pop(); 140 - locationMap.set(uri, { 141 - uri, 142 - rkey, 143 - name: value.name ?? null, 144 - lat: parseFloat(value.latitude), 145 - lng: parseFloat(value.longitude), 146 - }); 147 - } 148 - 149 - const trailMap = new Map(); 150 - for (const { uri, cid, value } of trailRecs) { 151 - const rkey = uri.split("/").pop(); 152 - const activityType = value.activityType?.uri 153 - ? (activityMap.get(value.activityType.uri) ?? null) 154 - : null; 155 - const locations = (value.locations ?? []) 156 - .map((ref: { uri: string }) => locationMap.get(ref.uri)) 157 - .filter(Boolean); 158 - trailMap.set(uri, { 159 - uri, 160 - cid, 161 - rkey, 162 - name: value.name, 163 - activityType, 164 - locations, 165 - geo: value.polyline ? decodePolyline(value.polyline) : null, 166 - }); 167 - } 168 - 169 - const trails = [...trailMap.values()]; 170 - const locations = [...locationMap.values()]; 171 - const activities = [...activityMap.values()]; 172 - const memberDids = new Set<string>( 173 - memberRecs.map((r: any) => r.value?.did).filter(Boolean), 174 - ); 175 - 176 - return { 177 - trailMap, 178 - locationMap, 179 - activityMap, 180 - trails, 181 - locations, 182 - activities, 183 - memberDids, 184 - }; 185 - } 186 - 187 - async function _fetchLiveServiceData() { 188 - const [trailRecs, locationRecs, activityRecs, memberRecs] = await Promise.all( 189 - [ 190 - listRecords("at.mapped.trail"), 191 - listRecords("at.mapped.location"), 192 - listRecords("at.mapped.activity"), 193 - listRecords("at.mapped.member"), 194 - ], 195 - ); 196 - const data = _buildFromRecs( 197 - trailRecs, 198 - locationRecs, 199 - activityRecs, 200 - memberRecs, 201 - ); 202 - _cachedServiceData = data; 203 - return data; 204 - } 205 - 206 13 // ── DID resolver ─────────────────────────────────────────────────── 207 - const _didResolver = new CompositeDidDocumentResolver({ 14 + const didResolver = new CompositeDidDocumentResolver({ 208 15 methods: { 209 16 plc: new PlcDidDocumentResolver(), 210 17 web: new WebDidDocumentResolver(), 211 18 }, 212 19 }); 213 20 214 - // ── resolveDidInfo ───────────────────────────────────────────────── 215 - // Resolves a DID to { author, pds }. 216 - // Gets the PDS endpoint from the DID document, then fetches the handle 217 - // from com.atproto.repo.describeRepo (more reliable than alsoKnownAs). 218 - // Returns null if resolution fails (post will be skipped). 219 - async function resolveDidInfo(did: Parameters<typeof _didResolver.resolve>[0]) { 220 - try { 221 - const didDoc = (await _didResolver.resolve(did)) as { document: any } | any; 222 - const doc = didDoc?.document ?? didDoc; 223 - const pds = pdsFromDidDoc(doc); 224 - if (!pds) return null; 21 + export type Did = `did:plc:${string}` | `did:web:${string}`; 22 + 23 + const resolvedDidInfoCache = new Map< 24 + string, 25 + { handle: string | null; pds: { serviceEndpoint: string } } | null 26 + >(); 27 + 28 + export async function resolveDidInfo(dids: Did[]) { 29 + // deduplicate 30 + const uniqueDids = Array.from(new Set(dids)); 31 + const results = await Promise.all( 32 + uniqueDids.map(async (did) => { 33 + if (resolvedDidInfoCache.has(did)) { 34 + return resolvedDidInfoCache.get(did) ?? null; 35 + } 36 + 37 + try { 38 + const didDoc = await didResolver.resolve(did); 39 + const pds = didDoc.service?.find( 40 + (s) => s.type === "AtprotoPersonalDataServer", 41 + ); 42 + if (!pds) return null; 225 43 226 - // describeRepo returns the account's current handle directly 227 - const repoRes = await fetch( 228 - `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`, 229 - ); 230 - const handle = repoRes.ok ? ((await repoRes.json()).handle ?? null) : null; 44 + const url = `${pds.serviceEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`; 45 + // describeRepo returns the account's current handle directly 46 + const repoRes = await fetch(url); 47 + const handle = repoRes.ok 48 + ? ((await repoRes.json()).handle ?? null) 49 + : null; 231 50 232 - const author = handle 233 - ? { handle, initials: toInitials(handle) } 234 - : authorFromDidDoc(did, doc); // fallback to alsoKnownAs parsing 51 + return { handle, pds }; 52 + } catch (err) { 53 + console.warn(`DID resolution failed for ${did}:`, err); 54 + return null; 55 + } 56 + }), 57 + ); 235 58 236 - return { author, pds }; 237 - } catch (err) { 238 - console.warn(`DID resolution failed for ${did}:`, err); 239 - return null; 240 - } 59 + return results; 241 60 } 242 61 243 62 export async function fetchPds(did: string): Promise<string | null> { 244 - const info = await resolveDidInfo( 245 - did as Parameters<typeof _didResolver.resolve>[0], 246 - ); 247 - return info?.pds ?? null; 63 + const [info] = await resolveDidInfo([did as Did]); 64 + const endpoint = info?.pds.serviceEndpoint; 65 + return typeof endpoint === "string" ? endpoint : null; 248 66 } 249 67 250 - // ── createProfile ────────────────────────────────────────────────── 251 - // Creates an at.mapped.profile singleton in the user's repo (if absent), 252 - // then registers the user with the backend so a member record is written 253 - // to the service account. Safe to call on every login — both are idempotent. 254 - async function _profileExists(pds: string, did: string): Promise<boolean> { 255 - try { 256 - const res = await fetch( 257 - `${pds}/xrpc/com.atproto.repo.getRecord?` + 258 - `repo=${encodeURIComponent(did)}&collection=at.mapped.profile&rkey=self`, 259 - ); 260 - return res.ok; 261 - } catch { 262 - return false; 68 + export async function collectPostRefs( 69 + subject: string, 70 + ): Promise<Array<{ did: Did; rkey: string }>> { 71 + const url = 72 + `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks` + 73 + `?subject=${encodeURIComponent(subject)}&source=at.mapped.post:trail.uri&limit=100`; 74 + const res = await fetch(url); 75 + if (!res.ok) return []; 76 + const links = (await res.json()).records ?? []; 77 + 78 + const seen = new Set(); 79 + const refs: { did: Did; rkey: string }[] = []; 80 + for (const { did, rkey } of links) { 81 + const uri = `at://${did}/at.mapped.post/${rkey}`; 82 + if (seen.has(uri)) continue; 83 + seen.add(uri); 84 + refs.push({ did, rkey }); 263 85 } 86 + return refs; 264 87 } 265 88 266 - export async function createProfile( 267 - agent: OAuthUserAgent, 268 - { did, pds }: { did: string; pds: string }, 269 - ): Promise<void> { 270 - const exists = await _profileExists(pds, did); 89 + export type TrailRecord = { 90 + uri: string; 91 + name?: string; 92 + text?: string; 93 + timestamp?: number; 94 + polyline?: string; 95 + activityType?: { uri: string; cid: string }; 96 + locations?: { uri: string; cid: string }[]; 97 + contributor?: string; 98 + }; 271 99 272 - if (!exists) { 273 - const rpc = new Client< 274 - {}, 275 - { 276 - "com.atproto.repo.createRecord": { 277 - input: { 278 - repo: string; 279 - collection: string; 280 - rkey: string; 281 - record: unknown; 282 - }; 283 - }; 284 - } 285 - >({ handler: agent }); 286 - await ok( 287 - rpc.post("com.atproto.repo.createRecord", { 288 - input: { 289 - repo: did, 290 - rkey: "self", 291 - collection: "at.mapped.profile", 292 - record: { 293 - createdAt: new Date().toISOString(), 294 - }, 295 - }, 296 - as: "json", 297 - }), 298 - ); 299 - } 300 - setTimeout(() => { 301 - // Register with backend — idempotent, non-blocking 302 - fetch(`${MAPPED_AT_BACKEND_URL}/register`, { 303 - method: "POST", 304 - headers: { "Content-Type": "application/json" }, 305 - body: JSON.stringify({ did }), 306 - }).catch((err) => console.warn("Registration backend call failed:", err)); 307 - }, 500); 308 - } 100 + const resolvedTrailCache = new Map<string, any>(); 309 101 310 - // ── submitTrail ──────────────────────────────────────────────────── 311 - // Submits a new trail to the service account via the backend. 312 - // Returns the service account's { uri, cid } for use in createPost. 313 - export async function submitTrail( 314 - did: string, 315 - trail: { 316 - name: string; 317 - polyline?: string; 318 - activityType?: { uri: string; cid: string }; 319 - locations?: { uri: string; cid: string }[]; 320 - }, 321 - ): Promise<{ uri: string; cid: string }> { 322 - const res = await fetch(`${MAPPED_AT_BACKEND_URL}/submit-trail`, { 323 - method: "POST", 324 - headers: { "Content-Type": "application/json" }, 325 - body: JSON.stringify({ did, trail }), 326 - }); 327 - if (!res.ok) { 328 - const err = await res.json().catch(() => ({ error: res.statusText })); 329 - throw new Error( 330 - (err as { error?: string }).error ?? "Trail submission failed", 331 - ); 102 + export async function fetchTrails(refs: { did: Did; rkey: string }[]) { 103 + const uniqueDids = Array.from(new Set(refs.map((r) => r.did))); 104 + const didInfoMap = new Map( 105 + (await resolveDidInfo(uniqueDids)).map((info, i) => [uniqueDids[i], info]), 106 + ); 107 + const trails: TrailRecord[] = []; 108 + for (const { did, rkey } of refs) { 109 + const cacheKey = `${did}/at.mapped.trail/${rkey}`; 110 + if (resolvedTrailCache.has(cacheKey)) { 111 + trails.push(resolvedTrailCache.get(cacheKey)!); 112 + } 113 + try { 114 + const didInfo = didInfoMap.get(did); 115 + if (!didInfo) { 116 + console.warn(`No DID info found for ${did}, skipping trail ${rkey}`); 117 + continue; 118 + } 119 + const { pds } = didInfo; 120 + const url = 121 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.getRecord?` + 122 + `repo=${encodeURIComponent(did)}&collection=at.mapped.trail&rkey=${encodeURIComponent(rkey)}`; 123 + const res = await fetch(url); 124 + if (!res.ok) throw new Error("Failed to fetch trail record"); 125 + const { value } = await res.json(); 126 + const trail = { 127 + ...value, 128 + uri: `at://${did}/at.mapped.trail/${rkey}`, 129 + }; 130 + resolvedTrailCache.set(cacheKey, trail); 131 + trails.push(trail); 132 + } catch (err) { 133 + console.warn(`Failed to fetch trail ${did}/${rkey}:`, err); 134 + } 332 135 } 333 - return res.json(); 136 + return trails; 334 137 } 335 138 336 - export type Post = { 139 + export type PostRecord = { 337 140 uri: string; 338 - rkey: string; 339 - pds: string; 340 - author: any; 341 - title: string | null; 342 - text: string | null; 343 - timestamp: number; 344 - activityType: any | null; 345 - location: any | null; 346 - trail: any | null; 347 - stats: { 141 + rkey?: string; 142 + author?: { did: string; handle: string | null }; 143 + pds?: string; 144 + title?: string; 145 + text?: string; 146 + timestamp?: string; 147 + activity?: { uri: string; cid: string; name?: string }; 148 + location?: { uri: string; cid: string; name?: string }; 149 + trail?: { uri: string; cid: string }; 150 + images?: Array<{ image: BlobRef; alt?: string }>; 151 + stats?: { 348 152 distance?: number; 349 153 duration?: number; 350 154 elevation?: number; 351 - } | null; 352 - images: Array<{ image: BlobRef; alt?: string }> | null; 353 - }; 354 - 355 - // ── _hydratePost ─────────────────────────────────────────────────── 356 - // Builds the normalised Post object from raw PDS value + lookup maps + author. 357 - function _hydratePost( 358 - did: string, 359 - rkey: string, 360 - value: any, 361 - author: any, 362 - { 363 - trails, 364 - locations, 365 - activities, 366 - }: { trails: any; locations: any; activities: any }, 367 - pds: string, 368 - ): Post { 369 - const uri = `at://${did}/at.mapped.post/${rkey}`; 370 - const activityType = value.activity?.uri 371 - ? (activities.find((a: any) => a.uri === value.activity.uri) ?? null) 372 - : null; 373 - const location = value.location?.uri 374 - ? (locations.find((a: any) => a.uri === value.location.uri) ?? null) 375 - : null; 376 - const trail = value.trail?.uri 377 - ? (trails.find((a: any) => a.uri === value.trail.uri) ?? null) 378 - : null; 379 - 380 - return { 381 - uri, 382 - rkey, 383 - pds, 384 - author, 385 - title: value.title ?? null, 386 - text: value.text ?? null, 387 - timestamp: value.timestamp, 388 - activityType, 389 - location, 390 - trail, 391 - stats: normaliseStats(value.stats ?? null), 392 - images: value.images ?? null, 393 155 }; 394 - } 156 + }; 395 157 396 - // ── Post cache helpers ───────────────────────────────────────────── 397 - const POST_CACHE_KEY = "mapped_cache"; 398 - const POST_CACHE_TTL_MS = 60_000; 158 + const resolvedPostCache = new Map<string, PostRecord>(); 399 159 400 - function _readPostsCache(did: string) { 401 - try { 402 - const raw = localStorage.getItem(`${POST_CACHE_KEY}_${did}`); 403 - if (!raw) return null; 404 - const { posts, cachedAt } = JSON.parse(raw) as { 405 - posts: Post[]; 406 - cachedAt: number; 407 - }; 408 - if (Date.now() - cachedAt > POST_CACHE_TTL_MS) return null; 409 - return posts; 410 - } catch (_) { 411 - return null; 160 + export async function fetchPosts(refs: { did: Did; rkey: string }[]) { 161 + const uniqueDids = Array.from(new Set(refs.map((r) => r.did))); 162 + const didInfoMap = new Map( 163 + (await resolveDidInfo(uniqueDids)).map((info, i) => [uniqueDids[i], info]), 164 + ); 165 + const posts: PostRecord[] = []; 166 + for (const { did, rkey } of refs) { 167 + const cacheKey = `${did}/at.mapped.post/${rkey}`; 168 + if (resolvedPostCache.has(cacheKey)) { 169 + posts.push(resolvedPostCache.get(cacheKey)!); 170 + } 171 + const didInfo = didInfoMap.get(did); 172 + if (!didInfo) { 173 + console.warn(`No DID info found for ${did}, skipping post ${rkey}`); 174 + continue; 175 + } 176 + try { 177 + const { pds } = didInfo; 178 + // https://amanita.us-east.host.bsky.network 179 + // /xrpc/com.atproto.repo.getRecord? 180 + // repo=did%3Aplc%3Aoxdlsmnvpk2riyyuvq5jtdkd 181 + // &collection=at.mapped.post&rkey=3mja4d427qe2z 182 + const url = 183 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.getRecord?` + 184 + `repo=${encodeURIComponent(did)}&collection=at.mapped.post&rkey=${encodeURIComponent(rkey)}`; 185 + const res = await fetch(url); 186 + if (!res.ok) throw new Error("Failed to fetch post record"); 187 + const { value } = await res.json(); 188 + const post = { 189 + author: { 190 + did, 191 + handle: didInfo.handle, 192 + }, 193 + uri: `at://${did}/at.mapped.post/${rkey}`, 194 + rkey, 195 + pds: pds.serviceEndpoint, 196 + ...value, 197 + }; 198 + resolvedPostCache.set(cacheKey, post); 199 + posts.push(post); 200 + } catch (err) { 201 + console.warn(`Failed to fetch post ${did}/${rkey}:`, err); 202 + } 412 203 } 413 - } 414 204 415 - function _writePostsCache(did: string, posts: Post[]) { 416 - try { 417 - localStorage.setItem( 418 - `${POST_CACHE_KEY}_${did}`, 419 - JSON.stringify({ posts, cachedAt: Date.now() }), 420 - ); 421 - } catch (_) { 422 - // localStorage may be unavailable (private browsing, quota exceeded) — ignore 423 - } 205 + return posts; 424 206 } 425 207 426 - // ── fetchAll ─────────────────────────────────────────────────────── 427 - // Returns posts for the logged-in user based on their follow graph. 428 - // When called without opts (logged-out), returns service data with empty posts. 429 - const _cachedResults = new Map< 430 - string, 431 - { trails: any; locations: any; activities: any; posts: Post[] } 432 - >(); 433 - const _fetchAllInflights = new Map< 434 - string, 435 - Promise<{ trails: any; locations: any; activities: any; posts: Post[] }> 436 - >(); 437 - 438 - export function fetchAll(opts?: { did?: string; pds?: string }) { 439 - const did = opts?.did; 440 - const pds = opts?.pds ?? ""; 441 - 442 - if (!did) { 443 - return fetchServiceData().then(({ trails, locations, activities }) => ({ 444 - trails, 445 - locations, 446 - activities, 447 - posts: [] as Post[], 448 - })); 208 + export async function fetchPostByUri(uri: string) { 209 + const match = uri.match(/^at:\/\/([^/]+)\/at\.mapped\.post\/([^/]+)$/); 210 + if (!match) { 211 + throw new Error(`Invalid post URI format: ${uri}`); 449 212 } 450 - 451 - const cached = _cachedResults.get(did); 452 - if (cached) return Promise.resolve(cached); 453 - 454 - const inflight = _fetchAllInflights.get(did); 455 - if (inflight) return inflight; 456 - 457 - const promise = _fetchAll(did, pds); 458 - _fetchAllInflights.set(did, promise); 459 - return promise; 213 + const [, did, rkey] = match; 214 + const posts = await fetchPosts([{ did: did as Did, rkey }]); 215 + return posts[0] ?? null; 460 216 } 461 217 462 - async function _fetchAll(did: string, pds: string) { 463 - const serviceData = await fetchServiceData(); 464 - const { trails, locations, activities } = serviceData; 465 - 466 - // Serve from localStorage cache if fresh 467 - const cached = _readPostsCache(did); 468 - if (cached) { 469 - const result = { trails, locations, activities, posts: cached }; 470 - _cachedResults.set(did, result); 471 - _fetchAllInflights.delete(did); 472 - _revalidatePosts(serviceData, did, pds); 473 - return result; 218 + export async function fetchTrailByUri(uri: string) { 219 + const match = uri.match(/^at:\/\/([^/]+)\/at\.mapped\.trail\/([^/]+)$/); 220 + if (!match) { 221 + throw new Error(`Invalid trail URI format: ${uri}`); 474 222 } 475 - 476 - // Cold fetch 477 - const posts = await _fetchPosts(serviceData, did, pds); 478 - _writePostsCache(did, posts); 479 - const result = { trails, locations, activities, posts }; 480 - _cachedResults.set(did, result); 481 - _fetchAllInflights.delete(did); 482 - return result; 223 + const [, did, rkey] = match; 224 + const trails = await fetchTrails([{ did: did as Did, rkey }]); 225 + return trails[0] ?? null; 483 226 } 484 227 485 - // Fetches the DIDs that `did` follows, from their PDS. 486 - // Only retrieves the first 100 follows (no pagination — sufficient for MVP). 487 - async function getFollows(did: string, pds: string): Promise<string[]> { 488 - try { 489 - const url = 490 - `${pds}/xrpc/com.atproto.repo.listRecords` + 491 - `?repo=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&limit=100`; 492 - const res = await fetch(url); 493 - if (!res.ok) return []; 494 - const { records } = await res.json(); 495 - return (records ?? []) 496 - .map((r: any) => r.value?.subject) 497 - .filter((s: unknown): s is string => typeof s === "string"); 498 - } catch { 499 - return []; 228 + export async function fetchActivityByUri(uri: string) { 229 + const match = uri.match(/^at:\/\/([^/]+)\/at\.mapped\.activity\/([^/]+)$/); 230 + if (!match) { 231 + throw new Error(`Invalid activity URI format: ${uri}`); 500 232 } 233 + const [, did, rkey] = match; 234 + const activities = await fetchActivities([{ did: did as Did, rkey }]); 235 + return activities[0] ?? null; 501 236 } 502 237 503 - // Lists all at.mapped.post records for a user from their PDS. 504 - async function listUserPosts(pds: string, did: string): Promise<any[]> { 505 - try { 506 - const url = 507 - `${pds}/xrpc/com.atproto.repo.listRecords` + 508 - `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&limit=100`; 509 - const res = await fetch(url); 510 - if (!res.ok) return []; 511 - return (await res.json()).records ?? []; 512 - } catch { 513 - return []; 238 + export async function fetchLocationByUri(uri: string) { 239 + const match = uri.match(/^at:\/\/([^/]+)\/at\.mapped\.location\/([^/]+)$/); 240 + if (!match) { 241 + throw new Error(`Invalid location URI format: ${uri}`); 514 242 } 243 + const [, did, rkey] = match; 244 + const locations = await fetchLocations([{ did: did as Did, rkey }]); 245 + return locations[0] ?? null; 515 246 } 516 247 517 - async function _fetchPosts( 518 - serviceData: ReturnType<typeof _buildFromRecs>, 519 - loggedInDid: string, 520 - loggedInPds: string, 521 - ) { 522 - // Get who the logged-in user follows 523 - const followedDids = await getFollows(loggedInDid, loggedInPds); 248 + export type ActivityRecord = { 249 + uri: string; 250 + name?: string; 251 + }; 524 252 525 - // Keep only follows who are mapped.at members 526 - const memberFollows = followedDids.filter((did) => 527 - serviceData.memberDids.has(did), 253 + const resolvedActivityCache = new Map<string, ActivityRecord[]>(); 254 + export async function fetchActivities(refs: { did: Did; rkey: string }[]) { 255 + const uniqueDids = Array.from(new Set(refs.map((r) => r.did))); 256 + const didInfoMap = new Map( 257 + (await resolveDidInfo(uniqueDids)).map((info, i) => [uniqueDids[i], info]), 528 258 ); 259 + const activities: ActivityRecord[] = []; 260 + for (const { did, rkey } of refs) { 261 + const cacheKey = `${did}/at.mapped.activity/${rkey}`; 262 + if (resolvedActivityCache.has(cacheKey)) { 263 + activities.push(...resolvedActivityCache.get(cacheKey)!); 264 + continue; 265 + } 266 + const didInfo = didInfoMap.get(did); 267 + if (!didInfo) { 268 + console.warn(`No DID info found for ${did}, skipping activity ${rkey}`); 269 + continue; 270 + } 271 + try { 272 + const { pds } = didInfo; 273 + const url = 274 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.getRecord?` + 275 + `repo=${encodeURIComponent(did)}&collection=at.mapped.activity&rkey=${encodeURIComponent(rkey)}`; 276 + const res = await fetch(url); 277 + if (!res.ok) throw new Error("Failed to fetch activity record"); 278 + const { value } = await res.json(); 279 + const activity = { 280 + ...value, 281 + uri: `at://${did}/at.mapped.activity/${rkey}`, 282 + }; 283 + activities.push(activity); 284 + resolvedActivityCache.set(cacheKey, [activity]); 285 + } catch (err) { 286 + console.warn(`Failed to fetch activity ${did}/${rkey}:`, err); 287 + } 288 + } 289 + return activities; 290 + } 529 291 530 - // Resolve DID info (author + PDS) for each member-follow in parallel 531 - const didInfoMap = new Map<string, { author: any; pds: string }>(); 532 - await Promise.all( 533 - memberFollows.map(async (did) => { 534 - const info = await resolveDidInfo( 535 - did as Parameters<typeof _didResolver.resolve>[0], 536 - ); 537 - if (info) didInfoMap.set(did, info); 538 - }), 292 + const resolvedLocationCache = new Map<string, any>(); 293 + export async function fetchLocations(refs: { did: Did; rkey: string }[]) { 294 + const uniqueDids = Array.from(new Set(refs.map((r) => r.did))); 295 + const didInfoMap = new Map( 296 + (await resolveDidInfo(uniqueDids)).map((info, i) => [uniqueDids[i], info]), 539 297 ); 540 - 541 - // Fetch all posts from each member-follow in parallel 542 - const allPosts = ( 543 - await Promise.all( 544 - memberFollows.map(async (did) => { 545 - const info = didInfoMap.get(did); 546 - if (!info) return []; 547 - const records = await listUserPosts(info.pds, did); 548 - return records.map((r: any) => 549 - _hydratePost( 550 - did, 551 - r.uri.split("/").pop()!, 552 - r.value, 553 - info.author, 554 - serviceData, 555 - info.pds, 556 - ), 557 - ); 558 - }), 559 - ) 560 - ).flat(); 561 - 562 - allPosts.sort((a, b) => b.timestamp - a.timestamp); 563 - return allPosts; 298 + const locations: any[] = []; 299 + for (const { did, rkey } of refs) { 300 + const cacheKey = `${did}/at.mapped.location/${rkey}`; 301 + if (resolvedLocationCache.has(cacheKey)) { 302 + locations.push(resolvedLocationCache.get(cacheKey)!); 303 + continue; 304 + } 305 + const didInfo = didInfoMap.get(did); 306 + if (!didInfo) { 307 + console.warn(`No DID info found for ${did}, skipping location ${rkey}`); 308 + continue; 309 + } 310 + try { 311 + const { pds } = didInfo; 312 + const url = 313 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.getRecord?` + 314 + `repo=${encodeURIComponent(did)}&collection=at.mapped.location&rkey=${encodeURIComponent(rkey)}`; 315 + const res = await fetch(url); 316 + if (!res.ok) throw new Error("Failed to fetch location record"); 317 + const { value } = await res.json(); 318 + const location = { 319 + ...value, 320 + uri: `at://${did}/at.mapped.location/${rkey}`, 321 + }; 322 + locations.push(location); 323 + resolvedLocationCache.set(cacheKey, location); 324 + } catch (err) { 325 + console.warn(`Failed to fetch location ${did}/${rkey}:`, err); 326 + } 327 + } 328 + return locations; 564 329 } 565 330 566 - async function _revalidatePosts( 567 - serviceData: ReturnType<typeof _buildFromRecs>, 568 - did: string, 569 - pds: string, 570 - ) { 571 - try { 572 - const prevPosts = _cachedResults.get(did)?.posts ?? []; 573 - const posts = await _fetchPosts(serviceData, did, pds); 574 - _writePostsCache(did, posts); 575 - const { trails, locations, activities } = serviceData; 576 - _cachedResults.set(did, { trails, locations, activities, posts }); 577 - if (_postsChanged(prevPosts, posts)) { 578 - document.dispatchEvent(new CustomEvent("mapped:posts")); 579 - } 580 - } catch (_) { 581 - // Silently ignore — stale data is fine 331 + export const fetchRecord = async ( 332 + did: Did, 333 + collection: string, 334 + rkey: string, 335 + ) => { 336 + const didInfo = await resolveDidInfo([did]); 337 + if (!didInfo[0]) { 338 + throw new Error(`No DID info found for ${did}`); 339 + } 340 + const { pds } = didInfo[0]; 341 + const url = 342 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.getRecord?` + 343 + `repo=${encodeURIComponent(did)}&collection=${encodeURIComponent( 344 + collection, 345 + )}&rkey=${encodeURIComponent(rkey)}`; 346 + const res = await fetch(url); 347 + if (!res.ok) { 348 + throw new Error(`Failed to fetch record ${did}/${collection}/${rkey}`); 582 349 } 583 - } 350 + const { value } = await res.json(); 351 + return { 352 + ...value, 353 + uri: `at://${did}/${collection}/${rkey}`, 354 + }; 355 + }; 584 356 585 - function _postsChanged(prev: Post[], next: Post[]) { 586 - if (prev.length !== next.length) return true; 587 - return prev.some( 588 - (p, i) => p.uri !== next[i].uri || p.timestamp !== next[i].timestamp, 589 - ); 357 + export async function fetchRecordByUri(uri: string) { 358 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); 359 + if (!match) { 360 + throw new Error(`Invalid URI format: ${uri}`); 361 + } 362 + const [, did, collection, rkey] = match; 363 + return await fetchRecord(did as Did, collection, rkey); 590 364 } 591 365 592 366 export type BlobRef = { ··· 630 404 agent: OAuthUserAgent, 631 405 { 632 406 did, 633 - handle, 634 - pds, 407 + handle: _handle, 408 + pds: _pds, 635 409 activityEntry, 636 410 trailEntry, 637 411 title, ··· 702 476 }), 703 477 )) as { uri: string }; 704 478 705 - const rkey = res.uri.split("/").pop()!; 706 - const author = { handle, initials: toInitials(handle) }; 479 + return res; 480 + } 707 481 708 - // Optimistic update: prepend to in-memory cache 709 - const cached = _cachedResults.get(did); 710 - if (cached) { 711 - const post = _hydratePost(did, rkey, record, author, cached, pds); 712 - cached.posts.unshift(post); 713 - _writePostsCache(did, cached.posts); 714 - document.dispatchEvent(new CustomEvent("mapped:posts")); 482 + // ── createProfile ────────────────────────────────────────────────── 483 + // Creates an at.mapped.profile singleton in the user's repo (if absent), 484 + // then registers the user with the backend so a member record is written 485 + // to the service account. Safe to call on every login — both are idempotent. 486 + async function _profileExists(pds: string, did: string): Promise<boolean> { 487 + try { 488 + const res = await fetch( 489 + `${pds}/xrpc/com.atproto.repo.getRecord?` + 490 + `repo=${encodeURIComponent(did)}&collection=at.mapped.profile&rkey=self`, 491 + ); 492 + return res.ok; 493 + } catch { 494 + return false; 715 495 } 716 496 } 497 + 498 + export async function createProfile( 499 + agent: OAuthUserAgent, 500 + { did, pds }: { did: string; pds: string }, 501 + ): Promise<void> { 502 + const exists = await _profileExists(pds, did); 503 + 504 + if (!exists) { 505 + const rpc = new Client< 506 + {}, 507 + { 508 + "com.atproto.repo.createRecord": { 509 + input: { 510 + repo: string; 511 + collection: string; 512 + rkey: string; 513 + record: unknown; 514 + }; 515 + }; 516 + } 517 + >({ handler: agent }); 518 + await ok( 519 + rpc.post("com.atproto.repo.createRecord", { 520 + input: { 521 + repo: did, 522 + rkey: "self", 523 + collection: "at.mapped.profile", 524 + record: { 525 + createdAt: new Date().toISOString(), 526 + }, 527 + }, 528 + as: "json", 529 + }), 530 + ); 531 + } 532 + setTimeout(() => { 533 + // Register with backend — idempotent, non-blocking 534 + fetch(`${MAPPED_AT_BACKEND_URL}/register`, { 535 + method: "POST", 536 + headers: { "Content-Type": "application/json" }, 537 + body: JSON.stringify({ did }), 538 + }).catch((err) => console.warn("Registration backend call failed:", err)); 539 + }, 500); 540 + } 541 + 542 + // ── submitTrail ──────────────────────────────────────────────────── 543 + // Submits a new trail to the service account via the backend. 544 + // Returns the service account's { uri, cid } for use in createPost. 545 + export async function submitTrail( 546 + did: string, 547 + trail: { 548 + name: string; 549 + polyline?: string; 550 + activityType?: { uri: string; cid: string }; 551 + locations?: { uri: string; cid: string }[]; 552 + }, 553 + ): Promise<{ uri: string; cid: string }> { 554 + const res = await fetch(`${MAPPED_AT_BACKEND_URL}/submit-trail`, { 555 + method: "POST", 556 + headers: { "Content-Type": "application/json" }, 557 + body: JSON.stringify({ did, trail }), 558 + }); 559 + if (!res.ok) { 560 + const err = await res.json().catch(() => ({ error: res.statusText })); 561 + throw new Error( 562 + (err as { error?: string }).error ?? "Trail submission failed", 563 + ); 564 + } 565 + return res.json(); 566 + } 567 + 568 + // ── fetchServiceData ────────────────────────────────────────────────────────── 569 + export type ServiceActivity = { 570 + uri: string; 571 + cid: string; 572 + rkey: string; 573 + name: string; 574 + }; 575 + export type ServiceLocation = { 576 + uri: string; 577 + cid: string; 578 + rkey: string; 579 + name: string | null; 580 + }; 581 + export type ServiceTrail = { 582 + uri: string; 583 + cid: string; 584 + rkey: string; 585 + name: string; 586 + polyline?: string; 587 + activityType?: { uri: string; cid: string }; 588 + locations: ServiceLocation[]; 589 + }; 590 + export type ServiceData = { 591 + activityMap: Map<string, ServiceActivity>; 592 + locationMap: Map<string, ServiceLocation>; 593 + trailMap: Map<string, ServiceTrail>; 594 + activities: ServiceActivity[]; 595 + locations: ServiceLocation[]; 596 + trails: ServiceTrail[]; 597 + }; 598 + 599 + async function listRecords( 600 + collection: string, 601 + ): Promise<Array<{ uri: string; cid: string; value: any }>> { 602 + const didInfo = await resolveDidInfo([MAPPED_AT_DID]); 603 + if (!didInfo[0]) return []; 604 + const { pds } = didInfo[0]; 605 + const records: Array<{ uri: string; cid: string; value: any }> = []; 606 + let cursor: string | undefined; 607 + do { 608 + const url = 609 + `${pds.serviceEndpoint}/xrpc/com.atproto.repo.listRecords?` + 610 + `repo=${encodeURIComponent(MAPPED_AT_DID)}&collection=${encodeURIComponent(collection)}&limit=100` + 611 + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""); 612 + const res = await fetch(url); 613 + if (!res.ok) break; 614 + const { records: recs, cursor: next } = await res.json(); 615 + records.push(...(recs ?? [])); 616 + cursor = next; 617 + } while (cursor); 618 + return records; 619 + } 620 + 621 + function _buildFromRecs( 622 + trailRecs: Array<{ uri: string; cid: string; value: any }>, 623 + locationRecs: Array<{ uri: string; cid: string; value: any }>, 624 + activityRecs: Array<{ uri: string; cid: string; value: any }>, 625 + ): ServiceData { 626 + const activityMap = new Map<string, ServiceActivity>(); 627 + for (const { uri, cid, value } of activityRecs) { 628 + const rkey = uri.split("/").pop()!; 629 + activityMap.set(uri, { uri, cid, rkey, name: value.name }); 630 + } 631 + 632 + const locationMap = new Map<string, ServiceLocation>(); 633 + for (const { uri, cid, value } of locationRecs) { 634 + const rkey = uri.split("/").pop()!; 635 + locationMap.set(uri, { uri, cid, rkey, name: value.name ?? null }); 636 + } 637 + 638 + const trailMap = new Map<string, ServiceTrail>(); 639 + for (const { uri, cid, value } of trailRecs) { 640 + const rkey = uri.split("/").pop()!; 641 + const locations = (value.locations ?? []) 642 + .map((ref: { uri: string }) => locationMap.get(ref.uri)) 643 + .filter( 644 + (l: ServiceLocation | undefined): l is ServiceLocation => 645 + l !== undefined, 646 + ); 647 + trailMap.set(uri, { 648 + uri, 649 + cid, 650 + rkey, 651 + name: value.name, 652 + ...(value.polyline ? { polyline: value.polyline } : {}), 653 + ...(value.activityType ? { activityType: value.activityType } : {}), 654 + locations, 655 + }); 656 + } 657 + 658 + return { 659 + activityMap, 660 + locationMap, 661 + trailMap, 662 + activities: [...activityMap.values()], 663 + locations: [...locationMap.values()], 664 + trails: [...trailMap.values()], 665 + }; 666 + } 667 + 668 + let _cachedServiceData: ServiceData | null = null; 669 + let _serviceInflight: Promise<ServiceData> | null = null; 670 + 671 + export function fetchServiceData(): Promise<ServiceData> { 672 + if (_cachedServiceData) return Promise.resolve(_cachedServiceData); 673 + if (!_serviceInflight) _serviceInflight = _fetchLiveServiceData(); 674 + return _serviceInflight; 675 + } 676 + 677 + async function _fetchLiveServiceData(): Promise<ServiceData> { 678 + const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 679 + listRecords("at.mapped.trail"), 680 + listRecords("at.mapped.location"), 681 + listRecords("at.mapped.activity"), 682 + ]); 683 + const data = _buildFromRecs(trailRecs, locationRecs, activityRecs); 684 + _cachedServiceData = data; 685 + return data; 686 + }
-230
frontend/src/components/activity-card.ts
··· 1 - import { LitElement, html, css, unsafeCSS } from "lit"; 2 - import { customElement, property } from "lit/decorators.js"; 3 - import * as L from "leaflet"; 4 - import { GeoJSON } from "leaflet"; 5 - import leafletCss from "leaflet/dist/leaflet.css?inline"; 6 - import type { Post } from "../api.ts"; 7 - import { relativeTime, formatDuration, getColorForString, getPillConfig } from "../utils.ts"; 8 - import { sharedStyles } from "./shared-styles.ts"; 9 - 10 - @customElement("activity-card") 11 - export class ActivityCard extends LitElement { 12 - static styles = [ 13 - sharedStyles, 14 - unsafeCSS(leafletCss), 15 - css` 16 - .user-info { 17 - flex: 1; 18 - min-width: 0; 19 - } 20 - .user-name { 21 - font-weight: 600; 22 - font-size: 14px; 23 - color: var(--color-text-primary); 24 - } 25 - .user-meta { 26 - font-size: 12px; 27 - color: var(--color-text-secondary); 28 - display: flex; 29 - align-items: center; 30 - gap: 4px; 31 - margin-top: 2px; 32 - } 33 - .distance { 34 - margin-left: auto; 35 - font-size: 18px; 36 - font-weight: 800; 37 - color: var(--color-text-primary); 38 - white-space: nowrap; 39 - } 40 - .distance span { 41 - font-size: 12px; 42 - font-weight: 500; 43 - color: var(--color-text-secondary); 44 - } 45 - .gallery-map { 46 - height: 160px; 47 - width: 240px; 48 - flex-shrink: 0; 49 - border-radius: 6px; 50 - overflow: hidden; 51 - } 52 - .activity-title { 53 - padding: 0 16px 12px; 54 - font-size: 16px; 55 - font-weight: 600; 56 - color: var(--color-text-primary); 57 - line-height: 1.3; 58 - } 59 - .travel-cover { 60 - height: 160px; 61 - display: flex; 62 - align-items: flex-end; 63 - padding: 14px 16px; 64 - } 65 - .travel-cover-location { 66 - color: rgba(255, 255, 255, 0.8); 67 - font-size: 11px; 68 - margin-bottom: 2px; 69 - } 70 - .travel-cover-title { 71 - color: #fff; 72 - font-size: 17px; 73 - font-weight: 700; 74 - } 75 - .travel-author { 76 - display: flex; 77 - align-items: center; 78 - gap: 10px; 79 - margin-bottom: 10px; 80 - } 81 - .travel-author .avatar { 82 - width: 28px; 83 - height: 28px; 84 - font-size: 11px; 85 - } 86 - .travel-author-name { 87 - font-weight: 600; 88 - font-size: 13px; 89 - color: var(--color-text-primary); 90 - } 91 - .travel-author-meta { 92 - font-size: 11px; 93 - color: var(--color-text-secondary); 94 - } 95 - .gallery { 96 - display: flex; 97 - gap: 4px; 98 - overflow-x: auto; 99 - padding: 0 16px 12px; 100 - } 101 - .gallery img { 102 - height: 160px; 103 - width: auto; 104 - max-width: 240px; 105 - border-radius: 6px; 106 - object-fit: cover; 107 - flex-shrink: 0; 108 - } 109 - `, 110 - ]; 111 - 112 - @property({ attribute: false }) post!: Post; 113 - @property({ type: Boolean }) hideLink = false; 114 - 115 - private _renderGallery() { 116 - const { images, uri, pds, trail, activityType } = this.post; 117 - const hasMap = activityType !== null && !!trail?.geo; 118 - const hasImages = images && images.length > 0; 119 - if (!hasMap && !hasImages) return html``; 120 - const did = uri.split("/")[2]; 121 - return html` 122 - <div class="gallery"> 123 - ${hasMap ? html`<div class="activity-map gallery-map"></div>` : ""} 124 - ${(images ?? []).map( 125 - ({ image, alt }) => html` 126 - <img 127 - src="${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(image.ref.$link)}" 128 - alt="${alt ?? ""}" 129 - /> 130 - ` 131 - )} 132 - </div> 133 - `; 134 - } 135 - 136 - firstUpdated() { 137 - if (this.post?.activityType !== null && this.post?.trail) { 138 - const mapEl = this.renderRoot.querySelector(".activity-map") as HTMLDivElement | null; 139 - if (!mapEl) return; 140 - const lMap = new L.Map(mapEl, { 141 - zoomControl: false, 142 - scrollWheelZoom: false, 143 - dragging: false, 144 - doubleClickZoom: false, 145 - }); 146 - new L.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 147 - maxZoom: 19, 148 - attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 149 - }).addTo(lMap); 150 - const layer = new GeoJSON(this.post.trail.geo, { 151 - style: { color: "#4a7c59", weight: 3, opacity: 0.85 }, 152 - }).addTo(lMap); 153 - lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 154 - } 155 - } 156 - 157 - private _renderActivity() { 158 - const post = this.post; 159 - const activityName = post.activityType?.name || "Activity"; 160 - const pill = getPillConfig(activityName); 161 - const showElevation = (post.stats?.elevation || 0) > 0; 162 - const avatarColor = getColorForString(post.author.handle); 163 - const title = post.title || `${activityName} Activity`; 164 - return html` 165 - <div class="card"> 166 - <div class="card-header"> 167 - <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 168 - <div class="user-info"> 169 - <div class="user-name">${post.author.handle}</div> 170 - <div class="user-meta"> 171 - <span class="pill ${pill.cls}">${activityName}</span> 172 - <span>· ${relativeTime(post.timestamp)}</span> 173 - </div> 174 - </div> 175 - <div class="distance">${post.stats?.distance ?? "—"} <span>km</span></div> 176 - </div> 177 - <div class="activity-title">${title}</div> 178 - ${this._renderGallery()} 179 - <div class="card-body"> 180 - ${post.text ? html`<p class="caption">${post.text}</p>` : ""} 181 - <div class="stats"> 182 - <span>⏱ ${formatDuration(post.stats?.duration ?? null)}</span> 183 - ${showElevation ? html`<span>📈 ${post.stats?.elevation}m elev</span>` : ""} 184 - <span>📍 ${post.location?.name ?? "—"}</span> 185 - </div> 186 - ${!this.hideLink 187 - ? html`<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` 188 - : ""} 189 - </div> 190 - </div> 191 - `; 192 - } 193 - 194 - private _renderTravel() { 195 - const post = this.post; 196 - const locationName = post.location?.name ?? "Other"; 197 - const bgColor = getColorForString(locationName); 198 - const bg = `linear-gradient(to bottom, ${bgColor}88, ${bgColor})`; 199 - const avatarColor = getColorForString(post.author.handle); 200 - return html` 201 - <div class="card"> 202 - <div class="travel-cover" style="background:${bg}"> 203 - <div> 204 - <div class="travel-cover-location">📍 ${post.location?.name ?? "Travel"}</div> 205 - <div class="travel-cover-title">${post.title ?? "Travel"}</div> 206 - </div> 207 - </div> 208 - ${this._renderGallery()} 209 - <div class="card-body"> 210 - <div class="travel-author"> 211 - <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 212 - <div> 213 - <div class="travel-author-name">${post.author.handle}</div> 214 - <div class="travel-author-meta">Travel · ${relativeTime(post.timestamp)}</div> 215 - </div> 216 - </div> 217 - <p class="caption">${post.text ?? "No description"}</p> 218 - ${!this.hideLink 219 - ? html`<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` 220 - : ""} 221 - </div> 222 - </div> 223 - `; 224 - } 225 - 226 - render() { 227 - if (!this.post) return html``; 228 - return this.post.activityType !== null ? this._renderActivity() : this._renderTravel(); 229 - } 230 - }
+12 -13
frontend/src/components/activity-feed.ts frontend/src/components/post-feed.ts
··· 1 1 import { LitElement, html, css } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 3 import { unsafeHTML } from "lit/directives/unsafe-html.js"; 4 - import { fetchAll, type Post } from "../api.ts"; 5 - import { filterPosts, skeletonCards } from "../utils.ts"; 4 + import { skeletonCards } from "../utils.ts"; 6 5 import { sharedStyles } from "./shared-styles.ts"; 6 + import type { PostRecord } from "../api.ts"; 7 7 8 8 @customElement("activity-feed") 9 9 export class ActivityFeed extends LitElement { ··· 23 23 24 24 @property({ attribute: "data-filter" }) filter = "all"; 25 25 26 - @state() private _posts: Post[] = []; 26 + @state() private _posts: PostRecord[] = []; 27 27 @state() private _loading = true; 28 28 @state() private _error = false; 29 29 @state() private _loggedIn = false; ··· 58 58 this._loading = true; 59 59 this._error = false; 60 60 const did = localStorage.getItem("atproto-did") ?? undefined; 61 - const pds = localStorage.getItem("atproto-pds") ?? undefined; 62 61 this._loggedIn = !!did; 63 62 64 63 if (!did) { ··· 67 66 return; 68 67 } 69 68 70 - let data; 71 - try { 72 - data = await fetchAll({ did, pds }); 73 - } catch { 74 - this._loading = false; 75 - this._error = true; 76 - return; 77 - } 78 - this._posts = filterPosts(data.posts, this.filter); 69 + // let data; 70 + // try { 71 + // data = await fetchAll({ did, pds }); 72 + // } catch { 73 + // this._loading = false; 74 + // this._error = true; 75 + // return; 76 + // } 77 + // this._posts = filterPosts(data.posts, this.filter); 79 78 this._loading = false; 80 79 } 81 80
+95 -25
frontend/src/components/compose-card.ts
··· 129 129 `, 130 130 ]; 131 131 132 - @state() private _serviceData: Awaited<ReturnType<typeof fetchServiceData>> | null = null; 132 + @state() private _serviceData: Awaited< 133 + ReturnType<typeof fetchServiceData> 134 + > | null = null; 133 135 @state() private _submitting = false; 134 136 @state() private _errorMessage = ""; 135 137 @state() private _activityType = ""; 136 138 @state() private _trail = ""; 137 - @state() private _images: Array<{ file: File; alt: string; previewUrl: string }> = []; 139 + @state() private _images: Array<{ 140 + file: File; 141 + alt: string; 142 + previewUrl: string; 143 + }> = []; 138 144 @state() private _userPds: string | null = null; 139 145 140 146 connectedCallback() { ··· 167 173 168 174 private _onAltChange(index: number, value: string) { 169 175 this._images = this._images.map((img, i) => 170 - i === index ? { ...img, alt: value } : img 176 + i === index ? { ...img, alt: value } : img, 171 177 ); 172 178 } 173 179 ··· 178 184 const form = e.target as HTMLFormElement; 179 185 const titleInput = form.querySelector<HTMLInputElement>('[name="title"]'); 180 186 const textInput = form.querySelector<HTMLTextAreaElement>('[name="text"]'); 181 - const distanceKm = parseFloat(form.querySelector<HTMLInputElement>('[name="distance"]')?.value ?? ""); 182 - const durationMin = parseFloat(form.querySelector<HTMLInputElement>('[name="duration"]')?.value ?? ""); 183 - const elevationM = parseFloat(form.querySelector<HTMLInputElement>('[name="elevation"]')?.value ?? ""); 187 + const distanceKm = parseFloat( 188 + form.querySelector<HTMLInputElement>('[name="distance"]')?.value ?? "", 189 + ); 190 + const durationMin = parseFloat( 191 + form.querySelector<HTMLInputElement>('[name="duration"]')?.value ?? "", 192 + ); 193 + const elevationM = parseFloat( 194 + form.querySelector<HTMLInputElement>('[name="elevation"]')?.value ?? "", 195 + ); 184 196 const stats = { 185 - distance: !isNaN(distanceKm) && distanceKm > 0 ? Math.round(distanceKm * 1000) : undefined, 186 - duration: !isNaN(durationMin) && durationMin > 0 ? Math.round(durationMin * 60) : undefined, 187 - elevation: !isNaN(elevationM) && elevationM !== 0 ? Math.round(elevationM) : undefined, 197 + distance: 198 + !isNaN(distanceKm) && distanceKm > 0 199 + ? Math.round(distanceKm * 1000) 200 + : undefined, 201 + duration: 202 + !isNaN(durationMin) && durationMin > 0 203 + ? Math.round(durationMin * 60) 204 + : undefined, 205 + elevation: 206 + !isNaN(elevationM) && elevationM !== 0 207 + ? Math.round(elevationM) 208 + : undefined, 188 209 }; 189 210 190 211 this._submitting = true; 191 212 this._errorMessage = ""; 192 213 193 214 try { 194 - const did = localStorage.getItem("atproto-did")! as `did:${string}:${string}`; 215 + const did = localStorage.getItem( 216 + "atproto-did", 217 + )! as `did:${string}:${string}`; 195 218 const handle = localStorage.getItem("atproto-handle") ?? did; 196 219 const session = await getSession(did, { allowStale: false }); 197 220 const agent = new OAuthUserAgent(session); 198 221 199 - const activityEntry = this._serviceData.activityMap.get(this._activityType); 222 + const activityEntry = this._serviceData.activityMap.get( 223 + this._activityType, 224 + ); 200 225 const trailEntry = this._serviceData.trailMap.get(this._trail); 201 226 202 227 if (!this._userPds) { 203 228 this._errorMessage = "Could not determine your PDS. Please try again."; 204 229 return; 205 230 } 231 + if (!activityEntry || !trailEntry) { 232 + this._errorMessage = "Could not find selected activity or trail."; 233 + return; 234 + } 206 235 207 236 await createPost(agent, { 208 237 did, ··· 212 241 trailEntry, 213 242 title: titleInput?.value.trim() || undefined, 214 243 text: textInput?.value.trim() || undefined, 215 - images: this._images.length > 0 216 - ? this._images.map(({ file, alt }) => ({ file, alt: alt || undefined })) 244 + images: 245 + this._images.length > 0 246 + ? this._images.map(({ file, alt }) => ({ 247 + file, 248 + alt: alt || undefined, 249 + })) 250 + : undefined, 251 + stats: Object.values(stats).some((v) => v !== undefined) 252 + ? stats 217 253 : undefined, 218 - stats: Object.values(stats).some((v) => v !== undefined) ? stats : undefined, 219 254 }); 220 255 221 256 for (const img of this._images) URL.revokeObjectURL(img.previewUrl); ··· 225 260 this._trail = ""; 226 261 this._images = []; 227 262 } catch (err: any) { 228 - this._errorMessage = err.description ?? err.message ?? "Failed to post. Please try again."; 263 + this._errorMessage = 264 + err.description ?? err.message ?? "Failed to post. Please try again."; 229 265 } finally { 230 266 this._submitting = false; 231 267 } ··· 255 291 .slice(0, 2) 256 292 .map((p) => (p[0] ?? "").toUpperCase()) 257 293 .join(""); 258 - const submitDisabled = !this._activityType || !this._trail || this._submitting; 294 + const submitDisabled = 295 + !this._activityType || !this._trail || this._submitting; 259 296 260 297 return html` 261 298 <div class="card"> 262 299 <form class="compose-form" @submit=${this._handleSubmit}> 263 300 <div class="compose-top-row"> 264 - <div class="avatar" style="background:${avatarColor}">${initials}</div> 301 + <div class="avatar" style="background:${avatarColor}"> 302 + ${initials} 303 + </div> 265 304 <div class="compose-selects"> 266 305 <select 267 306 name="activityType" 268 307 required 269 308 .value=${this._activityType} 270 - @change=${(e: Event) => { this._activityType = (e.target as HTMLSelectElement).value; }} 309 + @change=${(e: Event) => { 310 + this._activityType = (e.target as HTMLSelectElement).value; 311 + }} 271 312 > 272 313 <option value="">Select type…</option> 273 314 ${this._serviceData.activities.map( ··· 278 319 name="trail" 279 320 required 280 321 .value=${this._trail} 281 - @change=${(e: Event) => { this._trail = (e.target as HTMLSelectElement).value; }} 322 + @change=${(e: Event) => { 323 + this._trail = (e.target as HTMLSelectElement).value; 324 + }} 282 325 > 283 326 <option value="">Select trail…</option> 284 327 ${this._serviceData.trails.map( ··· 305 348 <div class="stats-row"> 306 349 <label> 307 350 Distance (km) 308 - <input name="distance" type="number" min="0" step="0.1" placeholder="0.0" ?disabled=${this._submitting} /> 351 + <input 352 + name="distance" 353 + type="number" 354 + min="0" 355 + step="0.1" 356 + placeholder="0.0" 357 + ?disabled=${this._submitting} 358 + /> 309 359 </label> 310 360 <label> 311 361 Duration (min) 312 - <input name="duration" type="number" min="0" step="1" placeholder="0" ?disabled=${this._submitting} /> 362 + <input 363 + name="duration" 364 + type="number" 365 + min="0" 366 + step="1" 367 + placeholder="0" 368 + ?disabled=${this._submitting} 369 + /> 313 370 </label> 314 371 <label> 315 372 Elevation (m) 316 - <input name="elevation" type="number" step="1" placeholder="0" ?disabled=${this._submitting} /> 373 + <input 374 + name="elevation" 375 + type="number" 376 + step="1" 377 + placeholder="0" 378 + ?disabled=${this._submitting} 379 + /> 317 380 </label> 318 381 </div> 319 382 <input ··· 336 399 maxlength="1000" 337 400 .value=${img.alt} 338 401 @input=${(e: Event) => 339 - this._onAltChange(i, (e.target as HTMLInputElement).value)} 402 + this._onAltChange( 403 + i, 404 + (e.target as HTMLInputElement).value, 405 + )} 340 406 ?disabled=${this._submitting} 341 407 /> 342 408 </div> 343 - ` 409 + `, 344 410 )} 345 411 </div> 346 412 ` ··· 349 415 ${this._errorMessage 350 416 ? html`<p class="compose-error-msg">${this._errorMessage}</p>` 351 417 : ""} 352 - <button type="submit" class="compose-submit" ?disabled=${submitDisabled}> 418 + <button 419 + type="submit" 420 + class="compose-submit" 421 + ?disabled=${submitDisabled} 422 + > 353 423 ${this._submitting ? "Posting…" : "Post activity"} 354 424 </button> 355 425 </div>
+211
frontend/src/components/post-card.ts
··· 1 + import { LitElement, html, css, unsafeCSS } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + import * as L from "leaflet"; 4 + import { GeoJSON } from "leaflet"; 5 + import leafletCss from "leaflet/dist/leaflet.css?inline"; 6 + import { 7 + formatDuration, 8 + decodePolyline, 9 + } from "../utils.ts"; 10 + import { sharedStyles } from "./shared-styles.ts"; 11 + import { fetchTrails, MAPPED_AT_DID, type PostRecord } from "../api.ts"; 12 + 13 + @customElement("activity-card") 14 + export class ActivityCard extends LitElement { 15 + static styles = [ 16 + sharedStyles, 17 + unsafeCSS(leafletCss), 18 + css` 19 + .user-info { 20 + flex: 1; 21 + min-width: 0; 22 + } 23 + .user-name { 24 + font-weight: 600; 25 + font-size: 14px; 26 + color: var(--color-text-primary); 27 + } 28 + .user-meta { 29 + font-size: 12px; 30 + color: var(--color-text-secondary); 31 + display: flex; 32 + align-items: center; 33 + gap: 4px; 34 + margin-top: 2px; 35 + } 36 + .distance { 37 + margin-left: auto; 38 + font-size: 18px; 39 + font-weight: 800; 40 + color: var(--color-text-primary); 41 + white-space: nowrap; 42 + } 43 + .distance span { 44 + font-size: 12px; 45 + font-weight: 500; 46 + color: var(--color-text-secondary); 47 + } 48 + .gallery-map { 49 + height: 160px; 50 + width: 240px; 51 + flex-shrink: 0; 52 + border-radius: 6px; 53 + overflow: hidden; 54 + } 55 + .activity-title { 56 + padding: 0 16px 12px; 57 + font-size: 16px; 58 + font-weight: 600; 59 + color: var(--color-text-primary); 60 + line-height: 1.3; 61 + } 62 + .travel-cover { 63 + height: 160px; 64 + display: flex; 65 + align-items: flex-end; 66 + padding: 14px 16px; 67 + } 68 + .travel-cover-location { 69 + color: rgba(255, 255, 255, 0.8); 70 + font-size: 11px; 71 + margin-bottom: 2px; 72 + } 73 + .travel-cover-title { 74 + color: #fff; 75 + font-size: 17px; 76 + font-weight: 700; 77 + } 78 + .travel-author { 79 + display: flex; 80 + align-items: center; 81 + gap: 10px; 82 + margin-bottom: 10px; 83 + } 84 + .travel-author .avatar { 85 + width: 28px; 86 + height: 28px; 87 + font-size: 11px; 88 + } 89 + .travel-author-name { 90 + font-weight: 600; 91 + font-size: 13px; 92 + color: var(--color-text-primary); 93 + } 94 + .travel-author-meta { 95 + font-size: 11px; 96 + color: var(--color-text-secondary); 97 + } 98 + .gallery { 99 + display: flex; 100 + gap: 4px; 101 + overflow-x: auto; 102 + padding: 0 16px 12px; 103 + } 104 + .gallery img { 105 + height: 160px; 106 + width: auto; 107 + max-width: 240px; 108 + border-radius: 6px; 109 + object-fit: cover; 110 + flex-shrink: 0; 111 + } 112 + `, 113 + ]; 114 + 115 + @property({ attribute: false }) post!: PostRecord; 116 + @property({ type: Boolean }) hideLink = false; 117 + 118 + private _renderGallery() { 119 + const { images, uri, pds } = this.post; 120 + const did = uri.split("/")[2]; 121 + return html` 122 + <div class="gallery"> 123 + <div class="activity-map gallery-map"></div> 124 + ${(images ?? []).map( 125 + ({ image, alt }) => html` 126 + <img 127 + src="${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( 128 + did, 129 + )}&cid=${encodeURIComponent(image.ref.$link)}" 130 + alt="${alt ?? ""}" 131 + /> 132 + `, 133 + )} 134 + </div> 135 + `; 136 + } 137 + 138 + async firstUpdated() { 139 + if (this.post?.trail) { 140 + const [trail] = await fetchTrails([ 141 + { did: MAPPED_AT_DID, rkey: this.post.trail.uri.split("/").pop()! }, 142 + ]); 143 + const mapEl = this.renderRoot.querySelector( 144 + ".activity-map", 145 + ) as HTMLDivElement | null; 146 + if (!mapEl || !trail?.polyline) return; 147 + const lMap = new L.Map(mapEl, { 148 + zoomControl: false, 149 + scrollWheelZoom: false, 150 + dragging: false, 151 + doubleClickZoom: false, 152 + }); 153 + new L.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 154 + maxZoom: 19, 155 + attribution: 156 + '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 157 + }).addTo(lMap); 158 + const layer = new GeoJSON(decodePolyline(trail.polyline), { 159 + style: { color: "#4a7c59", weight: 3, opacity: 0.85 }, 160 + }).addTo(lMap); 161 + lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 162 + } 163 + } 164 + 165 + render() { 166 + const post = this.post; 167 + const activityName = post.activity || "Activity"; 168 + // const pill = getPillConfig(activityName); 169 + const showElevation = (post.stats?.elevation || 0) > 0; 170 + // const avatarColor = getColorForString(post.author.handle); 171 + const title = post.title || `${activityName} Activity`; 172 + return html` 173 + <div class="card"> 174 + <div class="card-header"> 175 + <!-- <div class="avatar" style="background:$ {avatarColor}"> 176 + $ {post.author.initials} 177 + </div> 178 + <div class="user-info"> 179 + <div class="user-name">$ {post.author.handle}</div> 180 + <div class="user-meta"> 181 + <span class="pill $ {pill.cls}">${activityName}</span> 182 + <span>· $ {relativeTime(post.timestamp)}</span> 183 + </div> 184 + </div> 185 + <div class="distance"> 186 + ${post.stats?.distance ?? "—"} <span>km</span> 187 + </div> --> 188 + </div> 189 + <div class="activity-title">${title}</div> 190 + ${this._renderGallery()} 191 + <div class="card-body"> 192 + ${post.text ? html`<p class="caption">${post.text}</p>` : ""} 193 + <div class="stats"> 194 + <span>⏱ ${formatDuration(post.stats?.duration ?? null)}</span> 195 + ${showElevation 196 + ? html`<span>📈 ${post.stats?.elevation}m elev</span>` 197 + : ""} 198 + <span>📍 ${post.location?.name ?? "—"}</span> 199 + </div> 200 + ${!this.hideLink 201 + ? html`<a 202 + class="view-link" 203 + href="?view=post&id=${encodeURIComponent(post.uri)}" 204 + >View post →</a 205 + >` 206 + : ""} 207 + </div> 208 + </div> 209 + `; 210 + } 211 + }
+15 -7
frontend/src/components/post-detail.ts
··· 1 1 import { LitElement, html } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 - import { fetchAll, type Post } from "../api.ts"; 4 3 import { sharedStyles } from "./shared-styles.ts"; 4 + import { fetchPostByUri, type PostRecord } from "../api.ts"; 5 5 6 6 @customElement("post-detail") 7 7 export class PostDetail extends LitElement { ··· 9 9 10 10 @property() postId: string | null = null; 11 11 12 - @state() private _post: Post | null = null; 12 + @state() private _post: PostRecord | null = null; 13 13 @state() private _loading = true; 14 14 @state() private _error = false; 15 15 ··· 21 21 private async _load() { 22 22 this._loading = true; 23 23 this._error = false; 24 - let data; 24 + if (!this.postId) { 25 + this._loading = false; 26 + return; 27 + } 25 28 try { 26 - data = await fetchAll(); 29 + this._post = await fetchPostByUri(this.postId); 27 30 } catch { 28 31 this._loading = false; 29 32 this._error = true; 30 33 return; 31 34 } 32 - this._post = data.posts.find((p) => p.rkey === this.postId) ?? null; 33 35 this._loading = false; 34 36 } 35 37 ··· 38 40 <div class="detail-page"> 39 41 <button class="back-btn" @click=${() => history.back()}>← Back</button> 40 42 ${this._loading 41 - ? html`<div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div>` 43 + ? html`<div 44 + class="skeleton-map" 45 + style="border-radius:12px;margin-top:12px" 46 + ></div>` 42 47 : this._error 43 48 ? html`<p class="not-found">Failed to load post.</p>` 44 49 : this._post 45 - ? html`<activity-card .post=${this._post} .hideLink=${true}></activity-card>` 50 + ? html`<activity-card 51 + .post=${this._post} 52 + .hideLink=${true} 53 + ></activity-card>` 46 54 : html`<p class="not-found">Post not found.</p>`} 47 55 </div> 48 56 `;
+5 -2
frontend/src/components/tab-switcher.ts
··· 92 92 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); 93 93 background-repeat: no-repeat; 94 94 background-position: right 12px center; 95 - transition: color 0.2s ease, border-color 0.2s ease; 95 + transition: 96 + color 0.2s ease, 97 + border-color 0.2s ease; 96 98 } 97 99 98 100 .filter-row select:focus { ··· 104 106 ]; 105 107 106 108 @state() private _route: Route = getRoute(); 107 - @state() private _locations: Array<{ rkey: string; name: string | null }> = []; 109 + @state() private _locations: Array<{ rkey: string; name: string | null }> = 110 + []; 108 111 @state() private _filterOptions: Array<{ value: string; label: string }> = []; 109 112 110 113 private _unsubscribe: (() => void) | null = null;
+21 -11
frontend/src/components/trail-card.ts
··· 3 3 import * as L from "leaflet"; 4 4 import { GeoJSON } from "leaflet"; 5 5 import leafletCss from "leaflet/dist/leaflet.css?inline"; 6 - import { getPillConfig } from "../utils.ts"; 6 + import { decodePolyline } from "../utils.ts"; 7 7 import { sharedStyles } from "./shared-styles.ts"; 8 + import type { TrailRecord } from "../api.ts"; 8 9 9 10 @customElement("trail-card") 10 11 export class TrailCard extends LitElement { ··· 41 42 `, 42 43 ]; 43 44 44 - @property({ attribute: false }) trail: any; 45 + @property({ attribute: false }) trail: TrailRecord | null = null; 45 46 @property() trailKey: string = ""; 46 47 @property({ type: Boolean }) hideLink = false; 47 48 48 49 firstUpdated() { 49 - if (!this.trail) return; 50 - const mapEl = this.renderRoot.querySelector(".trail-map") as HTMLDivElement | null; 50 + console.log("TrailCard firstUpdated with trail:", this.trail); 51 + if (!this.trail?.polyline) { 52 + console.log( 53 + "Trail has no polyline, skipping map initialization", 54 + this.trail, 55 + ); 56 + return; 57 + } 58 + const mapEl = this.renderRoot.querySelector( 59 + ".trail-map", 60 + ) as HTMLDivElement | null; 51 61 if (!mapEl) return; 52 62 const lMap = new L.Map(mapEl, { 53 63 zoomControl: false, ··· 57 67 }); 58 68 new L.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 59 69 maxZoom: 19, 60 - attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 70 + attribution: 71 + '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 61 72 }).addTo(lMap); 62 - const layer = new GeoJSON(this.trail.geo, { 73 + const layer = new GeoJSON(decodePolyline(this.trail.polyline), { 63 74 style: { color: "#4a7c59", weight: 3, opacity: 0.85 }, 64 75 pointToLayer: (_feature, latlng) => 65 76 L.circleMarker(latlng, { ··· 76 87 77 88 render() { 78 89 if (!this.trail || !this.trailKey) return html``; 79 - const pill = this.trail.activityType ? getPillConfig(this.trail.activityType.name) : null; 90 + // const pill = this.trail.activity ? getPillConfig(this.trail.activityType.name) : null; 80 91 return html` 81 92 <div class="card"> 82 93 <div class="card-header"> 83 94 <div class="trail-header-content"> 84 95 <div class="trail-title">${this.trail.name || "Unknown Trail"}</div> 85 - ${pill 86 - ? html`<span class="pill ${pill.cls}">${this.trail.activityType.name}</span>` 87 - : ""} 88 96 </div> 89 97 </div> 90 98 <div class="trail-map"></div> 91 99 <div class="card-body"> 92 100 <div class="trail-info"> 93 101 ${!this.hideLink 94 - ? html`<a class="view-link" href="?view=trail&id=${this.trailKey}">View trail →</a>` 102 + ? html`<a class="view-link" href="?view=trail&id=${this.trailKey}" 103 + >View trail →</a 104 + >` 95 105 : ""} 96 106 </div> 97 107 </div>
+56 -20
frontend/src/components/trail-detail.ts
··· 1 - import { LitElement, html } from "lit"; 1 + import { LitElement, css, html } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 - import { fetchAll, type Post } from "../api.ts"; 4 3 import { sharedStyles } from "./shared-styles.ts"; 4 + import { 5 + collectPostRefs, 6 + fetchPosts, 7 + fetchTrails, 8 + MAPPED_AT_DID, 9 + type PostRecord, 10 + type TrailRecord, 11 + } from "../api.ts"; 5 12 6 13 @customElement("trail-detail") 7 14 export class TrailDetail extends LitElement { 8 - static styles = [sharedStyles]; 15 + static styles = [ 16 + sharedStyles, 17 + css` 18 + .related-posts { 19 + display: flex; 20 + flex-direction: column; 21 + gap: 1rem; 22 + } 23 + `, 24 + ]; 9 25 10 26 @property() trailId: string | null = null; 11 27 12 - @state() private _trail: any = null; 13 - @state() private _relatedPosts: Post[] = []; 28 + @state() private _trail: TrailRecord | null = null; 29 + @state() private _relatedPosts: PostRecord[] = []; 14 30 @state() private _loading = true; 15 31 @state() private _error = false; 16 32 ··· 22 38 private async _load() { 23 39 this._loading = true; 24 40 this._error = false; 25 - let data; 41 + if (!this.trailId) { 42 + this._loading = false; 43 + this._error = true; 44 + return; 45 + } 26 46 try { 27 - data = await fetchAll(); 47 + const [data] = await fetchTrails([ 48 + { did: MAPPED_AT_DID, rkey: this.trailId }, 49 + ]); 50 + this._trail = data; 51 + const backlinks = await collectPostRefs(this._trail.uri); 52 + console.log("Backlinks for trail", this._trail.uri, backlinks); 53 + const posts = await fetchPosts(backlinks); 54 + console.log("Fetched posts for backlinks:", posts); 55 + this._relatedPosts = posts; 28 56 } catch { 29 57 this._loading = false; 30 58 this._error = true; 31 - return; 32 59 } 33 - this._trail = data.trails.find((t: any) => t.rkey === this.trailId) ?? null; 34 - this._relatedPosts = data.posts.filter((p) => p.trail?.rkey === this.trailId); 60 + 35 61 this._loading = false; 36 62 } 37 63 ··· 40 66 <div class="detail-page"> 41 67 <button class="back-btn" @click=${() => history.back()}>← Back</button> 42 68 ${this._loading 43 - ? html`<div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div>` 69 + ? html`<div 70 + class="skeleton-map" 71 + style="border-radius:12px;margin-top:12px" 72 + ></div>` 44 73 : this._error 45 74 ? html`<p class="not-found">Failed to load trail.</p>` 46 75 : this._trail 47 76 ? html` 48 77 <trail-card 49 78 .trail=${this._trail} 50 - .trailKey=${this._trail.rkey} 79 + .trailKey=${this._trail.uri.split("/").slice(-1)[0]} 51 80 .hideLink=${true} 52 81 ></trail-card> 53 - ${this._relatedPosts.length > 0 54 - ? html` 55 - <h3 class="detail-section-heading">Posts on this trail</h3> 56 - ${this._relatedPosts.map( 57 - (post) => html`<activity-card .post=${post}></activity-card>`, 58 - )} 59 - ` 60 - : ""} 82 + <div class="related-posts"> 83 + ${this._relatedPosts.length > 0 84 + ? html` 85 + <h3 class="detail-section-heading"> 86 + Posts on this trail 87 + </h3> 88 + ${this._relatedPosts.map( 89 + (post) => 90 + html`<activity-card 91 + .post=${post} 92 + ></activity-card>`, 93 + )} 94 + ` 95 + : ""} 96 + </div> 61 97 ` 62 98 : html`<p class="not-found">Trail not found.</p>`} 63 99 </div>
+4 -1
frontend/src/components/trails-list.ts
··· 45 45 } 46 46 47 47 updated(changedProperties: Map<string, unknown>) { 48 - if (changedProperties.has("filter") && changedProperties.get("filter") !== undefined) { 48 + if ( 49 + changedProperties.has("filter") && 50 + changedProperties.get("filter") !== undefined 51 + ) { 49 52 this._load(); 50 53 } 51 54 }
+2 -2
frontend/src/main.ts
··· 14 14 import "./components/about-view.ts"; 15 15 import "./components/app-nav.ts"; 16 16 import "./components/app-root.ts"; 17 - import "./components/activity-card.ts"; 17 + import "./components/post-card.ts"; 18 18 import "./components/trail-card.ts"; 19 19 import "./components/tab-switcher.ts"; 20 20 import "./components/post-detail.ts"; 21 21 import "./components/trail-detail.ts"; 22 22 import "./components/trails-list.ts"; 23 23 import "./components/compose-card.ts"; 24 - import "./components/activity-feed.ts"; 24 + import "./components/post-feed.ts"; 25 25 26 26 // ── ATProto OAuth configuration ─────────────────────────────────── 27 27 configureOAuth({
+35 -12
frontend/src/utils.ts
··· 1 - import type { Post } from "./api.ts"; 1 + export function decodePolyline(encoded: string) { 2 + const coords = []; 3 + let index = 0, 4 + lat = 0, 5 + lng = 0; 6 + while (index < encoded.length) { 7 + let b, 8 + shift = 0, 9 + result = 0; 10 + do { 11 + b = encoded.charCodeAt(index++) - 63; 12 + result |= (b & 0x1f) << shift; 13 + shift += 5; 14 + } while (b >= 0x20); 15 + lat += result & 1 ? ~(result >> 1) : result >> 1; 16 + 17 + shift = 0; 18 + result = 0; 19 + do { 20 + b = encoded.charCodeAt(index++) - 63; 21 + result |= (b & 0x1f) << shift; 22 + shift += 5; 23 + } while (b >= 0x20); 24 + lng += result & 1 ? ~(result >> 1) : result >> 1; 25 + 26 + coords.push([lat / 1e5, lng / 1e5]); 27 + } 28 + return { 29 + type: "Feature", 30 + geometry: { 31 + type: "LineString", 32 + coordinates: coords.map(([la, lo]) => [lo, la]), 33 + }, 34 + } as const; 35 + } 2 36 3 - // ── Time formatting ─────────────────────────────────────────────── 4 37 export function relativeTime(date: number) { 5 38 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 6 39 const diffSeconds = (date - Date.now()) / 1000; ··· 20 53 return "just now"; 21 54 } 22 55 23 - // ── Duration formatting ─────────────────────────────────────────── 24 56 export function formatDuration(minutes: number | null) { 25 57 if (!minutes || minutes <= 0) return "—"; 26 58 const hours = Math.floor(minutes / 60); ··· 39 71 return `hsl(${hue}, 65%, 50%)`; 40 72 } 41 73 42 - // ── Activity pill config ────────────────────────────────────────── 43 74 const PILL_CONFIG = { 44 75 Hiking: { cls: "pill-green" }, 45 76 Running: { cls: "pill-green" }, ··· 55 86 ); 56 87 } 57 88 58 - // ── Post filtering ──────────────────────────────────────────────── 59 - export function filterPosts(posts: Post[], filter: string) { 60 - if (filter === "all") return posts; 61 - if (filter === "other") return posts.filter((p) => p.activityType === null); 62 - return posts.filter((p) => p.activityType?.name?.toLowerCase() === filter); 63 - } 64 - 65 - // ── Skeleton card HTML ──────────────────────────────────────────── 66 89 export function skeletonCards(n: number) { 67 90 return Array.from( 68 91 { length: n },