91719740b9
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках - Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit - make push (git, Ctrl-D) и актуальный README
473 lines
24 KiB
JavaScript
473 lines
24 KiB
JavaScript
(() => {
|
||
const KEY = "wrapped.lang";
|
||
const dict = {
|
||
en: {
|
||
"footer.tagline": "Zero-knowledge · one-time · encrypted",
|
||
"footer.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
|
||
"footer.cap.text": "Text",
|
||
"footer.cap.text.hint": "Secrets, configs, notes",
|
||
"footer.cap.media": "Images & files",
|
||
"footer.cap.media.hint": "Screenshots, archives, docs",
|
||
"footer.cap.browser": "Encrypted in-browser",
|
||
"footer.cap.browser.hint": "Only ciphertext reaches the server",
|
||
"footer.pillar.zk": "Zero-knowledge",
|
||
"footer.pillar.once": "One-time",
|
||
"footer.pillar.encrypted": "Encrypted",
|
||
"footer.pillar.zk.hint": "Server never sees content",
|
||
"footer.pillar.once.hint": "Gone after unwrap",
|
||
"footer.pillar.encrypted.hint": "Key lives only in your link",
|
||
"footer.copy.author": "Sergey Antropov",
|
||
"brand.tagline": "Wrap. Send. Vanish.",
|
||
"create.eyebrow": "Secure drop",
|
||
"create.title": "Wrap. Send. Vanish.",
|
||
"create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
|
||
"create.language": "Highlight language",
|
||
"create.languageChange": "Change type",
|
||
"create.languagePickTitle": "Text type",
|
||
"create.languagePickHint": "Choose how the text should be highlighted.",
|
||
"create.languageUnsure": "Not sure — maybe: {suggestions}. Or change type.",
|
||
"create.languageUnsurePlain": "Could not detect type — change type if needed.",
|
||
"create.text": "Text",
|
||
"create.textPlaceholder": "Paste secrets, configs, notes…",
|
||
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
|
||
"create.ttl": "Time to live",
|
||
"create.password": "Password (optional)",
|
||
"create.passwordPlaceholder": "Extra unlock secret",
|
||
"create.passwordGenerate": "Generate password",
|
||
"create.passwordGenerateTip": "Generate a strong password",
|
||
"create.submit": "Create wrapped token",
|
||
"create.working": "Encrypting and uploading…",
|
||
"create.openToken": "Open a token",
|
||
"create.successTitle": "Token issued",
|
||
"create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.",
|
||
"create.shareLink": "Share link",
|
||
"create.token": "Wrapped token",
|
||
"create.another": "Create another",
|
||
"create.needPayload": "Add text or at least one file.",
|
||
"create.tooLarge": "Package exceeds max size.",
|
||
"create.mimeDenied": "One or more MIME types are not allowed.",
|
||
"create.expires": "Expires {datetime}",
|
||
"create.expiresRelative": "{relative}",
|
||
"create.ttl.1h": "1 hour",
|
||
"create.ttl.6h": "6 hours",
|
||
"create.ttl.24h": "24 hours",
|
||
"create.ttl.3d": "3 days",
|
||
"create.ttl.7d": "7 days",
|
||
"create.ttl.default": "Default ({seconds} s)",
|
||
"lang.plaintext": "Plain text",
|
||
"lang.markdown": "Markdown",
|
||
"lang.json": "JSON",
|
||
"lang.yaml": "YAML",
|
||
"lang.python": "Python",
|
||
"lang.javascript": "JavaScript",
|
||
"lang.typescript": "TypeScript",
|
||
"lang.go": "Go",
|
||
"lang.rust": "Rust",
|
||
"lang.sql": "SQL",
|
||
"lang.bash": "Bash",
|
||
"lang.html": "HTML",
|
||
"lang.css": "CSS",
|
||
"relative.inMinutes": "in {n} min",
|
||
"relative.inHours": "in {n} h",
|
||
"relative.inDays": "in {n} d",
|
||
"relative.soon": "soon",
|
||
"unwrap.eyebrow": "Unwrap",
|
||
"unwrap.title": "Reveal once",
|
||
"unwrap.lede": "Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.",
|
||
"unwrap.token": "Wrapped token or ID",
|
||
"unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id",
|
||
"unwrap.password": "Password (if set)",
|
||
"unwrap.submit": "Unwrap",
|
||
"unwrap.working": "Decrypting…",
|
||
"unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.",
|
||
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
|
||
"unwrap.badPassword": "Wrong password.",
|
||
"unwrap.badPasswordRemaining": "Wrong password. Attempts left: {n} of {max}.",
|
||
"unwrap.passwordLocked": "Too many wrong passwords. This wrap has been destroyed.",
|
||
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
|
||
"common.copy": "Copy",
|
||
"common.copied": "Copied",
|
||
"common.download": "Download",
|
||
"common.error": "Something went wrong.",
|
||
"common.working": "Working…",
|
||
"admin.nav.settings": "Settings",
|
||
"admin.nav.audit": "Audit",
|
||
"admin.nav.danger": "Danger",
|
||
"admin.nav.site": "Site",
|
||
"admin.nav.logout": "Logout",
|
||
"admin.brand.tagline": "Admin console",
|
||
"admin.settings.title": "Settings",
|
||
"admin.audit.title": "Audit",
|
||
"admin.tab.limits": "Limits",
|
||
"admin.tab.rate": "Rate limits",
|
||
"admin.tab.mime": "MIME",
|
||
"admin.tab.password": "Password",
|
||
"admin.tab.captcha": "CAPTCHA",
|
||
"admin.login.sub": "Admin",
|
||
"admin.login.username": "Username",
|
||
"admin.login.password": "Password",
|
||
"admin.login.submit": "Sign in",
|
||
"admin.login.error.rate": "Too many attempts. Try later.",
|
||
"admin.login.error.bad": "Invalid credentials",
|
||
"admin.flash.saved": "Settings saved.",
|
||
"admin.flash.purgedPrefix": "Purged",
|
||
"admin.flash.purgedWraps": "wrap(s) and",
|
||
"admin.flash.purgedObjects": "object(s).",
|
||
"admin.flash.purgeConfirm": "Type PURGE to confirm forced cleanup.",
|
||
"admin.flash.purgeError": "Purge failed. Check logs / MinIO connectivity.",
|
||
"admin.limits.title": "Limits",
|
||
"admin.limits.maxUpload": "Max upload (MB)",
|
||
"admin.limits.defaultTtl": "Default TTL (hours)",
|
||
"admin.limits.maxTtl": "Max TTL (hours)",
|
||
"admin.limits.auditRetention": "Audit retention (days)",
|
||
"admin.rate.title": "Rate limits (per IP / minute)",
|
||
"admin.rate.create": "Create",
|
||
"admin.rate.unwrap": "Unwrap",
|
||
"admin.rate.adminLogin": "Admin login",
|
||
"admin.mime.title": "MIME allowlist",
|
||
"admin.mime.hint": "One pattern per line. Supports exact types and wildcards like image/*.",
|
||
"admin.mime.examples": "Examples / suggested defaults",
|
||
"admin.passwordMode.title": "Password mode",
|
||
"admin.passwordMode.client_only": "Password also wraps ciphertext in the browser. Server stores an Argon2id hash and rejects wrong passwords before the one-time unwrap (mistypes do not burn the package).",
|
||
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Ciphertext is still encrypted client-side with the same password.",
|
||
"admin.password.maxAttempts": "Wrong password attempts (unwrap)",
|
||
"admin.password.maxAttemptsHint": "After this many wrong passwords the wrap is destroyed. Default: 3.",
|
||
"admin.captcha.title": "CAPTCHA",
|
||
"admin.captcha.provider": "Provider",
|
||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||
"admin.captcha.hcaptchaSite": "hCaptcha site key",
|
||
"admin.captcha.secrets": "Secrets:",
|
||
"admin.captcha.set": "set",
|
||
"admin.captcha.missing": "missing",
|
||
"admin.captcha.viaEnv": "(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
|
||
"admin.captcha.helperTitle": "How to get CAPTCHA tokens",
|
||
"admin.captcha.turnstile.1": "Open Cloudflare Turnstile dashboard",
|
||
"admin.captcha.turnstile.2": "Create a site widget for your domain (use localhost for dev)",
|
||
"admin.captcha.turnstile.3": "Copy Site Key into the field above",
|
||
"admin.captcha.turnstile.4": "Copy Secret Key into env TURNSTILE_SECRET_KEY and restart",
|
||
"admin.captcha.turnstile.5": "Set provider to turnstile",
|
||
"admin.captcha.hcaptcha.1": "Open hCaptcha dashboard",
|
||
"admin.captcha.hcaptcha.2": "New site → copy Sitekey here",
|
||
"admin.captcha.hcaptcha.3": "Secret into env HCAPTCHA_SECRET_KEY, restart",
|
||
"admin.captcha.hcaptcha.4": "Set provider to hcaptcha",
|
||
"admin.settings.save": "Save settings",
|
||
"admin.danger.title": "Danger zone",
|
||
"admin.danger.hint": "Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.",
|
||
"admin.danger.confirmLabel": "Type PURGE to confirm",
|
||
"admin.danger.submit": "Clear all wraps & files",
|
||
"admin.danger.confirmDialog": "Delete ALL wraps and files? This cannot be undone.",
|
||
"admin.danger.confirmTitle": "Confirm purge",
|
||
"admin.danger.confirmOk": "Delete everything",
|
||
"modal.confirm.title": "Confirm",
|
||
"modal.confirm.ok": "Confirm",
|
||
"modal.confirm.cancel": "Cancel",
|
||
"admin.audit.eventType": "Event type",
|
||
"admin.audit.wrapId": "Wrap ID",
|
||
"admin.audit.filter": "Filter",
|
||
"admin.audit.allEvents": "All events",
|
||
"admin.audit.perPage": "Per page",
|
||
"admin.audit.shown": "events shown",
|
||
"admin.audit.of": "of",
|
||
"admin.audit.events": "events",
|
||
"admin.audit.page": "Page",
|
||
"admin.audit.prev": "Prev",
|
||
"admin.audit.next": "Next",
|
||
"admin.audit.col.time": "Time",
|
||
"admin.audit.col.event": "Event",
|
||
"admin.audit.col.ok": "OK",
|
||
"admin.audit.col.wrap": "Wrap",
|
||
"admin.audit.col.details": "Details",
|
||
"admin.audit.yes": "yes",
|
||
"admin.audit.no": "no",
|
||
"admin.audit.empty": "No events",
|
||
},
|
||
ru: {
|
||
"footer.tagline": "Zero-knowledge · одноразово · зашифровано",
|
||
"footer.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
|
||
"footer.cap.text": "Текст",
|
||
"footer.cap.text.hint": "Секреты, конфиги, заметки",
|
||
"footer.cap.media": "Картинки и файлы",
|
||
"footer.cap.media.hint": "Скриншоты, архивы, документы",
|
||
"footer.cap.browser": "Шифрование в браузере",
|
||
"footer.cap.browser.hint": "На сервер уходит только ciphertext",
|
||
"footer.pillar.zk": "Zero-knowledge",
|
||
"footer.pillar.once": "Одноразово",
|
||
"footer.pillar.encrypted": "Зашифровано",
|
||
"footer.pillar.zk.hint": "Сервер не видит содержимое",
|
||
"footer.pillar.once.hint": "После открытия — удаление",
|
||
"footer.pillar.encrypted.hint": "Ключ только в вашей ссылке",
|
||
"footer.copy.author": "Сергей Антропов",
|
||
"brand.tagline": "Упакуй. Отправь. Исчезни.",
|
||
"create.eyebrow": "Безопасная передача",
|
||
"create.title": "Упакуй. Отправь. Исчезни.",
|
||
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
|
||
"create.language": "Язык подсветки",
|
||
"create.languageChange": "Изменить тип",
|
||
"create.languagePickTitle": "Тип текста",
|
||
"create.languagePickHint": "Выберите, как подсвечивать текст.",
|
||
"create.languageUnsure": "Не уверен — возможно: {suggestions}. Или измените тип.",
|
||
"create.languageUnsurePlain": "Не удалось определить тип — при необходимости измените вручную.",
|
||
"create.text": "Текст",
|
||
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
|
||
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
|
||
"create.ttl": "Время жизни",
|
||
"create.password": "Пароль (необязательно)",
|
||
"create.passwordPlaceholder": "Дополнительный секрет",
|
||
"create.passwordGenerate": "Сгенерировать пароль",
|
||
"create.passwordGenerateTip": "Сгенерировать надёжный пароль",
|
||
"create.submit": "Создать wrapped-токен",
|
||
"create.working": "Шифрование и загрузка…",
|
||
"create.openToken": "Открыть токен",
|
||
"create.successTitle": "Токен выдан",
|
||
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
|
||
"create.shareLink": "Ссылка",
|
||
"create.token": "Wrapped-токен",
|
||
"create.another": "Создать ещё",
|
||
"create.needPayload": "Добавь текст или хотя бы один файл.",
|
||
"create.tooLarge": "Пакет превышает лимит размера.",
|
||
"create.mimeDenied": "Один или несколько MIME-типов запрещены.",
|
||
"create.expires": "Истекает {datetime}",
|
||
"create.expiresRelative": "{relative}",
|
||
"create.ttl.1h": "1 час",
|
||
"create.ttl.6h": "6 часов",
|
||
"create.ttl.24h": "24 часа",
|
||
"create.ttl.3d": "3 дня",
|
||
"create.ttl.7d": "7 дней",
|
||
"create.ttl.default": "По умолчанию ({seconds} с)",
|
||
"lang.plaintext": "Обычный текст",
|
||
"lang.markdown": "Markdown",
|
||
"lang.json": "JSON",
|
||
"lang.yaml": "YAML",
|
||
"lang.python": "Python",
|
||
"lang.javascript": "JavaScript",
|
||
"lang.typescript": "TypeScript",
|
||
"lang.go": "Go",
|
||
"lang.rust": "Rust",
|
||
"lang.sql": "SQL",
|
||
"lang.bash": "Bash",
|
||
"lang.html": "HTML",
|
||
"lang.css": "CSS",
|
||
"relative.inMinutes": "через {n} мин",
|
||
"relative.inHours": "через {n} ч",
|
||
"relative.inDays": "через {n} дн",
|
||
"relative.soon": "скоро",
|
||
"unwrap.eyebrow": "Unwrap",
|
||
"unwrap.title": "Открыть один раз",
|
||
"unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.",
|
||
"unwrap.token": "Wrapped-токен или ID",
|
||
"unwrap.tokenPlaceholder": "wrapped_v1.… или ID",
|
||
"unwrap.password": "Пароль (если задан)",
|
||
"unwrap.submit": "Расшифровать",
|
||
"unwrap.working": "Расшифровка…",
|
||
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
|
||
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
|
||
"unwrap.badPassword": "Неверный пароль.",
|
||
"unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.",
|
||
"unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.",
|
||
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
|
||
"common.copy": "Копировать",
|
||
"common.copied": "Скопировано",
|
||
"common.download": "Скачать",
|
||
"common.error": "Что-то пошло не так.",
|
||
"common.working": "Подождите…",
|
||
"admin.nav.settings": "Настройки",
|
||
"admin.nav.audit": "Аудит",
|
||
"admin.nav.danger": "Опасная зона",
|
||
"admin.nav.site": "Сайт",
|
||
"admin.nav.logout": "Выйти",
|
||
"admin.brand.tagline": "Консоль администратора",
|
||
"admin.settings.title": "Настройки",
|
||
"admin.audit.title": "Аудит",
|
||
"admin.tab.limits": "Лимиты",
|
||
"admin.tab.rate": "Rate limits",
|
||
"admin.tab.mime": "MIME",
|
||
"admin.tab.password": "Пароль",
|
||
"admin.tab.captcha": "CAPTCHA",
|
||
"admin.login.sub": "Админ",
|
||
"admin.login.username": "Логин",
|
||
"admin.login.password": "Пароль",
|
||
"admin.login.submit": "Войти",
|
||
"admin.login.error.rate": "Слишком много попыток. Попробуйте позже.",
|
||
"admin.login.error.bad": "Неверный логин или пароль",
|
||
"admin.flash.saved": "Настройки сохранены.",
|
||
"admin.flash.purgedPrefix": "Удалено",
|
||
"admin.flash.purgedWraps": "wrap(ов) и",
|
||
"admin.flash.purgedObjects": "объект(ов).",
|
||
"admin.flash.purgeConfirm": "Введите PURGE для подтверждения очистки.",
|
||
"admin.flash.purgeError": "Очистка не удалась. Проверьте логи / MinIO.",
|
||
"admin.limits.title": "Лимиты",
|
||
"admin.limits.maxUpload": "Макс. размер (МБ)",
|
||
"admin.limits.defaultTtl": "TTL по умолчанию (часы)",
|
||
"admin.limits.maxTtl": "Макс. TTL (часы)",
|
||
"admin.limits.auditRetention": "Хранение аудита (дни)",
|
||
"admin.rate.title": "Rate limits (на IP / минуту)",
|
||
"admin.rate.create": "Создание",
|
||
"admin.rate.unwrap": "Unwrap",
|
||
"admin.rate.adminLogin": "Вход в админку",
|
||
"admin.mime.title": "MIME allowlist",
|
||
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
|
||
"admin.mime.examples": "Примеры / рекомендуемые значения",
|
||
"admin.passwordMode.title": "Режим пароля",
|
||
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).",
|
||
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.",
|
||
"admin.password.maxAttempts": "Неверных вводов пароля (unwrap)",
|
||
"admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.",
|
||
"admin.captcha.title": "CAPTCHA",
|
||
"admin.captcha.provider": "Провайдер",
|
||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||
"admin.captcha.hcaptchaSite": "hCaptcha site key",
|
||
"admin.captcha.secrets": "Секреты:",
|
||
"admin.captcha.set": "задан",
|
||
"admin.captcha.missing": "нет",
|
||
"admin.captcha.viaEnv": "(через env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
|
||
"admin.captcha.helperTitle": "Как получить токены CAPTCHA",
|
||
"admin.captcha.turnstile.1": "Откройте Cloudflare Turnstile dashboard",
|
||
"admin.captcha.turnstile.2": "Создайте виджет для домена (для dev — localhost)",
|
||
"admin.captcha.turnstile.3": "Скопируйте Site Key в поле выше",
|
||
"admin.captcha.turnstile.4": "Secret Key в env TURNSTILE_SECRET_KEY и перезапустите",
|
||
"admin.captcha.turnstile.5": "Выберите provider = turnstile",
|
||
"admin.captcha.hcaptcha.1": "Откройте hCaptcha dashboard",
|
||
"admin.captcha.hcaptcha.2": "Новый сайт → Sitekey сюда",
|
||
"admin.captcha.hcaptcha.3": "Secret в env HCAPTCHA_SECRET_KEY, перезапуск",
|
||
"admin.captcha.hcaptcha.4": "Выберите provider = hcaptcha",
|
||
"admin.settings.save": "Сохранить",
|
||
"admin.danger.title": "Опасная зона",
|
||
"admin.danger.hint": "Принудительно удаляет все wraps из БД и все ciphertext-объекты в MinIO (wraps/). Аудит сохраняется. Отменить нельзя.",
|
||
"admin.danger.confirmLabel": "Введите PURGE для подтверждения",
|
||
"admin.danger.submit": "Очистить все wraps и файлы",
|
||
"admin.danger.confirmDialog": "Удалить ВСЕ wraps и файлы? Это нельзя отменить.",
|
||
"admin.danger.confirmTitle": "Подтверждение очистки",
|
||
"admin.danger.confirmOk": "Удалить всё",
|
||
"modal.confirm.title": "Подтверждение",
|
||
"modal.confirm.ok": "Подтвердить",
|
||
"modal.confirm.cancel": "Отмена",
|
||
"admin.audit.eventType": "Тип события",
|
||
"admin.audit.wrapId": "Wrap ID",
|
||
"admin.audit.filter": "Фильтр",
|
||
"admin.audit.allEvents": "Все события",
|
||
"admin.audit.perPage": "На странице",
|
||
"admin.audit.shown": "событий показано",
|
||
"admin.audit.of": "из",
|
||
"admin.audit.events": "событий",
|
||
"admin.audit.page": "Страница",
|
||
"admin.audit.prev": "Назад",
|
||
"admin.audit.next": "Далее",
|
||
"admin.audit.col.time": "Время",
|
||
"admin.audit.col.event": "Событие",
|
||
"admin.audit.col.ok": "OK",
|
||
"admin.audit.col.wrap": "Wrap",
|
||
"admin.audit.col.details": "Детали",
|
||
"admin.audit.yes": "да",
|
||
"admin.audit.no": "нет",
|
||
"admin.audit.empty": "Нет событий",
|
||
},
|
||
};
|
||
|
||
const listeners = [];
|
||
|
||
function current() {
|
||
const stored = localStorage.getItem(KEY);
|
||
if (stored === "en" || stored === "ru") return stored;
|
||
return "ru";
|
||
}
|
||
|
||
function t(key, vars) {
|
||
const lang = current();
|
||
let text = (dict[lang] && dict[lang][key]) || dict.en[key] || key;
|
||
if (vars) {
|
||
Object.entries(vars).forEach(([k, v]) => {
|
||
text = text.replaceAll(`{${k}}`, String(v));
|
||
});
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function locale() {
|
||
return current() === "ru" ? "ru-RU" : "en-GB";
|
||
}
|
||
|
||
function expiresParts(iso) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) {
|
||
return { datetime: String(iso), relative: "" };
|
||
}
|
||
const datetime = new Intl.DateTimeFormat(locale(), {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(d);
|
||
const diffMs = d.getTime() - Date.now();
|
||
let relative = t("relative.soon");
|
||
if (diffMs > 0) {
|
||
const mins = Math.round(diffMs / 60000);
|
||
if (mins < 60) relative = t("relative.inMinutes", { n: Math.max(1, mins) });
|
||
else if (mins < 60 * 48) relative = t("relative.inHours", { n: Math.round(mins / 60) });
|
||
else relative = t("relative.inDays", { n: Math.round(mins / (60 * 24)) });
|
||
}
|
||
return { datetime, relative };
|
||
}
|
||
|
||
function formatExpires(iso) {
|
||
const { datetime, relative } = expiresParts(iso);
|
||
return `${t("create.expires", { datetime })} · ${t("create.expiresRelative", { relative })}`;
|
||
}
|
||
|
||
function formatExpiresHtml(iso) {
|
||
const { datetime, relative } = expiresParts(iso);
|
||
const main = t("create.expires", { datetime });
|
||
const rel = t("create.expiresRelative", { relative });
|
||
return `<span class="expires-main">${main}</span><span class="expires-rel">${rel}</span>`;
|
||
}
|
||
|
||
function apply() {
|
||
const lang = current();
|
||
document.documentElement.lang = lang;
|
||
const label = document.getElementById("lang-label");
|
||
if (label) label.textContent = lang.toUpperCase();
|
||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||
el.textContent = t(el.getAttribute("data-i18n"));
|
||
});
|
||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder")));
|
||
});
|
||
document.querySelectorAll("[data-i18n-tooltip]").forEach((el) => {
|
||
el.textContent = t(el.getAttribute("data-i18n-tooltip"));
|
||
});
|
||
listeners.forEach((fn) => {
|
||
try {
|
||
fn(lang);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
}
|
||
|
||
function onChange(fn) {
|
||
listeners.push(fn);
|
||
}
|
||
|
||
function toggle() {
|
||
const next = current() === "en" ? "ru" : "en";
|
||
localStorage.setItem(KEY, next);
|
||
apply();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
apply();
|
||
const btn = document.getElementById("lang-toggle");
|
||
if (btn) btn.addEventListener("click", toggle);
|
||
});
|
||
|
||
window.WrappedI18n = {
|
||
t,
|
||
apply,
|
||
current,
|
||
locale,
|
||
formatExpires,
|
||
formatExpiresHtml,
|
||
onChange,
|
||
};
|
||
})();
|