@atmo-dev/kit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -1
- package/dist/components/ExternalEmbed.svelte +174 -0
- package/dist/components/ExternalEmbed.svelte.d.ts +8 -0
- package/dist/components/GrainAlbumPicker.svelte +218 -0
- package/dist/components/GrainAlbumPicker.svelte.d.ts +14 -0
- package/dist/components/GrainAlbumView.svelte +94 -0
- package/dist/components/GrainAlbumView.svelte.d.ts +10 -0
- package/dist/components/NestedComments.svelte +20 -10
- package/dist/components/NestedComments.svelte.d.ts +2 -0
- package/dist/components/PhotoGrid.svelte +53 -0
- package/dist/components/PhotoGrid.svelte.d.ts +9 -0
- package/dist/components/PhotoLightbox.svelte +332 -0
- package/dist/components/PhotoLightbox.svelte.d.ts +12 -0
- package/dist/components/PostContent.svelte +78 -20
- package/dist/components/PostContent.svelte.d.ts +5 -0
- package/dist/components/StreamplacePicker.svelte +94 -0
- package/dist/components/StreamplacePicker.svelte.d.ts +12 -0
- package/dist/grain/types.d.ts +41 -0
- package/dist/grain/types.js +1 -0
- package/dist/grain/urls.d.ts +7 -0
- package/dist/grain/urls.js +74 -0
- package/dist/grain/xrpc.d.ts +3 -0
- package/dist/grain/xrpc.js +129 -0
- package/dist/grain.d.ts +3 -0
- package/dist/grain.js +2 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +1 -0
- package/dist/social/external-embeds.d.ts +39 -0
- package/dist/social/external-embeds.js +107 -0
- package/dist/social/posts.d.ts +59 -17
- package/dist/social/posts.js +99 -12
- package/dist/social.d.ts +2 -0
- package/dist/social.js +1 -0
- package/dist/streamplace.d.ts +6 -0
- package/dist/streamplace.js +1 -0
- package/package.json +13 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { PostData } from '../social/posts.js';
|
|
2
|
+
import type { ExternalEmbedProvider } from '../social/external-embeds.js';
|
|
2
3
|
import type { PreferredSocialClient } from '../social/preferred-client.js';
|
|
3
4
|
import NestedComments from './NestedComments.svelte';
|
|
4
5
|
type $$ComponentProps = {
|
|
5
6
|
comments: PostData[];
|
|
6
7
|
socialClient?: PreferredSocialClient | null;
|
|
8
|
+
embedProviders?: readonly ExternalEmbedProvider[];
|
|
7
9
|
};
|
|
8
10
|
declare const NestedComments: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
11
|
type NestedComments = ReturnType<typeof NestedComments>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../utils.js';
|
|
3
|
+
import type { Photo } from '../media.js';
|
|
4
|
+
import PhotoLightbox from './PhotoLightbox.svelte';
|
|
5
|
+
export type PhotoGridProps = { photos: Photo[]; label?: string; class?: string };
|
|
6
|
+
let { photos, label = 'Photos', class: className }: PhotoGridProps = $props();
|
|
7
|
+
let revealed: Set<number> = $state(new Set());
|
|
8
|
+
let open = $state(false);
|
|
9
|
+
let index = $state(0);
|
|
10
|
+
let trigger: HTMLButtonElement | null = $state(null);
|
|
11
|
+
let visible = $derived(photos.filter((photo, i) => !photo.contentWarning || revealed.has(i)));
|
|
12
|
+
$effect(() => {
|
|
13
|
+
void photos;
|
|
14
|
+
revealed = new Set();
|
|
15
|
+
open = false;
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<div class={cn('columns-2 gap-2 sm:columns-3', className)}>
|
|
20
|
+
{#each photos as photo, i (`${photo.id || photo.src}-${i}`)}
|
|
21
|
+
{#if photo.contentWarning && !revealed.has(i)}
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="mb-2 block w-full break-inside-avoid-column rounded-lg border border-line p-3 text-sm text-muted"
|
|
25
|
+
style:aspect-ratio={photo.width && photo.height ? `${photo.width} / ${photo.height}` : '1'}
|
|
26
|
+
onclick={() => (revealed = new Set([...revealed, i]))}
|
|
27
|
+
>Content warning · Show photo {i + 1}</button
|
|
28
|
+
>
|
|
29
|
+
{:else}
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="group mb-2 block w-full break-inside-avoid-column overflow-hidden rounded-lg focus-visible:ring-2 focus-visible:ring-accent-500"
|
|
33
|
+
aria-label={`View photo ${i + 1} of ${photos.length}${photo.alt ? `: ${photo.alt}` : ''}`}
|
|
34
|
+
onclick={(event) => {
|
|
35
|
+
trigger = event.currentTarget;
|
|
36
|
+
index = visible.indexOf(photo);
|
|
37
|
+
open = true;
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<img
|
|
41
|
+
src={photo.thumbnail || photo.src}
|
|
42
|
+
alt={photo.alt || ''}
|
|
43
|
+
loading="lazy"
|
|
44
|
+
decoding="async"
|
|
45
|
+
width={photo.width}
|
|
46
|
+
height={photo.height}
|
|
47
|
+
class="block h-auto w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-out motion-safe:pointer-fine:group-hover:scale-[1.02]"
|
|
48
|
+
/>
|
|
49
|
+
</button>
|
|
50
|
+
{/if}
|
|
51
|
+
{/each}
|
|
52
|
+
</div>
|
|
53
|
+
<PhotoLightbox photos={visible} {label} bind:open bind:index returnFocus={trigger} />
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Photo } from '../media.js';
|
|
2
|
+
export type PhotoGridProps = {
|
|
3
|
+
photos: Photo[];
|
|
4
|
+
label?: string;
|
|
5
|
+
class?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const PhotoGrid: import("svelte").Component<PhotoGridProps, {}, "">;
|
|
8
|
+
type PhotoGrid = ReturnType<typeof PhotoGrid>;
|
|
9
|
+
export default PhotoGrid;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { Dialog } from 'bits-ui';
|
|
4
|
+
import { ChevronLeft, ChevronRight, X } from '@sketchyicons/svelte';
|
|
5
|
+
import Icon from './Icon.svelte';
|
|
6
|
+
import { cn } from '../utils.js';
|
|
7
|
+
import type { Photo } from '../media.js';
|
|
8
|
+
export type PhotoLightboxProps = {
|
|
9
|
+
photos: Photo[];
|
|
10
|
+
open?: boolean;
|
|
11
|
+
index?: number;
|
|
12
|
+
label?: string;
|
|
13
|
+
returnFocus?: HTMLElement | null;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
let {
|
|
17
|
+
photos,
|
|
18
|
+
open = $bindable(false),
|
|
19
|
+
index = $bindable(0),
|
|
20
|
+
label = 'Photos',
|
|
21
|
+
returnFocus,
|
|
22
|
+
class: className
|
|
23
|
+
}: PhotoLightboxProps = $props();
|
|
24
|
+
let selected = $derived(photos[index]);
|
|
25
|
+
let viewport = $state<HTMLDivElement>();
|
|
26
|
+
// End copies make native swipes loop, without showing duplicate slides to AT.
|
|
27
|
+
let slides = $derived.by(() => {
|
|
28
|
+
const real = photos.map((photo, index) => ({ photo, index, copy: false }));
|
|
29
|
+
return real.length > 1
|
|
30
|
+
? [{ ...real[real.length - 1], copy: true }, ...real, { ...real[0], copy: true }]
|
|
31
|
+
: real;
|
|
32
|
+
});
|
|
33
|
+
let knownNode: HTMLDivElement | undefined;
|
|
34
|
+
let knownPhotos: Photo[] | undefined;
|
|
35
|
+
let knownCount = 0;
|
|
36
|
+
let acknowledgedIndex = 0;
|
|
37
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
let touching = false;
|
|
39
|
+
let suppressClick = false;
|
|
40
|
+
let drag:
|
|
41
|
+
| { pointerId: number; x: number; y: number; left: number; slot: number; moved: boolean }
|
|
42
|
+
| undefined;
|
|
43
|
+
|
|
44
|
+
function photoIndex(slot: number) {
|
|
45
|
+
return (((slot - (photos.length > 1 ? 1 : 0)) % photos.length) + photos.length) % photos.length;
|
|
46
|
+
}
|
|
47
|
+
function select(value: number) {
|
|
48
|
+
acknowledgedIndex = value;
|
|
49
|
+
index = value;
|
|
50
|
+
}
|
|
51
|
+
function resetDrag(node = viewport, restoreSnap = true) {
|
|
52
|
+
if (drag && node?.hasPointerCapture(drag.pointerId)) node.releasePointerCapture(drag.pointerId);
|
|
53
|
+
drag = undefined;
|
|
54
|
+
if (restoreSnap) node?.style.removeProperty('scroll-snap-type');
|
|
55
|
+
}
|
|
56
|
+
function align(value: number) {
|
|
57
|
+
select(value >= 0 && value < photos.length ? value : 0);
|
|
58
|
+
viewport?.scrollTo({
|
|
59
|
+
left: (index + (photos.length > 1 ? 1 : 0)) * viewport.clientWidth,
|
|
60
|
+
behavior: 'instant'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function move(delta: number) {
|
|
64
|
+
if (!photos.length) return;
|
|
65
|
+
resetDrag();
|
|
66
|
+
align((index + delta + photos.length) % photos.length);
|
|
67
|
+
}
|
|
68
|
+
function settled() {
|
|
69
|
+
const node = viewport;
|
|
70
|
+
if (!node?.clientWidth || touching || drag || photos.length < 2) return;
|
|
71
|
+
const slot = Math.round(node.scrollLeft / node.clientWidth);
|
|
72
|
+
if (Math.abs(node.scrollLeft - slot * node.clientWidth) > 1) return;
|
|
73
|
+
node.style.removeProperty('scroll-snap-type');
|
|
74
|
+
select(photoIndex(slot));
|
|
75
|
+
// Recenter only after scrolling stops, never underneath a finger or wheel.
|
|
76
|
+
if (slot === 0 || slot === slides.length - 1) align(index);
|
|
77
|
+
}
|
|
78
|
+
function settleSoon() {
|
|
79
|
+
clearTimeout(settleTimer);
|
|
80
|
+
// Fallback for browsers without scrollend.
|
|
81
|
+
settleTimer = setTimeout(settled, 150);
|
|
82
|
+
}
|
|
83
|
+
function scrolled() {
|
|
84
|
+
const node = viewport;
|
|
85
|
+
if (!node?.clientWidth || !photos.length) return;
|
|
86
|
+
select(photoIndex(Math.round(node.scrollLeft / node.clientWidth)));
|
|
87
|
+
if (touching) suppressClick = true;
|
|
88
|
+
settleSoon();
|
|
89
|
+
}
|
|
90
|
+
function pointerDown(event: PointerEvent) {
|
|
91
|
+
suppressClick = false;
|
|
92
|
+
// Touch and trackpads use native scrolling, including inertia and pinch zoom.
|
|
93
|
+
if (
|
|
94
|
+
event.pointerType === 'touch' ||
|
|
95
|
+
!event.isPrimary ||
|
|
96
|
+
event.button !== 0 ||
|
|
97
|
+
photos.length < 2
|
|
98
|
+
)
|
|
99
|
+
return;
|
|
100
|
+
if (drag) {
|
|
101
|
+
resetDrag();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const node = viewport;
|
|
105
|
+
if (!node) return;
|
|
106
|
+
const left = node.scrollLeft;
|
|
107
|
+
node.style.scrollSnapType = 'none';
|
|
108
|
+
// Stop any previous release animation before starting another drag.
|
|
109
|
+
node.scrollTo({ left, behavior: 'instant' });
|
|
110
|
+
drag = {
|
|
111
|
+
pointerId: event.pointerId,
|
|
112
|
+
x: event.clientX,
|
|
113
|
+
y: event.clientY,
|
|
114
|
+
left,
|
|
115
|
+
slot: Math.round(node.scrollLeft / node.clientWidth),
|
|
116
|
+
moved: false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function pointerMove(event: PointerEvent) {
|
|
120
|
+
const node = viewport,
|
|
121
|
+
start = drag;
|
|
122
|
+
if (!node || !start || start.pointerId !== event.pointerId) return;
|
|
123
|
+
const dx = event.clientX - start.x,
|
|
124
|
+
dy = event.clientY - start.y;
|
|
125
|
+
if (!start.moved) {
|
|
126
|
+
if (Math.abs(dy) > 8 && Math.abs(dy) > Math.abs(dx)) {
|
|
127
|
+
resetDrag();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (Math.abs(dx) < 5 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
|
131
|
+
start.moved = true;
|
|
132
|
+
node.style.scrollSnapType = 'none';
|
|
133
|
+
node.setPointerCapture(event.pointerId);
|
|
134
|
+
}
|
|
135
|
+
suppressClick = true;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
node.scrollLeft = start.left - dx;
|
|
138
|
+
}
|
|
139
|
+
function pointerUp(event: PointerEvent, cancelled = false) {
|
|
140
|
+
const node = viewport,
|
|
141
|
+
start = drag;
|
|
142
|
+
if (!node || !start || start.pointerId !== event.pointerId) return;
|
|
143
|
+
// Keep snapping disabled until the release animation reaches its target;
|
|
144
|
+
// restoring it earlier makes the browser snap back and cancel scrollTo.
|
|
145
|
+
resetDrag(node, !start.moved);
|
|
146
|
+
if (!start.moved) return;
|
|
147
|
+
suppressClick = true;
|
|
148
|
+
const dx = event.clientX - start.x;
|
|
149
|
+
const threshold = Math.min(80, Math.max(40, node.clientWidth * 0.1));
|
|
150
|
+
const delta = !cancelled && Math.abs(dx) >= threshold ? (dx < 0 ? 1 : -1) : 0;
|
|
151
|
+
const slot = Math.max(0, Math.min(slides.length - 1, start.slot + delta));
|
|
152
|
+
node.scrollTo({
|
|
153
|
+
left: slot * node.clientWidth,
|
|
154
|
+
behavior:
|
|
155
|
+
cancelled || matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'
|
|
156
|
+
});
|
|
157
|
+
settleSoon();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
$effect(() => {
|
|
161
|
+
if (!photos.length) open = false;
|
|
162
|
+
if (index < 0 || index >= photos.length) index = 0;
|
|
163
|
+
});
|
|
164
|
+
$effect(() => {
|
|
165
|
+
const node = viewport,
|
|
166
|
+
items = photos,
|
|
167
|
+
count = photos.length,
|
|
168
|
+
requested = index;
|
|
169
|
+
if (!open || !node || !count) return;
|
|
170
|
+
untrack(() => {
|
|
171
|
+
// Native scroll updates acknowledge their index, so bindings never reset a
|
|
172
|
+
// drag. External bind:index updates and replaced lists still realign.
|
|
173
|
+
if (
|
|
174
|
+
knownNode !== node ||
|
|
175
|
+
knownPhotos !== items ||
|
|
176
|
+
knownCount !== count ||
|
|
177
|
+
acknowledgedIndex !== requested
|
|
178
|
+
) {
|
|
179
|
+
resetDrag();
|
|
180
|
+
knownNode = node;
|
|
181
|
+
knownPhotos = items;
|
|
182
|
+
knownCount = count;
|
|
183
|
+
align(requested);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
$effect(() => {
|
|
188
|
+
const node = viewport;
|
|
189
|
+
if (!node) return;
|
|
190
|
+
let width = node.clientWidth,
|
|
191
|
+
height = node.clientHeight;
|
|
192
|
+
const observer = new ResizeObserver(() => {
|
|
193
|
+
// Ignore the observer's initial notification: it must not cancel a
|
|
194
|
+
// gesture that began immediately after opening.
|
|
195
|
+
if (width === node.clientWidth && height === node.clientHeight) return;
|
|
196
|
+
width = node.clientWidth;
|
|
197
|
+
height = node.clientHeight;
|
|
198
|
+
resetDrag(node);
|
|
199
|
+
align(index);
|
|
200
|
+
});
|
|
201
|
+
observer.observe(node);
|
|
202
|
+
return () => {
|
|
203
|
+
observer.disconnect();
|
|
204
|
+
clearTimeout(settleTimer);
|
|
205
|
+
resetDrag(node);
|
|
206
|
+
touching = false;
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<svelte:window
|
|
212
|
+
onkeydown={(event) => {
|
|
213
|
+
if (!open || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
214
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
move(event.key === 'ArrowLeft' ? -1 : 1);
|
|
217
|
+
}
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
|
|
221
|
+
<Dialog.Root bind:open>
|
|
222
|
+
{#if open && selected}
|
|
223
|
+
<Dialog.Portal>
|
|
224
|
+
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/85" />
|
|
225
|
+
<Dialog.Content
|
|
226
|
+
aria-describedby={undefined}
|
|
227
|
+
onclick={(event) => {
|
|
228
|
+
if (suppressClick) {
|
|
229
|
+
suppressClick = false;
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
event.target === event.currentTarget ||
|
|
235
|
+
event.target === viewport ||
|
|
236
|
+
(event.target instanceof HTMLElement &&
|
|
237
|
+
event.target.hasAttribute('data-lightbox-slide'))
|
|
238
|
+
)
|
|
239
|
+
open = false;
|
|
240
|
+
}}
|
|
241
|
+
class={cn('fixed inset-0 z-50 outline-none', className)}
|
|
242
|
+
onCloseAutoFocus={(event) => {
|
|
243
|
+
if (returnFocus?.isConnected) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
returnFocus.focus();
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<Dialog.Title class="sr-only">{label} · {index + 1} / {photos.length}</Dialog.Title>
|
|
250
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable carousel needs a tab stop for Safari; arrow keys navigate photos.) -->
|
|
251
|
+
<div
|
|
252
|
+
tabindex="0"
|
|
253
|
+
bind:this={viewport}
|
|
254
|
+
data-slot="photo-lightbox-track"
|
|
255
|
+
role="group"
|
|
256
|
+
aria-label="Photos"
|
|
257
|
+
aria-roledescription="carousel"
|
|
258
|
+
dir="ltr"
|
|
259
|
+
class="flex h-full w-full snap-x snap-mandatory [scrollbar-width:none] overflow-x-auto overscroll-x-contain outline-none select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white/70 [&::-webkit-scrollbar]:hidden"
|
|
260
|
+
onscroll={scrolled}
|
|
261
|
+
onscrollend={settled}
|
|
262
|
+
onpointerdown={pointerDown}
|
|
263
|
+
onpointermove={pointerMove}
|
|
264
|
+
onpointerup={(event) => pointerUp(event)}
|
|
265
|
+
onpointercancel={(event) => pointerUp(event, true)}
|
|
266
|
+
ontouchstart={() => {
|
|
267
|
+
touching = true;
|
|
268
|
+
suppressClick = false;
|
|
269
|
+
}}
|
|
270
|
+
ontouchend={(event) => {
|
|
271
|
+
touching = event.touches.length > 0;
|
|
272
|
+
settleSoon();
|
|
273
|
+
}}
|
|
274
|
+
ontouchcancel={() => {
|
|
275
|
+
touching = false;
|
|
276
|
+
settleSoon();
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{#each slides as slide, slot (slot)}
|
|
280
|
+
<div
|
|
281
|
+
data-lightbox-slide
|
|
282
|
+
role="group"
|
|
283
|
+
aria-roledescription="slide"
|
|
284
|
+
aria-label={`Photo ${slide.index + 1} of ${photos.length}`}
|
|
285
|
+
aria-hidden={slide.copy || slide.index !== index}
|
|
286
|
+
class="flex h-full min-h-0 w-full min-w-0 shrink-0 snap-start snap-always items-center justify-center"
|
|
287
|
+
>
|
|
288
|
+
<img
|
|
289
|
+
src={slide.photo.src}
|
|
290
|
+
alt={slide.photo.alt || ''}
|
|
291
|
+
draggable={false}
|
|
292
|
+
width={slide.photo.width}
|
|
293
|
+
height={slide.photo.height}
|
|
294
|
+
loading={slide.index === index ? 'eager' : 'lazy'}
|
|
295
|
+
decoding="async"
|
|
296
|
+
class="pointer-events-auto h-auto max-h-full w-auto max-w-full object-contain"
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
{/each}
|
|
300
|
+
</div>
|
|
301
|
+
<p
|
|
302
|
+
role="status"
|
|
303
|
+
aria-label="Photo position"
|
|
304
|
+
class="pointer-events-none fixed right-4 bottom-4 rounded-full bg-black/60 px-3 py-1.5 text-xs text-white/90 tabular-nums"
|
|
305
|
+
>
|
|
306
|
+
{index + 1} / {photos.length}
|
|
307
|
+
</p>
|
|
308
|
+
<Dialog.Close
|
|
309
|
+
aria-label="Close dialog"
|
|
310
|
+
class="pointer-events-auto fixed top-4 right-4 inline-flex size-11 items-center justify-center rounded-full bg-black/30 text-white transition-colors hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
|
311
|
+
><Icon img={X} size={22} /></Dialog.Close
|
|
312
|
+
>
|
|
313
|
+
{#if photos.length > 1}
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
aria-label="Previous photo"
|
|
317
|
+
onclick={() => move(-1)}
|
|
318
|
+
class="pointer-events-auto fixed top-1/2 left-3 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/30 text-white transition-colors hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:left-6"
|
|
319
|
+
><Icon img={ChevronLeft} size={26} /></button
|
|
320
|
+
>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
aria-label="Next photo"
|
|
324
|
+
onclick={() => move(1)}
|
|
325
|
+
class="pointer-events-auto fixed top-1/2 right-3 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/30 text-white transition-colors hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:right-6"
|
|
326
|
+
><Icon img={ChevronRight} size={26} /></button
|
|
327
|
+
>
|
|
328
|
+
{/if}
|
|
329
|
+
</Dialog.Content>
|
|
330
|
+
</Dialog.Portal>
|
|
331
|
+
{/if}
|
|
332
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Photo } from '../media.js';
|
|
2
|
+
export type PhotoLightboxProps = {
|
|
3
|
+
photos: Photo[];
|
|
4
|
+
open?: boolean;
|
|
5
|
+
index?: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
returnFocus?: HTMLElement | null;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const PhotoLightbox: import("svelte").Component<PhotoLightboxProps, {}, "open" | "index">;
|
|
11
|
+
type PhotoLightbox = ReturnType<typeof PhotoLightbox>;
|
|
12
|
+
export default PhotoLightbox;
|
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { List, Radio, ShieldCheck, Users } from '@sketchyicons/svelte';
|
|
2
3
|
import type { PostData } from '../social/posts.js';
|
|
4
|
+
import type { PreferredSocialClient } from '../social/preferred-client.js';
|
|
5
|
+
import { socialPostUrl } from '../social/urls.js';
|
|
6
|
+
import { focus } from '../styles.js';
|
|
7
|
+
import Icon from './Icon.svelte';
|
|
3
8
|
import { richTextSegments, safeWebUrl } from '../social/richtext.js';
|
|
4
|
-
import
|
|
9
|
+
import type { ExternalEmbedProvider } from '../social/external-embeds.js';
|
|
10
|
+
import ExternalEmbed from './ExternalEmbed.svelte';
|
|
5
11
|
import PostVideo from './PostVideo.svelte';
|
|
6
12
|
import PostContent from './PostContent.svelte';
|
|
7
|
-
let {
|
|
13
|
+
let {
|
|
14
|
+
post,
|
|
15
|
+
socialClient,
|
|
16
|
+
embedProviders
|
|
17
|
+
}: {
|
|
18
|
+
post: PostData;
|
|
19
|
+
socialClient?: PreferredSocialClient | null;
|
|
20
|
+
/** Defaults to YouTube and Klipy. Pass [] to render only ordinary link cards. */
|
|
21
|
+
embedProviders?: readonly ExternalEmbedProvider[];
|
|
22
|
+
} = $props();
|
|
23
|
+
const recordKinds = {
|
|
24
|
+
feed: { label: 'Feed', icon: Radio },
|
|
25
|
+
list: { label: 'List', icon: List },
|
|
26
|
+
'starter-pack': { label: 'Starter pack', icon: Users },
|
|
27
|
+
labeler: { label: 'Moderation service', icon: ShieldCheck }
|
|
28
|
+
};
|
|
8
29
|
</script>
|
|
9
30
|
|
|
10
|
-
<p class="text-sm leading-relaxed break-words whitespace-pre-wrap text-ink">
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
</p>
|
|
31
|
+
{#if post.text}<p class="text-sm leading-relaxed break-words whitespace-pre-wrap text-ink">
|
|
32
|
+
{#each richTextSegments(post.text, post.facets) as segment, index (index)}{#if segment.href}<a
|
|
33
|
+
href={segment.href}
|
|
34
|
+
target="_blank"
|
|
35
|
+
rel="noopener noreferrer nofollow"
|
|
36
|
+
class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
|
|
37
|
+
>{segment.text}</a
|
|
38
|
+
>{:else}{segment.text}{/if}{/each}
|
|
39
|
+
</p>{/if}
|
|
19
40
|
{#each post.embeds ?? [] as embed, index (index)}
|
|
20
41
|
<div class="mt-3">
|
|
21
|
-
{#if embed.type === 'external'}<
|
|
22
|
-
href={embed.uri}
|
|
23
|
-
meta={{ title: embed.title, description: embed.description, image: embed.image }}
|
|
24
|
-
/>
|
|
42
|
+
{#if embed.type === 'external'}<ExternalEmbed {embed} providers={embedProviders} />
|
|
25
43
|
{:else if embed.type === 'images'}<div
|
|
26
44
|
class="grid gap-1 overflow-hidden rounded-ui border border-line"
|
|
27
45
|
class:grid-cols-2={embed.images.length > 1}
|
|
28
46
|
>
|
|
29
|
-
{#each embed.images as image (
|
|
47
|
+
{#each embed.images as image, imageIndex (imageIndex)}<a
|
|
30
48
|
href={safeWebUrl(image.fullsize ?? image.src)}
|
|
31
49
|
target="_blank"
|
|
32
50
|
rel="noopener noreferrer"
|
|
@@ -47,16 +65,56 @@
|
|
|
47
65
|
label={embed.alt}
|
|
48
66
|
href={post.href}
|
|
49
67
|
/>
|
|
68
|
+
{:else if embed.type === 'record'}
|
|
69
|
+
{@const kind = recordKinds[embed.kind]}
|
|
70
|
+
<a
|
|
71
|
+
href={safeWebUrl(embed.href)}
|
|
72
|
+
target="_blank"
|
|
73
|
+
rel="noopener noreferrer"
|
|
74
|
+
class={`flex items-start gap-3 rounded-ui border border-line p-3 text-ink no-underline hover:border-control-line ${focus}`}
|
|
75
|
+
>
|
|
76
|
+
{#if safeWebUrl(embed.image)}<img
|
|
77
|
+
src={safeWebUrl(embed.image)}
|
|
78
|
+
alt=""
|
|
79
|
+
loading="lazy"
|
|
80
|
+
class="size-10 shrink-0 rounded-ui object-cover"
|
|
81
|
+
/>{:else}<span
|
|
82
|
+
class="flex size-10 shrink-0 items-center justify-center rounded-ui bg-surface text-muted"
|
|
83
|
+
><Icon img={kind.icon} size={22} /></span
|
|
84
|
+
>{/if}
|
|
85
|
+
<div class="min-w-0">
|
|
86
|
+
<p class="truncate text-xs text-muted">
|
|
87
|
+
{kind.label}{embed.handle ? ` · @${embed.handle}` : ''}
|
|
88
|
+
</p>
|
|
89
|
+
<p class="mt-0.5 text-sm font-medium break-words">{embed.title}</p>
|
|
90
|
+
{#if embed.description}<p
|
|
91
|
+
class="mt-1 line-clamp-3 text-xs leading-relaxed break-words text-muted"
|
|
92
|
+
>
|
|
93
|
+
{embed.description}
|
|
94
|
+
</p>{/if}
|
|
95
|
+
</div>
|
|
96
|
+
</a>
|
|
50
97
|
{:else if embed.type === 'quote'}<div class="rounded-ui border border-line p-3">
|
|
51
98
|
{#if embed.post}<a
|
|
52
|
-
href={safeWebUrl(embed.post.href)}
|
|
99
|
+
href={socialPostUrl(embed.post.uri, socialClient) ?? safeWebUrl(embed.post.href)}
|
|
53
100
|
target="_blank"
|
|
54
101
|
rel="noopener noreferrer"
|
|
55
102
|
class="mb-2 block text-xs font-medium text-muted"
|
|
56
103
|
>{embed.post.author.name} · @{embed.post.author.handle}</a
|
|
57
|
-
><PostContent post={embed.post} />{:else}<p
|
|
58
|
-
|
|
104
|
+
><PostContent post={embed.post} {socialClient} {embedProviders} />{:else}<p
|
|
105
|
+
class="text-xs text-muted"
|
|
106
|
+
>
|
|
107
|
+
{embed.reason === 'blocked'
|
|
108
|
+
? 'Post blocked'
|
|
109
|
+
: embed.reason === 'detached'
|
|
110
|
+
? 'Quote removed'
|
|
111
|
+
: 'Post unavailable'}
|
|
59
112
|
</p>{/if}
|
|
60
|
-
</div>
|
|
113
|
+
</div>
|
|
114
|
+
{:else if embed.type === 'unsupported'}<p
|
|
115
|
+
class="rounded-ui border border-line p-3 text-xs text-muted"
|
|
116
|
+
>
|
|
117
|
+
Embed unavailable
|
|
118
|
+
</p>{/if}
|
|
61
119
|
</div>
|
|
62
120
|
{/each}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { PostData } from '../social/posts.js';
|
|
2
|
+
import type { PreferredSocialClient } from '../social/preferred-client.js';
|
|
3
|
+
import type { ExternalEmbedProvider } from '../social/external-embeds.js';
|
|
2
4
|
import PostContent from './PostContent.svelte';
|
|
3
5
|
type $$ComponentProps = {
|
|
4
6
|
post: PostData;
|
|
7
|
+
socialClient?: PreferredSocialClient | null;
|
|
8
|
+
/** Defaults to YouTube and Klipy. Pass [] to render only ordinary link cards. */
|
|
9
|
+
embedProviders?: readonly ExternalEmbedProvider[];
|
|
5
10
|
};
|
|
6
11
|
declare const PostContent: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
12
|
type PostContent = ReturnType<typeof PostContent>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from './Button.svelte';
|
|
3
|
+
import Input from './Input.svelte';
|
|
4
|
+
import Modal from './Modal.svelte';
|
|
5
|
+
import { parseStreamplaceLink } from '../streamplace.js';
|
|
6
|
+
import type { StreamplaceSelection } from '../streamplace.js';
|
|
7
|
+
|
|
8
|
+
export type StreamplacePickerProps = {
|
|
9
|
+
open?: boolean;
|
|
10
|
+
actor?: string | null;
|
|
11
|
+
replacing?: boolean;
|
|
12
|
+
resolveHandle: (handle: string) => Promise<string>;
|
|
13
|
+
onselect: StreamplaceSelection;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
let {
|
|
17
|
+
open = $bindable(false),
|
|
18
|
+
actor,
|
|
19
|
+
replacing = false,
|
|
20
|
+
resolveHandle,
|
|
21
|
+
onselect,
|
|
22
|
+
class: className
|
|
23
|
+
}: StreamplacePickerProps = $props();
|
|
24
|
+
let input = $state('');
|
|
25
|
+
let error = $state('');
|
|
26
|
+
let busy = $state(false);
|
|
27
|
+
let generation = 0;
|
|
28
|
+
const id = $props.id();
|
|
29
|
+
$effect(() => {
|
|
30
|
+
void open;
|
|
31
|
+
void actor;
|
|
32
|
+
generation++;
|
|
33
|
+
input = '';
|
|
34
|
+
error = '';
|
|
35
|
+
busy = false;
|
|
36
|
+
return () => {
|
|
37
|
+
generation++;
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
async function addStream(value: string) {
|
|
41
|
+
if (busy) return;
|
|
42
|
+
const stream = parseStreamplaceLink(value);
|
|
43
|
+
if (!stream) {
|
|
44
|
+
error = 'Enter a handle or Streamplace URL.';
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const session = generation;
|
|
48
|
+
busy = true;
|
|
49
|
+
error = '';
|
|
50
|
+
try {
|
|
51
|
+
if (!stream.actor.startsWith('did:')) await resolveHandle(stream.actor);
|
|
52
|
+
if (session !== generation) return;
|
|
53
|
+
const message = await onselect(stream);
|
|
54
|
+
if (session !== generation) return;
|
|
55
|
+
if (message) error = message;
|
|
56
|
+
else open = false;
|
|
57
|
+
} catch {
|
|
58
|
+
if (session === generation)
|
|
59
|
+
error = 'Couldn’t add that stream. Check the handle and try again.';
|
|
60
|
+
} finally {
|
|
61
|
+
if (session === generation) busy = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<Modal bind:open title="Add stream" class={className}>
|
|
67
|
+
{#if actor}<Button class="mb-5" disabled={busy} onclick={() => addStream(actor!)}
|
|
68
|
+
>{replacing ? 'Use my Streamplace link instead' : 'Add my Streamplace link'}</Button
|
|
69
|
+
>{/if}
|
|
70
|
+
<label for={`${id}-handle`} class="mb-2 block text-sm"
|
|
71
|
+
>Or enter another person’s handle or Streamplace URL</label
|
|
72
|
+
>
|
|
73
|
+
<div class="flex flex-col gap-2 sm:flex-row">
|
|
74
|
+
<Input
|
|
75
|
+
id={`${id}-handle`}
|
|
76
|
+
placeholder="handle.example"
|
|
77
|
+
bind:value={input}
|
|
78
|
+
class="min-w-0 flex-1"
|
|
79
|
+
onkeydown={(event) => {
|
|
80
|
+
if (event.key === 'Enter') {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
void addStream(input);
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
<Button disabled={busy || !input.trim()} onclick={() => addStream(input)}
|
|
87
|
+
>{replacing ? 'Replace stream' : 'Add stream'}</Button
|
|
88
|
+
>
|
|
89
|
+
</div>
|
|
90
|
+
{#if replacing}<p class="mt-3 text-sm text-muted">
|
|
91
|
+
This replaces the current Streamplace link.
|
|
92
|
+
</p>{/if}
|
|
93
|
+
{#if error}<p class="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>{/if}
|
|
94
|
+
</Modal>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StreamplaceSelection } from '../streamplace.js';
|
|
2
|
+
export type StreamplacePickerProps = {
|
|
3
|
+
open?: boolean;
|
|
4
|
+
actor?: string | null;
|
|
5
|
+
replacing?: boolean;
|
|
6
|
+
resolveHandle: (handle: string) => Promise<string>;
|
|
7
|
+
onselect: StreamplaceSelection;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const StreamplacePicker: import("svelte").Component<StreamplacePickerProps, {}, "open">;
|
|
11
|
+
type StreamplacePicker = ReturnType<typeof StreamplacePicker>;
|
|
12
|
+
export default StreamplacePicker;
|