this repo has no descr,ription vt3e.cat

feat: init gallery page functionality

vt3e.cat 1304f6eb 32f11603

verified
+269 -11
+4
pkgs/web/package.json
··· 9 9 "preview": "vite preview" 10 10 }, 11 11 "dependencies": { 12 + "@atcute/atproto": "^3.1.8", 13 + "@atcute/client": "^4.0.5", 14 + "@sillowww/gallery": "workspace:*", 15 + "blurhash": "^2.0.5", 12 16 "lit": "^3.3.1" 13 17 }, 14 18 "devDependencies": {
+265 -11
pkgs/web/src/pages/gallery-page.ts
··· 1 - import { html, LitElement } from "lit"; 2 - import { customElement } from "lit/decorators.js"; 1 + import type {} from "@atcute/atproto"; 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 + import type { MoeWloGalleryImage } from "@sillowww/gallery"; 4 + import { css, html, LitElement } from "lit"; 5 + import { customElement, state } from "lit/decorators.js"; 6 + import global from "../css/global"; 7 + import { decode } from "blurhash"; 8 + 9 + const galleryStyles = css` 10 + .gallery-grid { 11 + display: grid; 12 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 13 + gap: 1rem; 14 + margin-top: 1rem; 15 + &:has(.gallery-item:hover) .gallery-item:not(:hover) .image-container { 16 + opacity: 0.95; 17 + filter: blur(0.25rem) brightness(0.9); 18 + } 19 + } 20 + 21 + .gallery-item { 22 + border-radius: 0.5rem; 23 + overflow: hidden; 24 + background-color: hsla(var(--surface0) / 0.25); 25 + border: 1px solid hsla(var(--surface0) / 0.5); 26 + cursor: pointer; 27 + position: relative; 28 + 29 + .image-container { 30 + position: relative; 31 + width: 100%; 32 + height: 100%; 33 + } 34 + 35 + .blurhash { 36 + position: absolute; 37 + top: 0; 38 + left: 0; 39 + width: 100%; 40 + transform: scale(1.1); 41 + filter: blur(0.5rem); 42 + height: 100%; 43 + object-fit: cover; 44 + } 3 45 4 - import global from "../css/global"; 46 + .image-actual { 47 + position: absolute; 48 + top: 0; 49 + left: 0; 50 + width: 100%; 51 + height: 100%; 52 + object-fit: cover; 53 + 54 + transform-origin: center; 55 + transition-duration: 0.7s; 56 + 57 + opacity: 1; 58 + transform: scale(1.05); 59 + filter: blur(0); 60 + 61 + &:not(.loaded) { 62 + opacity: 0; 63 + transform: scale(1.25); 64 + filter: blur(0.5rem); 65 + } 66 + } 67 + 68 + .gallery-caption { 69 + position: absolute; 70 + bottom: 0; 71 + left: 0; 72 + width: 100%; 73 + padding: 0.75rem; 74 + background: linear-gradient( 75 + to top, 76 + hsla(var(--crust) / 0.95) 0%, 77 + hsla(var(--crust) / 0.7) 60%, 78 + hsla(var(--crust) / 0) 100% 79 + ); 80 + color: hsl(var(--text)); 81 + font-size: 0.85rem; 82 + font-weight: 500; 83 + line-height: 1.4; 84 + text-align: center; 85 + 86 + white-space: nowrap; 87 + overflow: hidden; 88 + text-overflow: ellipsis; 89 + } 90 + 91 + &:hover { 92 + border: 1px solid hsla(var(--surface0) / 1); 93 + .image-actual { 94 + transform: scale(1); 95 + transition-duration: 0.3s; 96 + } 97 + } 98 + } 99 + 100 + .gallery-loading { 101 + text-align: center; 102 + padding: 2rem; 103 + color: hsl(var(--subtext0)); 104 + } 105 + 106 + .gallery-error { 107 + padding: 1rem; 108 + border-radius: 0.5rem; 109 + background-color: hsla(var(--red) / 0.1); 110 + border: 1px solid hsla(var(--red) / 0.3); 111 + color: hsl(var(--red)); 112 + } 113 + 114 + .gallery-empty { 115 + text-align: center; 116 + padding: 2rem; 117 + color: hsl(var(--subtext0)); 118 + } 119 + `; 5 120 6 121 @customElement("gallery-page") 7 122 export class GalleryPage extends LitElement { 8 - static styles = [global]; 123 + static styles = [global, galleryStyles]; 124 + 125 + @state() private images: MoeWloGalleryImage.Main[] = []; 126 + @state() private loading = true; 127 + @state() private error: string | null = null; 128 + 129 + async connectedCallback() { 130 + super.connectedCallback(); 131 + await this.loadGallery(); 132 + } 133 + 134 + private async loadGallery() { 135 + try { 136 + this.loading = true; 137 + this.error = null; 138 + 139 + const handler = simpleFetchHandler({ 140 + service: "https://pds.wlo.moe", 141 + }); 142 + const rpc = new Client({ handler }); 143 + 144 + const repo = "did:plc:2hcnfmbfr4ucfbjpnvjqvt3e"; 145 + const collection = "moe.wlo.gallery.image"; 146 + 147 + const response = await rpc.get("com.atproto.repo.listRecords", { 148 + params: { 149 + repo, 150 + collection, 151 + limit: 100, 152 + }, 153 + }); 154 + 155 + type WrappedRecord = { 156 + cid: string; 157 + uri: string; 158 + value: MoeWloGalleryImage.Main; 159 + $type?: "com.atproto.repo.listRecords#record" | undefined; 160 + }[]; 161 + 162 + if (response.ok && response.data.records) { 163 + const sorted = (response.data.records as WrappedRecord) 164 + .sort((a, b) => { 165 + const timeA = new Date(a.value.createdAt).getTime(); 166 + const timeB = new Date(b.value.createdAt).getTime(); 167 + return timeB - timeA; 168 + }) 169 + .map((rec) => { 170 + return rec.value; 171 + }); 172 + 173 + this.images = sorted; 174 + } else { 175 + this.error = "failed to load gallery"; 176 + } 177 + } catch (err) { 178 + console.error("gallery load error:", err); 179 + this.error = 180 + err instanceof Error ? err.message : "failed to load gallery"; 181 + } finally { 182 + this.loading = false; 183 + } 184 + } 185 + 186 + private getBlobUrl(image: MoeWloGalleryImage.Main): string { 187 + const blobCid = "ref" in image.image ? image.image.ref.$link : undefined; 188 + const repo = "did:plc:2hcnfmbfr4ucfbjpnvjqvt3e"; 189 + return `https://pds.wlo.moe/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blobCid}`; 190 + } 191 + 192 + private renderImage(image: MoeWloGalleryImage.Main) { 193 + const blobUrl = this.getBlobUrl(image); 194 + const alt = image.alt || image.caption; 195 + const blurhashDataUrl = image.blurhash 196 + ? this.decodeBlurhash(image.blurhash) 197 + : undefined; 198 + 199 + const aspectRatio = image.aspectRatio; 200 + const id = `img-${Math.random().toString(36).substring(2, 15)}`; 201 + 202 + fetch(blobUrl) 203 + .then((response) => response.blob()) 204 + .then(async (blob) => { 205 + const url = URL.createObjectURL(blob); 206 + const img = this.renderRoot.querySelector(`#${id}`) as HTMLImageElement; 207 + if (img) { 208 + img.src = url; 209 + img.classList.add("loaded"); 210 + } 211 + }) 212 + .catch((err) => { 213 + console.error(`failed to fetch image ${id}:`, err); 214 + }); 215 + 216 + return html` 217 + <div class="gallery-item" style="aspect-ratio: ${aspectRatio}" title="${alt}"> 218 + <div class="image-container"> 219 + <img 220 + class="blurhash" 221 + src="${blurhashDataUrl}" 222 + aria-hidden="true" 223 + /> 224 + <img 225 + id="${id}" 226 + class="image-actual" 227 + alt="${alt}" 228 + loading="lazy" 229 + /> 230 + </div> 231 + ${image.title ? html`<div class="gallery-caption">${image.title}</div>` : ""} 232 + </div> 233 + `; 234 + } 235 + 236 + private decodeBlurhash(blurhash: string): string { 237 + const pixels = decode(blurhash, 32, 32); 238 + const canvas = document.createElement("canvas"); 239 + canvas.width = 32; 240 + canvas.height = 32; 241 + 242 + const ctx = canvas.getContext("2d"); 243 + if (!ctx) throw new Error("failed to get canvas context"); 244 + const imageData = ctx.createImageData(32, 32); 245 + imageData.data.set(pixels); 246 + ctx.putImageData(imageData, 0, 0); 247 + 248 + return canvas.toDataURL(); 249 + } 9 250 10 251 render() { 11 252 return html` 12 - <main> 13 - <header> 14 - <h1>gallery</h1> 15 - <p>work in progress!</p> 16 - </header> 17 - </main> 18 - `; 253 + <main> 254 + <header> 255 + <h1>gallery</h1> 256 + <p>a collection of images</p> 257 + </header> 258 + 259 + ${ 260 + this.loading 261 + ? html`<div class="gallery-loading">loading images...</div>` 262 + : this.error 263 + ? html`<div class="gallery-error">${this.error}</div>` 264 + : this.images.length === 0 265 + ? html`<div class="gallery-empty">no images yet</div>` 266 + : html`<div class="gallery-grid"> 267 + ${this.images.map((img) => this.renderImage(img))} 268 + ${this.images.map((img) => this.renderImage(img))} 269 + </div>` 270 + } 271 + </main> 272 + `; 19 273 } 20 274 }