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

Configure Feed

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

Load data from PDS and Constallation (via mapped.at service account)

+468 -344
+289
api.js
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + PlcDidDocumentResolver, 4 + WebDidDocumentResolver, 5 + } from '@atcute/identity-resolver'; 6 + 7 + // ── Constants ───────────────────────────────────────────────────── 8 + const MAPPED_AT_DID = 'did:plc:l5m5nuh5cvdatyn5fjxar2sh'; 9 + const MAPPED_AT_PDS = 'https://leccinum.us-west.host.bsky.network'; 10 + const MAPPED_AT_BASE_POST = 'at://did:plc:l5m5nuh5cvdatyn5fjxar2sh/at.mapped.post/3mitem4c3p727' 11 + const CONSTELLATION_URL = 'https://constellation.microcosm.blue'; 12 + 13 + // ── decodePolyline ───────────────────────────────────────────────── 14 + // Decodes a Google encoded polyline string into a GeoJSON Feature. 15 + // GeoJSON uses [longitude, latitude] order; Leaflet's GeoJSON layer expects this. 16 + function decodePolyline(encoded) { 17 + const coords = []; 18 + let index = 0, lat = 0, lng = 0; 19 + while (index < encoded.length) { 20 + let b, shift = 0, result = 0; 21 + do { 22 + b = encoded.charCodeAt(index++) - 63; 23 + result |= (b & 0x1f) << shift; 24 + shift += 5; 25 + } while (b >= 0x20); 26 + lat += (result & 1) ? ~(result >> 1) : (result >> 1); 27 + 28 + shift = 0; result = 0; 29 + do { 30 + b = encoded.charCodeAt(index++) - 63; 31 + result |= (b & 0x1f) << shift; 32 + shift += 5; 33 + } while (b >= 0x20); 34 + lng += (result & 1) ? ~(result >> 1) : (result >> 1); 35 + 36 + coords.push([lat / 1e5, lng / 1e5]); 37 + } 38 + return { 39 + type: 'Feature', 40 + geometry: { 41 + type: 'LineString', 42 + coordinates: coords.map(([la, lo]) => [lo, la]), 43 + }, 44 + }; 45 + } 46 + 47 + // ── normaliseStats ───────────────────────────────────────────────── 48 + // Converts wire units (metres/seconds) to display units (km/minutes). 49 + function normaliseStats(stats) { 50 + if (!stats) return null; 51 + return { 52 + distance: stats.distance != null ? Math.round(stats.distance / 100) / 10 : null, 53 + duration: stats.duration != null ? Math.round(stats.duration / 60) : null, 54 + elevation: stats.elevation ?? null, 55 + }; 56 + } 57 + 58 + // ── Author helpers ───────────────────────────────────────────────── 59 + function toInitials(str) { 60 + const parts = str.split(/[.\-]/); 61 + return parts.slice(0, 2).map(p => (p[0] ?? '').toUpperCase()).join(''); 62 + } 63 + 64 + // Extracts { handle, initials } from a resolved DID document. 65 + // Falls back to a truncated DID if alsoKnownAs is absent. 66 + function authorFromDidDoc(did, doc) { 67 + const raw = doc?.alsoKnownAs?.[0]; 68 + if (raw) { 69 + const handle = raw.replace(/^at:\/\//, ''); 70 + return { handle, initials: toInitials(handle) }; 71 + } 72 + // fallback: first 4 chars of the method-specific part 73 + const short = did.split(':').slice(2).join(':').slice(0, 4); 74 + return { handle: did, initials: short.slice(0, 2).toUpperCase() }; 75 + } 76 + 77 + // Extracts the PDS endpoint URL from a DID document. 78 + function pdsFromDidDoc(doc) { 79 + const svc = doc?.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 80 + return svc?.serviceEndpoint ?? null; 81 + } 82 + 83 + // ── listRecords ──────────────────────────────────────────────────── 84 + // Fetches all records in a collection from the mapped.at service account PDS. 85 + async function listRecords(collection) { 86 + const url = `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 87 + `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 88 + const res = await fetch(url); 89 + if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 90 + return (await res.json()).records ?? []; 91 + } 92 + 93 + // ── fetchServiceData ─────────────────────────────────────────────── 94 + // Fetches trails, locations, activities from the service account and returns 95 + // URI→record lookup maps plus normalised arrays for public consumption. 96 + async function fetchServiceData() { 97 + const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 98 + listRecords('at.mapped.trail'), 99 + listRecords('at.mapped.location'), 100 + listRecords('at.mapped.activity'), 101 + ]); 102 + 103 + // Activity map: uri → { uri, rkey, name } 104 + const activityMap = new Map(); 105 + for (const { uri, value } of activityRecs) { 106 + const rkey = uri.split('/').pop(); 107 + activityMap.set(uri, { uri, rkey, name: value.name }); 108 + } 109 + 110 + // Location map: uri → { uri, rkey, name, lat, lng } 111 + const locationMap = new Map(); 112 + for (const { uri, value } of locationRecs) { 113 + const rkey = uri.split('/').pop(); 114 + locationMap.set(uri, { 115 + uri, rkey, 116 + name: value.name ?? null, 117 + lat: parseFloat(value.latitude), 118 + lng: parseFloat(value.longitude), 119 + }); 120 + } 121 + 122 + // Trail map: uri → { uri, rkey, name, activityType, locations, geo } 123 + // locations is an array of resolved location objects (used for the filter dropdown). 124 + const trailMap = new Map(); 125 + for (const { uri, value } of trailRecs) { 126 + const rkey = uri.split('/').pop(); 127 + const activityType = value.activityType?.uri 128 + ? activityMap.get(value.activityType.uri) ?? null 129 + : null; 130 + const locations = (value.locations ?? []) 131 + .map(ref => locationMap.get(ref.uri)) 132 + .filter(Boolean); 133 + trailMap.set(uri, { 134 + uri, rkey, 135 + name: value.name, 136 + activityType, 137 + locations, 138 + geo: value.polyline ? decodePolyline(value.polyline) : null, 139 + }); 140 + } 141 + 142 + const trails = [...trailMap.values()]; 143 + const locations = [...locationMap.values()]; 144 + const activities = [...activityMap.values()]; 145 + 146 + return { trailMap, locationMap, activityMap, trails, locations, activities }; 147 + } 148 + 149 + // ── collectPostRefs ──────────────────────────────────────────────── 150 + // Discovers all user posts that reference the mapped.at base post via basePost. 151 + // Returns an array of { did, rkey } unique post references. 152 + async function collectPostRefs() { 153 + const url = `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks` + 154 + `?subject=${encodeURIComponent(MAPPED_AT_BASE_POST)}&source=at.mapped.post:basePost.uri&limit=100`; 155 + const res = await fetch(url); 156 + if (!res.ok) return []; 157 + const links = (await res.json()).records ?? []; 158 + 159 + const seen = new Set(); 160 + const refs = []; 161 + for (const { did, rkey } of links) { 162 + const uri = `at://${did}/at.mapped.post/${rkey}`; 163 + if (seen.has(uri)) continue; 164 + seen.add(uri); 165 + refs.push({ did, rkey }); 166 + } 167 + return refs; 168 + } 169 + 170 + // ── DID resolver ─────────────────────────────────────────────────── 171 + const _didResolver = new CompositeDidDocumentResolver({ 172 + methods: { 173 + plc: new PlcDidDocumentResolver(), 174 + web: new WebDidDocumentResolver(), 175 + }, 176 + }); 177 + 178 + // ── resolveDidInfo ───────────────────────────────────────────────── 179 + // Resolves a DID to { author, pds }. 180 + // Gets the PDS endpoint from the DID document, then fetches the handle 181 + // from com.atproto.repo.describeRepo (more reliable than alsoKnownAs). 182 + // Returns null if resolution fails (post will be skipped). 183 + async function resolveDidInfo(did) { 184 + try { 185 + const didDoc = await _didResolver.resolve(did); 186 + const doc = didDoc?.document ?? didDoc; 187 + const pds = pdsFromDidDoc(doc); 188 + if (!pds) return null; 189 + 190 + // describeRepo returns the account's current handle directly 191 + const repoRes = await fetch( 192 + `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}` 193 + ); 194 + const handle = repoRes.ok ? (await repoRes.json()).handle ?? null : null; 195 + 196 + const author = handle 197 + ? { handle, initials: toInitials(handle) } 198 + : authorFromDidDoc(did, doc); // fallback to alsoKnownAs parsing 199 + 200 + return { author, pds }; 201 + } catch (err) { 202 + console.warn(`DID resolution failed for ${did}:`, err); 203 + return null; 204 + } 205 + } 206 + 207 + // ── fetchPostRecord ──────────────────────────────────────────────── 208 + // Fetches a single at.mapped.post record from its author's PDS. 209 + // Returns the raw record value, or null on failure. 210 + async function fetchPostRecord(pds, did, rkey) { 211 + try { 212 + const url = `${pds}/xrpc/com.atproto.repo.getRecord` + 213 + `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&rkey=${rkey}`; 214 + const res = await fetch(url); 215 + if (!res.ok) return null; 216 + return (await res.json()).value ?? null; 217 + } catch (err) { 218 + console.warn(`fetchPostRecord failed for at://${did}/at.mapped.post/${rkey}:`, err); 219 + return null; 220 + } 221 + } 222 + 223 + // ── _hydratePost ─────────────────────────────────────────────────── 224 + // Builds the normalised Post object from raw PDS value + lookup maps + author. 225 + function _hydratePost(did, rkey, value, author, { trailMap, locationMap, activityMap }) { 226 + const uri = `at://${did}/at.mapped.post/${rkey}`; 227 + const activityType = value.activity?.uri 228 + ? activityMap.get(value.activity.uri) ?? null 229 + : null; 230 + const location = value.location?.uri 231 + ? locationMap.get(value.location.uri) ?? null 232 + : null; 233 + const trail = value.trail?.uri 234 + ? trailMap.get(value.trail.uri) ?? null 235 + : null; 236 + 237 + return { 238 + uri, 239 + rkey, 240 + author, 241 + title: value.title ?? null, 242 + text: value.text ?? null, 243 + timestamp: new Date(value.timestamp), 244 + activityType, 245 + location, 246 + trail, 247 + stats: normaliseStats(value.stats ?? null), 248 + }; 249 + } 250 + 251 + // ── fetchAll ─────────────────────────────────────────────────────── 252 + // Main entry point. Fetches all mapped.at data and returns normalised arrays. 253 + // Returns a cached Promise — safe to call from multiple components simultaneously. 254 + let _promise = null; 255 + export function fetchAll() { 256 + if (!_promise) _promise = _fetchAll(); 257 + return _promise; 258 + } 259 + 260 + async function _fetchAll() { 261 + // Step 1: Service account data (parallel) 262 + const serviceData = await fetchServiceData(); 263 + const { trails, locations, activities } = serviceData; 264 + 265 + // Step 2: Discover posts via basePost backlinks on the mapped.at base post 266 + const postRefs = await collectPostRefs(); 267 + 268 + // Step 3: Resolve each unique DID (parallel) 269 + const uniqueDids = [...new Set(postRefs.map(r => r.did))]; 270 + const didInfoMap = new Map(); 271 + await Promise.all(uniqueDids.map(async did => { 272 + const info = await resolveDidInfo(did); 273 + if (info) didInfoMap.set(did, info); 274 + })); 275 + 276 + // Step 4: Fetch each post record from its author's PDS (parallel) 277 + const posts = (await Promise.all(postRefs.map(async ({ did, rkey }) => { 278 + const info = didInfoMap.get(did); 279 + if (!info) return null; // DID resolution failed — skip this post 280 + const value = await fetchPostRecord(info.pds, did, rkey); 281 + if (!value) return null; 282 + return _hydratePost(did, rkey, value, info.author, serviceData); 283 + }))).filter(Boolean); 284 + 285 + // Sort posts newest first 286 + posts.sort((a, b) => b.timestamp - a.timestamp); 287 + 288 + return { trails, locations, activities, posts }; 289 + }
-223
data.js
··· 1 - // ── Test data ───────────────────────────────────────────────────── 2 - // Activity types (at.mapped.test.activityType records) 3 - export function getActivityTypes() { 4 - return { 5 - hiking: { name: 'Hiking', description: 'Walking in natural terrain' }, 6 - running: { name: 'Running', description: 'Running on trails or roads' }, 7 - cycling: { name: 'Cycling', description: 'Riding a bicycle' }, 8 - kayaking: { name: 'Kayaking', description: 'Paddling in water' }, 9 - }; 10 - } 11 - 12 - // Locations (at.mapped.test.location records) 13 - export function getLocations() { 14 - return { 15 - blueRidge: { name: 'Blue Ridge, VA', geo: { type: 'Point', coordinates: [-80.02, 37.17] } }, 16 - alpeHuez: { name: "Alpe d'Huez, FR", geo: { type: 'Point', coordinates: [6.052, 45.073] } }, 17 - torresdelPaine: { name: 'Torres del Paine, Chile', geo: { type: 'Point', coordinates: [-72.95, -51.00] } }, 18 - pugetSound: { name: 'Puget Sound, WA', geo: { type: 'Point', coordinates: [-122.42, 47.65] } }, 19 - shenandoah: { name: 'Shenandoah NP, VA', geo: { type: 'Point', coordinates: [-78.35, 38.53] } }, 20 - dolomites: { name: 'Dolomites, Italy', geo: { type: 'Point', coordinates: [12.31, 46.41] } }, 21 - olympicNP: { name: 'Olympic NP, WA', geo: { type: 'Point', coordinates: [-123.60, 47.80] } }, 22 - }; 23 - } 24 - 25 - // Trails (at.mapped.test.trail records) 26 - export function getTrails() { 27 - const activityTypes = getActivityTypes(); 28 - const locations = getLocations(); 29 - return { 30 - blueRidgeTrail: { 31 - name: 'Blue Ridge Trail', 32 - activityType: activityTypes.hiking, 33 - location: locations.blueRidge, 34 - geo: { 35 - type: 'Feature', 36 - geometry: { 37 - type: 'LineString', 38 - coordinates: [ 39 - [-80.05, 37.15], 40 - [-80.04, 37.16], 41 - [-80.02, 37.17], 42 - [-80.00, 37.18], 43 - [-79.99, 37.19], 44 - ], 45 - }, 46 - }, 47 - }, 48 - alpeHuezRoute: { 49 - name: "Alpe d'Huez Route", 50 - activityType: activityTypes.cycling, 51 - location: locations.alpeHuez, 52 - geo: { 53 - type: 'Feature', 54 - geometry: { 55 - type: 'LineString', 56 - coordinates: [ 57 - [6.027, 45.055], 58 - [6.034, 45.061], 59 - [6.042, 45.067], 60 - [6.051, 45.073], 61 - [6.060, 45.080], 62 - [6.071, 45.091], 63 - ], 64 - }, 65 - }, 66 - }, 67 - pugetSoundRoute: { 68 - name: 'Puget Sound Route', 69 - activityType: activityTypes.kayaking, 70 - location: locations.pugetSound, 71 - geo: { 72 - type: 'Feature', 73 - geometry: { 74 - type: 'LineString', 75 - coordinates: [ 76 - [-122.45, 47.63], 77 - [-122.43, 47.64], 78 - [-122.42, 47.65], 79 - [-122.41, 47.66], 80 - [-122.40, 47.67], 81 - ], 82 - }, 83 - }, 84 - }, 85 - shenandoahTrail: { 86 - name: 'Shenandoah Trail', 87 - activityType: activityTypes.running, 88 - location: locations.shenandoah, 89 - geo: { 90 - type: 'Feature', 91 - geometry: { 92 - type: 'LineString', 93 - coordinates: [ 94 - [-78.38, 38.51], 95 - [-78.36, 38.52], 96 - [-78.35, 38.53], 97 - [-78.34, 38.54], 98 - [-78.32, 38.55], 99 - ], 100 - }, 101 - }, 102 - }, 103 - olympicRoute: { 104 - name: 'Olympic Route', 105 - activityType: activityTypes.hiking, 106 - location: locations.olympicNP, 107 - geo: { 108 - type: 'Feature', 109 - geometry: { 110 - type: 'LineString', 111 - coordinates: [ 112 - [-123.63, 47.78], 113 - [-123.61, 47.79], 114 - [-123.60, 47.80], 115 - [-123.59, 47.81], 116 - [-123.57, 47.82], 117 - ], 118 - }, 119 - }, 120 - }, 121 - }; 122 - } 123 - 124 - // Posts (at.mapped.test.post records) 125 - export function getPosts() { 126 - const activityTypes = getActivityTypes(); 127 - const locations = getLocations(); 128 - const trails = getTrails(); 129 - 130 - return [ 131 - { 132 - id: '1', 133 - uri: 'at://did:plc:samk/at.mapped.test.post/1', 134 - cid: 'bafy1', 135 - author: { name: 'Sam K.', initials: 'SK' }, 136 - title: 'Ridge Trail Summit', 137 - text: 'Perfect morning on the ridge trail. The fog was rolling in just as I reached the summit.', 138 - timestamp: new Date(Date.now() - 5 * 3600 * 1000), 139 - activityType: activityTypes.hiking, 140 - location: locations.blueRidge, 141 - trail: trails.blueRidgeTrail, 142 - stats: { distance: 12.4, duration: 222, elevation: 480 }, 143 - }, 144 - { 145 - id: '2', 146 - uri: 'at://did:plc:alexr/at.mapped.test.post/2', 147 - cid: 'bafy2', 148 - author: { name: 'Alex R.', initials: 'AR' }, 149 - title: 'Alpe d\'Huez Climb', 150 - text: null, 151 - timestamp: new Date(Date.now() - 26 * 3600 * 1000), 152 - activityType: activityTypes.cycling, 153 - location: locations.alpeHuez, 154 - trail: trails.alpeHuezRoute, 155 - stats: { distance: 62.4, duration: 138, elevation: 820 }, 156 - }, 157 - { 158 - id: '3', 159 - uri: 'at://did:plc:maria/at.mapped.test.post/3', 160 - cid: 'bafy3', 161 - author: { name: 'Maria L.', initials: 'ML' }, 162 - title: 'Week 2 in Patagonia', 163 - text: 'Woke up to frost on the tent and an absolutely still lake. The kind of morning that makes you forget what day it is.', 164 - timestamp: new Date(Date.now() - 3 * 24 * 3600 * 1000), 165 - activityType: null, // Travel post (no activity type) 166 - location: locations.torresdelPaine, 167 - trail: null, 168 - stats: null, 169 - }, 170 - { 171 - id: '4', 172 - uri: 'at://did:plc:jordanp/at.mapped.test.post/4', 173 - cid: 'bafy4', 174 - author: { name: 'Jordan P.', initials: 'JP' }, 175 - title: 'Seal Colony Paddle', 176 - text: 'Spotted a seal colony on the eastern shore. Paddled for 3 hours without checking my phone once.', 177 - timestamp: new Date(Date.now() - 2 * 24 * 3600 * 1000), 178 - activityType: activityTypes.kayaking, 179 - location: locations.pugetSound, 180 - trail: trails.pugetSoundRoute, 181 - stats: { distance: 18.2, duration: 185, elevation: 0 }, 182 - }, 183 - { 184 - id: '5', 185 - uri: 'at://did:plc:samk/at.mapped.test.post/5', 186 - cid: 'bafy5', 187 - author: { name: 'Sam K.', initials: 'SK' }, 188 - title: 'Shenandoah Run', 189 - text: null, 190 - timestamp: new Date(Date.now() - 4 * 24 * 3600 * 1000), 191 - activityType: activityTypes.running, 192 - location: locations.shenandoah, 193 - trail: trails.shenandoahTrail, 194 - stats: { distance: 9.2, duration: 52, elevation: 320 }, 195 - }, 196 - { 197 - id: '6', 198 - uri: 'at://did:plc:alexr/at.mapped.test.post/6', 199 - cid: 'bafy6', 200 - author: { name: 'Alex R.', initials: 'AR' }, 201 - title: 'Dolomites Traverse', 202 - text: 'Three passes in two days. My legs are destroyed but the views were worth every metre of climbing.', 203 - timestamp: new Date(Date.now() - 7 * 24 * 3600 * 1000), 204 - activityType: null, // Travel post 205 - location: locations.dolomites, 206 - trail: null, 207 - stats: null, 208 - }, 209 - { 210 - id: '7', 211 - uri: 'at://did:plc:jordanp/at.mapped.test.post/7', 212 - cid: 'bafy7', 213 - author: { name: 'Jordan P.', initials: 'JP' }, 214 - title: 'Olympic Ridge Push', 215 - text: 'Hoh Rainforest to Hurricane Ridge in one push. Absolutely brutal. Would do again.', 216 - timestamp: new Date(Date.now() - 9 * 24 * 3600 * 1000), 217 - activityType: activityTypes.hiking, 218 - location: locations.olympicNP, 219 - trail: trails.olympicRoute, 220 - stats: { distance: 28.7, duration: 555, elevation: 1240 }, 221 - }, 222 - ]; 223 - }
+6 -19
lexicons/at/mapped/post.json
··· 27 27 "format": "datetime", 28 28 "description": "When the post was created" 29 29 }, 30 - "activityType": { 30 + "activity": { 31 31 "type": "ref", 32 32 "ref": "com.atproto.repo.strongRef", 33 - "description": "Reference to an activityType record. If present, this is an activity post; if absent, it's a travel post" 33 + "description": "Reference to an activity record. If present, this is an activity post; if absent, it's a travel post" 34 34 }, 35 35 "location": { 36 36 "type": "ref", ··· 42 42 "ref": "com.atproto.repo.strongRef", 43 43 "description": "Reference to a trail record with route geometry" 44 44 }, 45 - "stats": { 46 - "type": "object", 47 - "description": "Activity statistics (distance, duration, elevation). Only relevant for activity posts", 48 - "properties": { 49 - "distance": { 50 - "type": "integer", 51 - "description": "Distance covered in meters" 52 - }, 53 - "duration": { 54 - "type": "integer", 55 - "description": "Duration of the activity in seconds" 56 - }, 57 - "elevation": { 58 - "type": "integer", 59 - "description": "Elevation gain in meters" 60 - } 61 - } 45 + "basePost": { 46 + "type": "ref", 47 + "ref": "com.atproto.repo.strongRef", 48 + "description": "Reference to another post that this post is based on (e.g. a travel post based on an activity post)" 62 49 } 63 50 } 64 51 }
+130 -102
script.js
··· 15 15 XrpcHandleResolver, 16 16 } from '@atcute/identity-resolver'; 17 17 import { getRoute, navigate, onRouteChange } from './router.js'; 18 - import { getLocations, getTrails, getPosts } from './data.js'; 18 + import { fetchAll } from './api.js'; 19 19 20 20 // ── ATProto OAuth configuration ─────────────────────────────────── 21 21 configureOAuth({ ··· 362 362 class PostDetail extends HTMLElement { 363 363 set postId(value) { this._postId = value; } 364 364 365 - connectedCallback() { 365 + async connectedCallback() { 366 366 const shadow = this.attachShadow({ mode: 'open' }); 367 - const post = getPosts().find(p => p.id === this._postId); 368 - 369 - if (!post) { 370 - shadow.innerHTML = /*html*/` 371 - <link rel="stylesheet" href="styles.css"> 372 - <div class="detail-page"> 373 - <p class="not-found">Post not found.</p> 374 - </div> 375 - `; 376 - return; 377 - } 378 - 379 367 shadow.innerHTML = /*html*/` 380 368 <link rel="stylesheet" href="styles.css"> 381 369 <div class="detail-page"> 382 370 <button class="back-btn">← Back</button> 371 + <div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div> 383 372 </div> 384 373 `; 374 + shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 375 + 376 + let data; 377 + try { 378 + data = await fetchAll(); 379 + } catch { 380 + shadow.querySelector('.skeleton-map').outerHTML = 381 + '<p class="not-found">Failed to load post.</p>'; 382 + return; 383 + } 384 + 385 + const post = data.posts.find(p => p.rkey === this._postId); 386 + shadow.querySelector('.skeleton-map')?.remove(); 387 + 388 + if (!post) { 389 + shadow.querySelector('.detail-page').insertAdjacentHTML( 390 + 'beforeend', '<p class="not-found">Post not found.</p>' 391 + ); 392 + return; 393 + } 385 394 386 395 const card = document.createElement('activity-card'); 387 396 card.post = post; 388 397 shadow.querySelector('.detail-page').appendChild(card); 389 - 390 - shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 391 398 } 392 399 } 393 400 ··· 433 440 const activityName = post.activityType?.name || 'Activity'; 434 441 const pill = getPillConfig(activityName); 435 442 const showElevation = post.stats?.elevation > 0; 436 - const avatarColor = getColorForString(post.author.name); 443 + const avatarColor = getColorForString(post.author.handle); 437 444 const title = post.title || `${activityName} Activity`; 438 445 return /*html*/` 439 446 <div class="card"> 440 447 <div class="card-header"> 441 448 <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 442 449 <div class="user-info"> 443 - <div class="user-name">${post.author.name}</div> 450 + <div class="user-name">${post.author.handle}</div> 444 451 <div class="user-meta"> 445 452 <span class="pill ${pill.cls}">${activityName}</span> 446 453 <span>· ${relativeTime(post.timestamp)}</span> ··· 457 464 ${showElevation ? `<span>📈 ${post.stats.elevation}m elev</span>` : ''} 458 465 <span>📍 ${post.location?.name ?? '—'}</span> 459 466 </div> 460 - <a class="view-link" href="?view=post&id=${post.id}">View post →</a> 467 + <a class="view-link" href="?view=post&id=${post.rkey}">View post →</a> 461 468 </div> 462 469 </div> 463 470 `; ··· 468 475 const locationName = post.location?.name ?? 'Other'; 469 476 const bgColor = getColorForString(locationName); 470 477 const bg = `linear-gradient(to bottom, ${bgColor}88, ${bgColor})`; 471 - const avatarColor = getColorForString(post.author.name); 478 + const avatarColor = getColorForString(post.author.handle); 472 479 473 480 return /*html*/` 474 481 <div class="card"> ··· 482 489 <div class="travel-author"> 483 490 <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 484 491 <div> 485 - <div class="travel-author-name">${post.author.name}</div> 492 + <div class="travel-author-name">${post.author.handle}</div> 486 493 <div class="travel-author-meta">Travel · ${relativeTime(post.timestamp)}</div> 487 494 </div> 488 495 </div> 489 496 <p class="caption">${post.text ?? 'No description'}</p> 490 - <a class="view-link" href="?view=post&id=${post.id}">View post →</a> 497 + <a class="view-link" href="?view=post&id=${post.rkey}">View post →</a> 491 498 </div> 492 499 </div> 493 500 `; ··· 553 560 const filterRow = this.shadowRoot.querySelector('.filter-row'); 554 561 filterRow.style.display = route.view === 'about' ? 'none' : ''; 555 562 556 - // Rebuild dropdown options for current view 557 563 const select = this.shadowRoot.querySelector('select'); 558 564 select.innerHTML = ''; 565 + select.disabled = true; 559 566 560 567 if (route.view === 'trails') { 561 - const defaultOpt = document.createElement('option'); 562 - defaultOpt.value = ''; 563 - defaultOpt.textContent = 'All Locations'; 564 - select.appendChild(defaultOpt); 568 + fetchAll().then(({ locations }) => { 569 + select.disabled = false; 570 + const defaultOpt = document.createElement('option'); 571 + defaultOpt.value = ''; 572 + defaultOpt.textContent = 'All Locations'; 573 + select.appendChild(defaultOpt); 565 574 566 - const seen = new Set(); 567 - for (const trail of Object.values(getTrails())) { 568 - if (!trail.location) continue; 569 - const locationKey = Object.entries(getLocations()).find( 570 - ([, loc]) => loc.name === trail.location.name 571 - )?.[0]; 572 - if (!locationKey || seen.has(locationKey)) continue; 573 - seen.add(locationKey); 574 - const opt = document.createElement('option'); 575 - opt.value = locationKey; 576 - opt.textContent = trail.location.name; 577 - select.appendChild(opt); 578 - } 575 + const seen = new Set(); 576 + for (const loc of locations) { 577 + if (seen.has(loc.rkey)) continue; 578 + seen.add(loc.rkey); 579 + const opt = document.createElement('option'); 580 + opt.value = loc.rkey; 581 + opt.textContent = loc.name ?? loc.rkey; 582 + select.appendChild(opt); 583 + } 584 + select.value = route.filter ?? ''; 585 + }).catch(() => { select.disabled = false; }); 579 586 } else { 587 + select.disabled = false; 580 588 const defaultOpt = document.createElement('option'); 581 589 defaultOpt.value = ''; 582 590 defaultOpt.textContent = 'All Activity'; ··· 588 596 opt.textContent = label; 589 597 select.appendChild(opt); 590 598 }); 599 + select.value = route.filter ?? ''; 591 600 } 592 - 593 - select.value = route.filter ?? ''; 594 601 } 595 602 } 596 603 ··· 601 608 class TrailDetail extends HTMLElement { 602 609 set trailId(value) { this._trailId = value; } 603 610 604 - connectedCallback() { 611 + async connectedCallback() { 605 612 const shadow = this.attachShadow({ mode: 'open' }); 606 - const trail = getTrails()[this._trailId]; 607 - 608 - if (!trail) { 609 - shadow.innerHTML = /*html*/` 610 - <link rel="stylesheet" href="styles.css"> 611 - <div class="detail-page"> 612 - <p class="not-found">Trail not found.</p> 613 - </div> 614 - `; 615 - return; 616 - } 617 - 618 613 shadow.innerHTML = /*html*/` 619 614 <link rel="stylesheet" href="styles.css"> 620 615 <div class="detail-page"> 621 616 <button class="back-btn">← Back</button> 617 + <div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div> 622 618 </div> 623 619 `; 620 + shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 621 + 622 + let data; 623 + try { 624 + data = await fetchAll(); 625 + } catch { 626 + shadow.querySelector('.skeleton-map').outerHTML = 627 + '<p class="not-found">Failed to load trail.</p>'; 628 + return; 629 + } 630 + 631 + const trail = data.trails.find(t => t.rkey === this._trailId); 632 + shadow.querySelector('.skeleton-map')?.remove(); 633 + 634 + if (!trail) { 635 + shadow.querySelector('.detail-page').insertAdjacentHTML( 636 + 'beforeend', '<p class="not-found">Trail not found.</p>' 637 + ); 638 + return; 639 + } 624 640 625 641 const card = document.createElement('trail-card'); 626 642 card.trail = trail; 627 - card.trailKey = this._trailId; 643 + card.trailKey = trail.rkey; 628 644 shadow.querySelector('.detail-page').appendChild(card); 629 - 630 - shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 631 645 } 632 646 } 633 647 ··· 717 731 <link rel="stylesheet" href="styles.css"> 718 732 <link rel="stylesheet" href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css"> 719 733 <div class="trails-container"> 720 - <div class="trails-list"></div> 734 + <div class="trails-list">${_skeletonCards(3)}</div> 721 735 </div> 722 736 `; 723 - this._render(); 737 + this._load(shadow); 724 738 725 - const observer = new MutationObserver(() => this._render()); 739 + const observer = new MutationObserver(() => this._load(shadow)); 726 740 observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 727 741 } 728 742 729 - _getFilteredTrails() { 743 + async _load(shadow) { 744 + let data; 745 + try { 746 + data = await fetchAll(); 747 + } catch (err) { 748 + shadow.querySelector('.trails-list').innerHTML = 749 + `<p style="text-align:center;color:#999;padding:32px">Failed to load trails.</p>`; 750 + return; 751 + } 730 752 const filter = this.getAttribute('data-filter'); 731 - const trails = Object.entries(getTrails()); 732 - if (!filter) return trails; 733 - 734 - return trails.filter(([, trail]) => { 735 - if (!trail.location) return false; 736 - const locationKey = Object.entries(getLocations()).find( 737 - ([, loc]) => loc.name === trail.location.name 738 - )?.[0]; 739 - return locationKey === filter; 740 - }); 741 - } 753 + const trails = filter 754 + ? data.trails.filter(t => t.locations.some(l => l.rkey === filter)) 755 + : data.trails; 742 756 743 - _render() { 744 - const container = this.shadowRoot.querySelector('.trails-list'); 757 + const container = shadow.querySelector('.trails-list'); 745 758 container.innerHTML = ''; 746 - 747 - for (const [key, trail] of this._getFilteredTrails()) { 759 + for (const trail of trails) { 748 760 const card = document.createElement('trail-card'); 749 - card.trailKey = key; 761 + card.trailKey = trail.rkey; 750 762 card.trail = trail; 751 763 container.appendChild(card); 752 764 } ··· 755 767 756 768 customElements.define('trails-list', TrailsList); 757 769 758 - // ── Activity Feed ────────────────────────────────────────────────── 770 + // ── <activity-feed> ─────────────────────────────────────────────── 759 771 class ActivityFeed extends HTMLElement { 760 772 connectedCallback() { 761 773 const shadow = this.attachShadow({ mode: 'open' }); 762 774 shadow.innerHTML = /*html*/` 763 775 <link rel="stylesheet" href="styles.css"> 764 - <div class="feed"></div> 776 + <div class="feed">${_skeletonCards(3)}</div> 765 777 `; 766 - this._render(); 778 + this._load(shadow); 767 779 768 - // Watch for filter changes 769 - const observer = new MutationObserver(() => this._render()); 780 + const observer = new MutationObserver(() => this._load(shadow)); 770 781 observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 771 782 } 772 783 773 - _getFilteredPosts() { 774 - const filter = this.getAttribute('data-filter') || 'all'; 775 - 776 - if (filter === 'all') return getPosts(); 777 - if (filter === 'other') return getPosts().filter(post => post.activityType === null); 778 - 779 - // For specific activity filters, check the activity type name 780 - return getPosts().filter(post => { 781 - if (post.activityType === null) return false; 782 - const activityNameLower = post.activityType.name?.toLowerCase(); 783 - return activityNameLower === filter; 784 - }); 785 - } 786 - 787 - _render() { 788 - const shadow = this.shadowRoot; 784 + async _load(shadow) { 785 + let data; 786 + try { 787 + data = await fetchAll(); 788 + } catch (err) { 789 + shadow.querySelector('.feed').innerHTML = 790 + `<p style="text-align:center;color:#999;padding:32px">Failed to load posts.</p>`; 791 + return; 792 + } 789 793 const feed = shadow.querySelector('.feed'); 790 794 feed.innerHTML = ''; 791 - 792 - const filtered = this._getFilteredPosts(); 793 - for (const post of filtered) { 795 + const filter = this.getAttribute('data-filter') || 'all'; 796 + for (const post of _filterPosts(data.posts, filter)) { 794 797 const card = document.createElement('activity-card'); 795 798 card.post = post; 796 799 feed.appendChild(card); ··· 799 802 } 800 803 801 804 customElements.define('activity-feed', ActivityFeed); 805 + 806 + function _filterPosts(posts, filter) { 807 + if (filter === 'all') return posts; 808 + if (filter === 'other') return posts.filter(p => p.activityType === null); 809 + return posts.filter(p => p.activityType?.name?.toLowerCase() === filter); 810 + } 811 + 812 + function _skeletonCards(n) { 813 + return Array.from({ length: n }, () => /*html*/` 814 + <div class="card"> 815 + <div class="card-header"> 816 + <div class="skeleton-circle"></div> 817 + <div class="skeleton-lines"> 818 + <div class="skeleton-line w60"></div> 819 + <div class="skeleton-line w40"></div> 820 + </div> 821 + </div> 822 + <div class="skeleton-map"></div> 823 + <div class="card-body"> 824 + <div class="skeleton-line w80" style="margin-bottom:8px"></div> 825 + <div class="skeleton-line w50"></div> 826 + </div> 827 + </div> 828 + `).join(''); 829 + }
+43
styles.css
··· 728 728 .view-link:hover { 729 729 text-decoration: underline; 730 730 } 731 + 732 + /* ── Skeleton loading ────────────────────────────────────────────── */ 733 + @keyframes shimmer { 734 + 0% { background-position: 200% 0; } 735 + 100% { background-position: -200% 0; } 736 + } 737 + 738 + .skeleton-circle { 739 + width: 38px; 740 + height: 38px; 741 + border-radius: 50%; 742 + flex-shrink: 0; 743 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 744 + background-size: 200% 100%; 745 + animation: shimmer 1.4s infinite; 746 + } 747 + 748 + .skeleton-lines { 749 + flex: 1; 750 + display: flex; 751 + flex-direction: column; 752 + gap: 6px; 753 + } 754 + 755 + .skeleton-line { 756 + height: 12px; 757 + border-radius: 4px; 758 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 759 + background-size: 200% 100%; 760 + animation: shimmer 1.4s infinite; 761 + } 762 + 763 + .skeleton-line.w80 { width: 80%; } 764 + .skeleton-line.w60 { width: 60%; } 765 + .skeleton-line.w50 { width: 50%; } 766 + .skeleton-line.w40 { width: 40%; } 767 + 768 + .skeleton-map { 769 + height: 140px; 770 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 771 + background-size: 200% 100%; 772 + animation: shimmer 1.4s infinite; 773 + }