astro-atmosphere-comments 0.2.0 → 0.3.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/CHANGELOG.md +6 -0
- package/README.md +19 -1
- package/components/AtmosphereComments.astro +119 -91
- package/index.d.ts +11 -0
- package/index.mjs +2 -0
- package/lib/i18n.ts +32 -0
- package/lib/settings.ts +2 -0
- package/lib/translate.ts +36 -0
- package/locales/de.json +75 -0
- package/locales/en.json +75 -0
- package/locales/es.json +78 -0
- package/locales/fr.json +78 -0
- package/locales/it.json +78 -0
- package/locales/ja.json +72 -0
- package/locales/nl.json +75 -0
- package/locales/pt.json +78 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (4 November 2026)
|
|
4
|
+
|
|
5
|
+
- **Languages:** the wording now comes from language files (`locales/*.json`, i18next JSON) in English, French, German, Spanish, Portuguese, Italian, Dutch and Japanese, picked from the component's `locale` prop, the integration's `locale` option or Astro's locale for the page, then English. Dates and plurals ("1 like", "2 likes") follow the language. The translations are drafts for native speakers to check; translation is being set up on Weblate.
|
|
6
|
+
- **Your own wording:** a `messages` option (and prop) changes any line, by its key in `locales/en.json`.
|
|
7
|
+
- The `label` and `authorLabel` props still work, and win over `messages`.
|
|
8
|
+
|
|
3
9
|
## 0.2.0 (4 November 2026)
|
|
4
10
|
|
|
5
11
|
- The sign-in form is shown straight away under each post: a handle box and a **Sign in with Atmosphere** button, both with the Atmosphere logo. The "Respond with your Atmosphere Account" button that opened it is gone.
|
package/README.md
CHANGED
|
@@ -106,6 +106,8 @@ Locally the integration uses the protocol's development mode, so no key is neede
|
|
|
106
106
|
| `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. |
|
|
107
107
|
| `hidden` | | Comments never to show: bsky.app post or profile addresses, AT URIs, handles or DIDs. Replies you hide in the Bluesky app, and posts with Bluesky's moderation labels (spam and so on), are left out anyway. |
|
|
108
108
|
| `base` | | Where the integration's routes live. Default `/atmosphere`. |
|
|
109
|
+
| `locale` | | The language of the wording, such as `"fr"` or `"pt-BR"` (see Languages). |
|
|
110
|
+
| `messages` | | Your own wording for any line, by its key in [`locales/en.json`](locales/en.json). |
|
|
109
111
|
|
|
110
112
|
## The component
|
|
111
113
|
|
|
@@ -115,12 +117,29 @@ Locally the integration uses the protocol's development mode, so no key is neede
|
|
|
115
117
|
| `label` | The sign-in form's heading. Default "Sign in to leave a comment, like or repost". |
|
|
116
118
|
| `authorLabel` | The badge on your own comments. Default "Author". |
|
|
117
119
|
| `blueskyLink` | "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form, linking to the announcement (with `u-syndication`, so microformats readers learn it's the post's copy on Bluesky). Default `true`; `blueskyLink={false}` hides it. |
|
|
120
|
+
| `locale`, `messages` | The same as the integration's options, for this one component. |
|
|
118
121
|
| `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
|
|
119
122
|
|
|
120
123
|
**Slots:** `after-box` goes between the comment box and the comments (a note about other ways to respond, say); the default slot goes after the comments (other responses, older comments).
|
|
121
124
|
|
|
122
125
|
**Layout, top to bottom:** likes and reposts, the sign-in form and the "Reply on Bluesky instead" line (or, signed in, Like, Repost, Quote and the comment box), your `after-box` content, the comments, your default slot content.
|
|
123
126
|
|
|
127
|
+
## Languages
|
|
128
|
+
|
|
129
|
+
The wording comes in English, French, German, Spanish, Portuguese, Italian, Dutch and Japanese. The component uses, in order: its `locale` prop, the integration's `locale` option, Astro's own locale for the page (if you use [Astro's i18n routing](https://docs.astro.build/en/guides/internationalization/)), then English. A regional code falls back to its language (`pt-BR` uses `pt`), and any line a language lacks is shown in English. Dates follow the same language.
|
|
130
|
+
|
|
131
|
+
To change any line, give its key from [`locales/en.json`](locales/en.json):
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
atmosphereComments({
|
|
135
|
+
// …
|
|
136
|
+
locale: "fr",
|
|
137
|
+
messages: { "signIn.heading": "Connecte-toi pour rejoindre la conversation" },
|
|
138
|
+
}),
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The translations other than English started as drafts and need checking by native speakers. Translation is being set up on [Weblate](https://weblate.org), the open-source translation platform; until then, corrections and new languages are very welcome as [GitHub issues or pull requests](https://github.com/saltedlolly/astro-atmosphere-comments/issues). (Bluesky's own approval page is in the reader's language already.)
|
|
142
|
+
|
|
124
143
|
## Styling
|
|
125
144
|
|
|
126
145
|
It has a plain default look and takes your text colour. Colours come from CSS custom properties, set on `.atmo`:
|
|
@@ -162,7 +181,6 @@ The component fires (bubbling):
|
|
|
162
181
|
|
|
163
182
|
- Needs a Node server with a lasting disk (no serverless or edge hosts yet).
|
|
164
183
|
- Rate limits are kept in memory, so they apply per server process.
|
|
165
|
-
- The wording is English only.
|
|
166
184
|
- Quoting individual comments isn't supported.
|
|
167
185
|
|
|
168
186
|
If you only want readers to sign in with their Atmosphere account, without comments, see [AuthProto](https://www.npmjs.com/package/@fujocoded/authproto).
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
// can show it straight away.
|
|
34
34
|
import logo from "../assets/union.svg?url";
|
|
35
35
|
import settings from "../lib/settings";
|
|
36
|
+
import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
|
|
37
|
+
import { escapeHtml } from "../lib/translate";
|
|
36
38
|
|
|
37
39
|
interface Props {
|
|
38
40
|
/**
|
|
@@ -49,15 +51,26 @@ interface Props {
|
|
|
49
51
|
credit?: boolean;
|
|
50
52
|
/** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
|
|
51
53
|
blueskyLink?: boolean;
|
|
54
|
+
/** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
|
|
55
|
+
locale?: string;
|
|
56
|
+
/** Your own wording for any line, by its key in locales/en.json. */
|
|
57
|
+
messages?: Record<string, string>;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
const {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const { post = "", label, authorLabel, credit = true, blueskyLink = true } = Astro.props;
|
|
61
|
+
// The wording: the language asked for (or Astro's i18n locale for the page),
|
|
62
|
+
// with the site's own lines on top. The script gets the same, as JSON.
|
|
63
|
+
const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
|
|
64
|
+
const messages = messagesFor(lang, {
|
|
65
|
+
...settings.messages,
|
|
66
|
+
...Astro.props.messages,
|
|
67
|
+
...(label && { "signIn.heading": label }),
|
|
68
|
+
...(authorLabel && { "comment.author": authorLabel }),
|
|
69
|
+
});
|
|
70
|
+
const t = (key: string, vars?: Record<string, string | number>) => translate(messages, lang, key, vars);
|
|
71
|
+
const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
|
|
72
|
+
const link = (href: string | undefined, text: string, attributes = "") =>
|
|
73
|
+
`<a${href ? ` href="${escapeHtml(href)}"` : ""}${attributes}>${escapeHtml(text)}</a>`;
|
|
61
74
|
// The announcement on bsky.app, when the page is given it (otherwise the
|
|
62
75
|
// script fills it in once the thread has loaded).
|
|
63
76
|
const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
|
|
@@ -71,13 +84,19 @@ const { base } = settings;
|
|
|
71
84
|
const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
72
85
|
---
|
|
73
86
|
|
|
74
|
-
<atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-
|
|
87
|
+
<atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
|
|
75
88
|
<div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
|
|
76
89
|
|
|
77
90
|
<div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
|
|
78
91
|
<form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
|
|
79
|
-
<label for="atmo-handle">{
|
|
80
|
-
<p
|
|
92
|
+
<label for="atmo-handle">{t("signIn.heading")}</label>
|
|
93
|
+
<p
|
|
94
|
+
class="atmo__hint"
|
|
95
|
+
set:html={th("signIn.anyAccount", {
|
|
96
|
+
atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
|
|
97
|
+
bluesky: link("https://bsky.app", "Bluesky"),
|
|
98
|
+
})}
|
|
99
|
+
/>
|
|
81
100
|
<div class="atmo__row">
|
|
82
101
|
<span class="atmo__handle">
|
|
83
102
|
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
@@ -95,54 +114,56 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
95
114
|
placeholder="alice.bsky.social"
|
|
96
115
|
required
|
|
97
116
|
/>
|
|
98
|
-
<ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label="
|
|
117
|
+
<ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label={t("signIn.matchingAccounts")} data-handle-suggestions hidden></ul>
|
|
99
118
|
</span>
|
|
100
119
|
<input type="hidden" name="returnTo" value={returnTo} />
|
|
101
120
|
<button type="submit" class="atmo__button atmo__button--sign-in">
|
|
102
121
|
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
103
|
-
|
|
122
|
+
{t("signIn.button")}
|
|
104
123
|
</button>
|
|
105
124
|
</div>
|
|
106
|
-
<p
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
</p>
|
|
125
|
+
<p
|
|
126
|
+
class="atmo__hint"
|
|
127
|
+
set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
|
|
128
|
+
/>
|
|
111
129
|
</form>
|
|
112
130
|
{blueskyLink && (
|
|
113
|
-
<p
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
<p
|
|
132
|
+
class="atmo__elsewhere"
|
|
133
|
+
data-elsewhere
|
|
134
|
+
hidden={!blueskyUrl}
|
|
135
|
+
set:html={th("elsewhere.text", {
|
|
136
|
+
// u-syndication: microformats readers learn this is the post's copy on Bluesky.
|
|
137
|
+
link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
|
|
138
|
+
})}
|
|
139
|
+
/>
|
|
119
140
|
)}
|
|
120
141
|
</div>
|
|
121
142
|
|
|
122
143
|
<div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
|
|
123
|
-
<div class="atmo__actions" role="group" aria-label="
|
|
144
|
+
<div class="atmo__actions" role="group" aria-label={t("actions.label")}>
|
|
124
145
|
<button type="button" class="atmo__action" data-react="like" aria-pressed="false">
|
|
125
146
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" /></svg>
|
|
126
|
-
<span data-label-off>
|
|
147
|
+
<span data-label-off>{t("actions.like")}</span><span data-label-on>{t("actions.liked")}</span>
|
|
127
148
|
</button>
|
|
128
149
|
<button type="button" class="atmo__action" data-react="repost" aria-pressed="false">
|
|
129
150
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8h10l-3-3M17 16H7l3 3M5 13V9.5A1.5 1.5 0 0 1 6.5 8M19 11v3.5a1.5 1.5 0 0 1-1.5 1.5" /></svg>
|
|
130
|
-
<span data-label-off>
|
|
151
|
+
<span data-label-off>{t("actions.repost")}</span><span data-label-on>{t("actions.reposted")}</span>
|
|
131
152
|
</button>
|
|
132
153
|
<button type="button" class="atmo__action" data-quote>
|
|
133
154
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
|
|
134
|
-
<span>
|
|
155
|
+
<span>{t("actions.quote")}</span>
|
|
135
156
|
</button>
|
|
136
157
|
</div>
|
|
137
158
|
|
|
138
159
|
<form class="atmo__composer" data-composer>
|
|
139
160
|
<p class="atmo__context" data-context hidden>
|
|
140
161
|
<span data-context-text></span>
|
|
141
|
-
<button type="button" class="atmo__link-button" data-cancel-context>
|
|
162
|
+
<button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
|
|
142
163
|
</p>
|
|
143
164
|
<label for="atmo-text" class="atmo__as">
|
|
144
165
|
<span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
|
|
145
|
-
<span
|
|
166
|
+
<span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
|
|
146
167
|
</label>
|
|
147
168
|
<div class="atmo__field">
|
|
148
169
|
<textarea
|
|
@@ -153,14 +174,14 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
153
174
|
aria-autocomplete="list"
|
|
154
175
|
aria-controls="atmo-suggestions"
|
|
155
176
|
aria-expanded="false"></textarea>
|
|
156
|
-
<ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label="
|
|
177
|
+
<ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label={t("composer.mentions")} data-suggestions hidden></ul>
|
|
157
178
|
</div>
|
|
158
179
|
<figure class="atmo__preview" data-preview hidden>
|
|
159
|
-
<figcaption>
|
|
180
|
+
<figcaption>{t("composer.previewCaption")}</figcaption>
|
|
160
181
|
<div class="atmo__preview-post">
|
|
161
182
|
<span class="atmo__avatar" data-preview-avatar aria-hidden="true"></span>
|
|
162
183
|
<div class="atmo__preview-body">
|
|
163
|
-
<p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· now</span></p>
|
|
184
|
+
<p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
|
|
164
185
|
<p class="atmo__preview-text" data-preview-text></p>
|
|
165
186
|
<div class="atmo__quoted">
|
|
166
187
|
<p class="atmo__preview-author">
|
|
@@ -182,8 +203,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
182
203
|
</figure>
|
|
183
204
|
<div class="atmo__row atmo__row--end">
|
|
184
205
|
<span class="atmo__count" data-count aria-live="polite"></span>
|
|
185
|
-
<button type="button" class="atmo__link-button" data-sign-out>
|
|
186
|
-
<button type="submit" class="atmo__button" data-submit>
|
|
206
|
+
<button type="button" class="atmo__link-button" data-sign-out>{t("composer.signOut")}</button>
|
|
207
|
+
<button type="submit" class="atmo__button" data-submit>{t("composer.postComment")}</button>
|
|
187
208
|
</div>
|
|
188
209
|
</form>
|
|
189
210
|
</div>
|
|
@@ -199,29 +220,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
199
220
|
<slot />
|
|
200
221
|
|
|
201
222
|
{credit && (
|
|
202
|
-
<p class="atmo__credit" data-credit data-pagefind-ignore
|
|
203
|
-
Powered by <a href={CREDIT_URL}>Atmosphere Comments for Astro</a>
|
|
204
|
-
</p>
|
|
223
|
+
<p class="atmo__credit" data-credit data-pagefind-ignore set:html={th("credit", { link: link(CREDIT_URL, "Atmosphere Comments for Astro") })} />
|
|
205
224
|
)}
|
|
206
225
|
</atmosphere-comments>
|
|
207
226
|
|
|
208
227
|
<script>
|
|
228
|
+
import { translate } from "../lib/translate";
|
|
229
|
+
|
|
209
230
|
const MAX = 300;
|
|
210
231
|
const graphemes = new Intl.Segmenter();
|
|
211
232
|
const count = (text: string) => [...graphemes.segment(text.trim())].length;
|
|
212
233
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
|
|
234
|
+
// The error and status codes the routes and the sign-in round trip send,
|
|
235
|
+
// and the wording for each (in locales/*.json).
|
|
236
|
+
const MESSAGE_KEYS: Record<string, string> = {
|
|
237
|
+
"signed-in": "messages.signedIn",
|
|
238
|
+
"sign-in-failed": "messages.signInFailed",
|
|
239
|
+
"account-not-found": "messages.accountNotFound",
|
|
240
|
+
"invalid-handle": "messages.invalidHandle",
|
|
241
|
+
"signed-out": "messages.signedOut",
|
|
242
|
+
"needs-permission": "messages.needsPermission",
|
|
243
|
+
"not-allowed": "messages.notAllowed",
|
|
244
|
+
"new-permissions": "messages.newPermissions",
|
|
245
|
+
"too-long": "messages.tooLong",
|
|
246
|
+
"too-many": "messages.tooMany",
|
|
247
|
+
error: "messages.error",
|
|
225
248
|
};
|
|
226
249
|
|
|
227
250
|
type Posted = { uri: string; url: string; quote: boolean };
|
|
@@ -230,6 +253,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
230
253
|
class AtmosphereComments extends HTMLElement {
|
|
231
254
|
connectedCallback() {
|
|
232
255
|
const base = this.dataset.base ?? "/atmosphere";
|
|
256
|
+
// The wording, in the page's language, from the server.
|
|
257
|
+
const lang = this.dataset.locale || "en";
|
|
258
|
+
const wording = JSON.parse(this.dataset.messages || "{}") as Record<string, string>;
|
|
259
|
+
const t = (key: string, vars?: Record<string, string | number>) => translate(wording, lang, key, vars);
|
|
260
|
+
const message = (code: string) => t(MESSAGE_KEYS[code] ?? MESSAGE_KEYS.error, { max: MAX });
|
|
233
261
|
// The announcement's AT URI. A bsky.app address or the page's
|
|
234
262
|
// Standard.site record is turned into one when the thread loads.
|
|
235
263
|
let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
|
|
@@ -281,9 +309,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
281
309
|
let thread: Thread | null = null;
|
|
282
310
|
const reactionsBox = $<HTMLElement>("[data-reactions]");
|
|
283
311
|
const repliesList = $<HTMLOListElement>("[data-replies]");
|
|
284
|
-
const authorLabel =
|
|
312
|
+
const authorLabel = t("comment.author");
|
|
285
313
|
const nameOf = (person: Pick<Person, "handle" | "displayName">) => person.displayName || `@${person.handle}`;
|
|
286
|
-
const dates = new Intl.DateTimeFormat(
|
|
314
|
+
const dates = new Intl.DateTimeFormat(lang, {
|
|
287
315
|
day: "numeric",
|
|
288
316
|
month: "long",
|
|
289
317
|
year: "numeric",
|
|
@@ -312,7 +340,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
312
340
|
...groups.map(({ kind, count, people }) => {
|
|
313
341
|
const group = make("div", "atmo-reactions__group");
|
|
314
342
|
group.dataset.reaction = kind;
|
|
315
|
-
group.append(make("p", "atmo-reactions__count",
|
|
343
|
+
group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
|
|
316
344
|
const faces = make("ul", "atmo-facepile");
|
|
317
345
|
for (const person of people) {
|
|
318
346
|
const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener" });
|
|
@@ -345,7 +373,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
345
373
|
if (reply.byAuthor) who.append(make("span", "atmo-comment__badge", authorLabel));
|
|
346
374
|
const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener" });
|
|
347
375
|
const time = Object.assign(make("time"), { dateTime: reply.createdAt, textContent: reply.createdAt ? dates.format(new Date(reply.createdAt)) : "" });
|
|
348
|
-
date.append(time, make("span", "",
|
|
376
|
+
date.append(time, make("span", "", ` · ${t("comment.viewOnBluesky")}`));
|
|
349
377
|
const names = make("div");
|
|
350
378
|
names.append(who, date);
|
|
351
379
|
const header = make("header", "atmo-comment__header");
|
|
@@ -547,13 +575,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
547
575
|
|
|
548
576
|
const handleError = (error: string, tell: (message: string) => void = say) => {
|
|
549
577
|
if (error === "signed-out" || error === "new-permissions") void show(false);
|
|
550
|
-
tell(
|
|
578
|
+
tell(message(error));
|
|
551
579
|
};
|
|
552
580
|
|
|
553
581
|
const postedMessage = (where: HTMLElement, data: Posted) =>
|
|
554
582
|
where.replaceChildren(
|
|
555
|
-
data.quote ? "
|
|
556
|
-
Object.assign(document.createElement("a"), { href: data.url, textContent: "
|
|
583
|
+
`${t(data.quote ? "posted.quote" : "posted.comment")} `,
|
|
584
|
+
Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
|
|
557
585
|
);
|
|
558
586
|
|
|
559
587
|
// Everything a comment box does: counting characters, @mention
|
|
@@ -574,7 +602,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
574
602
|
|
|
575
603
|
const updateCount = () => {
|
|
576
604
|
const used = count(textarea.value);
|
|
577
|
-
counter.textContent = used > MAX - 50 ?
|
|
605
|
+
counter.textContent = used > MAX - 50 ? t("composer.charactersLeft", { count: MAX - used }) : "";
|
|
578
606
|
counter.classList.toggle("is-over", used > MAX);
|
|
579
607
|
};
|
|
580
608
|
|
|
@@ -681,9 +709,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
681
709
|
event.preventDefault();
|
|
682
710
|
const text = textarea.value.trim();
|
|
683
711
|
if (!text) return;
|
|
684
|
-
if (count(text) > MAX) return behaviour.tell(
|
|
712
|
+
if (count(text) > MAX) return behaviour.tell(message("too-long"));
|
|
685
713
|
submit.disabled = true;
|
|
686
|
-
behaviour.tell("
|
|
714
|
+
behaviour.tell(t("composer.posting"));
|
|
687
715
|
try {
|
|
688
716
|
const response = await send("comment", { text, root: post, ...behaviour.extra() });
|
|
689
717
|
const data = await response.json();
|
|
@@ -694,7 +722,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
694
722
|
showPosted(data);
|
|
695
723
|
this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
|
|
696
724
|
} catch {
|
|
697
|
-
behaviour.tell(
|
|
725
|
+
behaviour.tell(message("error"));
|
|
698
726
|
} finally {
|
|
699
727
|
submit.disabled = false;
|
|
700
728
|
}
|
|
@@ -710,7 +738,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
710
738
|
let quotedLoaded = false;
|
|
711
739
|
const updatePreviewText = () => {
|
|
712
740
|
const words = main.textarea.value.trim();
|
|
713
|
-
previewText.textContent = words || "
|
|
741
|
+
previewText.textContent = words || t("composer.previewEmpty");
|
|
714
742
|
previewText.classList.toggle("is-placeholder", !words);
|
|
715
743
|
};
|
|
716
744
|
const loadQuoted = async () => {
|
|
@@ -752,9 +780,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
752
780
|
mainMode = next;
|
|
753
781
|
const quoting = next === "quote";
|
|
754
782
|
$<HTMLElement>("[data-context]").hidden = !quoting;
|
|
755
|
-
$<HTMLElement>("[data-context-text]").textContent = quoting ? "
|
|
756
|
-
$<HTMLElement>("[data-verb]").textContent = quoting ? "
|
|
757
|
-
$<HTMLElement>("[data-submit]").textContent = quoting ? "
|
|
783
|
+
$<HTMLElement>("[data-context-text]").textContent = quoting ? t("composer.quoting") : "";
|
|
784
|
+
$<HTMLElement>("[data-verb]").textContent = t(quoting ? "composer.quote" : "composer.comment");
|
|
785
|
+
$<HTMLElement>("[data-submit]").textContent = t(quoting ? "composer.postQuote" : "composer.postComment");
|
|
758
786
|
preview.hidden = !quoting;
|
|
759
787
|
if (quoting) void loadQuoted();
|
|
760
788
|
};
|
|
@@ -795,8 +823,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
795
823
|
suggestions.id = `${id}-suggestions`;
|
|
796
824
|
textarea.setAttribute("aria-controls", suggestions.id);
|
|
797
825
|
form.querySelector<HTMLElement>("[data-context]")!.hidden = false;
|
|
798
|
-
form.querySelector("[data-context-text]")!.textContent =
|
|
799
|
-
form.querySelector("[data-verb]")!.textContent = "
|
|
826
|
+
form.querySelector("[data-context-text]")!.textContent = t("composer.replyingTo", { name: author });
|
|
827
|
+
form.querySelector("[data-verb]")!.textContent = t("comment.reply");
|
|
800
828
|
form.querySelector("[data-submit]")!.textContent = "Post reply";
|
|
801
829
|
form.querySelector("[data-handle]")!.textContent = me.handle ? `@${me.handle}` : "";
|
|
802
830
|
setAvatar(form.querySelector<HTMLElement>("[data-me-avatar]")!, me.displayName || me.handle || "?", me.avatar);
|
|
@@ -835,8 +863,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
835
863
|
button.dataset.kind = kind;
|
|
836
864
|
const seen = known[comment.dataset.atmosphereUri!];
|
|
837
865
|
button.setAttribute("aria-pressed", String(!!(kind === "like" ? seen?.liked : seen?.reposted)));
|
|
838
|
-
const author = comment.dataset.atmosphereAuthor || "
|
|
839
|
-
button.setAttribute("aria-label",
|
|
866
|
+
const author = comment.dataset.atmosphereAuthor || t("comment.someone");
|
|
867
|
+
button.setAttribute("aria-label", t(kind === "like" ? "comment.likeLabel" : "comment.repostLabel", { name: author }));
|
|
840
868
|
const count = Number(comment.dataset[kind === "like" ? "atmosphereLikes" : "atmosphereReposts"] || 0);
|
|
841
869
|
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${kind === "like" ? HEART : ARROWS}</svg><span></span>`;
|
|
842
870
|
const setCount = (value: number) => {
|
|
@@ -845,7 +873,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
845
873
|
};
|
|
846
874
|
setCount(count);
|
|
847
875
|
button.addEventListener("click", async () => {
|
|
848
|
-
if (!isSignedIn) return signInFirst(
|
|
876
|
+
if (!isSignedIn) return signInFirst(t(kind === "like" ? "signInFirst.like" : "signInFirst.repost"));
|
|
849
877
|
const on = button.getAttribute("aria-pressed") !== "true";
|
|
850
878
|
const before = Number(button.dataset.count || 0);
|
|
851
879
|
button.setAttribute("aria-pressed", String(on));
|
|
@@ -869,15 +897,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
869
897
|
if (comment.querySelector(":scope .atmo-comment-actions")) return;
|
|
870
898
|
added += 1;
|
|
871
899
|
const actions = Object.assign(document.createElement("div"), { className: "atmo-comment-actions" });
|
|
872
|
-
const author = comment.dataset.atmosphereAuthor || "
|
|
900
|
+
const author = comment.dataset.atmosphereAuthor || t("comment.someone");
|
|
873
901
|
const reply = Object.assign(document.createElement("button"), {
|
|
874
902
|
type: "button",
|
|
875
903
|
className: "atmo-comment-action",
|
|
876
|
-
textContent: "
|
|
904
|
+
textContent: t("comment.reply"),
|
|
877
905
|
});
|
|
878
|
-
reply.setAttribute("aria-label",
|
|
906
|
+
reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
|
|
879
907
|
reply.addEventListener("click", () => {
|
|
880
|
-
if (!isSignedIn) return signInFirst("
|
|
908
|
+
if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
|
|
881
909
|
openReply(comment, author);
|
|
882
910
|
});
|
|
883
911
|
actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
|
|
@@ -892,13 +920,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
892
920
|
|
|
893
921
|
const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
|
|
894
922
|
const uri = comment.dataset.atmosphereUri!;
|
|
895
|
-
panel.replaceChildren(make("span", "atmo-comment-delete__text", "
|
|
923
|
+
panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.deleting")));
|
|
896
924
|
try {
|
|
897
925
|
const response = await send("delete", { post: uri });
|
|
898
926
|
const data = (await response.json()) as { error?: string };
|
|
899
927
|
if (response.ok) {
|
|
900
928
|
comment.remove();
|
|
901
|
-
say("
|
|
929
|
+
say(t("comment.deleted"));
|
|
902
930
|
this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
|
|
903
931
|
return;
|
|
904
932
|
}
|
|
@@ -907,7 +935,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
907
935
|
handleError(data.error ?? "error");
|
|
908
936
|
} catch {
|
|
909
937
|
panel.remove();
|
|
910
|
-
say(
|
|
938
|
+
say(message("error"));
|
|
911
939
|
}
|
|
912
940
|
};
|
|
913
941
|
|
|
@@ -920,15 +948,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
920
948
|
field("handle", me.did!);
|
|
921
949
|
field("permission", "delete");
|
|
922
950
|
field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
|
|
923
|
-
const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "
|
|
924
|
-
const cancel = Object.assign(make("button", "atmo-comment-action", "
|
|
951
|
+
const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
|
|
952
|
+
const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
|
|
925
953
|
cancel.addEventListener("click", () => panel.remove());
|
|
926
954
|
form.append(go, cancel);
|
|
927
955
|
panel.replaceChildren(
|
|
928
956
|
make(
|
|
929
957
|
"span",
|
|
930
958
|
"atmo-comment-delete__text",
|
|
931
|
-
"
|
|
959
|
+
t("comment.deletePermission"),
|
|
932
960
|
),
|
|
933
961
|
form,
|
|
934
962
|
);
|
|
@@ -936,22 +964,22 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
936
964
|
};
|
|
937
965
|
|
|
938
966
|
const deleteButton = (comment: HTMLElement) => {
|
|
939
|
-
const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", "
|
|
940
|
-
button.setAttribute("aria-label", "
|
|
967
|
+
const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
|
|
968
|
+
button.setAttribute("aria-label", t("comment.deleteLabel"));
|
|
941
969
|
button.addEventListener("click", () => {
|
|
942
970
|
const actions = button.parentElement!;
|
|
943
971
|
if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
|
|
944
972
|
const panel = make("div", "atmo-comment-delete");
|
|
945
|
-
const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "
|
|
946
|
-
const no = Object.assign(make("button", "atmo-comment-action", "
|
|
973
|
+
const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
|
|
974
|
+
const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
|
|
947
975
|
yes.addEventListener("click", () => deleteComment(comment, panel));
|
|
948
976
|
no.addEventListener("click", () => {
|
|
949
977
|
panel.remove();
|
|
950
978
|
button.focus();
|
|
951
979
|
});
|
|
952
|
-
panel.append(make("span", "atmo-comment-delete__text", "
|
|
980
|
+
panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
|
|
953
981
|
panel.setAttribute("role", "group");
|
|
954
|
-
panel.setAttribute("aria-label", "
|
|
982
|
+
panel.setAttribute("aria-label", t("comment.deleteLabel"));
|
|
955
983
|
actions.after(panel);
|
|
956
984
|
no.focus();
|
|
957
985
|
});
|
|
@@ -1033,7 +1061,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1033
1061
|
const url = new URL(location.href);
|
|
1034
1062
|
const result = url.searchParams.get("atmosphere");
|
|
1035
1063
|
if (result) {
|
|
1036
|
-
say(
|
|
1064
|
+
say(MESSAGE_KEYS[result] ? message(result) : "");
|
|
1037
1065
|
url.searchParams.delete("atmosphere");
|
|
1038
1066
|
history.replaceState(history.state, "", url);
|
|
1039
1067
|
}
|
|
@@ -1072,7 +1100,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1072
1100
|
setPressed(kind, !on);
|
|
1073
1101
|
return handleError((await response.json()).error);
|
|
1074
1102
|
}
|
|
1075
|
-
say(on ? (kind === "like" ? "
|
|
1103
|
+
say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
|
|
1076
1104
|
showReaction(kind, on);
|
|
1077
1105
|
this.dispatchEvent(
|
|
1078
1106
|
new CustomEvent("atmosphere:reacted", {
|
|
@@ -1082,7 +1110,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1082
1110
|
);
|
|
1083
1111
|
} catch {
|
|
1084
1112
|
setPressed(kind, !on);
|
|
1085
|
-
say(
|
|
1113
|
+
say(message("error"));
|
|
1086
1114
|
}
|
|
1087
1115
|
});
|
|
1088
1116
|
}
|
|
@@ -1091,7 +1119,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1091
1119
|
await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
|
|
1092
1120
|
void show(false);
|
|
1093
1121
|
setMainMode("comment");
|
|
1094
|
-
say("
|
|
1122
|
+
say(t("status.signedOut"));
|
|
1095
1123
|
});
|
|
1096
1124
|
}
|
|
1097
1125
|
}
|
|
@@ -1175,7 +1203,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1175
1203
|
font-size: 0.9em;
|
|
1176
1204
|
}
|
|
1177
1205
|
|
|
1178
|
-
.atmo__elsewhere-link {
|
|
1206
|
+
.atmo__elsewhere :global(.atmo__elsewhere-link) {
|
|
1179
1207
|
font-weight: 700;
|
|
1180
1208
|
}
|
|
1181
1209
|
|
|
@@ -1335,7 +1363,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1335
1363
|
opacity: 0.8;
|
|
1336
1364
|
}
|
|
1337
1365
|
|
|
1338
|
-
.atmo__credit a {
|
|
1366
|
+
.atmo__credit :global(a) {
|
|
1339
1367
|
color: inherit;
|
|
1340
1368
|
}
|
|
1341
1369
|
|
package/index.d.ts
CHANGED
|
@@ -21,6 +21,17 @@ export type AtmosphereCommentsOptions = {
|
|
|
21
21
|
* handles or DIDs (on top of replies hidden on Bluesky and moderation labels).
|
|
22
22
|
*/
|
|
23
23
|
hidden?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* The language of the wording, e.g. "fr" or "pt-BR". Default: Astro's own
|
|
26
|
+
* i18n locale for the page, if you use Astro's i18n routing, else English.
|
|
27
|
+
* The component's `locale` prop overrides it.
|
|
28
|
+
*/
|
|
29
|
+
locale?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Your own wording for any line, by its key in locales/en.json, e.g.
|
|
32
|
+
* { "signIn.heading": "Sign in to join the conversation" }.
|
|
33
|
+
*/
|
|
34
|
+
messages?: Record<string, string>;
|
|
24
35
|
};
|
|
25
36
|
|
|
26
37
|
export default function atmosphereComments(options: AtmosphereCommentsOptions): AstroIntegration;
|
package/index.mjs
CHANGED
|
@@ -80,6 +80,8 @@ export default function atmosphereComments(options) {
|
|
|
80
80
|
policyUri: options.policyUri,
|
|
81
81
|
tosUri: options.tosUri,
|
|
82
82
|
hidden: options.hidden ?? [],
|
|
83
|
+
locale: options.locale,
|
|
84
|
+
messages: options.messages ?? {},
|
|
83
85
|
};
|
|
84
86
|
|
|
85
87
|
// The client metadata lives at the address account hosts treat as the
|
package/lib/i18n.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The component's wording in each language. The files in locales/ are
|
|
2
|
+
// i18next JSON (v4): flat keys, {{placeholders}}, and plural forms as
|
|
3
|
+
// key_one, key_other (and key_few, key_many… where a language needs them).
|
|
4
|
+
// English is the source; translations are made in Weblate, which commits them
|
|
5
|
+
// back to the repository. Any key a language lacks falls back to English.
|
|
6
|
+
|
|
7
|
+
import type { Messages } from "./translate";
|
|
8
|
+
|
|
9
|
+
export type { Messages } from "./translate";
|
|
10
|
+
export { translate, translateHtml } from "./translate";
|
|
11
|
+
|
|
12
|
+
const files = import.meta.glob<Messages>("../locales/*.json", { eager: true, import: "default" });
|
|
13
|
+
const LOCALES: Record<string, Messages> = Object.fromEntries(
|
|
14
|
+
Object.entries(files).map(([path, messages]) => [path.replace(/^.*\/|\.json$/g, ""), messages]),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** The best language we have for `wanted` ("pt-BR" → "pt-BR", else "pt", else "en"). */
|
|
18
|
+
export function pickLocale(...wanted: Array<string | undefined>): string {
|
|
19
|
+
for (const tag of wanted) {
|
|
20
|
+
if (!tag) continue;
|
|
21
|
+
const exact = Object.keys(LOCALES).find((code) => code.toLowerCase() === tag.toLowerCase());
|
|
22
|
+
if (exact) return exact;
|
|
23
|
+
const base = tag.split("-")[0].toLowerCase();
|
|
24
|
+
if (LOCALES[base]) return base;
|
|
25
|
+
}
|
|
26
|
+
return "en";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** English, with the language's own wording on top, then the site's overrides. */
|
|
30
|
+
export function messagesFor(locale: string, overrides: Messages = {}): Messages {
|
|
31
|
+
return { ...LOCALES.en, ...(LOCALES[locale] ?? {}), ...overrides };
|
|
32
|
+
}
|