astro-atmosphere-comments 0.1.1 → 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 +18 -0
- package/README.md +37 -13
- package/components/AtmosphereComments.astro +406 -127
- package/index.d.ts +11 -0
- package/index.mjs +18 -5
- package/lib/i18n.ts +32 -0
- package/lib/oauth.ts +18 -7
- package/lib/reader.ts +18 -3
- 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 +3 -2
- package/routes/delete.ts +41 -0
- package/routes/login.ts +4 -2
- package/routes/typeahead.ts +48 -18
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
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Sign-in uses AT Protocol OAuth straight to the reader's own account host:
|
|
8
8
|
// no third-party login service is involved. This site only ever holds a
|
|
9
|
-
// grant to create and delete
|
|
10
|
-
//
|
|
9
|
+
// grant to create posts and to create and delete likes and reposts, plus, if
|
|
10
|
+
// a reader allows it when they first delete a comment, to delete posts (see
|
|
11
|
+
// SCOPE and DELETE_SCOPE in lib/oauth.ts).
|
|
11
12
|
//
|
|
12
|
-
// It adds
|
|
13
|
-
//
|
|
13
|
+
// It adds /oauth-client-metadata.json at the site's root (what account hosts
|
|
14
|
+
// read to know the site), and these routes under `base` (default /atmosphere):
|
|
15
|
+
// jwks.json the public half of the site's signing key
|
|
14
16
|
// login (POST) starts sign-in with the reader's handle
|
|
15
17
|
// callback where the reader's host sends them back
|
|
16
18
|
// logout (POST) signs out and revokes the site's grant
|
|
@@ -30,7 +32,6 @@ import { fileURLToPath } from "node:url";
|
|
|
30
32
|
const VIRTUAL_ID = "virtual:atmosphere-comments/settings";
|
|
31
33
|
|
|
32
34
|
const ROUTES = {
|
|
33
|
-
"client-metadata.json": "client-metadata.json.ts",
|
|
34
35
|
"jwks.json": "jwks.json.ts",
|
|
35
36
|
login: "login.ts",
|
|
36
37
|
callback: "callback.ts",
|
|
@@ -39,6 +40,7 @@ const ROUTES = {
|
|
|
39
40
|
comment: "comment.ts",
|
|
40
41
|
state: "state.ts",
|
|
41
42
|
react: "react.ts",
|
|
43
|
+
delete: "delete.ts",
|
|
42
44
|
post: "post.ts",
|
|
43
45
|
media: "media.ts",
|
|
44
46
|
typeahead: "typeahead.ts",
|
|
@@ -78,8 +80,19 @@ export default function atmosphereComments(options) {
|
|
|
78
80
|
policyUri: options.policyUri,
|
|
79
81
|
tosUri: options.tosUri,
|
|
80
82
|
hidden: options.hidden ?? [],
|
|
83
|
+
locale: options.locale,
|
|
84
|
+
messages: options.messages ?? {},
|
|
81
85
|
};
|
|
82
86
|
|
|
87
|
+
// The client metadata lives at the address account hosts treat as the
|
|
88
|
+
// convention, so Bluesky's approval page names the app by the site's
|
|
89
|
+
// address alone (saltedlolly.com) rather than the full file address.
|
|
90
|
+
injectRoute({
|
|
91
|
+
pattern: "/oauth-client-metadata.json",
|
|
92
|
+
entrypoint: fileURLToPath(new URL("./routes/client-metadata.json.ts", import.meta.url)),
|
|
93
|
+
prerender: false,
|
|
94
|
+
});
|
|
95
|
+
|
|
83
96
|
for (const [path, file] of Object.entries(ROUTES)) {
|
|
84
97
|
injectRoute({
|
|
85
98
|
pattern: `${base}/${path}`,
|
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
|
+
}
|
package/lib/oauth.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// permission to post comments on their behalf.
|
|
3
3
|
//
|
|
4
4
|
// - Live, the site is a "confidential client": account hosts read its
|
|
5
|
-
// client-metadata.json, and it proves who it is with a private key
|
|
5
|
+
// /oauth-client-metadata.json, and it proves who it is with a private key
|
|
6
6
|
// (ATMOSPHERE_PRIVATE_JWK, server-only). Sign-ins can then last for months.
|
|
7
7
|
// - In `astro dev`, it uses the protocol's local-testing client (no key, no
|
|
8
8
|
// public address needed). Open the site at http://127.0.0.1:<port>, not
|
|
@@ -19,31 +19,42 @@ import { getSecret } from "astro:env/server";
|
|
|
19
19
|
import settings from "./settings";
|
|
20
20
|
import { fileStore } from "./store";
|
|
21
21
|
|
|
22
|
-
// Only what commenting needs: create (and delete again)
|
|
23
|
-
// reposts, and read posts as the reader sees them (to know whether they've
|
|
22
|
+
// Only what commenting needs: create posts, create (and delete again) likes
|
|
23
|
+
// and reposts, and read posts as the reader sees them (to know whether they've
|
|
24
24
|
// already liked or reposted), through Bluesky's AppView.
|
|
25
25
|
export const SCOPE = [
|
|
26
26
|
"atproto",
|
|
27
|
-
"repo:app.bsky.feed.post?action=create
|
|
27
|
+
"repo:app.bsky.feed.post?action=create",
|
|
28
28
|
"repo:app.bsky.feed.like?action=create&action=delete",
|
|
29
29
|
"repo:app.bsky.feed.repost?action=create&action=delete",
|
|
30
30
|
"rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview",
|
|
31
31
|
].join(" ");
|
|
32
32
|
|
|
33
|
+
// Deleting posts is asked for separately, only when a reader first deletes a
|
|
34
|
+
// comment here, so most readers never grant it. (A permission can only name a
|
|
35
|
+
// kind of record, not particular posts: the delete route itself makes sure it
|
|
36
|
+
// is only used on the reader's own comments under the site's posts.)
|
|
37
|
+
export const DELETE_SCOPE = "repo:app.bsky.feed.post?action=delete";
|
|
38
|
+
|
|
39
|
+
// Everything the site may ask for, which its client metadata has to list.
|
|
40
|
+
const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE}`;
|
|
41
|
+
|
|
33
42
|
const KEY_ID = "atmosphere-comments-1";
|
|
34
43
|
|
|
35
44
|
export const callbackPath = `${settings.base}/callback`;
|
|
36
45
|
|
|
37
46
|
export function clientMetadata() {
|
|
38
47
|
return {
|
|
39
|
-
|
|
48
|
+
// At the site's root, the address account hosts treat as the convention:
|
|
49
|
+
// Bluesky's approval page then shows just the site's address.
|
|
50
|
+
client_id: `${settings.site}/oauth-client-metadata.json`,
|
|
40
51
|
client_name: settings.clientName,
|
|
41
52
|
client_uri: settings.site,
|
|
42
53
|
...(settings.logoUri && { logo_uri: settings.logoUri }),
|
|
43
54
|
...(settings.policyUri && { policy_uri: settings.policyUri }),
|
|
44
55
|
...(settings.tosUri && { tos_uri: settings.tosUri }),
|
|
45
56
|
redirect_uris: [`${settings.site}${callbackPath}`] as [string],
|
|
46
|
-
scope:
|
|
57
|
+
scope: ALL_SCOPES,
|
|
47
58
|
grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
|
|
48
59
|
response_types: ["code"] as ["code"],
|
|
49
60
|
application_type: "web" as const,
|
|
@@ -84,7 +95,7 @@ export function oauthClient(origin: string): Promise<NodeOAuthClient> {
|
|
|
84
95
|
return new NodeOAuthClient({
|
|
85
96
|
...common,
|
|
86
97
|
clientMetadata: buildAtprotoLoopbackClientMetadata({
|
|
87
|
-
scope:
|
|
98
|
+
scope: ALL_SCOPES,
|
|
88
99
|
redirect_uris: [`${local.origin}${callbackPath}`],
|
|
89
100
|
}),
|
|
90
101
|
});
|
package/lib/reader.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { oauthClient, SCOPE } from "./oauth";
|
|
|
9
9
|
import settings from "./settings";
|
|
10
10
|
import { signedInDid, signOut } from "./visitor";
|
|
11
11
|
|
|
12
|
-
export type Reader = { did: string; session: OAuthSession };
|
|
12
|
+
export type Reader = { did: string; session: OAuthSession; scopes: string[] };
|
|
13
13
|
|
|
14
14
|
const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
|
|
15
15
|
const POST_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._~-]+)$/;
|
|
@@ -29,12 +29,13 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
|
|
|
29
29
|
const session = await (await oauthClient(url.origin)).restore(did);
|
|
30
30
|
// Compared by permission name ("repo:app.bsky.feed.like"), since a host
|
|
31
31
|
// may write the parameters after it differently.
|
|
32
|
-
const
|
|
32
|
+
const scopes = (await session.getTokenInfo(false)).scope.split(" ");
|
|
33
|
+
const granted = scopes.map((part) => part.split("?")[0]);
|
|
33
34
|
if (!SCOPE.split(" ").every((part) => granted.includes(part.split("?")[0]))) {
|
|
34
35
|
await signOut(cookies);
|
|
35
36
|
return json({ error: "new-permissions" }, 401);
|
|
36
37
|
}
|
|
37
|
-
return { did, session };
|
|
38
|
+
return { did, session, scopes };
|
|
38
39
|
} catch {
|
|
39
40
|
// The grant has expired or been revoked: the reader needs to sign in again.
|
|
40
41
|
await signOut(cookies);
|
|
@@ -42,6 +43,20 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether the reader's grant allows `action` on records of `collection`: a
|
|
48
|
+
* repo permission for that collection (or all of them) listing the action,
|
|
49
|
+
* or listing no actions, which means all of them.
|
|
50
|
+
*/
|
|
51
|
+
export function allows(reader: Reader, collection: string, action: string): boolean {
|
|
52
|
+
return reader.scopes.some((scope) => {
|
|
53
|
+
const [name, query = ""] = scope.split("?");
|
|
54
|
+
if (name !== `repo:${collection}` && name !== "repo:*") return false;
|
|
55
|
+
const actions = new URLSearchParams(query).getAll("action");
|
|
56
|
+
return !actions.length || actions.includes(action);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
/** True for a post by one of the site's own accounts (an announcement). */
|
|
46
61
|
export function isSitePost(uri: unknown): uri is string {
|
|
47
62
|
const match = typeof uri === "string" ? POST_URI.exec(uri) : null;
|
package/lib/settings.ts
CHANGED
package/lib/translate.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Filling in the component's wording: placeholders and plural forms. Kept
|
|
2
|
+
// apart from lib/i18n.ts so the browser script can use it without bundling
|
|
3
|
+
// every language.
|
|
4
|
+
|
|
5
|
+
export type Messages = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One piece of wording: `vars` fill its {{placeholders}}, and a `count`
|
|
9
|
+
* picks the plural form the language needs. Placeholders not given are
|
|
10
|
+
* left as they are (the component fills some with links).
|
|
11
|
+
*/
|
|
12
|
+
export function translate(messages: Messages, locale: string, key: string, vars: Record<string, string | number> = {}): string {
|
|
13
|
+
let text = messages[key];
|
|
14
|
+
if (typeof vars.count === "number") {
|
|
15
|
+
const form = new Intl.PluralRules(locale).select(vars.count);
|
|
16
|
+
text = messages[`${key}_${form}`] ?? messages[`${key}_other`] ?? text;
|
|
17
|
+
}
|
|
18
|
+
text ??= key;
|
|
19
|
+
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name: string) => (name in vars ? String(vars[name]) : match));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const escapeHtml = (text: string) =>
|
|
23
|
+
text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
24
|
+
|
|
25
|
+
/** Like translate, for markup: the wording is escaped, and `html` fills placeholders with markup (links, say). */
|
|
26
|
+
export function translateHtml(
|
|
27
|
+
messages: Messages,
|
|
28
|
+
locale: string,
|
|
29
|
+
key: string,
|
|
30
|
+
html: Record<string, string>,
|
|
31
|
+
vars: Record<string, string | number> = {},
|
|
32
|
+
): string {
|
|
33
|
+
return escapeHtml(translate(messages, locale, key, vars)).replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name: string) =>
|
|
34
|
+
name in html ? html[name] : match,
|
|
35
|
+
);
|
|
36
|
+
}
|
package/locales/de.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"signIn.heading": "Melde dich an, um zu kommentieren, zu liken oder zu reposten",
|
|
3
|
+
"signIn.anyAccount": "Jedes {{atmosphereAccount}} funktioniert, auch {{bluesky}}: ein Konto für ein ganzes Netzwerk offener Apps.",
|
|
4
|
+
"signIn.atmosphereAccount": "Atmosphere-Konto",
|
|
5
|
+
"signIn.button": "Mit Atmosphere anmelden",
|
|
6
|
+
"signIn.matchingAccounts": "Passende Konten",
|
|
7
|
+
"signIn.privacy": "Du bestätigst das auf der Seite deines eigenen Kontos (für die meisten bei Bluesky); dein Passwort wird hier nie weitergegeben. Noch kein Konto? {{createAccount}}.",
|
|
8
|
+
"signIn.createAccount": "Erstelle ein kostenloses Atmosphere-Konto",
|
|
9
|
+
"elsewhere.text": "Du möchtest dich hier lieber nicht anmelden? {{link}} Deine Antwort erscheint hier genauso, ebenso Likes und Reposts.",
|
|
10
|
+
"elsewhere.link": "Antworte stattdessen auf Bluesky →",
|
|
11
|
+
"actions.label": "Auf diesen Beitrag reagieren",
|
|
12
|
+
"actions.like": "Gefällt mir",
|
|
13
|
+
"actions.liked": "Gefällt dir",
|
|
14
|
+
"actions.repost": "Reposten",
|
|
15
|
+
"actions.reposted": "Repostet",
|
|
16
|
+
"actions.quote": "Zitieren",
|
|
17
|
+
"composer.commentAs": "{{verb}} als {{handle}}",
|
|
18
|
+
"composer.comment": "Kommentieren",
|
|
19
|
+
"composer.quote": "Zitieren",
|
|
20
|
+
"composer.mentions": "Konten zum Erwähnen",
|
|
21
|
+
"composer.previewCaption": "So sieht dein Beitrag aus",
|
|
22
|
+
"composer.now": "jetzt",
|
|
23
|
+
"composer.previewEmpty": "Dein Text erscheint hier.",
|
|
24
|
+
"composer.quoting": "Du zitierst diesen Beitrag mit deinem eigenen Konto",
|
|
25
|
+
"composer.replyingTo": "Antwort an {{name}}",
|
|
26
|
+
"composer.cancel": "Abbrechen",
|
|
27
|
+
"composer.signOut": "Abmelden",
|
|
28
|
+
"composer.postComment": "Kommentar posten",
|
|
29
|
+
"composer.postQuote": "Zitat posten",
|
|
30
|
+
"composer.charactersLeft_one": "Noch {{count}} Zeichen",
|
|
31
|
+
"composer.charactersLeft_other": "Noch {{count}} Zeichen",
|
|
32
|
+
"composer.posting": "Wird gepostet…",
|
|
33
|
+
"posted.comment": "Dein Kommentar ist gepostet.",
|
|
34
|
+
"posted.quote": "Dein Zitat ist auf Bluesky.",
|
|
35
|
+
"posted.see": "Auf Bluesky ansehen →",
|
|
36
|
+
"reactions.likes_one": "{{count}} Like",
|
|
37
|
+
"reactions.likes_other": "{{count}} Likes",
|
|
38
|
+
"reactions.reposts_one": "{{count}} Repost",
|
|
39
|
+
"reactions.reposts_other": "{{count}} Reposts",
|
|
40
|
+
"comment.author": "Autor",
|
|
41
|
+
"comment.viewOnBluesky": "auf Bluesky ansehen",
|
|
42
|
+
"comment.someone": "diesen Kommentar",
|
|
43
|
+
"comment.reply": "Antworten",
|
|
44
|
+
"comment.replyTo": "{{name}} antworten",
|
|
45
|
+
"comment.likeLabel": "Den Kommentar von {{name}} liken",
|
|
46
|
+
"comment.repostLabel": "Den Kommentar von {{name}} reposten",
|
|
47
|
+
"comment.delete": "Löschen",
|
|
48
|
+
"comment.deleteLabel": "Deinen Kommentar löschen",
|
|
49
|
+
"comment.deleteConfirm": "Diesen Kommentar von Bluesky löschen?",
|
|
50
|
+
"comment.deleting": "Wird gelöscht…",
|
|
51
|
+
"comment.deleted": "Dein Kommentar wurde gelöscht.",
|
|
52
|
+
"comment.deletePermission": "Um hier Kommentare zu löschen, braucht diese Seite eine weitere Erlaubnis von deinem Konto. Du kannst ihn auch in jeder Bluesky-App löschen.",
|
|
53
|
+
"comment.continue": "Weiter zur Bestätigung",
|
|
54
|
+
"comment.cancel": "Abbrechen",
|
|
55
|
+
"signInFirst.reply": "Melde dich an, um zu antworten.",
|
|
56
|
+
"signInFirst.like": "Melde dich an, um Kommentare zu liken.",
|
|
57
|
+
"signInFirst.repost": "Melde dich an, um Kommentare zu reposten.",
|
|
58
|
+
"status.liked": "Geliked.",
|
|
59
|
+
"status.likeRemoved": "Like entfernt.",
|
|
60
|
+
"status.reposted": "Repostet.",
|
|
61
|
+
"status.repostRemoved": "Repost entfernt.",
|
|
62
|
+
"status.signedOut": "Du bist abgemeldet.",
|
|
63
|
+
"messages.signedIn": "Du bist angemeldet. Du kannst oben kommentieren, liken oder reposten.",
|
|
64
|
+
"messages.signInFailed": "Die Anmeldung wurde nicht abgeschlossen. Bitte versuche es noch einmal.",
|
|
65
|
+
"messages.accountNotFound": "Dieses Konto wurde nicht gefunden. Prüfe den Handle und versuche es noch einmal.",
|
|
66
|
+
"messages.invalidHandle": "Das sieht nicht wie ein Handle aus. Meist ist es etwas wie alice.bsky.social.",
|
|
67
|
+
"messages.signedOut": "Du wurdest abgemeldet. Bitte melde dich erneut an.",
|
|
68
|
+
"messages.needsPermission": "Um hier Kommentare zu löschen, braucht diese Seite eine weitere Erlaubnis von deinem Konto.",
|
|
69
|
+
"messages.notAllowed": "Das ist hier nicht möglich.",
|
|
70
|
+
"messages.newPermissions": "Diese Seite kann jetzt auch für dich liken und reposten und braucht deshalb erneut deine Erlaubnis. Bitte melde dich erneut an.",
|
|
71
|
+
"messages.tooLong": "Beiträge können bis zu {{max}} Zeichen lang sein.",
|
|
72
|
+
"messages.tooMany": "Du bist ziemlich schnell. Bitte warte eine Minute und versuche es noch einmal.",
|
|
73
|
+
"messages.error": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
|
|
74
|
+
"credit": "Bereitgestellt von {{link}}"
|
|
75
|
+
}
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"signIn.heading": "Sign in to leave a comment, like or repost",
|
|
3
|
+
"signIn.anyAccount": "Any {{atmosphereAccount}} works, including {{bluesky}}: one account for a whole network of open apps.",
|
|
4
|
+
"signIn.atmosphereAccount": "Atmosphere account",
|
|
5
|
+
"signIn.button": "Sign in with Atmosphere",
|
|
6
|
+
"signIn.matchingAccounts": "Matching accounts",
|
|
7
|
+
"signIn.privacy": "You'll approve this on your account's own site (Bluesky's, for most people); your password is never shared here. No account yet? {{createAccount}}.",
|
|
8
|
+
"signIn.createAccount": "Create a free Atmosphere account",
|
|
9
|
+
"elsewhere.text": "Rather not sign in here? {{link}} It shows up here just the same, as do likes and reposts.",
|
|
10
|
+
"elsewhere.link": "Reply on Bluesky instead →",
|
|
11
|
+
"actions.label": "Respond to this post",
|
|
12
|
+
"actions.like": "Like",
|
|
13
|
+
"actions.liked": "Liked",
|
|
14
|
+
"actions.repost": "Repost",
|
|
15
|
+
"actions.reposted": "Reposted",
|
|
16
|
+
"actions.quote": "Quote",
|
|
17
|
+
"composer.commentAs": "{{verb}} as {{handle}}",
|
|
18
|
+
"composer.comment": "Comment",
|
|
19
|
+
"composer.quote": "Quote",
|
|
20
|
+
"composer.mentions": "Accounts to mention",
|
|
21
|
+
"composer.previewCaption": "How your post will look",
|
|
22
|
+
"composer.now": "now",
|
|
23
|
+
"composer.previewEmpty": "Your words appear here.",
|
|
24
|
+
"composer.quoting": "Quoting this post on your own account",
|
|
25
|
+
"composer.replyingTo": "Replying to {{name}}",
|
|
26
|
+
"composer.cancel": "Cancel",
|
|
27
|
+
"composer.signOut": "Sign out",
|
|
28
|
+
"composer.postComment": "Post comment",
|
|
29
|
+
"composer.postQuote": "Post quote",
|
|
30
|
+
"composer.charactersLeft_one": "{{count}} left",
|
|
31
|
+
"composer.charactersLeft_other": "{{count}} left",
|
|
32
|
+
"composer.posting": "Posting…",
|
|
33
|
+
"posted.comment": "Your comment is posted.",
|
|
34
|
+
"posted.quote": "Your quote post is on Bluesky.",
|
|
35
|
+
"posted.see": "See it on Bluesky →",
|
|
36
|
+
"reactions.likes_one": "{{count}} like",
|
|
37
|
+
"reactions.likes_other": "{{count}} likes",
|
|
38
|
+
"reactions.reposts_one": "{{count}} repost",
|
|
39
|
+
"reactions.reposts_other": "{{count}} reposts",
|
|
40
|
+
"comment.author": "Author",
|
|
41
|
+
"comment.viewOnBluesky": "view on Bluesky",
|
|
42
|
+
"comment.someone": "this comment",
|
|
43
|
+
"comment.reply": "Reply",
|
|
44
|
+
"comment.replyTo": "Reply to {{name}}",
|
|
45
|
+
"comment.likeLabel": "Like {{name}}'s comment",
|
|
46
|
+
"comment.repostLabel": "Repost {{name}}'s comment",
|
|
47
|
+
"comment.delete": "Delete",
|
|
48
|
+
"comment.deleteLabel": "Delete your comment",
|
|
49
|
+
"comment.deleteConfirm": "Delete this comment from Bluesky?",
|
|
50
|
+
"comment.deleting": "Deleting…",
|
|
51
|
+
"comment.deleted": "Your comment was deleted.",
|
|
52
|
+
"comment.deletePermission": "To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
|
|
53
|
+
"comment.continue": "Continue to approve",
|
|
54
|
+
"comment.cancel": "Cancel",
|
|
55
|
+
"signInFirst.reply": "Sign in to reply.",
|
|
56
|
+
"signInFirst.like": "Sign in to like comments.",
|
|
57
|
+
"signInFirst.repost": "Sign in to repost comments.",
|
|
58
|
+
"status.liked": "Liked.",
|
|
59
|
+
"status.likeRemoved": "Like removed.",
|
|
60
|
+
"status.reposted": "Reposted.",
|
|
61
|
+
"status.repostRemoved": "Repost removed.",
|
|
62
|
+
"status.signedOut": "You're signed out.",
|
|
63
|
+
"messages.signedIn": "You're signed in. You can comment, like or repost above.",
|
|
64
|
+
"messages.signInFailed": "Signing in didn't finish. Please try again.",
|
|
65
|
+
"messages.accountNotFound": "We couldn't find that account. Check the handle and try again.",
|
|
66
|
+
"messages.invalidHandle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
|
|
67
|
+
"messages.signedOut": "You've been signed out. Please sign in again.",
|
|
68
|
+
"messages.needsPermission": "To delete comments here, this site needs one more permission from your account.",
|
|
69
|
+
"messages.notAllowed": "That can't be done here.",
|
|
70
|
+
"messages.newPermissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
|
|
71
|
+
"messages.tooLong": "Posts can be up to {{max}} characters.",
|
|
72
|
+
"messages.tooMany": "You're going quickly. Please wait a minute and try again.",
|
|
73
|
+
"messages.error": "That didn't work. Please try again.",
|
|
74
|
+
"credit": "Powered by {{link}}"
|
|
75
|
+
}
|
package/locales/es.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"signIn.heading": "Inicia sesión para comentar, dar me gusta o compartir",
|
|
3
|
+
"signIn.anyAccount": "Sirve cualquier {{atmosphereAccount}}, incluida {{bluesky}}: una sola cuenta para toda una red de aplicaciones abiertas.",
|
|
4
|
+
"signIn.atmosphereAccount": "cuenta de Atmosphere",
|
|
5
|
+
"signIn.button": "Iniciar sesión con Atmosphere",
|
|
6
|
+
"signIn.matchingAccounts": "Cuentas que coinciden",
|
|
7
|
+
"signIn.privacy": "Lo aprobarás en el sitio de tu propia cuenta (el de Bluesky, para la mayoría); tu contraseña nunca se comparte aquí. ¿Aún no tienes cuenta? {{createAccount}}.",
|
|
8
|
+
"signIn.createAccount": "Crea una cuenta de Atmosphere gratis",
|
|
9
|
+
"elsewhere.text": "¿Prefieres no iniciar sesión aquí? {{link}} Tu respuesta aparece aquí igualmente, igual que los me gusta y las publicaciones compartidas.",
|
|
10
|
+
"elsewhere.link": "Responde en Bluesky →",
|
|
11
|
+
"actions.label": "Responder a esta publicación",
|
|
12
|
+
"actions.like": "Me gusta",
|
|
13
|
+
"actions.liked": "Te gusta",
|
|
14
|
+
"actions.repost": "Compartir",
|
|
15
|
+
"actions.reposted": "Compartido",
|
|
16
|
+
"actions.quote": "Citar",
|
|
17
|
+
"composer.commentAs": "{{verb}} como {{handle}}",
|
|
18
|
+
"composer.comment": "Comentar",
|
|
19
|
+
"composer.quote": "Citar",
|
|
20
|
+
"composer.mentions": "Cuentas para mencionar",
|
|
21
|
+
"composer.previewCaption": "Así se verá tu publicación",
|
|
22
|
+
"composer.now": "ahora",
|
|
23
|
+
"composer.previewEmpty": "Tu texto aparece aquí.",
|
|
24
|
+
"composer.quoting": "Estás citando esta publicación en tu propia cuenta",
|
|
25
|
+
"composer.replyingTo": "Respondiendo a {{name}}",
|
|
26
|
+
"composer.cancel": "Cancelar",
|
|
27
|
+
"composer.signOut": "Cerrar sesión",
|
|
28
|
+
"composer.postComment": "Publicar comentario",
|
|
29
|
+
"composer.postQuote": "Publicar cita",
|
|
30
|
+
"composer.charactersLeft_one": "Queda {{count}}",
|
|
31
|
+
"composer.charactersLeft_many": "Quedan {{count}}",
|
|
32
|
+
"composer.charactersLeft_other": "Quedan {{count}}",
|
|
33
|
+
"composer.posting": "Publicando…",
|
|
34
|
+
"posted.comment": "Tu comentario está publicado.",
|
|
35
|
+
"posted.quote": "Tu cita está en Bluesky.",
|
|
36
|
+
"posted.see": "Verla en Bluesky →",
|
|
37
|
+
"reactions.likes_one": "{{count}} me gusta",
|
|
38
|
+
"reactions.likes_many": "{{count}} me gusta",
|
|
39
|
+
"reactions.likes_other": "{{count}} me gusta",
|
|
40
|
+
"reactions.reposts_one": "{{count}} vez compartida",
|
|
41
|
+
"reactions.reposts_many": "{{count}} veces compartida",
|
|
42
|
+
"reactions.reposts_other": "{{count}} veces compartida",
|
|
43
|
+
"comment.author": "Autor",
|
|
44
|
+
"comment.viewOnBluesky": "ver en Bluesky",
|
|
45
|
+
"comment.someone": "este comentario",
|
|
46
|
+
"comment.reply": "Responder",
|
|
47
|
+
"comment.replyTo": "Responder a {{name}}",
|
|
48
|
+
"comment.likeLabel": "Dar me gusta al comentario de {{name}}",
|
|
49
|
+
"comment.repostLabel": "Compartir el comentario de {{name}}",
|
|
50
|
+
"comment.delete": "Eliminar",
|
|
51
|
+
"comment.deleteLabel": "Eliminar tu comentario",
|
|
52
|
+
"comment.deleteConfirm": "¿Eliminar este comentario de Bluesky?",
|
|
53
|
+
"comment.deleting": "Eliminando…",
|
|
54
|
+
"comment.deleted": "Tu comentario se ha eliminado.",
|
|
55
|
+
"comment.deletePermission": "Para eliminar comentarios aquí, este sitio necesita un permiso más de tu cuenta. También puedes eliminarlo en cualquier aplicación de Bluesky.",
|
|
56
|
+
"comment.continue": "Continuar para aprobar",
|
|
57
|
+
"comment.cancel": "Cancelar",
|
|
58
|
+
"signInFirst.reply": "Inicia sesión para responder.",
|
|
59
|
+
"signInFirst.like": "Inicia sesión para dar me gusta a los comentarios.",
|
|
60
|
+
"signInFirst.repost": "Inicia sesión para compartir comentarios.",
|
|
61
|
+
"status.liked": "Te gusta.",
|
|
62
|
+
"status.likeRemoved": "Me gusta quitado.",
|
|
63
|
+
"status.reposted": "Compartido.",
|
|
64
|
+
"status.repostRemoved": "Ya no lo compartes.",
|
|
65
|
+
"status.signedOut": "Has cerrado sesión.",
|
|
66
|
+
"messages.signedIn": "Has iniciado sesión. Puedes comentar, dar me gusta o compartir arriba.",
|
|
67
|
+
"messages.signInFailed": "El inicio de sesión no se completó. Inténtalo de nuevo.",
|
|
68
|
+
"messages.accountNotFound": "No encontramos esa cuenta. Revisa el identificador e inténtalo de nuevo.",
|
|
69
|
+
"messages.invalidHandle": "Eso no parece un identificador. Suele ser algo como alice.bsky.social.",
|
|
70
|
+
"messages.signedOut": "Se ha cerrado tu sesión. Vuelve a iniciar sesión.",
|
|
71
|
+
"messages.needsPermission": "Para eliminar comentarios aquí, este sitio necesita un permiso más de tu cuenta.",
|
|
72
|
+
"messages.notAllowed": "Eso no se puede hacer aquí.",
|
|
73
|
+
"messages.newPermissions": "Este sitio ahora también puede dar me gusta y compartir por ti, así que necesita tu permiso de nuevo. Vuelve a iniciar sesión.",
|
|
74
|
+
"messages.tooLong": "Las publicaciones pueden tener hasta {{max}} caracteres.",
|
|
75
|
+
"messages.tooMany": "Vas muy rápido. Espera un minuto e inténtalo de nuevo.",
|
|
76
|
+
"messages.error": "No ha funcionado. Inténtalo de nuevo.",
|
|
77
|
+
"credit": "Funciona con {{link}}"
|
|
78
|
+
}
|
package/locales/fr.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"signIn.heading": "Connecte-toi pour commenter, aimer ou republier",
|
|
3
|
+
"signIn.anyAccount": "N'importe quel {{atmosphereAccount}} fonctionne, y compris {{bluesky}} : un seul compte pour tout un réseau d'applications ouvertes.",
|
|
4
|
+
"signIn.atmosphereAccount": "compte Atmosphere",
|
|
5
|
+
"signIn.button": "Se connecter avec Atmosphere",
|
|
6
|
+
"signIn.matchingAccounts": "Comptes correspondants",
|
|
7
|
+
"signIn.privacy": "Tu valideras sur le site de ton propre compte (celui de Bluesky, pour la plupart des gens) ; ton mot de passe n'est jamais partagé ici. Pas encore de compte ? {{createAccount}}.",
|
|
8
|
+
"signIn.createAccount": "Crée un compte Atmosphere gratuit",
|
|
9
|
+
"elsewhere.text": "Tu préfères ne pas te connecter ici ? {{link}} Ta réponse s'affiche ici de la même façon, tout comme les « J'aime » et les republications.",
|
|
10
|
+
"elsewhere.link": "Réponds plutôt sur Bluesky →",
|
|
11
|
+
"actions.label": "Réagir à cette publication",
|
|
12
|
+
"actions.like": "J'aime",
|
|
13
|
+
"actions.liked": "Aimé",
|
|
14
|
+
"actions.repost": "Republier",
|
|
15
|
+
"actions.reposted": "Republié",
|
|
16
|
+
"actions.quote": "Citer",
|
|
17
|
+
"composer.commentAs": "{{verb}} en tant que {{handle}}",
|
|
18
|
+
"composer.comment": "Commenter",
|
|
19
|
+
"composer.quote": "Citer",
|
|
20
|
+
"composer.mentions": "Comptes à mentionner",
|
|
21
|
+
"composer.previewCaption": "Aperçu de ta publication",
|
|
22
|
+
"composer.now": "maintenant",
|
|
23
|
+
"composer.previewEmpty": "Ton texte s'affiche ici.",
|
|
24
|
+
"composer.quoting": "Tu cites cette publication sur ton propre compte",
|
|
25
|
+
"composer.replyingTo": "En réponse à {{name}}",
|
|
26
|
+
"composer.cancel": "Annuler",
|
|
27
|
+
"composer.signOut": "Se déconnecter",
|
|
28
|
+
"composer.postComment": "Publier le commentaire",
|
|
29
|
+
"composer.postQuote": "Publier la citation",
|
|
30
|
+
"composer.charactersLeft_one": "{{count}} caractère restant",
|
|
31
|
+
"composer.charactersLeft_many": "{{count}} caractères restants",
|
|
32
|
+
"composer.charactersLeft_other": "{{count}} caractères restants",
|
|
33
|
+
"composer.posting": "Publication…",
|
|
34
|
+
"posted.comment": "Ton commentaire est publié.",
|
|
35
|
+
"posted.quote": "Ta citation est publiée sur Bluesky.",
|
|
36
|
+
"posted.see": "La voir sur Bluesky →",
|
|
37
|
+
"reactions.likes_one": "{{count}} J'aime",
|
|
38
|
+
"reactions.likes_many": "{{count}} J'aime",
|
|
39
|
+
"reactions.likes_other": "{{count}} J'aime",
|
|
40
|
+
"reactions.reposts_one": "{{count}} republication",
|
|
41
|
+
"reactions.reposts_many": "{{count}} republications",
|
|
42
|
+
"reactions.reposts_other": "{{count}} republications",
|
|
43
|
+
"comment.author": "Auteur",
|
|
44
|
+
"comment.viewOnBluesky": "voir sur Bluesky",
|
|
45
|
+
"comment.someone": "ce commentaire",
|
|
46
|
+
"comment.reply": "Répondre",
|
|
47
|
+
"comment.replyTo": "Répondre à {{name}}",
|
|
48
|
+
"comment.likeLabel": "Aimer le commentaire de {{name}}",
|
|
49
|
+
"comment.repostLabel": "Republier le commentaire de {{name}}",
|
|
50
|
+
"comment.delete": "Supprimer",
|
|
51
|
+
"comment.deleteLabel": "Supprimer ton commentaire",
|
|
52
|
+
"comment.deleteConfirm": "Supprimer ce commentaire de Bluesky ?",
|
|
53
|
+
"comment.deleting": "Suppression…",
|
|
54
|
+
"comment.deleted": "Ton commentaire a été supprimé.",
|
|
55
|
+
"comment.deletePermission": "Pour supprimer des commentaires ici, ce site a besoin d'une autorisation de plus de ton compte. Tu peux aussi le supprimer dans n'importe quelle application Bluesky.",
|
|
56
|
+
"comment.continue": "Continuer pour autoriser",
|
|
57
|
+
"comment.cancel": "Annuler",
|
|
58
|
+
"signInFirst.reply": "Connecte-toi pour répondre.",
|
|
59
|
+
"signInFirst.like": "Connecte-toi pour aimer des commentaires.",
|
|
60
|
+
"signInFirst.repost": "Connecte-toi pour republier des commentaires.",
|
|
61
|
+
"status.liked": "Aimé.",
|
|
62
|
+
"status.likeRemoved": "« J'aime » retiré.",
|
|
63
|
+
"status.reposted": "Republié.",
|
|
64
|
+
"status.repostRemoved": "Republication retirée.",
|
|
65
|
+
"status.signedOut": "Tu es déconnecté.",
|
|
66
|
+
"messages.signedIn": "Tu es connecté. Tu peux commenter, aimer ou republier ci-dessus.",
|
|
67
|
+
"messages.signInFailed": "La connexion n'a pas abouti. Réessaie.",
|
|
68
|
+
"messages.accountNotFound": "Ce compte est introuvable. Vérifie l'identifiant et réessaie.",
|
|
69
|
+
"messages.invalidHandle": "Ça ne ressemble pas à un identifiant. C'est généralement quelque chose comme alice.bsky.social.",
|
|
70
|
+
"messages.signedOut": "Tu as été déconnecté. Connecte-toi à nouveau.",
|
|
71
|
+
"messages.needsPermission": "Pour supprimer des commentaires ici, ce site a besoin d'une autorisation de plus de ton compte.",
|
|
72
|
+
"messages.notAllowed": "Ce n'est pas possible ici.",
|
|
73
|
+
"messages.newPermissions": "Ce site peut désormais aussi aimer et republier pour toi ; il a donc de nouveau besoin de ton autorisation. Connecte-toi à nouveau.",
|
|
74
|
+
"messages.tooLong": "Les publications peuvent contenir jusqu'à {{max}} caractères.",
|
|
75
|
+
"messages.tooMany": "Tu vas un peu vite. Attends une minute et réessaie.",
|
|
76
|
+
"messages.error": "Ça n'a pas marché. Réessaie.",
|
|
77
|
+
"credit": "Propulsé par {{link}}"
|
|
78
|
+
}
|