+15
-13
main.tsx
+15
-13
main.tsx
···
160
160
}
161
161
if (!gallery) return ctx.next();
162
162
ctx.state.meta = getGalleryMeta(gallery);
163
-
ctx.state.scripts = ["photo_dialog.js"];
163
+
ctx.state.scripts = ["photo_dialog.js", "masonry.js"];
164
164
return ctx.render(
165
165
<GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />,
166
166
);
···
374
374
];
375
375
return ctx.html(
376
376
<>
377
-
<div hx-swap-oob="beforeend:#gallery-photo-grid">
377
+
<div hx-swap-oob="beforeend:#masonry-container">
378
378
<PhotoButton
379
379
key={photo.cid}
380
380
photo={photoToView(photo.did, photo)}
···
956
956
href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css"
957
957
preload
958
958
/>
959
-
{scripts?.includes("photo_dialog.js")
960
-
? <script src="/static/photo_dialog.js" />
961
-
: null}
959
+
{scripts?.map((file) => <script key={file} src={`/static/${file}`} />)}
962
960
</head>
963
961
<body class="h-full w-full dark:bg-zinc-950 dark:text-white">
964
962
<Layout id="layout" class="dark:border-zinc-800">
···
1378
1376
>
1379
1377
<label htmlFor="file">
1380
1378
<span class="sr-only">Upload avatar</span>
1381
-
<div class="border rounded-full border-slate-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer">
1382
-
<div class="absolute bottom-0 right-0 bg-slate-800 rounded-full w-5 h-5 flex items-center justify-center z-10">
1379
+
<div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer">
1380
+
<div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10">
1383
1381
<i class="fa-solid fa-camera text-white text-xs"></i>
1384
1382
</div>
1385
1383
<div id="image-preview" class="w-full h-full">
···
1474
1472
: null}
1475
1473
</div>
1476
1474
<div
1477
-
id="gallery-photo-grid"
1478
-
class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"
1475
+
id="masonry-container"
1476
+
class="h-0 overflow-hidden relative mx-auto w-full"
1477
+
_="on load or htmx:afterSettle call computeMasonry()"
1479
1478
>
1480
1479
{gallery.items?.filter(isPhotoView)?.length
1481
1480
? gallery?.items?.filter(isPhotoView)?.map((photo) => (
···
1507
1506
hx-trigger="click"
1508
1507
hx-target="#layout"
1509
1508
hx-swap="afterbegin"
1510
-
class="cursor-pointer relative sm:aspect-square"
1509
+
class="masonry-tile absolute cursor-pointer"
1510
+
data-width={photo.aspectRatio?.width}
1511
+
data-height={photo.aspectRatio?.height}
1511
1512
>
1512
1513
{isLoggedIn && isCreator
1513
1514
? <AltTextButton galleryUri={gallery.uri} cid={photo.cid} />
···
1515
1516
<img
1516
1517
src={photo.fullsize}
1517
1518
alt={photo.alt}
1518
-
class="sm:absolute sm:inset-0 w-full h-full sm:object-contain"
1519
+
class="w-full h-full object-cover"
1519
1520
/>
1520
1521
{!isCreator && photo.alt
1521
1522
? (
1522
-
<div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-2 right-2 sm:bottom-0 sm:right-0 text-xs text-white font-semibold py-[1px] px-[3px]">
1523
+
<div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-1 right-1 sm:bottom-1 sm:right-1 text-xs text-white font-semibold py-[1px] px-[3px]">
1523
1524
ALT
1524
1525
</div>
1525
1526
)
···
1685
1686
}: Readonly<{ galleryUri: string; cid: string }>) {
1686
1687
return (
1687
1688
<div
1688
-
class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-2 left-2 sm:top-0 sm:left-0 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
1689
+
class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-1 left-1 sm:top-1 sm:left-1 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
1689
1690
hx-get={`/dialogs/image-alt?galleryUri=${galleryUri}&imageCid=${cid}`}
1690
1691
hx-trigger="click"
1691
1692
hx-target="#layout"
···
1785
1786
rows={4}
1786
1787
defaultValue={photo.alt}
1787
1788
placeholder="Alt text"
1789
+
autoFocus
1788
1790
class="dark:bg-zinc-800 dark:text-white"
1789
1791
/>
1790
1792
</div>
+84
static/masonry.js
+84
static/masonry.js
···
1
+
// deno-lint-ignore-file
2
+
3
+
let masonryObserverInitialized = false;
4
+
5
+
function computeMasonry() {
6
+
const container = document.getElementById("masonry-container");
7
+
if (!container) return;
8
+
9
+
const spacing = 12;
10
+
const containerWidth = container.offsetWidth;
11
+
12
+
if (containerWidth === 0) {
13
+
requestAnimationFrame(computeMasonry);
14
+
return;
15
+
}
16
+
17
+
const columns = containerWidth < 640 ? 1 : 3;
18
+
19
+
const columnWidth = (containerWidth + spacing) / columns - spacing;
20
+
const columnHeights = new Array(columns).fill(0);
21
+
const tiles = container.querySelectorAll(".masonry-tile");
22
+
23
+
tiles.forEach((tile) => {
24
+
const imgW = parseFloat(tile.dataset.width);
25
+
const imgH = parseFloat(tile.dataset.height);
26
+
if (!imgW || !imgH) return;
27
+
28
+
const aspectRatio = imgH / imgW;
29
+
const renderedHeight = aspectRatio * columnWidth;
30
+
31
+
let shortestIndex = 0;
32
+
for (let i = 1; i < columns; i++) {
33
+
if (columnHeights[i] < columnHeights[shortestIndex]) {
34
+
shortestIndex = i;
35
+
}
36
+
}
37
+
38
+
const left = (columnWidth + spacing) * shortestIndex;
39
+
const top = columnHeights[shortestIndex];
40
+
41
+
Object.assign(tile.style, {
42
+
position: "absolute",
43
+
width: `${columnWidth}px`,
44
+
height: `${renderedHeight}px`,
45
+
left: `${left}px`,
46
+
top: `${top}px`,
47
+
});
48
+
49
+
columnHeights[shortestIndex] = top + renderedHeight + spacing;
50
+
});
51
+
52
+
container.style.height = `${Math.max(...columnHeights)}px`;
53
+
}
54
+
55
+
function observeMasonry() {
56
+
if (masonryObserverInitialized) return;
57
+
masonryObserverInitialized = true;
58
+
59
+
const container = document.getElementById("masonry-container");
60
+
if (!container) return;
61
+
62
+
// Observe parent resize
63
+
if (typeof ResizeObserver !== "undefined") {
64
+
const resizeObserver = new ResizeObserver(() => computeMasonry());
65
+
if (container.parentElement) {
66
+
resizeObserver.observe(container.parentElement);
67
+
}
68
+
}
69
+
70
+
// Observe inner content changes (tiles being added/removed)
71
+
const mutationObserver = new MutationObserver(() => {
72
+
computeMasonry();
73
+
});
74
+
75
+
mutationObserver.observe(container, {
76
+
childList: true,
77
+
subtree: true,
78
+
});
79
+
}
80
+
81
+
document.addEventListener("DOMContentLoaded", () => {
82
+
computeMasonry();
83
+
observeMasonry();
84
+
});
+44
-37
static/styles.css
+44
-37
static/styles.css
···
208
208
.inset-0 {
209
209
inset: calc(var(--spacing) * 0);
210
210
}
211
-
.top-0 {
212
-
top: calc(var(--spacing) * 0);
211
+
.top-1 {
212
+
top: calc(var(--spacing) * 1);
213
213
}
214
214
.top-2 {
215
215
top: calc(var(--spacing) * 2);
···
217
217
.right-0 {
218
218
right: calc(var(--spacing) * 0);
219
219
}
220
+
.right-1 {
221
+
right: calc(var(--spacing) * 1);
222
+
}
220
223
.right-2 {
221
224
right: calc(var(--spacing) * 2);
222
225
}
223
226
.bottom-0 {
224
227
bottom: calc(var(--spacing) * 0);
228
+
}
229
+
.bottom-1 {
230
+
bottom: calc(var(--spacing) * 1);
225
231
}
226
232
.bottom-2 {
227
233
bottom: calc(var(--spacing) * 2);
···
232
238
.left-0 {
233
239
left: calc(var(--spacing) * 0);
234
240
}
235
-
.left-2 {
236
-
left: calc(var(--spacing) * 2);
241
+
.left-1 {
242
+
left: calc(var(--spacing) * 1);
237
243
}
238
244
.z-10 {
239
245
z-index: 10;
···
244
250
.z-30 {
245
251
z-index: 30;
246
252
}
253
+
.container {
254
+
width: 100%;
255
+
@media (width >= 40rem) {
256
+
max-width: 40rem;
257
+
}
258
+
@media (width >= 48rem) {
259
+
max-width: 48rem;
260
+
}
261
+
@media (width >= 64rem) {
262
+
max-width: 64rem;
263
+
}
264
+
@media (width >= 80rem) {
265
+
max-width: 80rem;
266
+
}
267
+
@media (width >= 96rem) {
268
+
max-width: 96rem;
269
+
}
270
+
}
247
271
.mx-auto {
248
272
margin-inline: auto;
249
273
}
···
293
317
.size-16 {
294
318
width: calc(var(--spacing) * 16);
295
319
height: calc(var(--spacing) * 16);
320
+
}
321
+
.h-0 {
322
+
height: calc(var(--spacing) * 0);
296
323
}
297
324
.h-1\/2 {
298
325
height: calc(1/2 * 100%);
···
351
378
.cursor-pointer {
352
379
cursor: pointer;
353
380
}
381
+
.resize {
382
+
resize: both;
383
+
}
354
384
.grid-cols-1 {
355
385
grid-template-columns: repeat(1, minmax(0, 1fr));
356
386
}
···
406
436
border-style: var(--tw-border-style);
407
437
border-width: 1px;
408
438
}
409
-
.border-slate-900 {
410
-
border-color: var(--color-slate-900);
439
+
.border-zinc-900 {
440
+
border-color: var(--color-zinc-900);
411
441
}
412
442
.bg-black {
413
443
background-color: var(--color-black);
···
417
447
@supports (color: color-mix(in lab, red, red)) {
418
448
background-color: color-mix(in oklab, var(--color-black) 80%, transparent);
419
449
}
420
-
}
421
-
.bg-slate-800 {
422
-
background-color: var(--color-slate-800);
423
450
}
424
451
.bg-zinc-100 {
425
452
background-color: var(--color-zinc-100);
···
549
576
opacity: 50%;
550
577
}
551
578
}
552
-
.sm\:absolute {
553
-
@media (width >= 40rem) {
554
-
position: absolute;
555
-
}
556
-
}
557
-
.sm\:inset-0 {
558
-
@media (width >= 40rem) {
559
-
inset: calc(var(--spacing) * 0);
560
-
}
561
-
}
562
-
.sm\:top-0 {
579
+
.sm\:top-1 {
563
580
@media (width >= 40rem) {
564
-
top: calc(var(--spacing) * 0);
565
-
}
566
-
}
567
-
.sm\:right-0 {
568
-
@media (width >= 40rem) {
569
-
right: calc(var(--spacing) * 0);
581
+
top: calc(var(--spacing) * 1);
570
582
}
571
583
}
572
-
.sm\:bottom-0 {
584
+
.sm\:right-1 {
573
585
@media (width >= 40rem) {
574
-
bottom: calc(var(--spacing) * 0);
586
+
right: calc(var(--spacing) * 1);
575
587
}
576
588
}
577
-
.sm\:left-0 {
589
+
.sm\:bottom-1 {
578
590
@media (width >= 40rem) {
579
-
left: calc(var(--spacing) * 0);
591
+
bottom: calc(var(--spacing) * 1);
580
592
}
581
593
}
582
-
.sm\:aspect-square {
594
+
.sm\:left-1 {
583
595
@media (width >= 40rem) {
584
-
aspect-ratio: 1 / 1;
596
+
left: calc(var(--spacing) * 1);
585
597
}
586
598
}
587
599
.sm\:h-screen {
···
617
629
.sm\:justify-between {
618
630
@media (width >= 40rem) {
619
631
justify-content: space-between;
620
-
}
621
-
}
622
-
.sm\:object-contain {
623
-
@media (width >= 40rem) {
624
-
object-fit: contain;
625
632
}
626
633
}
627
634
.sm\:px-0 {