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