@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.
Files changed (38) hide show
  1. package/README.md +59 -1
  2. package/dist/components/ExternalEmbed.svelte +174 -0
  3. package/dist/components/ExternalEmbed.svelte.d.ts +8 -0
  4. package/dist/components/GrainAlbumPicker.svelte +218 -0
  5. package/dist/components/GrainAlbumPicker.svelte.d.ts +14 -0
  6. package/dist/components/GrainAlbumView.svelte +94 -0
  7. package/dist/components/GrainAlbumView.svelte.d.ts +10 -0
  8. package/dist/components/NestedComments.svelte +20 -10
  9. package/dist/components/NestedComments.svelte.d.ts +2 -0
  10. package/dist/components/PhotoGrid.svelte +53 -0
  11. package/dist/components/PhotoGrid.svelte.d.ts +9 -0
  12. package/dist/components/PhotoLightbox.svelte +332 -0
  13. package/dist/components/PhotoLightbox.svelte.d.ts +12 -0
  14. package/dist/components/PostContent.svelte +78 -20
  15. package/dist/components/PostContent.svelte.d.ts +5 -0
  16. package/dist/components/StreamplacePicker.svelte +94 -0
  17. package/dist/components/StreamplacePicker.svelte.d.ts +12 -0
  18. package/dist/grain/types.d.ts +41 -0
  19. package/dist/grain/types.js +1 -0
  20. package/dist/grain/urls.d.ts +7 -0
  21. package/dist/grain/urls.js +74 -0
  22. package/dist/grain/xrpc.d.ts +3 -0
  23. package/dist/grain/xrpc.js +129 -0
  24. package/dist/grain.d.ts +3 -0
  25. package/dist/grain.js +2 -0
  26. package/dist/index.d.ts +16 -0
  27. package/dist/index.js +9 -0
  28. package/dist/media.d.ts +10 -0
  29. package/dist/media.js +1 -0
  30. package/dist/social/external-embeds.d.ts +39 -0
  31. package/dist/social/external-embeds.js +107 -0
  32. package/dist/social/posts.d.ts +59 -17
  33. package/dist/social/posts.js +99 -12
  34. package/dist/social.d.ts +2 -0
  35. package/dist/social.js +1 -0
  36. package/dist/streamplace.d.ts +6 -0
  37. package/dist/streamplace.js +1 -0
  38. 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 LinkCard from './LinkCard.svelte';
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 { post }: { post: PostData } = $props();
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
- {#each richTextSegments(post.text, post.facets) as segment, index (index)}{#if segment.href}<a
12
- href={segment.href}
13
- target="_blank"
14
- rel="noopener noreferrer nofollow"
15
- class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
16
- >{segment.text}</a
17
- >{:else}{segment.text}{/if}{/each}
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'}<LinkCard
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 (image.src)}<a
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 class="text-xs text-muted">
58
- Post unavailable
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>{/if}
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;