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

Configure Feed

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

Add stats and images to posts

+278 -8
+49 -1
lexicons/at/mapped/post.json
··· 46 46 "type": "ref", 47 47 "ref": "com.atproto.repo.strongRef", 48 48 "description": "Reference to another post that this post is based on (e.g. a travel post based on an activity post)" 49 + }, 50 + "images": { 51 + "type": "array", 52 + "description": "Optional images attached to the post", 53 + "items": { 54 + "type": "ref", 55 + "ref": "#image" 56 + } 57 + }, 58 + "stats": { 59 + "type": "ref", 60 + "ref": "#stats", 61 + "description": "Optional activity statistics" 49 62 } 50 63 } 51 64 } 65 + }, 66 + "stats": { 67 + "type": "object", 68 + "properties": { 69 + "distance": { 70 + "type": "integer", 71 + "description": "Distance in metres", 72 + "minimum": 0 73 + }, 74 + "duration": { 75 + "type": "integer", 76 + "description": "Duration in seconds", 77 + "minimum": 0 78 + }, 79 + "elevation": { 80 + "type": "integer", 81 + "description": "Elevation gain in metres" 82 + } 83 + } 84 + }, 85 + "image": { 86 + "type": "object", 87 + "required": ["image"], 88 + "properties": { 89 + "image": { 90 + "type": "blob", 91 + "accept": ["image/*"], 92 + "description": "The image blob" 93 + }, 94 + "alt": { 95 + "type": "string", 96 + "description": "Alt text for accessibility", 97 + "maxLength": 1000 98 + } 99 + } 52 100 } 53 101 } 54 - } 102 + }
+58 -2
src/api.ts
··· 247 247 } 248 248 } 249 249 250 + export async function fetchPds(did: string): Promise<string | null> { 251 + const info = await resolveDidInfo(did as Parameters<typeof _didResolver.resolve>[0]); 252 + return info?.pds ?? null; 253 + } 254 + 250 255 // ── fetchPostRecord ──────────────────────────────────────────────── 251 256 // Fetches a single at.mapped.post record from its author's PDS. 252 257 // Returns the raw record value, or null on failure. ··· 270 275 export type Post = { 271 276 uri: string; 272 277 rkey: string; 278 + pds: string; 273 279 author: any; 274 280 title: string | null; 275 281 text: string | null; ··· 282 288 duration?: number; 283 289 elevation?: number; 284 290 } | null; 291 + images: Array<{ image: BlobRef; alt?: string }> | null; 285 292 }; 286 293 287 294 // ── _hydratePost ─────────────────────────────────────────────────── ··· 296 303 locations, 297 304 activities, 298 305 }: { trails: any; locations: any; activities: any }, 306 + pds: string, 299 307 ): Post { 300 308 const uri = `at://${did}/at.mapped.post/${rkey}`; 301 309 const activityType = value.activity?.uri ··· 311 319 return { 312 320 uri, 313 321 rkey, 322 + pds, 314 323 author, 315 324 title: value.title ?? null, 316 325 text: value.text ?? null, ··· 319 328 location, 320 329 trail, 321 330 stats: normaliseStats(value.stats ?? null), 331 + images: value.images ?? null, 322 332 }; 323 333 } 324 334 ··· 415 425 if (!info) return null; 416 426 const value = await fetchPostRecord(info.pds, did, rkey); 417 427 if (!value) return null; 418 - return _hydratePost(did, rkey, value, info.author, serviceData); 428 + return _hydratePost(did, rkey, value, info.author, serviceData, info.pds); 419 429 }), 420 430 ) 421 431 ).filter((p): p is Post => p !== null); ··· 448 458 ); 449 459 } 450 460 461 + export type BlobRef = { 462 + $type: "blob"; 463 + ref: { $link: string }; 464 + mimeType: string; 465 + size: number; 466 + }; 467 + 468 + // ── uploadBlob ──────────────────────────────────────────────────── 469 + async function uploadBlob(agent: OAuthUserAgent, file: File): Promise<BlobRef> { 470 + const rpc = new Client< 471 + {}, 472 + { 473 + "com.atproto.repo.uploadBlob": { 474 + input: Blob; 475 + }; 476 + } 477 + >({ handler: agent }); 478 + const result = await ok( 479 + rpc.post("com.atproto.repo.uploadBlob", { 480 + input: file, 481 + headers: { "Content-Type": file.type }, 482 + as: "json", 483 + }) 484 + ) as unknown as { blob: BlobRef }; 485 + return result.blob; 486 + } 487 + 451 488 // ── createPost ──────────────────────────────────────────────────── 452 489 // Writes an at.mapped.post record to the user's PDS and optimistically 453 490 // prepends it to the feed cache. ··· 463 500 { 464 501 did, 465 502 handle, 503 + pds, 466 504 activityEntry, 467 505 trailEntry, 468 506 title, 469 507 text, 508 + images, 509 + stats, 470 510 }: { 471 511 did: string; 472 512 handle: string; 513 + pds: string; 473 514 activityEntry: { uri: string; cid: string; name: string }; 474 515 trailEntry: { uri: string; cid: string; name: string }; 475 516 title?: string; 476 517 text?: string; 518 + images?: { file: File; alt?: string }[]; 519 + stats?: { distance?: number; duration?: number; elevation?: number }; 477 520 }, 478 521 ) { 522 + // Upload images in parallel before creating the record 523 + let imageRefs: Array<{ image: BlobRef; alt?: string }> | undefined; 524 + if (images && images.length > 0) { 525 + const blobRefs = await Promise.all(images.map(({ file }) => uploadBlob(agent, file))); 526 + imageRefs = blobRefs.map((image, i) => ({ 527 + image, 528 + ...(images[i].alt ? { alt: images[i].alt } : {}), 529 + })); 530 + } 479 531 const rpc = new Client< 480 532 {}, 481 533 { ··· 497 549 activity: { uri: string; cid: string }; 498 550 trail: { uri: string; cid: string }; 499 551 basePost: { uri: string; cid: string }; 552 + images?: Array<{ image: BlobRef; alt?: string }>; 553 + stats?: { distance?: number; duration?: number; elevation?: number }; 500 554 } = { 501 555 $type: "at.mapped.post", 502 556 timestamp: new Date().toISOString(), ··· 506 560 }; 507 561 if (title) record.title = title; 508 562 if (text) record.text = text; 563 + if (imageRefs) record.images = imageRefs; 564 + if (stats && Object.values(stats).some((v) => v !== undefined)) record.stats = stats; 509 565 510 566 const res = (await ok( 511 567 rpc.post("com.atproto.repo.createRecord", { ··· 519 575 520 576 // Optimistic update: prepend to in-memory cache 521 577 if (_cachedResult) { 522 - const post = _hydratePost(did, rkey, record, author, _cachedResult); 578 + const post = _hydratePost(did, rkey, record, author, _cachedResult, pds); 523 579 _cachedResult.posts.unshift(post); 524 580 _writePostsCache(_cachedResult.posts); 525 581 document.dispatchEvent(new CustomEvent("mapped:posts"));
+43 -4
src/components/activity-card.ts
··· 42 42 font-weight: 500; 43 43 color: var(--color-text-secondary); 44 44 } 45 - .activity-map { 46 - height: 140px; 47 - width: 100%; 45 + .gallery-map { 46 + height: 160px; 47 + width: 240px; 48 + flex-shrink: 0; 49 + border-radius: 6px; 50 + overflow: hidden; 48 51 } 49 52 .activity-title { 50 53 padding: 0 16px 12px; ··· 89 92 font-size: 11px; 90 93 color: var(--color-text-secondary); 91 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 + } 92 109 `, 93 110 ]; 94 111 95 112 @property({ attribute: false }) post!: Post; 96 113 @property({ type: Boolean }) hideLink = false; 97 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 + 98 136 firstUpdated() { 99 137 if (this.post?.activityType !== null && this.post?.trail) { 100 138 const mapEl = this.renderRoot.querySelector(".activity-map") as HTMLDivElement | null; ··· 137 175 <div class="distance">${post.stats?.distance ?? "—"} <span>km</span></div> 138 176 </div> 139 177 <div class="activity-title">${title}</div> 140 - <div class="activity-map"></div> 178 + ${this._renderGallery()} 141 179 <div class="card-body"> 142 180 ${post.text ? html`<p class="caption">${post.text}</p>` : ""} 143 181 <div class="stats"> ··· 167 205 <div class="travel-cover-title">${post.title ?? "Travel"}</div> 168 206 </div> 169 207 </div> 208 + ${this._renderGallery()} 170 209 <div class="card-body"> 171 210 <div class="travel-author"> 172 211 <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div>
+128 -1
src/components/compose-card.ts
··· 1 1 import { LitElement, html, css } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 4 - import { fetchServiceData, createPost } from "../api.ts"; 4 + import { fetchServiceData, fetchPds, createPost } from "../api.ts"; 5 5 import { getColorForString } from "../utils.ts"; 6 6 import { sharedStyles } from "./shared-styles.ts"; 7 7 ··· 69 69 color: #c0392b; 70 70 flex: 1; 71 71 } 72 + .stats-row { 73 + display: flex; 74 + gap: 8px; 75 + } 76 + .stats-row label { 77 + display: flex; 78 + flex-direction: column; 79 + gap: 2px; 80 + flex: 1; 81 + font-size: 11px; 82 + color: var(--color-text-secondary); 83 + } 84 + .stats-row input { 85 + width: 100%; 86 + box-sizing: border-box; 87 + } 88 + .image-previews { 89 + display: flex; 90 + flex-wrap: wrap; 91 + gap: 8px; 92 + } 93 + .image-preview-item { 94 + display: flex; 95 + flex-direction: column; 96 + gap: 4px; 97 + width: 80px; 98 + } 99 + .image-preview-item img { 100 + width: 80px; 101 + height: 60px; 102 + object-fit: cover; 103 + border-radius: 4px; 104 + border: 1px solid var(--color-border); 105 + } 106 + .image-preview-item input { 107 + font-size: 11px; 108 + padding: 2px 4px; 109 + } 72 110 .compose-submit { 73 111 padding: 8px 16px; 74 112 background: var(--color-accent-green); ··· 96 134 @state() private _errorMessage = ""; 97 135 @state() private _activityType = ""; 98 136 @state() private _trail = ""; 137 + @state() private _images: Array<{ file: File; alt: string; previewUrl: string }> = []; 138 + @state() private _userPds: string | null = null; 99 139 100 140 connectedCallback() { 101 141 super.connectedCallback(); ··· 106 146 .catch(() => { 107 147 this._errorMessage = "Failed to load activities."; 108 148 }); 149 + 150 + const did = localStorage.getItem("atproto-did"); 151 + if (did) { 152 + fetchPds(did).then((pds) => { 153 + this._userPds = pds; 154 + }); 155 + } 156 + } 157 + 158 + private _onFileChange(e: Event) { 159 + const input = e.target as HTMLInputElement; 160 + for (const img of this._images) URL.revokeObjectURL(img.previewUrl); 161 + this._images = Array.from(input.files ?? []).map((file) => ({ 162 + file, 163 + alt: "", 164 + previewUrl: URL.createObjectURL(file), 165 + })); 166 + } 167 + 168 + private _onAltChange(index: number, value: string) { 169 + this._images = this._images.map((img, i) => 170 + i === index ? { ...img, alt: value } : img 171 + ); 109 172 } 110 173 111 174 private async _handleSubmit(e: Event) { ··· 115 178 const form = e.target as HTMLFormElement; 116 179 const titleInput = form.querySelector<HTMLInputElement>('[name="title"]'); 117 180 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 ?? ""); 184 + 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, 188 + }; 118 189 119 190 this._submitting = true; 120 191 this._errorMessage = ""; ··· 128 199 const activityEntry = this._serviceData.activityMap.get(this._activityType); 129 200 const trailEntry = this._serviceData.trailMap.get(this._trail); 130 201 202 + if (!this._userPds) { 203 + this._errorMessage = "Could not determine your PDS. Please try again."; 204 + return; 205 + } 206 + 131 207 await createPost(agent, { 132 208 did, 133 209 handle, 210 + pds: this._userPds, 134 211 activityEntry, 135 212 trailEntry, 136 213 title: titleInput?.value.trim() || undefined, 137 214 text: textInput?.value.trim() || undefined, 215 + images: this._images.length > 0 216 + ? this._images.map(({ file, alt }) => ({ file, alt: alt || undefined })) 217 + : undefined, 218 + stats: Object.values(stats).some((v) => v !== undefined) ? stats : undefined, 138 219 }); 139 220 221 + for (const img of this._images) URL.revokeObjectURL(img.previewUrl); 222 + 140 223 form.reset(); 141 224 this._activityType = ""; 142 225 this._trail = ""; 226 + this._images = []; 143 227 } catch (err: any) { 144 228 this._errorMessage = err.description ?? err.message ?? "Failed to post. Please try again."; 145 229 } finally { ··· 218 302 rows="3" 219 303 ?disabled=${this._submitting} 220 304 ></textarea> 305 + <div class="stats-row"> 306 + <label> 307 + Distance (km) 308 + <input name="distance" type="number" min="0" step="0.1" placeholder="0.0" ?disabled=${this._submitting} /> 309 + </label> 310 + <label> 311 + Duration (min) 312 + <input name="duration" type="number" min="0" step="1" placeholder="0" ?disabled=${this._submitting} /> 313 + </label> 314 + <label> 315 + Elevation (m) 316 + <input name="elevation" type="number" step="1" placeholder="0" ?disabled=${this._submitting} /> 317 + </label> 318 + </div> 319 + <input 320 + type="file" 321 + accept="image/*" 322 + multiple 323 + ?disabled=${this._submitting} 324 + @change=${this._onFileChange} 325 + /> 326 + ${this._images.length > 0 327 + ? html` 328 + <div class="image-previews"> 329 + ${this._images.map( 330 + (img, i) => html` 331 + <div class="image-preview-item"> 332 + <img src="${img.previewUrl}" alt="${img.alt}" /> 333 + <input 334 + type="text" 335 + placeholder="Alt text" 336 + maxlength="1000" 337 + .value=${img.alt} 338 + @input=${(e: Event) => 339 + this._onAltChange(i, (e.target as HTMLInputElement).value)} 340 + ?disabled=${this._submitting} 341 + /> 342 + </div> 343 + ` 344 + )} 345 + </div> 346 + ` 347 + : ""} 221 348 <div class="compose-footer"> 222 349 ${this._errorMessage 223 350 ? html`<p class="compose-error-msg">${this._errorMessage}</p>`