@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
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
|
|
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-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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}
|