Выпущен 0.1.3: UX convenience pack, unwrap/admin polish и cache-bust статики.

Добавлены Share/QR PNG, zip all, size meter, PWA-manifest, системная тема и haptic на copy; улучшены success/unwrap и статистика админки (EN/RU).
This commit is contained in:
Sergey Antropoff
2026-07-29 11:33:21 +03:00
parent a5b29ee81e
commit 37349afab9
26 changed files with 1317 additions and 136 deletions
+248 -23
View File
@@ -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")));
})();