Rewild Your Web
18
fork

Configure Feed

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

atproto: custom elements viewers

Signed-off-by: webbeef <me@webbeef.org>

webbeef 99ff4c93 a13cd7f7

+420 -19
+88 -19
resources/json-viewer.html
··· 41 41 word-break: break-all; 42 42 } 43 43 44 + #smart-view { 45 + display: none; 46 + padding: 0.5em 1em; 47 + } 48 + 44 49 .json-error { 45 50 padding: 0.5em 1em; 46 51 color: #c00; ··· 139 144 // Toolbar 140 145 let toolbar = createElement("div"); 141 146 toolbar.id = "toolbar"; 147 + let smartBtn = null; 142 148 let prettyBtn = createElement("button", "active", "Pretty"); 143 149 let rawBtn = createElement("button", null, "Raw"); 144 - toolbar.append(prettyBtn); 145 - toolbar.append(rawBtn); 146 - document.body.append(toolbar); 150 + 151 + // Smart view container (may or may not be used) 152 + let smartView = createElement("div"); 153 + smartView.id = "smart-view"; 147 154 148 155 // Pretty view 149 156 let viewer = createElement("div"); 150 157 viewer.id = "viewer"; 151 - document.body.append(viewer); 152 158 153 159 // Raw view 154 160 let rawView = createElement("pre"); 155 161 rawView.id = "raw-view"; 156 - document.body.append(rawView); 162 + 163 + // Check for AT Protocol smart view 164 + let smartViewerPromise = null; 165 + if (!parseError && location.protocol === "at:" && data && data.value) { 166 + let segments = location.pathname.split("/").filter(Boolean); 167 + console.log("[json-viewer] AT protocol detected, segments:", segments); 168 + if (segments.length >= 1) { 169 + let collection = segments[0]; 170 + let tagName = collection.replaceAll(".", "-"); 171 + let viewerFile = collection.replaceAll(".", "_"); 172 + let viewerUrl = "beaver://atproto/viewers/" + viewerFile + ".js"; 173 + console.log("[json-viewer] Trying smart viewer:", viewerUrl, "tag:", tagName); 174 + 175 + smartViewerPromise = import(viewerUrl) 176 + .then(() => { 177 + console.log("[json-viewer] Smart viewer loaded successfully"); 178 + // The module registers its custom element via customElements.define(). 179 + let el = document.createElement(tagName); 180 + el.data = data; 181 + smartView.append(el); 182 + return true; 183 + }) 184 + .catch((err) => { 185 + console.log("[json-viewer] Smart viewer failed to load:", err); 186 + return false; 187 + }); 188 + } 189 + } else { 190 + console.log("[json-viewer] No AT protocol detected. protocol:", location.protocol, "has value:", !!(data && data.value)); 191 + } 192 + 193 + function buildToolbar(hasSmartView) { 194 + if (hasSmartView) { 195 + smartBtn = createElement("button", "active", "Smart"); 196 + toolbar.append(smartBtn); 197 + // Demote pretty button to inactive 198 + prettyBtn.className = ""; 199 + } 200 + toolbar.append(prettyBtn); 201 + toolbar.append(rawBtn); 202 + document.body.append(toolbar); 203 + 204 + document.body.append(smartView); 205 + document.body.append(viewer); 206 + document.body.append(rawView); 207 + 208 + if (hasSmartView) { 209 + smartView.style.display = "block"; 210 + viewer.style.display = "none"; 211 + } 212 + 213 + // Wire up toggle buttons 214 + function activateView(activeBtn, showEl) { 215 + if (smartBtn) smartBtn.className = ""; 216 + prettyBtn.className = ""; 217 + rawBtn.className = ""; 218 + activeBtn.className = "active"; 219 + 220 + smartView.style.display = "none"; 221 + viewer.style.display = "none"; 222 + rawView.style.display = "none"; 223 + showEl.style.display = "block"; 224 + } 225 + 226 + if (smartBtn) { 227 + smartBtn.onclick = () => activateView(smartBtn, smartView); 228 + } 229 + prettyBtn.onclick = () => activateView(prettyBtn, viewer); 230 + rawBtn.onclick = () => activateView(rawBtn, rawView); 231 + } 157 232 233 + // Populate pretty and raw views 158 234 if (parseError) { 159 235 let errDiv = createElement( 160 236 "div", ··· 165 241 let pre = createElement("pre", null, rawText); 166 242 viewer.append(pre); 167 243 rawView.textContent = rawText; 244 + buildToolbar(false); 168 245 } else { 169 246 renderNode(data, viewer); 170 247 rawView.textContent = JSON.stringify(data, null, 2); 171 - } 172 248 173 - // Toggle buttons 174 - prettyBtn.onclick = function () { 175 - viewer.style.display = ""; 176 - rawView.style.display = "none"; 177 - prettyBtn.className = "active"; 178 - rawBtn.className = ""; 179 - }; 180 - rawBtn.onclick = function () { 181 - viewer.style.display = "none"; 182 - rawView.style.display = "block"; 183 - rawBtn.className = "active"; 184 - prettyBtn.className = ""; 185 - }; 249 + if (smartViewerPromise) { 250 + smartViewerPromise.then((ok) => buildToolbar(ok)); 251 + } else { 252 + buildToolbar(false); 253 + } 254 + } 186 255 187 256 function renderNode(value, container) { 188 257 if (value === null) {
+111
ui/atproto/viewers/app_bsky_actor_profile.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + // Smart viewer for app.bsky.actor.profile AT Protocol records. 4 + // Displays a compact profile card with avatar, display name, and handle. 5 + 6 + import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; 7 + import { AtRecordElement } from "./at_record_element.js"; 8 + 9 + class AppBskyActorProfile extends AtRecordElement { 10 + static styles = css` 11 + :host { 12 + display: block; 13 + font-family: system-ui, -apple-system, sans-serif; 14 + } 15 + .card { 16 + display: flex; 17 + align-items: center; 18 + gap: 0.75em; 19 + padding: 0.75em; 20 + border-radius: 8px; 21 + background-size: cover; 22 + background-position: center; 23 + background-color: #e8e8e8; 24 + } 25 + .avatar { 26 + width: 40px; 27 + height: 40px; 28 + border-radius: 50%; 29 + object-fit: cover; 30 + background: #ccc; 31 + flex-shrink: 0; 32 + } 33 + .info { 34 + display: flex; 35 + flex-direction: column; 36 + } 37 + .name { 38 + font-weight: 600; 39 + font-size: 0.95em; 40 + color: #333; 41 + } 42 + .handle { 43 + font-size: 0.8em; 44 + color: #666; 45 + } 46 + :host([has-banner]) .name, 47 + :host([has-banner]) .handle { 48 + color: #fff; 49 + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); 50 + } 51 + a { 52 + text-decoration: none; 53 + color: inherit; 54 + } 55 + a:hover .name { 56 + text-decoration: underline; 57 + } 58 + `; 59 + 60 + _getSubject() { 61 + // Extract subject from the src URL: at://subject/collection/rkey 62 + if (this.src) { 63 + try { 64 + const url = new URL(this.src); 65 + return url.host; 66 + } catch { 67 + // fall through 68 + } 69 + } 70 + return location.host; 71 + } 72 + 73 + render() { 74 + if (!this.data) return html``; 75 + const profile = this.data.value || {}; 76 + const subject = this._getSubject(); 77 + const profileUrl = `at://${subject}/app.bsky.actor.profile/self`; 78 + const avatarCid = profile.avatar?.ref?.["$link"]; 79 + const avatarUrl = avatarCid 80 + ? `at://${subject}/com.atproto.sync.blob/${avatarCid}` 81 + : null; 82 + const bannerCid = profile.banner?.ref?.["$link"]; 83 + const bannerUrl = bannerCid 84 + ? `at://${subject}/com.atproto.sync.blob/${bannerCid}` 85 + : null; 86 + 87 + if (bannerUrl) { 88 + this.setAttribute("has-banner", ""); 89 + } else { 90 + this.removeAttribute("has-banner"); 91 + } 92 + 93 + return html` 94 + <a 95 + class="card" 96 + href=${profileUrl} 97 + style=${bannerUrl ? `background-image: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)), url(${bannerUrl})` : ""} 98 + > 99 + ${avatarUrl 100 + ? html`<img class="avatar" src=${avatarUrl} alt="" />` 101 + : html`<div class="avatar"></div>`} 102 + <div class="info"> 103 + <div class="name">${profile.displayName || subject}</div> 104 + <div class="handle">@${subject}</div> 105 + </div> 106 + </a> 107 + `; 108 + } 109 + } 110 + 111 + customElements.define("app-bsky-actor-profile", AppBskyActorProfile);
+40
ui/atproto/viewers/at_record_element.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + // Base class for AT Protocol record viewer custom elements. 4 + // Supports two ways of receiving data: 5 + // - Set the `data` property directly with a parsed AT Protocol record. 6 + // - Set the `src` attribute to an at:// URL; the element will fetch and parse it. 7 + 8 + import { LitElement } from "beaver://shared/third_party/lit/lit-all.min.js"; 9 + 10 + export class AtRecordElement extends LitElement { 11 + static properties = { 12 + src: { type: String }, 13 + data: { attribute: false }, 14 + }; 15 + 16 + constructor() { 17 + super(); 18 + this.src = ""; 19 + this.data = null; 20 + } 21 + 22 + willUpdate(changed) { 23 + if (changed.has("src") && this.src) { 24 + this._fetchData(this.src); 25 + } 26 + } 27 + 28 + async _fetchData(url) { 29 + try { 30 + const response = await fetch(url); 31 + if (response.ok) { 32 + this.data = await response.json(); 33 + } else { 34 + console.error(`[AtRecordElement] Failed to fetch ${url}: ${response.status}`); 35 + } 36 + } catch (err) { 37 + console.error(`[AtRecordElement] Error fetching ${url}:`, err); 38 + } 39 + } 40 + }
+181
ui/atproto/viewers/ing_dasl_masl.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + // Smart viewer for ing.dasl.masl (Web Tile) AT Protocol records. 4 + 5 + import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; 6 + import { AtRecordElement } from "./at_record_element.js"; 7 + import "./app_bsky_actor_profile.js"; 8 + 9 + function formatSize(bytes) { 10 + if (bytes < 1024) return bytes + " B"; 11 + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; 12 + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; 13 + } 14 + 15 + class IngDaslMasl extends AtRecordElement { 16 + static styles = css` 17 + :host { 18 + display: block; 19 + font-family: system-ui, -apple-system, sans-serif; 20 + max-width: 720px; 21 + color: #333; 22 + } 23 + .header { 24 + display: flex; 25 + align-items: center; 26 + gap: 1em; 27 + margin-bottom: 1em; 28 + } 29 + .icon { 30 + width: 64px; 31 + height: 64px; 32 + border-radius: 12px; 33 + object-fit: cover; 34 + background: #f0f0f0; 35 + } 36 + .title { 37 + font-size: 1.5em; 38 + font-weight: 600; 39 + } 40 + .meta { 41 + color: #666; 42 + font-size: 0.85em; 43 + } 44 + .description { 45 + line-height: 1.6; 46 + margin-bottom: 1em; 47 + } 48 + .screenshot { 49 + max-width: 100%; 50 + border-radius: 8px; 51 + border: 1px solid #ddd; 52 + margin-bottom: 1em; 53 + } 54 + .launch { 55 + display: inline-block; 56 + padding: 0.5em 1.5em; 57 + background: #0066cc; 58 + color: #fff; 59 + text-decoration: none; 60 + border-radius: 6px; 61 + font-weight: 500; 62 + margin-bottom: 1.5em; 63 + } 64 + .launch:hover { 65 + background: #0055aa; 66 + } 67 + table { 68 + border-collapse: collapse; 69 + width: 100%; 70 + font-size: 0.85em; 71 + } 72 + th, 73 + td { 74 + text-align: left; 75 + padding: 0.4em 0.8em; 76 + border-bottom: 1px solid #eee; 77 + } 78 + th { 79 + background: #f5f5f5; 80 + font-weight: 600; 81 + } 82 + .section { 83 + font-weight: 600; 84 + margin: 1em 0 0.5em; 85 + font-size: 0.9em; 86 + color: #555; 87 + } 88 + .profile-section { 89 + margin-bottom: 1.5em; 90 + display: flex; 91 + justify-content: flex-end; 92 + } 93 + `; 94 + 95 + _getTileUrl() { 96 + if (!this.data) return ""; 97 + const subject = location.host; 98 + const segments = location.pathname.split("/").filter(Boolean); 99 + const rkey = segments[1] || ""; 100 + return `tile://${rkey}.${subject}`; 101 + } 102 + 103 + render() { 104 + if (!this.data) return html``; 105 + const tile = this.data.value?.tile; 106 + if (!tile) return html`<p>Invalid tile record</p>`; 107 + 108 + const tileBaseUrl = this._getTileUrl(); 109 + const subject = location.host; 110 + const profileSrc = `at://${subject}/app.bsky.actor.profile/self`; 111 + 112 + const iconSrc = tile.icons?.[0]?.src; 113 + const screenshotSrc = tile.screenshots?.[0]?.src; 114 + const resources = Object.entries(tile.resources || {}); 115 + const totalSize = Object.values(tile.resources || {}).reduce( 116 + (sum, r) => sum + (r.src?.size || 0), 117 + 0, 118 + ); 119 + 120 + return html` 121 + <div class="profile-section"> 122 + <app-bsky-actor-profile src=${profileSrc}></app-bsky-actor-profile> 123 + </div> 124 + 125 + <div class="header"> 126 + ${iconSrc 127 + ? html`<img class="icon" src="${tileBaseUrl}${iconSrc}" alt="" />` 128 + : ""} 129 + <div> 130 + <div class="title">${tile.name || "Untitled Tile"}</div> 131 + <div class="meta"> 132 + ${tile.sizing 133 + ? html`${tile.sizing.width} &times; ${tile.sizing.height}` 134 + : ""} 135 + ${totalSize ? html` &middot; ${formatSize(totalSize)}` : ""} 136 + ${this.data.value?.createdAt 137 + ? html` &middot; 138 + ${new Date(this.data.value.createdAt).toLocaleDateString()}` 139 + : ""} 140 + </div> 141 + </div> 142 + </div> 143 + 144 + ${tile.description 145 + ? html`<div class="description">${tile.description}</div>` 146 + : ""} 147 + 148 + <a class="launch" href="${tileBaseUrl}/" target="_blank">Open Tile</a> 149 + 150 + ${screenshotSrc 151 + ? html`<div> 152 + <img 153 + class="screenshot" 154 + src="${tileBaseUrl}${screenshotSrc}" 155 + alt="Screenshot" 156 + /> 157 + </div>` 158 + : ""} 159 + 160 + <div class="section">Resources (${resources.length})</div> 161 + <table> 162 + <tr> 163 + <th>Path</th> 164 + <th>Type</th> 165 + <th>Size</th> 166 + </tr> 167 + ${resources.map( 168 + ([path, res]) => html` 169 + <tr> 170 + <td><a href="${tileBaseUrl}${path}">${path}</a></td> 171 + <td>${res["content-type"] || ""}</td> 172 + <td>${res.src?.size ? formatSize(res.src.size) : ""}</td> 173 + </tr> 174 + `, 175 + )} 176 + </table> 177 + `; 178 + } 179 + } 180 + 181 + customElements.define("ing-dasl-masl", IngDaslMasl);