this repo has no descr,ription vt3e.cat

feat: lightbox for gallery

vt3e.cat 4f62cb67 15cbb364

verified
+506 -31
+4 -4
pkgs/web/src/components/footer/index.ts
··· 1 1 import { css, html, LitElement, unsafeCSS } from "lit"; 2 2 import { customElement } from "lit/decorators.js"; 3 3 4 - import bskySvg from "../../../public/svgs/bluesky.svg?raw"; 5 - import tangledSvg from "../../../public/svgs/tangled.svg?raw"; 6 - import discordSvg from "../../../public/svgs/discord.svg?raw"; 7 - import emailSvg from "../../../public/svgs/email.svg?raw"; 4 + import bskySvg from "/svgs/bluesky.svg?raw"; 5 + import tangledSvg from "/svgs/tangled.svg?raw"; 6 + import discordSvg from "/svgs/discord.svg?raw"; 7 + import emailSvg from "/svgs/email.svg?raw"; 8 8 9 9 import global from "../../css/global"; 10 10 import styles from "./main.css?inline";
+122
pkgs/web/src/components/image-viewer/index.ts
··· 1 + import { css, html, LitElement, unsafeCSS } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import type { MoeWloGalleryImage } from "@sillowww/gallery"; 4 + 5 + import global from "../../css/global"; 6 + import styles from "./main.css?inline"; 7 + 8 + @customElement("image-viewer") 9 + export class ImageViewer extends LitElement { 10 + static styles = [css`${unsafeCSS(styles)}`, global]; 11 + 12 + @property({ type: Object }) 13 + image: MoeWloGalleryImage.Main | null = null; 14 + 15 + @property({ type: String }) 16 + imageUrl: string = ""; 17 + 18 + @state() private isOpen = false; 19 + 20 + open() { 21 + this.isOpen = true; 22 + document.body.style.overflow = "hidden"; 23 + } 24 + 25 + close() { 26 + this.isOpen = false; 27 + document.body.style.overflow = ""; 28 + } 29 + 30 + private handleDialogClick(e: MouseEvent) { 31 + const dialog = this.renderRoot.querySelector("dialog"); 32 + if (e.target === dialog) { 33 + this.close(); 34 + } 35 + } 36 + 37 + private handleKeydown(e: KeyboardEvent) { 38 + if (e.key === "Escape") { 39 + this.close(); 40 + } 41 + } 42 + 43 + connectedCallback() { 44 + super.connectedCallback(); 45 + document.addEventListener("keydown", this.handleKeydown.bind(this)); 46 + } 47 + 48 + disconnectedCallback() { 49 + super.disconnectedCallback(); 50 + document.removeEventListener("keydown", this.handleKeydown.bind(this)); 51 + document.body.style.overflow = ""; 52 + } 53 + 54 + updated(changedProperties: Map<string | number | symbol, unknown>) { 55 + if (changedProperties.has("isOpen")) { 56 + const dialog = this.renderRoot.querySelector( 57 + "dialog", 58 + ) as HTMLDialogElement; 59 + if (dialog) { 60 + if (this.isOpen) { 61 + dialog.showModal(); 62 + } else { 63 + dialog.close(); 64 + } 65 + } 66 + } 67 + } 68 + 69 + private get hasMetadata() { 70 + return this.image?.title || this.image?.caption; 71 + } 72 + 73 + private renderContent() { 74 + if (!this.image || !this.imageUrl) return html``; 75 + 76 + const alt = 77 + this.image.alt || 78 + this.image.caption || 79 + this.image.title || 80 + "Gallery image"; 81 + 82 + return html` 83 + <div class="viewer-content ${this.hasMetadata ? "has-metadata" : ""}"> 84 + <div class="image-section"> 85 + <img src="${this.imageUrl}" alt="${alt}" /> 86 + </div> 87 + ${ 88 + this.hasMetadata 89 + ? html` 90 + <div class="metadata-section"> 91 + <div class="metadata-content"> 92 + ${ 93 + this.image.title 94 + ? html` 95 + <h2 class="image-title">${this.image.title}</h2> 96 + ` 97 + : "" 98 + } 99 + ${ 100 + this.image.caption 101 + ? html` 102 + <p class="image-caption">${this.image.caption}</p> 103 + ` 104 + : "" 105 + } 106 + </div> 107 + </div> 108 + ` 109 + : "" 110 + } 111 + </div> 112 + `; 113 + } 114 + 115 + render() { 116 + return html` 117 + <dialog @click=${this.handleDialogClick}> 118 + ${this.renderContent()} 119 + </dialog> 120 + `; 121 + } 122 + }
+152
pkgs/web/src/components/image-viewer/main.css
··· 1 + dialog { 2 + left: 50%; 3 + top: 50%; 4 + transform: translate(-50%, -50%); 5 + 6 + width: 75vw; 7 + height: 80vh; 8 + 9 + padding: 0; 10 + border: none; 11 + border-radius: 0.75rem; 12 + background: hsl(var(--base)); 13 + position: relative; 14 + overflow: hidden; 15 + 16 + &::backdrop { 17 + backdrop-filter: blur(4px) brightness(0.75); 18 + } 19 + } 20 + 21 + .close-button { 22 + position: absolute; 23 + top: -1rem; 24 + right: -1rem; 25 + z-index: 10; 26 + display: flex; 27 + align-items: center; 28 + justify-content: center; 29 + width: 2.5rem; 30 + height: 2.5rem; 31 + border: none; 32 + border-radius: 0.5rem; 33 + background: hsla(var(--surface0) / 0.9); 34 + color: hsl(var(--text)); 35 + cursor: pointer; 36 + backdrop-filter: blur(8px); 37 + 38 + &:hover { 39 + background: hsla(var(--surface1) / 0.9); 40 + } 41 + 42 + &:active { 43 + background: hsla(var(--surface2) / 0.9); 44 + transform: scale(0.95); 45 + } 46 + 47 + svg { 48 + width: 1.25rem; 49 + height: 1.25rem; 50 + } 51 + } 52 + 53 + .viewer-content { 54 + display: flex; 55 + min-height: 60vh; 56 + max-height: 90vh; 57 + 58 + .image-section { 59 + flex: 1; 60 + display: flex; 61 + align-items: center; 62 + justify-content: center; 63 + background: hsl(var(--mantle)); 64 + 65 + img { 66 + max-width: 100%; 67 + max-height: 100%; 68 + object-fit: contain; 69 + } 70 + } 71 + 72 + &:not(.has-metadata) { 73 + justify-content: center; 74 + align-items: center; 75 + 76 + .image-section { 77 + flex: none; 78 + display: flex; 79 + align-items: center; 80 + justify-content: center; 81 + padding: 2rem; 82 + } 83 + } 84 + 85 + &.has-metadata { 86 + @media (max-width: 768px) { 87 + flex-direction: column; 88 + } 89 + } 90 + } 91 + 92 + .metadata-section { 93 + flex: 0 0 320px; 94 + display: flex; 95 + flex-direction: column; 96 + background: hsl(var(--base)); 97 + border-left: 1px solid hsla(var(--surface0) / 0.5); 98 + 99 + @media (max-width: 768px) { 100 + flex: none; 101 + border-left: none; 102 + border-top: 1px solid hsla(var(--surface0) / 0.5); 103 + } 104 + 105 + .metadata-content { 106 + padding: 0.5rem; 107 + flex: 1; 108 + display: flex; 109 + flex-direction: column; 110 + gap: 1rem; 111 + 112 + .image-title { 113 + font-size: 1.5rem; 114 + font-weight: 900; 115 + color: hsl(var(--text)); 116 + line-height: 1.3; 117 + margin: 0; 118 + } 119 + 120 + .image-caption { 121 + font-size: 1rem; 122 + color: hsl(var(--subtext1)); 123 + line-height: 1.6; 124 + margin: 0; 125 + } 126 + } 127 + } 128 + 129 + /* Responsive adjustments */ 130 + @media (max-width: 768px) { 131 + dialog { 132 + max-width: 100vw; 133 + max-height: 100vh; 134 + width: 100vw; 135 + height: 100vh; 136 + border-radius: 0; 137 + } 138 + 139 + .viewer-content { 140 + min-height: 100vh; 141 + max-height: 100vh; 142 + } 143 + 144 + .metadata-section { 145 + min-height: 200px; 146 + } 147 + 148 + .close-button { 149 + top: 0.75rem; 150 + right: 0.75rem; 151 + } 152 + }
+2 -2
pkgs/web/src/components/project-card/index.ts
··· 5 5 import global from "../../css/global"; 6 6 import styles from "./main.css?inline"; 7 7 8 - import tangledIcon from "../../../public/svgs/tangled.svg?raw"; 9 - import openInNewIcon from "../../../public/svgs/open-in-new.svg?raw"; 8 + import tangledIcon from "/svgs/tangled.svg?raw"; 9 + import openInNewIcon from "/svgs/open-in-new.svg?raw"; 10 10 11 11 export type LinkType = "tangled" | "website"; 12 12
+226 -25
pkgs/web/src/pages/gallery-page.ts
··· 59 59 filter: blur(0); 60 60 61 61 &:not(.loaded) { 62 - opacity: 0; 63 - transform: scale(1.25); 64 - filter: blur(0.5rem); 62 + opacity: 0; 63 + transform: scale(1.25); 64 + filter: blur(0.5rem); 65 65 } 66 66 } 67 67 68 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; 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 85 86 - white-space: nowrap; 87 - overflow: hidden; 88 - text-overflow: ellipsis; 89 - } 86 + white-space: nowrap; 87 + overflow: hidden; 88 + text-overflow: ellipsis; 89 + } 90 90 91 91 &:hover { 92 92 border: 1px solid hsla(var(--surface0) / 1); ··· 116 116 padding: 2rem; 117 117 color: hsl(var(--subtext0)); 118 118 } 119 + 120 + dialog { 121 + position: fixed; 122 + top: 50%; 123 + left: 50%; 124 + transform: translate(-50%, -50%); 125 + margin: 0; 126 + 127 + border: none; 128 + border-radius: 0.75rem; 129 + padding: 0; 130 + 131 + width: min(1400px, 95vw); 132 + height: min(900px, 95vh); 133 + 134 + background-color: hsl(var(--mantle)); 135 + box-shadow: 0 20px 60px hsla(var(--crust) / 0.8); 136 + 137 + &::backdrop { 138 + background-color: hsla(var(--crust) / 0.85); 139 + backdrop-filter: blur(8px); 140 + } 141 + 142 + .dialog-content { 143 + display: flex; 144 + flex-direction: row; 145 + width: 100%; 146 + height: 100%; 147 + position: relative; 148 + 149 + &.centered { 150 + justify-content: center; 151 + align-items: center; 152 + } 153 + 154 + @media (max-width: 768px) { 155 + flex-direction: column; 156 + } 157 + } 158 + 159 + .close-button { 160 + position: absolute; 161 + top: 1rem; 162 + right: 1rem; 163 + width: 2.5rem; 164 + height: 2.5rem; 165 + border: none; 166 + border-radius: 0.375rem; 167 + background-color: hsla(var(--surface0) / 0.8); 168 + color: hsl(var(--text)); 169 + cursor: pointer; 170 + display: flex; 171 + align-items: center; 172 + justify-content: center; 173 + font-size: 1.5rem; 174 + font-weight: 600; 175 + z-index: 10; 176 + backdrop-filter: blur(8px); 177 + 178 + &:hover { 179 + background-color: hsla(var(--surface1) / 0.9); 180 + } 181 + 182 + &:active { 183 + background-color: hsla(var(--surface0) / 0.6); 184 + } 185 + } 186 + 187 + .image-pane { 188 + flex: 1; 189 + display: flex; 190 + align-items: center; 191 + justify-content: center; 192 + padding: 1.5rem; 193 + min-width: 0; 194 + min-height: 0; 195 + 196 + img { 197 + max-width: 100%; 198 + max-height: 100%; 199 + width: auto; 200 + height: auto; 201 + object-fit: contain; 202 + border-radius: 0.5rem; 203 + } 204 + } 205 + 206 + .info-pane { 207 + width: 350px; 208 + flex-shrink: 0; 209 + padding: 2rem; 210 + background-color: hsl(var(--base)); 211 + border-left: 1px solid hsla(var(--surface0) / 0.5); 212 + overflow-y: auto; 213 + display: flex; 214 + flex-direction: column; 215 + gap: 1rem; 216 + 217 + @media (max-width: 768px) { 218 + width: 100%; 219 + border-left: none; 220 + border-top: 1px solid hsla(var(--surface0) / 0.5); 221 + max-height: 40%; 222 + } 223 + 224 + h2 { 225 + font-size: 1.5rem; 226 + font-weight: 900; 227 + color: hsl(var(--text)); 228 + margin: 0; 229 + line-height: 1.3; 230 + } 231 + 232 + p { 233 + font-size: 0.95rem; 234 + color: hsl(var(--subtext1)); 235 + line-height: 1.6; 236 + margin: 0; 237 + white-space: pre-wrap; 238 + word-wrap: break-word; 239 + } 240 + } 241 + 242 + @media (max-width: 768px) { 243 + width: 95vw; 244 + height: 95vh; 245 + } 246 + } 119 247 `; 120 248 121 249 @customElement("gallery-page") ··· 125 253 @state() private images: MoeWloGalleryImage.Main[] = []; 126 254 @state() private loading = true; 127 255 @state() private error: string | null = null; 256 + @state() private selectedImage: MoeWloGalleryImage.Main | null = null; 128 257 129 258 async connectedCallback() { 130 259 super.connectedCallback(); ··· 189 318 return `https://pds.wlo.moe/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blobCid}`; 190 319 } 191 320 321 + private openDialog(image: MoeWloGalleryImage.Main) { 322 + this.selectedImage = image; 323 + this.requestUpdate(); 324 + this.updateComplete.then(() => { 325 + const dialog = this.renderRoot.querySelector("dialog"); 326 + dialog?.showModal(); 327 + }); 328 + } 329 + 330 + private closeDialog() { 331 + const dialog = this.renderRoot.querySelector("dialog"); 332 + dialog?.close(); 333 + this.selectedImage = null; 334 + } 335 + 192 336 private renderImage(image: MoeWloGalleryImage.Main) { 193 337 const blobUrl = this.getBlobUrl(image); 194 338 const alt = image.alt || image.caption; ··· 214 358 }); 215 359 216 360 return html` 217 - <div class="gallery-item" style="aspect-ratio: ${aspectRatio}" title="${alt}"> 361 + <div 362 + class="gallery-item" 363 + style="aspect-ratio: ${aspectRatio}" 364 + title="${alt}" 365 + @click=${() => this.openDialog(image)} 366 + > 218 367 <div class="image-container"> 219 368 <img 220 369 class="blurhash" ··· 233 382 `; 234 383 } 235 384 385 + private renderDialog() { 386 + if (!this.selectedImage) return null; 387 + 388 + const blobUrl = this.getBlobUrl(this.selectedImage); 389 + const alt = this.selectedImage.alt || this.selectedImage.caption; 390 + const hasInfo = this.selectedImage.title || this.selectedImage.caption; 391 + 392 + return html` 393 + <dialog @click=${(e: MouseEvent) => { 394 + if (e.target === e.currentTarget) this.closeDialog(); 395 + }}> 396 + <div class="dialog-content ${hasInfo ? "" : "centered"}"> 397 + <button 398 + class="close-button" 399 + @click=${() => this.closeDialog()} 400 + aria-label="Close dialog" 401 + > 402 + × 403 + </button> 404 + 405 + <div class="image-pane"> 406 + <img src="${blobUrl}" alt="${alt}" /> 407 + </div> 408 + 409 + ${ 410 + hasInfo 411 + ? html` 412 + <div class="info-pane"> 413 + ${ 414 + this.selectedImage.title 415 + ? html` 416 + <h2>${this.selectedImage.title}</h2> 417 + ` 418 + : "" 419 + } 420 + ${ 421 + this.selectedImage.caption 422 + ? html` 423 + <p>${this.selectedImage.caption}</p> 424 + ` 425 + : "" 426 + } 427 + </div> 428 + ` 429 + : "" 430 + } 431 + </div> 432 + </dialog> 433 + `; 434 + } 435 + 236 436 private decodeBlurhash(blurhash: string): string { 237 437 const pixels = decode(blurhash, 32, 32); 238 438 const canvas = document.createElement("canvas"); ··· 264 464 : this.images.length === 0 265 465 ? html`<div class="gallery-empty">no images yet</div>` 266 466 : html`<div class="gallery-grid"> 267 - ${this.images.map((img) => this.renderImage(img))} 268 467 ${this.images.map((img) => this.renderImage(img))} 269 468 </div>` 270 469 } 470 + 471 + ${this.renderDialog()} 271 472 </main> 272 473 `; 273 474 }