+5
deno.lock
+5
deno.lock
···
32
32
"npm:globals@17": "17.0.0",
33
33
"npm:hash-wasm@^4.12.0": "4.12.0",
34
34
"npm:lru-cache@^11.2.4": "11.2.4",
35
+
"npm:photoswipe@^5.4.4": "5.4.4",
35
36
"npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0",
36
37
"npm:prettier-plugin-tailwindcss@~0.7.2": "0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0",
37
38
"npm:prettier@^3.7.4": "3.7.4",
···
1486
1487
"path-key@3.1.1": {
1487
1488
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
1488
1489
},
1490
+
"photoswipe@5.4.4": {
1491
+
"integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA=="
1492
+
},
1489
1493
"picocolors@1.1.1": {
1490
1494
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
1491
1495
},
···
1869
1873
"npm:globals@17",
1870
1874
"npm:hash-wasm@^4.12.0",
1871
1875
"npm:lru-cache@^11.2.4",
1876
+
"npm:photoswipe@^5.4.4",
1872
1877
"npm:prettier-plugin-svelte@^3.4.1",
1873
1878
"npm:prettier-plugin-tailwindcss@~0.7.2",
1874
1879
"npm:prettier@^3.7.4",
+1
package.json
+1
package.json
+70
-17
src/components/BskyPost.svelte
+70
-17
src/components/BskyPost.svelte
···
43
43
import { getRelativeTime } from '$lib/date';
44
44
import { likeSource, repostSource, toCanonicalUri } from '$lib';
45
45
import ProfileInfo from './ProfileInfo.svelte';
46
+
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
46
47
47
48
interface Props {
48
49
client: AtpClient;
···
95
96
if (!p.ok) return;
96
97
profile = p.value;
97
98
profiles.set(did, profile);
98
-
// console.log(profile.description);
99
99
});
100
100
101
-
const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`);
101
+
const postId = $derived(
102
+
`timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}`
103
+
);
102
104
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
103
105
104
-
// todo: this fucking sucks
105
106
const scrollToAndPulse = (targetUri: ResourceUri) => {
106
107
const targetId = `timeline-post-${targetUri}-0`;
107
-
// console.log(`Scrolling to ${targetId}`);
108
108
const element = document.getElementById(targetId);
109
109
if (!element) return;
110
110
···
116
116
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
117
117
);
118
118
pulsingPostId.set(targetId);
119
-
// Clear pulse after animation
120
119
setTimeout(() => pulsingPostId.set(null), 1200);
121
120
}, 400);
122
121
};
···
212
211
</button>
213
212
{/snippet}
214
213
215
-
<!-- eslint-disable svelte/no-navigation-without-resolve -->
216
214
{#snippet profilePopout()}
217
215
<Dropdown
218
216
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
···
328
326
<!-- svelte-ignore a11y_no_static_element_interactions -->
329
327
<div oncontextmenu={(e) => e.stopPropagation()}>
330
328
{#if embed.$type === 'app.bsky.embed.images'}
331
-
<!-- todo: improve how images are displayed, and pop out on click -->
332
-
{#each embed.images as image (image.image)}
333
-
{#if isBlob(image.image)}
334
-
<img
335
-
class="w-full rounded-sm"
336
-
src={img('feed_thumbnail', did, image.image.ref.$link)}
337
-
alt={image.alt}
338
-
/>
339
-
{/if}
340
-
{/each}
329
+
{@const _images = embed.images.flatMap((img) =>
330
+
isBlob(img.image) ? [{ ...img, image: img.image }] : []
331
+
)}
332
+
{@const images = _images.map((i): GalleryItem => {
333
+
const sizeFactor = 200;
334
+
const size = {
335
+
width: (i.aspectRatio?.width ?? 4) * sizeFactor,
336
+
height: (i.aspectRatio?.height ?? 3) * sizeFactor
337
+
};
338
+
return {
339
+
...size,
340
+
src: img('feed_fullsize', did, i.image.ref.$link),
341
+
thumbnail: {
342
+
src: img('feed_thumbnail', did, i.image.ref.$link),
343
+
...size
344
+
}
345
+
};
346
+
})}
347
+
<PhotoSwipeGallery {images} />
341
348
{:else if embed.$type === 'app.bsky.embed.video'}
342
349
{#if isBlob(embed.video)}
343
350
{#await didDoc then didDoc}
···
385
392
{@render embedMedia(embed.media)}
386
393
</div>
387
394
{/if}
388
-
<!-- todo: implement external link embeds -->
389
395
{/snippet}
390
396
391
397
{#snippet postControls(post: PostWithUri)}
···
535
541
536
542
:global(.post-dropdown) {
537
543
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
544
+
}
545
+
546
+
.image-grid {
547
+
display: grid;
548
+
gap: 2px;
549
+
border-radius: 0.375rem;
550
+
overflow: hidden;
551
+
max-height: 500px;
552
+
}
553
+
554
+
/* 1 image: full width */
555
+
.image-grid.count-1 {
556
+
grid-template-columns: 1fr;
557
+
}
558
+
559
+
/* 2 images: side by side */
560
+
.image-grid.count-2 {
561
+
grid-template-columns: repeat(2, 1fr);
562
+
}
563
+
564
+
/* 3 images: first spans left, two stack on right */
565
+
.image-grid.count-3 {
566
+
grid-template-columns: repeat(2, 1fr);
567
+
grid-template-rows: repeat(2, 1fr);
568
+
}
569
+
.image-grid.count-3 a:first-child {
570
+
grid-row: 1 / 3;
571
+
}
572
+
573
+
/* 4+ images: 2x2 grid */
574
+
.image-grid.count-4,
575
+
.image-grid.count-5 {
576
+
grid-template-columns: repeat(2, 1fr);
577
+
grid-template-rows: repeat(2, 1fr);
578
+
}
579
+
580
+
.image-item {
581
+
width: 100%;
582
+
height: 100%;
583
+
object-fit: cover;
584
+
display: block;
585
+
cursor: pointer;
586
+
transition: opacity 0.2s;
587
+
}
588
+
589
+
.image-item:hover {
590
+
opacity: 0.9;
538
591
}
539
592
</style>
+199
src/components/PhotoSwipeGallery.svelte
+199
src/components/PhotoSwipeGallery.svelte
···
1
+
<script context="module" lang="ts">
2
+
export interface GalleryItem {
3
+
src: string;
4
+
thumbnail?: {
5
+
src: string;
6
+
width: number;
7
+
height: number;
8
+
};
9
+
width: number;
10
+
height: number;
11
+
cropped?: boolean;
12
+
alt?: string;
13
+
}
14
+
export type GalleryData = Array<GalleryItem>;
15
+
</script>
16
+
17
+
<script lang="ts">
18
+
import 'photoswipe/photoswipe.css';
19
+
import PhotoSwipeLightbox from 'photoswipe/lightbox';
20
+
import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe';
21
+
import { onMount } from 'svelte';
22
+
import { writable } from 'svelte/store';
23
+
24
+
export let images: GalleryData;
25
+
let element: HTMLDivElement;
26
+
27
+
const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined);
28
+
$: {
29
+
if (!element) break $;
30
+
const opts: Partial<PreparedPhotoSwipeOptions> = {
31
+
pswpModule: PhotoSwipe,
32
+
children: element.childNodes as ElementProvider,
33
+
gallery: element,
34
+
hideAnimationDuration: 0,
35
+
showAnimationDuration: 0,
36
+
zoomAnimationDuration: 200,
37
+
zoomSVG:
38
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M6.25 8.75v-1h-1a.75.75 0 0 1 0-1.5h1v-1a.75.75 0 0 1 1.5 0v1h1a.75.75 0 0 1 0 1.5h-1v1a.75.75 0 0 1-1.5 0"/><path fill="currentColor" fill-rule="evenodd" d="M7 12c1.11 0 2.136-.362 2.965-.974l2.755 2.754a.75.75 0 1 0 1.06-1.06l-2.754-2.755A5 5 0 1 0 7 12m0-1.5a3.5 3.5 0 1 0 0-7a3.5 3.5 0 0 0 0 7" clip-rule="evenodd"/></svg>',
39
+
closeSVG:
40
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94z"/></svg>',
41
+
arrowPrevSVG:
42
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>',
43
+
arrowNextSVG:
44
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8L6.22 5.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>'
45
+
};
46
+
$options = opts;
47
+
}
48
+
49
+
onMount(() => {
50
+
let lightbox: PhotoSwipeLightbox | undefined;
51
+
const unsub = options.subscribe((opts) => {
52
+
lightbox?.destroy?.();
53
+
if (opts === undefined) return;
54
+
lightbox = new PhotoSwipeLightbox(opts);
55
+
lightbox.init();
56
+
});
57
+
return () => {
58
+
unsub();
59
+
lightbox?.destroy?.();
60
+
};
61
+
});
62
+
</script>
63
+
64
+
<div class="gallery styling-twitter" data-total={images.length} bind:this={element}>
65
+
{#each images as img, i (img.src)}
66
+
{@const thumb = img.thumbnail ?? img}
67
+
{@const isHidden = i > 3}
68
+
{@const isOverlay = i === 3 && images.length > 4}
69
+
70
+
<a
71
+
href={img.src}
72
+
data-pswp-width={img.width}
73
+
data-pswp-height={img.height}
74
+
target="_blank"
75
+
class:hidden-in-grid={isHidden}
76
+
class:overlay-container={isOverlay}
77
+
>
78
+
<img src={thumb.src} alt={img.alt ?? ''} width={thumb.width} height={thumb.height} />
79
+
80
+
{#if isOverlay}
81
+
<div class="more-overlay">
82
+
+{images.length - 4}
83
+
</div>
84
+
{/if}
85
+
</a>
86
+
{/each}
87
+
</div>
88
+
89
+
<style>
90
+
:global(.gallery--icon) {
91
+
--drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent);
92
+
color: var(--nucleus-fg);
93
+
filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color))
94
+
drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color));
95
+
}
96
+
97
+
/* --- Default Grid (for 2+ images) --- */
98
+
.gallery.styling-twitter {
99
+
display: grid;
100
+
gap: 2px;
101
+
border-radius: 4px;
102
+
overflow: hidden;
103
+
width: 100%;
104
+
}
105
+
106
+
.gallery.styling-twitter > a {
107
+
width: 100%;
108
+
height: 100%;
109
+
display: block;
110
+
position: relative;
111
+
overflow: hidden;
112
+
}
113
+
114
+
.gallery.styling-twitter > a > img {
115
+
@apply transition-opacity duration-200 hover:opacity-80;
116
+
width: 100%;
117
+
height: 100%;
118
+
object-fit: cover; /* Standard tile crop */
119
+
}
120
+
121
+
/* --- SINGLE IMAGE OVERRIDES --- */
122
+
/* This configuration allows the image to determine the width/height
123
+
naturally based on aspect ratio, up to a max-height limit.
124
+
*/
125
+
.gallery.styling-twitter[data-total='1'] {
126
+
display: block; /* Remove grid constraints */
127
+
height: auto;
128
+
aspect-ratio: auto; /* Remove 16:9 ratio */
129
+
border-radius: 0;
130
+
}
131
+
132
+
.gallery.styling-twitter[data-total='1'] > a {
133
+
/* fit-content is key: the container shrinks to fit the image width */
134
+
width: fit-content;
135
+
height: auto;
136
+
display: block;
137
+
border-radius: 4px;
138
+
overflow: hidden;
139
+
max-width: 100%; /* Prevent overflowing the parent */
140
+
}
141
+
142
+
.gallery.styling-twitter[data-total='1'] > a > img {
143
+
/* Let dimensions flow naturally */
144
+
width: auto;
145
+
height: auto;
146
+
147
+
/* Constraints: */
148
+
max-width: 100%; /* Never wider than container */
149
+
max-height: 60vh; /* Never taller than 60% of viewport (adjust if needed) */
150
+
151
+
object-fit: contain; /* Never crop the single image */
152
+
}
153
+
154
+
/* --- Grid Layouts (2+ Images) --- */
155
+
/* These retain the standard grid look */
156
+
157
+
/* 2 Images: Split vertically */
158
+
.gallery.styling-twitter[data-total='2'] {
159
+
grid-template-columns: 1fr 1fr;
160
+
grid-template-rows: 1fr;
161
+
aspect-ratio: 16/9;
162
+
}
163
+
164
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
165
+
.gallery.styling-twitter[data-total='3'] {
166
+
grid-template-columns: 1fr 1fr;
167
+
grid-template-rows: 1fr 1fr;
168
+
aspect-ratio: 16/9;
169
+
}
170
+
.gallery.styling-twitter[data-total='3'] > a:first-child {
171
+
grid-row: span 2;
172
+
}
173
+
174
+
/* 4+ Images: 2x2 Grid */
175
+
.gallery.styling-twitter[data-total='4'],
176
+
.gallery.styling-twitter[data-total^='5'],
177
+
.gallery.styling-twitter:not([data-total='1']):not([data-total='2']):not([data-total='3']) {
178
+
grid-template-columns: 1fr 1fr;
179
+
grid-template-rows: 1fr 1fr;
180
+
aspect-ratio: 16/9;
181
+
}
182
+
183
+
.gallery.styling-twitter .hidden-in-grid {
184
+
display: none;
185
+
}
186
+
187
+
.more-overlay {
188
+
position: absolute;
189
+
inset: 0;
190
+
background-color: rgba(0, 0, 0, 0.5);
191
+
color: white;
192
+
display: flex;
193
+
align-items: center;
194
+
justify-content: center;
195
+
font-size: 2rem;
196
+
font-weight: bold;
197
+
pointer-events: none;
198
+
}
199
+
</style>
+1
-4
src/components/PostComposer.svelte
+1
-4
src/components/PostComposer.svelte
···
174
174
{_state.text.length} / 300
175
175
</span>
176
176
<button
177
-
onmousedown={(e) => {
178
-
e.preventDefault();
179
-
doPost();
180
-
}}
177
+
onclick={doPost}
181
178
disabled={_state.text.length === 0 || _state.text.length > 300}
182
179
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
183
180
style="background: color-mix(in srgb, {color} 87%, transparent);"