appview-less bluesky client
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 alt?: string;
12 }
13 export type GalleryData = Array<GalleryItem>;
14</script>
15
16<script lang="ts">
17 import 'photoswipe/photoswipe.css';
18 import PhotoSwipeLightbox from 'photoswipe/lightbox';
19 import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe';
20 import { onMount } from 'svelte';
21 import { writable } from 'svelte/store';
22
23 export let images: GalleryData;
24 let element: HTMLDivElement;
25 let imageElements: { [key: number]: HTMLImageElement } = {};
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}#${i}`)}
66 {@const thumb = img.thumbnail ?? img}
67 {@const isHidden = i > 3}
68 {@const isOverlay = i === 3 && images.length > 4}
69
70 <!-- eslint-disable svelte/no-navigation-without-resolve -->
71 <a
72 href={img.src}
73 data-pswp-width={img.width ?? imageElements[i]?.width}
74 data-pswp-height={img.height ?? imageElements[i]?.height}
75 target="_blank"
76 class:hidden-in-grid={isHidden}
77 class:overlay-container={isOverlay}
78 >
79 <img bind:this={imageElements[i]} src={thumb.src} title={img.alt ?? ''} alt={img.alt ?? ''} />
80
81 {#if isOverlay}
82 <div class="more-overlay">
83 +{images.length - 4}
84 </div>
85 {/if}
86 </a>
87 {/each}
88</div>
89
90<style>
91 :global(.gallery--icon) {
92 --drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent);
93 color: var(--nucleus-fg);
94 filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color))
95 drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color));
96 }
97
98 /* --- Default Grid (for 2+ images) --- */
99 .gallery.styling-twitter {
100 display: grid;
101 gap: 2px;
102 border-radius: 4px;
103 overflow: hidden;
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>