@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
package/README.md CHANGED
@@ -9,6 +9,35 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
9
9
  - Releases: `pnpm changeset`, then merge the generated release PR. [Details](.changeset/README.md).
10
10
  - License: [MIT](LICENSE).
11
11
 
12
+ ## Photos, Grain albums and Streamplace
13
+
14
+ `PhotoGrid` and `PhotoLightbox` work with provider-neutral `Photo[]` data (`src`, optional `thumbnail`, `alt`, dimensions and `contentWarning`). The grid preserves natural image proportions in responsive masonry columns, with a subtle 2% hover zoom disabled for reduced motion. It gates warned images before mounting them and opens a borderless fullscreen viewer. The viewer has a dimmed backdrop, previous/next arrows, close and a small photo-position counter; native horizontal scrolling with CSS scroll snap makes photos follow your finger or trackpad, with inertia rather than switching only on release. Mouse/pen drags also move the track directly. Swipes loop like the arrows; vertical pans do not navigate and controls are separate from the drag surface. Native pinch zoom, arrow keys, Escape, focus trapping and restoration are preserved; release animations respect reduced motion. `PhotoLightbox` also works independently with `bind:open` and `bind:index`; callers using it directly must first filter or reveal any warned photos. Apps own URL validation and moderation policy.
15
+
16
+ `GrainAlbumPicker`, `GrainAlbumView` and `StreamplacePicker` take explicit callbacks. They own presentation, pending/error states and stale-read protection—not sessions, event records, persistence, routes or caching. Picker selection callbacks can return a user-facing error string to keep the dialog open. See the offline examples at `/components`.
17
+
18
+ ```svelte
19
+ <GrainAlbumPicker
20
+ bind:open
21
+ actor={viewer.did}
22
+ listAlbums={photos.listAlbums}
23
+ loadAlbum={photos.getGallery}
24
+ onselect={(album) => attachAlbum(album)}
25
+ />
26
+ <GrainAlbumView reference={albumUri} loadAlbum={photos.getGallery} />
27
+ <StreamplacePicker
28
+ bind:open={streamOpen}
29
+ actor={viewer.handle}
30
+ {resolveHandle}
31
+ onselect={(stream) => attachStream(stream.url)}
32
+ />
33
+ ```
34
+
35
+ `photos` implements the framework-free `GrainClient` contract from `@atmo-dev/kit/grain`: `getGallery(reference)` and `listAlbums(actor, cursor?)`, returning normalized `GrainGallery`/`GrainAlbumPage` data. The UI does **not** select a backend or fetch a default app route. An atmo.pics/Contrail implementation can replace the provider later without changing the pickers, gallery or lightbox.
36
+
37
+ For now, `createGrainXrpcClient(fetchImpl?)` provides the Grain.social implementation: its fixed public `social.grain.unspecced.getGallery` and `dev.hatk.getFeed` endpoints, response normalization, CDN validation, content-warning flags, five-second timeouts and no redirects. It performs no IO until a method is called. Apps choose where it runs and own any proxy endpoint, cache, authentication or additional moderation. No arbitrary-origin proxy option is exposed.
38
+
39
+ `parseGrainLink` is exported from `@atmo-dev/kit/grain`, `parseStreamplaceLink` from `@atmo-dev/kit/streamplace`, and `Photo` from `@atmo-dev/kit/media`. The main entry point exports these too. Grain URLs normalize to DID-based links after loading; handles and percent-encoded DID identifiers are accepted. These helpers validate syntax only, not account existence.
40
+
12
41
  ## Read-only profiles
13
42
 
14
43
  `UserProfile` displays a Bluesky profile header: banner, avatar, name, handle, pronouns, optional counts, bio, modern profile links, and a DID-based “View on Bluesky” link. It has no editing or follow behavior. An optional `actions` snippet holds app-owned controls. `metrics={[{ label: 'events created', value: 12 }]}` replaces the default Bluesky counts; `metrics={[]}` hides all counts.
@@ -29,6 +58,35 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
29
58
 
30
59
  `blueskyProfileToProfileData` accepts an `app.bsky.actor.getProfile` view and validates optional fields. Its `links` must be hydrated `app.bsky.actor.link` views (`url`, optional `title` and `icon` image URL), not the profile record's ordered strongRefs. It omits unresolved refs and labeled links conservatively, deduplicates destinations, and displays at most ten. The legacy `website` field is deliberately ignored. Apps own hydration, account moderation and any more specific link-moderation policy. Supplied icons render as small decorative images; unsafe or failed icons fall back to text-only links. No link metadata or destination is fetched by the component.
