Выпущен 0.1.4: N открытий, «доступно с», шаблоны, /verify и A2HS.
This commit is contained in:
+85
-5
@@ -252,7 +252,41 @@ html[data-theme="dark"] .icon-btn:hover {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
|
||||
.verify-sections {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.verify-block h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.verify-block p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
#install-app {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
#install-app .ui-tooltip {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#install-app .ui-tooltip::after {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
#install-app:hover .ui-tooltip,
|
||||
#install-app:focus-visible .ui-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.composer, .success-panel, .result-panel {
|
||||
display: grid;
|
||||
@@ -800,21 +834,67 @@ body.busy-open { overflow: hidden; }
|
||||
}
|
||||
|
||||
.file-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; }
|
||||
.file-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
.file-list li,
|
||||
.file-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(5.5rem, 9rem) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.file-label-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.file-list button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.template-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.template-chip {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.template-chip:hover,
|
||||
.template-chip:focus-visible {
|
||||
color: var(--accent-2);
|
||||
border-color: rgba(75, 134, 240, 0.4);
|
||||
outline: none;
|
||||
}
|
||||
.success-meta-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.success-meta-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(110, 168, 255, 0.28);
|
||||
background: rgba(110, 168, 255, 0.1);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; }
|
||||
|
||||
+152
-12
@@ -1,11 +1,14 @@
|
||||
(() => {
|
||||
const t = (k, vars) => window.WrappedI18n.t(k, vars);
|
||||
/** @type {{ file: File, label: string }[]} */
|
||||
const files = [];
|
||||
let settings = null;
|
||||
let captchaWidgetId = null;
|
||||
|
||||
const els = {
|
||||
text: document.getElementById("payload-text"),
|
||||
textLabel: document.getElementById("text-label"),
|
||||
templateChips: document.getElementById("template-chips"),
|
||||
lang: document.getElementById("text-language"),
|
||||
langChip: document.getElementById("lang-chip"),
|
||||
langChipText: document.getElementById("lang-chip-text"),
|
||||
@@ -14,6 +17,8 @@
|
||||
highlight: document.getElementById("code-highlight"),
|
||||
editor: document.getElementById("code-editor"),
|
||||
ttl: document.getElementById("ttl-seconds"),
|
||||
maxOpens: document.getElementById("max-opens"),
|
||||
availableFrom: document.getElementById("available-from"),
|
||||
password: document.getElementById("password"),
|
||||
generatePassword: document.getElementById("generate-password"),
|
||||
dropzone: document.getElementById("dropzone"),
|
||||
@@ -27,6 +32,7 @@
|
||||
shareToken: document.getElementById("share-token"),
|
||||
sharePassword: document.getElementById("share-password"),
|
||||
passwordBlock: document.getElementById("success-password-block"),
|
||||
successMetaBadges: document.getElementById("success-meta-badges"),
|
||||
shareQr: document.getElementById("share-qr"),
|
||||
expiresMeta: document.getElementById("expires-meta"),
|
||||
captchaSlot: document.getElementById("captcha-slot"),
|
||||
@@ -108,7 +114,7 @@
|
||||
|
||||
function estimatePayloadBytes() {
|
||||
const textBytes = new TextEncoder().encode(els.text?.value || "").length;
|
||||
const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||||
const fileBytes = files.reduce((sum, entry) => sum + (Number(entry.file.size) || 0), 0);
|
||||
return textBytes + fileBytes;
|
||||
}
|
||||
|
||||
@@ -136,9 +142,21 @@
|
||||
|
||||
function renderFiles() {
|
||||
els.fileList.innerHTML = "";
|
||||
files.forEach((f, idx) => {
|
||||
files.forEach((entry, idx) => {
|
||||
const f = entry.file;
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
|
||||
li.className = "file-list-item";
|
||||
const meta = document.createElement("span");
|
||||
meta.innerHTML = `${escapeHtml(f.name)} <small>(${escapeHtml(f.type || "file")} · ${window.WrappedUI.formatBytes(f.size)})</small>`;
|
||||
const labelInput = document.createElement("input");
|
||||
labelInput.type = "text";
|
||||
labelInput.className = "file-label-input";
|
||||
labelInput.maxLength = 120;
|
||||
labelInput.placeholder = t("create.itemLabelPlaceholder");
|
||||
labelInput.value = entry.label || "";
|
||||
labelInput.addEventListener("input", () => {
|
||||
entry.label = labelInput.value;
|
||||
});
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "×";
|
||||
@@ -146,33 +164,54 @@
|
||||
files.splice(idx, 1);
|
||||
renderFiles();
|
||||
});
|
||||
li.appendChild(meta);
|
||||
li.appendChild(labelInput);
|
||||
li.appendChild(btn);
|
||||
els.fileList.appendChild(li);
|
||||
});
|
||||
refreshSizeMeter();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function addFiles(list) {
|
||||
for (const f of list) {
|
||||
if (!mimeAllowed(f.type || "application/octet-stream")) {
|
||||
showError(t("create.mimeDenied") + `: ${f.type || f.name}`);
|
||||
continue;
|
||||
}
|
||||
files.push(f);
|
||||
files.push({ file: f, label: "" });
|
||||
}
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
let lastExpiresAt = null;
|
||||
let lastAvailableFrom = null;
|
||||
let lastMaxOpens = 1;
|
||||
|
||||
function secondsUntilEvening() {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(20, 0, 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1);
|
||||
return Math.max(60, Math.round((target.getTime() - now.getTime()) / 1000));
|
||||
}
|
||||
|
||||
function fillTtl() {
|
||||
if (!settings || !els.ttl) return;
|
||||
const max = settings.max_ttl_seconds;
|
||||
const def = settings.default_ttl_seconds;
|
||||
const selected = els.ttl.value ? Number(els.ttl.value) : def;
|
||||
const evening = secondsUntilEvening();
|
||||
const options = [
|
||||
{ key: "create.ttl.1h", value: 3600 },
|
||||
{ key: "create.ttl.6h", value: 6 * 3600 },
|
||||
{ key: "create.ttl.evening", value: evening },
|
||||
{ key: "create.ttl.24h", value: 24 * 3600 },
|
||||
{ key: "create.ttl.3d", value: 3 * 24 * 3600 },
|
||||
{ key: "create.ttl.7d", value: 7 * 24 * 3600 },
|
||||
@@ -192,6 +231,78 @@
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fillMaxOpens() {
|
||||
if (!els.maxOpens || !settings) return;
|
||||
const limit = Math.max(1, Math.min(10, Number(settings.max_opens_limit) || 3));
|
||||
const uiMax = Math.min(3, limit);
|
||||
const selected = els.maxOpens.value ? Number(els.maxOpens.value) : 1;
|
||||
const pick = selected >= 1 && selected <= uiMax ? selected : 1;
|
||||
els.maxOpens.innerHTML = Array.from({ length: uiMax }, (_, i) => i + 1)
|
||||
.map(
|
||||
(n) =>
|
||||
`<option value="${n}" ${n === pick ? "selected" : ""}>${t("create.maxOpensOption", { n })}</option>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTemplates() {
|
||||
if (!els.templateChips) return;
|
||||
els.templateChips.innerHTML = "";
|
||||
const keys = [
|
||||
{ id: "access", key: "create.tpl.access" },
|
||||
{ id: "code", key: "create.tpl.code" },
|
||||
{ id: "fileNote", key: "create.tpl.fileNote" },
|
||||
];
|
||||
for (const item of keys) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "template-chip";
|
||||
btn.textContent = t(`create.tpl.${item.id}.name`);
|
||||
btn.addEventListener("click", () => {
|
||||
els.text.value = t(`create.tpl.${item.id}.body`);
|
||||
refreshHighlight();
|
||||
refreshSizeMeter();
|
||||
});
|
||||
els.templateChips.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function datetimeLocalToIso(value) {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function formatLocalDateTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function refreshSuccessBadges() {
|
||||
if (!els.successMetaBadges) return;
|
||||
els.successMetaBadges.innerHTML = "";
|
||||
if (lastMaxOpens > 1) {
|
||||
const b = document.createElement("span");
|
||||
b.className = "success-meta-badge";
|
||||
b.textContent = t("create.opensBadge", { n: lastMaxOpens });
|
||||
els.successMetaBadges.appendChild(b);
|
||||
}
|
||||
if (lastAvailableFrom) {
|
||||
const b = document.createElement("span");
|
||||
b.className = "success-meta-badge";
|
||||
b.textContent = t("create.availableFromBadge", {
|
||||
datetime: formatLocalDateTime(lastAvailableFrom),
|
||||
});
|
||||
els.successMetaBadges.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshExpiresMeta() {
|
||||
if (lastExpiresAt && els.expiresMeta) {
|
||||
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
|
||||
@@ -239,6 +350,8 @@
|
||||
const resp = await fetch("/api/v1/settings");
|
||||
settings = await resp.json();
|
||||
fillTtl();
|
||||
fillMaxOpens();
|
||||
renderTemplates();
|
||||
loadCaptcha();
|
||||
setLanguage("plaintext", { silent: true });
|
||||
refreshHighlight();
|
||||
@@ -325,11 +438,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess({ link, token, password, expiresAt }) {
|
||||
function showSuccess({ link, token, password, expiresAt, availableFrom, maxOpens }) {
|
||||
els.shareLink.value = link;
|
||||
els.shareToken.value = token;
|
||||
lastExpiresAt = expiresAt;
|
||||
lastAvailableFrom = availableFrom || null;
|
||||
lastMaxOpens = maxOpens || 1;
|
||||
refreshExpiresMeta();
|
||||
refreshSuccessBadges();
|
||||
if (password) {
|
||||
els.sharePassword.value = password;
|
||||
els.passwordBlock.classList.remove("hidden");
|
||||
@@ -409,15 +525,18 @@
|
||||
const items = [];
|
||||
const contentTypes = [];
|
||||
if (text.trim()) {
|
||||
items.push({
|
||||
const textItem = {
|
||||
type: "text",
|
||||
language: els.lang.value,
|
||||
content: text,
|
||||
});
|
||||
};
|
||||
const label = (els.textLabel?.value || "").trim();
|
||||
if (label) textItem.label = label.slice(0, 120);
|
||||
items.push(textItem);
|
||||
contentTypes.push("text/plain");
|
||||
}
|
||||
for (const f of files) {
|
||||
const item = await window.WrappedCrypto.fileToItem(f);
|
||||
for (const entry of files) {
|
||||
const item = await window.WrappedCrypto.fileToItem(entry.file, entry.label);
|
||||
items.push(item);
|
||||
contentTypes.push(item.mime);
|
||||
}
|
||||
@@ -430,6 +549,8 @@
|
||||
}
|
||||
|
||||
const password = els.password.value || "";
|
||||
const maxOpens = Number(els.maxOpens?.value || 1);
|
||||
const availableFromIso = datetimeLocalToIso(els.availableFrom?.value || "");
|
||||
els.wrapBtn.disabled = true;
|
||||
window.WrappedUI.showBusy(t("create.working"));
|
||||
try {
|
||||
@@ -450,9 +571,10 @@
|
||||
content_types: contentTypes,
|
||||
item_count: items.length,
|
||||
has_password: Boolean(password),
|
||||
// Always send password when set so server can gate wrong guesses.
|
||||
password: password || null,
|
||||
captcha_token: captchaToken() || null,
|
||||
max_opens: maxOpens,
|
||||
available_from: availableFromIso,
|
||||
};
|
||||
const resp = await fetch("/api/v1/wraps", {
|
||||
method: "POST",
|
||||
@@ -461,7 +583,20 @@
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showError(err.detail || t("common.error"));
|
||||
const detail = err.detail;
|
||||
if (detail === "available_from_past") {
|
||||
showError(t("create.availableFromPast"));
|
||||
return;
|
||||
}
|
||||
if (detail === "available_from_after_expiry") {
|
||||
showError(t("create.availableFromAfterExpiry"));
|
||||
return;
|
||||
}
|
||||
if (detail === "max_opens_invalid") {
|
||||
showError(t("create.maxOpensInvalid"));
|
||||
return;
|
||||
}
|
||||
showError(typeof detail === "string" ? detail : t("common.error"));
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
@@ -472,6 +607,8 @@
|
||||
token,
|
||||
password,
|
||||
expiresAt: data.expires_at,
|
||||
availableFrom: data.available_from,
|
||||
maxOpens: data.max_opens || maxOpens,
|
||||
});
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
@@ -500,7 +637,10 @@
|
||||
if (window.WrappedI18n.onChange) {
|
||||
window.WrappedI18n.onChange(() => {
|
||||
fillTtl();
|
||||
fillMaxOpens();
|
||||
renderTemplates();
|
||||
refreshExpiresMeta();
|
||||
refreshSuccessBadges();
|
||||
refreshSizeMeter();
|
||||
syncShareControls();
|
||||
setLanguage(els.lang.value, { silent: true });
|
||||
|
||||
@@ -176,14 +176,17 @@
|
||||
return b64decode(b64);
|
||||
}
|
||||
|
||||
async function fileToItem(file) {
|
||||
async function fileToItem(file, label) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
return {
|
||||
const item = {
|
||||
type: "file",
|
||||
name: file.name || "file",
|
||||
mime: file.type || "application/octet-stream",
|
||||
data_b64: b64encode(buf),
|
||||
};
|
||||
const trimmed = (label || "").trim();
|
||||
if (trimmed) item.label = trimmed.slice(0, 120);
|
||||
return item;
|
||||
}
|
||||
|
||||
window.WrappedCrypto = {
|
||||
|
||||
@@ -66,6 +66,51 @@
|
||||
"create.ttl.24h": "24 hours",
|
||||
"create.ttl.3d": "3 days",
|
||||
"create.ttl.7d": "7 days",
|
||||
"create.ttl.evening": "Until evening (20:00)",
|
||||
"create.maxOpens": "Opens",
|
||||
"create.maxOpensOption": "{n}",
|
||||
"create.maxOpensInvalid": "Invalid number of opens.",
|
||||
"create.availableFrom": "Available from (optional)",
|
||||
"create.availableFromHint": "Empty = available immediately",
|
||||
"create.availableFromPast": "Available-from must not be in the past.",
|
||||
"create.availableFromAfterExpiry": "Available-from must be before expiry.",
|
||||
"create.opensBadge": "{n} opens",
|
||||
"create.availableFromBadge": "Available from {datetime}",
|
||||
"create.itemLabel": "Label (optional)",
|
||||
"create.itemLabelPlaceholder": "e.g. Instructions",
|
||||
"create.tpl.access.name": "Access / password",
|
||||
"create.tpl.access.body": "Access details\n\nURL:\nUsername:\nPassword:\n\nNotes:\n",
|
||||
"create.tpl.code.name": "Code + notes",
|
||||
"create.tpl.code.body": "One-time code / snippet\n\n```\n\n```\n\nNotes:\n",
|
||||
"create.tpl.fileNote.name": "File + note",
|
||||
"create.tpl.fileNote.body": "Attached file(s) — see below.\n\nWhat this is for:\nHow to use:\n",
|
||||
"unwrap.notYetTitle": "Not available yet",
|
||||
"unwrap.notYetHint": "This wrap opens at {datetime}.",
|
||||
"unwrap.notYetHintGeneric": "This wrap is not available yet.",
|
||||
"unwrap.stillOnServerTitle": "Still on the server",
|
||||
"unwrap.stillOnServerHint": "{n} open(s) remaining — ciphertext stays until the last unwrap.",
|
||||
"unwrap.opensRemaining": "{n} open(s) remaining after this session.",
|
||||
"verify.eyebrow": "Verify",
|
||||
"verify.title": "How to verify Wrapped",
|
||||
"verify.lede": "What leaves your browser, what stays on the server, and how the key in #fragment works.",
|
||||
"verify.s1.title": "Encryption in the browser",
|
||||
"verify.s1.body": "Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).",
|
||||
"verify.s2.title": "Key in the URL fragment",
|
||||
"verify.s2.body": "The share link looks like /w/<id>#<key>. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.",
|
||||
"verify.s3.title": "Opens and destruction",
|
||||
"verify.s3.body": "By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 2–3 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.",
|
||||
"verify.s4.title": "Optional password",
|
||||
"verify.s4.body": "When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.",
|
||||
"verify.s5.title": "Available from",
|
||||
"verify.s5.body": "If \"available from\" is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.",
|
||||
"verify.createCta": "Create a wrap",
|
||||
"about.verifyLink": "How to verify →",
|
||||
"footer.verify": "How to verify",
|
||||
"install.aria": "Add to Home Screen",
|
||||
"install.tip": "Install Wrapped",
|
||||
"install.iosTip": "Share → Add to Home Screen",
|
||||
"admin.limits.maxOpens": "Max opens per wrap",
|
||||
"admin.limits.maxOpensHint": "Ceiling for create UI (1–10). Default: 3.",
|
||||
"create.ttl.default": "Default ({seconds} s)",
|
||||
"lang.plaintext": "Plain text",
|
||||
"lang.markdown": "Markdown",
|
||||
@@ -326,6 +371,51 @@
|
||||
"create.ttl.24h": "24 часа",
|
||||
"create.ttl.3d": "3 дня",
|
||||
"create.ttl.7d": "7 дней",
|
||||
"create.ttl.evening": "До вечера (20:00)",
|
||||
"create.maxOpens": "Открытий",
|
||||
"create.maxOpensOption": "{n}",
|
||||
"create.maxOpensInvalid": "Недопустимое число открытий.",
|
||||
"create.availableFrom": "Доступно с (необязательно)",
|
||||
"create.availableFromHint": "Пусто = доступно сразу",
|
||||
"create.availableFromPast": "«Доступно с» не может быть в прошлом.",
|
||||
"create.availableFromAfterExpiry": "«Доступно с» должно быть раньше срока истечения.",
|
||||
"create.opensBadge": "{n} открытий",
|
||||
"create.availableFromBadge": "Доступно с {datetime}",
|
||||
"create.itemLabel": "Подпись (необязательно)",
|
||||
"create.itemLabelPlaceholder": "напр. Инструкция",
|
||||
"create.tpl.access.name": "Доступ / пароль",
|
||||
"create.tpl.access.body": "Данные доступа\n\nURL:\nЛогин:\nПароль:\n\nЗаметки:\n",
|
||||
"create.tpl.code.name": "Код + заметка",
|
||||
"create.tpl.code.body": "Одноразовый код / фрагмент\n\n```\n\n```\n\nЗаметки:\n",
|
||||
"create.tpl.fileNote.name": "Файл + заметка",
|
||||
"create.tpl.fileNote.body": "Вложения — см. ниже.\n\nДля чего:\nКак пользоваться:\n",
|
||||
"unwrap.notYetTitle": "Ещё недоступно",
|
||||
"unwrap.notYetHint": "Этот wrap откроется {datetime}.",
|
||||
"unwrap.notYetHintGeneric": "Этот wrap ещё недоступен.",
|
||||
"unwrap.stillOnServerTitle": "Ещё на сервере",
|
||||
"unwrap.stillOnServerHint": "Осталось открытий: {n} — ciphertext хранится до последнего unwrap.",
|
||||
"unwrap.opensRemaining": "После этой сессии останется открытий: {n}.",
|
||||
"verify.eyebrow": "Проверка",
|
||||
"verify.title": "Как проверить Wrapped",
|
||||
"verify.lede": "Что уходит из браузера, что лежит на сервере и как работает ключ в #fragment.",
|
||||
"verify.s1.title": "Шифрование в браузере",
|
||||
"verify.s1.body": "Текст и файлы упаковываются и шифруются Web Crypto (AES-GCM) до загрузки. На сервер уходит только ciphertext и метаданные (TTL, MIME, размер, опциональный хеш пароля).",
|
||||
"verify.s2.title": "Ключ во фрагменте URL",
|
||||
"verify.s2.body": "Ссылка вида /w/<id>#<key>. Часть после # не уходит на сервер в запросе страницы. Без фрагмента (или полного токена) ciphertext не расшифровать.",
|
||||
"verify.s3.title": "Открытия и уничтожение",
|
||||
"verify.s3.body": "По умолчанию wrap открывается один раз — затем ciphertext удаляется. Если отправитель выбрал 2–3 открытия, сервер хранит ciphertext до последнего успешного unwrap. Срок и блокировка пароля по-прежнему уничтожают пакет.",
|
||||
"verify.s4.title": "Опциональный пароль",
|
||||
"verify.s4.body": "При пароле сервер проверяет Argon2-хеш до выдачи ciphertext. Число попыток ограничено; пустой пароль попытку не тратит.",
|
||||
"verify.s5.title": "Доступно с",
|
||||
"verify.s5.body": "Если задано «доступно с», unwrap отклоняется до этого момента. Дальше действуют обычные правила открытий и срока.",
|
||||
"verify.createCta": "Создать wrap",
|
||||
"about.verifyLink": "Как проверить →",
|
||||
"footer.verify": "Как проверить",
|
||||
"install.aria": "На экран «Домой»",
|
||||
"install.tip": "Установить Wrapped",
|
||||
"install.iosTip": "Поделиться → На экран «Домой»",
|
||||
"admin.limits.maxOpens": "Макс. открытий на wrap",
|
||||
"admin.limits.maxOpensHint": "Потолок для UI создания (1–10). По умолчанию: 3.",
|
||||
"create.ttl.default": "По умолчанию ({seconds} с)",
|
||||
"lang.plaintext": "Обычный текст",
|
||||
"lang.markdown": "Markdown",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
(() => {
|
||||
const t = (k, vars) => window.WrappedI18n?.t(k, vars) || k;
|
||||
const btn = document.getElementById("install-app");
|
||||
if (!btn) return;
|
||||
|
||||
let deferredPrompt = null;
|
||||
const isIos =
|
||||
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const isStandalone =
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
window.navigator.standalone === true;
|
||||
|
||||
function syncLabels() {
|
||||
btn.setAttribute("aria-label", t("install.aria"));
|
||||
const tip = btn.querySelector(".ui-tooltip");
|
||||
if (tip) tip.textContent = t(isIos ? "install.iosTip" : "install.tip");
|
||||
}
|
||||
|
||||
function showBtn() {
|
||||
if (isStandalone) {
|
||||
btn.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
btn.classList.remove("hidden");
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
showBtn();
|
||||
syncLabels();
|
||||
});
|
||||
|
||||
if (isIos && !isStandalone) {
|
||||
showBtn();
|
||||
}
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
try {
|
||||
await deferredPrompt.userChoice;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
deferredPrompt = null;
|
||||
return;
|
||||
}
|
||||
if (isIos) {
|
||||
window.WrappedUI?.showBusy?.(t("install.iosTip"));
|
||||
setTimeout(() => window.WrappedUI?.hideBusy?.(), 2200);
|
||||
}
|
||||
});
|
||||
|
||||
if (window.WrappedI18n?.onChange) {
|
||||
window.WrappedI18n.onChange(syncLabels);
|
||||
}
|
||||
syncLabels();
|
||||
})();
|
||||
+69
-17
@@ -25,6 +25,10 @@
|
||||
lightboxCaption: document.getElementById("lightbox-caption"),
|
||||
lightboxClose: document.getElementById("lightbox-close"),
|
||||
downloadAll: document.getElementById("download-all"),
|
||||
resultCalloutTitle: document.getElementById("result-callout-title"),
|
||||
resultCalloutHint: document.getElementById("result-callout-hint"),
|
||||
resultCalloutFa: document.getElementById("result-callout-fa"),
|
||||
resultOpensHint: document.getElementById("result-opens-hint"),
|
||||
};
|
||||
|
||||
let lastPack = null;
|
||||
@@ -273,28 +277,51 @@
|
||||
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
|
||||
}
|
||||
|
||||
function renderPackage(pack) {
|
||||
function renderPackage(pack, meta = {}) {
|
||||
revokeAllUrls();
|
||||
lastPack = pack;
|
||||
els.items.innerHTML = "";
|
||||
const destroyed = meta.destroyed !== false && !(Number(meta.opens_remaining) > 0);
|
||||
const opensRemaining = Number(meta.opens_remaining) || 0;
|
||||
if (els.resultCalloutTitle && els.resultCalloutHint) {
|
||||
if (destroyed) {
|
||||
els.resultCalloutTitle.textContent = t("unwrap.destroyedTitle");
|
||||
els.resultCalloutHint.textContent = t("unwrap.destroyedHint");
|
||||
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-fire";
|
||||
} else {
|
||||
els.resultCalloutTitle.textContent = t("unwrap.stillOnServerTitle");
|
||||
els.resultCalloutHint.textContent = t("unwrap.stillOnServerHint", { n: opensRemaining });
|
||||
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-clock";
|
||||
}
|
||||
}
|
||||
if (els.resultOpensHint) {
|
||||
if (!destroyed && opensRemaining > 0) {
|
||||
els.resultOpensHint.textContent = t("unwrap.opensRemaining", { n: opensRemaining });
|
||||
els.resultOpensHint.classList.remove("hidden");
|
||||
} else {
|
||||
els.resultOpensHint.textContent = "";
|
||||
els.resultOpensHint.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
for (const item of pack.items || []) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "item-card";
|
||||
const label = (item.label || "").trim();
|
||||
if (item.type === "text") {
|
||||
const lang = item.language || "plaintext";
|
||||
const text = item.content || "";
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
const meta = document.createElement("div");
|
||||
const metaEl = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = "text";
|
||||
meta.appendChild(strong);
|
||||
meta.appendChild(document.createTextNode(" · "));
|
||||
strong.textContent = label || "text";
|
||||
metaEl.appendChild(strong);
|
||||
metaEl.appendChild(document.createTextNode(" · "));
|
||||
const langEl = document.createElement("span");
|
||||
langEl.className = "mono";
|
||||
langEl.textContent = lang;
|
||||
meta.appendChild(langEl);
|
||||
head.appendChild(meta);
|
||||
metaEl.appendChild(langEl);
|
||||
head.appendChild(metaEl);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "item-card-actions";
|
||||
actions.appendChild(makeCopyBtn(() => text));
|
||||
@@ -318,27 +345,31 @@
|
||||
const name = item.name || "file";
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
const meta = document.createElement("div");
|
||||
const metaEl = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = name;
|
||||
meta.appendChild(strong);
|
||||
meta.appendChild(document.createTextNode(" · "));
|
||||
strong.textContent = label || name;
|
||||
metaEl.appendChild(strong);
|
||||
if (label) {
|
||||
metaEl.appendChild(document.createTextNode(" · "));
|
||||
metaEl.appendChild(document.createTextNode(name));
|
||||
}
|
||||
metaEl.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);
|
||||
metaEl.appendChild(mimeEl);
|
||||
metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
|
||||
head.appendChild(metaEl);
|
||||
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
|
||||
card.appendChild(head);
|
||||
if (mime.startsWith("image/")) {
|
||||
const url = trackUrl(URL.createObjectURL(blob));
|
||||
const img = document.createElement("img");
|
||||
img.alt = name;
|
||||
img.alt = label || name;
|
||||
img.src = url;
|
||||
img.className = "item-preview-img";
|
||||
img.loading = "lazy";
|
||||
img.addEventListener("click", () => openLightbox(url, name));
|
||||
img.addEventListener("click", () => openLightbox(url, label || name));
|
||||
card.appendChild(img);
|
||||
const tip = document.createElement("p");
|
||||
tip.className = "hint item-preview-hint";
|
||||
@@ -424,6 +455,22 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detail.code === "not_yet_available") {
|
||||
const when = detail.available_from
|
||||
? new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(detail.available_from))
|
||||
: "";
|
||||
showState({
|
||||
title: t("unwrap.notYetTitle"),
|
||||
hint: when
|
||||
? t("unwrap.notYetHint", { datetime: when })
|
||||
: t("unwrap.notYetHintGeneric"),
|
||||
icon: "fa-clock",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detail.code === "password_required") {
|
||||
showError(
|
||||
t("unwrap.passwordRequired"),
|
||||
@@ -477,7 +524,12 @@
|
||||
return;
|
||||
}
|
||||
lastWrapId = parsed.wrapId;
|
||||
renderPackage(pack);
|
||||
renderPackage(pack, {
|
||||
destroyed: data.destroyed !== false && !(Number(data.opens_remaining) > 0),
|
||||
opens_remaining: data.opens_remaining,
|
||||
max_opens: data.max_opens,
|
||||
opens_used: data.opens_used,
|
||||
});
|
||||
els.form.classList.add("hidden");
|
||||
els.head?.classList.add("hidden");
|
||||
els.state?.classList.add("hidden");
|
||||
|
||||
Reference in New Issue
Block a user