feat: add profile language preferences and refine toast, dropdown and history UI

This commit is contained in:
Mateusz Gruszczyński
2026-09-04 23:48:09 +02:00
parent f036240d5d
commit bf13587a71
42 changed files with 3971 additions and 357 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.55"
version = "0.2.64"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.55"
version = "0.2.64"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+23
View File
@@ -10,6 +10,29 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces.
The script creates `data/db` and `data/files`, refreshes the generated browser libraries, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead; the Docker build downloads the libraries in a separate stage.
## Interface languages
Frontend translations live in the top-level `lang/` directory and are embedded into the Rust binary during the build. There is no separate language manifest: every `*.json` file is discovered automatically and its `meta` block is used to populate the language selector in the profile.
```json
{
"meta": {
"code": "pl",
"name": "Polish",
"native_name": "Polski",
"locale": "pl-PL"
},
"translations": {
"common.save": "Zapisz"
}
}
```
`lang/en.json` is the required fallback and source language. Every language file must have the same translation keys as `en.json`; invalid metadata, non-string values, duplicate codes, or mismatched keys are rejected when the embedded language catalog is initialized. To add a language, copy `lang/en.json`, translate the values, and set `meta.code`, `meta.name`, `meta.native_name`, and `meta.locale`. The filename must match `meta.code` (for example `de.json` and `"code": "de"`).
The selected interface language is stored in the browser and can be changed from the profile without reloading the page. API responses remain in their original English form; the browser client translates known messages only when presenting them in the UI.
## Workspace features
- Real-time collaborative editing over WebSocket.
+35
View File
@@ -0,0 +1,35 @@
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=lang");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let lang_dir = manifest_dir.join("lang");
let mut files = fs::read_dir(&lang_dir)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", lang_dir.display()))
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect::<Vec<_>>();
files.sort();
if files.is_empty() {
panic!("lang directory must contain at least one .json language file");
}
let mut generated = String::from("pub const EMBEDDED_LANGUAGE_FILES: &[(&str, &str)] = &[\n");
for path in files {
let name = path
.file_name()
.and_then(|value| value.to_str())
.expect("language filename must be valid UTF-8");
println!("cargo:rerun-if-changed=lang/{name}");
generated.push_str(&format!(
" ({name:?}, include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/lang/{name}\"))),\n"
));
}
generated.push_str("];\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
fs::write(out_dir.join("languages.rs"), generated).expect("failed to generate embedded languages");
}
+925
View File
@@ -0,0 +1,925 @@
{
"meta": {
"code": "en",
"name": "English",
"native_name": "English",
"locale": "en-US"
},
"translations": {
"about.author": "Author",
"about.commercial": "Commercial use in proprietary applications (including by linuxiarz.pl Mateusz Gruszczyński) is not permitted under GPLv3 without an explicit Commercial License Agreement.",
"about.commercialTitle": "COMMERCIAL LICENSE NOTICE:",
"about.copy": "RustPad project information and licensing.",
"about.gpl": "This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License.",
"about.license": "Licence",
"about.repository": "Git repository",
"api.accountAlreadyConfirmed": "This account is already confirmed.",
"api.accountConfirmationDisabled": "Account confirmation is not enabled.",
"api.accountConfirmationRequiresSmtp": "Account confirmation requires SMTP configuration.",
"api.accountConfirmed": "Account confirmed. You can now log in.",
"api.accountCreateFailed": "Failed to create the account.",
"api.accountCreated": "Account created.",
"api.accountCreatedConfirm": "Account created. Check your e-mail and confirm the account before logging in.",
"api.accountInactive": "This account is inactive.",
"api.accountUpdateFailed": "The account could not be updated.",
"api.checkAddress": "Check the address or return to the home page.",
"api.confirmAccountFailed": "The account could not be confirmed.",
"api.confirmBeforeLogin": "Confirm the account using the link sent by e-mail before logging in.",
"api.confirmInvalid": "The confirmation link is invalid or has expired.",
"api.confirmationCooldown": "A new confirmation e-mail can be sent in {minutes} minute(s).",
"api.confirmationEmailBuildFailed": "Failed to build account confirmation e-mail.",
"api.confirmationResent": "A new confirmation e-mail has been sent.",
"api.confirmationSent": "A confirmation link has been sent to your e-mail address.",
"api.currentPasswordIncorrect": "The current password is incorrect.",
"api.databaseError": "Database error.",
"api.directoryProvisionFailed": "Failed to provision the organization account.",
"api.directorySyncFailed": "Failed to synchronize the organization account.",
"api.directoryUnavailable": "The organization directory is currently unavailable.",
"api.documentTooLarge": "The document is too large",
"api.emailAnother": "This e-mail address already belongs to another account. Ask an administrator to link it or enable LDAP_LINK_EXISTING_BY_EMAIL.",
"api.emailChanged": "E-mail address changed.",
"api.emailExists": "This e-mail address is already registered.",
"api.emailSendFailed": "The e-mail could not be sent. Check the SMTP configuration.",
"api.expirationFuture": "Expiration must be in the future.",
"api.fileAssetNotFound": "The requested application asset does not exist.",
"api.fileDeleteFailed": "Failed to delete the file",
"api.fileMissing": "No file provided",
"api.fileNotFound": "File not found",
"api.fileReadFailed": "Failed to read the file",
"api.fileSaveFailed": "Failed to save the file",
"api.fileStorageCheckFailed": "Failed to check file storage",
"api.fileTooLarge": "The file may be at most {max_mb} MB",
"api.guestUploadsDisabled": "File uploads are disabled for guests.",
"api.invalidAccessToken": "Invalid access token",
"api.invalidAuthorship": "Invalid authorship mode",
"api.invalidCredentials": "Invalid e-mail address or password.",
"api.invalidDirectoryCredentials": "Invalid organization login or password.",
"api.invalidEditorColor": "Invalid editor color",
"api.invalidEditorColorDot": "Invalid editor color.",
"api.invalidEditorFont": "Invalid editor font",
"api.invalidEditorFontSize": "Invalid editor font size",
"api.invalidEmail": "Enter a valid e-mail address.",
"api.invalidExpiration": "Invalid expiration date.",
"api.invalidForm": "Invalid form data",
"api.invalidPassword": "Invalid password",
"api.invalidRecipient": "Invalid recipient address.",
"api.invalidResourceKind": "Invalid resource kind",
"api.itemNotOwned": "This item does not belong to your account.",
"api.ldapDelete": "LDAP accounts cannot be deleted here.",
"api.ldapManaged": "E-mail and password are managed by LDAP/AD.",
"api.ldapNotConfigured": "LDAP authentication is not configured.",
"api.linkLabelLength": "Link label must contain at most 120 printable characters.",
"api.localRegistrationDisabled": "Local registration is disabled while LDAP authentication is enabled.",
"api.loginFirst": "Log in first.",
"api.loginSaveColors": "Log in to save note colors",
"api.loginSavePreferences": "Log in to save personal editor preferences",
"api.methodNotAllowed": "Method not allowed",
"api.missingEmailPayload": "Missing e-mail change payload.",
"api.missingKind": "Missing kind.",
"api.missingSlug": "Missing slug.",
"api.nameAddress": "The name cannot be converted into a valid address",
"api.nameLength": "{field} must contain between 1 and {max} characters",
"api.nicknameInvalid": "Nickname contains invalid characters.",
"api.nicknameLength": "Nickname must contain 1 to 40 characters.",
"api.nicknameLogin": "This nickname is registered. Log in to use it.",
"api.nicknameOwned": "This nickname belongs to another account.",
"api.nicknameOwnedSession": "This nickname belongs to another account or the session expired.",
"api.nicknameRegistered": "This nickname is already registered.",
"api.noActiveAccounts": "No active registered account for: {accounts}",
"api.noSettings": "No editor settings were provided",
"api.notLoggedIn": "Not logged in.",
"api.noteDeleted": "This note does not exist or has been deleted.",
"api.noteFilesDeleteFailed": "Failed to delete note files",
"api.noteHasPassword": "This note already has a password.",
"api.noteNotFound": "Note not found",
"api.noteProtectedDelete": "This note is protected. Only its owner can delete it.",
"api.noteProtectedFiles": "This note is protected. Only its owner can delete files.",
"api.onlyOwnerFiles": "Only the note owner can delete files",
"api.onlyOwnerNotePassword": "Only the note owner can set its password.",
"api.onlyOwnerWorkspacePassword": "Only the workspace owner can set its password.",
"api.ownerAuthorship": "Only the resource owner can change authorship settings",
"api.padFilesDeleteFailed": "Failed to delete pad files.",
"api.pageLinkInvalid": "The link is invalid or the published page has been removed.",
"api.pageNotFound": "Page not found",
"api.pageUnavailablePassword": "This published page is unavailable until a resource password is set.",
"api.pageUnavailableWorkspacePassword": "This published page is unavailable until a workspace password is set.",
"api.passwordLength": "Password must contain 8 to 128 characters.",
"api.passwordLengthBetween": "Password must contain between 8 and 128 characters",
"api.passwordRequired": "Password is required.",
"api.passwordRequiredIncorrect": "Password required or incorrect.",
"api.passwordSecureFailed": "Failed to secure the password.",
"api.permission": "Permission must be ro or rw.",
"api.profileUpdated": "Profile updated.",
"api.profileUpdatedConfirmEmail": "Profile updated. Confirm the new e-mail address using the link sent to it.",
"api.publishedNotFound": "Published page not found",
"api.publishedProtected": "This published page is protected.",
"api.rateLogin": "Too many login attempts. Try again in {seconds} seconds.",
"api.ratePassword": "Too many password attempts. Try again in {seconds} seconds.",
"api.rateReset": "Too many password reset requests. Try again in {seconds} seconds.",
"api.rateResetAttempt": "Too many reset attempts. Try again in {seconds} seconds.",
"api.rateShare": "Too many share-link attempts. Try again in {seconds} seconds.",
"api.rateShareSessions": "Too many share-link sessions. Try again in {seconds} seconds.",
"api.readOnly": "Read-only access.",
"api.recipientsRange": "Enter between 1 and 100 e-mail addresses or user names.",
"api.registrationDisabled": "Registration is disabled.",
"api.registrationEmailBuildFailed": "Failed to build registration e-mail.",
"api.resetEmailBuildFailed": "Failed to build reset e-mail.",
"api.resetInvalid": "The reset link is invalid or has expired.",
"api.resetNotConfigured": "Password reset is not configured on this server.",
"api.resetSent": "If the account exists, a reset link has been sent.",
"api.restoreFailed": "The selected revision could not be restored",
"api.revisionNotFound": "Revision not found",
"api.serverError": "Server error",
"api.sessionExpired": "Your session has expired.",
"api.sessionExpiredLogin": "Your session has expired. Log in again.",
"api.setResourcePasswordFirst": "Set a resource password before enabling the published page.",
"api.setWorkspacePasswordFirst": "Set a workspace password before enabling the published page.",
"api.shareConfirmationRequiresSmtp": "Share confirmation requires SMTP configuration.",
"api.shareEmailBuildFailed": "Failed to build sharing invitation e-mail.",
"api.shareInviteInvalid": "The sharing invitation is invalid or has expired.",
"api.shareLinkMissing": "Share link was not found or is already revoked.",
"api.shareLinksDisabled": "Direct share links are disabled for public resources without a password.",
"api.smtpFromInvalid": "Invalid SMTP_FROM.",
"api.smtpInvalid": "Invalid SMTP configuration.",
"api.smtpNotConfigured": "SMTP is not configured.",
"api.taskUpdateFailed": "The task could not be updated",
"api.taskUpdatesDisabled": "Task updates are disabled for this page",
"api.unconfirmedMissing": "No unconfirmed account exists for this e-mail address.",
"api.uniqueAddressFailed": "Failed to create a unique address",
"api.unknownAccountAction": "Unknown account action.",
"api.unknownResourceType": "Unknown resource type.",
"api.unknownLanguage": "Unknown interface language.",
"api.unknownTheme": "Unknown interface theme.",
"api.workspaceFilesDeleteFailed": "Failed to delete workspace files.",
"api.workspaceHasPassword": "This workspace already has a password.",
"api.workspaceNotFound": "Workspace not found",
"api.writeRequiredPrefs": "Read and write access is required to save editor preferences",
"auth.accountAction.ask": "Confirm the requested account action. If you did not request it, cancel and ignore the e-mail.",
"auth.accountAction.confirm": "Confirm action",
"auth.accountAction.done": "Account action confirmed",
"auth.accountAction.failed": "Account action failed",
"auth.accountAction.title": "Confirm account action",
"auth.accountConfirmationFailed": "Account confirmation failed",
"auth.accountConfirmed": "Account confirmed",
"auth.backLogin": "Back to log in",
"auth.confirmAccount.action": "Activate account",
"auth.confirmAccount.message": "Confirm activation of this RustPad account.",
"auth.confirmAccount.title": "Confirm account",
"auth.confirmation.resend": "Resend confirmation e-mail",
"auth.confirmation.resendFailed": "Could not resend confirmation",
"auth.confirmation.sent": "Confirmation e-mail sent",
"auth.createAccount": "Create an account",
"auth.delete.confirm": "Send an e-mail link to permanently delete this account?",
"auth.delete.currentPassword": "Enter the current password first.",
"auth.delete.requestFailed": "Could not request account deletion",
"auth.email.directory": "E-mail / LDAP or AD Username",
"auth.email.directory.placeholder": "you@example.com or username",
"auth.email.placeholder": "you@example.com",
"auth.error.login": "Sign-in failed",
"auth.error.registration": "Registration failed",
"auth.error.reset": "Password reset failed",
"auth.error.retry": "Please try again.",
"auth.forgot": "Forgot password?",
"auth.guest.success": "Continuing as {nickname}.",
"auth.guest.title": "Guest session",
"auth.inbox": "Check your inbox",
"auth.invitation.accept": "Accept invitation",
"auth.invitation.ask": "Accept this sharing invitation?",
"auth.invitation.failed": "Invitation failed",
"auth.invitation.title": "Sharing invitation",
"auth.login": "Log in",
"auth.login.success": "Signed in as {nickname}.",
"auth.login.title": "Signed in",
"auth.logout.message": "You have been signed out.",
"auth.logout.title": "Signed out",
"auth.newPassword.action": "Change password",
"auth.newPassword.copy": "The password must contain at least 8 characters.",
"auth.newPassword.title": "Set a new password",
"auth.nickname.placeholder": "Your nickname",
"auth.password.changed": "Password changed",
"auth.password.changed.copy": "Password changed. The reset link has been used and cannot be opened again.",
"auth.password.min": "At least 8 characters",
"auth.passwordChange.currentRequired": "Enter the current password to change e-mail or password.",
"auth.register": "Register",
"auth.register.already": "Already registered? Log in",
"auth.register.create": "Create account",
"auth.reset": "Reset password",
"auth.reset.copy": "Enter the e-mail address assigned to your local account.",
"auth.reset.send": "Send reset link",
"auth.reset.sent": "Reset link sent",
"auth.session.expired.message": "Your session expired. Sign in again to continue with account-only actions.",
"auth.session.expired.title": "Session expired",
"characters.zero": "0 characters",
"client.accessDenied": "Access denied.",
"client.authRequired": "Authentication required.",
"client.badGateway": "The server returned an invalid response. Try again.",
"client.conflict": "The requested change conflicts with existing data.",
"client.copyFailed": "Failed to copy the link",
"client.gatewayTimeout": "The server took too long to respond. Try again.",
"client.invalidRequest": "Invalid request.",
"client.offline": "Your device is offline.",
"client.operationNotAllowed": "This operation is not allowed.",
"client.requestFailed": "Request failed ({status}).",
"client.requestTimedOut": "The request timed out. Try again.",
"client.resourceNotFound": "The requested resource was not found.",
"client.serverLater": "Server error. Try again later.",
"client.timedOut": "Timed out",
"client.tooEarly": "The request was sent too early. Try again.",
"client.tooMany": "Too many requests. Try again later.",
"client.unavailable": "Service temporarily unavailable.",
"client.uploadCancelled": "Upload cancelled.",
"client.uploadConnection": "Upload failed before the server returned a response. Check the connection and try again.",
"client.uploadInterrupted": "Upload interrupted. Try again.",
"client.uploadLimit": "The selected file exceeds the allowed upload limit.",
"client.uploadProcessingTimeout": "The file was sent, but the server did not finish processing it. Try again.",
"client.uploadProxy": "Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.",
"client.uploadSize": "The selected file is {size}. The upload limit is {limit}.",
"client.uploadStalled": "Upload stopped making progress. Check the connection and try again.",
"common.about": "About",
"common.back": "Back",
"common.backHome": "Back to home",
"common.cancel": "Cancel",
"common.closeDialog": "Close dialog",
"common.closeFiles": "Close files",
"common.confirm": "Confirm",
"common.continue": "Continue",
"common.create": "Create",
"common.currentLocation": "Current location",
"common.delete": "Delete",
"common.dismiss": "Dismiss",
"common.done": "Done",
"common.edit": "Edit",
"common.email": "E-mail",
"common.files": "Files",
"common.hide": "Hide",
"common.history": "History",
"common.information": "Information",
"common.language": "Language",
"common.loading": "Loading…",
"common.more": "More",
"common.next": "Next",
"common.nickname": "Nickname",
"common.note": "Note",
"common.notes": "Notes",
"common.ok": "OK",
"common.open": "Open",
"common.password": "Password",
"common.perPage": "Per page",
"common.preview": "Preview",
"common.previous": "Previous",
"common.private": "Private",
"common.profile": "Profile",
"common.public": "Public",
"common.remove": "Remove",
"common.retry": "Retry",
"common.save": "Save",
"common.search": "Search",
"common.set": "Set",
"common.share": "Share",
"common.show": "Show",
"common.success": "Success",
"common.system": "System",
"common.table": "Table",
"common.update": "Update",
"common.view": "View",
"common.warning": "Warning",
"common.workspace": "Workspace",
"diag.client": "Client",
"diag.jitter": "Jitter",
"diag.lastEvent": "Last event",
"diag.latency": "Latency",
"diag.quality": "Quality",
"diag.reconnects": "Reconnects",
"diag.title": "Connection diagnostics",
"diag.uptime": "Uptime",
"diagnostics.buffered": " · {amount} buffered",
"diagnostics.closed": "Closed {code}",
"diagnostics.closedReason": "Closed {code}: {reason}",
"diagnostics.lastUptime": "last {duration}",
"diagnostics.latency": "{current} ms · avg {average} ms · {minimum}{maximum} ms",
"diagnostics.messageAt": "Message {time}",
"diagnostics.quality.degraded": "Degraded",
"diagnostics.quality.excellent": "Excellent",
"diagnostics.quality.good": "Good",
"diagnostics.quality.poor": "Poor",
"diagnostics.reconnectAttempt": "{count} · attempt {attempt}",
"diagnostics.state.closed": "Closed",
"diagnostics.state.closing": "Closing",
"diagnostics.state.connecting": "Connecting",
"diagnostics.state.measuring": "Measuring",
"diagnostics.state.open": "Connected",
"diagnostics.state.reconnecting": "Reconnecting",
"diagnostics.state.waiting": "Waiting",
"diagnostics.traffic": "{received} received · {sent} sent",
"diagnostics.visibility.hidden": "hidden",
"diagnostics.visibility.prerender": "prerender",
"diagnostics.visibility.visible": "visible",
"diagnostics.waitHeartbeat": "Waiting for heartbeat",
"diagnostics.waitServer": "Waiting for server data",
"editor.accessChecking": "Access: checking…",
"editor.authorship": "Authorship display",
"editor.authorshipTitle": "Show or hide author coloring",
"editor.autosave": "Changes are saved automatically",
"editor.bold": "Bold",
"editor.bulletButton": "• List",
"editor.bulletList": "Bullet list",
"editor.changeColor": "Change your color",
"editor.changeEditorColor": "Change editor color",
"editor.changesSynced": "Changes synchronized",
"editor.characters": "{count} characters",
"editor.chat": "Chat",
"editor.chatEphemeral": "Messages disappear after disconnect",
"editor.chatLabel": "Chat message",
"editor.chatPlaceholder": "Write a message…",
"editor.closeNavigation": "Close navigation menu",
"editor.codeBlock": "Code block",
"editor.codeBlockLines": "Code block with line numbers",
"editor.collapsible": "Collapsible section",
"editor.colorSaveFailed": "Could not save editor color",
"editor.colorSaved": "This color will be used for the current tab.",
"editor.colorSavedTitle": "Editor color saved",
"editor.colorsOff": "Colors off",
"editor.colorsOn": "Colors on",
"editor.columnLabel": "Editor",
"editor.compact": "Compact",
"editor.compactView": "Compact view",
"editor.connectionError": "Editor connection error",
"editor.connectionInterrupted": "Connection interrupted",
"editor.copyOpen": "Copy its link and open it in a new tab",
"editor.copyThisLink": "Copy this link",
"editor.currentLocation": "Current location",
"editor.dangerAlert": "Danger alert",
"editor.definition": "Definition",
"editor.dragResize": "Drag to resize",
"editor.editableTasks": "Editable tasks",
"editor.editableTasksTitle": "Allow visitors to update task checkboxes on the published page",
"editor.editorColor": "Editor color",
"editor.editorLines": "Editor lines",
"editor.editorOptions": "Editor options",
"editor.emoji": "😀 Emoji",
"editor.emojiCategories": "Emoji categories",
"editor.emojiEmpty": "No emoji found.",
"editor.emojiLabel": "Emoji",
"editor.emojiSearch": "Search emoji",
"editor.emojiSearchPlaceholder": "Search emoji…",
"editor.emptyNote": "Empty note",
"editor.enablePage": "Enable Page",
"editor.enablePageTitle": "Enable or disable the published page",
"editor.extendedMarkdown": "Extended Markdown",
"editor.failedMermaid": "Failed to load Mermaid.",
"editor.fileCount": "{count} files",
"editor.font": "Font",
"editor.footnote": "Footnote",
"editor.format.alertContent": "Alert content",
"editor.format.code": "code",
"editor.format.column1": "Column 1",
"editor.format.column2": "Column 2",
"editor.format.content": "Content",
"editor.format.definition": "Definition",
"editor.format.description": "description",
"editor.format.detailsSummary": "Click me",
"editor.format.diagram": "graph TD\n A[Start] --> B[End]",
"editor.format.footnote": "Footnote text",
"editor.format.important": "important",
"editor.format.term": "Term",
"editor.format.text": "text",
"editor.format.textWithFootnote": "Text with footnote",
"editor.format.value": "value",
"editor.full": "Full",
"editor.globalAuthorship": "Global authorship settings",
"editor.globalProfileColor": "Global profile color",
"editor.guest": "Guest",
"editor.headings": "Headings H1H4",
"editor.hideNavigation": "Hide navigation bar",
"editor.hideToolbar": "Hide editor toolbar",
"editor.highlight": "Highlight",
"editor.historyCopy": "Author, time, and version preview",
"editor.historyEmpty": "No history yet.",
"editor.historyLoadFailed": "Could not load version history",
"editor.historyTitle": "Change history",
"editor.home": "RustPad home",
"editor.horizontalRule": "Horizontal rule",
"editor.imageSizeError": "Enter a width and height between 1 and 10,000 px.",
"editor.imageSizeTitle": "Invalid image size",
"editor.inRoom": "In this room",
"editor.indent": "Indent by 2 spaces",
"editor.infoAlert": "Info alert",
"editor.inlineCode": "Inline code",
"editor.insertEmoji": "Insert emoji",
"editor.italic": "Italic",
"editor.keyboardShortcuts": "Keyboard shortcuts",
"editor.layout.auto": "Auto",
"editor.layout.center": "Center",
"editor.layout.left": "Left",
"editor.layout.natural": "Natural",
"editor.layout.right": "Right",
"editor.lineCopyFailed": "Could not copy line link",
"editor.link": "Link",
"editor.localRecovered": "Local changes recovered",
"editor.markdown": "Markdown",
"editor.markdownPreview": "Markdown preview",
"editor.mermaid": "Mermaid diagram",
"editor.minimum8": "Minimum 8 characters.",
"editor.missedMerged": "A missed update was merged with your local edits.",
"editor.mono": "Mono",
"editor.moveQuickActions": "Move quick actions",
"editor.noActiveUsers": "No active users",
"editor.noMessages": "No messages yet",
"editor.noteColorOverride": "Note color override",
"editor.noteFiles": "Note files",
"editor.noteFilesCopy": "Copy a direct link or ready Markdown/Alias code.",
"editor.noteLinkCopied": "Note link copied to the clipboard.",
"editor.noteLinkCopyFailed": "Could not copy note link",
"editor.noteReadOnly": "This note is read only. Enter the password or ask the owner to grant write access.",
"editor.noteUnlockFailed": "Could not unlock note",
"editor.noteUnlocked": "Editing access has been unlocked.",
"editor.noteUnlockedTitle": "Note unlocked",
"editor.notificationChat": "{sender} wrote in RustPad",
"editor.numberedButton": "1. List",
"editor.numberedList": "Numbered list",
"editor.numberedListShortcut": "Numbered list · Ctrl/Cmd+Shift+7",
"editor.openChat": "Open chat",
"editor.openEditorOptions": "Open editor options",
"editor.openFiles": "Open files",
"editor.openNavigation": "Open navigation menu",
"editor.openPublishedFailed": "Could not open published page",
"editor.outdent": "Remove indentation",
"editor.overrideColor": "Override color for this note",
"editor.ownerAuthorshipOnly": "Only the owner can change authorship settings",
"editor.pageAccessCopy": "Access to page options requires a password-protected note.",
"editor.pageDisabled": "Published page disabled",
"editor.pageLabel": "Page",
"editor.pageLinkCopied": "Published page link copied to the clipboard.",
"editor.pageLinkTitle": "Page link copied",
"editor.pageLoadFailed": "Page could not be loaded",
"editor.pageOptions": "Page options",
"editor.participants": "Participants",
"editor.passwordRequired": "Password required",
"editor.passwordSetContinue": "A password was set for this note. Enter it to continue.",
"editor.passwordSetEditing": "A password was set for this note. Enter it to continue editing.",
"editor.passwordSetFailed": "Could not set password",
"editor.pendingMerged": "Pending edits were merged after reconnecting.",
"editor.previewLines": "Preview lines",
"editor.previewMedia": "Preview (media / mermaid / markdown)",
"editor.previewText": "Text preview",
"editor.profileColorRestoreFailed": "Could not restore profile color",
"editor.profileColorRestored": "The global profile color is active again.",
"editor.profileColorRestoredTitle": "Profile color restored",
"editor.protectedResource": "Protected {resource}",
"editor.protectionEnabled": "Password protection is enabled. Publishing options are now available.",
"editor.protectionEnabledTitle": "Protection enabled",
"editor.publicProtected": "Password protection is required again for the published page.",
"editor.publicProtectedTitle": "Public page protected",
"editor.publicProtectionFailed": "Could not update page protection",
"editor.publicUnprotected": "The published page can now be opened without the resource password.",
"editor.publicUnprotectedTitle": "Public page unprotected",
"editor.publishingDisabled": "Publishing disabled",
"editor.publishingEnabled": "Publishing enabled",
"editor.publishingFailed": "Could not update publishing",
"editor.publishingOff": "The published page is no longer available.",
"editor.publishingOn": "The published page is now available.",
"editor.quickActions": "Quick editor actions",
"editor.quote": "Quote",
"editor.readOnly": "Read only",
"editor.readOnlyTitle": "Read-only access",
"editor.reconnectAuto": "Trying to reconnect automatically.",
"editor.recoveryFailed": "Recovery failed",
"editor.recoveryTooLarge": "The recovery copy is larger than the document size limit.",
"editor.redo": "Redo",
"editor.resizeImage": "Resize image",
"editor.restore": "Restore",
"editor.roomChat": "Room chat",
"editor.saveFailed": "Save failed",
"editor.saving": "Saving…",
"editor.send": "Send",
"editor.serif": "Serif",
"editor.settingsFailed": "Could not save editor settings",
"editor.shortcuts": "Shortcuts",
"editor.shortcutsOs": "Use Ctrl on Windows/Linux or Cmd on macOS.",
"editor.showNavigation": "Show navigation bar",
"editor.showToolbar": "Show editor toolbar",
"editor.simple": "Simple",
"editor.size": "Size",
"editor.split": "Split",
"editor.startWriting": "Start writing…",
"editor.strike": "Strikethrough",
"editor.subscript": "Subscript",
"editor.successAlert": "Success alert",
"editor.superscript": "Superscript",
"editor.syncConflict": "A sync conflict occurred. Your local changes were preserved in a recovery block.",
"editor.syncError": "Synchronization error",
"editor.syncResync": "Resynchronizing…",
"editor.taskButton": "☑ Task",
"editor.taskList": "Task list",
"editor.taskListShortcut": "Task list · Ctrl/Cmd+Shift+9",
"editor.tasksFailed": "Could not update task permissions",
"editor.tasksOff": "Visitors can no longer update public tasks.",
"editor.tasksOffTitle": "Task updates disabled",
"editor.tasksOn": "Visitors can now update public tasks.",
"editor.tasksOnTitle": "Task updates enabled",
"editor.toc": "Table of contents",
"editor.undo": "Undo last change",
"editor.unknownAuthor": "Unknown author",
"editor.unprotectPage": "Unprotect Page",
"editor.unprotectPageTitle": "Allow the published page to open without the resource password or private access",
"editor.upload": "Upload",
"editor.uploadFile": "Upload file",
"editor.uploadTitle": "Upload a file",
"editor.useGlobalColor": "Use global profile color",
"editor.user": "{count} user",
"editor.users": "{count} users",
"editor.versionRestored": "The selected revision is now the current version.",
"editor.versionRestoredTitle": "Version restored",
"editor.view": "Editor view",
"editor.waitConnection": "Waiting for connection…",
"editor.warningAlert": "Warning alert",
"editor.websocketDiagnostics": "WebSocket connection diagnostics",
"editor.words": "{count} words",
"emoji.group.activities": "Activities",
"emoji.group.animalsNature": "Animals & Nature",
"emoji.group.flags": "Flags",
"emoji.group.foodDrink": "Food & Drink",
"emoji.group.objects": "Objects",
"emoji.group.peopleBody": "People & Body",
"emoji.group.smileysEmotion": "Smileys & Emotion",
"emoji.group.symbols": "Symbols",
"emoji.group.travelPlaces": "Travel & Places",
"emoji.itemLabel": "Emoji {emoji}",
"emoji.recentGroup": "Recently Used",
"emoji.recentItem": "Recent emoji",
"error.fileNotFoundTitle": "File not found · RustPad",
"error.home": "Home page",
"error.internal.message": "The page could not be loaded. Please try again shortly.",
"error.methodNotAllowedTitle": "Method not allowed · RustPad",
"error.methodUnsupported": "This address does not support the requested operation.",
"error.notFoundFallback": "404 Not Found",
"error.noteNotFoundTitle": "Note not found · RustPad",
"error.pageNotFoundTitle": "Page not found · RustPad",
"error.publishedNotFoundTitle": "Published page not found · RustPad",
"error.serverTitle": "Server error · RustPad",
"error.staticMissing": "The requested static file does not exist.",
"files.addDownload": "Add download",
"files.addPlayer": "Add player",
"files.addToNote": "Add to note",
"files.addedTitle": "Added to note",
"files.alias": "Alias",
"files.codeCopied": "Generated file code copied to the clipboard.",
"files.codeCopyFailed": "Could not copy file code",
"files.delete": "Delete file",
"files.deleteAsk": "Delete file \"{name}\" permanently?",
"files.deleteFailed": "Could not delete file",
"files.deleted": "The file was permanently deleted.",
"files.deletedTitle": "File deleted",
"files.download": "Download {label}",
"files.downloadCode": "Download code",
"files.empty": "No files uploaded.",
"files.generatedCode": "Generated file code",
"files.imageAlignment": "Image alignment",
"files.imageHeight": "Image height",
"files.imageLayout": "Image layout",
"files.imageWidth": "Image width",
"files.inNote": "in note",
"files.inserted": "The file reference was inserted into the note.",
"files.loadFailed": "Could not load files",
"files.ownerDelete": "Only the note owner can delete files.",
"files.pasteUnavailable": "Paste upload unavailable",
"files.pasteUnavailable.message": "Read-write access and file uploads are required to paste files.",
"files.playerCode": "Player code",
"files.prepareImageFailed": "Could not prepare image",
"files.previewMedia": "Playback is unavailable.",
"files.readWriteDelete": "Read-write access is required to delete files.",
"files.removedContent": "removed from content",
"files.summary.few": "{count} files · {size}",
"files.summary.many": "{count} files · {size}",
"files.summary.one": "{count} file · {size}",
"files.summary.other": "{count} files · {size}",
"files.uploadUnavailable": "Upload unavailable",
"files.uploadUnavailable.message": "Read-write access and file uploads are required to upload files.",
"files.video.download": "Download link",
"files.video.downloadHelp": "Insert a link that downloads the original file.",
"files.video.embedded": "Embedded player",
"files.video.embeddedHelp": "Play the video directly in the note.",
"files.video.question": "How should it be added?",
"files.video.title": "Video file",
"files.zero": "0 files",
"home.hero.copy": "Create a standalone note or organize multiple notes in a workspace.",
"home.hero.title": "Write. Share. Collaborate.",
"home.login": "Log in",
"home.logout": "Log out",
"home.myNotes": "My notes",
"home.note.copy": "A single document with its own link.",
"home.note.create": "Create note",
"home.note.name": "Note name",
"home.note.password": "Note password",
"home.note.placeholder": "Meeting notes",
"home.password.optional": "optional, min. 8 characters",
"home.register": "Register",
"home.workspace.copy": "A workspace containing multiple notes.",
"home.workspace.create": "Create workspace",
"home.workspace.name": "Workspace name",
"home.workspace.password": "Workspace password",
"home.workspace.placeholder": "My project",
"i18n.loadFailed": "Could not load language resource ({status})",
"identity.backNickname": "Back to nickname",
"identity.copy": "Use a free nickname without an account, or register it to reserve it.",
"identity.guest": "Continue as guest",
"identity.loginContinue": "Log in and continue",
"identity.logoutSaved": "Log out saved account",
"identity.placeholder": "Name or nickname",
"identity.registerContinue": "Register and continue",
"identity.title": "What should we call you?",
"identity.workspace.copy": "Log in, or choose a free nickname to continue as a guest.",
"image.adjust.help": "Keep the whole image or choose a crop, then select output size.",
"image.adjust.title": "Adjust image",
"image.crop": "Crop",
"image.free": "Free",
"image.maxSize": "Max size",
"image.original": "Original",
"image.square": "Square",
"image.use": "Use image",
"image.whole": "Whole image",
"image.zoom": "Zoom",
"markdown.backReference": "Back to reference",
"markdown.details": "Details",
"markdown.download": "Download {label}",
"markdown.open": "Open {title}",
"markdown.playbackUnavailable": "Playback is unavailable.",
"markdown.toc": "Table of contents",
"markdown.youtubeVideo": "YouTube video",
"pagination.items": "Page {page} of {pages} · {count} items",
"pagination.notes": "Page {page} of {pages} · {count} notes",
"permission.readOnly": "Read only",
"permission.readWrite": "Read and write",
"profile.copy.directory": "Directory account details are read-only. You can change the nickname, editor color, interface theme, and language.",
"profile.copy.local": "Manage your local RustPad account.",
"profile.currentEmail": "Current e-mail",
"profile.currentPassword": "Current password",
"profile.delete": "Delete account",
"profile.editorColor": "Editor color",
"profile.editorColor.choose": "Choose your editor color",
"profile.fullName": "Full name",
"profile.keepCurrent": "Leave empty to keep current",
"profile.language.changed.message": "The interface language has been changed.",
"profile.language.changed.title": "Language changed",
"profile.language.failed": "Could not change language",
"profile.language.help": "Saved with your profile and applied after you save these settings.",
"profile.newEmail": "New e-mail",
"profile.newPassword": "New password",
"profile.noNicknameSuggestion": "No automatic nickname suggestion is available.",
"profile.organization": "Organization",
"profile.passwordRequired": "Required for e-mail or password changes",
"profile.save": "Save profile",
"profile.saved": "Your profile settings were saved.",
"profile.signedAs": "Signed as {identity}",
"profile.suggestedNickname": "Suggested nickname: {nickname}",
"profile.theme": "Interface theme",
"profile.updateFailed": "Could not update profile",
"profile.updated": "Profile updated",
"proper.arial": "Arial",
"proper.emailExample": "you@example.com",
"proper.georgia": "Georgia",
"proper.markdown": "Markdown",
"proper.mermaid": "Mermaid",
"proper.rustpad": "RustPad",
"public.copyFailed": "Could not copy link",
"public.copyHeadingAria": "Copy link to heading on line {line}",
"public.copyHeadingTitle": "Copy link to this heading (line {line})",
"public.copyLineAria": "Copy link to line {line}",
"public.copyLink": "Copy link",
"public.fullWidth": "Full width",
"public.headingCopied": "Heading link copied to the clipboard.",
"public.lineCopied": "Link to line {line} copied to the clipboard.",
"public.lineLinks": "Line links",
"public.lineNumbers": "Line numbers",
"public.linkCopiedTitle": "Link copied",
"public.loadFailed": "Could not load published page",
"public.mermaidFailed": "Failed to load Mermaid.",
"public.open": "Open page",
"public.pageLinkCopied": "Published page link copied to the clipboard.",
"public.pageLinkCopyFailed": "Could not copy page link",
"public.passwordAuthorized": "Sign in with an authorized account or enter the resource password.",
"public.passwordCorrect": "Enter the correct password.",
"public.protected": "Protected page",
"public.protected.copy": "Enter the note password or sign in with an account that has access.",
"public.task.disabled": "Task updates are disabled by the owner",
"public.task.update": "Update this task",
"public.taskComplete": "Task marked as complete.",
"public.taskIncomplete": "Task marked as incomplete.",
"public.taskUpdateFailed": "Could not update task",
"public.taskUpdated": "Task updated",
"public.title": "Published note · RustPad",
"public.unlockFailed": "Could not unlock page",
"public.unlocked": "The published page has been unlocked.",
"public.unlockedTitle": "Page unlocked",
"public.updated": "Updated: {date}",
"public.updatedTasks": "Updated: {date} · tasks can be updated",
"resource.changePassword": "Change password",
"resource.deleteAsk": "Delete “{title}” permanently?",
"resource.deleteFailed": "Could not delete item",
"resource.deleted": "{kind} deleted.",
"resource.makePrivate": "Make private",
"resource.makePublic": "Make public",
"resource.minimumPassword": "Minimum 8 characters",
"resource.passwordEnabled": "Password protection is enabled for {title}.",
"resource.passwordMinError": "Password must contain at least 8 characters.",
"resource.passwordProtectedMeta": "password protected",
"resource.passwordRemoveAsk": "Remove password protection from “{title}”?",
"resource.passwordRemoveCopy": "Anyone with the public link will be able to open it without a password.",
"resource.passwordRemoveFailed": "Could not remove password",
"resource.passwordRemoved": "Password protection was removed from {title}.",
"resource.passwordRemovedTitle": "Password removed",
"resource.passwordSaveFailed": "Could not save password",
"resource.passwordSaved": "Password saved",
"resource.passwordSettings": "Password settings",
"resource.passwordShort": "Password…",
"resource.passwordVeryShort": "Pass…",
"resource.privateMeta": "private",
"resource.removePassword": "Remove password",
"resource.removed": "Item removed",
"resource.setPassword": "Set password",
"resource.visibilityChanged": "{title} is now {visibility}.",
"resource.visibilityFailed": "Could not update visibility",
"resource.visibilityTitle": "Visibility updated",
"resources.accessRules": "Access rules:",
"resources.accessRules.copy": "Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.",
"resources.anotherUser": "another user",
"resources.copy": "Items created while signed in are assigned to your account.",
"resources.createWorkspaceFailed": "Could not create workspace",
"resources.empty": "No assigned items yet.",
"resources.loadFailed": "Could not load your items",
"resources.loading": "Loading…",
"resources.pagination": "Resources pagination",
"resources.search": "Search notes and workspaces",
"resources.search.placeholder": "Search notes and workspaces…",
"resources.sharedBy": "Shared by {user}",
"resources.title": "My notes and workspaces",
"share.accessGranted": "Access granted.",
"share.accessRemoved": "Access removed.",
"share.attention": "Sharing needs attention",
"share.close": "Close share dialog",
"share.copy": "Copy",
"share.createLink": "Create link",
"share.created": "Created {date}",
"share.directDisabled": "Direct links are disabled while this item is public and has no password. Existing links are preserved and become active again after you make it private or add a password.",
"share.directLinks": "Direct links",
"share.directLinks.copy": "Create links for people without an account or manage existing links.",
"share.emailOrUsername": "E-mail or username",
"share.expires": "Expires {date}",
"share.failed": "Sharing action failed",
"share.grant": "Grant access",
"share.hours": "hours",
"share.individualLinks": "Individual links",
"share.invitationSent": "Invitation sent. Access will appear after the recipient accepts it.",
"share.label": "Label",
"share.linkCopied": "Link copied.",
"share.linkCreatedCopied": "Link created, displayed below and copied.",
"share.linkCreatedCopyFailed": "Link created and displayed below. Automatic copying failed; use the Copy button.",
"share.linkId": "Link ID #{id} · Created {created} · {expires}",
"share.linkNotStored": "The full address is not stored. Use the label or link ID #{id} to identify it.",
"share.linkPermission": "Link permission",
"share.linkRevoked": "Link revoked.",
"share.linkUpdated": "Link updated.",
"share.linkVisibleOnce": "The full address remains visible until this dialog is closed.",
"share.manage": "Manage registered users and direct access links for {title}.",
"share.manageFor": "Manage registered users and direct access links for",
"share.never": "Never",
"share.neverExpires": "Never expires",
"share.newLink": "New share link",
"share.newValidityHours": "New validity in hours",
"share.noActiveLinks": "No active links.",
"share.noUsers": "No users have access.",
"share.people": "People with access",
"share.people.copy": "Invite existing RustPad users by e-mail or account/directory username.",
"share.permission": "Permission",
"share.revoke": "Revoke",
"share.title": "Share access",
"share.unlabeled": "Unlabeled link",
"share.updated": "Sharing updated",
"share.validFor": "Valid for",
"share.validityHours": "Validity in hours",
"share.validityRange": "Enter a validity between 1 and 87600 hours.",
"status.accessFull": "Access: full",
"status.accessReadOnly": "Access: read only",
"status.applicationError": "Application error",
"status.connecting": "Connecting…",
"status.heartbeatTimeout": "heartbeat timeout",
"status.inactiveTimeout": "The connection stopped responding after the tab was inactive.",
"status.measuring": "Measuring",
"status.networkRestored": "Network connection restored.",
"status.offline": "Offline",
"status.online": "Online",
"status.openFailed": "The live connection could not be opened.",
"status.openingConnection": "Opening the live connection.",
"status.reconnecting": "Reconnecting…",
"status.reestablishing": "Re-establishing the live connection.",
"status.serverInterrupted": "The server connection was interrupted.",
"status.waiting": "Waiting",
"theme.dark": "Dark",
"theme.dark.copy": "Current RustPad appearance",
"theme.light": "Light",
"theme.light.copy": "Warm cream surfaces and dark text",
"toast.close": "Close notification",
"toast.region.label": "Notifications",
"toast.title.danger": "Something went wrong",
"toast.title.info": "Information",
"toast.title.success": "Success",
"toast.title.warning": "Warning",
"upload.error.generic": "The file could not be uploaded.",
"upload.progress.label": "File upload progress",
"upload.status.complete": "Complete",
"upload.status.preparing": "Preparing...",
"upload.status.processing": "Processing...",
"upload.status.starting": "Starting...",
"upload.title.complete": "File uploaded",
"upload.title.failed": "Upload failed",
"upload.title.uploading": "Uploading file",
"users.zero": "0 users",
"vendor.globalMissing": "{name} did not register a browser global.",
"vendor.loadFailed": "Failed to load {path}.",
"words.zero": "0 words",
"workspace.aria": "RustPad Workspace",
"workspace.cards": "Cards",
"workspace.connectionError": "Workspace connection error",
"workspace.createNoteFailed": "Could not create note",
"workspace.createdBy": "Created by: {author}",
"workspace.deleteNoteAsk": "Delete note “{title}”? This cannot be undone.",
"workspace.deleteNoteFailed": "Could not delete note",
"workspace.deleteNoteTitle": "Delete note",
"workspace.deleteProtected": "Protected note: only its owner can delete it",
"workspace.deleteReadWrite": "Read-write access is required to delete this note",
"workspace.deleteTitle": "Delete {title}",
"workspace.empty": "No notes yet.",
"workspace.enterPassword": "Enter the workspace password to continue.",
"workspace.identify": "Identify yourself",
"workspace.linkCopied": "Workspace link copied to the clipboard.",
"workspace.linkCopyFailed": "Could not copy workspace link",
"workspace.minPassword": "Min. 8 chars",
"workspace.newNote": "New note",
"workspace.noteDeleted": "The note \"{title}\" was deleted.",
"workspace.noteDeletedTitle": "Note deleted",
"workspace.noteName.placeholder": "Note name",
"workspace.noteProtect": "Protect this note from deletion",
"workspace.notes.copy": "Select a note or create a new one.",
"workspace.notesView": "Notes view",
"workspace.pagination": "Notes pagination",
"workspace.passwordFailed": "Could not set workspace password",
"workspace.protect": "Protect workspace",
"workspace.protect.copy": "One password secures the workspace and all its notes.",
"workspace.protected": "Protected workspace",
"workspace.protected.message": "Password protection is now enabled for this workspace.",
"workspace.protected.title": "Workspace protected",
"workspace.protectedBadge": "Protected",
"workspace.search": "Search notes",
"workspace.search.placeholder": "Search notes…",
"workspace.stats.files": "Files: {count} ({size})",
"workspace.stats.participants": "Participants: {count}",
"workspace.stats.revisions": "Revisions: {count}",
"workspace.table.actions": "Actions",
"workspace.table.createdBy": "Created by",
"workspace.table.files": "Files",
"workspace.table.name": "Name",
"workspace.table.participants": "Participants",
"workspace.table.revisions": "Revisions",
"workspace.table.status": "Status",
"workspace.table.updated": "Updated",
"workspace.unknown": "Unknown",
"workspace.unlockFailed": "Could not unlock workspace",
"workspace.unlocked.message": "Workspace access has been unlocked.",
"workspace.unlocked.title": "Workspace unlocked",
"workspace.unprotectedBadge": "Unprotected",
"workspace.updated": "Updated: {date}",
"editor.protectedNote": "Protected note",
"editor.protectedWorkspace": "Protected workspace",
"editor.resourceAriaNote": "RustPad Note",
"editor.resourceAriaWorkspaceNote": "RustPad Workspace Note",
"editor.shortcut.previewNewLine": "New line while editing Preview",
"editor.shortcut.previewRaw": "Edit raw Markdown of current Preview line",
"workspace.deleteProtectedTitle": "Delete protected note (owner only)",
"auth.login.copy": "Use your e-mail address and password.",
"auth.login.directoryCopy": "Use your e-mail address or LDAP/AD username and password.",
"auth.register.copy": "Register your account with an e-mail address and password.",
"common.creating": "Creating…",
"editor.pageAccessWorkspaceCopy": "Access to page options requires a password-protected workspace.",
"editor.pagePasswordSetWorkspace": "Set a workspace password here to enable Page publishing.",
"editor.pagePasswordSetNote": "Set a note password here to enable Page publishing.",
"editor.pagePasswordWorkspaceHelp": "At least 8 characters. It protects the workspace and all notes.",
"editor.pagePasswordNoteHelp": "At least 8 characters. It also protects editing access.",
"editor.pagePasswordFirst": "Set a password before enabling the published page",
"editor.pageEnabled": "Published page enabled",
"editor.text": "Text",
"common.close": "Close",
"editor.connectionRestored": "Connection restored",
"editor.connectionRestored.message": "Live editing is active again.",
"editor.passwordPending": "Password required — pending changes kept",
"editor.readOnlyUnsaved": "Read only — changes not saved",
"editor.pageDisabledError": "The published page is disabled.",
"workspace.passwordSetDuringSession": "A password was set for this workspace. Enter it to continue.",
"common.copied": "Copied",
"error.document.fileNotFound": "File not found · RustPad",
"error.document.noteNotFound": "Note not found · RustPad",
"error.document.publishedNotFound": "Published page not found · RustPad",
"error.document.pageNotFound": "Page not found · RustPad",
"error.document.methodNotAllowed": "Method not allowed · RustPad",
"error.document.serverError": "Server error · RustPad"
}
}
+925
View File
@@ -0,0 +1,925 @@
{
"meta": {
"code": "pl",
"name": "Polish",
"native_name": "Polski",
"locale": "pl-PL"
},
"translations": {
"about.author": "Autor",
"about.commercial": "Komercyjne użycie w aplikacjach własnościowych (w tym przez linuxiarz.pl Mateusz Gruszczyński) nie jest dozwolone na mocy GPLv3 bez wyraźnej umowy licencji komercyjnej.",
"about.commercialTitle": "INFORMACJA O LICENCJI KOMERCYJNEJ:",
"about.copy": "Informacje o projekcie RustPad i licencji.",
"about.gpl": "Ten program jest wolnym oprogramowaniem: możesz go rozpowszechniać i/lub modyfikować na warunkach GNU General Public License opublikowanej przez Free Software Foundation, w wersji 3 licencji.",
"about.license": "Licencja",
"about.repository": "Repozytorium Git",
"api.accountAlreadyConfirmed": "To konto jest już potwierdzone.",
"api.accountConfirmationDisabled": "Potwierdzanie kont nie jest włączone.",
"api.accountConfirmationRequiresSmtp": "Potwierdzanie kont wymaga konfiguracji SMTP.",
"api.accountConfirmed": "Konto potwierdzone. Możesz się teraz zalogować.",
"api.accountCreateFailed": "Nie udało się utworzyć konta.",
"api.accountCreated": "Konto utworzone.",
"api.accountCreatedConfirm": "Konto utworzone. Sprawdź e-mail i potwierdź konto przed zalogowaniem.",
"api.accountInactive": "To konto jest nieaktywne.",
"api.accountUpdateFailed": "Nie udało się zaktualizować konta.",
"api.checkAddress": "Sprawdź adres albo wróć do strony głównej.",
"api.confirmAccountFailed": "Nie udało się potwierdzić konta.",
"api.confirmBeforeLogin": "Przed zalogowaniem potwierdź konto za pomocą linku wysłanego e-mailem.",
"api.confirmInvalid": "Link potwierdzający jest nieprawidłowy lub wygasł.",
"api.confirmationCooldown": "Nową wiadomość potwierdzającą można wysłać za {minutes} min.",
"api.confirmationEmailBuildFailed": "Nie udało się przygotować wiadomości e-mail z potwierdzeniem konta.",
"api.confirmationResent": "Wysłano nową wiadomość e-mail z potwierdzeniem.",
"api.confirmationSent": "Link potwierdzający został wysłany na Twój adres e-mail.",
"api.currentPasswordIncorrect": "Obecne hasło jest nieprawidłowe.",
"api.databaseError": "Błąd bazy danych.",
"api.directoryProvisionFailed": "Nie udało się utworzyć konta organizacji.",
"api.directorySyncFailed": "Nie udało się zsynchronizować konta organizacji.",
"api.directoryUnavailable": "Katalog organizacji jest obecnie niedostępny.",
"api.documentTooLarge": "Dokument jest zbyt duży",
"api.emailAnother": "Ten adres e-mail należy już do innego konta. Poproś administratora o jego powiązanie albo włączenie LDAP_LINK_EXISTING_BY_EMAIL.",
"api.emailChanged": "Adres e-mail został zmieniony.",
"api.emailExists": "Ten adres e-mail jest już zarejestrowany.",
"api.emailSendFailed": "Nie udało się wysłać e-maila. Sprawdź konfigurację SMTP.",
"api.expirationFuture": "Termin wygaśnięcia musi przypadać w przyszłości.",
"api.fileAssetNotFound": "Żądany zasób aplikacji nie istnieje.",
"api.fileDeleteFailed": "Nie udało się usunąć pliku",
"api.fileMissing": "Nie podano pliku",
"api.fileNotFound": "Nie znaleziono pliku",
"api.fileReadFailed": "Nie udało się odczytać pliku",
"api.fileSaveFailed": "Nie udało się zapisać pliku",
"api.fileStorageCheckFailed": "Nie udało się sprawdzić magazynu plików",
"api.fileTooLarge": "Plik może mieć maksymalnie {max_mb} MB",
"api.guestUploadsDisabled": "Przesyłanie plików przez gości jest wyłączone.",
"api.invalidAccessToken": "Nieprawidłowy token dostępu",
"api.invalidAuthorship": "Nieprawidłowy tryb autorstwa",
"api.invalidCredentials": "Nieprawidłowy adres e-mail lub hasło.",
"api.invalidDirectoryCredentials": "Nieprawidłowy login organizacji lub hasło.",
"api.invalidEditorColor": "Nieprawidłowy kolor edytora",
"api.invalidEditorColorDot": "Nieprawidłowy kolor edytora.",
"api.invalidEditorFont": "Nieprawidłowa czcionka edytora",
"api.invalidEditorFontSize": "Nieprawidłowy rozmiar czcionki edytora",
"api.invalidEmail": "Podaj prawidłowy adres e-mail.",
"api.invalidExpiration": "Nieprawidłowa data wygaśnięcia.",
"api.invalidForm": "Nieprawidłowe dane formularza",
"api.invalidPassword": "Nieprawidłowe hasło",
"api.invalidRecipient": "Nieprawidłowy adres odbiorcy.",
"api.invalidResourceKind": "Nieprawidłowy typ zasobu",
"api.itemNotOwned": "Ten element nie należy do Twojego konta.",
"api.ldapDelete": "Kont LDAP nie można usunąć w tym miejscu.",
"api.ldapManaged": "E-mail i hasło są zarządzane przez LDAP/AD.",
"api.ldapNotConfigured": "Uwierzytelnianie LDAP nie jest skonfigurowane.",
"api.linkLabelLength": "Etykieta linku może zawierać maksymalnie 120 drukowalnych znaków.",
"api.localRegistrationDisabled": "Lokalna rejestracja jest wyłączona, gdy aktywne jest uwierzytelnianie LDAP.",
"api.loginFirst": "Najpierw się zaloguj.",
"api.loginSaveColors": "Zaloguj się, aby zapisywać kolory notatek",
"api.loginSavePreferences": "Zaloguj się, aby zapisywać osobiste ustawienia edytora",
"api.methodNotAllowed": "Metoda niedozwolona",
"api.missingEmailPayload": "Brak danych zmiany adresu e-mail.",
"api.missingKind": "Brak typu zasobu.",
"api.missingSlug": "Brak identyfikatora zasobu.",
"api.nameAddress": "Nie można przekształcić nazwy w prawidłowy adres",
"api.nameLength": "Pole {field} musi zawierać od 1 do {max} znaków",
"api.nicknameInvalid": "Pseudonim zawiera niedozwolone znaki.",
"api.nicknameLength": "Pseudonim musi mieć od 1 do 40 znaków.",
"api.nicknameLogin": "Ten pseudonim jest zarejestrowany. Zaloguj się, aby go użyć.",
"api.nicknameOwned": "Ten pseudonim należy do innego konta.",
"api.nicknameOwnedSession": "Ten pseudonim należy do innego konta albo sesja wygasła.",
"api.nicknameRegistered": "Ten pseudonim jest już zarejestrowany.",
"api.noActiveAccounts": "Brak aktywnego zarejestrowanego konta dla: {accounts}",
"api.noSettings": "Nie podano ustawień edytora",
"api.notLoggedIn": "Nie jesteś zalogowany.",
"api.noteDeleted": "Ta notatka nie istnieje lub została usunięta.",
"api.noteFilesDeleteFailed": "Nie udało się usunąć plików notatki",
"api.noteHasPassword": "Ta notatka ma już hasło.",
"api.noteNotFound": "Nie znaleziono notatki",
"api.noteProtectedDelete": "Ta notatka jest chroniona. Usunąć ją może tylko właściciel.",
"api.noteProtectedFiles": "Ta notatka jest chroniona. Pliki może usuwać tylko właściciel.",
"api.onlyOwnerFiles": "Pliki może usuwać tylko właściciel notatki",
"api.onlyOwnerNotePassword": "Hasło notatki może ustawić tylko jej właściciel.",
"api.onlyOwnerWorkspacePassword": "Hasło obszaru roboczego może ustawić tylko jego właściciel.",
"api.ownerAuthorship": "Ustawienia autorstwa może zmieniać tylko właściciel zasobu",
"api.padFilesDeleteFailed": "Nie udało się usunąć plików notatki.",
"api.pageLinkInvalid": "Link jest nieprawidłowy albo opublikowana strona została usunięta.",
"api.pageNotFound": "Nie znaleziono strony",
"api.pageUnavailablePassword": "Ta opublikowana strona będzie niedostępna do czasu ustawienia hasła zasobu.",
"api.pageUnavailableWorkspacePassword": "Ta opublikowana strona będzie niedostępna do czasu ustawienia hasła obszaru roboczego.",
"api.passwordLength": "Hasło musi mieć od 8 do 128 znaków.",
"api.passwordLengthBetween": "Hasło musi mieć od 8 do 128 znaków",
"api.passwordRequired": "Hasło jest wymagane.",
"api.passwordRequiredIncorrect": "Hasło jest wymagane lub nieprawidłowe.",
"api.passwordSecureFailed": "Nie udało się bezpiecznie zapisać hasła.",
"api.permission": "Uprawnienie musi mieć wartość ro lub rw.",
"api.profileUpdated": "Profil zaktualizowany.",
"api.profileUpdatedConfirmEmail": "Profil zaktualizowany. Potwierdź nowy adres e-mail za pomocą wysłanego na niego linku.",
"api.publishedNotFound": "Nie znaleziono opublikowanej strony",
"api.publishedProtected": "Ta opublikowana strona jest chroniona.",
"api.rateLogin": "Zbyt wiele prób logowania. Spróbuj ponownie za {seconds} s.",
"api.ratePassword": "Zbyt wiele prób podania hasła. Spróbuj ponownie za {seconds} s.",
"api.rateReset": "Zbyt wiele żądań resetu hasła. Spróbuj ponownie za {seconds} s.",
"api.rateResetAttempt": "Zbyt wiele prób resetu. Spróbuj ponownie za {seconds} s.",
"api.rateShare": "Zbyt wiele prób użycia linku udostępniania. Spróbuj ponownie za {seconds} s.",
"api.rateShareSessions": "Zbyt wiele sesji linków udostępniania. Spróbuj ponownie za {seconds} s.",
"api.readOnly": "Dostęp tylko do odczytu.",
"api.recipientsRange": "Podaj od 1 do 100 adresów e-mail lub nazw użytkowników.",
"api.registrationDisabled": "Rejestracja jest wyłączona.",
"api.registrationEmailBuildFailed": "Nie udało się przygotować wiadomości rejestracyjnej e-mail.",
"api.resetEmailBuildFailed": "Nie udało się przygotować wiadomości e-mail do resetu hasła.",
"api.resetInvalid": "Link resetujący jest nieprawidłowy lub wygasł.",
"api.resetNotConfigured": "Resetowanie hasła nie jest skonfigurowane na tym serwerze.",
"api.resetSent": "Jeśli konto istnieje, wysłano link do resetu hasła.",
"api.restoreFailed": "Nie udało się przywrócić wybranej wersji",
"api.revisionNotFound": "Nie znaleziono wersji",
"api.serverError": "Błąd serwera",
"api.sessionExpired": "Twoja sesja wygasła.",
"api.sessionExpiredLogin": "Twoja sesja wygasła. Zaloguj się ponownie.",
"api.setResourcePasswordFirst": "Ustaw hasło zasobu przed włączeniem opublikowanej strony.",
"api.setWorkspacePasswordFirst": "Ustaw hasło obszaru roboczego przed włączeniem opublikowanej strony.",
"api.shareConfirmationRequiresSmtp": "Potwierdzanie udostępnienia wymaga konfiguracji SMTP.",
"api.shareEmailBuildFailed": "Nie udało się przygotować wiadomości e-mail z zaproszeniem do udostępnienia.",
"api.shareInviteInvalid": "Zaproszenie do udostępnienia jest nieprawidłowe lub wygasło.",
"api.shareLinkMissing": "Nie znaleziono linku udostępniania albo został już unieważniony.",
"api.shareLinksDisabled": "Bezpośrednie linki udostępniania są wyłączone dla publicznych zasobów bez hasła.",
"api.smtpFromInvalid": "Nieprawidłowa wartość SMTP_FROM.",
"api.smtpInvalid": "Nieprawidłowa konfiguracja SMTP.",
"api.smtpNotConfigured": "SMTP nie jest skonfigurowane.",
"api.taskUpdateFailed": "Nie udało się zaktualizować zadania",
"api.taskUpdatesDisabled": "Aktualizowanie zadań jest wyłączone dla tej strony",
"api.unconfirmedMissing": "Nie istnieje niepotwierdzone konto dla tego adresu e-mail.",
"api.uniqueAddressFailed": "Nie udało się utworzyć unikalnego adresu",
"api.unknownAccountAction": "Nieznana operacja na koncie.",
"api.unknownResourceType": "Nieznany typ zasobu.",
"api.unknownLanguage": "Nieznany język interfejsu.",
"api.unknownTheme": "Nieznany motyw interfejsu.",
"api.workspaceFilesDeleteFailed": "Nie udało się usunąć plików obszaru roboczego.",
"api.workspaceHasPassword": "Ten obszar roboczy ma już hasło.",
"api.workspaceNotFound": "Nie znaleziono obszaru roboczego",
"api.writeRequiredPrefs": "Do zapisania ustawień edytora wymagany jest dostęp do odczytu i zapisu",
"auth.accountAction.ask": "Potwierdź żądaną operację na koncie. Jeśli jej nie zlecałeś, anuluj i zignoruj wiadomość e-mail.",
"auth.accountAction.confirm": "Potwierdź operację",
"auth.accountAction.done": "Operacja na koncie potwierdzona",
"auth.accountAction.failed": "Operacja na koncie nie powiodła się",
"auth.accountAction.title": "Potwierdź operację na koncie",
"auth.accountConfirmationFailed": "Potwierdzenie konta nie powiodło się",
"auth.accountConfirmed": "Konto potwierdzone",
"auth.backLogin": "Wróć do logowania",
"auth.confirmAccount.action": "Aktywuj konto",
"auth.confirmAccount.message": "Potwierdź aktywację tego konta RustPad.",
"auth.confirmAccount.title": "Potwierdź konto",
"auth.confirmation.resend": "Wyślij ponownie e-mail potwierdzający",
"auth.confirmation.resendFailed": "Nie udało się ponownie wysłać potwierdzenia",
"auth.confirmation.sent": "Wysłano e-mail potwierdzający",
"auth.createAccount": "Utwórz konto",
"auth.delete.confirm": "Wysłać e-mail z linkiem do trwałego usunięcia tego konta?",
"auth.delete.currentPassword": "Najpierw podaj obecne hasło.",
"auth.delete.requestFailed": "Nie udało się zlecić usunięcia konta",
"auth.email.directory": "E-mail / nazwa użytkownika LDAP lub AD",
"auth.email.directory.placeholder": "ty@example.com lub nazwa użytkownika",
"auth.email.placeholder": "ty@example.com",
"auth.error.login": "Logowanie nie powiodło się",
"auth.error.registration": "Rejestracja nie powiodła się",
"auth.error.reset": "Reset hasła nie powiódł się",
"auth.error.retry": "Spróbuj ponownie.",
"auth.forgot": "Nie pamiętasz hasła?",
"auth.guest.success": "Kontynuujesz jako {nickname}.",
"auth.guest.title": "Sesja gościa",
"auth.inbox": "Sprawdź skrzynkę odbiorczą",
"auth.invitation.accept": "Akceptuj zaproszenie",
"auth.invitation.ask": "Zaakceptować to zaproszenie do udostępnionego zasobu?",
"auth.invitation.failed": "Nie udało się przyjąć zaproszenia",
"auth.invitation.title": "Zaproszenie do udostępnienia",
"auth.login": "Zaloguj się",
"auth.login.success": "Zalogowano jako {nickname}.",
"auth.login.title": "Zalogowano",
"auth.logout.message": "Wylogowano.",
"auth.logout.title": "Wylogowano",
"auth.newPassword.action": "Zmień hasło",
"auth.newPassword.copy": "Hasło musi mieć co najmniej 8 znaków.",
"auth.newPassword.title": "Ustaw nowe hasło",
"auth.nickname.placeholder": "Twój pseudonim",
"auth.password.changed": "Hasło zmienione",
"auth.password.changed.copy": "Hasło zostało zmienione. Link resetujący został wykorzystany i nie można go użyć ponownie.",
"auth.password.min": "Co najmniej 8 znaków",
"auth.passwordChange.currentRequired": "Podaj obecne hasło, aby zmienić e-mail lub hasło.",
"auth.register": "Zarejestruj się",
"auth.register.already": "Masz już konto? Zaloguj się",
"auth.register.create": "Utwórz konto",
"auth.reset": "Resetuj hasło",
"auth.reset.copy": "Podaj adres e-mail przypisany do lokalnego konta.",
"auth.reset.send": "Wyślij link resetujący",
"auth.reset.sent": "Wysłano link resetujący",
"auth.session.expired.message": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować operacje wymagające konta.",
"auth.session.expired.title": "Sesja wygasła",
"characters.zero": "0 znaków",
"client.accessDenied": "Brak dostępu.",
"client.authRequired": "Wymagane jest uwierzytelnienie.",
"client.badGateway": "Serwer zwrócił nieprawidłową odpowiedź. Spróbuj ponownie.",
"client.conflict": "Żądana zmiana jest sprzeczna z istniejącymi danymi.",
"client.copyFailed": "Nie udało się skopiować linku",
"client.gatewayTimeout": "Serwer zbyt długo nie odpowiadał. Spróbuj ponownie.",
"client.invalidRequest": "Nieprawidłowe żądanie.",
"client.offline": "Urządzenie jest offline.",
"client.operationNotAllowed": "Ta operacja jest niedozwolona.",
"client.requestFailed": "Żądanie nie powiodło się ({status}).",
"client.requestTimedOut": "Przekroczono limit czasu żądania. Spróbuj ponownie.",
"client.resourceNotFound": "Nie znaleziono żądanego zasobu.",
"client.serverLater": "Błąd serwera. Spróbuj ponownie później.",
"client.timedOut": "Przekroczono limit czasu",
"client.tooEarly": "Żądanie wysłano zbyt wcześnie. Spróbuj ponownie.",
"client.tooMany": "Zbyt wiele żądań. Spróbuj ponownie później.",
"client.unavailable": "Usługa jest tymczasowo niedostępna.",
"client.uploadCancelled": "Przesyłanie anulowane.",
"client.uploadConnection": "Przesyłanie nie powiodło się przed odpowiedzią serwera. Sprawdź połączenie i spróbuj ponownie.",
"client.uploadInterrupted": "Przesyłanie przerwane. Spróbuj ponownie.",
"client.uploadLimit": "Wybrany plik przekracza dozwolony limit przesyłania.",
"client.uploadProcessingTimeout": "Plik został wysłany, ale serwer nie zakończył jego przetwarzania. Spróbuj ponownie.",
"client.uploadProxy": "Przesyłanie nie powiodło się przed odpowiedzią serwera. Plik może przekraczać limit serwera lub proxy.",
"client.uploadSize": "Wybrany plik ma {size}. Limit przesyłania wynosi {limit}.",
"client.uploadStalled": "Przesyłanie przestało postępować. Sprawdź połączenie i spróbuj ponownie.",
"common.about": "O aplikacji",
"common.back": "Wstecz",
"common.backHome": "Wróć do strony głównej",
"common.cancel": "Anuluj",
"common.closeDialog": "Zamknij okno",
"common.closeFiles": "Zamknij pliki",
"common.confirm": "Potwierdź",
"common.continue": "Kontynuuj",
"common.create": "Utwórz",
"common.currentLocation": "Bieżąca lokalizacja",
"common.delete": "Usuń",
"common.dismiss": "Zamknij",
"common.done": "Gotowe",
"common.edit": "Edycja",
"common.email": "E-mail",
"common.files": "Pliki",
"common.hide": "Ukryj",
"common.history": "Historia",
"common.information": "Informacja",
"common.language": "Język",
"common.loading": "Ładowanie…",
"common.more": "Więcej",
"common.next": "Dalej",
"common.nickname": "Pseudonim",
"common.note": "Notatka",
"common.notes": "Notatki",
"common.ok": "OK",
"common.open": "Otwórz",
"common.password": "Hasło",
"common.perPage": "Na stronę",
"common.preview": "Podgląd",
"common.previous": "Poprzednia",
"common.private": "Prywatny",
"common.profile": "Profil",
"common.public": "Publiczny",
"common.remove": "Usuń",
"common.retry": "Ponów",
"common.save": "Zapisz",
"common.search": "Szukaj",
"common.set": "Ustaw",
"common.share": "Udostępnij",
"common.show": "Pokaż",
"common.success": "Sukces",
"common.system": "System",
"common.table": "Tabela",
"common.update": "Aktualizuj",
"common.view": "Widok",
"common.warning": "Ostrzeżenie",
"common.workspace": "Obszar roboczy",
"diag.client": "Klient",
"diag.jitter": "Jitter",
"diag.lastEvent": "Ostatnie zdarzenie",
"diag.latency": "Opóźnienie",
"diag.quality": "Jakość",
"diag.reconnects": "Ponowne połączenia",
"diag.title": "Diagnostyka połączenia",
"diag.uptime": "Czas działania",
"diagnostics.buffered": " · w buforze {amount}",
"diagnostics.closed": "Zamknięto {code}",
"diagnostics.closedReason": "Zamknięto {code}: {reason}",
"diagnostics.lastUptime": "ostatnio {duration}",
"diagnostics.latency": "{current} ms · śr. {average} ms · {minimum}{maximum} ms",
"diagnostics.messageAt": "Wiadomość {time}",
"diagnostics.quality.degraded": "Pogorszona",
"diagnostics.quality.excellent": "Doskonała",
"diagnostics.quality.good": "Dobra",
"diagnostics.quality.poor": "Słaba",
"diagnostics.reconnectAttempt": "{count} · próba {attempt}",
"diagnostics.state.closed": "Rozłączono",
"diagnostics.state.closing": "Zamykanie",
"diagnostics.state.connecting": "Łączenie",
"diagnostics.state.measuring": "Pomiar",
"diagnostics.state.open": "Połączono",
"diagnostics.state.reconnecting": "Ponowne łączenie",
"diagnostics.state.waiting": "Oczekiwanie",
"diagnostics.traffic": "odebrano {received} · wysłano {sent}",
"diagnostics.visibility.hidden": "ukryta",
"diagnostics.visibility.prerender": "wstępnie renderowana",
"diagnostics.visibility.visible": "widoczna",
"diagnostics.waitHeartbeat": "Oczekiwanie na heartbeat",
"diagnostics.waitServer": "Oczekiwanie na dane serwera",
"editor.accessChecking": "Dostęp: sprawdzanie…",
"editor.authorship": "Widok autorstwa",
"editor.authorshipTitle": "Pokaż lub ukryj kolory autorów",
"editor.autosave": "Zmiany są zapisywane automatycznie",
"editor.bold": "Pogrubienie",
"editor.bulletButton": "• Lista",
"editor.bulletList": "Lista punktowana",
"editor.changeColor": "Zmień swój kolor",
"editor.changeEditorColor": "Zmień kolor edytora",
"editor.changesSynced": "Zmiany zsynchronizowane",
"editor.characters": "{count} znaków",
"editor.chat": "Czat",
"editor.chatEphemeral": "Wiadomości znikają po rozłączeniu",
"editor.chatLabel": "Wiadomość na czacie",
"editor.chatPlaceholder": "Napisz wiadomość…",
"editor.closeNavigation": "Zamknij menu nawigacji",
"editor.codeBlock": "Blok kodu",
"editor.codeBlockLines": "Blok kodu z numerami linii",
"editor.collapsible": "Sekcja rozwijana",
"editor.colorSaveFailed": "Nie udało się zapisać koloru edytora",
"editor.colorSaved": "Ten kolor będzie używany w bieżącej karcie.",
"editor.colorSavedTitle": "Kolor edytora zapisany",
"editor.colorsOff": "Kolory wyłączone",
"editor.colorsOn": "Kolory włączone",
"editor.columnLabel": "Edytor",
"editor.compact": "Kompaktowy",
"editor.compactView": "Widok kompaktowy",
"editor.connectionError": "Błąd połączenia edytora",
"editor.connectionInterrupted": "Połączenie przerwane",
"editor.copyOpen": "Skopiuj link i otwórz go w nowej karcie",
"editor.copyThisLink": "Kopiuj ten link",
"editor.currentLocation": "Bieżąca lokalizacja",
"editor.dangerAlert": "Alert błędu",
"editor.definition": "Definicja",
"editor.dragResize": "Przeciągnij, aby zmienić rozmiar",
"editor.editableTasks": "Edytowalne zadania",
"editor.editableTasksTitle": "Pozwól odwiedzającym zmieniać pola zadań na opublikowanej stronie",
"editor.editorColor": "Kolor edytora",
"editor.editorLines": "Linie edytora",
"editor.editorOptions": "Opcje edytora",
"editor.emoji": "😀 Emoji",
"editor.emojiCategories": "Kategorie emoji",
"editor.emojiEmpty": "Nie znaleziono emoji.",
"editor.emojiLabel": "Emoji",
"editor.emojiSearch": "Szukaj emoji",
"editor.emojiSearchPlaceholder": "Szukaj emoji…",
"editor.emptyNote": "Pusta notatka",
"editor.enablePage": "Włącz stronę",
"editor.enablePageTitle": "Włącz lub wyłącz opublikowaną stronę",
"editor.extendedMarkdown": "Rozszerzony Markdown",
"editor.failedMermaid": "Nie udało się załadować Mermaid.",
"editor.fileCount": "{count} plików",
"editor.font": "Czcionka",
"editor.footnote": "Przypis",
"editor.format.alertContent": "Treść alertu",
"editor.format.code": "kod",
"editor.format.column1": "Kolumna 1",
"editor.format.column2": "Kolumna 2",
"editor.format.content": "Treść",
"editor.format.definition": "Definicja",
"editor.format.description": "opis",
"editor.format.detailsSummary": "Kliknij mnie",
"editor.format.diagram": "graph TD\n A[Start] --> B[Koniec]",
"editor.format.footnote": "Treść przypisu",
"editor.format.important": "ważne",
"editor.format.term": "Termin",
"editor.format.text": "tekst",
"editor.format.textWithFootnote": "Tekst z przypisem",
"editor.format.value": "wartość",
"editor.full": "Pełny",
"editor.globalAuthorship": "Globalne ustawienia autorstwa",
"editor.globalProfileColor": "Globalny kolor profilu",
"editor.guest": "Gość",
"editor.headings": "Nagłówki H1H4",
"editor.hideNavigation": "Ukryj pasek nawigacji",
"editor.hideToolbar": "Ukryj pasek narzędzi edytora",
"editor.highlight": "Wyróżnienie",
"editor.historyCopy": "Autor, czas i podgląd wersji",
"editor.historyEmpty": "Brak historii.",
"editor.historyLoadFailed": "Nie udało się wczytać historii wersji",
"editor.historyTitle": "Historia zmian",
"editor.home": "Strona główna RustPad",
"editor.horizontalRule": "Linia pozioma",
"editor.imageSizeError": "Podaj szerokość i wysokość od 1 do 10 000 px.",
"editor.imageSizeTitle": "Nieprawidłowy rozmiar obrazu",
"editor.inRoom": "W tym pokoju",
"editor.indent": "Wcięcie o 2 spacje",
"editor.infoAlert": "Alert informacyjny",
"editor.inlineCode": "Kod w tekście",
"editor.insertEmoji": "Wstaw emoji",
"editor.italic": "Kursywa",
"editor.keyboardShortcuts": "Skróty klawiaturowe",
"editor.layout.auto": "Automatycznie",
"editor.layout.center": "Wyśrodkowanie",
"editor.layout.left": "Do lewej",
"editor.layout.natural": "Naturalny",
"editor.layout.right": "Do prawej",
"editor.lineCopyFailed": "Nie udało się skopiować linku do linii",
"editor.link": "Link",
"editor.localRecovered": "Odzyskano lokalne zmiany",
"editor.markdown": "Markdown",
"editor.markdownPreview": "Podgląd Markdown",
"editor.mermaid": "Diagram Mermaid",
"editor.minimum8": "Minimum 8 znaków.",
"editor.missedMerged": "Pominięta aktualizacja została scalona z lokalnymi zmianami.",
"editor.mono": "Monospace",
"editor.moveQuickActions": "Przenieś szybkie akcje",
"editor.noActiveUsers": "Brak aktywnych użytkowników",
"editor.noMessages": "Brak wiadomości",
"editor.noteColorOverride": "Nadpisany kolor notatki",
"editor.noteFiles": "Pliki notatki",
"editor.noteFilesCopy": "Skopiuj bezpośredni link albo gotowy kod Markdown/Alias.",
"editor.noteLinkCopied": "Link do notatki skopiowano do schowka.",
"editor.noteLinkCopyFailed": "Nie udało się skopiować linku do notatki",
"editor.noteReadOnly": "Ta notatka jest tylko do odczytu. Podaj hasło albo poproś właściciela o nadanie dostępu do zapisu.",
"editor.noteUnlockFailed": "Nie udało się odblokować notatki",
"editor.noteUnlocked": "Odblokowano dostęp do edycji.",
"editor.noteUnlockedTitle": "Notatka odblokowana",
"editor.notificationChat": "{sender} napisał(a) w RustPad",
"editor.numberedButton": "1. Lista",
"editor.numberedList": "Lista numerowana",
"editor.numberedListShortcut": "Lista numerowana · Ctrl/Cmd+Shift+7",
"editor.openChat": "Otwórz czat",
"editor.openEditorOptions": "Otwórz opcje edytora",
"editor.openFiles": "Otwórz pliki",
"editor.openNavigation": "Otwórz menu nawigacji",
"editor.openPublishedFailed": "Nie udało się otworzyć opublikowanej strony",
"editor.outdent": "Usuń wcięcie",
"editor.overrideColor": "Nadpisz kolor dla tej notatki",
"editor.ownerAuthorshipOnly": "Ustawienia autorstwa może zmieniać tylko właściciel",
"editor.pageAccessCopy": "Dostęp do opcji strony wymaga notatki chronionej hasłem.",
"editor.pageDisabled": "Opublikowana strona wyłączona",
"editor.pageLabel": "Strona",
"editor.pageLinkCopied": "Link do opublikowanej strony skopiowano do schowka.",
"editor.pageLinkTitle": "Link do strony skopiowany",
"editor.pageLoadFailed": "Nie udało się wczytać strony",
"editor.pageOptions": "Opcje strony",
"editor.participants": "Uczestnicy",
"editor.passwordRequired": "Wymagane hasło",
"editor.passwordSetContinue": "Dla tej notatki ustawiono hasło. Podaj je, aby kontynuować.",
"editor.passwordSetEditing": "Dla tej notatki ustawiono hasło. Podaj je, aby kontynuować edycję.",
"editor.passwordSetFailed": "Nie udało się ustawić hasła",
"editor.pendingMerged": "Oczekujące zmiany zostały scalone po ponownym połączeniu.",
"editor.previewLines": "Linie podglądu",
"editor.previewMedia": "Podgląd (media / Mermaid / Markdown)",
"editor.previewText": "Podgląd tekstu",
"editor.profileColorRestoreFailed": "Nie udało się przywrócić koloru profilu",
"editor.profileColorRestored": "Globalny kolor profilu jest ponownie aktywny.",
"editor.profileColorRestoredTitle": "Przywrócono kolor profilu",
"editor.protectedResource": "Chroniony zasób: {resource}",
"editor.protectionEnabled": "Ochrona hasłem jest włączona. Opcje publikowania są teraz dostępne.",
"editor.protectionEnabledTitle": "Ochrona włączona",
"editor.publicProtected": "Opublikowana strona ponownie wymaga ochrony hasłem.",
"editor.publicProtectedTitle": "Strona publiczna chroniona",
"editor.publicProtectionFailed": "Nie udało się zmienić ochrony strony",
"editor.publicUnprotected": "Opublikowaną stronę można teraz otworzyć bez hasła zasobu.",
"editor.publicUnprotectedTitle": "Wyłączono ochronę strony publicznej",
"editor.publishingDisabled": "Publikowanie wyłączone",
"editor.publishingEnabled": "Publikowanie włączone",
"editor.publishingFailed": "Nie udało się zaktualizować publikowania",
"editor.publishingOff": "Opublikowana strona nie jest już dostępna.",
"editor.publishingOn": "Opublikowana strona jest teraz dostępna.",
"editor.quickActions": "Szybkie akcje edytora",
"editor.quote": "Cytat",
"editor.readOnly": "Tylko odczyt",
"editor.readOnlyTitle": "Dostęp tylko do odczytu",
"editor.reconnectAuto": "Trwa automatyczna próba ponownego połączenia.",
"editor.recoveryFailed": "Odzyskiwanie nie powiodło się",
"editor.recoveryTooLarge": "Kopia odzyskiwania przekracza limit rozmiaru dokumentu.",
"editor.redo": "Ponów",
"editor.resizeImage": "Zmień rozmiar obrazu",
"editor.restore": "Przywróć",
"editor.roomChat": "Czat pokoju",
"editor.saveFailed": "Zapis nie powiódł się",
"editor.saving": "Zapisywanie…",
"editor.send": "Wyślij",
"editor.serif": "Szeryfowa",
"editor.settingsFailed": "Nie udało się zapisać ustawień edytora",
"editor.shortcuts": "Skróty",
"editor.shortcutsOs": "Użyj Ctrl w Windows/Linux lub Cmd w macOS.",
"editor.showNavigation": "Pokaż pasek nawigacji",
"editor.showToolbar": "Pokaż pasek narzędzi edytora",
"editor.simple": "Prosty",
"editor.size": "Rozmiar",
"editor.split": "Podział",
"editor.startWriting": "Zacznij pisać…",
"editor.strike": "Przekreślenie",
"editor.subscript": "Indeks dolny",
"editor.successAlert": "Alert sukcesu",
"editor.superscript": "Indeks górny",
"editor.syncConflict": "Wystąpił konflikt synchronizacji. Lokalne zmiany zachowano w bloku odzyskiwania.",
"editor.syncError": "Błąd synchronizacji",
"editor.syncResync": "Ponowna synchronizacja…",
"editor.taskButton": "☑ Zadanie",
"editor.taskList": "Lista zadań",
"editor.taskListShortcut": "Lista zadań · Ctrl/Cmd+Shift+9",
"editor.tasksFailed": "Nie udało się zmienić uprawnień do zadań",
"editor.tasksOff": "Odwiedzający nie mogą już aktualizować publicznych zadań.",
"editor.tasksOffTitle": "Aktualizowanie zadań wyłączone",
"editor.tasksOn": "Odwiedzający mogą teraz aktualizować publiczne zadania.",
"editor.tasksOnTitle": "Aktualizowanie zadań włączone",
"editor.toc": "Spis treści",
"editor.undo": "Cofnij ostatnią zmianę",
"editor.unknownAuthor": "Nieznany autor",
"editor.unprotectPage": "Wyłącz ochronę strony",
"editor.unprotectPageTitle": "Pozwól otwierać opublikowaną stronę bez hasła zasobu lub prywatnego dostępu",
"editor.upload": "Prześlij",
"editor.uploadFile": "Prześlij plik",
"editor.uploadTitle": "Prześlij plik",
"editor.useGlobalColor": "Użyj globalnego koloru profilu",
"editor.user": "{count} użytkownik",
"editor.users": "{count} użytkowników",
"editor.versionRestored": "Wybrana wersja jest teraz wersją bieżącą.",
"editor.versionRestoredTitle": "Wersja przywrócona",
"editor.view": "Widok edytora",
"editor.waitConnection": "Oczekiwanie na połączenie…",
"editor.warningAlert": "Alert ostrzegawczy",
"editor.websocketDiagnostics": "Diagnostyka połączenia WebSocket",
"editor.words": "{count} słów",
"emoji.group.activities": "Aktywności",
"emoji.group.animalsNature": "Zwierzęta i natura",
"emoji.group.flags": "Flagi",
"emoji.group.foodDrink": "Jedzenie i napoje",
"emoji.group.objects": "Przedmioty",
"emoji.group.peopleBody": "Ludzie i ciało",
"emoji.group.smileysEmotion": "Uśmiechy i emocje",
"emoji.group.symbols": "Symbole",
"emoji.group.travelPlaces": "Podróże i miejsca",
"emoji.itemLabel": "Emoji {emoji}",
"emoji.recentGroup": "Ostatnio używane",
"emoji.recentItem": "Ostatnie emoji",
"error.fileNotFoundTitle": "Nie znaleziono pliku · RustPad",
"error.home": "Strona główna",
"error.internal.message": "Nie udało się wczytać strony. Spróbuj ponownie za chwilę.",
"error.methodNotAllowedTitle": "Metoda niedozwolona · RustPad",
"error.methodUnsupported": "Ten adres nie obsługuje żądanej operacji.",
"error.notFoundFallback": "404 Nie znaleziono",
"error.noteNotFoundTitle": "Nie znaleziono notatki · RustPad",
"error.pageNotFoundTitle": "Nie znaleziono strony · RustPad",
"error.publishedNotFoundTitle": "Nie znaleziono opublikowanej strony · RustPad",
"error.serverTitle": "Błąd serwera · RustPad",
"error.staticMissing": "Żądany plik statyczny nie istnieje.",
"files.addDownload": "Dodaj pobieranie",
"files.addPlayer": "Dodaj odtwarzacz",
"files.addToNote": "Dodaj do notatki",
"files.addedTitle": "Dodano do notatki",
"files.alias": "Alias",
"files.codeCopied": "Wygenerowany kod pliku skopiowano do schowka.",
"files.codeCopyFailed": "Nie udało się skopiować kodu pliku",
"files.delete": "Usuń plik",
"files.deleteAsk": "Trwale usunąć plik „{name}”?",
"files.deleteFailed": "Nie udało się usunąć pliku",
"files.deleted": "Plik został trwale usunięty.",
"files.deletedTitle": "Plik usunięty",
"files.download": "Pobierz {label}",
"files.downloadCode": "Kod pobierania",
"files.empty": "Nie przesłano żadnych plików.",
"files.generatedCode": "Wygenerowany kod pliku",
"files.imageAlignment": "Wyrównanie obrazu",
"files.imageHeight": "Wysokość obrazu",
"files.imageLayout": "Układ obrazu",
"files.imageWidth": "Szerokość obrazu",
"files.inNote": "w notatce",
"files.inserted": "Odwołanie do pliku zostało wstawione do notatki.",
"files.loadFailed": "Nie udało się wczytać plików",
"files.ownerDelete": "Pliki może usuwać tylko właściciel notatki.",
"files.pasteUnavailable": "Przesyłanie ze schowka niedostępne",
"files.pasteUnavailable.message": "Do wklejania plików wymagany jest dostęp do odczytu i zapisu oraz włączone przesyłanie plików.",
"files.playerCode": "Kod odtwarzacza",
"files.prepareImageFailed": "Nie udało się przygotować obrazu",
"files.previewMedia": "Odtwarzanie jest niedostępne.",
"files.readWriteDelete": "Do usuwania plików wymagany jest dostęp do odczytu i zapisu.",
"files.removedContent": "usunięty z treści",
"files.summary.few": "{count} pliki · {size}",
"files.summary.many": "{count} plików · {size}",
"files.summary.one": "{count} plik · {size}",
"files.summary.other": "{count} pliku · {size}",
"files.uploadUnavailable": "Przesyłanie niedostępne",
"files.uploadUnavailable.message": "Do przesyłania plików wymagany jest dostęp do odczytu i zapisu oraz włączone przesyłanie plików.",
"files.video.download": "Link do pobrania",
"files.video.downloadHelp": "Wstaw link pobierający oryginalny plik.",
"files.video.embedded": "Osadzony odtwarzacz",
"files.video.embeddedHelp": "Odtwarzaj wideo bezpośrednio w notatce.",
"files.video.question": "Jak ma zostać dodany?",
"files.video.title": "Plik wideo",
"files.zero": "0 plików",
"home.hero.copy": "Utwórz samodzielną notatkę albo uporządkuj wiele notatek w obszarze roboczym.",
"home.hero.title": "Pisz. Udostępniaj. Współpracuj.",
"home.login": "Zaloguj się",
"home.logout": "Wyloguj się",
"home.myNotes": "Moje notatki",
"home.note.copy": "Pojedynczy dokument z własnym linkiem.",
"home.note.create": "Utwórz notatkę",
"home.note.name": "Nazwa notatki",
"home.note.password": "Hasło do notatki",
"home.note.placeholder": "Notatki ze spotkania",
"home.password.optional": "opcjonalne, min. 8 znaków",
"home.register": "Zarejestruj się",
"home.workspace.copy": "Obszar roboczy zawierający wiele notatek.",
"home.workspace.create": "Utwórz obszar roboczy",
"home.workspace.name": "Nazwa obszaru roboczego",
"home.workspace.password": "Hasło do obszaru roboczego",
"home.workspace.placeholder": "Mój projekt",
"i18n.loadFailed": "Nie udało się wczytać zasobu językowego ({status})",
"identity.backNickname": "Wróć do pseudonimu",
"identity.copy": "Użyj wolnego pseudonimu bez konta albo zarejestruj go, aby go zarezerwować.",
"identity.guest": "Kontynuuj jako gość",
"identity.loginContinue": "Zaloguj się i kontynuuj",
"identity.logoutSaved": "Wyloguj zapisane konto",
"identity.placeholder": "Imię lub pseudonim",
"identity.registerContinue": "Zarejestruj się i kontynuuj",
"identity.title": "Jak mamy Cię nazywać?",
"identity.workspace.copy": "Zaloguj się albo wybierz wolny pseudonim, aby kontynuować jako gość.",
"image.adjust.help": "Zachowaj cały obraz albo wybierz kadrowanie, a następnie rozmiar wyjściowy.",
"image.adjust.title": "Dostosuj obraz",
"image.crop": "Kadrowanie",
"image.free": "Dowolne",
"image.maxSize": "Maks. rozmiar",
"image.original": "Oryginalny",
"image.square": "Kwadrat",
"image.use": "Użyj obrazu",
"image.whole": "Cały obraz",
"image.zoom": "Powiększenie",
"markdown.backReference": "Wróć do odwołania",
"markdown.details": "Szczegóły",
"markdown.download": "Pobierz {label}",
"markdown.open": "Otwórz {title}",
"markdown.playbackUnavailable": "Odtwarzanie jest niedostępne.",
"markdown.toc": "Spis treści",
"markdown.youtubeVideo": "Film YouTube",
"pagination.items": "Strona {page} z {pages} · elementów: {count}",
"pagination.notes": "Strona {page} z {pages} · notatek: {count}",
"permission.readOnly": "Tylko odczyt",
"permission.readWrite": "Odczyt i zapis",
"profile.copy.directory": "Dane konta katalogowego są tylko do odczytu. Możesz zmienić pseudonim, kolor edytora, motyw interfejsu i język.",
"profile.copy.local": "Zarządzaj lokalnym kontem RustPad.",
"profile.currentEmail": "Obecny e-mail",
"profile.currentPassword": "Obecne hasło",
"profile.delete": "Usuń konto",
"profile.editorColor": "Kolor edytora",
"profile.editorColor.choose": "Wybierz kolor edytora",
"profile.fullName": "Imię i nazwisko",
"profile.keepCurrent": "Pozostaw puste, aby zachować obecną wartość",
"profile.language.changed.message": "Język interfejsu został zmieniony.",
"profile.language.changed.title": "Zmieniono język",
"profile.language.failed": "Nie udało się zmienić języka",
"profile.language.help": "Zapisywany w profilu i stosowany po zapisaniu tych ustawień.",
"profile.newEmail": "Nowy e-mail",
"profile.newPassword": "Nowe hasło",
"profile.noNicknameSuggestion": "Brak automatycznej propozycji pseudonimu.",
"profile.organization": "Organizacja",
"profile.passwordRequired": "Wymagane przy zmianie e-maila lub hasła",
"profile.save": "Zapisz profil",
"profile.saved": "Ustawienia profilu zostały zapisane.",
"profile.signedAs": "Zalogowano jako {identity}",
"profile.suggestedNickname": "Sugerowany pseudonim: {nickname}",
"profile.theme": "Motyw interfejsu",
"profile.updateFailed": "Nie udało się zaktualizować profilu",
"profile.updated": "Profil zaktualizowany",
"proper.arial": "Arial",
"proper.emailExample": "you@example.com",
"proper.georgia": "Georgia",
"proper.markdown": "Markdown",
"proper.mermaid": "Mermaid",
"proper.rustpad": "RustPad",
"public.copyFailed": "Nie udało się skopiować linku",
"public.copyHeadingAria": "Kopiuj link do nagłówka w linii {line}",
"public.copyHeadingTitle": "Kopiuj link do tego nagłówka (linia {line})",
"public.copyLineAria": "Kopiuj link do linii {line}",
"public.copyLink": "Kopiuj link",
"public.fullWidth": "Pełna szerokość",
"public.headingCopied": "Link do nagłówka skopiowano do schowka.",
"public.lineCopied": "Link do linii {line} skopiowano do schowka.",
"public.lineLinks": "Linki do linii",
"public.lineNumbers": "Numery linii",
"public.linkCopiedTitle": "Link skopiowany",
"public.loadFailed": "Nie udało się wczytać opublikowanej strony",
"public.mermaidFailed": "Nie udało się załadować Mermaid.",
"public.open": "Otwórz stronę",
"public.pageLinkCopied": "Link do opublikowanej strony skopiowano do schowka.",
"public.pageLinkCopyFailed": "Nie udało się skopiować linku do strony",
"public.passwordAuthorized": "Zaloguj się na uprawnione konto albo podaj hasło zasobu.",
"public.passwordCorrect": "Podaj prawidłowe hasło.",
"public.protected": "Chroniona strona",
"public.protected.copy": "Podaj hasło do notatki albo zaloguj się na konto z dostępem.",
"public.task.disabled": "Aktualizowanie zadań zostało wyłączone przez właściciela",
"public.task.update": "Zaktualizuj to zadanie",
"public.taskComplete": "Zadanie oznaczono jako ukończone.",
"public.taskIncomplete": "Zadanie oznaczono jako nieukończone.",
"public.taskUpdateFailed": "Nie udało się zaktualizować zadania",
"public.taskUpdated": "Zadanie zaktualizowane",
"public.title": "Opublikowana notatka · RustPad",
"public.unlockFailed": "Nie udało się odblokować strony",
"public.unlocked": "Opublikowana strona została odblokowana.",
"public.unlockedTitle": "Strona odblokowana",
"public.updated": "Zaktualizowano: {date}",
"public.updatedTasks": "Zaktualizowano: {date} · zadania można edytować",
"resource.changePassword": "Zmień hasło",
"resource.deleteAsk": "Trwale usunąć „{title}”?",
"resource.deleteFailed": "Nie udało się usunąć elementu",
"resource.deleted": "Usunięto: {kind}.",
"resource.makePrivate": "Ustaw jako prywatny",
"resource.makePublic": "Ustaw jako publiczny",
"resource.minimumPassword": "Minimum 8 znaków",
"resource.passwordEnabled": "Ochrona hasłem jest włączona dla {title}.",
"resource.passwordMinError": "Hasło musi mieć co najmniej 8 znaków.",
"resource.passwordProtectedMeta": "chroniony hasłem",
"resource.passwordRemoveAsk": "Usunąć ochronę hasłem z „{title}”?",
"resource.passwordRemoveCopy": "Każda osoba z publicznym linkiem będzie mogła otworzyć ten element bez hasła.",
"resource.passwordRemoveFailed": "Nie udało się usunąć hasła",
"resource.passwordRemoved": "Ochrona hasłem została usunięta z {title}.",
"resource.passwordRemovedTitle": "Usunięto hasło",
"resource.passwordSaveFailed": "Nie udało się zapisać hasła",
"resource.passwordSaved": "Hasło zapisane",
"resource.passwordSettings": "Ustawienia hasła",
"resource.passwordShort": "Hasło…",
"resource.passwordVeryShort": "Hasło…",
"resource.privateMeta": "prywatny",
"resource.removePassword": "Usuń hasło",
"resource.removed": "Element usunięty",
"resource.setPassword": "Ustaw hasło",
"resource.visibilityChanged": "{title}: widoczność ustawiono na {visibility}.",
"resource.visibilityFailed": "Nie udało się zmienić widoczności",
"resource.visibilityTitle": "Zmieniono widoczność",
"resources.accessRules": "Zasady dostępu:",
"resources.accessRules.copy": "Elementy publiczne otwierają się z linku; hasło dodaje ochronę dostępu przez link. Elementy prywatne są widoczne tylko dla właściciela, wskazanych kont i użytkowników z ważnym linkiem udostępniania. Nieuprawnieni użytkownicy otrzymują odpowiedź o braku zasobu.",
"resources.anotherUser": "innego użytkownika",
"resources.copy": "Elementy utworzone po zalogowaniu są przypisywane do Twojego konta.",
"resources.createWorkspaceFailed": "Nie udało się utworzyć obszaru roboczego",
"resources.empty": "Nie masz jeszcze przypisanych elementów.",
"resources.loadFailed": "Nie udało się wczytać Twoich elementów",
"resources.loading": "Ładowanie…",
"resources.pagination": "Paginacja zasobów",
"resources.search": "Szukaj notatek i obszarów roboczych",
"resources.search.placeholder": "Szukaj notatek i obszarów roboczych…",
"resources.sharedBy": "Udostępnione przez: {user}",
"resources.title": "Moje notatki i obszary robocze",
"share.accessGranted": "Dostęp nadany.",
"share.accessRemoved": "Dostęp został odebrany.",
"share.attention": "Udostępnianie wymaga uwagi",
"share.close": "Zamknij okno udostępniania",
"share.copy": "Kopiuj",
"share.createLink": "Utwórz link",
"share.created": "Utworzono: {date}",
"share.directDisabled": "Linki bezpośrednie są wyłączone, gdy element jest publiczny i nie ma hasła. Istniejące linki są zachowane i ponownie staną się aktywne po ustawieniu elementu jako prywatnego lub dodaniu hasła.",
"share.directLinks": "Linki bezpośrednie",
"share.directLinks.copy": "Twórz linki dla osób bez konta albo zarządzaj istniejącymi linkami.",
"share.emailOrUsername": "E-mail lub nazwa użytkownika",
"share.expires": "Wygasa: {date}",
"share.failed": "Operacja udostępniania nie powiodła się",
"share.grant": "Nadaj dostęp",
"share.hours": "godz.",
"share.individualLinks": "Indywidualne linki",
"share.invitationSent": "Zaproszenie wysłano. Dostęp pojawi się po zaakceptowaniu go przez odbiorcę.",
"share.label": "Etykieta",
"share.linkCopied": "Link skopiowany.",
"share.linkCreatedCopied": "Link utworzono, wyświetlono poniżej i skopiowano.",
"share.linkCreatedCopyFailed": "Link utworzono i wyświetlono poniżej. Automatyczne kopiowanie nie powiodło się; użyj przycisku Kopiuj.",
"share.linkId": "ID linku #{id} · Utworzono {created} · {expires}",
"share.linkNotStored": "Pełny adres nie jest przechowywany. Użyj etykiety lub identyfikatora linku #{id}, aby go rozpoznać.",
"share.linkPermission": "Uprawnienie linku",
"share.linkRevoked": "Link unieważniony.",
"share.linkUpdated": "Link zaktualizowany.",
"share.linkVisibleOnce": "Pełny adres pozostanie widoczny do zamknięcia tego okna.",
"share.manage": "Zarządzaj zarejestrowanymi użytkownikami i bezpośrednimi linkami dostępu do {title}.",
"share.manageFor": "Zarządzaj zarejestrowanymi użytkownikami i bezpośrednimi linkami dla",
"share.never": "Nigdy",
"share.neverExpires": "Nigdy nie wygasa",
"share.newLink": "Nowy link udostępniania",
"share.newValidityHours": "Nowa ważność w godzinach",
"share.noActiveLinks": "Brak aktywnych linków.",
"share.noUsers": "Żaden użytkownik nie ma dostępu.",
"share.people": "Osoby z dostępem",
"share.people.copy": "Zaproś istniejących użytkowników RustPad przez e-mail albo nazwę konta/użytkownika katalogowego.",
"share.permission": "Uprawnienie",
"share.revoke": "Unieważnij",
"share.title": "Udostępnianie dostępu",
"share.unlabeled": "Link bez etykiety",
"share.updated": "Zaktualizowano udostępnianie",
"share.validFor": "Ważny przez",
"share.validityHours": "Ważność w godzinach",
"share.validityRange": "Podaj okres ważności od 1 do 87600 godzin.",
"status.accessFull": "Dostęp: pełny",
"status.accessReadOnly": "Dostęp: tylko odczyt",
"status.applicationError": "Błąd aplikacji",
"status.connecting": "Łączenie…",
"status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat",
"status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.",
"status.measuring": "Pomiar",
"status.networkRestored": "Połączenie sieciowe zostało przywrócone.",
"status.offline": "Offline",
"status.online": "Online",
"status.openFailed": "Nie udało się otworzyć połączenia na żywo.",
"status.openingConnection": "Otwieranie połączenia na żywo.",
"status.reconnecting": "Ponowne łączenie…",
"status.reestablishing": "Ponowne nawiązywanie połączenia na żywo.",
"status.serverInterrupted": "Połączenie z serwerem zostało przerwane.",
"status.waiting": "Oczekiwanie",
"theme.dark": "Ciemny",
"theme.dark.copy": "Obecny wygląd RustPad",
"theme.light": "Jasny",
"theme.light.copy": "Ciepłe kremowe powierzchnie i ciemny tekst",
"toast.close": "Zamknij powiadomienie",
"toast.region.label": "Powiadomienia",
"toast.title.danger": "Coś poszło nie tak",
"toast.title.info": "Informacja",
"toast.title.success": "Sukces",
"toast.title.warning": "Ostrzeżenie",
"upload.error.generic": "Nie udało się przesłać pliku.",
"upload.progress.label": "Postęp przesyłania pliku",
"upload.status.complete": "Gotowe",
"upload.status.preparing": "Przygotowywanie…",
"upload.status.processing": "Przetwarzanie…",
"upload.status.starting": "Rozpoczynanie…",
"upload.title.complete": "Plik przesłany",
"upload.title.failed": "Przesyłanie nie powiodło się",
"upload.title.uploading": "Przesyłanie pliku",
"users.zero": "0 użytkowników",
"vendor.globalMissing": "{name} nie zarejestrował globalnego obiektu przeglądarki.",
"vendor.loadFailed": "Nie udało się wczytać {path}.",
"words.zero": "0 słów",
"workspace.aria": "Obszar roboczy RustPad",
"workspace.cards": "Karty",
"workspace.connectionError": "Błąd połączenia z obszarem roboczym",
"workspace.createNoteFailed": "Nie udało się utworzyć notatki",
"workspace.createdBy": "Utworzona przez: {author}",
"workspace.deleteNoteAsk": "Usunąć notatkę „{title}”? Tej operacji nie można cofnąć.",
"workspace.deleteNoteFailed": "Nie udało się usunąć notatki",
"workspace.deleteNoteTitle": "Usuń notatkę",
"workspace.deleteProtected": "Chroniona notatka: usunąć ją może tylko właściciel",
"workspace.deleteReadWrite": "Do usunięcia tej notatki wymagany jest dostęp do odczytu i zapisu",
"workspace.deleteTitle": "Usuń {title}",
"workspace.empty": "Brak notatek.",
"workspace.enterPassword": "Podaj hasło obszaru roboczego, aby kontynuować.",
"workspace.identify": "Przedstaw się",
"workspace.linkCopied": "Link do obszaru roboczego skopiowano do schowka.",
"workspace.linkCopyFailed": "Nie udało się skopiować linku do obszaru roboczego",
"workspace.minPassword": "Min. 8 znaków",
"workspace.newNote": "Nowa notatka",
"workspace.noteDeleted": "Notatka „{title}” została usunięta.",
"workspace.noteDeletedTitle": "Notatka usunięta",
"workspace.noteName.placeholder": "Nazwa notatki",
"workspace.noteProtect": "Chroń tę notatkę przed usunięciem",
"workspace.notes.copy": "Wybierz notatkę albo utwórz nową.",
"workspace.notesView": "Widok notatek",
"workspace.pagination": "Paginacja notatek",
"workspace.passwordFailed": "Nie udało się ustawić hasła obszaru roboczego",
"workspace.protect": "Chroń obszar roboczy",
"workspace.protect.copy": "Jedno hasło chroni obszar roboczy i wszystkie jego notatki.",
"workspace.protected": "Chroniony obszar roboczy",
"workspace.protected.message": "Ochrona hasłem dla tego obszaru roboczego jest teraz włączona.",
"workspace.protected.title": "Obszar roboczy chroniony",
"workspace.protectedBadge": "Chroniona",
"workspace.search": "Szukaj notatek",
"workspace.search.placeholder": "Szukaj notatek…",
"workspace.stats.files": "Pliki: {count} ({size})",
"workspace.stats.participants": "Uczestnicy: {count}",
"workspace.stats.revisions": "Wersje: {count}",
"workspace.table.actions": "Akcje",
"workspace.table.createdBy": "Utworzona przez",
"workspace.table.files": "Pliki",
"workspace.table.name": "Nazwa",
"workspace.table.participants": "Uczestnicy",
"workspace.table.revisions": "Wersje",
"workspace.table.status": "Status",
"workspace.table.updated": "Zaktualizowano",
"workspace.unknown": "Nieznany",
"workspace.unlockFailed": "Nie udało się odblokować obszaru roboczego",
"workspace.unlocked.message": "Odblokowano dostęp do obszaru roboczego.",
"workspace.unlocked.title": "Obszar roboczy odblokowany",
"workspace.unprotectedBadge": "Bez ochrony",
"workspace.updated": "Zaktualizowano: {date}",
"editor.protectedNote": "Chroniona notatka",
"editor.protectedWorkspace": "Chroniony obszar roboczy",
"editor.resourceAriaNote": "Notatka RustPad",
"editor.resourceAriaWorkspaceNote": "Notatka obszaru roboczego RustPad",
"editor.shortcut.previewNewLine": "Nowy wiersz podczas edycji podglądu",
"editor.shortcut.previewRaw": "Edytuj surowy Markdown bieżącego wiersza podglądu",
"workspace.deleteProtectedTitle": "Usuń chronioną notatkę (tylko właściciel)",
"auth.login.copy": "Użyj adresu e-mail i hasła.",
"auth.login.directoryCopy": "Użyj adresu e-mail lub nazwy użytkownika LDAP/AD oraz hasła.",
"auth.register.copy": "Zarejestruj konto za pomocą adresu e-mail i hasła.",
"common.creating": "Tworzenie…",
"editor.pageAccessWorkspaceCopy": "Dostęp do opcji strony wymaga obszaru roboczego chronionego hasłem.",
"editor.pagePasswordSetWorkspace": "Ustaw tutaj hasło obszaru roboczego, aby włączyć publikowanie strony.",
"editor.pagePasswordSetNote": "Ustaw tutaj hasło notatki, aby włączyć publikowanie strony.",
"editor.pagePasswordWorkspaceHelp": "Co najmniej 8 znaków. Hasło chroni obszar roboczy i wszystkie jego notatki.",
"editor.pagePasswordNoteHelp": "Co najmniej 8 znaków. Hasło chroni również dostęp do edycji.",
"editor.pagePasswordFirst": "Ustaw hasło przed włączeniem opublikowanej strony",
"editor.pageEnabled": "Opublikowana strona włączona",
"editor.text": "Tekst",
"common.close": "Zamknij",
"editor.connectionRestored": "Połączenie przywrócone",
"editor.connectionRestored.message": "Edycja na żywo jest ponownie aktywna.",
"editor.passwordPending": "Wymagane hasło — oczekujące zmiany zachowano",
"editor.readOnlyUnsaved": "Tylko do odczytu — zmiany nie są zapisywane",
"editor.pageDisabledError": "Opublikowana strona jest wyłączona.",
"workspace.passwordSetDuringSession": "Ustawiono hasło dla tego obszaru roboczego. Wprowadź je, aby kontynuować.",
"common.copied": "Skopiowano",
"error.document.fileNotFound": "Nie znaleziono pliku · RustPad",
"error.document.noteNotFound": "Nie znaleziono notatki · RustPad",
"error.document.publishedNotFound": "Nie znaleziono opublikowanej strony · RustPad",
"error.document.pageNotFound": "Nie znaleziono strony · RustPad",
"error.document.methodNotAllowed": "Metoda niedozwolona · RustPad",
"error.document.serverError": "Błąd serwera · RustPad"
}
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN language VARCHAR(16) NOT NULL DEFAULT 'en';
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'en';
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'en';
+34 -2
View File
@@ -11,7 +11,7 @@ mod pages;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Request},
extract::{DefaultBodyLimit, Path, Request},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
@@ -79,6 +79,8 @@ pub fn router(
.route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/health", get(health))
.route("/lang", get(language_catalog))
.route("/lang/{code}", get(language_bundle))
.route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
.route("/favicon.svg", get(favicon_svg))
@@ -240,6 +242,36 @@ pub fn router(
.with_state(state)
}
async fn language_catalog() -> Response {
let mut response = Json(crate::i18n::metadata()).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
);
response
}
async fn language_bundle(Path(code): Path<String>) -> Response {
let Some(source) = crate::i18n::source(&code) else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Language not found" })),
)
.into_response();
};
let mut response = source.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
);
response
}
async fn require_csrf_token(request: Request, next: Next) -> Response {
let method = request.method();
let unsafe_method = method == Method::POST
@@ -324,7 +356,7 @@ fn is_file_path(path: &str) -> bool {
}
fn is_asset_path(path: &str) -> bool {
path == "/assets" || path.starts_with("/assets/")
path == "/assets" || path.starts_with("/assets/") || path == "/lang" || path.starts_with("/lang/")
}
fn is_icon_path(path: &str) -> bool {
+29 -9
View File
@@ -130,6 +130,7 @@ fn render_editor_page(
entrypoint: &str,
resource_kind: &str,
document_title: &str,
document_title_i18n: Option<&str>,
parent_title: &str,
parent_url: &str,
parent_class: &str,
@@ -156,6 +157,13 @@ fn render_editor_page(
.replace("__RESOURCE_ARIA_LABEL__", resource_aria_label)
.replace("__RESOURCE_BREADCRUMB__", &resource_breadcrumb)
.replace("__DOCUMENT_TITLE__", &escape_html(document_title))
.replace(
"__DOCUMENT_TITLE_I18N__",
document_title_i18n
.map(|key| format!(r#"data-i18n="{}""#, escape_html(key)))
.as_deref()
.unwrap_or(""),
)
.replace("__PARENT_TITLE__", &escape_html(parent_title))
.replace("__PARENT_URL__", &escape_html(parent_url))
.replace("__PARENT_CLASS__", parent_class)
@@ -289,6 +297,7 @@ pub(super) async fn pad(
"pad",
"pad",
&pad.title,
None,
"RustPad",
"/",
"home-brand",
@@ -363,14 +372,19 @@ pub(super) async fn workspace(
)
.await;
}
let html = include_str!("../../static/workspace.html").replace(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
}),
);
let html = include_str!("../../static/workspace.html")
.replace(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
}),
)
.replace(
"__WORKSPACE_TITLE_I18N__",
if workspace.is_private != 0 { r#"data-i18n="common.workspace""# } else { "" },
);
assets::render_html(
&html,
&state.asset_version,
@@ -446,6 +460,7 @@ pub(super) async fn note(
} else {
&note.title
},
if workspace.is_private != 0 { Some("common.note") } else { None },
if workspace.is_private != 0 {
"Workspace"
} else {
@@ -531,11 +546,16 @@ pub(super) fn error_response(
asset_version: &str,
) -> Response {
let html = include_str!("../../static/error.html")
.replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap())
.replace(
"__APP_THEME_BOOTSTRAP__",
&format!("{}{}", assets::theme_bootstrap(), assets::language_bootstrap()),
)
.replace(
"__APP_STYLESHEET__",
&assets::stylesheet_tag(asset_version, "styles"),
)
.replace("__APP_IMPORT_MAP__", &assets::import_map_tag(asset_version))
.replace("__APP_I18N__", assets::i18n_entrypoint())
.replace("__ERROR_CODE__", &escape_html(code))
.replace("__ERROR_TITLE__", &escape_html(title))
.replace("__ERROR_MESSAGE__", &escape_html(message))
+17 -3
View File
@@ -24,6 +24,7 @@ const MODULES: &[&str] = &[
"emoji-picker",
"image-alias",
"image-upload",
"i18n",
"logger",
"line-links",
"markdown",
@@ -64,7 +65,7 @@ pub fn render_html(
urls.stylesheet_path("libs/rustpad-player/player.css"),
);
let html = template
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
.replace("__APP_THEME_BOOTSTRAP__", &format!("{}{}", theme_bootstrap(), language_bootstrap()))
.replace("__APP_STYLESHEET__", &app_stylesheets)
.replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
@@ -95,10 +96,22 @@ pub fn theme_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:theme";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
}
pub fn language_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:language";let lang="en";try{if(localStorage.getItem("rustpad:auth-state")==="1"){const saved=localStorage.getItem(key);if(saved)lang=saved}}catch{}const root=document.documentElement;root.lang=lang;if(lang!=="en"){root.dataset.i18nPending="true";root.style.visibility="hidden"}})();</script>"#
}
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
AssetUrls::new(asset_version).stylesheet(name)
}
pub fn import_map_tag(asset_version: &str) -> String {
AssetUrls::new(asset_version).import_map()
}
pub fn i18n_entrypoint() -> &'static str {
r#"<script type="module">import { initI18n } from "@rustpad/i18n";await initI18n();</script>"#
}
fn frontend_config(
frontend_log_level: &str,
upload_max_size_bytes: usize,
@@ -136,9 +149,10 @@ impl<'a> AssetUrls<'a> {
}
fn entrypoint(&self, name: &str) -> String {
let entrypoint = self.url(&format!("js/{name}.js"));
format!(
r#"<script type="module" src="{}"></script>"#,
self.url(&format!("js/{name}.js"))
r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";await initI18n();await import("{}");</script>"#,
escape_js_string(&entrypoint)
)
}
+28
View File
@@ -51,6 +51,7 @@ pub struct User {
pub confirmed_at: Option<String>,
pub is_active: i64,
pub theme: String,
pub language: String,
}
#[derive(Deserialize)]
@@ -99,6 +100,8 @@ pub struct ProfileUpdateRequest {
editor_color: Option<String>,
#[serde(default)]
theme: Option<String>,
#[serde(default)]
language: Option<String>,
}
#[derive(Deserialize)]
pub struct DeleteAccountRequest {
@@ -252,6 +255,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User {
confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?,
is_active: row.try_get("is_active")?,
theme: crate::row_decode::text(row, "theme")?,
language: crate::row_decode::text(row, "language")?,
})
}
}
@@ -367,6 +371,7 @@ pub struct SessionResponse {
suggested_nickname: Option<String>,
editor_color: Option<String>,
theme: String,
language: String,
}
#[derive(Serialize)]
pub struct IdentityResponse {
@@ -380,6 +385,7 @@ pub struct RegisterResponse {
expires_at: Option<String>,
confirmation_required: bool,
theme: String,
language: String,
message: String,
}
@@ -531,6 +537,7 @@ pub async fn register(
expires_at: None,
confirmation_required: true,
theme: user.theme,
language: user.language,
message:
"Account created. Check your e-mail and confirm the account before logging in."
.into(),
@@ -550,6 +557,7 @@ pub async fn register(
expires_at: Some(session.expires_at),
confirmation_required: false,
theme: session.theme,
language: session.language,
message: "Account created.".into(),
}),
)
@@ -845,6 +853,7 @@ pub async fn me(
suggested_nickname,
editor_color,
theme: user.theme,
language: user.language,
},
state.user_session_ttl_days,
))
@@ -972,11 +981,24 @@ pub async fn update_profile(
.map_err(AuthError::database)?;
}
let mut language = user.language.clone();
if let Some(value) = req.language.as_deref() {
language = validate_language(value)?.to_owned();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_LANGUAGE))
.bind(&language)
.bind(Utc::now().to_rfc3339())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
Ok(Json(serde_json::json!({
"ok": true,
"nickname": nickname,
"editor_color": req.editor_color.as_deref(),
"theme": theme,
"language": language,
"email_pending": email_pending,
"message": if email_pending {
"Profile updated. Confirm the new e-mail address using the link sent to it."
@@ -2560,6 +2582,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
suggested_nickname,
editor_color,
theme: user.theme.clone(),
language: user.language.clone(),
})
}
async fn find_user_by_nickname(
@@ -2620,6 +2643,11 @@ fn validate_theme(value: &str) -> Result<&str, AuthError> {
}
}
fn validate_language(value: &str) -> Result<&'static str, AuthError> {
crate::i18n::canonical_code(value)
.ok_or_else(|| AuthError::bad_request("Unknown interface language."))
}
fn validate_editor_color(value: &str) -> Result<String, AuthError> {
let value = value.trim();
if value.len() == 7
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*/
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LanguageMeta {
pub code: String,
pub name: String,
pub native_name: String,
pub locale: String,
}
#[derive(Debug, Deserialize)]
struct LanguageFile {
meta: LanguageMeta,
translations: serde_json::Map<String, serde_json::Value>,
}
#[derive(Clone, Debug)]
struct EmbeddedLanguage {
meta: LanguageMeta,
source: &'static str,
}
static LANGUAGES: OnceLock<Vec<EmbeddedLanguage>> = OnceLock::new();
fn embedded_languages() -> &'static [EmbeddedLanguage] {
LANGUAGES.get_or_init(|| {
let mut seen = std::collections::HashSet::new();
let mut parsed_files = EMBEDDED_LANGUAGE_FILES
.iter()
.map(|(filename, source)| {
let parsed: LanguageFile = serde_json::from_str(source)
.unwrap_or_else(|error| panic!("invalid language file {filename}: {error}"));
let code = parsed.meta.code.trim();
assert!(!code.is_empty(), "language file {filename} has an empty meta.code");
assert!(
filename.strip_suffix(".json") == Some(code),
"language file {filename} must use the same name as meta.code ({code})"
);
assert!(
!parsed.meta.name.trim().is_empty()
&& !parsed.meta.native_name.trim().is_empty()
&& !parsed.meta.locale.trim().is_empty(),
"language file {filename} has incomplete metadata"
);
assert!(
!parsed.translations.is_empty(),
"language file {filename} has no translations"
);
assert!(
parsed.translations.values().all(serde_json::Value::is_string),
"language file {filename} contains a non-string translation value"
);
assert!(
seen.insert(parsed.meta.code.clone()),
"duplicate language code {}",
parsed.meta.code
);
let keys = parsed.translations.keys().cloned().collect::<std::collections::HashSet<_>>();
(*filename, parsed.meta, *source, keys)
})
.collect::<Vec<_>>();
let fallback_keys = parsed_files
.iter()
.find(|(_, meta, _, _)| meta.code == "en")
.map(|(_, _, _, keys)| keys.clone())
.expect("lang/en.json with meta.code=en is required as the fallback language");
for (filename, _, _, keys) in &parsed_files {
let missing = fallback_keys.difference(keys).take(8).cloned().collect::<Vec<_>>();
let extra = keys.difference(&fallback_keys).take(8).cloned().collect::<Vec<_>>();
assert!(
missing.is_empty() && extra.is_empty(),
"language file {filename} does not match lang/en.json keys; missing: {missing:?}; extra: {extra:?}"
);
}
let mut languages = parsed_files
.drain(..)
.map(|(_, meta, source, _)| EmbeddedLanguage { meta, source })
.collect::<Vec<_>>();
languages.sort_by(|left, right| left.meta.native_name.cmp(&right.meta.native_name));
languages
})
}
pub fn metadata() -> Vec<LanguageMeta> {
embedded_languages()
.iter()
.map(|language| language.meta.clone())
.collect()
}
pub fn source(code: &str) -> Option<&'static str> {
let code = code.trim().trim_end_matches(".json");
embedded_languages()
.iter()
.find(|language| language.meta.code.eq_ignore_ascii_case(code))
.map(|language| language.source)
}
pub fn canonical_code(code: &str) -> Option<&'static str> {
let code = code.trim().trim_end_matches(".json");
embedded_languages()
.iter()
.find(|language| language.meta.code.eq_ignore_ascii_case(code))
.map(|language| language.meta.code.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_fallback_is_embedded() {
assert!(source("en").is_some());
assert!(source("en.json").is_some());
}
#[test]
fn metadata_has_unique_codes() {
let metadata = metadata();
let mut codes = metadata.iter().map(|item| item.code.as_str()).collect::<Vec<_>>();
let len = codes.len();
codes.sort_unstable();
codes.dedup();
assert_eq!(codes.len(), len);
}
#[test]
fn canonical_language_code_uses_embedded_catalog() {
assert_eq!(canonical_code("EN"), Some("en"));
assert_eq!(canonical_code("pl.json"), Some("pl"));
assert_eq!(canonical_code("missing"), None);
}
}
+1
View File
@@ -17,6 +17,7 @@ mod config;
mod database;
mod db;
mod file_urls;
mod i18n;
mod queries;
mod row_decode;
mod security;
+2
View File
@@ -27,6 +27,7 @@ pub enum Query {
AUTH_UPDATE_NICKNAME,
AUTH_UPDATE_EDITOR_COLOR,
AUTH_UPDATE_THEME,
AUTH_UPDATE_LANGUAGE,
AUTH_EDITOR_COLOR_BY_USER,
RESOURCE_COLOR_BY_USER,
RESOURCE_COLOR_DELETE,
@@ -190,6 +191,7 @@ pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIR
pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR;
pub const AUTH_UPDATE_THEME: Query = Query::AUTH_UPDATE_THEME;
pub const AUTH_UPDATE_LANGUAGE: Query = Query::AUTH_UPDATE_LANGUAGE;
pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
+6 -5
View File
@@ -32,6 +32,7 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -104,7 +105,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE auth_provider = ? AND external_id = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme, CAST(language AS CHAR CHARACTER SET utf8mb4) AS language FROM users WHERE auth_provider = ? AND external_id = ?"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -151,19 +152,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme, CAST(u.language AS CHAR CHARACTER SET utf8mb4) AS language FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE nickname_key = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme, CAST(language AS CHAR CHARACTER SET utf8mb4) AS language FROM users WHERE nickname_key = ?"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE email_key = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme, CAST(language AS CHAR CHARACTER SET utf8mb4) AS language FROM users WHERE email_key = ?"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme, CAST(language AS CHAR CHARACTER SET utf8mb4) AS language FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
+6 -5
View File
@@ -32,6 +32,7 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#,
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
@@ -104,7 +105,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE auth_provider = $1 AND external_id = $2"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme, language FROM users WHERE auth_provider = $1 AND external_id = $2"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -153,19 +154,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme, u.language FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE nickname_key = $1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme, language FROM users WHERE nickname_key = $1"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE email_key = $1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme, language FROM users WHERE email_key = $1"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme, language FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#
+6 -5
View File
@@ -32,6 +32,7 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -104,7 +105,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE auth_provider = ? AND external_id = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme, language FROM users WHERE auth_provider = ? AND external_id = ?"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -151,19 +152,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme, u.language FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE nickname_key = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme, language FROM users WHERE nickname_key = ?"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE email_key = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme, language FROM users WHERE email_key = ?"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme, language FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
+483 -74
View File
@@ -99,6 +99,7 @@
--link-hover: #c3bbff;
--caret: #9b89ff;
--owner-fallback: #8b7af4;
--info: #6fa8ff;
--warning: #e3a94d;
--warning-muted: #d6a84b;
--danger: #ff7b91;
@@ -244,6 +245,7 @@
--link-hover: #4c3f8b;
--caret: #6859b5;
--owner-fallback: #7060c1;
--info: #3568b8;
--warning: #9a6816;
--warning-muted: #7e5d1d;
--danger: #9b3443;
@@ -749,14 +751,13 @@ textarea:focus {
.editor-layout {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
grid-template-columns: minmax(0, 1fr);
height: calc(100vh - 70px);
overflow: hidden;
transition: grid-template-columns .18s ease;
}
.history-open .editor-layout {
grid-template-columns: minmax(0, 1fr) 340px;
grid-template-columns: minmax(0, 1fr);
}
.editor-panel {
@@ -1080,17 +1081,33 @@ textarea::selection {
}
.history-panel {
position: relative;
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 24;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: 340px;
max-width: 100%;
overflow: hidden;
border-left: 1px solid var(--border);
background: var(--surface-panel);
transform: translateX(100%);
transition: transform .18s ease;
box-shadow: -18px 0 42px transparent;
opacity: 0;
visibility: hidden;
transform: translateX(calc(100% + 12px));
pointer-events: none;
transition: transform .18s ease, opacity .14s ease, box-shadow .18s ease, visibility 0s linear .18s;
}
.history-panel.open {
box-shadow: -18px 0 42px var(--shadow-28);
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
transition-delay: 0s;
}
.history-header {
@@ -1115,7 +1132,8 @@ textarea::selection {
.history-list {
overflow: auto;
height: calc(100vh - 185px);
min-height: 0;
height: auto;
padding: 10px 18px 24px;
}
@@ -1216,26 +1234,258 @@ dialog::backdrop {
font-size: .78rem;
}
.toast {
.toast-region {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20;
padding: 11px 14px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-floating);
color: var(--text-bright);
font-size: .8rem;
opacity: 0;
transform: translateY(8px);
z-index: 100;
display: flex;
width: min(390px, calc(100vw - 32px));
max-height: min(720px, calc(100dvh - 40px));
flex-direction: column;
gap: 10px;
pointer-events: none;
transition: .16s ease;
}
.toast.visible {
.toast-card {
--toast-tone: var(--info);
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 10px;
align-items: start;
width: 100%;
overflow: hidden;
box-sizing: border-box;
padding: 12px 10px 13px 12px;
border: 1px solid color-mix(in srgb, var(--toast-tone) 28%, var(--border));
border-radius: 13px;
background: color-mix(in srgb, var(--surface-floating) 96%, var(--toast-tone) 4%);
box-shadow: 0 16px 42px rgba(0, 0, 0, .22), 0 2px 8px rgba(0, 0, 0, .12);
color: var(--text-bright);
opacity: 0;
transform: translateY(10px) scale(.985);
pointer-events: auto;
transition: opacity .16s ease, transform .16s ease, border-color .16s ease, background .16s ease;
backdrop-filter: blur(14px);
}
.toast-region--modal {
/* A manual popover enters the browser top layer above the active dialog.
Reset popover defaults while keeping the regular toast viewport geometry. */
top: auto;
left: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
z-index: 100;
}
.app-dialog.has-modal-toast-region {
/* Fallback for browsers without the Popover API. */
overflow: visible;
}
.toast-card.is-visible {
opacity: 1;
transform: translateY(0);
transform: translateY(0) scale(1);
}
.toast-card.is-leaving {
opacity: 0;
transform: translateY(6px) scale(.985);
pointer-events: none;
}
.toast-card--info {
--toast-tone: var(--info);
}
.toast-card--success {
--toast-tone: var(--success);
}
.toast-card--warning {
--toast-tone: var(--warning);
}
.toast-card--danger {
--toast-tone: var(--danger);
}
.toast-card__close svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.toast-card__content {
min-width: 0;
padding-top: 1px;
}
.toast-card__title {
display: block;
margin: 0 0 3px;
color: var(--text-max);
font-size: .8rem;
font-weight: 700;
line-height: 1.25;
}
.toast-card__message {
margin: 0;
color: var(--text-tertiary);
font-size: .74rem;
line-height: 1.42;
overflow-wrap: anywhere;
}
.toast-card__close {
display: grid;
width: 28px;
height: 28px;
margin: -2px -2px 0 0;
padding: 0;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted-2);
cursor: pointer;
transition: background .14s ease, color .14s ease;
}
.toast-card__close:hover {
background: color-mix(in srgb, var(--toast-tone) 10%, var(--surface-hover));
color: var(--text-max);
}
.toast-card__close:focus-visible {
outline: 2px solid color-mix(in srgb, var(--toast-tone) 62%, transparent);
outline-offset: 1px;
}
.toast-card__close[hidden] {
display: none;
}
.toast-card__timer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
overflow: hidden;
background: color-mix(in srgb, var(--toast-tone) 12%, transparent);
}
.toast-card__timer[hidden] {
display: none;
}
.toast-card__timer>span {
display: block;
width: 100%;
height: 100%;
background: var(--toast-tone);
transform-origin: left center;
}
@keyframes toast-countdown {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@media (max-width: 520px) {
.toast-region {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom, 0px));
left: auto;
width: min(300px, calc(100vw - 20px));
max-height: calc(100dvh - 20px);
gap: 7px;
}
.toast-card {
grid-template-columns: minmax(0, 1fr) 24px;
gap: 7px;
padding: 9px 8px 10px 9px;
border-radius: 11px;
box-shadow: 0 10px 28px rgba(0, 0, 0, .2), 0 2px 6px rgba(0, 0, 0, .1);
backdrop-filter: blur(11px);
}
.toast-card__close svg {
width: 16px;
height: 16px;
}
.toast-card__title {
margin-bottom: 2px;
font-size: .74rem;
}
.toast-card__message {
font-size: .68rem;
line-height: 1.34;
}
.toast-card__close {
width: 24px;
height: 24px;
margin: -1px -1px 0 0;
border-radius: 7px;
}
.toast-card__timer {
height: 2px;
}
.toast-card--upload {
grid-template-columns: minmax(0, 1fr) 24px;
}
.toast-card--upload .toast-card__content {
gap: 6px;
}
.upload-toast__filename,
.upload-toast__error {
font-size: .67rem;
}
.upload-toast__progress {
height: 5px;
}
.upload-toast__meta {
gap: 8px;
font-size: .63rem;
}
.upload-toast__actions {
gap: 5px;
}
.upload-toast__actions button {
min-height: 27px;
padding: 0 8px;
border-radius: 7px;
font-size: .67rem;
}
}
@media (prefers-reduced-motion: reduce) {
.toast-card {
transition-duration: .01ms;
}
}
.error-page {
@@ -3635,6 +3885,7 @@ dialog::backdrop {
}
#profile-dialog .profile-theme-field,
#profile-dialog .profile-language-field,
#profile-dialog .profile-suggestion {
grid-column: 1 / -1;
}
@@ -3645,6 +3896,7 @@ dialog::backdrop {
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .profile-theme-field,
#profile-dialog .profile-language-field,
#profile-dialog .theme-options,
#profile-dialog .theme-option {
min-width: 0;
@@ -5440,6 +5692,196 @@ dialog::backdrop {
cursor: pointer;
}
.profile-language-field {
display: grid;
gap: 7px;
min-width: 0;
}
.profile-language-field > span:first-child {
color: var(--text-label);
font-size: .86rem;
font-weight: 750;
}
.profile-language-control {
position: relative;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-2);
box-shadow: inset 0 1px 0 var(--wash-soft);
color: var(--muted);
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.profile-language-control:hover,
.profile-language-control[data-open="true"] {
border-color: var(--accent-border);
background: var(--surface-3);
}
.profile-language-control:focus-within {
border-color: var(--focus);
box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft);
}
.profile-language-control__icon,
.profile-language-control__chevron {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
pointer-events: none;
}
.profile-language-control__chevron {
width: 16px;
height: 16px;
justify-self: end;
transition: transform .16s ease;
}
.profile-language-control[data-open="true"] .profile-language-control__chevron {
transform: rotate(180deg);
}
.profile-language-trigger {
width: 100%;
min-width: 0;
min-height: 42px;
margin: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: .82rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.profile-language-current {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-language-menu {
position: absolute;
z-index: 60;
top: calc(100% + 8px);
left: -1px;
right: -1px;
display: grid;
gap: 2px;
max-height: min(260px, 42dvh);
padding: 4px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--surface-floating);
box-shadow: 0 16px 36px var(--shadow-38), inset 0 1px 0 var(--wash-soft);
scrollbar-width: thin;
}
.profile-language-menu[hidden] {
display: none;
}
.profile-language-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 7px;
width: 100%;
min-width: 0;
height: 38px;
min-height: 38px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color .12s ease, background-color .12s ease, color .12s ease;
}
.profile-language-option:hover,
.profile-language-option:focus-visible {
border-color: var(--border-strong);
outline: 0;
background: var(--surface-hover);
}
.profile-language-option[aria-selected="true"] {
border-color: var(--accent-a35);
background: var(--accent-soft);
}
.profile-language-option__check {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 5px;
color: var(--accent-text);
font-size: .76rem;
font-weight: 900;
}
.profile-language-option[aria-selected="true"] .profile-language-option__check {
background: var(--accent-a25);
}
.profile-language-option__copy {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.profile-language-option__copy strong,
.profile-language-option__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-language-option__copy strong {
color: var(--text);
flex: 0 1 auto;
font-size: .8rem;
line-height: 1;
}
.profile-language-option__copy small {
flex: 1 1 auto;
color: var(--text-muted);
font-size: .7rem;
line-height: 1;
}
.profile-language-field > small {
color: var(--text-muted);
line-height: 1.45;
}
.profile-theme-field {
min-width: 0;
margin: 0;
@@ -6054,7 +6496,7 @@ dialog::backdrop {
scrollbar-gutter: stable;
}
.pad-page .toast {
.pad-page .toast-region {
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
}
}
@@ -6639,40 +7081,23 @@ dialog::backdrop {
}
/* Upload progress toast --------------------------------------------------- */
.toast--interactive {
width: min(420px, calc(100vw - 40px));
box-sizing: border-box;
.toast-card--upload {
grid-template-columns: minmax(0, 1fr) 28px;
}
.toast--interactive.visible {
pointer-events: auto;
}
.toast--upload {
.toast-card--upload .toast-card__content {
display: grid;
gap: 9px;
padding: 13px 14px;
gap: 8px;
}
.upload-toast__header,
.upload-toast__heading {
min-width: 0;
}
.upload-toast__heading {
display: grid;
gap: 2px;
}
.upload-toast__title {
font-size: .82rem;
line-height: 1.25;
.toast-card--upload .toast-card__title {
margin-bottom: -4px;
}
.upload-toast__filename {
overflow: hidden;
color: var(--muted);
font-size: .73rem;
color: var(--text-tertiary);
font-size: .72rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
@@ -6680,10 +7105,10 @@ dialog::backdrop {
.upload-toast__progress {
position: relative;
height: 7px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--code-bg);
background: color-mix(in srgb, var(--toast-tone) 12%, var(--surface-3));
}
.upload-toast__progress>span {
@@ -6691,12 +7116,12 @@ dialog::backdrop {
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
background: var(--toast-tone);
transition: width .14s linear;
}
.upload-toast__progress.is-complete>span {
background: var(--success, var(--success-fallback));
background: var(--success);
}
.upload-toast__progress.is-error>span {
@@ -6708,17 +7133,9 @@ dialog::backdrop {
}
@keyframes upload-toast-indeterminate {
0% {
transform: translateX(-120%);
}
50% {
transform: translateX(180%);
}
100% {
transform: translateX(420%);
}
0% { transform: translateX(-120%); }
50% { transform: translateX(180%); }
100% { transform: translateX(420%); }
}
.upload-toast__meta {
@@ -6728,7 +7145,7 @@ dialog::backdrop {
justify-content: space-between;
gap: 12px;
color: var(--muted-2);
font-size: .7rem;
font-size: .69rem;
font-variant-numeric: tabular-nums;
}
@@ -6743,9 +7160,9 @@ dialog::backdrop {
}
.upload-toast__error {
margin: 0;
margin: -1px 0 0;
color: var(--danger-soft);
font-size: .73rem;
font-size: .72rem;
line-height: 1.4;
}
@@ -6763,10 +7180,10 @@ dialog::backdrop {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface-2);
color: var(--text);
font-size: .72rem;
font-size: .71rem;
cursor: pointer;
}
@@ -6778,14 +7195,6 @@ dialog::backdrop {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong)) !important;
}
@media (max-width: 520px) {
.pad-page .toast--interactive {
right: 10px;
left: 10px;
width: auto;
}
}
/* Optional links to exact editor lines. */
.line-number-button {
display: block;
+3 -3
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
<title data-i18n-ignore>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
@@ -22,7 +22,7 @@
<span class="header-divider"></span>
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
title="Copy this link" aria-label="Copy this link">
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
<h1 id="document-title" __DOCUMENT_TITLE_I18N__>__DOCUMENT_TITLE__</h1>
<p id="document-url" class="document-url"></p>
</div>
</div>
@@ -406,7 +406,7 @@
</div>
</details>
</div>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>
+2
View File
@@ -9,6 +9,8 @@
<title>__ERROR_TITLE__ · RustPad</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_I18N__
</head>
<body>
+22 -1
View File
@@ -191,6 +191,27 @@
</label>
</div>
</fieldset>
<div class="profile-language-field">
<span id="profile-language-label" data-i18n="common.language">Language</span>
<div class="profile-language-control" data-language-picker>
<svg class="profile-language-control__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"></path>
</svg>
<button id="profile-language-button" class="profile-language-trigger" type="button"
aria-labelledby="profile-language-label profile-language-current" aria-haspopup="listbox"
aria-expanded="false" aria-controls="profile-language-options">
<span id="profile-language-current" class="profile-language-current" data-i18n-ignore>English</span>
</button>
<svg class="profile-language-control__chevron" viewBox="0 0 20 20" aria-hidden="true">
<path d="m6 8 4 4 4-4"></path>
</svg>
<input id="profile-language" type="hidden" value="en">
<div id="profile-language-options" class="profile-language-menu" role="listbox" data-i18n-ignore
aria-labelledby="profile-language-label" hidden></div>
</div>
<small data-i18n="profile.language.help">Saved with your profile and applied after you save these settings.</small>
</div>
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320"
placeholder="Leave empty to keep current"></label>
@@ -206,7 +227,7 @@
<p id="profile-message" class="form-message" role="status"></p>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
+21 -11
View File
@@ -8,6 +8,7 @@
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
import { formatNumber, setLanguage, translateApiMessage } from "@rustpad/i18n";
const DEFAULT_ERRORS = {
400: "Invalid request.",
@@ -58,9 +59,9 @@ async function csrfToken({ refresh = false } = {}) {
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${bytes} B`;
if (bytes >= 1024 * 1024) return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: bytes % (1024 * 1024) ? 1 : 0 })} MB`;
if (bytes >= 1024) return `${formatNumber(Math.ceil(bytes / 1024))} KB`;
return `${formatNumber(bytes)} B`;
}
function clearExpiredSession() {
@@ -69,7 +70,9 @@ function clearExpiredSession() {
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname");
localStorage.removeItem("rustpad:language");
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0";
void setLanguage("en", { persist: false });
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
}
@@ -91,7 +94,9 @@ function validateUploadSize(body, configuredMaxBytes) {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
const error = new Error(`The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`);
const sourceMessage = `The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`;
const error = new Error(translateApiMessage(sourceMessage));
error.serverMessage = sourceMessage;
error.status = 413;
throw error;
}
@@ -110,7 +115,9 @@ async function requestHeaders(options, body) {
}
function requestError(status, data = {}) {
const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`);
const serverMessage = data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`;
const error = new Error(translateApiMessage(serverMessage));
error.serverMessage = serverMessage;
error.status = status;
return error;
}
@@ -153,11 +160,11 @@ export async function api(path, options = {}) {
} catch (error) {
if (error.name === "AbortError") {
logWarn("api.timeout", { method: options.method || "GET", path });
throw new Error("Timed out");
throw new Error(translateApiMessage("Timed out"));
}
logError("api.network_error", error, { method: options.method || "GET", path });
if (options.body instanceof FormData && error instanceof TypeError) {
throw new Error("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.");
throw new Error(translateApiMessage("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit."));
}
throw error;
} finally {
@@ -261,15 +268,17 @@ export function uploadWithProgress(path, options = {}) {
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
const error = new Error(translateApiMessage("Upload failed before the server returned a response. Check the connection and try again."));
logError("api.network_error", error, { method, path });
fail(error);
});
xhr.addEventListener("abort", () => {
const error = new Error(stalled
const sourceMessage = stalled
? "Upload stopped making progress. Check the connection and try again."
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.";
const error = new Error(translateApiMessage(sourceMessage));
error.serverMessage = sourceMessage;
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
@@ -278,7 +287,8 @@ export function uploadWithProgress(path, options = {}) {
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true;
const error = new Error("Upload cancelled.");
const error = new Error(translateApiMessage("Upload cancelled."));
error.serverMessage = "Upload cancelled.";
error.name = "AbortError";
fail(error);
return;
+36 -8
View File
@@ -10,6 +10,7 @@
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
import { toast } from "@rustpad/toast";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
@@ -26,6 +27,21 @@ const clearResourceAccessState = sessionStore.clearResourceAccessState || (() =>
}
});
function authErrorTitle(mode) {
if (mode === "register") return "Registration failed";
if (mode === "reset") return "Password reset failed";
return "Sign-in failed";
}
function notifyAuthError(error, mode) {
toast.danger(error?.message || "Please try again.", { title: authErrorTitle(mode) });
}
if (typeof window !== "undefined") window.addEventListener("rustpad:session-expired", () => {
toast.warning("Your session expired. Sign in again to continue with account-only actions.", { title: "Session expired", duration: 7000 });
});
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
const directoryLogin = loginMode && externalAuth;
emailLabel.textContent = directoryLogin ? "E-mail / LDAP or AD Username" : "E-mail";
@@ -129,6 +145,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Reset link sent", duration: 6500 });
return;
}
@@ -141,16 +158,19 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.add("success");
message.textContent = session.message;
password.value = "";
toast.success(session.message, { title: "Check your inbox", duration: 7000 });
return;
}
setAuthSession(session);
await setAuthSession(session);
await onIdentity(session.nickname, session);
toast.success(`Signed in as ${session.nickname}.`, { title: "Signed in" });
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/confirm the account/i.test(error.message) && email.value.trim()) {
notifyAuthError(error, mode);
if (/confirm the account/i.test(error.serverMessage || error.message) && email.value.trim()) {
const resend = document.createElement("button");
resend.type = "button";
resend.className = "text-button resend-confirmation";
@@ -162,8 +182,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Confirmation e-mail sent", duration: 6500 });
} catch (resendError) {
message.textContent = resendError.message;
toast.danger(resendError.message, { title: "Could not resend confirmation" });
resend.disabled = false;
}
});
@@ -258,10 +280,12 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
toast.success(result.message, { title: "Reset link sent", duration: 6500 });
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
notifyAuthError(error, "reset");
}
});
@@ -270,7 +294,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
updateActions();
collapse();
nickname.value = "";
message.textContent = "Logged out.";
toast.info("You have been signed out.", { title: "Signed out" });
});
form.addEventListener("submit", async (event) => {
@@ -289,10 +313,12 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
message.classList.add("success");
message.textContent = session.message;
password.value = "";
toast.success(session.message, { title: "Check your inbox", duration: 7000 });
return;
}
setAuthSession(session);
await setAuthSession(session);
await onIdentity(session.nickname, session);
toast.success(`Signed in as ${session.nickname}.`, { title: "Signed in" });
dialog.close();
return;
}
@@ -300,12 +326,14 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
toast.info(`Continuing as ${result.nickname}.`, { title: "Guest session" });
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/registered|session|account/i.test(error.message)) showAuth("login");
notifyAuthError(error, authRequested ? mode : "login");
if (/registered|session|account/i.test(error.serverMessage || error.message)) showAuth("login");
}
});
@@ -318,10 +346,10 @@ export async function validateCurrentSession() {
const expectedSession = Boolean(getAuthToken());
try {
const session = await api("/api/auth/me");
setAuthSession(session);
await setAuthSession(session);
return session;
} catch {
if (expectedSession) clearAuthSession();
if (expectedSession) await clearAuthSession();
return null;
}
}
@@ -331,7 +359,7 @@ export async function logoutCurrentSession() {
await api("/api/auth/logout", { method: "POST" });
} catch { }
clearResourceAccessState();
clearAuthSession();
await clearAuthSession();
}
+14 -12
View File
@@ -7,11 +7,13 @@
* See LICENSE file in repository root for details.
*/
import { t } from "@rustpad/i18n";
function selection(editor) {
return { start: editor.selectionStart, end: editor.selectionEnd };
}
function toggleWrap(editor, before, after = before, placeholder = "tekst") {
function toggleWrap(editor, before, after = before, placeholder = t("editor.format.text", {}, "text")) {
let { start, end } = selection(editor);
const value = editor.value;
const selected = value.slice(start, end);
@@ -65,15 +67,15 @@ export function applyFormat(editor, format) {
if (format === "number") togglePrefix(editor, index => `${index + 1}. `);
if (format === "task") togglePrefix(editor, "- [ ] ");
if (format === "quote") togglePrefix(editor, "> ");
if (format === "link") toggleWrap(editor, "[", "](https://)", "description");
if (format === "inline-code") toggleWrap(editor, "`", "`", "code");
if (format === "highlight") toggleWrap(editor, "==", "==", "important");
if (format === "link") toggleWrap(editor, "[", "](https://)", t("editor.format.description", {}, "description"));
if (format === "inline-code") toggleWrap(editor, "`", "`", t("editor.format.code", {}, "code"));
if (format === "highlight") toggleWrap(editor, "==", "==", t("editor.format.important", {}, "important"));
if (format === "subscript") toggleWrap(editor, "~", "~", "2");
if (format === "superscript") toggleWrap(editor, "^", "^", "2");
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code");
if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", "code");
if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", "graph TD\n A[Start] --> B[End]");
if (format === "details") toggleWrap(editor, "<details>\n<summary>Click me</summary>\n\n", "\n</details>", "Content");
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", t("editor.format.code", {}, "code"));
if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", t("editor.format.code", {}, "code"));
if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", t("editor.format.diagram", {}, "graph TD\n A[Start] --> B[End]"));
if (format === "details") toggleWrap(editor, `<details>\n<summary>${t("editor.format.detailsSummary", {}, "Click me")}</summary>\n\n`, "\n</details>", t("editor.format.content", {}, "Content"));
if (format === "toc") {
const { start, end } = selection(editor);
const selected = editor.value.slice(start, end);
@@ -81,11 +83,11 @@ export function applyFormat(editor, format) {
}
if (format.startsWith("alert-")) {
const type = format.slice("alert-".length);
toggleWrap(editor, `:::${type}\n`, "\n:::", "Alert content");
toggleWrap(editor, `:::${type}\n`, "\n:::", t("editor.format.alertContent", {}, "Alert content"));
}
if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value");
if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote");
if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term");
if (format === "table") toggleWrap(editor, `| ${t("editor.format.column1", {}, "Column 1")} | ${t("editor.format.column2", {}, "Column 2")} |\n| --- | --- |\n| `, ` | ${t("editor.format.value", {}, "value")} |`, t("editor.format.value", {}, "value"));
if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote"));
if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term"));
if (format === "horizontal-rule") toggleWrap(editor, "\n---\n", "", "");
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
+35 -8
View File
@@ -8,9 +8,31 @@
*/
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
import { t } from "@rustpad/i18n";
const RECENTS_KEY = "rustpad:recent-emojis";
const MAX_RECENTS = 24;
const GROUP_KEYS = new Map([
["Smileys & Emotion", "emoji.group.smileysEmotion"],
["People & Body", "emoji.group.peopleBody"],
["Animals & Nature", "emoji.group.animalsNature"],
["Food & Drink", "emoji.group.foodDrink"],
["Travel & Places", "emoji.group.travelPlaces"],
["Activities", "emoji.group.activities"],
["Objects", "emoji.group.objects"],
["Symbols", "emoji.group.symbols"],
["Flags", "emoji.group.flags"],
]);
function groupLabel(name) {
if (name === "Recently Used") return t("emoji.recentGroup", {}, "Recently Used");
const key = GROUP_KEYS.get(name);
return key ? t(key, {}, name) : name;
}
function itemLabel(item) {
return t("emoji.itemLabel", { emoji: item.emoji }, `Emoji ${item.emoji}`);
}
function loadRecents() {
try {
@@ -50,7 +72,7 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
const found = group.items.find(item => item.emoji === value);
if (found) return found;
}
return { emoji: value, name: "Recent emoji", keywords: "recent" };
return { emoji: value, name: t("emoji.recentItem", {}, "Recent emoji"), keywords: "recent" };
});
return recentItems.length ? [{ name: "Recently Used", items: recentItems }, ...EMOJI_GROUPS] : EMOJI_GROUPS;
};
@@ -64,8 +86,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
button.className = "emoji-category";
button.dataset.emojiGroup = group.name;
button.textContent = group.items[0]?.emoji || "•";
button.title = group.name;
button.setAttribute("aria-label", group.name);
const label = groupLabel(group.name);
button.title = label;
button.setAttribute("aria-label", label);
button.setAttribute("aria-pressed", String(group.name === activeGroup));
return button;
}));
@@ -75,9 +98,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
const query = normalize(search.value.trim());
let items;
if (query) {
items = EMOJI_GROUPS.flatMap(group => group.items).filter(item =>
normalize(`${item.name} ${item.keywords}`).includes(query)
);
items = EMOJI_GROUPS.flatMap(group => group.items.map(item => ({ item, group })))
.filter(({ item, group }) => normalize(`${item.name} ${item.keywords} ${groupLabel(group.name)}`).includes(query))
.map(({ item }) => item);
} else {
items = groups().find(group => group.name === activeGroup)?.items || [];
}
@@ -88,8 +111,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
button.type = "button";
button.className = "emoji-item";
button.dataset.emoji = item.emoji;
button.title = item.name;
button.setAttribute("aria-label", item.name);
const label = itemLabel(item);
button.title = label;
button.setAttribute("aria-label", label);
button.textContent = item.emoji;
fragment.append(button);
}
@@ -126,4 +150,7 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
document.addEventListener("pointerdown", event => {
if (details.open && !details.contains(event.target)) details.removeAttribute("open");
});
document.addEventListener("rustpad:languagechange", () => {
if (details.open) render();
});
}
+104 -17
View File
@@ -17,6 +17,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { formatDateTime, populateLanguageSelect, setLanguage, t } from "@rustpad/i18n";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
@@ -68,6 +69,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
toast.danger(requestError.message, { title: "Could not create note" });
} finally {
setBusy(button, false, "Create note", "Creating…");
}
@@ -89,6 +91,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
toast.danger(requestError.message, { title: t("resources.createWorkspaceFailed", {}, "Could not create workspace") });
} finally {
setBusy(button, false, "Create workspace", "Creating…");
}
@@ -125,8 +128,8 @@ function resourceActionLabel(full, short = full) {
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
const full = isPrivate ? t("resource.makePublic", {}, "Make public") : t("resource.makePrivate", {}, "Make private");
const short = isPrivate ? t("common.public", {}, "Public") : t("common.private", {}, "Private");
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
@@ -134,13 +137,29 @@ function setResourcePrivacyLabel(button, isPrivate) {
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error(t("share.validityRange", {}, "Enter a validity between 1 and 87600 hours.")); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return t("share.neverExpires", {}, "Never expires"); const date = new Date(value); return Number.isNaN(date.getTime()) ? value : t("share.expires", { date: formatDateTime(date) }, `Expires ${formatDateTime(date)}`); }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : t("share.created", { date: formatDateTime(date) }, `Created ${formatDateTime(date)}`); }
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>${escapeHtml(t("common.previous", {}, "Previous"))}</button><span>${escapeHtml(t("pagination.items", { page: meta.page, pages: meta.total_pages, count: meta.total }, `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items`))}</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>${escapeHtml(t("common.next", {}, "Next"))}</button>` : "";
}
function setResourceMeta(element, item) {
if (!element) return;
const parts = [item.kind === "workspace" ? "Workspace" : "Note"];
if (item.private) parts.push("private");
if (!item.owned) parts.push(item.permission === "rw" ? "Read and write" : "Read only");
else if (item.protected) parts.push("password protected");
const nodes = [];
parts.forEach((part, index) => {
if (index) nodes.push(document.createTextNode(" · "));
const span = document.createElement("span");
span.textContent = part;
nodes.push(span);
});
element.replaceChildren(...nodes);
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
@@ -167,14 +186,16 @@ async function loadResources() {
const row = document.createElement("article");
row.className = "resource-row";
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
const permissionLabel = item.permission === "rw" ? t("permission.readWrite", {}, "Read and write") : t("permission.readOnly", {}, "Read only");
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small data-resource-meta></small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
setResourceMeta(row.querySelector("[data-resource-meta]"), item);
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -194,9 +215,11 @@ async function loadResources() {
item.private = nextPrivate;
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
setResourceMeta(meta, item);
toast.success(`${item.title} is now ${item.private ? "private" : "public"}.`, { title: "Visibility updated" });
} catch (e) {
resourcesError.textContent = e.message;
toast.danger(e.message, { title: "Could not update visibility" });
} finally {
button.disabled = false;
}
@@ -233,6 +256,9 @@ async function loadResources() {
const message = dialog.querySelector("[data-inline-message]");
message.className = `form-message resource-inline-message ${type}`.trim();
message.textContent = text;
if (type === "success") toast.success(text, { title: "Sharing updated" });
else if (type === "warning") toast.warning(text, { title: "Sharing needs attention" });
else if (type === "error") toast.danger(text, { title: "Sharing action failed" });
};
const userForm = dialog.querySelector("[data-user-share-form]");
const linkForm = dialog.querySelector("[data-link-form]");
@@ -317,9 +343,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password }) });
toast.success(`Password protection is enabled for ${item.title}.`, { title: "Password saved" });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not save password" });
submit.disabled = false;
}
});
@@ -336,9 +364,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
toast.warning(`Password protection was removed from ${item.title}.`, { title: "Password removed", duration: 6000 });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not remove password" });
event.currentTarget.disabled = false;
}
});
@@ -353,9 +383,11 @@ async function loadResources() {
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug }) });
toast.success(`${item.kind === "workspace" ? "Workspace" : "Note"} deleted.`, { title: "Item removed" });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
toast.danger(e.message, { title: "Could not delete item" });
event.currentTarget.disabled = false;
}
});
@@ -363,7 +395,7 @@ async function loadResources() {
resourcesList.append(row);
}
renderResourcesPagination(data.pagination);
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; }
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); }
}
resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); });
@@ -413,7 +445,7 @@ aboutDialog?.addEventListener("click", event => { if (event.target === aboutDial
if (identityDialog) {
const authDialog = bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (_nickname, session) => { renderAccount(session); if (session) toast(`Logged in as ${session.nickname}.`); },
onIdentity: async (_nickname, session) => { renderAccount(session); },
});
document.querySelector("#footer-login")?.addEventListener("click", () => {
@@ -431,6 +463,7 @@ if (identityDialog) {
profileColor.value = currentSession?.editor_color || "#7c6cff";
const selectedTheme = currentSession?.theme || getTheme();
profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; });
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
document.querySelector("#profile-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = "";
@@ -440,7 +473,7 @@ if (identityDialog) {
profileMessage.classList.remove("success", "error");
const directoryManaged = Boolean(currentSession?.directory_managed);
document.querySelector("#profile-copy").textContent = directoryManaged
? "Directory account details are read-only. You can change the nickname, editor color, and interface theme."
? "Directory account details are read-only. You can change the nickname, editor color, interface theme, and language."
: "Manage your local RustPad account.";
document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; });
document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; });
@@ -452,18 +485,72 @@ if (identityDialog) {
document.querySelector("#profile-password").required = false;
profileDialog.showModal();
});
document.addEventListener("rustpad:languagechange", () => {
if (!profileDialog?.open) return;
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
});
document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
profileForm?.addEventListener("submit", async event => {
event.preventDefault(); const message = document.querySelector("#profile-message"); message.textContent = ""; message.classList.remove("success", "error");
try { const selectedColor = document.querySelector("#profile-color").value; const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark"; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, theme: selectedTheme, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; currentSession.theme = result.theme; applyTheme(result.theme); renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
event.preventDefault();
const message = document.querySelector("#profile-message");
const saveButton = profileForm.querySelector('button[type="submit"]');
message.textContent = "";
message.classList.remove("success", "error");
if (saveButton) saveButton.disabled = true;
try {
const selectedColor = document.querySelector("#profile-color").value;
const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark";
const selectedLanguage = document.querySelector("#profile-language")?.value || "en";
const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null);
const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null);
const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value;
if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password.");
const result = await api("/api/auth/profile", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
nickname: document.querySelector("#profile-nickname").value.trim(),
editor_color: selectedColor,
theme: selectedTheme,
language: selectedLanguage,
new_email: newEmail,
new_password: newPassword,
password,
}),
});
currentSession.nickname = result.nickname;
currentSession.editor_color = result.editor_color;
currentSession.theme = result.theme;
currentSession.language = result.language || "en";
applyTheme(result.theme);
await setLanguage(currentSession.language);
populateLanguageSelect(document.querySelector("#profile-language"), currentSession.language);
renderAccount(currentSession);
message.textContent = result.message;
message.classList.add("success");
toast.success(
t("profile.saved", {}, "Your profile settings were saved."),
{ title: t("profile.updated", {}, "Profile updated") }
);
} catch (error) {
message.textContent = error.message;
message.classList.add("error");
toast.danger(error.message, { title: t("profile.updateFailed", {}, "Could not update profile") });
} finally {
if (saveButton) saveButton.disabled = false;
}
});
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value;
message.classList.remove("success", "error");
if (!password) { message.textContent = "Enter the current password first."; message.classList.add("error"); return; }
if (!confirm("Send an e-mail link to permanently delete this account?")) return;
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
if (!confirm(t("auth.delete.confirm", {}, "Send an e-mail link to permanently delete this account?"))) return;
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); toast.info(result.message, { title: "Check your inbox", duration: 6500 }); } catch (e) { message.textContent = e.message; message.classList.add("error"); toast.danger(e.message, { title: "Could not request account deletion" }); }
});
document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
@@ -472,7 +559,7 @@ if (identityDialog) {
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
await logoutCurrentSession();
renderAccount(null);
toast("Logged out.");
toast.info("You have been signed out.", { title: "Signed out" });
});
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
+526
View File
@@ -0,0 +1,526 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Frontend internationalization. Language files live in /lang at the repository
* root and are embedded into the Rust binary at build time.
*/
const STORAGE_KEY = "rustpad:language";
const FALLBACK_LANGUAGE = "en";
const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"];
const SKIP_SELECTOR = [
"script",
"style",
"textarea",
"#editor",
"#preview",
"#chat-messages",
".markdown-body",
".revision__snippet",
".revision__preview",
".chat-message",
".resource-title-line a",
".share-list-identity",
".note-card-title h3",
".note-table-link",
"#document-title",
"#workspace-title",
"#public-title",
"#room-users",
"[data-account-primary]",
"[data-account-secondary]",
"[contenteditable='true']",
"[data-i18n-ignore]",
].join(",");
const textState = new WeakMap();
const attributeState = new WeakMap();
const bundles = new Map();
let catalog = [];
let activeCode = FALLBACK_LANGUAGE;
let activeBundle = null;
let fallbackBundle = null;
let sourceIndex = new Map();
let sourcePatterns = [];
let observer = null;
let initialized = false;
let initialization = null;
function configVersion() {
return window.__RUSTPAD_CONFIG__?.assetVersion || "dev";
}
function normalizeSource(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileSourcePattern(source, key) {
const names = [];
let cursor = 0;
let expression = "^";
const placeholder = /\{([a-zA-Z0-9_]+)\}/g;
let match;
while ((match = placeholder.exec(source))) {
expression += escapeRegExp(source.slice(cursor, match.index));
expression += "(.+?)";
names.push(match[1]);
cursor = match.index + match[0].length;
}
expression += escapeRegExp(source.slice(cursor));
expression += "$";
try {
return { key, source, names, regex: new RegExp(expression, "u") };
} catch {
return null;
}
}
function rebuildSourceIndex() {
sourceIndex = new Map();
sourcePatterns = [];
const indexedBundles = new Set();
const candidates = [fallbackBundle, ...bundles.values()].filter(Boolean);
for (const bundle of candidates) {
if (indexedBundles.has(bundle)) continue;
indexedBundles.add(bundle);
for (const [key, rawValue] of Object.entries(bundle.translations || {})) {
if (typeof rawValue !== "string" || !rawValue.trim()) continue;
const value = normalizeSource(rawValue);
if (!sourceIndex.has(value)) sourceIndex.set(value, key);
if (/\{[a-zA-Z0-9_]+\}/.test(value)) {
const pattern = compileSourcePattern(value, key);
if (pattern) sourcePatterns.push(pattern);
}
}
}
sourcePatterns.sort((left, right) => right.source.length - left.source.length);
}
function interpolate(value, params = {}) {
return String(value ?? "").replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
);
}
export function t(key, params = {}, fallback = key) {
const active = activeBundle?.translations?.[key];
const base = fallbackBundle?.translations?.[key];
const value = typeof active === "string" ? active : typeof base === "string" ? base : fallback;
return interpolate(value, params);
}
export function tp(key, count, params = {}, fallback = "") {
const numericCount = Number(count);
let category = "other";
try { category = new Intl.PluralRules(getLocale()).select(numericCount); } catch { /* use other */ }
const candidates = [`${key}.${category}`, `${key}.other`, `${key}.many`, `${key}.few`, `${key}.one`];
for (const candidate of candidates) {
const active = activeBundle?.translations?.[candidate];
const base = fallbackBundle?.translations?.[candidate];
const value = typeof active === "string" ? active : typeof base === "string" ? base : null;
if (value != null) return interpolate(value, { ...params, count: numericCount });
}
return interpolate(fallback || String(numericCount), { ...params, count: numericCount });
}
function matchSource(value) {
const normalized = normalizeSource(value);
if (!normalized) return null;
const exactKey = sourceIndex.get(normalized);
if (exactKey) return { key: exactKey, params: {} };
for (const pattern of sourcePatterns) {
const match = normalized.match(pattern.regex);
if (!match) continue;
const params = {};
pattern.names.forEach((name, index) => { params[name] = match[index + 1]; });
return { key: pattern.key, params };
}
return null;
}
export function translateSource(value) {
const raw = String(value ?? "");
if (!raw.trim()) return raw;
const leading = raw.match(/^\s*/u)?.[0] || "";
const trailing = raw.match(/\s*$/u)?.[0] || "";
const match = matchSource(raw);
if (!match) return raw;
return `${leading}${t(match.key, match.params, normalizeSource(raw))}${trailing}`;
}
export function translateApiMessage(message) {
return translateSource(message);
}
function isSkipped(element) {
return element instanceof Element && Boolean(element.closest(SKIP_SELECTOR));
}
function translateTextNode(node, force = false) {
const parent = node.parentElement;
if (!parent || isSkipped(parent)) return;
const current = node.data;
let state = textState.get(node);
if (!state || (!force && current !== state.rendered)) {
state = { original: current, rendered: current };
textState.set(node, state);
}
const rendered = translateSource(state.original);
state.rendered = rendered;
if (node.data !== rendered) node.data = rendered;
}
function attributeMap(element) {
let map = attributeState.get(element);
if (!map) {
map = new Map();
attributeState.set(element, map);
}
return map;
}
function translateAttribute(element, name, force = false) {
if (!element.hasAttribute(name) || isSkipped(element)) return;
const current = element.getAttribute(name) || "";
const map = attributeMap(element);
let state = map.get(name);
if (!state || (!force && current !== state.rendered)) {
state = { original: current, rendered: current };
map.set(name, state);
}
const rendered = translateSource(state.original);
state.rendered = rendered;
if (current !== rendered) element.setAttribute(name, rendered);
}
function translateExplicit(element) {
const key = element.dataset.i18n;
if (key) element.textContent = t(key);
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
const attrKey = element.dataset[`i18n${attribute.replace(/(^|-)([a-z])/g, (_, __, char) => char.toUpperCase())}`];
if (attrKey) element.setAttribute(attribute, t(attrKey));
}
}
function translateElementAttributes(element, force = false) {
translateExplicit(element);
if (isSkipped(element)) return;
for (const name of TRANSLATABLE_ATTRIBUTES) translateAttribute(element, name, force);
}
function translateTree(root = document, force = false) {
if (root instanceof Element) translateElementAttributes(root, force);
const elementRoot = root instanceof Document ? root.documentElement : root;
if (!elementRoot) return;
const elementWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_ELEMENT);
let element = elementWalker.currentNode;
while (element) {
if (element instanceof Element) translateElementAttributes(element, force);
element = elementWalker.nextNode();
}
const textWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_TEXT);
let textNode = textWalker.nextNode();
while (textNode) {
translateTextNode(textNode, force);
textNode = textWalker.nextNode();
}
}
function startObserver() {
observer?.disconnect();
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === "characterData") {
translateTextNode(mutation.target);
continue;
}
if (mutation.type === "attributes") {
translateAttribute(mutation.target, mutation.attributeName);
continue;
}
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node);
else if (node instanceof Element) translateTree(node);
}
}
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATABLE_ATTRIBUTES,
});
}
async function fetchJson(path) {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(`${path}${separator}v=${encodeURIComponent(configVersion())}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(t("i18n.loadFailed", { status: response.status }, `Could not load language resource (${response.status})`));
return response.json();
}
async function loadCatalog() {
if (catalog.length) return catalog;
const result = await fetchJson("/lang");
catalog = Array.isArray(result) ? result.filter(item => item && item.code && item.locale) : [];
return catalog;
}
async function loadBundle(code) {
if (bundles.has(code)) return bundles.get(code);
const bundle = await fetchJson(`/lang/${encodeURIComponent(code)}.json`);
if (!bundle?.meta?.code || !bundle?.translations) throw new Error(`Invalid language bundle: ${code}`);
bundles.set(bundle.meta.code, bundle);
return bundle;
}
function preferredCode() {
// English is always the default. A cached language is only used while an
// authenticated profile is expected; the server session remains the source
// of truth and will re-apply its saved preference after validation.
let hasAccountSession = false;
let saved = "";
try {
hasAccountSession = localStorage.getItem("rustpad:auth-state") === "1";
if (hasAccountSession) saved = localStorage.getItem(STORAGE_KEY) || "";
} catch { /* storage may be blocked */ }
if (!hasAccountSession) return FALLBACK_LANGUAGE;
const available = new Set(catalog.map(item => item.code));
return available.has(saved) ? saved : FALLBACK_LANGUAGE;
}
function applyDocumentLanguage() {
const meta = activeBundle?.meta || fallbackBundle?.meta;
if (!meta) return;
document.documentElement.lang = meta.code;
document.documentElement.dataset.locale = meta.locale;
}
export async function setLanguage(code, { persist = true } = {}) {
await initI18n();
const available = catalog.find(item => item.code === code);
const target = available ? available.code : FALLBACK_LANGUAGE;
activeBundle = await loadBundle(target);
activeCode = activeBundle.meta.code;
rebuildSourceIndex();
if (persist) {
try { localStorage.setItem(STORAGE_KEY, activeCode); } catch { /* storage may be blocked */ }
}
applyDocumentLanguage();
translateTree(document, true);
document.dispatchEvent(new CustomEvent("rustpad:languagechange", {
detail: { language: activeCode, locale: activeBundle.meta.locale },
}));
return activeCode;
}
export function getLanguage() {
return activeCode;
}
export function getLocale() {
return activeBundle?.meta?.locale || fallbackBundle?.meta?.locale || "en-US";
}
export function getAvailableLanguages() {
return catalog.map(item => ({ ...item }));
}
function languageLabel(language) {
const nativeName = language.native_name || language.name || language.code;
const englishName = language.name || nativeName;
return nativeName === englishName ? nativeName : `${nativeName} · ${englishName}`;
}
function closeLanguagePicker(picker, { focusTrigger = false } = {}) {
if (!(picker instanceof HTMLElement)) return;
const menu = picker.querySelector(".profile-language-menu");
const trigger = picker.querySelector(".profile-language-trigger");
if (menu instanceof HTMLElement) menu.hidden = true;
picker.removeAttribute("data-open");
trigger?.setAttribute("aria-expanded", "false");
if (focusTrigger) trigger?.focus();
}
function focusLanguageOption(menu, direction = 1) {
const options = [...menu.querySelectorAll(".profile-language-option")];
if (!options.length) return;
const current = document.activeElement;
const index = options.indexOf(current);
const next = index < 0
? (direction > 0 ? 0 : options.length - 1)
: (index + direction + options.length) % options.length;
options[next].focus();
}
function bindLanguagePicker(input, picker) {
if (picker.dataset.languagePickerBound === "true") return;
picker.dataset.languagePickerBound = "true";
const trigger = picker.querySelector(".profile-language-trigger");
const menu = picker.querySelector(".profile-language-menu");
if (!(trigger instanceof HTMLButtonElement) || !(menu instanceof HTMLElement)) return;
const open = (focusSelected = false) => {
menu.hidden = false;
picker.dataset.open = "true";
trigger.setAttribute("aria-expanded", "true");
if (focusSelected) {
const selected = menu.querySelector('[aria-selected="true"]') || menu.querySelector(".profile-language-option");
selected?.focus();
}
};
trigger.addEventListener("click", () => {
if (menu.hidden) open(false);
else closeLanguagePicker(picker);
});
trigger.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
open(true);
}
});
menu.addEventListener("click", event => {
const option = event.target.closest(".profile-language-option");
if (!(option instanceof HTMLElement)) return;
input.value = option.dataset.languageCode || FALLBACK_LANGUAGE;
populateLanguageSelect(input, input.value);
closeLanguagePicker(picker, { focusTrigger: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
menu.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
focusLanguageOption(menu, event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const options = [...menu.querySelectorAll(".profile-language-option")];
(event.key === "Home" ? options[0] : options.at(-1))?.focus();
} else if (event.key === "Escape") {
event.preventDefault();
closeLanguagePicker(picker, { focusTrigger: true });
}
});
document.addEventListener("pointerdown", event => {
if (!picker.contains(event.target)) closeLanguagePicker(picker);
});
picker.closest("dialog")?.addEventListener("close", () => closeLanguagePicker(picker));
}
export function populateLanguageSelect(input, selectedCode = getLanguage()) {
const selected = catalog.find(item => item.code === selectedCode)
|| catalog.find(item => item.code === FALLBACK_LANGUAGE)
|| catalog[0];
if (!selected) return;
if (input instanceof HTMLSelectElement) {
input.replaceChildren(...catalog.map(language => {
const option = document.createElement("option");
option.value = language.code;
option.textContent = languageLabel(language);
option.title = `${language.native_name || language.name || language.code} (${language.locale})`;
option.lang = language.code;
return option;
}));
input.value = selected.code;
return;
}
if (!(input instanceof HTMLInputElement)) return;
const picker = input.closest("[data-language-picker]");
if (!(picker instanceof HTMLElement)) return;
bindLanguagePicker(input, picker);
const current = picker.querySelector(".profile-language-current");
const trigger = picker.querySelector(".profile-language-trigger");
const menu = picker.querySelector(".profile-language-menu");
input.value = selected.code;
if (current instanceof HTMLElement) {
current.textContent = languageLabel(selected);
current.lang = selected.code;
}
if (trigger instanceof HTMLElement) {
trigger.title = `${selected.native_name || selected.name || selected.code} (${selected.locale})`;
}
if (!(menu instanceof HTMLElement)) return;
menu.replaceChildren(...catalog.map(language => {
const option = document.createElement("button");
const nativeName = language.native_name || language.name || language.code;
const englishName = language.name || nativeName;
const isSelected = language.code === selected.code;
option.type = "button";
option.className = "profile-language-option";
option.dataset.languageCode = language.code;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", isSelected ? "true" : "false");
option.lang = language.code;
option.innerHTML = `<span class="profile-language-option__check" aria-hidden="true">${isSelected ? "✓" : ""}</span><span class="profile-language-option__copy"><strong></strong><small></small></span>`;
option.querySelector("strong").textContent = nativeName;
option.querySelector("small").textContent = nativeName === englishName ? language.locale : `${englishName} · ${language.locale}`;
return option;
}));
}
export function formatDateTime(value, options) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value ?? "");
return new Intl.DateTimeFormat(getLocale(), options).format(date);
}
export function formatTime(value, options = { hour: "2-digit", minute: "2-digit" }) {
return formatDateTime(value, options);
}
export function formatNumber(value, options) {
return new Intl.NumberFormat(getLocale(), options).format(value);
}
function revealDocumentAfterI18n() {
const root = document.documentElement;
root.removeAttribute("data-i18n-pending");
if (root.style.visibility === "hidden") root.style.removeProperty("visibility");
}
export async function initI18n() {
if (initialized) return;
if (initialization) return initialization;
initialization = (async () => {
try {
await loadCatalog();
const code = preferredCode();
const [fallback, preferred] = await Promise.all([
loadBundle(FALLBACK_LANGUAGE),
code === FALLBACK_LANGUAGE ? Promise.resolve(null) : loadBundle(code),
]);
fallbackBundle = fallback;
activeBundle = preferred || fallbackBundle;
activeCode = activeBundle.meta.code;
rebuildSourceIndex();
} catch (error) {
console.warn("RustPad i18n initialization failed; using source language.", error);
catalog = catalog.length ? catalog : [{ code: "en", name: "English", native_name: "English", locale: "en-US" }];
fallbackBundle = fallbackBundle || { meta: catalog[0], translations: {} };
activeBundle = fallbackBundle;
activeCode = FALLBACK_LANGUAGE;
rebuildSourceIndex();
}
applyDocumentLanguage();
translateTree(document);
startObserver();
revealDocumentAfterI18n();
initialized = true;
})();
await initialization;
}
+7 -6
View File
@@ -8,6 +8,7 @@
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
import { t } from "@rustpad/i18n";
import { parseImageAlias } from "@rustpad/image-alias";
function escapeHtml(value) {
@@ -224,7 +225,7 @@ function renderStandaloneMedia(line, sourceLine) {
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>Playback is unavailable. <a href="${downloadUrl}" download="${escapeHtml(filename)}">Download ${escapeHtml(label)}</a>.</p></div>`;
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>${escapeHtml(t("markdown.playbackUnavailable", {}, "Playback is unavailable."))} <a href="${downloadUrl}" download="${escapeHtml(filename)}">${escapeHtml(t("markdown.download", { label }, `Download ${label}`))}</a>.</p></div>`;
}
const trimmed = String(line).trim();
@@ -232,8 +233,8 @@ function renderStandaloneMedia(line, sourceLine) {
const candidate = markdownLink ? markdownLink[2] : trimmed;
const youtube = youtubeVideo(candidate);
if (!youtube) return null;
const title = markdownLink?.[1]?.trim() || "YouTube video";
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(title)}</a></p></div></div>`;
const title = markdownLink?.[1]?.trim() || t("markdown.youtubeVideo", {}, "YouTube video");
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t("markdown.open", { title }, `Open ${title}`))}</a></p></div></div>`;
}
function listLine(line) {
@@ -462,11 +463,11 @@ export function renderMarkdown(source, lineOffset = 0) {
while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++;
if (end < lines.length) {
let bodyStart = index + 1;
let summary = "Details";
let summary = t("markdown.details", {}, "Details");
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
if (bodyStart < end) {
const summaryMatch = lines[bodyStart].trim().match(/^<summary>([\s\S]*?)<\/summary>$/i);
if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; bodyStart++; }
if (summaryMatch) { summary = summaryMatch[1].trim() || t("markdown.details", {}, "Details"); bodyStart++; }
}
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
const body = lines.slice(bodyStart, end).join("\n");
@@ -480,7 +481,7 @@ export function renderMarkdown(source, lineOffset = 0) {
closeList();
const tocHeadings = headings.filter(item => item.index > index);
if (tocHeadings.length) {
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="${escapeHtml(t("markdown.toc", {}, "Table of contents"))}">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
}
continue;
}
+15 -13
View File
@@ -7,6 +7,8 @@
* See LICENSE file in repository root for details.
*/
import { t, translateSource } from "@rustpad/i18n";
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
@@ -14,10 +16,10 @@ function ensureDialog() {
dialog.id = "system-dialog";
dialog.className = "app-dialog";
dialog.innerHTML = `<form method="dialog" class="dialog-panel system-dialog-panel">
<button class="modal-close" value="cancel" aria-label="Close dialog">×</button>
<button class="modal-close" value="cancel" aria-label="${t("common.closeDialog", {}, "Close dialog")}">×</button>
<h2 data-title></h2><p class="dialog-copy" data-message></p>
<label data-input-wrap hidden><span data-input-label></span><input data-input name="modal-input" data-bwignore="true"></label>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>Cancel</button><button class="primary-button" value="confirm" data-confirm>OK</button></div>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>${t("common.cancel", {}, "Cancel")}</button><button class="primary-button" value="confirm" data-confirm>${t("common.ok", {}, "OK")}</button></div>
</form>`;
document.body.append(dialog);
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
@@ -26,23 +28,23 @@ function ensureDialog() {
export function showMessage(message, { title = "Information", button = "OK" } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = true;
dialog.querySelector("[data-confirm]").textContent = button;
dialog.querySelector("[data-confirm]").textContent = translateSource(button);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => resolve(), { once: true }));
}
export function askConfirm(message, { title = "Confirm", confirmText = "Confirm", danger = false } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = false;
const confirm = dialog.querySelector("[data-confirm]");
confirm.textContent = confirmText;
confirm.textContent = translateSource(confirmText);
confirm.classList.toggle("danger-button", danger);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => {
@@ -53,18 +55,18 @@ export function askConfirm(message, { title = "Confirm", confirmText = "Confirm"
export function askInput({ title, message = "", label, type = "text", autocomplete = "off", minLength, placeholder = "", confirmText = "Continue", bitwardenIgnore = false }) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-title]").textContent = translateSource(title);
dialog.querySelector("[data-message]").textContent = translateSource(message);
const wrap = dialog.querySelector("[data-input-wrap]");
const input = dialog.querySelector("[data-input]");
wrap.hidden = false;
dialog.querySelector("[data-input-label]").textContent = label;
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = placeholder;
dialog.querySelector("[data-input-label]").textContent = translateSource(label);
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = translateSource(placeholder);
input.name = type === "password" ? "new-password" : type === "email" ? "email" : "modal-input";
if (bitwardenIgnore) input.setAttribute("data-bwignore", "true"); else input.removeAttribute("data-bwignore");
input.minLength = minLength || 0;
dialog.querySelector("[data-cancel]").hidden = false;
dialog.querySelector("[data-confirm]").textContent = confirmText;
dialog.querySelector("[data-confirm]").textContent = translateSource(confirmText);
dialog.showModal();
queueMicrotask(() => input.focus());
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true }));
+5
View File
@@ -10,6 +10,7 @@
import { api } from "@rustpad/api";
import { askConfirm } from "@rustpad/modal";
import { NoteSocket, PadSocket } from "@rustpad/socket";
import { queueToast } from "@rustpad/toast";
function encode(value) {
return encodeURIComponent(value);
@@ -118,6 +119,10 @@ export function createWorkspaceNoteAdapter() {
method: "DELETE",
body: JSON.stringify({ access_token: accessToken || null }),
});
queueToast(`The note "${info.title}" was deleted.`, {
type: "success",
title: "Note deleted",
});
location.assign(`/w/${encode(workspaceSlug)}`);
},
};
+88 -57
View File
@@ -26,6 +26,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast";
import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
@@ -245,7 +246,7 @@ export function startNoteEditor(adapter) {
syncMobileEditorControls();
updateCurrentUser(); return info;
}
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = t(entries.length === 1 ? "editor.user" : "editor.users", { count: entries.length }, `${entries.length} ${entries.length === 1 ? "user" : "users"}`); roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || t("editor.guest", {}, "Guest"); li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = t("editor.noActiveUsers", {}, "No active users"); roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) {
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
socketLatency.textContent = text;
@@ -269,32 +270,56 @@ export function startNoteEditor(adapter) {
const latency = runtime.latency || {};
const client = server.client || {};
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
const fallbackQuality = quality.charAt(0).toUpperCase() + quality.slice(1);
const qualityLabel = t(`diagnostics.quality.${quality}`, {}, t(`diagnostics.state.${quality}`, {}, fallbackQuality));
setDiagnosticField("quality", qualityLabel);
setDiagnosticField("latency", Number.isFinite(latency.current)
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`
: "Waiting for heartbeat");
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
? t("diagnostics.latency", {
current: formatNumber(latency.current),
average: formatNumber(latency.average),
minimum: formatNumber(latency.minimum),
maximum: formatNumber(latency.maximum),
}, `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`)
: t("diagnostics.waitHeartbeat", {}, "Waiting for heartbeat"));
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${formatNumber(latency.jitter)} ms` : "—");
setDiagnosticField("uptime", runtime.authenticated_at
? formatDiagnosticDuration(runtime.uptime_ms)
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
: runtime.last_connection_uptime_ms
? t("diagnostics.lastUptime", { duration: formatDiagnosticDuration(runtime.last_connection_uptime_ms) }, `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}`)
: "—");
setDiagnosticField("reconnects", runtime.reconnect_attempt
? t("diagnostics.reconnectAttempt", { count: formatNumber(runtime.total_reconnects || 0), attempt: formatNumber(runtime.reconnect_attempt) }, `${runtime.total_reconnects || 0} · attempt ${runtime.reconnect_attempt}`)
: formatNumber(runtime.total_reconnects || 0));
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
const lastEvent = runtime.last_close
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || t("diagnostics.waitServer", {}, "Waiting for server data"));
let lastEvent;
if (runtime.last_close) {
const reason = runtime.last_close.reason ? translateSource(runtime.last_close.reason) : "";
lastEvent = reason
? t("diagnostics.closedReason", { code: runtime.last_close.code, reason }, `Closed ${runtime.last_close.code}: ${reason}`)
: t("diagnostics.closed", { code: runtime.last_close.code }, `Closed ${runtime.last_close.code}`);
} else if (runtime.last_message_at) {
const time = formatTime(runtime.last_message_at);
lastEvent = t("diagnostics.messageAt", { time }, `Message ${time}`);
} else {
lastEvent = t("editor.noMessages", {}, "No messages yet");
}
const traffic = t("diagnostics.traffic", { received: formatBytes(runtime.bytes_received), sent: formatBytes(runtime.bytes_sent) }, `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`);
const buffered = runtime.buffered_amount
? t("diagnostics.buffered", { amount: formatBytes(runtime.buffered_amount) }, ` · ${formatBytes(runtime.buffered_amount)} buffered`)
: "";
const visibility = String(runtime.visibility || document.visibilityState || "visible");
const visibilityLabel = t(`diagnostics.visibility.${visibility}`, {}, visibility);
setDiagnosticField("last-event", `${lastEvent} · ${visibilityLabel} · ${traffic}${buffered}`);
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
if (!details) continue;
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
}
}
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(t("editor.notificationChat", { sender: message.sender }, `${message.sender} wrote in RustPad`), { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
@@ -346,7 +371,7 @@ export function startNoteEditor(adapter) {
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = "Failed to load Mermaid.";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
node.parentNode.insertBefore(message, node);
});
}
@@ -358,7 +383,7 @@ export function startNoteEditor(adapter) {
const people = new Map();
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
for (const user of presenceUsers) {
const name = user.name || "Guest";
const name = user.name || t("editor.guest", {}, "Guest");
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
people.set(name, { name, compactName: user.compact_name || name, color });
}
@@ -449,7 +474,7 @@ export function startNoteEditor(adapter) {
gutter.querySelector(`[data-line="${line}"]`)?.classList.add("is-linked");
syncEditorLayers();
}
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : formatDateTime(date, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function previewNodeMarkdownParts(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
@@ -610,7 +635,7 @@ export function startNoteEditor(adapter) {
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${formatNumber(index === 0 ? Math.round(size) : size, { maximumFractionDigits: index === 0 ? 0 : size >= 10 ? 1 : 2 })} ${units[index]}`; }
function replaceTableCell(line, index, value) {
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
@@ -674,24 +699,24 @@ export function startNoteEditor(adapter) {
const tools = document.createElement("div");
tools.className = "image-alias-tools";
tools.setAttribute("role", "toolbar");
tools.setAttribute("aria-label", "Image layout");
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="Image alignment">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>Auto</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>Left</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>Center</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>Right</button>
tools.setAttribute("aria-label", t("files.imageLayout", {}, "Image layout"));
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="${escapeHtml(t("files.imageAlignment", {}, "Image alignment"))}">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>${escapeHtml(t("editor.layout.auto", {}, "Auto"))}</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.left", {}, "Left"))}</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.center", {}, "Center"))}</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>${escapeHtml(t("editor.layout.right", {}, "Right"))}</button>
</div><div class="image-alias-size">
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="Image width"></label>
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="${escapeHtml(t("files.imageWidth", {}, "Image width"))}"></label>
<span aria-hidden="true">×</span>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="Image height"></label>
<button type="button" data-image-size-apply>Set</button>
<button type="button" data-image-size-reset>Natural</button>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="${escapeHtml(t("files.imageHeight", {}, "Image height"))}"></label>
<button type="button" data-image-size-apply>${escapeHtml(t("common.set", {}, "Set"))}</button>
<button type="button" data-image-size-reset>${escapeHtml(t("editor.layout.natural", {}, "Natural"))}</button>
</div>`;
const resize = document.createElement("button");
resize.type = "button";
resize.className = "image-alias-resize";
resize.setAttribute("aria-label", "Resize image");
resize.title = "Drag to resize";
resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image"));
resize.title = t("editor.dragResize", {}, "Drag to resize");
frame.append(tools, resize);
}
@@ -719,7 +744,7 @@ export function startNoteEditor(adapter) {
const width = Math.round(Number(tools?.querySelector("[data-image-width-input]")?.value));
const height = Math.round(Number(tools?.querySelector("[data-image-height-input]")?.value));
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1 || width > 10000 || height > 10000) {
toast("Image size must be between 1 and 10000 px.");
toast.warning("Enter a width and height between 1 and 10,000 px.", { title: "Invalid image size" });
return;
}
updateImageFrameAlias(frame, { width, height });
@@ -1295,7 +1320,7 @@ export function startNoteEditor(adapter) {
const result = collaboration.resynchronize(message);
if (result.replayed) {
flushRequested = true;
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
toast.info(reason === "resync" ? "Pending edits were merged after reconnecting." : "A missed update was merged with your local edits.", { title: "Changes synchronized", duration: 5200 });
}
} catch (error) {
console.error("Failed to transform pending changes during resynchronization", error);
@@ -1311,9 +1336,9 @@ export function startNoteEditor(adapter) {
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
);
flushRequested = true;
toast("A synchronization conflict was preserved as a local recovery block.");
toast.warning("A sync conflict occurred. Your local changes were preserved in a recovery block.", { title: "Local changes recovered", duration: 7000 });
} else {
toast("Synchronization failed because the recoverable document exceeds the size limit.");
toast.danger("The recovery copy is larger than the document size limit.", { title: "Recovery failed", duration: 7000 });
}
}
applyCollaborativeView({ resetHistory: true });
@@ -1374,7 +1399,7 @@ export function startNoteEditor(adapter) {
try {
const result = integrateCollaborativeEnvelope(message);
if (result.duplicate) return;
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
const timestamp = formatTime(message.updated_at, { hour: "2-digit", minute: "2-digit" });
if (collaboration.hasPending()) saveState.textContent = "Saving…";
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
@@ -1498,6 +1523,7 @@ export function startNoteEditor(adapter) {
: "Password required";
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
try {
@@ -1510,7 +1536,7 @@ export function startNoteEditor(adapter) {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
if (/read-only access/i.test(message)) {
toast(friendly);
toast.warning(friendly, { title: "Read-only access", duration: 6500 });
accessLevel.textContent = "Access: read only";
setDocumentReadOnly(true, "Read only — changes not saved");
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
@@ -1533,7 +1559,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus();
return;
}
toast(friendly);
toast.danger(friendly, { title: "Editor connection error" });
},
});
socket.connect();
@@ -1869,7 +1895,7 @@ export function startNoteEditor(adapter) {
await adapter.saveEditorSettings(sessionHeaders(), settings);
if (savePersonal) info.personal_editor_settings = true;
} catch (error) {
toast(error.message);
toast.danger(error.message, { title: "Could not save editor settings" });
} finally {
editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
@@ -1890,14 +1916,14 @@ export function startNoteEditor(adapter) {
gutter.querySelectorAll(".line-number-button.is-copied").forEach(item => item.classList.remove("is-copied"));
button.classList.add("is-copied");
setTimeout(() => button.classList.remove("is-copied"), 900);
toast(`Link to line ${line} copied`);
toast.success(`Link to line ${line} copied to the clipboard.`, { title: "Link copied" });
} catch (error) {
toast(error.message);
toast.danger(error.message, { title: "Could not copy line link" });
}
});
async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
try { await copyText(currentShareUrl(uiState)); toast.success("Note link copied to the clipboard.", { title: "Link copied" }); }
catch (error) { toast.danger(error.message, { title: "Could not copy note link" }); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
@@ -2129,11 +2155,12 @@ export function startNoteEditor(adapter) {
socket?.stop();
loadFiles();
connect();
toast("Password set. Page options are now available.");
toast.success("Password protection is enabled. Publishing options are now available.", { title: "Protection enabled" });
pageSettings.open = true;
requestAnimationFrame(() => publicPageEnabled.focus());
} catch (error) {
setPagePasswordError.textContent = error.message;
toast.danger(error.message, { title: "Could not set password" });
} finally {
submit.disabled = false;
updatePageControls();
@@ -2144,17 +2171,21 @@ export function startNoteEditor(adapter) {
const previous = !publicPageEnabled.checked;
updatePageControls();
publicPageEnabled.disabled = true;
try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); }
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); }
try {
await savePublicOptions();
if (publicPageEnabled.checked) toast.success("The published page is now available.", { title: "Publishing enabled" });
else toast.info("The published page is no longer available.", { title: "Publishing disabled" });
}
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast.danger(error.message, { title: "Could not update publishing" }); }
finally { publicPageEnabled.disabled = false; }
});
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast.info(publicTaskUpdates.checked ? "Visitors can now update public tasks." : "Visitors can no longer update public tasks.", { title: publicTaskUpdates.checked ? "Task updates enabled" : "Task updates disabled" }); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast.danger(error.message, { title: "Could not update task permissions" }); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); if (unprotectPublicPage.checked) toast.warning("The published page can now be opened without the resource password.", { title: "Public page unprotected", duration: 6500 }); else toast.success("Password protection is required again for the published page.", { title: "Public page protected" }); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast.danger(error.message, { title: "Could not update page protection" }); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("The published page is disabled."); const url = new URL(result.url, location.origin).href; await copyText(url); toast.success("Published page link copied to the clipboard.", { title: "Page link copied" }); window.open(url, "_blank", "noopener"); } catch (error) { toast.danger(error.message, { title: "Could not open published page" }); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = t("editor.noMessages", {}, "No messages yet"); chatMessages.append(empty); }
currentUser.addEventListener("click", () => {
if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker();
else userColorPicker.click();
@@ -2162,10 +2193,10 @@ export function startNoteEditor(adapter) {
async function saveUserColor(color) {
noteColor = color;
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast.danger(error.message, { title: "Could not save editor color" }); await loadNoteInfo(); return; }
} else {
writeGuestColor(noteColor);
toast("Color saved for this tab");
toast.success("This color will be used for the current tab.", { title: "Editor color saved" });
}
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
@@ -2177,7 +2208,7 @@ export function startNoteEditor(adapter) {
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
useGlobalColorButton.addEventListener("click", async () => {
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast.danger(error.message, { title: "Could not restore profile color" }); return; }
}
noteColor = "";
if (!getAuthToken()) writeGuestColor("");
@@ -2186,7 +2217,7 @@ export function startNoteEditor(adapter) {
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
toast("Global profile color restored");
toast.info("The global profile color is active again.", { title: "Profile color restored" });
});
editor.addEventListener("keydown", continueIndentation);
editor.addEventListener("scroll", () => {
@@ -2217,10 +2248,10 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus();
}
});
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
initialize();
+17 -14
View File
@@ -7,6 +7,8 @@
* See LICENSE file in repository root for details.
*/
import { formatDateTime, formatNumber, tp } from "@rustpad/i18n";
import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
@@ -20,14 +22,14 @@ function escapeHtml(value) {
function formatBytes(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024) return `${formatNumber(bytes)} B`;
if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: 1 })} KB`;
return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: 1 })} MB`;
}
function formatDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
return Number.isNaN(date.getTime()) ? "" : formatDateTime(date);
}
function isVideo(mimeType) {
@@ -114,6 +116,7 @@ function createVideoInsertDialog() {
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", toast, onFilesChanged = () => { } }) {
const notify = (type, message, options = {}) => toast(message, { ...options, type });
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -173,7 +176,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
try {
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
footer.textContent = tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
const restrictionMessage = getDeleteRestrictionMessage();
const restrictionNotice = restrictionMessage
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
@@ -191,7 +194,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
onFilesChanged(files);
if (open && !dialog.open) dialog.showModal();
} catch (error) {
if (open) toast(error.message);
if (open) notify("danger", error.message, { title: "Could not load files" });
}
}
@@ -258,7 +261,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
function requestUpload() {
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to upload files.");
notify("warning", "Read-write access and file uploads are required to upload files.", { title: "Upload unavailable" });
return;
}
input.click();
@@ -276,7 +279,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not prepare image" });
return;
}
if (!file) return;
@@ -293,7 +296,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (!files.length) return;
event.preventDefault();
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to paste files.");
notify("warning", "Read-write access and file uploads are required to paste files.", { title: "Paste upload unavailable" });
return;
}
@@ -333,7 +336,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
if (mode === "player") insertVideoPlayer(text);
else insertAttachmentText(text);
toast("Added to note");
notify("success", "The file reference was inserted into the note.", { title: "Added to note" });
return;
}
@@ -366,9 +369,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (copyButton) {
try {
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
toast("Copied");
notify("success", "Generated file code copied to the clipboard.", { title: "Copied" });
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not copy file code" });
}
return;
}
@@ -378,10 +381,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
try {
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
toast("File deleted");
notify("success", "The file was permanently deleted.", { title: "File deleted" });
await loadFiles();
} catch (error) {
toast(error.message);
notify("danger", error.message, { title: "Could not delete file" });
}
});
+15 -11
View File
@@ -15,6 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink } from "@rustpad/line-links";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
import { formatDateTime, t } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
@@ -43,7 +44,7 @@ async function renderMermaid() {
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = "Failed to load Mermaid.";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
node.parentNode.insertBefore(message, node);
});
}
@@ -53,7 +54,7 @@ async function renderMediaPlayers() { const nodes = content.querySelectorAll("[d
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? 'Update this task' : 'Task updates are disabled by the owner'; });
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
}
function publicAnchorTarget(hash) {
const rawId = String(hash || "").replace(/^#/, "");
@@ -154,8 +155,8 @@ function installPublicPermalinks() {
anchor.dataset.line = String(line);
anchor.dataset.linkKind = heading ? "heading" : "line";
anchor.textContent = String(line);
anchor.setAttribute("aria-label", heading ? `Copy link to heading on line ${line}` : `Copy link to line ${line}`);
anchor.title = heading ? `Copy link to this heading (line ${line})` : `Copy link to line ${line}`;
anchor.setAttribute("aria-label", heading ? t("public.copyHeadingAria", { line }, `Copy link to heading on line ${line}`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`));
anchor.title = heading ? t("public.copyHeadingTitle", { line }, `Copy link to this heading (line ${line})`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`);
target.append(anchor);
});
}
@@ -179,7 +180,9 @@ async function initialize() {
if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = "";
document.querySelector("#public-title").textContent = page.title;
document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`;
document.querySelector("#public-meta").textContent = page.allow_task_updates
? t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`)
: t("public.updated", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)}`);
document.title = `${page.title} · RustPad`;
setMarkdownFiles(page.files || []);
content.innerHTML = renderMarkdown(page.content);
@@ -191,7 +194,7 @@ async function initialize() {
} catch (error) {
publicAnchorSettleCleanup?.();
if (error.status === 401 || error.status === 403) {
passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password.";
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
if (!passwordDialog.open) passwordDialog.showModal();
passwordInput.focus();
return;
@@ -201,6 +204,7 @@ async function initialize() {
message.className = "error";
message.textContent = String(error.message);
content.append(message);
toast.danger(error.message, { title: "Could not load published page" });
}
}
content.addEventListener("click", async event => {
@@ -216,16 +220,16 @@ content.addEventListener("click", async event => {
await copyText(href);
link.classList.add("is-copied");
setTimeout(() => link.classList.remove("is-copied"), 900);
toast(link.dataset.linkKind === "heading" ? "Heading link copied" : `Link to line ${link.dataset.line} copied`);
} catch (error) { toast(error.message); }
toast.success(link.dataset.linkKind === "heading" ? "Heading link copied to the clipboard." : `Link to line ${link.dataset.line} copied to the clipboard.`, { title: "Link copied" });
} catch (error) { toast.danger(error.message, { title: "Could not copy link" }); }
});
window.addEventListener("hashchange", () => { publicAnchorSettleCleanup?.(); scrollToPublicAnchor(location.hash, "smooth"); });
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`); toast.success(box.checked ? "Task marked as complete." : "Task marked as incomplete.", { title: "Task updated" }); } catch (error) { box.checked = previous; toast.danger(error.message, { title: "Could not update task" }); } finally { box.disabled = false; } });
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
lineLinksToggle.addEventListener("change", () => { document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); });
fullWidthToggle.addEventListener("change", () => setFullWidth(fullWidthToggle.checked));
window.addEventListener("resize", () => requestAnimationFrame(() => alignPreviewLineNumbers(content)));
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); });
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); if (!passwordDialog.open) toast.success("The published page has been unlocked.", { title: "Page unlocked" }); else toast.danger(passwordError.textContent || "Enter the correct password.", { title: "Could not unlock page" }); });
passwordDialog.addEventListener("cancel", event => event.preventDefault());
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast.success("Published page link copied to the clipboard.", { title: "Link copied" }); } catch (error) { toast.danger(error.message, { title: "Could not copy page link" }); } });
initialize();
+1 -1
View File
@@ -11,7 +11,7 @@ const RESOURCE_ACCESS_ERROR_PATTERN = /(?:invalid password|password required|aut
export function isResourceAccessError(error) {
const status = Number(error?.status);
const message = typeof error === "string" ? error : error?.message;
const message = typeof error === "string" ? error : (error?.serverMessage || error?.message);
return status === 401 || RESOURCE_ACCESS_ERROR_PATTERN.test(String(message || ""));
}
+6 -2
View File
@@ -8,6 +8,7 @@
*/
import { applySessionTheme } from "@rustpad/theme";
import { setLanguage } from "@rustpad/i18n";
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
@@ -82,16 +83,19 @@ const AUTH_STATE_KEY = "rustpad:auth-state";
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; }
export function setAuthSession(session) {
export async function setAuthSession(session) {
localStorage.setItem(AUTH_STATE_KEY, "1");
setNickname(session.nickname);
applySessionTheme(session);
await setLanguage(session?.language || "en");
}
export function clearAuthSession() {
export async function clearAuthSession() {
localStorage.removeItem(AUTH_STATE_KEY);
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY);
localStorage.removeItem("rustpad:language");
setNicknameCookie("");
await setLanguage("en", { persist: false });
}
+358 -71
View File
@@ -1,109 +1,386 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
let hideTimer;
import { formatNumber, t, translateSource } from "@rustpad/i18n";
function toastElement(selector = "#toast") {
return document.querySelector(selector);
const TOAST_TYPES = new Set(["info", "success", "warning", "danger"]);
const DEFAULT_TITLE_KEYS = {
info: "toast.title.info",
success: "toast.title.success",
warning: "toast.title.warning",
danger: "toast.title.danger",
};
function defaultTitle(type) {
const normalized = normalizeType(type);
return t(DEFAULT_TITLE_KEYS[normalized], {}, { info: "Information", success: "Success", warning: "Warning", danger: "Something went wrong" }[normalized]);
}
const DEFAULT_DURATIONS = {
info: 4200,
success: 3800,
warning: 5600,
danger: 6500,
};
const MAX_VISIBLE_TOASTS = 4;
const FLASH_TOAST_STORAGE_KEY = "rustpad:toast:flash";
const MODAL_REGION_CLASS = "toast-region--modal";
const modalRegionBindings = new WeakSet();
function normalizeType(type) {
return TOAST_TYPES.has(type) ? type : "info";
}
function show(element, duration = null) {
element.classList.add("visible");
clearTimeout(hideTimer);
if (duration != null) hideTimer = setTimeout(() => element.classList.remove("visible"), duration);
function normalizeToastCopy(value) {
return String(value ?? "")
.replace(/\s+/gu, " ")
.trim()
.replace(/[.!?…]+$/gu, "")
.trim()
.toLocaleLowerCase();
}
function reset(element) {
element.className = "toast";
element.removeAttribute("aria-busy");
element.removeAttribute("aria-label");
function prepareToastRegion(element) {
if (!element) return null;
element.classList.remove("toast");
element.classList.add("toast-region");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-label", t("toast.region.label", {}, "Notifications"));
element.setAttribute("aria-relevant", "additions removals");
return element;
}
function pageToastContainer(selector = "#toast") {
return prepareToastRegion(document.querySelector(selector));
}
function activeModalDialog() {
const dialogs = [...document.querySelectorAll("dialog[open]")];
for (let index = dialogs.length - 1; index >= 0; index -= 1) {
const dialog = dialogs[index];
try {
if (dialog.matches(":modal")) return dialog;
} catch {
return dialog;
}
}
return null;
}
function rehomeModalToasts(dialog) {
const region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) return;
const pageRegion = pageToastContainer();
if (pageRegion) {
[...region.querySelectorAll(".toast-card")].forEach(card => pageRegion.append(card));
}
try {
if (region.matches(":popover-open")) region.hidePopover();
} catch { /* Popover API fallback. */ }
dialog.classList.remove("has-modal-toast-region");
region.remove();
}
function modalToastContainer(dialog) {
let region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) {
region = document.createElement("div");
region.className = `toast-region ${MODAL_REGION_CLASS}`;
region.setAttribute("popover", "manual");
dialog.append(region);
}
prepareToastRegion(region);
if (typeof region.showPopover === "function") {
try {
if (!region.matches(":popover-open")) region.showPopover();
} catch {
dialog.classList.add("has-modal-toast-region");
}
} else {
dialog.classList.add("has-modal-toast-region");
}
if (!modalRegionBindings.has(dialog)) {
dialog.addEventListener("close", () => rehomeModalToasts(dialog));
modalRegionBindings.add(dialog);
}
return region;
}
function toastContainer(selector = "#toast", { modalAware = true } = {}) {
if (modalAware && selector === "#toast") {
const dialog = activeModalDialog();
if (dialog) return modalToastContainer(dialog);
}
return pageToastContainer(selector);
}
function applyType(card, type) {
const normalized = normalizeType(type);
for (const candidate of TOAST_TYPES) card.classList.remove(`toast-card--${candidate}`);
card.classList.add(`toast-card--${normalized}`);
card.dataset.toastType = normalized;
if (normalized === "danger" || normalized === "warning") card.setAttribute("role", "alert");
else card.removeAttribute("role");
return normalized;
}
function clearAutoDismiss(card) {
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
card._toastRemaining = null;
card._toastStartedAt = null;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer) timer.hidden = true;
if (fill) {
fill.style.animation = "none";
fill.style.animationPlayState = "running";
}
}
function pauseAutoDismiss(card) {
if (!card._toastHideTimer || !Number.isFinite(card._toastRemaining)) return;
const elapsed = Date.now() - card._toastStartedAt;
card._toastRemaining = Math.max(0, card._toastRemaining - elapsed);
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "paused";
}
function resumeAutoDismiss(card) {
if (card.dataset.dismissed === "true" || !Number.isFinite(card._toastRemaining) || card._toastRemaining <= 0) return;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "running";
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), card._toastRemaining);
}
function dismissCard(card) {
if (!card || card.dataset.dismissed === "true") return;
card.dataset.dismissed = "true";
clearAutoDismiss(card);
card.classList.remove("is-visible");
card.classList.add("is-leaving");
window.setTimeout(() => card.remove(), 180);
}
function armAutoDismiss(card, duration) {
clearAutoDismiss(card);
const timeout = Number(duration);
if (!Number.isFinite(timeout) || timeout <= 0) return;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer && fill) {
timer.hidden = false;
fill.style.animation = "none";
void fill.offsetWidth;
fill.style.animation = `toast-countdown ${timeout}ms linear forwards`;
}
card._toastRemaining = timeout;
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), timeout);
if (card._toastHovering || card._toastFocused) pauseAutoDismiss(card);
}
function createCard(container, { type = "info", title, dismissible = true, persistent = false } = {}) {
const card = document.createElement("section");
card.className = "toast-card";
card.dataset.persistent = String(Boolean(persistent));
card.setAttribute("aria-atomic", "true");
card.innerHTML = `
<div class="toast-card__content">
<strong class="toast-card__title"></strong>
</div>
<button class="toast-card__close" type="button" aria-label="${t("toast.close", {}, "Close notification")}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10"></path><path d="m17 7-10 10"></path></svg>
</button>
<div class="toast-card__timer" aria-hidden="true" hidden><span></span></div>`;
const normalized = applyType(card, type);
card.querySelector(".toast-card__title").textContent = title ? translateSource(title) : defaultTitle(normalized);
const closeButton = card.querySelector(".toast-card__close");
closeButton.hidden = !dismissible;
closeButton.addEventListener("click", () => dismissCard(card));
card.addEventListener("mouseenter", () => {
card._toastHovering = true;
pauseAutoDismiss(card);
});
card.addEventListener("mouseleave", () => {
card._toastHovering = false;
if (!card._toastFocused) resumeAutoDismiss(card);
});
card.addEventListener("focusin", () => {
card._toastFocused = true;
pauseAutoDismiss(card);
});
card.addEventListener("focusout", event => {
if (card.contains(event.relatedTarget)) return;
card._toastFocused = false;
if (!card._toastHovering) resumeAutoDismiss(card);
});
if (!persistent) {
const transient = [...container.querySelectorAll('.toast-card[data-persistent="false"]')];
while (transient.length >= MAX_VISIBLE_TOASTS) dismissCard(transient.shift());
}
container.append(card);
requestAnimationFrame(() => card.classList.add("is-visible"));
return card;
}
function setCardTitle(card, title, fallbackType = "info") {
const titleElement = card.querySelector(".toast-card__title");
if (titleElement) titleElement.textContent = title ? translateSource(title) : defaultTitle(fallbackType);
}
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10240 ? 0 : 1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
if (bytes < 1024) return `${formatNumber(Math.round(bytes))} B`;
if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: bytes >= 10240 ? 0 : 1 })} KB`;
if (bytes < 1024 * 1024 * 1024) return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: bytes >= 10 * 1024 * 1024 ? 1 : 2 })} MB`;
return `${formatNumber(bytes / (1024 * 1024 * 1024), { maximumFractionDigits: 2 })} GB`;
}
export function queueToast(message, options = {}) {
const payload = {
message: String(message ?? ""),
type: normalizeType(options.type),
title: options.title == null ? null : String(options.title),
duration: Number.isFinite(Number(options.duration)) ? Number(options.duration) : null,
};
try {
sessionStorage.setItem(FLASH_TOAST_STORAGE_KEY, JSON.stringify(payload));
return true;
} catch {
return false;
}
}
export function consumeQueuedToast() {
let raw = null;
try {
raw = sessionStorage.getItem(FLASH_TOAST_STORAGE_KEY);
sessionStorage.removeItem(FLASH_TOAST_STORAGE_KEY);
} catch {
return false;
}
if (!raw) return false;
try {
const payload = JSON.parse(raw);
if (!payload || typeof payload.message !== "string") return false;
const options = { type: normalizeType(payload.type) };
if (typeof payload.title === "string" && payload.title) options.title = payload.title;
if (Number.isFinite(payload.duration) && payload.duration > 0) options.duration = payload.duration;
toast(payload.message, options);
return true;
} catch {
return false;
}
}
export function toast(message, options = {}) {
const element = toastElement(options.selector);
if (!element) return;
reset(element);
element.textContent = String(message ?? "");
show(element, options.duration ?? 1800);
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) return { dismiss() {} };
const type = normalizeType(options.type);
const card = createCard(container, {
type,
title: options.title,
dismissible: options.dismissible !== false,
});
const translatedMessage = translateSource(String(message ?? ""));
const renderedTitle = card.querySelector(".toast-card__title")?.textContent || "";
const normalizedMessage = normalizeToastCopy(translatedMessage);
if (normalizedMessage && normalizedMessage !== normalizeToastCopy(renderedTitle)) {
const body = document.createElement("p");
body.className = "toast-card__message";
body.textContent = translatedMessage;
card.querySelector(".toast-card__content").append(body);
}
armAutoDismiss(card, options.duration ?? DEFAULT_DURATIONS[type]);
return { dismiss: () => dismissCard(card), element: card };
}
toast.info = (message, options = {}) => toast(message, { ...options, type: "info" });
toast.success = (message, options = {}) => toast(message, { ...options, type: "success" });
toast.warning = (message, options = {}) => toast(message, { ...options, type: "warning" });
toast.danger = (message, options = {}) => toast(message, { ...options, type: "danger" });
export function createUploadToast(filename, options = {}) {
const element = toastElement(options.selector);
if (!element) {
return { start() { }, update() { }, fail() { }, success() { }, dismiss() { } };
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) {
return { start() {}, update() {}, fail() {}, success() {}, dismiss() {} };
}
reset(element);
element.classList.add("toast--upload", "toast--interactive");
element.setAttribute("aria-live", "polite");
element.innerHTML = `
<div class="upload-toast__header">
<div class="upload-toast__heading">
<strong class="upload-toast__title">Uploading file</strong>
<span class="upload-toast__filename"></span>
</div>
</div>
<div class="upload-toast__progress" role="progressbar" aria-label="File upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>Preparing</span><span data-upload-speed></span></div>
const card = createCard(container, {
type: "info",
title: t("upload.title.uploading", {}, "Uploading file"),
dismissible: true,
persistent: true,
});
card.classList.add("toast-card--upload");
card.setAttribute("aria-busy", "true");
const content = card.querySelector(".toast-card__content");
content.insertAdjacentHTML("beforeend", `
<span class="upload-toast__filename"></span>
<div class="upload-toast__progress" role="progressbar" aria-label="${t("upload.progress.label", {}, "File upload progress")}" data-i18n-aria-label="upload.progress.label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>${t("upload.status.preparing", {}, "Preparing...")}</span><span data-upload-speed>-</span></div>
<p class="upload-toast__error" data-upload-error hidden></p>
<div class="upload-toast__actions" data-upload-actions hidden>
<button type="button" class="upload-toast__retry">Retry</button>
<button type="button" class="upload-toast__dismiss">Dismiss</button>
</div>`;
<button type="button" class="upload-toast__retry" data-i18n="common.retry">${t("common.retry", {}, "Retry")}</button>
<button type="button" class="upload-toast__dismiss" data-i18n="common.dismiss">${t("common.dismiss", {}, "Dismiss")}</button>
</div>`);
const title = element.querySelector(".upload-toast__title");
const filenameElement = element.querySelector(".upload-toast__filename");
const progress = element.querySelector(".upload-toast__progress");
const filenameElement = card.querySelector(".upload-toast__filename");
const progress = card.querySelector(".upload-toast__progress");
const progressFill = progress.querySelector("span");
const amount = element.querySelector("[data-upload-amount]");
const speedElement = element.querySelector("[data-upload-speed]");
const errorElement = element.querySelector("[data-upload-error]");
const actions = element.querySelector("[data-upload-actions]");
const retryButton = element.querySelector(".upload-toast__retry");
const dismissButton = element.querySelector(".upload-toast__dismiss");
const amount = card.querySelector("[data-upload-amount]");
const speedElement = card.querySelector("[data-upload-speed]");
const errorElement = card.querySelector("[data-upload-error]");
const actions = card.querySelector("[data-upload-actions]");
const retryButton = card.querySelector(".upload-toast__retry");
const dismissButton = card.querySelector(".upload-toast__dismiss");
let retryHandler = null;
filenameElement.textContent = String(filename || "file");
function dismiss() {
clearTimeout(hideTimer);
element.classList.remove("visible");
dismissCard(card);
}
function start() {
reset(element);
element.classList.add("toast--upload", "toast--interactive", "visible");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-busy", "true");
title.textContent = "Uploading file";
clearAutoDismiss(card);
card.dataset.dismissed = "false";
card.dataset.persistent = "true";
card.classList.remove("is-leaving");
card.classList.add("is-visible");
card.setAttribute("aria-busy", "true");
applyType(card, "info");
setCardTitle(card, t("upload.title.uploading", {}, "Uploading file"), "info");
filenameElement.textContent = String(filename || "file");
progress.classList.remove("is-error", "is-complete");
progress.classList.remove("is-error", "is-complete", "is-indeterminate");
progress.setAttribute("aria-valuenow", "0");
progressFill.style.width = "0%";
amount.textContent = "Preparing…";
speedElement.textContent = "—";
amount.textContent = t("upload.status.preparing", {}, "Preparing...");
delete speedElement.dataset.i18n;
speedElement.textContent = "-";
errorElement.hidden = true;
errorElement.textContent = "";
actions.hidden = true;
retryButton.hidden = false;
retryButton.disabled = false;
clearTimeout(hideTimer);
}
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
progress.classList.remove("is-indeterminate");
const normalizedPercent = Number.isFinite(percent)
? Math.max(0, Math.min(100, percent))
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
@@ -116,42 +393,52 @@ export function createUploadToast(filename, options = {}) {
progress.classList.add("is-indeterminate");
}
amount.textContent = total > 0
? `${Math.round(normalizedPercent || 0)}% · ${formatBytes(loaded)} / ${formatBytes(total)}`
? `${Math.round(normalizedPercent || 0)}% - ${formatBytes(loaded)} / ${formatBytes(total)}`
: formatBytes(loaded);
speedElement.textContent = phase === "processing" ? "Processing…" : speed > 0 ? `${formatBytes(speed)}/s` : "Starting…";
speedElement.textContent = phase === "processing" ? t("upload.status.processing", {}, "Processing...") : speed > 0 ? `${formatBytes(speed)}/s` : t("upload.status.starting", {}, "Starting...");
}
function fail(message, { retryable = true, onRetry = null } = {}) {
element.removeAttribute("aria-busy");
title.textContent = "Upload failed";
clearAutoDismiss(card);
card.removeAttribute("aria-busy");
card.dataset.persistent = "true";
applyType(card, "danger");
setCardTitle(card, t("upload.title.failed", {}, "Upload failed"), "danger");
progress.classList.remove("is-indeterminate", "is-complete");
progress.classList.add("is-error");
errorElement.textContent = String(message || "Upload failed.");
errorElement.textContent = translateSource(String(message || t("upload.error.generic", {}, "The file could not be uploaded.")));
errorElement.hidden = false;
actions.hidden = false;
retryButton.hidden = !retryable;
retryButton.disabled = false;
retryHandler = typeof onRetry === "function" ? onRetry : null;
show(element);
}
function success(message = "File uploaded") {
element.removeAttribute("aria-busy");
title.textContent = message;
function success(message = t("upload.title.complete", {}, "File uploaded")) {
card.removeAttribute("aria-busy");
card.dataset.persistent = "false";
applyType(card, "success");
setCardTitle(card, message, "success");
progress.classList.remove("is-error", "is-indeterminate");
progress.classList.add("is-complete");
progress.setAttribute("aria-valuenow", "100");
progressFill.style.width = "100%";
amount.textContent = "100%";
speedElement.textContent = "Complete";
speedElement.dataset.i18n = "upload.status.complete";
speedElement.textContent = t("upload.status.complete", {}, "Complete");
errorElement.hidden = true;
actions.hidden = true;
show(element, 1800);
armAutoDismiss(card, 3200);
}
retryButton.addEventListener("click", async () => {
if (!retryHandler) return;
retryButton.disabled = true;
await retryHandler();
try {
await retryHandler();
} finally {
if (card.isConnected && !card.hasAttribute("aria-busy")) retryButton.disabled = false;
}
});
dismissButton.addEventListener("click", dismiss);
+4 -2
View File
@@ -3,6 +3,8 @@
* Source-Available Code / Dual-Licensed.
*/
import { t } from "@rustpad/i18n";
const config = window.__RUSTPAD_CONFIG__ || {};
const assetVersion = encodeURIComponent(String(config.assetVersion || "dev"));
const promiseCache = new Map();
@@ -30,9 +32,9 @@ function loadClassicScript(path, globalName) {
script.dataset.rustpadLib = globalName;
script.addEventListener("load", () => {
if (window[globalName]) resolve(window[globalName]);
else reject(new Error(`${globalName} did not register a browser global.`));
else reject(new Error(t("vendor.globalMissing", { name: globalName }, `${globalName} did not register a browser global.`)));
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Failed to load ${path}.`)), { once: true });
script.addEventListener("error", () => reject(new Error(t("vendor.loadFailed", { path }, `Failed to load ${path}.`))), { once: true });
if (!existing) document.head.append(script);
}));
}
+18 -11
View File
@@ -16,7 +16,10 @@ import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken }
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { askConfirm } from "@rustpad/modal";
import { isResourceAccessError, safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { consumeQueuedToast, toast } from "@rustpad/toast";
import { formatDateTime, formatNumber } from "@rustpad/i18n";
consumeQueuedToast();
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
@@ -135,12 +138,12 @@ function connectWorkspaceWatch() {
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024) return `${formatNumber(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes;
let unit = -1;
do { amount /= 1024; unit++; } while (amount >= 1024 && unit < units.length - 1);
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
return `${formatNumber(amount, { maximumFractionDigits: amount >= 10 ? 0 : 1 })} ${units[unit]}`;
}
function noteStats(note) {
return `<span>Participants: ${Number(note.participant_count) || 0}</span><span>Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})</span><span>Revisions: ${Number(note.revision_count) || 0}</span>`;
@@ -151,7 +154,7 @@ function formatDate(value) {
if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; }
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) raw = raw.replace(" ", "T") + "Z";
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pl-PL");
return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date);
}
function setNotesView(view) {
notesView = view === "table" ? "table" : "grid";
@@ -226,6 +229,7 @@ async function openWorkspace() {
const params = new URLSearchParams({ q: notesSearch.value.trim(), page: String(notesPage), per_page: notesPerPage.value });
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open?${params}`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
info = data.workspace;
document.querySelector("#workspace-title").removeAttribute("data-i18n");
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
document.title = `${info.title} · RustPad`;
@@ -250,6 +254,7 @@ async function init() {
try {
const headers = accessToken && accessToken !== "cookie" ? { Authorization: `Bearer ${accessToken}` } : {};
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
document.querySelector("#workspace-title").removeAttribute("data-i18n");
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
updateWorkspacePasswordControl();
@@ -270,7 +275,8 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
document.querySelector("#open-password").value = "";
document.querySelector("#password-error").textContent = "";
await openWorkspace();
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
toast.success("Workspace access has been unlocked.", { title: "Workspace unlocked" });
} catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock workspace" }); }
});
workspacePasswordForm.addEventListener("submit", async event => {
event.preventDefault();
@@ -294,10 +300,11 @@ workspacePasswordForm.addEventListener("submit", async event => {
setAccessToken("workspace", slug, result.granted);
accessToken = getAccessToken("workspace", slug);
workspacePasswordInput.value = "";
toast("Workspace password set");
toast.success("Password protection is now enabled for this workspace.", { title: "Workspace protected" });
await openWorkspace();
} catch (error) {
workspacePasswordError.textContent = error.message;
toast.danger(error.message, { title: "Could not set workspace password" });
} finally {
submit.disabled = false;
}
@@ -313,7 +320,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(note.url));
} catch (err) { error.textContent = err.message; }
} catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); }
});
notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]");
@@ -325,9 +332,9 @@ notesList.addEventListener("click", async event => {
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
method: "DELETE", body: JSON.stringify({ access_token: accessToken || null })
});
toast("Note deleted");
toast.success(`The note "${title}" was deleted.`, { title: "Note deleted" });
await openWorkspace();
} catch (error) { toast(error.message); button.disabled = false; }
} catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; }
});
document.querySelectorAll("[data-notes-view]").forEach(button => button.addEventListener("click", () => {
if (button.dataset.notesView === notesView) return;
@@ -338,8 +345,8 @@ notesSearch.addEventListener("input", () => { clearTimeout(notesSearchTimer); no
notesPerPage.addEventListener("change", () => { notesPage = 1; openWorkspace(); });
notesPagination.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; notesPage = Number(button.dataset.page) || 1; openWorkspace(); });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => {
try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); }
try { await copyText(new URL(location.pathname, location.origin).href); toast.success("Workspace link copied to the clipboard.", { title: "Link copied" }); }
catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
});
async function startAuthorizedWorkspace() {
const session = await validateCurrentSession();
+1 -1
View File
@@ -38,7 +38,7 @@
<a class="text-button" href="/">Back to home</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>
+3 -3
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>__WORKSPACE_TITLE__ · RustPad</title>
<title data-i18n-ignore>__WORKSPACE_TITLE__ · RustPad</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
@@ -21,7 +21,7 @@
</div>
<span class="header-divider"></span>
<div class="document-heading">
<h1 id="workspace-title">__WORKSPACE_TITLE__</h1>
<h1 id="workspace-title" __WORKSPACE_TITLE_I18N__>__WORKSPACE_TITLE__</h1>
<p id="workspace-url" class="document-url"></p>
</div>
</div>
@@ -108,7 +108,7 @@
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
</form>
</dialog>
<div id="toast" class="toast"></div>
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
</body>
</html>