31
60
 
61
+ ## Bluesky comments and embeds
62
+
63
+ `NestedComments` renders `PostData[]` with recursive replies and compact reply, repost, and like icon/count links. Counts default to zero; each link has a full accessible label and opens the post in the viewer's preferred social client. These are outbound links, not authenticated engagement buttons.
64
+
65
+ Use `blueskyPostToPostData(postView)` to convert public AppView posts, including `repostCount`. `PostContent` supports images, galleries, external link cards, HLS video, quoted posts (including their embeds), record-with-media combinations, feeds, lists, starter packs, and moderation-service cards. Missing, blocked, and detached quotes have readable placeholders; unknown embed variants show an unavailable fallback. Quote nesting is capped at three levels. Non-post record cards use Bluesky routes, since other clients may not implement them. Apps still own fetching and moderation.
66
+
67
+ ### Special link embeds
68
+
69
+ External links use an ordered provider registry. YouTube watch, short, live, embed, and `youtu.be` links get a click-to-load, privacy-enhanced player. No YouTube iframe or SDK is loaded before activation; the preview uses only the supplied AppView thumbnail. Klipy's `static.klipy.com/ii/...` media links render GIFs or looping, muted video using Bluesky's media proxy, preserving the GIF description as alt text. Klipy media autoplays and loops without native video controls or a caption row. A small corner-overlay pause/play button controls the animation; pausing video preserves its current frame. With reduced motion enabled, it stays on the preview until explicitly played. Ordinary Klipy page links without a usable media URL remain link cards; no metadata API or API key is needed. Unknown URLs and playback failures fall back to the original link card.
70
+
71
+ Pass `embedProviders` to `PostContent` or `NestedComments` (it propagates to all replies and quotes). Omit it for YouTube + Klipy, or pass `[]` to disable special players. Add providers without modifying the renderer:
72
+
73
+ ```ts
74
+ import { defaultExternalEmbedProviders, type ExternalEmbedProvider } from '@atmo-dev/kit/social';
75
+
76
+ const exampleProvider: ExternalEmbedProvider = {
77
+ id: 'example',
78
+ label: 'Example',
79
+ resolve(url) {
80
+ if (url.hostname !== 'example.com') return null;
81
+ const id = /^\/watch\/([a-zA-Z0-9_-]+)$/.exec(url.pathname)?.[1];
82
+ return id ? { type: 'iframe', src: `https://player.example.com/embed/${id}` } : null;
83
+ }
84
+ };
85
+ const embedProviders = [exampleProvider, ...defaultExternalEmbedProviders];
86
+ ```
87
+
88
+ Providers are trusted app-owned, synchronous URL resolvers returning `iframe`, `video` (typed sources), or `image` descriptors—not HTML. Match exact hosts, validate IDs, and construct known player URLs. Media descriptors can set `autoplay: true` and videos can set `controls: false` for GIF-like playback; iframe players always require a click. The shared `ExternalEmbed` renderer owns playback policy, reduced-motion handling, sandboxed iframes, safe URL schemes, pause controls, source-change resets, and fallback cards. It is also exported for standalone use.
89
+
32
90
  ## Preferred social client
33
91
 
34
92
  `SocialClientPicker` is a native form field group for Bluesky, mu, Blacksky, Witchsky, or a custom web client. Pass the viewer's saved `value` (a `PreferredSocialClient`, or `null` for Bluesky) and optionally `disabled`. It submits `client` and, for custom clients, `customUrl`. The app owns the form, save button, authentication, and feedback.
@@ -48,7 +106,7 @@ Framework-free helpers are exported from `@atmo-dev/kit/social` (and the main en
48
106
  - `preferredSocialClientRecord(selection)` builds a validated record. Never write merely because a read failed; distinguish `RecordNotFound` from PDS/network failures, and use `swapRecord` to guard updates.
49
107
  - `socialClientInfo(selection)`, `socialProfileUrl(actor, selection)`, `socialPostUrl(atUri, selection)`, and `socialHashtagUrl(tag, selection)` default to Bluesky. `socialPostUrl` accepts feed-post AT URIs and returns `null` for invalid ones. Custom URLs must be absolute HTTP(S), without credentials, query or fragment, and support `/profile/:actor` routes. Base paths are preserved.
50
108
 
51
- Read the signed-in viewer's record from their PDS, include the parsed selection in viewer data, and pass it to `<UserProfile {profile} socialClient={viewer.preferredSocialClient} />`. The profile's external button and handle link use that client. Pass the same `socialClient` prop to `NestedComments` for author, reply and like links, including nested replies. Changing the selection updates these links without changing the stored post data. Bio hashtags also use the selected client. Supply an app-owned `mentionUrl` callback to `UserProfile` (for example, `(actor) => '/p/' + encodeURIComponent(actor)`) to route bio mentions locally in the same tab; without it they open the selected social client. Post-content facets retain their existing Bluesky links. Apps remain responsible for server-side authorization, IO, incremental authorization when access is missing, and invalidating viewer data after saves.
109
+ Read the signed-in viewer's record from their PDS, include the parsed selection in viewer data, and pass it to `<UserProfile {profile} socialClient={viewer.preferredSocialClient} />`. The profile's external button and handle link use that client. Pass the same `socialClient` prop to `NestedComments` for author, reply, repost, like, and quoted-post links, including nested replies. Changing the selection updates these links without changing the stored post data. Bio hashtags also use the selected client. Supply an app-owned `mentionUrl` callback to `UserProfile` (for example, `(actor) => '/p/' + encodeURIComponent(actor)`) to route bio mentions locally in the same tab; without it they open the selected social client. Post-content facets retain their existing Bluesky links. Apps remain responsible for server-side authorization, IO, incremental authorization when access is missing, and invalidating viewer data after saves.
52
110
 
53
111
  ## Login identifiers
54
112
 
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte';
3
+ import { Pause, Play } from '@sketchyicons/svelte';
4
+ import {
5
+ resolveExternalEmbed,
6
+ type ExternalEmbedProvider,
7
+ type ExternalPostEmbed
8
+ } from '../social/external-embeds.js';
9
+ import { safeWebUrl } from '../social/richtext.js';
10
+ import { focus } from '../styles.js';
11
+ import Icon from './Icon.svelte';
12
+ import LinkCard from './LinkCard.svelte';
13
+
14
+ let {
15
+ embed,
16
+ providers
17
+ }: {
18
+ embed: ExternalPostEmbed;
19
+ providers?: readonly ExternalEmbedProvider[];
20
+ } = $props();
21
+ let match = $derived(resolveExternalEmbed(embed, providers));
22
+ // Consent belongs to this resolved player, not to its position in the feed.
23
+ // A changed URL/provider/source never inherits an earlier click, even for one render.
24
+ let activated = $state.raw<typeof match>(null);
25
+ let failed = $state.raw<typeof match>(null);
26
+ let paused = $state.raw<typeof match>(null);
27
+ // Wait for the browser preference before mounting autoplaying media, including GIF images.
28
+ let reducedMotion = $state(true);
29
+ onMount(() => {
30
+ const preference = window.matchMedia('(prefers-reduced-motion: reduce)');
31
+ const update = () => (reducedMotion = preference.matches);
32
+ update();
33
+ preference.addEventListener('change', update);
34
+ return () => preference.removeEventListener('change', update);
35
+ });
36
+ let autoplay = $derived(match?.player.type !== 'iframe' && match?.player.autoplay === true);
37
+ let active = $derived(
38
+ match !== null && (activated === match || (autoplay && !reducedMotion && paused !== match))
39
+ );
40
+ let label = $derived(embed.title || `${match?.provider.label ?? 'External'} media`);
41
+ let previewButton = $state<HTMLButtonElement | null>(null);
42
+ let stopButton = $state<HTMLButtonElement | null>(null);
43
+ let animationButton = $state<HTMLButtonElement | null>(null);
44
+ let video = $state<HTMLVideoElement | null>(null);
45
+ $effect(() => {
46
+ if (!video) return;
47
+ if (active) void video.play().catch(() => {});
48
+ else video.pause();
49
+ });
50
+ async function play() {
51
+ paused = null;
52
+ activated = match;
53
+ await tick();
54
+ (autoplay ? animationButton : stopButton)?.focus({ preventScroll: true });
55
+ }
56
+ async function stop() {
57
+ paused = match;
58
+ activated = null;
59
+ await tick();
60
+ (autoplay ? animationButton : previewButton)?.focus({ preventScroll: true });
61
+ }
62
+ </script>
63
+
64
+ {#if match && failed !== match}
65
+ <div class="relative overflow-hidden rounded-ui border border-line">
66
+ {#if active || (autoplay && match.player.type === 'video' && paused === match)}
67
+ {#if match.player.type === 'iframe'}
68
+ <iframe
69
+ src={safeWebUrl(match.player.src)}
70
+ title={label}
71
+ allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
72
+ allowfullscreen
73
+ sandbox="allow-scripts allow-same-origin allow-presentation"
74
+ referrerpolicy="strict-origin-when-cross-origin"
75
+ class="max-h-[32rem] w-full border-0 bg-black"
76
+ style:aspect-ratio={match.player.aspectRatio ?? 16 / 9}
77
+ onerror={() => (failed = match)}
78
+ ></iframe>
79
+ {:else if match.player.type === 'video'}
80
+ <!-- Klipy GIF videos have no audio or captions; the original description is the label. -->
81
+ {#key match}<video
82
+ bind:this={video}
83
+ aria-label={embed.description?.replace(/^Alt: /i, '') || label}
84
+ poster={safeWebUrl(embed.image)}
85
+ controls={match.player.controls ?? true}
86
+ autoplay
87
+ muted
88
+ loop
89
+ playsinline
90
+ class="max-h-[32rem] w-full bg-black object-contain"
91
+ style:aspect-ratio={match.player.aspectRatio ?? 16 / 9}
92
+ onerror={() => (failed = match)}
93
+ >
94
+ {#each match.player.sources as source (source.src)}
95
+ <source src={safeWebUrl(source.src)} type={source.type} />
96
+ {/each}
97
+ </video>{/key}
98
+ {:else}
99
+ <img
100
+ src={safeWebUrl(match.player.src)}
101
+ alt={embed.description?.replace(/^Alt: /i, '') || label}
102
+ class="max-h-[32rem] w-full object-contain"
103
+ onerror={() => (failed = match)}
104
+ />
105
+ {/if}
106
+ {:else if autoplay}
107
+ <div
108
+ class="relative max-h-[32rem] w-full bg-surface"
109
+ style:aspect-ratio={match.player.aspectRatio ?? 16 / 9}
110
+ >
111
+ {#if safeWebUrl(embed.image)}<img
112
+ src={safeWebUrl(embed.image)}
113
+ alt=""
114
+ loading="lazy"
115
+ class="absolute inset-0 size-full object-cover"
116
+ />{/if}
117
+ </div>
118
+ {:else}
119
+ <button
120
+ bind:this={previewButton}
121
+ type="button"
122
+ aria-label={`Play ${label} on ${match.provider.label}`}
123
+ onclick={play}
124
+ class={`relative flex max-h-[32rem] w-full items-center justify-center overflow-hidden bg-surface text-ink ${focus}`}
125
+ style:aspect-ratio={match.player.aspectRatio ?? 16 / 9}
126
+ >
127
+ {#if safeWebUrl(embed.image)}<img
128
+ src={safeWebUrl(embed.image)}
129
+ alt=""
130
+ loading="lazy"
131
+ class="absolute inset-0 size-full object-cover"
132
+ />{/if}
133
+ <span
134
+ class="relative inline-flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-medium"
135
+ >
136
+ <Icon img={Play} size={18} />Play · {match.provider.label}
137
+ </span>
138
+ </button>
139
+ {/if}
140
+ {#if autoplay}
141
+ <button
142
+ bind:this={animationButton}
143
+ type="button"
144
+ onclick={active ? stop : play}
145
+ aria-label={active
146
+ ? `Pause ${match.provider.label} animation`
147
+ : `Play ${label} on ${match.provider.label}`}
148
+ class={`absolute right-2 bottom-2 inline-flex size-8 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80 ${focus}`}
149
+ ><Icon img={active ? Pause : Play} size={16} /></button
150
+ >
151
+ {:else}
152
+ <div class="flex items-center justify-between gap-3 p-3 text-xs">
153
+ <a
154
+ href={safeWebUrl(embed.uri)}
155
+ target="_blank"
156
+ rel="noopener noreferrer"
157
+ class={`min-w-0 truncate rounded-ui text-muted hover:text-action ${focus}`}
158
+ >{embed.title || `Open on ${match.provider.label}`}</a
159
+ >
160
+ {#if active}<button
161
+ type="button"
162
+ bind:this={stopButton}
163
+ onclick={stop}
164
+ aria-label={`Stop ${match.provider.label} embed`}
165
+ class={`shrink-0 rounded-ui text-muted hover:text-action ${focus}`}>Stop</button
166
+ >{/if}
167
+ </div>{/if}
168
+ </div>
169
+ {:else}
170
+ <LinkCard
171
+ href={embed.uri}
172
+ meta={{ title: embed.title, description: embed.description, image: embed.image }}
173
+ />
174
+ {/if}
@@ -0,0 +1,8 @@
1
+ import { type ExternalEmbedProvider, type ExternalPostEmbed } from '../social/external-embeds.js';
2
+ type $$ComponentProps = {
3
+ embed: ExternalPostEmbed;
4
+ providers?: readonly ExternalEmbedProvider[];
5
+ };
6
+ declare const ExternalEmbed: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type ExternalEmbed = ReturnType<typeof ExternalEmbed>;
8
+ export default ExternalEmbed;
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { Camera } from '@sketchyicons/svelte';
4
+ import Button from './Button.svelte';
5
+ import Icon from './Icon.svelte';
6
+ import Input from './Input.svelte';
7
+ import Modal from './Modal.svelte';
8
+ import { parseGrainLink } from '../grain/urls.js';
9
+ import type { GrainAlbum, GrainClient, GrainGallery, GrainSelection } from '../grain/types.js';
10
+
11
+ export type GrainAlbumPickerProps = {
12
+ open?: boolean;
13
+ actor?: string | null;
14
+ listAlbums?: GrainClient['listAlbums'];
15
+ loadAlbum?: GrainClient['getGallery'];
16
+ selectedUris?: string[];
17
+ isSelected?: (album: GrainAlbum) => boolean;
18
+ onselect: GrainSelection;
19
+ class?: string;
20
+ };
21
+ let {
22
+ open = $bindable(false),
23
+ actor,
24
+ listAlbums,
25
+ loadAlbum,
26
+ selectedUris = [],
27
+ isSelected,
28
+ onselect,
29
+ class: className
30
+ }: GrainAlbumPickerProps = $props();
31
+ let input = $state('');
32
+ let error = $state('');
33
+ let listError = $state('');
34
+ let busy = $state(false);
35
+ let listing = $state(false);
36
+ let albums: GrainAlbum[] = $state([]);
37
+ let cursor = $state<string | undefined>();
38
+ let preview: GrainGallery | null = $state(null);
39
+ let previewInput = $state('');
40
+ let generation = 0;
41
+ function added(album: GrainAlbum) {
42
+ return selectedUris.includes(album.uri) || !!isSelected?.(album);
43
+ }
44
+
45
+ $effect(() => {
46
+ void actor;
47
+ void listAlbums;
48
+ void loadAlbum;
49
+ const shown = open;
50
+ generation++;
51
+ input = '';
52
+ error = '';
53
+ listError = '';
54
+ busy = false;
55
+ listing = false;
56
+ albums = [];
57
+ cursor = undefined;
58
+ preview = null;
59
+ if (shown)
60
+ untrack(() => {
61
+ void loadPage();
62
+ });
63
+ return () => {
64
+ generation++;
65
+ };
66
+ });
67
+ async function choose(album: GrainAlbum) {
68
+ if (busy || added(album)) return;
69
+ const id = generation;
70
+ busy = true;
71
+ error = '';
72
+ try {
73
+ const message = await onselect(album);
74
+ if (id !== generation) return;
75
+ if (message) error = message;
76
+ else open = false;
77
+ } catch {
78
+ if (id === generation) error = 'Couldn’t add that album. Please try again.';
79
+ } finally {
80
+ if (id === generation) busy = false;
81
+ }
82
+ }
83
+ async function loadPage(more = false) {
84
+ if (!actor || !listAlbums || listing) return;
85
+ const id = generation;
86
+ const previous = more ? cursor : undefined;
87
+ listing = true;
88
+ listError = '';
89
+ try {
90
+ const page = await listAlbums(actor, previous);
91
+ if (id !== generation) return;
92
+ albums = [
93
+ ...new Map(
94
+ (more ? [...albums, ...page.albums] : page.albums).map((album) => [album.uri, album])
95
+ ).values()
96
+ ];
97
+ cursor = page.cursor !== previous ? page.cursor : undefined;
98
+ } catch {
99
+ if (id === generation) listError = 'Couldn’t load your albums. You can still paste a link.';
100
+ } finally {
101
+ if (id === generation) listing = false;
102
+ }
103
+ }
104
+ async function previewAlbum() {
105
+ if (busy) return;
106
+ const ref = parseGrainLink(input);
107
+ if (!ref) {
108
+ error = 'Enter a Grain gallery URL.';
109
+ return;
110
+ }
111
+ if (!loadAlbum) {
112
+ error = 'Album previews aren’t available in this app.';
113
+ return;
114
+ }
115
+ const id = generation,
116
+ requested = input;
117
+ busy = true;
118
+ error = '';
119
+ preview = null;
120
+ try {
121
+ const result = await loadAlbum(ref.atUri);
122
+ if (id === generation && input === requested) {
123
+ preview = result;
124
+ previewInput = requested;
125
+ }
126
+ } catch {
127
+ if (id === generation) error = 'Couldn’t load that album. Check the link and try again.';
128
+ } finally {
129
+ if (id === generation) busy = false;
130
+ }
131
+ }
132
+ </script>
133
+
134
+ <Modal bind:open title="Add photo album" class={className}>
135
+ <div class="flex flex-col gap-2 sm:flex-row">
136
+ <Input
137
+ aria-label="Grain album link"
138
+ placeholder="Paste a Grain album link…"
139
+ bind:value={input}
140
+ class="min-w-0 flex-1"
141
+ oninput={() => {
142
+ preview = null;
143
+ error = '';
144
+ }}
145
+ onkeydown={(event) => {
146
+ if (event.key === 'Enter') {
147
+ event.preventDefault();
148
+ void previewAlbum();
149
+ }
150
+ }}
151
+ />
152
+ <Button disabled={busy || !input.trim()} onclick={previewAlbum}
153
+ >{busy ? 'Loading…' : 'Preview'}</Button
154
+ >
155
+ </div>
156
+ {#if preview && input === previewInput}
157
+ <div class="mt-4 flex items-center gap-3">
158
+ {#if preview.cover && !preview.contentWarning}<img
159
+ src={preview.cover}
160
+ alt=""
161
+ class="size-16 rounded-lg object-cover"
162
+ />{/if}
163
+ <div class="min-w-0 flex-1">
164
+ <p class="truncate font-medium">{preview.title}</p>
165
+ <p class="text-sm break-words text-muted">
166
+ @{preview.creator.handle} · {preview.photoCount} photos
167
+ </p>
168
+ {#if preview.contentWarning}<p class="text-xs text-muted">Content warning</p>{/if}
169
+ </div>
170
+ <Button disabled={busy || added(preview)} onclick={() => preview && choose(preview)}
171
+ >{added(preview) ? 'Added' : 'Add album'}</Button
172
+ >
173
+ </div>
174
+ {/if}
175
+ {#if actor && listAlbums}
176
+ <p class="mt-6 mb-2 text-sm font-medium">Your albums</p>
177
+ <div class="space-y-1">
178
+ {#each albums as album (album.uri)}
179
+ <button
180
+ type="button"
181
+ class="flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-recess disabled:opacity-50"
182
+ disabled={busy || added(album)}
183
+ onclick={() => choose(album)}
184
+ aria-label={`${added(album) ? 'Added' : 'Add'} ${album.title}`}
185
+ >
186
+ {#if album.cover && !album.contentWarning}<img
187
+ src={album.cover}
188
+ alt=""
189
+ loading="lazy"
190
+ class="size-14 shrink-0 rounded-md object-cover"
191
+ />{:else}<span
192
+ class="flex size-14 shrink-0 items-center justify-center rounded-md bg-recess"
193
+ ><Icon img={Camera} size={20} /></span
194
+ >{/if}
195
+ <span class="min-w-0 flex-1"
196
+ ><span class="block truncate font-medium">{album.title}</span><span
197
+ class="text-sm text-muted"
198
+ >{album.photoCount} photos{album.contentWarning ? ' · Content warning' : ''}</span
199
+ ></span
200
+ >
201
+ {#if added(album)}<span class="text-xs text-muted">Added</span>{/if}
202
+ </button>
203
+ {/each}
204
+ </div>
205
+ {#if listing}<p class="mt-3 text-sm text-muted" role="status">
206
+ Loading albums…
207
+ </p>{:else if listError}<p class="mt-3 text-sm text-muted" role="status">{listError}</p>
208
+ <Button variant="ghost" size="sm" onclick={() => loadPage(!!cursor)}>Try again</Button
209
+ >{:else if !albums.length}<p class="text-sm text-muted">No albums yet.</p>{/if}
210
+ {#if cursor && !listError}<Button
211
+ variant="ghost"
212
+ class="mt-3"
213
+ disabled={listing}
214
+ onclick={() => loadPage(true)}>Load more</Button
215
+ >{/if}
216
+ {/if}
217
+ {#if error}<p class="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>{/if}
218
+ </Modal>
@@ -0,0 +1,14 @@
1
+ import type { GrainAlbum, GrainClient, GrainSelection } from '../grain/types.js';
2
+ export type GrainAlbumPickerProps = {
3
+ open?: boolean;
4
+ actor?: string | null;
5
+ listAlbums?: GrainClient['listAlbums'];
6
+ loadAlbum?: GrainClient['getGallery'];
7
+ selectedUris?: string[];
8
+ isSelected?: (album: GrainAlbum) => boolean;
9
+ onselect: GrainSelection;
10
+ class?: string;
11
+ };
12
+ declare const GrainAlbumPicker: import("svelte").Component<GrainAlbumPickerProps, {}, "open">;
13
+ type GrainAlbumPicker = ReturnType<typeof GrainAlbumPicker>;
14
+ export default GrainAlbumPicker;
@@ -0,0 +1,94 @@
1
+ <script lang="ts">
2
+ import Button from './Button.svelte';
3
+ import PhotoGrid from './PhotoGrid.svelte';
4
+ import { parseGrainLink } from '../grain/urls.js';
5
+ import type { GrainClient, GrainGallery } from '../grain/types.js';
6
+ import type { Photo } from '../media.js';
7
+ import { cn } from '../utils.js';
8
+
9
+ export type GrainAlbumViewProps = {
10
+ reference: string;
11
+ title?: string;
12
+ loadAlbum?: GrainClient['getGallery'];
13
+ class?: string;
14
+ };
15
+ let { reference, title, loadAlbum, class: className }: GrainAlbumViewProps = $props();
16
+ let ref = $derived(parseGrainLink(reference));
17
+ let gallery: GrainGallery | null = $state(null);
18
+ let loading = $state(false);
19
+ let failed = $state(false);
20
+ let retry = $state(0);
21
+ let revealed = $state(false);
22
+ let photos: Photo[] = $derived.by(
23
+ () =>
24
+ gallery?.photos.map((photo) => ({
25
+ id: photo.uri,
26
+ src: photo.fullsize,
27
+ thumbnail: photo.thumb,
28
+ alt: photo.alt,
29
+ width: photo.width,
30
+ height: photo.height,
31
+ contentWarning: photo.contentWarning
32
+ })) ?? []
33
+ );
34
+ $effect(() => {
35
+ const input = parseGrainLink(reference)?.atUri ?? reference;
36
+ const transport = loadAlbum;
37
+ void retry;
38
+ let current = true;
39
+ gallery = null;
40
+ failed = false;
41
+ revealed = false;
42
+ loading = !!transport;
43
+ if (transport) {
44
+ void Promise.resolve()
45
+ .then(() => transport(input))
46
+ .then((result) => {
47
+ if (current) gallery = result;
48
+ })
49
+ .catch(() => {
50
+ if (current) failed = true;
51
+ })
52
+ .finally(() => {
53
+ if (current) loading = false;
54
+ });
55
+ }
56
+ return () => {
57
+ current = false;
58
+ };
59
+ });
60
+ </script>
61
+
62
+ <section aria-label={gallery?.title || title || 'Photo album'} class={cn(className)}>
63
+ <div class="mb-3 flex flex-wrap items-start justify-between gap-2">
64
+ <div class="min-w-0">
65
+ <h2 class="font-medium break-words">{gallery?.title || title || 'Photo album'}</h2>
66
+ {#if gallery}<a
67
+ href={`https://grain.social/profile/${encodeURIComponent(gallery.creator.did)}`}
68
+ target="_blank"
69
+ rel="noopener noreferrer"
70
+ class="text-sm break-all text-muted hover:text-action"
71
+ >{gallery.creator.displayName || gallery.creator.handle} · @{gallery.creator.handle}</a
72
+ >{/if}
73
+ </div>
74
+ {#if gallery?.url || ref?.url}<a
75
+ href={gallery?.url || ref?.url}
76
+ target="_blank"
77
+ rel="noopener noreferrer"
78
+ class="text-sm text-action hover:underline">Open on Grain</a
79
+ >{/if}
80
+ </div>
81
+ {#if loading}<p class="py-6 text-sm text-muted" role="status">Loading photos…</p>
82
+ {:else if failed || !loadAlbum}<p class="mb-2 text-sm text-muted" role="status">
83
+ Photos are unavailable. You can still open the album on Grain.
84
+ </p>
85
+ {#if loadAlbum}<Button variant="ghost" size="sm" onclick={() => retry++}>Try again</Button>{/if}
86
+ {:else if gallery}
87
+ {#if gallery.contentWarning && !revealed}<div class="rounded-lg border border-line p-4">
88
+ <p class="mb-3 text-sm">This album has a content warning.</p>
89
+ <Button variant="secondary" size="sm" onclick={() => (revealed = true)}>Show photos</Button>
90
+ </div>
91
+ {:else if !photos.length}<p class="py-4 text-sm text-muted">No photos in this album yet.</p>
92
+ {:else}<PhotoGrid {photos} label={gallery.title} />{/if}
93
+ {/if}
94
+ </section>
@@ -0,0 +1,10 @@
1
+ import type { GrainClient } from '../grain/types.js';
2
+ export type GrainAlbumViewProps = {
3
+ reference: string;
4
+ title?: string;
5
+ loadAlbum?: GrainClient['getGallery'];
6
+ class?: string;
7
+ };
8
+ declare const GrainAlbumView: import("svelte").Component<GrainAlbumViewProps, {}, "">;
9
+ type GrainAlbumView = ReturnType<typeof GrainAlbumView>;
10
+ export default GrainAlbumView;
@@ -1,17 +1,23 @@
1
1
  <script lang="ts">
2
2
  import type { PostData } from '../social/posts.js';
3
+ import type { ExternalEmbedProvider } from '../social/external-embeds.js';
3
4
  import { safeWebUrl } from '../social/richtext.js';
4
5
  import { socialPostUrl, socialProfileUrl } from '../social/urls.js';
5
6
  import type { PreferredSocialClient } from '../social/preferred-client.js';
7
+ import { Heart, MessageCircle, Repeat2 } from '@sketchyicons/svelte';
8
+ import { focus } from '../styles.js';
9
+ import Icon from './Icon.svelte';
6
10
  import Avatar from './Avatar.svelte';
7
11
  import PostContent from './PostContent.svelte';
8
12
  import NestedComments from './NestedComments.svelte';
9
13
  let {
10
14
  comments,
11
- socialClient
15
+ socialClient,
16
+ embedProviders
12
17
  }: {
13
18
  comments: PostData[];
14
19
  socialClient?: PreferredSocialClient | null;
20
+ embedProviders?: readonly ExternalEmbedProvider[];
15
21
  } = $props();
16
22
  </script>
17
23
 
@@ -44,19 +50,23 @@
44
50
  })}</time
45
51
  >{/if}
46
52
  </div>
47
- <PostContent post={comment} />
48
- <div class="mt-2 flex gap-5 text-[.6875rem] text-muted">
49
- <a {href} target="_blank" rel="noopener noreferrer" class="hover:text-action"
50
- >Reply{#if comment.replyCount}
51
- · {comment.replyCount}{/if}</a
52
- ><a {href} target="_blank" rel="noopener noreferrer" class="hover:text-action"
53
- >{comment.likeCount ?? 0} likes</a
54
- >
53
+ <PostContent post={comment} {socialClient} {embedProviders} />
54
+ <div class="mt-1 flex items-center gap-5 text-xs text-muted">
55
+ {#each [{ icon: MessageCircle, count: comment.replyCount ?? 0, singular: 'reply', plural: 'replies' }, { icon: Repeat2, count: comment.repostCount ?? 0, singular: 'repost', plural: 'reposts' }, { icon: Heart, count: comment.likeCount ?? 0, singular: 'like', plural: 'likes' }] as action (action.singular)}
56
+ <a
57
+ {href}
58
+ target="_blank"
59
+ rel="noopener noreferrer"
60
+ aria-label={`${action.count} ${action.count === 1 ? action.singular : action.plural}`}
61
+ class={`inline-flex min-h-8 items-center gap-1.5 rounded-ui tabular-nums transition-colors hover:text-action ${focus}`}
62
+ ><Icon img={action.icon} size={16} /><span>{action.count}</span></a
63
+ >
64
+ {/each}
55
65
  </div>
56
66
  </div>
57
67
  </div>
58
68
  {#if comment.replies?.length}<div class="mt-4 border-l border-line pl-3 sm:ml-4 sm:pl-6">
59
- <NestedComments comments={comment.replies} {socialClient} />
69
+ <NestedComments comments={comment.replies} {socialClient} {embedProviders} />
60
70
  </div>{/if}
61
71
  </article>
62
72
  {/each}