diff --git a/Cargo.lock b/Cargo.lock index 96c4ee0..e7345bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.55" +version = "0.2.64" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 3e58b70..3215123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index e6f050a..23489ab 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..206881d --- /dev/null +++ b/build.rs @@ -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::>(); + 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"); +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..83caa27 --- /dev/null +++ b/lang/en.json @@ -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 H1–H4", + "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" + } +} diff --git a/lang/pl.json b/lang/pl.json new file mode 100644 index 0000000..ce4baa6 --- /dev/null +++ b/lang/pl.json @@ -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 H1–H4", + "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" + } +} diff --git a/migrations/mysql/0033_user_language.sql b/migrations/mysql/0033_user_language.sql new file mode 100644 index 0000000..cf8d867 --- /dev/null +++ b/migrations/mysql/0033_user_language.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN language VARCHAR(16) NOT NULL DEFAULT 'en'; diff --git a/migrations/postgres/0033_user_language.sql b/migrations/postgres/0033_user_language.sql new file mode 100644 index 0000000..0f70d20 --- /dev/null +++ b/migrations/postgres/0033_user_language.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'en'; diff --git a/migrations/sqlite/0033_user_language.sql b/migrations/sqlite/0033_user_language.sql new file mode 100644 index 0000000..0f70d20 --- /dev/null +++ b/migrations/sqlite/0033_user_language.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'en'; diff --git a/src/app/mod.rs b/src/app/mod.rs index 00f773d..7d83c0c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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) -> 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 { diff --git a/src/app/pages.rs b/src/app/pages.rs index f7d3a39..4e42d3e 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -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 { ¬e.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)) diff --git a/src/assets.rs b/src/assets.rs index 8f7e168..682016e 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -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#""# } +pub fn language_bootstrap() -> &'static str { + r#""# +} + 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#""# +} + 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#""#, - self.url(&format!("js/{name}.js")) + r#""#, + escape_js_string(&entrypoint) ) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6eea1f1..b996eb0 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -51,6 +51,7 @@ pub struct User { pub confirmed_at: Option, pub is_active: i64, pub theme: String, + pub language: String, } #[derive(Deserialize)] @@ -99,6 +100,8 @@ pub struct ProfileUpdateRequest { editor_color: Option, #[serde(default)] theme: Option, + #[serde(default)] + language: Option, } #[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, editor_color: Option, theme: String, + language: String, } #[derive(Serialize)] pub struct IdentityResponse { @@ -380,6 +385,7 @@ pub struct RegisterResponse { expires_at: Option, 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 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 { let value = value.trim(); if value.len() == 7 diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..b43c5db --- /dev/null +++ b/src/i18n.rs @@ -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, +} + +#[derive(Clone, Debug)] +struct EmbeddedLanguage { + meta: LanguageMeta, + source: &'static str, +} + +static LANGUAGES: OnceLock> = 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::>(); + (*filename, parsed.meta, *source, keys) + }) + .collect::>(); + + 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::>(); + let extra = keys.difference(&fallback_keys).take(8).cloned().collect::>(); + 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::>(); + languages.sort_by(|left, right| left.meta.native_name.cmp(&right.meta.native_name)); + languages + }) +} + +pub fn metadata() -> Vec { + 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::>(); + 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); + } +} diff --git a/src/main.rs b/src/main.rs index 6ba66f9..9cc8841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod config; mod database; mod db; mod file_urls; +mod i18n; mod queries; mod row_decode; mod security; diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 8be0878..4066c63 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -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; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index fe58007..20d0d62 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -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 = ?"# diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 9a92e43..03ab76f 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -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"# diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index fd59740..6cacf4d 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -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 = ?"# diff --git a/static/css/styles.css b/static/css/styles.css index b340cec..ecbf6b6 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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; diff --git a/static/editor.html b/static/editor.html index e3021c9..b5488c9 100644 --- a/static/editor.html +++ b/static/editor.html @@ -5,7 +5,7 @@ - __DOCUMENT_TITLE__ · __PARENT_TITLE__ + __DOCUMENT_TITLE__ · __PARENT_TITLE__ __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ __APP_IMPORT_MAP__ @@ -22,7 +22,7 @@ @@ -406,7 +406,7 @@ -
+
\ No newline at end of file diff --git a/static/error.html b/static/error.html index 51b9f5e..d069372 100644 --- a/static/error.html +++ b/static/error.html @@ -9,6 +9,8 @@ __ERROR_TITLE__ · RustPad __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ + __APP_IMPORT_MAP__ + __APP_I18N__ diff --git a/static/home.html b/static/home.html index 95837a0..0986b1a 100644 --- a/static/home.html +++ b/static/home.html @@ -191,6 +191,27 @@ +
+ Language +
+ + + + + +
+ Saved with your profile and applied after you save these settings. +
@@ -206,7 +227,7 @@

