diff --git a/docs/localization.md b/docs/localization.md
index 6d18a60..86b15e9 100644
--- a/docs/localization.md
+++ b/docs/localization.md
@@ -305,14 +305,15 @@ An external machine-translation service connects over WebSocket at
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO
forward-auth does not cover that route, and the key in the path is the
access control. Keys live in `Data.translate_keys` (key -> display name) —
-12 lowercase alphanumeric characters each, the first one generated at
-database bootstrap and multiple keys reserved for future management (e.g.
-a web UI). The full WS URL(s) are printed in the startup log
-(`ws://localhost:{port}/_translate/{key}` locally,
-`wss://{hostname}/_translate/{key}` on a public hostname) and the keys are
-surfaced to the admin in `GET /_api/settings` as `translate_keys`. An
-unknown or empty key rejects the handshake (close-before-accept → HTTP
-403). Transactions storing results record the connecting key as the kanta
+12 lowercase alphanumeric characters each; the first is generated at
+database bootstrap, further ones are managed in the editor's lang tab
+(add/rename/delete ride the `PUT /_api/settings` round-trip; the name is
+an inline display label only). The full WS URL(s) are printed in the
+startup log (`ws://localhost:{port}/_translate/{key}` locally,
+`wss://{hostname}/_translate/{key}` on a public hostname) and shown in the
+lang tab as click-to-copy links; the keys are also surfaced in
+`GET /_api/settings` as `translate_keys`. An unknown or empty key rejects
+the handshake (close-before-accept → HTTP 403). Transactions storing results record the connecting key as the kanta
transaction `user`.
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
diff --git a/frontend/src/LocalizationEditor.vue b/frontend/src/LocalizationEditor.vue
index 2ca8f43..f671426 100644
--- a/frontend/src/LocalizationEditor.vue
+++ b/frontend/src/LocalizationEditor.vue
@@ -1,16 +1,21 @@
@@ -129,28 +176,37 @@ async function refresh() {
- translations
- deleting re-translates everything; user edits are kept
+ Translator API
-
-
-
-
-
- translator service
- connect scripts/translator.py to
+
AI translator agents can connect with the API keys to do machine translations to your selected languages. Click the button below to delete all translations and start over. User edits are kept.
+
+
@@ -252,10 +308,83 @@ async function refresh() {
gap: 0.6rem;
}
-.key-row code {
+/* Real links (handy for right-click/drag) showing just the key, but the
+ click copies the full URL instead of following — ws:// would fail to
+ navigate. Normal text color, not link-styled; position: relative
+ anchors the "Copied!" popup (analytics/format.js). */
+.key-link {
+ position: relative;
+ color: var(--text);
+ font-family: var(--font-code);
user-select: all;
}
+.refresh-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+}
+
+/* Name input / ✕ / ➕ follow the structure tab's conventions: inputs stay
+ borderless until interacted with, glyph buttons redden / solidify on
+ hover. */
+.key-name {
+ flex: 0 0 9rem;
+}
+
+.edit {
+ font: inherit;
+ font-size: 0.85rem;
+ padding: 0.1rem 0.4rem;
+ background: transparent;
+ color: var(--text);
+ border: 1px solid transparent;
+ border-radius: 4px;
+ min-width: 0;
+}
+
+.edit:hover {
+ border-color: var(--line);
+}
+
+.edit:focus {
+ background: var(--bg);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.act {
+ padding: 0 0.25rem;
+ background: none;
+ border: none;
+ color: var(--muted);
+ font-size: 0.8rem;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.del:hover {
+ color: #e06c75;
+}
+
+.add-row {
+ display: flex;
+ align-items: center;
+}
+
+.add {
+ padding: 0 0.3rem;
+ background: none;
+ border: none;
+ font-size: 0.9rem;
+ cursor: pointer;
+ opacity: 0.5;
+}
+
+.add:hover {
+ opacity: 1;
+}
+
.refresh-btn {
align-self: flex-start;
margin-bottom: 0.2rem;
diff --git a/pagerite/api.py b/pagerite/api.py
index 1641f86..30c38ab 100644
--- a/pagerite/api.py
+++ b/pagerite/api.py
@@ -298,6 +298,7 @@ class SettingsIn(BaseModel):
brand_html: str = ""
transition: str = "cube"
translate_langs: list[str] | None = None # None keeps the current set
+ translate_keys: dict[str, str] | None = None # None keeps the current keys
@router.put("/_api/settings", status_code=204)
@@ -318,6 +319,8 @@ async def put_settings(settings: SettingsIn, request: Request) -> None:
for lang in settings.translate_langs
if (tag := i18n.base_tag(lang))
}
+ if settings.translate_keys is not None:
+ data.translate_keys = settings.translate_keys
_invalidate_pages()
diff --git a/pagerite/data.py b/pagerite/data.py
index 0b6b010..ea3e987 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -104,8 +104,8 @@ class Data(msgspec.Struct):
#: API keys gating the translator service WebSocket (/_translate/{key};
#: the external forward-auth does not cover that route): key -> display
#: name. Keys are 12 lowercase alphanumeric characters; the first is
- #: generated at database bootstrap (see app.py), multiple keys are a
- #: future reservation (e.g. managed via a web interface).
+ #: generated at database bootstrap, more are managed in the editor
+ #: shell's lang tab (via /_api/settings).
translate_keys: dict[str, str] = {}
#: Wanted target languages for the translator service (presence-keys,
#: value always True). The dispatcher offers jobs only in the
diff --git a/pagerite/state.py b/pagerite/state.py
index b68b9dc..2f4af2b 100644
--- a/pagerite/state.py
+++ b/pagerite/state.py
@@ -342,6 +342,7 @@ def _seed(data: Data) -> None:
#: Translator key format: 12 lowercase alphanumeric characters — not
#: brute-forceable over a WebSocket handshake, still human-manageable.
+#: The editor's lang tab generates further keys in the same format.
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
@@ -349,10 +350,8 @@ _KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
def _translator_defaults(data: Data) -> None:
"""Translator defaults on database creation: the first service key and
the wanted target languages (Spanish and Chinese — English is the
- original language, never a translation target).
-
- Keys are a dict (key -> display name) with the future reservation that
- multiple keys could be managed (e.g. via a web interface)."""
+ original language, never a translation target). Further keys are
+ managed in the editor shell's lang tab."""
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
data.translate_keys[key] = "default"
data.translate_langs = {"es": True, "zh": True}