Выпущен 0.1.3: UX convenience pack, unwrap/admin polish и cache-bust статики.
devops-tools/wrapped/wrapped-build/pipeline/head There was a failure building this commit
devops-tools/wrapped/wrapped-build/pipeline/head There was a failure building this commit
Добавлены Share/QR PNG, zip all, size meter, PWA-manifest, системная тема и haptic на copy; улучшены success/unwrap и статистика админки (EN/RU).
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
(() => {
|
||||
const top = document.getElementById("admin-top");
|
||||
const toggle = document.getElementById("admin-nav-toggle");
|
||||
const nav = document.getElementById("admin-nav");
|
||||
if (!top || !toggle || !nav) return;
|
||||
|
||||
const mq = window.matchMedia("(max-width: 860px)");
|
||||
|
||||
const syncLabel = () => {
|
||||
const open = top.classList.contains("is-nav-open");
|
||||
const key = open ? "admin.nav.closeMenu" : "admin.nav.openMenu";
|
||||
const label = window.WrappedI18n?.t(key) || (open ? "Close menu" : "Menu");
|
||||
toggle.setAttribute("aria-label", label);
|
||||
toggle.setAttribute("title", label);
|
||||
};
|
||||
|
||||
const setOpen = (open) => {
|
||||
top.classList.toggle("is-nav-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
const icon = toggle.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = open ? "fa-solid fa-xmark" : "fa-solid fa-bars";
|
||||
}
|
||||
syncLabel();
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
setOpen(!top.classList.contains("is-nav-open"));
|
||||
});
|
||||
|
||||
nav.addEventListener("click", (e) => {
|
||||
if (!mq.matches) return;
|
||||
if (e.target.closest("a.admin-nav-link")) setOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && top.classList.contains("is-nav-open")) {
|
||||
setOpen(false);
|
||||
toggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
mq.addEventListener("change", () => {
|
||||
if (!mq.matches) setOpen(false);
|
||||
else syncLabel();
|
||||
});
|
||||
|
||||
if (window.WrappedI18n?.onChange) {
|
||||
window.WrappedI18n.onChange(syncLabel);
|
||||
}
|
||||
syncLabel();
|
||||
})();
|
||||
+133
-7
@@ -25,8 +25,15 @@
|
||||
composer: document.querySelector(".composer"),
|
||||
shareLink: document.getElementById("share-link"),
|
||||
shareToken: document.getElementById("share-token"),
|
||||
sharePassword: document.getElementById("share-password"),
|
||||
passwordBlock: document.getElementById("success-password-block"),
|
||||
shareQr: document.getElementById("share-qr"),
|
||||
expiresMeta: document.getElementById("expires-meta"),
|
||||
captchaSlot: document.getElementById("captcha-slot"),
|
||||
sizeMeter: document.getElementById("size-meter"),
|
||||
checkPasswordItem: document.getElementById("check-password-item"),
|
||||
shareNative: document.getElementById("share-native"),
|
||||
downloadQr: document.getElementById("download-qr"),
|
||||
};
|
||||
|
||||
function showError(msg) {
|
||||
@@ -100,6 +107,34 @@
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
function estimatePayloadBytes() {
|
||||
const textBytes = new TextEncoder().encode(els.text?.value || "").length;
|
||||
const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||||
return textBytes + fileBytes;
|
||||
}
|
||||
|
||||
function refreshSizeMeter() {
|
||||
if (!els.sizeMeter || !settings) return;
|
||||
const used = estimatePayloadBytes();
|
||||
const max = Number(settings.max_upload_bytes) || 0;
|
||||
if (!max) {
|
||||
els.sizeMeter.textContent = "";
|
||||
els.sizeMeter.classList.remove("is-warn", "is-over");
|
||||
return;
|
||||
}
|
||||
const fmt = window.WrappedUI.formatBytes;
|
||||
let text = t("create.sizeMeter", {
|
||||
used: fmt(used),
|
||||
max: fmt(max),
|
||||
});
|
||||
const near = used > max * 0.85 && used <= max;
|
||||
const over = used > max;
|
||||
if (near) text = `${text} · ${t("create.sizeNearLimit")}`;
|
||||
els.sizeMeter.textContent = text;
|
||||
els.sizeMeter.classList.toggle("is-over", over);
|
||||
els.sizeMeter.classList.toggle("is-warn", near);
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
els.fileList.innerHTML = "";
|
||||
files.forEach((f, idx) => {
|
||||
@@ -115,6 +150,7 @@
|
||||
li.appendChild(btn);
|
||||
els.fileList.appendChild(li);
|
||||
});
|
||||
refreshSizeMeter();
|
||||
}
|
||||
|
||||
function addFiles(list) {
|
||||
@@ -207,6 +243,8 @@
|
||||
loadCaptcha();
|
||||
setLanguage("plaintext", { silent: true });
|
||||
refreshHighlight();
|
||||
refreshSizeMeter();
|
||||
syncShareControls();
|
||||
}
|
||||
|
||||
els.dropzone.addEventListener("click", () => els.fileInput.click());
|
||||
@@ -243,6 +281,7 @@
|
||||
|
||||
els.text.addEventListener("input", () => {
|
||||
refreshHighlight();
|
||||
refreshSizeMeter();
|
||||
});
|
||||
els.text.addEventListener("scroll", () => {
|
||||
const pre = els.highlight.parentElement;
|
||||
@@ -260,19 +299,104 @@
|
||||
}
|
||||
});
|
||||
|
||||
function renderShareQr(link) {
|
||||
if (!els.shareQr) return;
|
||||
els.shareQr.innerHTML = "";
|
||||
if (!link || typeof QRCode === "undefined") return;
|
||||
const size = window.matchMedia("(max-width: 860px)").matches ? 200 : 164;
|
||||
// eslint-disable-next-line no-new
|
||||
new QRCode(els.shareQr, {
|
||||
text: link,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: "#0d1420",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M,
|
||||
});
|
||||
}
|
||||
|
||||
function syncShareControls() {
|
||||
if (els.shareNative) {
|
||||
const canShare = typeof navigator.share === "function";
|
||||
els.shareNative.classList.toggle("hidden", !canShare);
|
||||
els.shareNative.setAttribute("aria-label", t("create.share"));
|
||||
}
|
||||
if (els.downloadQr) {
|
||||
els.downloadQr.setAttribute("aria-label", t("create.downloadQr"));
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess({ link, token, password, expiresAt }) {
|
||||
els.shareLink.value = link;
|
||||
els.shareToken.value = token;
|
||||
lastExpiresAt = expiresAt;
|
||||
refreshExpiresMeta();
|
||||
if (password) {
|
||||
els.sharePassword.value = password;
|
||||
els.passwordBlock.classList.remove("hidden");
|
||||
els.checkPasswordItem?.classList.remove("hidden");
|
||||
} else {
|
||||
els.sharePassword.value = "";
|
||||
els.passwordBlock.classList.add("hidden");
|
||||
els.checkPasswordItem?.classList.add("hidden");
|
||||
}
|
||||
renderShareQr(link);
|
||||
syncShareControls();
|
||||
els.composer.classList.add("hidden");
|
||||
els.success.classList.remove("hidden");
|
||||
els.success.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
async function copyFrom(input, btn) {
|
||||
await navigator.clipboard.writeText(input.value);
|
||||
try {
|
||||
await window.WrappedUI.copyText(input.value);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(input.value);
|
||||
}
|
||||
const old = btn.textContent;
|
||||
btn.textContent = t("common.copied");
|
||||
setTimeout(() => (btn.textContent = old), 1200);
|
||||
}
|
||||
|
||||
function downloadQrPng() {
|
||||
if (!els.shareQr) return;
|
||||
const canvas = els.shareQr.querySelector("canvas");
|
||||
const img = els.shareQr.querySelector("img");
|
||||
let href = "";
|
||||
if (canvas && canvas.toDataURL) {
|
||||
href = canvas.toDataURL("image/png");
|
||||
} else if (img?.src) {
|
||||
href = img.src;
|
||||
}
|
||||
if (!href) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = "wrapped-qr.png";
|
||||
a.click();
|
||||
}
|
||||
|
||||
document.getElementById("copy-link").addEventListener("click", () => {
|
||||
copyFrom(els.shareLink, document.getElementById("copy-link"));
|
||||
});
|
||||
document.getElementById("copy-token").addEventListener("click", () => {
|
||||
copyFrom(els.shareToken, document.getElementById("copy-token"));
|
||||
});
|
||||
document.getElementById("copy-password")?.addEventListener("click", () => {
|
||||
copyFrom(els.sharePassword, document.getElementById("copy-password"));
|
||||
});
|
||||
els.shareNative?.addEventListener("click", async () => {
|
||||
if (typeof navigator.share !== "function" || !els.shareLink.value) return;
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("create.shareTitle"),
|
||||
text: t("create.shareText"),
|
||||
url: els.shareLink.value,
|
||||
});
|
||||
} catch {
|
||||
/* user cancelled or unsupported */
|
||||
}
|
||||
});
|
||||
els.downloadQr?.addEventListener("click", downloadQrPng);
|
||||
document.getElementById("create-another").addEventListener("click", () => {
|
||||
location.reload();
|
||||
});
|
||||
@@ -346,12 +470,12 @@
|
||||
const data = await resp.json();
|
||||
const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url);
|
||||
const link = `${location.origin}${data.share_path}#${keyB64url}`;
|
||||
els.shareLink.value = link;
|
||||
els.shareToken.value = token;
|
||||
lastExpiresAt = data.expires_at;
|
||||
refreshExpiresMeta();
|
||||
els.composer.classList.add("hidden");
|
||||
els.success.classList.remove("hidden");
|
||||
showSuccess({
|
||||
link,
|
||||
token,
|
||||
password,
|
||||
expiresAt: data.expires_at,
|
||||
});
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
@@ -380,6 +504,8 @@
|
||||
window.WrappedI18n.onChange(() => {
|
||||
fillTtl();
|
||||
refreshExpiresMeta();
|
||||
refreshSizeMeter();
|
||||
syncShareControls();
|
||||
setLanguage(els.lang.value, { silent: true });
|
||||
refreshHighlight();
|
||||
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
|
||||
|
||||
+90
-6
@@ -39,6 +39,20 @@
|
||||
"create.successTitle": "Token issued",
|
||||
"create.successWarnTitle": "One-time unwrap",
|
||||
"create.successWarnHint": "After opening, ciphertext is deleted on the server.",
|
||||
"create.passwordProtected": "Password protected",
|
||||
"create.sharePassword": "Password",
|
||||
"create.passwordSeparateHint": "Do not send the password in the same chat as the link.",
|
||||
"create.qrHint": "Scan to open the share link",
|
||||
"create.share": "Share",
|
||||
"create.shareTitle": "Wrapped link",
|
||||
"create.shareText": "One-time secure link from Wrapped",
|
||||
"create.downloadQr": "Download QR",
|
||||
"create.checkLink": "Copy the share link",
|
||||
"create.checkPassword": "Send the password in a separate message",
|
||||
"create.checkExpires": "Note the expiry time",
|
||||
"create.trustKey": "The encryption key lives only in the link #fragment — the server never sees it.",
|
||||
"create.sizeMeter": "≈ {used} / {max}",
|
||||
"create.sizeNearLimit": "Approaching size limit",
|
||||
"create.shareLink": "Share link",
|
||||
"create.token": "Wrapped token",
|
||||
"create.another": "Create another",
|
||||
@@ -78,17 +92,36 @@
|
||||
"unwrap.password": "Password (if set)",
|
||||
"unwrap.submit": "Unwrap",
|
||||
"unwrap.working": "Decrypting…",
|
||||
"unwrap.workingFetch": "Downloading…",
|
||||
"unwrap.workingDecrypt": "Decrypting…",
|
||||
"unwrap.destroyedTitle": "Server copy destroyed",
|
||||
"unwrap.destroyedHint": "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.badToken": "Invalid token",
|
||||
"unwrap.badTokenHint": "Paste a full wrapped token or open a valid share link.",
|
||||
"unwrap.badPassword": "Wrong password",
|
||||
"unwrap.badPasswordHint": "Check the password and try again.",
|
||||
"unwrap.passwordRequired": "Password required",
|
||||
"unwrap.passwordRequiredHint": "This wrap is password-protected.",
|
||||
"unwrap.attemptsLeft": "{n} of {max} attempts left",
|
||||
"unwrap.passwordLocked": "Too many wrong passwords",
|
||||
"unwrap.passwordLockedHint": "This wrap has been destroyed.",
|
||||
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
|
||||
"unwrap.passwordLockedHint": "This wrap has been destroyed after too many failed attempts.",
|
||||
"unwrap.unavailable": "Unavailable",
|
||||
"unwrap.unavailableHint": "Already used, expired, or invalid.",
|
||||
"unwrap.goneTitle": "This link is no longer available",
|
||||
"unwrap.goneHint": "Wrapped links are one-time and can expire. The ciphertext is gone from the server.",
|
||||
"unwrap.rateLimited": "Too many requests",
|
||||
"unwrap.rateLimitedHint": "Wait a minute and try again.",
|
||||
"unwrap.captchaFailed": "CAPTCHA failed",
|
||||
"unwrap.captchaFailedHint": "Complete the CAPTCHA and try again.",
|
||||
"unwrap.storageError": "Temporary storage error",
|
||||
"unwrap.storageErrorHint": "Try again in a moment.",
|
||||
"unwrap.decryptFailed": "Could not decrypt",
|
||||
"unwrap.decryptFailedHint": "The key in the link may be wrong. Ciphertext was already removed from the server.",
|
||||
"unwrap.createOwn": "Create your own wrap",
|
||||
"unwrap.tapPreview": "Tap image to enlarge",
|
||||
"unwrap.downloadAll": "Download all",
|
||||
"unwrap.trustKey": "The key was only in the link #fragment and was never sent to the server.",
|
||||
"common.copy": "Copy",
|
||||
"common.copied": "Copied",
|
||||
"common.download": "Download",
|
||||
@@ -100,6 +133,8 @@
|
||||
"admin.nav.danger": "Danger",
|
||||
"admin.nav.site": "Site",
|
||||
"admin.nav.logout": "Logout",
|
||||
"admin.nav.openMenu": "Menu",
|
||||
"admin.nav.closeMenu": "Close menu",
|
||||
"admin.brand.tagline": "Admin console",
|
||||
"admin.settings.title": "Settings",
|
||||
"admin.stats.title": "Statistics",
|
||||
@@ -107,14 +142,21 @@
|
||||
"admin.stats.minioTitle": "MinIO (pending ciphertext)",
|
||||
"admin.stats.objects": "object(s)",
|
||||
"admin.stats.dbPending": "DB pending",
|
||||
"admin.stats.unwrappedTitle": "Unwrapped (all time)",
|
||||
"admin.stats.unwrappedTitle": "Successful unwraps",
|
||||
"admin.stats.auditOk": "audit ok",
|
||||
"admin.stats.dbConsumed": "DB consumed",
|
||||
"admin.stats.last24h": "24h",
|
||||
"admin.stats.pendingTitle": "Not unwrapped",
|
||||
"admin.stats.items": "item(s)",
|
||||
"admin.stats.uploadedTitle": "Uploaded (all time)",
|
||||
"admin.stats.passwordBurnsTitle": "Burned by password",
|
||||
"admin.stats.passwordBurnsHint": "Destroyed after too many wrong passwords",
|
||||
"admin.stats.wrapsSection": "Wraps by status",
|
||||
"admin.stats.extraSection": "More",
|
||||
"admin.stats.failReasonsSection": "Unwrap fail reasons (all time)",
|
||||
"admin.stats.failReasons24hSection": "Unwrap fail reasons (24h)",
|
||||
"admin.stats.col.reason": "Reason",
|
||||
"admin.stats.noFailReasons": "No failed unwraps yet.",
|
||||
"admin.stats.col.status": "Status",
|
||||
"admin.stats.col.count": "Count",
|
||||
"admin.stats.col.size": "Size",
|
||||
@@ -257,6 +299,20 @@
|
||||
"create.successTitle": "Токен выдан",
|
||||
"create.successWarnTitle": "Расшифруй один раз",
|
||||
"create.successWarnHint": "После открытия ciphertext удаляется на сервере.",
|
||||
"create.passwordProtected": "Защищено паролем",
|
||||
"create.sharePassword": "Пароль",
|
||||
"create.passwordSeparateHint": "Не отправляйте пароль в той же переписке, что и ссылку.",
|
||||
"create.qrHint": "Отсканируйте, чтобы открыть ссылку",
|
||||
"create.share": "Поделиться",
|
||||
"create.shareTitle": "Ссылка Wrapped",
|
||||
"create.shareText": "Одноразовая защищённая ссылка из Wrapped",
|
||||
"create.downloadQr": "Скачать QR",
|
||||
"create.checkLink": "Скопируйте ссылку",
|
||||
"create.checkPassword": "Отправьте пароль отдельным сообщением",
|
||||
"create.checkExpires": "Учтите срок действия",
|
||||
"create.trustKey": "Ключ шифрования только во фрагменте ссылки #… — сервер его не видит.",
|
||||
"create.sizeMeter": "≈ {used} / {max}",
|
||||
"create.sizeNearLimit": "Близко к лимиту размера",
|
||||
"create.shareLink": "Ссылка",
|
||||
"create.token": "Wrapped-токен",
|
||||
"create.another": "Создать ещё",
|
||||
@@ -296,17 +352,36 @@
|
||||
"unwrap.password": "Пароль (если задан)",
|
||||
"unwrap.submit": "Расшифровать",
|
||||
"unwrap.working": "Расшифровка…",
|
||||
"unwrap.workingFetch": "Скачивание…",
|
||||
"unwrap.workingDecrypt": "Расшифровка…",
|
||||
"unwrap.destroyedTitle": "Копия на сервере уничтожена",
|
||||
"unwrap.destroyedHint": "Превью только в этой сессии браузера.",
|
||||
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
|
||||
"unwrap.badToken": "Неверный токен",
|
||||
"unwrap.badTokenHint": "Вставьте полный wrapped-токен или откройте корректную ссылку.",
|
||||
"unwrap.badPassword": "Неверный пароль",
|
||||
"unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.",
|
||||
"unwrap.passwordRequired": "Нужен пароль",
|
||||
"unwrap.passwordRequiredHint": "Этот wrap защищён паролем.",
|
||||
"unwrap.attemptsLeft": "Осталось попыток: {n} из {max}",
|
||||
"unwrap.passwordLocked": "Слишком много неверных паролей",
|
||||
"unwrap.passwordLockedHint": "Этот wrap уничтожен.",
|
||||
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
|
||||
"unwrap.passwordLockedHint": "Wrap уничтожен после исчерпания попыток.",
|
||||
"unwrap.unavailable": "Недоступно",
|
||||
"unwrap.unavailableHint": "Уже использовано, истекло или неверно.",
|
||||
"unwrap.goneTitle": "Ссылка больше недоступна",
|
||||
"unwrap.goneHint": "Wrapped-ссылки одноразовые и могут истекать. Ciphertext уже удалён с сервера.",
|
||||
"unwrap.rateLimited": "Слишком много запросов",
|
||||
"unwrap.rateLimitedHint": "Подождите минуту и попробуйте снова.",
|
||||
"unwrap.captchaFailed": "CAPTCHA не пройдена",
|
||||
"unwrap.captchaFailedHint": "Пройдите CAPTCHA и попробуйте снова.",
|
||||
"unwrap.storageError": "Временная ошибка хранилища",
|
||||
"unwrap.storageErrorHint": "Попробуйте чуть позже.",
|
||||
"unwrap.decryptFailed": "Не удалось расшифровать",
|
||||
"unwrap.decryptFailedHint": "Ключ в ссылке может быть неверным. Ciphertext уже удалён с сервера.",
|
||||
"unwrap.createOwn": "Создать свой wrap",
|
||||
"unwrap.tapPreview": "Нажмите на изображение, чтобы увеличить",
|
||||
"unwrap.downloadAll": "Скачать всё",
|
||||
"unwrap.trustKey": "Ключ был только во фрагменте ссылки #… и не уходил на сервер.",
|
||||
"common.copy": "Копировать",
|
||||
"common.copied": "Скопировано",
|
||||
"common.download": "Скачать",
|
||||
@@ -318,6 +393,8 @@
|
||||
"admin.nav.danger": "Опасная зона",
|
||||
"admin.nav.site": "Сайт",
|
||||
"admin.nav.logout": "Выйти",
|
||||
"admin.nav.openMenu": "Меню",
|
||||
"admin.nav.closeMenu": "Закрыть меню",
|
||||
"admin.brand.tagline": "Консоль администратора",
|
||||
"admin.settings.title": "Настройки",
|
||||
"admin.stats.title": "Статистика",
|
||||
@@ -325,14 +402,21 @@
|
||||
"admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)",
|
||||
"admin.stats.objects": "объект(ов)",
|
||||
"admin.stats.dbPending": "в БД pending",
|
||||
"admin.stats.unwrappedTitle": "Расшифровано (за всё время)",
|
||||
"admin.stats.unwrappedTitle": "Успешные unwrap",
|
||||
"admin.stats.auditOk": "audit ok",
|
||||
"admin.stats.dbConsumed": "в БД consumed",
|
||||
"admin.stats.last24h": "за 24ч",
|
||||
"admin.stats.pendingTitle": "Не расшифровано",
|
||||
"admin.stats.items": "элемент(ов)",
|
||||
"admin.stats.uploadedTitle": "Загружено (за всё время)",
|
||||
"admin.stats.passwordBurnsTitle": "Сожжено паролем",
|
||||
"admin.stats.passwordBurnsHint": "Уничтожено после слишком многих неверных паролей",
|
||||
"admin.stats.wrapsSection": "Wraps по статусу",
|
||||
"admin.stats.extraSection": "Ещё",
|
||||
"admin.stats.failReasonsSection": "Причины ошибок unwrap (всё время)",
|
||||
"admin.stats.failReasons24hSection": "Причины ошибок unwrap (24ч)",
|
||||
"admin.stats.col.reason": "Причина",
|
||||
"admin.stats.noFailReasons": "Пока нет неудачных unwrap.",
|
||||
"admin.stats.col.status": "Статус",
|
||||
"admin.stats.col.count": "Кол-во",
|
||||
"admin.stats.col.size": "Размер",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
(() => {
|
||||
const KEY = "wrapped.theme";
|
||||
|
||||
function systemTheme() {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
function preferred() {
|
||||
return localStorage.getItem(KEY) || "dark";
|
||||
const stored = localStorage.getItem(KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return systemTheme();
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
@@ -16,7 +22,8 @@
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const next = preferred() === "dark" ? "light" : "dark";
|
||||
const current = document.documentElement.getAttribute("data-theme") || preferred();
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(KEY, next);
|
||||
apply(next);
|
||||
}
|
||||
|
||||
+18
-2
@@ -8,10 +8,13 @@
|
||||
busyEl.className = "busy-overlay hidden";
|
||||
busyEl.setAttribute("aria-live", "polite");
|
||||
busyEl.setAttribute("aria-busy", "true");
|
||||
const favicon =
|
||||
document.querySelector('link[rel="icon"][type="image/svg+xml"]')?.href ||
|
||||
"/static/favicon.svg";
|
||||
busyEl.innerHTML = `
|
||||
<div class="busy-card">
|
||||
<span class="busy-logo" aria-hidden="true">
|
||||
<img src="/static/favicon.svg" alt="" width="48" height="48" />
|
||||
<img src="${favicon}" alt="" width="48" height="48" />
|
||||
</span>
|
||||
<p class="busy-text" data-busy-label></p>
|
||||
</div>
|
||||
@@ -48,5 +51,18 @@
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
window.WrappedUI = { showBusy, hideBusy, formatBytes };
|
||||
function hapticLight() {
|
||||
try {
|
||||
navigator.vibrate?.(12);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
hapticLight();
|
||||
}
|
||||
|
||||
window.WrappedUI = { showBusy, hideBusy, formatBytes, hapticLight, copyText };
|
||||
})();
|
||||
|
||||
+248
-23
@@ -1,6 +1,7 @@
|
||||
(() => {
|
||||
const t = (k, vars) => window.WrappedI18n.t(k, vars);
|
||||
let settings = null;
|
||||
const objectUrls = [];
|
||||
|
||||
const els = {
|
||||
token: document.getElementById("token-input"),
|
||||
@@ -11,11 +12,24 @@
|
||||
errorHint: document.getElementById("form-error-hint"),
|
||||
errorAttempts: document.getElementById("form-error-attempts"),
|
||||
form: document.getElementById("unwrap-form"),
|
||||
head: document.getElementById("unwrap-head"),
|
||||
state: document.getElementById("unwrap-state"),
|
||||
stateTitle: document.getElementById("unwrap-state-title"),
|
||||
stateHint: document.getElementById("unwrap-state-hint"),
|
||||
stateFa: document.getElementById("unwrap-state-fa"),
|
||||
result: document.getElementById("result-panel"),
|
||||
items: document.getElementById("items"),
|
||||
captchaSlot: document.getElementById("captcha-slot"),
|
||||
lightbox: document.getElementById("image-lightbox"),
|
||||
lightboxImg: document.getElementById("lightbox-img"),
|
||||
lightboxCaption: document.getElementById("lightbox-caption"),
|
||||
lightboxClose: document.getElementById("lightbox-close"),
|
||||
downloadAll: document.getElementById("download-all"),
|
||||
};
|
||||
|
||||
let lastPack = null;
|
||||
let lastWrapId = null;
|
||||
|
||||
function showError(title, hint, attempts) {
|
||||
if (!els.error) return;
|
||||
if (els.errorTitle) els.errorTitle.textContent = title || "";
|
||||
@@ -37,7 +51,6 @@
|
||||
els.errorAttempts.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
// Fallback if structured nodes missing
|
||||
if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" ");
|
||||
els.error.classList.remove("hidden");
|
||||
}
|
||||
@@ -56,6 +69,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showState({ title, hint, icon = "fa-link-slash" }) {
|
||||
clearError();
|
||||
els.form?.classList.add("hidden");
|
||||
els.head?.classList.add("hidden");
|
||||
els.result?.classList.add("hidden");
|
||||
if (els.stateFa) els.stateFa.className = `fa-solid ${icon}`;
|
||||
if (els.stateTitle) els.stateTitle.textContent = title || "";
|
||||
if (els.stateHint) els.stateHint.textContent = hint || "";
|
||||
els.state?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function attemptsLabel(detail) {
|
||||
const n = Number(detail?.attempts_remaining);
|
||||
const max = Number(detail?.attempts_max);
|
||||
@@ -63,6 +87,41 @@
|
||||
return t("unwrap.attemptsLeft", { n, max });
|
||||
}
|
||||
|
||||
function trackUrl(url) {
|
||||
objectUrls.push(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function revokeAllUrls() {
|
||||
while (objectUrls.length) {
|
||||
try {
|
||||
URL.revokeObjectURL(objectUrls.pop());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
if (!els.lightbox) return;
|
||||
els.lightbox.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (els.lightboxImg) {
|
||||
els.lightboxImg.removeAttribute("src");
|
||||
els.lightboxImg.alt = "";
|
||||
}
|
||||
if (els.lightboxCaption) els.lightboxCaption.textContent = "";
|
||||
}
|
||||
|
||||
function openLightbox(src, caption) {
|
||||
if (!els.lightbox || !els.lightboxImg) return;
|
||||
els.lightboxImg.src = src;
|
||||
els.lightboxImg.alt = caption || "";
|
||||
if (els.lightboxCaption) els.lightboxCaption.textContent = caption || "";
|
||||
els.lightbox.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
function loadCaptcha() {
|
||||
els.captchaSlot.innerHTML = "";
|
||||
const provider = settings.captcha_provider;
|
||||
@@ -105,10 +164,11 @@
|
||||
|
||||
function downloadBlob(name, blob) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
const url = URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
}
|
||||
|
||||
function makeDownloadBtn(onClick) {
|
||||
@@ -120,6 +180,12 @@
|
||||
return btn;
|
||||
}
|
||||
|
||||
function focusPassword({ select = false } = {}) {
|
||||
if (!els.password) return;
|
||||
els.password.focus();
|
||||
if (select) els.password.select?.();
|
||||
}
|
||||
|
||||
function makeCopyBtn(getText) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn copy-btn";
|
||||
@@ -129,7 +195,11 @@
|
||||
btn.innerHTML = label();
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getText() || "");
|
||||
if (window.WrappedUI?.copyText) {
|
||||
await window.WrappedUI.copyText(getText() || "");
|
||||
} else {
|
||||
await navigator.clipboard.writeText(getText() || "");
|
||||
}
|
||||
btn.innerHTML = `<i class="fa-solid fa-check" aria-hidden="true"></i><span>${t("common.copied")}</span>`;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = label();
|
||||
@@ -141,7 +211,71 @@
|
||||
return btn;
|
||||
}
|
||||
|
||||
function itemToBlob(item) {
|
||||
if (item.type === "text") {
|
||||
const lang = item.language || "plaintext";
|
||||
return {
|
||||
name: `wrapped-${lang}.txt`,
|
||||
blob: new Blob([item.content || ""], { type: "text/plain" }),
|
||||
};
|
||||
}
|
||||
if (item.type === "file") {
|
||||
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
|
||||
const mime = item.mime || "application/octet-stream";
|
||||
return {
|
||||
name: item.name || "file",
|
||||
blob: new Blob([bytes], { type: mime }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniqueZipName(name, used) {
|
||||
let base = name || "file";
|
||||
if (!used.has(base)) {
|
||||
used.add(base);
|
||||
return base;
|
||||
}
|
||||
const dot = base.lastIndexOf(".");
|
||||
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||
const ext = dot > 0 ? base.slice(dot) : "";
|
||||
let i = 2;
|
||||
let candidate = `${stem}-${i}${ext}`;
|
||||
while (used.has(candidate)) {
|
||||
i += 1;
|
||||
candidate = `${stem}-${i}${ext}`;
|
||||
}
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function downloadAllZip() {
|
||||
if (!lastPack || typeof JSZip === "undefined") return;
|
||||
const items = lastPack.items || [];
|
||||
if (items.length < 2) return;
|
||||
const zip = new JSZip();
|
||||
const used = new Set();
|
||||
for (const item of items) {
|
||||
const entry = itemToBlob(item);
|
||||
if (!entry) continue;
|
||||
zip.file(uniqueZipName(entry.name, used), entry.blob);
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const id = lastWrapId || "pack";
|
||||
downloadBlob(`wrapped-${id}.zip`, blob);
|
||||
}
|
||||
|
||||
function syncDownloadAll() {
|
||||
if (!els.downloadAll) return;
|
||||
const count = lastPack?.items?.length || 0;
|
||||
const show = count >= 2 && typeof JSZip !== "undefined";
|
||||
els.downloadAll.classList.toggle("hidden", !show);
|
||||
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
|
||||
}
|
||||
|
||||
function renderPackage(pack) {
|
||||
revokeAllUrls();
|
||||
lastPack = pack;
|
||||
els.items.innerHTML = "";
|
||||
for (const item of pack.items || []) {
|
||||
const card = document.createElement("div");
|
||||
@@ -151,7 +285,16 @@
|
||||
const text = item.content || "";
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
head.innerHTML = `<div><strong>text</strong> · <span class="mono">${lang}</span></div>`;
|
||||
const meta = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = "text";
|
||||
meta.appendChild(strong);
|
||||
meta.appendChild(document.createTextNode(" · "));
|
||||
const langEl = document.createElement("span");
|
||||
langEl.className = "mono";
|
||||
langEl.textContent = lang;
|
||||
meta.appendChild(langEl);
|
||||
head.appendChild(meta);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "item-card-actions";
|
||||
actions.appendChild(makeCopyBtn(() => text));
|
||||
@@ -170,28 +313,86 @@
|
||||
window.WrappedHighlight.highlightElement(code, text, lang);
|
||||
} else if (item.type === "file") {
|
||||
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
|
||||
const blob = new Blob([bytes], { type: item.mime || "application/octet-stream" });
|
||||
const mime = item.mime || "application/octet-stream";
|
||||
const blob = new Blob([bytes], { type: mime });
|
||||
const name = item.name || "file";
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
head.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${window.WrappedUI.formatBytes(bytes.length)}</div>`;
|
||||
head.appendChild(makeDownloadBtn(() => downloadBlob(item.name || "file", blob)));
|
||||
const meta = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = name;
|
||||
meta.appendChild(strong);
|
||||
meta.appendChild(document.createTextNode(" · "));
|
||||
const mimeEl = document.createElement("span");
|
||||
mimeEl.className = "mono";
|
||||
mimeEl.textContent = mime;
|
||||
meta.appendChild(mimeEl);
|
||||
meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
|
||||
head.appendChild(meta);
|
||||
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
|
||||
card.appendChild(head);
|
||||
if ((item.mime || "").startsWith("image/")) {
|
||||
if (mime.startsWith("image/")) {
|
||||
const url = trackUrl(URL.createObjectURL(blob));
|
||||
const img = document.createElement("img");
|
||||
img.alt = item.name;
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.alt = name;
|
||||
img.src = url;
|
||||
img.className = "item-preview-img";
|
||||
img.loading = "lazy";
|
||||
img.addEventListener("click", () => openLightbox(url, name));
|
||||
card.appendChild(img);
|
||||
const tip = document.createElement("p");
|
||||
tip.className = "hint item-preview-hint";
|
||||
tip.textContent = t("unwrap.tapPreview");
|
||||
card.appendChild(tip);
|
||||
}
|
||||
}
|
||||
els.items.appendChild(card);
|
||||
}
|
||||
syncDownloadAll();
|
||||
}
|
||||
|
||||
function mapHttpError(resp, detail) {
|
||||
if (resp.status === 429 || detail === "rate_limited") {
|
||||
showError(t("unwrap.rateLimited"), t("unwrap.rateLimitedHint"));
|
||||
return;
|
||||
}
|
||||
if (detail === "captcha_failed" || resp.status === 400) {
|
||||
if (detail === "captcha_failed") {
|
||||
showError(t("unwrap.captchaFailed"), t("unwrap.captchaFailedHint"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (resp.status === 500 || detail === "storage_error") {
|
||||
showError(t("unwrap.storageError"), t("unwrap.storageErrorHint"));
|
||||
return;
|
||||
}
|
||||
if (resp.status === 410 || detail === "unavailable") {
|
||||
showState({
|
||||
title: t("unwrap.goneTitle"),
|
||||
hint: t("unwrap.goneHint"),
|
||||
icon: "fa-link-slash",
|
||||
});
|
||||
return;
|
||||
}
|
||||
showError(t("unwrap.unavailable"), t("unwrap.unavailableHint"));
|
||||
}
|
||||
|
||||
els.lightboxClose?.addEventListener("click", closeLightbox);
|
||||
els.lightbox?.addEventListener("click", (e) => {
|
||||
if (e.target === els.lightbox) closeLightbox();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && els.lightbox && !els.lightbox.classList.contains("hidden")) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
window.addEventListener("pagehide", revokeAllUrls);
|
||||
|
||||
els.btn.addEventListener("click", async () => {
|
||||
clearError();
|
||||
const parsed = window.WrappedCrypto.parseToken(els.token.value);
|
||||
if (!parsed) {
|
||||
showError(t("unwrap.unavailable"));
|
||||
showError(t("unwrap.badToken"), t("unwrap.badTokenHint"));
|
||||
return;
|
||||
}
|
||||
const key = resolveKey(parsed);
|
||||
@@ -201,7 +402,7 @@
|
||||
}
|
||||
|
||||
els.btn.disabled = true;
|
||||
window.WrappedUI.showBusy(t("unwrap.working"));
|
||||
window.WrappedUI.showBusy(t("unwrap.workingFetch"));
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
|
||||
method: "POST",
|
||||
@@ -216,10 +417,11 @@
|
||||
const detail = err.detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
if (detail.code === "password_locked") {
|
||||
showError(
|
||||
t("unwrap.passwordLocked"),
|
||||
t("unwrap.passwordLockedHint")
|
||||
);
|
||||
showState({
|
||||
title: t("unwrap.passwordLocked"),
|
||||
hint: t("unwrap.passwordLockedHint"),
|
||||
icon: "fa-shield-halved",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detail.code === "password_required") {
|
||||
@@ -228,7 +430,7 @@
|
||||
t("unwrap.passwordRequiredHint"),
|
||||
attemptsLabel(detail)
|
||||
);
|
||||
els.password?.focus();
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
if (detail.code === "bad_password") {
|
||||
@@ -237,19 +439,21 @@
|
||||
t("unwrap.badPasswordHint"),
|
||||
attemptsLabel(detail)
|
||||
);
|
||||
els.password?.focus();
|
||||
els.password?.select?.();
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showError(t("unwrap.unavailable"));
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
mapHttpError(resp, err.detail);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
window.WrappedUI.showBusy(t("unwrap.workingDecrypt"));
|
||||
const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64);
|
||||
let pack;
|
||||
try {
|
||||
@@ -261,17 +465,22 @@
|
||||
} catch (err) {
|
||||
if (err.message === "password_required") {
|
||||
showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint"));
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
if (err.message === "bad_password") {
|
||||
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
showError(t("common.error"));
|
||||
showError(t("unwrap.decryptFailed"), t("unwrap.decryptFailedHint"));
|
||||
return;
|
||||
}
|
||||
lastWrapId = parsed.wrapId;
|
||||
renderPackage(pack);
|
||||
els.form.classList.add("hidden");
|
||||
els.head?.classList.add("hidden");
|
||||
els.state?.classList.add("hidden");
|
||||
els.result.classList.remove("hidden");
|
||||
history.replaceState(null, "", location.pathname);
|
||||
} catch {
|
||||
@@ -282,6 +491,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
els.password?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
els.btn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
els.downloadAll?.addEventListener("click", () => {
|
||||
downloadAllZip().catch(() => {});
|
||||
});
|
||||
|
||||
if (window.WrappedI18n.onChange) {
|
||||
window.WrappedI18n.onChange(() => {
|
||||
syncDownloadAll();
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch("/api/v1/settings");
|
||||
settings = await resp.json();
|
||||
@@ -295,4 +521,3 @@
|
||||
|
||||
init().catch(() => showError(t("common.error")));
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user