-
+
diff --git a/static/js/api.js b/static/js/api.js index 151a712..c92ac04 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -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; diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index a3d934f..8c3f4b5 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -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(); } diff --git a/static/js/editor-format.js b/static/js/editor-format.js index 8cda47c..4cb25ff 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -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, "
\nClick me\n\n", "\n
", "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, `
\n${t("editor.format.detailsSummary", {}, "Click me")}\n\n`, "\n
", 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 })); diff --git a/static/js/emoji-picker.js b/static/js/emoji-picker.js index 1b872b1..306e06e 100644 --- a/static/js/emoji-picker.js +++ b/static/js/emoji-picker.js @@ -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(); + }); } diff --git a/static/js/home.js b/static/js/home.js index 55d8438..0d3b418 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -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 ? `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items` : ""; + resourcesPagination.innerHTML = meta.total ? `${escapeHtml(t("pagination.items", { page: meta.page, pages: meta.total_pages, count: meta.total }, `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items`))}` : ""; } +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 ? `Shared by ${escapeHtml(item.shared_by || "another user")}` : ""; - 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 ? `` : ``; const privacyAction = item.private ? "Make public" : "Make private"; const privacyShort = item.private ? "Public" : "Private"; - row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `
${resourceActionLabel("Password…", "Pass…")}
${passwordActions}
` : ""}
`; + row.innerHTML = `
${item.owned ? `
${resourceActionLabel("Password…", "Pass…")}
${passwordActions}
` : ""}
`; + + 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)); diff --git a/static/js/i18n.js b/static/js/i18n.js new file mode 100644 index 0000000..596cc83 --- /dev/null +++ b/static/js/i18n.js @@ -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 = ``; + 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; +} diff --git a/static/js/markdown.js b/static/js/markdown.js index 535bad4..ce9ef06 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -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 `
`; + return `
`; } 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 ``; + const title = markdownLink?.[1]?.trim() || t("markdown.youtubeVideo", {}, "YouTube video"); + return ``; } 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(/^([\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 += ``; + html += ``; } continue; } diff --git a/static/js/modal.js b/static/js/modal.js index 3166087..29482a6 100644 --- a/static/js/modal.js +++ b/static/js/modal.js @@ -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 = `
- +

-
+
`; 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 })); diff --git a/static/js/note-api.js b/static/js/note-api.js index 62370cd..da091e1 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -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)}`); }, }; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 4f88ad0..8f7017c 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = `
- - - - + tools.setAttribute("aria-label", t("files.imageLayout", {}, "Image layout")); + tools.innerHTML = `
+ + + +
- + - - - + + +
`; 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 = '

Loading…

'; 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 `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; 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 = `

${escapeHtml(e.message)}

`; } }); 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 = '

Loading…

'; 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 `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; 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 = `

${escapeHtml(e.message)}

`; 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(); diff --git a/static/js/note-files.js b/static/js/note-files.js index 45c0259..ba4ccd5 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -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 ? `

${escapeHtml(restrictionMessage)}

` @@ -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" }); } }); diff --git a/static/js/public.js b/static/js/public.js index 1ac6456..1467d0c 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -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(); diff --git a/static/js/security.js b/static/js/security.js index 5941d41..12e80f8 100644 --- a/static/js/security.js +++ b/static/js/security.js @@ -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 || "")); } diff --git a/static/js/session.js b/static/js/session.js index ecf3b0a..f6fce8d 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -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 }); } diff --git a/static/js/toast.js b/static/js/toast.js index 9881b25..adf78ef 100644 --- a/static/js/toast.js +++ b/static/js/toast.js @@ -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 = ` +
+ +
+ + `; + + 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 = ` -
-
- Uploading file - -
-
-
-
Preparing…
+ 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", ` + +
+
${t("upload.status.preparing", {}, "Preparing...")}-
`; + + +
`); - 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); diff --git a/static/js/vendor-libs.js b/static/js/vendor-libs.js index 7b5fc04..67c92e8 100644 --- a/static/js/vendor-libs.js +++ b/static/js/vendor-libs.js @@ -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); })); } diff --git a/static/js/workspace.js b/static/js/workspace.js index 67bc049..e8ee7a4 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -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 `Participants: ${Number(note.participant_count) || 0}Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})Revisions: ${Number(note.revision_count) || 0}`; @@ -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(); diff --git a/static/public.html b/static/public.html index 78d19d8..1adff36 100644 --- a/static/public.html +++ b/static/public.html @@ -38,7 +38,7 @@ Back to home -
+
\ No newline at end of file diff --git a/static/workspace.html b/static/workspace.html index 67df1f0..1784c6d 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -5,7 +5,7 @@ - __WORKSPACE_TITLE__ · RustPad + __WORKSPACE_TITLE__ · RustPad __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ __APP_IMPORT_MAP__ @@ -21,7 +21,7 @@
-

__WORKSPACE_TITLE__

+

__WORKSPACE_TITLE__

@@ -108,7 +108,7 @@ class="secondary-button">Cancel -
+
\ No newline at end of file