2807 lines
98 KiB
HTML
2807 lines
98 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Secret Message Studio</title>
|
||
<meta name="theme-color" content="#e46f8b">
|
||
<link rel="manifest" href="manifest.json">
|
||
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Fraunces:opsz,wght@9..144,700;9..144,800&display=swap" rel="stylesheet">
|
||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
|
||
:root {
|
||
color-scheme: light;
|
||
--ink: #3f3440;
|
||
--muted: #7c707a;
|
||
--soft: #fffaf1;
|
||
--paper: #fffdf8;
|
||
--panel: rgba(255, 253, 248, 0.94);
|
||
--line: rgba(63, 52, 64, 0.16);
|
||
--accent: #e46f8b;
|
||
--accent-2: #6db7a8;
|
||
--accent-3: #e7b85f;
|
||
--shadow: 0 14px 34px rgba(82, 58, 49, 0.12);
|
||
--radius: 14px;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: Nunito, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
color: var(--ink);
|
||
background:
|
||
linear-gradient(90deg, rgba(222, 180, 122, 0.07) 1px, transparent 1px),
|
||
linear-gradient(0deg, rgba(222, 180, 122, 0.06) 1px, transparent 1px),
|
||
linear-gradient(135deg, #fff7e8 0%, #f9efe3 44%, #eef7ef 100%);
|
||
background-size: 28px 28px, 28px 28px, auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
body::before {
|
||
content: "♡ ✦ secret notes ✿ tiny codes ♡";
|
||
position: fixed;
|
||
left: 18px;
|
||
right: 18px;
|
||
top: 10px;
|
||
z-index: -1;
|
||
color: rgba(63, 52, 64, 0.11);
|
||
font: 800 15px Nunito, sans-serif;
|
||
letter-spacing: 0.18em;
|
||
text-align: center;
|
||
transform: rotate(-1.2deg);
|
||
pointer-events: none;
|
||
}
|
||
|
||
button, input, textarea, select {
|
||
font: inherit;
|
||
}
|
||
|
||
button {
|
||
border: 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.app {
|
||
width: min(1160px, 100%);
|
||
margin: 0 auto;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.02fr) minmax(360px, 0.98fr);
|
||
gap: 20px;
|
||
align-items: start;
|
||
position: relative;
|
||
}
|
||
|
||
.topbar {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: end;
|
||
gap: 16px;
|
||
padding: 10px 0 2px;
|
||
}
|
||
|
||
.brand h1 {
|
||
margin: 0;
|
||
font-family: Fraunces, Georgia, serif;
|
||
font-size: clamp(34px, 5vw, 62px);
|
||
line-height: 0.98;
|
||
letter-spacing: 0;
|
||
color: #4b3746;
|
||
text-shadow: 2px 2px 0 #ffe1a6;
|
||
}
|
||
|
||
.brand p {
|
||
margin: 12px 0 0;
|
||
color: var(--muted);
|
||
max-width: 640px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.lang-toggle, .segmented, .toolbar {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
background: #fff9ef;
|
||
border: 1.5px solid rgba(63, 52, 64, 0.18);
|
||
border-radius: 999px;
|
||
padding: 4px;
|
||
box-shadow: 0 5px 0 rgba(63, 52, 64, 0.08);
|
||
}
|
||
|
||
.lang-toggle button, .segmented button, .icon-btn {
|
||
min-height: 38px;
|
||
padding: 0 13px;
|
||
color: var(--muted);
|
||
background: transparent;
|
||
border-radius: 999px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.lang-toggle button.active, .segmented button.active, .icon-btn.active {
|
||
color: #4b3746;
|
||
background: #ffdbe5;
|
||
}
|
||
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1.5px solid rgba(63, 52, 64, 0.16);
|
||
border-radius: 22px;
|
||
box-shadow: var(--shadow);
|
||
position: relative;
|
||
}
|
||
|
||
.panel::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 9px;
|
||
border: 1px dashed rgba(63, 52, 64, 0.13);
|
||
border-radius: 16px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.composer {
|
||
padding: 22px;
|
||
}
|
||
|
||
.tabs {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tabs button {
|
||
min-height: 46px;
|
||
border-radius: 999px;
|
||
background: #f4eadc;
|
||
color: var(--muted);
|
||
font-weight: 800;
|
||
box-shadow: inset 0 -2px 0 rgba(63, 52, 64, 0.05);
|
||
}
|
||
|
||
.tabs button.active {
|
||
background: #ffd2df;
|
||
color: #4b3746;
|
||
box-shadow: 0 5px 0 rgba(228, 111, 139, 0.2);
|
||
}
|
||
|
||
.view { display: none; }
|
||
.view.active { display: block; }
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 7px;
|
||
margin-bottom: 13px;
|
||
}
|
||
|
||
.field.span { grid-column: 1 / -1; }
|
||
|
||
label, .field-label {
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: #8d7f87;
|
||
font-weight: 800;
|
||
}
|
||
|
||
textarea, input, select {
|
||
width: 100%;
|
||
border: 1.5px solid var(--line);
|
||
background: #fffdf8;
|
||
color: var(--ink);
|
||
border-radius: 16px;
|
||
padding: 12px;
|
||
outline: none;
|
||
box-shadow: inset 0 2px 0 rgba(63, 52, 64, 0.03);
|
||
}
|
||
|
||
textarea {
|
||
min-height: 130px;
|
||
resize: vertical;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
textarea:focus, input:focus, select:focus {
|
||
border-color: rgba(216, 63, 106, 0.55);
|
||
box-shadow: 0 0 0 4px rgba(228, 111, 139, 0.15), inset 0 2px 0 rgba(63, 52, 64, 0.03);
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 9px;
|
||
margin: 14px 0;
|
||
}
|
||
|
||
.btn {
|
||
min-height: 42px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
border-radius: var(--radius);
|
||
padding: 0 15px;
|
||
font-weight: 800;
|
||
color: #4b3746;
|
||
background: #f4eadc;
|
||
border: 1.5px solid rgba(63, 52, 64, 0.14);
|
||
box-shadow: 0 4px 0 rgba(63, 52, 64, 0.1);
|
||
transform: rotate(-0.2deg);
|
||
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-1px) rotate(-0.2deg);
|
||
box-shadow: 0 6px 0 rgba(63, 52, 64, 0.1);
|
||
}
|
||
|
||
.btn:active {
|
||
transform: translateY(2px);
|
||
box-shadow: 0 2px 0 rgba(63, 52, 64, 0.1);
|
||
}
|
||
|
||
.btn.primary { background: #ffd2df; }
|
||
.btn.teal { background: #c9eee4; }
|
||
.btn.gold { background: #ffe2a8; }
|
||
.btn.ghost {
|
||
color: var(--ink);
|
||
background: #fff7ec;
|
||
}
|
||
|
||
.btn.danger {
|
||
color: #4b2028;
|
||
background: #ffc3cb;
|
||
}
|
||
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 12px;
|
||
background: #fff8ee;
|
||
border: 1.5px solid var(--line);
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.switch-row span {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
width: 48px;
|
||
height: 28px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.switch input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
inset: 0;
|
||
}
|
||
|
||
.switch i {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 999px;
|
||
background: rgba(47, 38, 50, 0.2);
|
||
transition: 0.2s;
|
||
}
|
||
|
||
.switch i::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 22px;
|
||
height: 22px;
|
||
top: 3px;
|
||
left: 3px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||
transition: 0.2s;
|
||
}
|
||
|
||
.switch input:checked + i { background: #9bd7c9; }
|
||
.switch input:checked + i::after { transform: translateX(20px); }
|
||
|
||
.result-box {
|
||
min-height: 90px;
|
||
max-height: 220px;
|
||
overflow: auto;
|
||
padding: 13px;
|
||
border-radius: 18px;
|
||
border: 1.5px dashed rgba(63, 52, 64, 0.25);
|
||
background: #fffaf2;
|
||
color: #443946;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.result-box.empty {
|
||
color: #9a8f9e;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.mini-note {
|
||
margin-top: -5px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.meter {
|
||
height: 8px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: rgba(47, 38, 50, 0.12);
|
||
}
|
||
|
||
.meter i {
|
||
display: block;
|
||
width: 0%;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: #a73442;
|
||
transition: 0.2s ease;
|
||
}
|
||
|
||
.hint-box {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 11px 12px;
|
||
margin: 0 0 12px;
|
||
border-radius: 18px;
|
||
border: 1.5px solid rgba(109, 183, 168, 0.32);
|
||
background: #effbf6;
|
||
color: #255b58;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.hint-box:empty,
|
||
.playlist:empty,
|
||
.inbox-list:empty,
|
||
.compat-grid:empty {
|
||
display: none;
|
||
}
|
||
|
||
details.tool-section {
|
||
margin: 13px 0;
|
||
border: 1.5px solid var(--line);
|
||
border-radius: 18px;
|
||
background: #fff9ef;
|
||
overflow: hidden;
|
||
}
|
||
|
||
details.tool-section > summary {
|
||
min-height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
font-weight: 750;
|
||
list-style: none;
|
||
}
|
||
|
||
details.tool-section > summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.section-body {
|
||
padding: 0 12px 12px;
|
||
}
|
||
|
||
.range-row {
|
||
display: grid;
|
||
grid-template-columns: 150px 1fr 44px;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin: 9px 0;
|
||
}
|
||
|
||
input[type="range"] {
|
||
padding: 0;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
.sticker-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 7px;
|
||
}
|
||
|
||
.sticker-grid button {
|
||
min-height: 38px;
|
||
border-radius: 14px;
|
||
background: #f4eadc;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.sticker-grid button.active {
|
||
background: #ffd2df;
|
||
color: #4b3746;
|
||
}
|
||
|
||
.compat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin: 10px 0 2px;
|
||
}
|
||
|
||
.badge {
|
||
padding: 9px 10px;
|
||
border-radius: 14px;
|
||
font-size: 12px;
|
||
font-weight: 750;
|
||
background: #fff7ec;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.badge.good { background: #dff6ee; color: #245e59; }
|
||
.badge.warn { background: #fff0c9; color: #7d581a; }
|
||
.badge.bad { background: #ffe0e5; color: #7a2530; }
|
||
|
||
.inbox-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.inbox-item {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 10px;
|
||
border: 1.5px solid var(--line);
|
||
border-radius: 16px;
|
||
background: #fffaf2;
|
||
}
|
||
|
||
.inbox-item strong {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.inbox-item span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.playlist {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.scanner {
|
||
display: none;
|
||
gap: 10px;
|
||
margin: 10px 0 14px;
|
||
}
|
||
|
||
.scanner.active {
|
||
display: grid;
|
||
}
|
||
|
||
.scanner video {
|
||
width: 100%;
|
||
max-height: 280px;
|
||
border-radius: var(--radius);
|
||
background: #111;
|
||
}
|
||
|
||
audio {
|
||
width: 100%;
|
||
}
|
||
|
||
.template-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
.template-option {
|
||
height: 42px;
|
||
border-radius: 999px;
|
||
border: 2px solid rgba(63, 52, 64, 0.12);
|
||
background: var(--template-bg);
|
||
}
|
||
|
||
.template-option.active {
|
||
border-color: #4b3746;
|
||
box-shadow: 0 4px 0 rgba(63, 52, 64, 0.1);
|
||
}
|
||
|
||
.preview-panel {
|
||
padding: 18px;
|
||
position: sticky;
|
||
top: 20px;
|
||
}
|
||
|
||
.preview-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preview-head h2 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.canvas-shell {
|
||
position: relative;
|
||
overflow: hidden;
|
||
border-radius: 24px;
|
||
background: #f1e2cd;
|
||
border: 10px solid #fffaf2;
|
||
box-shadow: 0 8px 0 rgba(63, 52, 64, 0.1), inset 0 0 0 1px rgba(63, 52, 64, 0.1);
|
||
}
|
||
|
||
#previewCanvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.qr-wrap {
|
||
display: grid;
|
||
grid-template-columns: 120px 1fr;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
border: 1.5px solid var(--line);
|
||
border-radius: 18px;
|
||
background: #fff9ef;
|
||
}
|
||
|
||
#qrCanvas {
|
||
width: 112px;
|
||
height: 112px;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.meta {
|
||
display: grid;
|
||
gap: 5px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: 22px;
|
||
transform: translate(-50%, 80px);
|
||
opacity: 0;
|
||
z-index: 20;
|
||
max-width: min(520px, calc(100vw - 24px));
|
||
padding: 13px 16px;
|
||
border-radius: var(--radius);
|
||
color: #fff;
|
||
background: var(--ink);
|
||
box-shadow: var(--shadow);
|
||
transition: 0.28s ease;
|
||
font-weight: 750;
|
||
}
|
||
|
||
.toast.show {
|
||
transform: translate(-50%, 0);
|
||
opacity: 1;
|
||
}
|
||
|
||
.secret-reveal {
|
||
min-height: 110px;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.secret-reveal.fade span {
|
||
animation: fadeIn 0.24s ease forwards;
|
||
}
|
||
|
||
.secret-reveal.type span {
|
||
animation: riseIn 0.2s ease forwards;
|
||
}
|
||
|
||
.secret-reveal.glitch {
|
||
animation: glitchBox 0.45s steps(2, end) 3;
|
||
}
|
||
|
||
.secret-reveal.puzzle span {
|
||
display: inline-block;
|
||
animation: puzzleIn 0.32s ease forwards;
|
||
}
|
||
|
||
.self-destruct {
|
||
margin-top: 9px;
|
||
padding: 10px 12px;
|
||
border-radius: var(--radius);
|
||
color: #7d311d;
|
||
background: rgba(242, 185, 75, 0.18);
|
||
border: 1px solid rgba(242, 185, 75, 0.35);
|
||
font-weight: 750;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes riseIn {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@keyframes puzzleIn {
|
||
from { opacity: 0; transform: rotate(9deg) scale(0.8); }
|
||
to { opacity: 1; transform: rotate(0) scale(1); }
|
||
}
|
||
|
||
@keyframes glitchBox {
|
||
0% { transform: translate(0); filter: hue-rotate(0); }
|
||
30% { transform: translate(4px, -2px); filter: hue-rotate(50deg); }
|
||
60% { transform: translate(-3px, 2px); filter: hue-rotate(-30deg); }
|
||
100% { transform: translate(0); filter: hue-rotate(0); }
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
body { padding: 14px; }
|
||
.app { grid-template-columns: 1fr; }
|
||
.topbar { align-items: start; }
|
||
.preview-panel { position: static; }
|
||
}
|
||
|
||
@media (max-width: 580px) {
|
||
.topbar, .preview-head, .switch-row { align-items: stretch; flex-direction: column; }
|
||
.grid { grid-template-columns: 1fr; }
|
||
.template-grid { grid-template-columns: repeat(3, 1fr); }
|
||
.qr-wrap { grid-template-columns: 1fr; }
|
||
.lang-toggle, .segmented { width: 100%; }
|
||
.lang-toggle button, .segmented button { flex: 1; }
|
||
.actions .btn { flex: 1 1 150px; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.001ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.001ms !important;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="app">
|
||
<header class="topbar">
|
||
<div class="brand">
|
||
<h1>Secret Message Studio</h1>
|
||
<p data-i18n="subtitle">Baue schöne Story-Codes mit optionaler echter Passwort-Verschlüsselung, QR-Link und Reveal-Effekt.</p>
|
||
</div>
|
||
<div class="lang-toggle" aria-label="Sprache">
|
||
<button type="button" class="active" data-lang="de">DE</button>
|
||
<button type="button" data-lang="en">EN</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="panel composer" aria-label="Nachricht">
|
||
<div class="tabs" role="tablist">
|
||
<button type="button" class="active" data-tab="encode" role="tab" data-i18n="tabEncode">Kodieren</button>
|
||
<button type="button" data-tab="decode" role="tab" data-i18n="tabDecode">Öffnen</button>
|
||
</div>
|
||
|
||
<div class="view active" id="encodeView">
|
||
<div class="field">
|
||
<label for="messageInput" data-i18n="messageLabel">Nachricht</label>
|
||
<textarea id="messageInput" data-i18n-placeholder="messagePlaceholder" placeholder="Schreibe hier deine geheime Nachricht..."></textarea>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="toInput" data-i18n="toLabel">Für</label>
|
||
<input id="toInput" autocomplete="off" data-i18n-placeholder="toPlaceholder" placeholder="z.B. Lea">
|
||
</div>
|
||
<div class="field">
|
||
<label for="fromInput" data-i18n="fromLabel">Von</label>
|
||
<input id="fromInput" autocomplete="off" data-i18n-placeholder="fromPlaceholder" placeholder="z.B. M.">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="switch-row">
|
||
<div>
|
||
<strong data-i18n="passwordMode">Echte Verschlüsselung</strong>
|
||
<span data-i18n="passwordModeHint">Mit Passwort wird AES-GCM genutzt. Ohne Passwort bleibt es Base64.</span>
|
||
</div>
|
||
<label class="switch" aria-label="Passwort-Verschlüsselung">
|
||
<input id="secureToggle" type="checkbox">
|
||
<i></i>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="passwordInput" data-i18n="passwordLabel">Passwort</label>
|
||
<input id="passwordInput" type="password" autocomplete="new-password" data-i18n-placeholder="passwordPlaceholder" placeholder="Nur nötig bei Verschlüsselung">
|
||
<div class="meter" aria-hidden="true"><i id="strengthMeter"></i></div>
|
||
<div class="mini-note" id="strengthText" data-i18n="strengthEmpty">Passwort-Stärke erscheint hier.</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="expirySelect" data-i18n="expiryLabel">Ablauf</label>
|
||
<select id="expirySelect">
|
||
<option value="0" data-i18n="expiryNever">Kein Ablauf</option>
|
||
<option value="10" data-i18n="expiry10">10 Sekunden nach Öffnen</option>
|
||
<option value="30" data-i18n="expiry30">30 Sekunden nach Öffnen</option>
|
||
<option value="60" data-i18n="expiry60">60 Sekunden nach Öffnen</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions" style="margin-top:0;">
|
||
<button type="button" class="btn ghost" id="generatePasswordBtn" data-i18n="generatePasswordBtn">Passwort erzeugen</button>
|
||
<button type="button" class="btn ghost" id="copyPasswordBtn" data-i18n="copyPasswordBtn">Passwort kopieren</button>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="passwordHintInput" data-i18n="hintLabel">Passwort-Hinweis</label>
|
||
<input id="passwordHintInput" autocomplete="off" data-i18n-placeholder="hintPlaceholder" placeholder="z.B. unser Lieblingssong">
|
||
</div>
|
||
<div class="field">
|
||
<label for="unlockAtInput" data-i18n="unlockAtLabel">Öffnen ab</label>
|
||
<input id="unlockAtInput" type="datetime-local">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="extraSlidesInput" data-i18n="slidesLabel">Weitere Slides</label>
|
||
<textarea id="extraSlidesInput" data-i18n-placeholder="slidesPlaceholder" placeholder="Optional: eine weitere Nachricht pro Zeile"></textarea>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="decoyInput" data-i18n="decoyLabel">Decoy-Nachricht</label>
|
||
<input id="decoyInput" autocomplete="off" data-i18n-placeholder="decoyPlaceholder" placeholder="Erscheint bei falschem Passwort">
|
||
</div>
|
||
<div class="field">
|
||
<label for="decoyPasswordInput" data-i18n="decoyPasswordLabel">Decoy-Passwort</label>
|
||
<input id="decoyPasswordInput" type="password" autocomplete="new-password" data-i18n-placeholder="decoyPasswordPlaceholder" placeholder="Optional anderes Passwort">
|
||
</div>
|
||
</div>
|
||
|
||
<details class="tool-section">
|
||
<summary data-i18n="advancedPasswordsTitle">Passwortstufen</summary>
|
||
<div class="section-body">
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="tierPasswordInput" data-i18n="tierPasswordLabel">Zusatzpasswort</label>
|
||
<input id="tierPasswordInput" type="password" autocomplete="new-password">
|
||
</div>
|
||
<div class="field">
|
||
<label for="tierMessageInput" data-i18n="tierMessageLabel">Zusatz-Slide</label>
|
||
<input id="tierMessageInput" autocomplete="off">
|
||
</div>
|
||
</div>
|
||
<div class="mini-note" data-i18n="tierHint">Wenn dieses Passwort genutzt wird, erscheint eine zusätzliche Slide.</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="captionMode" data-i18n="captionModeLabel">Caption-Modus</label>
|
||
<select id="captionMode">
|
||
<option value="romantic" data-i18n="captionRomantic">Romantisch</option>
|
||
<option value="mystery" data-i18n="captionMystery">Mystery</option>
|
||
<option value="birthday" data-i18n="captionBirthday">Birthday</option>
|
||
<option value="apology" data-i18n="captionApology">Apology</option>
|
||
<option value="dare" data-i18n="captionDare">Dare</option>
|
||
<option value="crush" data-i18n="captionCrush">Crush</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label data-i18n="voiceHintLabel">Voice-Hinweis</label>
|
||
<div class="actions" style="margin:0;">
|
||
<button type="button" class="btn ghost" id="recordHintBtn" data-i18n="recordHintBtn">Aufnehmen</button>
|
||
<button type="button" class="btn ghost" id="clearVoiceBtn" data-i18n="clearVoiceBtn">Voice löschen</button>
|
||
</div>
|
||
<div class="mini-note" id="voiceStatus" data-i18n="voiceEmpty">Kein Voice-Hinweis.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<div class="field-label" data-i18n="templateLabel">Story-Vorlage</div>
|
||
<div class="template-grid" id="templateGrid"></div>
|
||
</div>
|
||
|
||
<details class="tool-section">
|
||
<summary data-i18n="designProTitle">Design Editor Pro</summary>
|
||
<div class="section-body">
|
||
<div class="range-row">
|
||
<label for="qrXRange" data-i18n="qrXLabel">QR X</label>
|
||
<input id="qrXRange" type="range" min="16" max="84" value="50">
|
||
<span id="qrXValue">50</span>
|
||
</div>
|
||
<div class="range-row">
|
||
<label for="qrYRange" data-i18n="qrYLabel">QR Y</label>
|
||
<input id="qrYRange" type="range" min="48" max="88" value="72">
|
||
<span id="qrYValue">72</span>
|
||
</div>
|
||
<div class="range-row">
|
||
<label for="codeYRange" data-i18n="codeYLabel">Code Y</label>
|
||
<input id="codeYRange" type="range" min="28" max="58" value="38">
|
||
<span id="codeYValue">38</span>
|
||
</div>
|
||
<div class="range-row">
|
||
<label for="fontScaleRange" data-i18n="fontScaleLabel">Schrift</label>
|
||
<input id="fontScaleRange" type="range" min="80" max="125" value="100">
|
||
<span id="fontScaleValue">100</span>
|
||
</div>
|
||
<div class="range-row">
|
||
<label for="overlayRange" data-i18n="overlayLabel">Overlay</label>
|
||
<input id="overlayRange" type="range" min="0" max="70" value="32">
|
||
<span id="overlayValue">32</span>
|
||
</div>
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="accentColorInput" data-i18n="accentColorLabel">Akzent</label>
|
||
<input id="accentColorInput" type="color" value="#e46f8b">
|
||
</div>
|
||
<div class="field">
|
||
<label for="textColorInput" data-i18n="textColorLabel">Textfarbe</label>
|
||
<input id="textColorInput" type="color" value="#4b3746">
|
||
</div>
|
||
</div>
|
||
<div class="actions" style="margin-bottom:0;">
|
||
<button type="button" class="btn ghost" id="undoDesignBtn" data-i18n="undoBtn">Undo</button>
|
||
<button type="button" class="btn ghost" id="redoDesignBtn" data-i18n="redoBtn">Redo</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="tool-section">
|
||
<summary data-i18n="stickersTitle">Sticker Packs</summary>
|
||
<div class="section-body">
|
||
<div class="sticker-grid" id="stickerGrid"></div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="bgInput" data-i18n="bgLabel">Eigenes Bild</label>
|
||
<input id="bgInput" type="file" accept="image/*">
|
||
</div>
|
||
<div class="field">
|
||
<label for="outputMode" data-i18n="outputModeLabel">Code-Ansicht</label>
|
||
<select id="outputMode">
|
||
<option value="base64" data-i18n="modeBase64">Base64/Text</option>
|
||
<option value="emoji" data-i18n="modeEmoji">Emoji-Code</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="switch-row">
|
||
<div>
|
||
<strong data-i18n="oneTimeLabel">One-Time-Modus</strong>
|
||
<span data-i18n="oneTimeHint">Nach dem Öffnen wird der Code auf diesem Gerät als verbraucht markiert.</span>
|
||
</div>
|
||
<label class="switch" aria-label="One-Time-Modus">
|
||
<input id="oneTimeToggle" type="checkbox">
|
||
<i></i>
|
||
</label>
|
||
</div>
|
||
<div class="switch-row">
|
||
<div>
|
||
<strong data-i18n="invisibleLabel">Unsichtbare Story</strong>
|
||
<span data-i18n="invisibleHint">Im Bild erscheint nur Design und QR, nicht der Textcode.</span>
|
||
</div>
|
||
<label class="switch" aria-label="Unsichtbare Story">
|
||
<input id="invisibleToggle" type="checkbox">
|
||
<i></i>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button type="button" class="btn primary" id="createBtn" data-i18n="createBtn">Erstellen</button>
|
||
<button type="button" class="btn teal" id="testSecretBtn" data-i18n="testSecretBtn">Secret testen</button>
|
||
<button type="button" class="btn ghost" id="clearEncodeBtn" data-i18n="clearBtn">Leeren</button>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="encodedResult" data-i18n="codeLabel">Code</label>
|
||
<div class="result-box empty" id="encodedResult" aria-live="polite" tabindex="0" data-i18n="codeEmpty">Hier erscheint dein Code.</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button type="button" class="btn" id="copyCodeBtn" data-i18n="copyCodeBtn">Code kopieren</button>
|
||
<button type="button" class="btn teal" id="copyLinkBtn" data-i18n="copyLinkBtn">Link kopieren</button>
|
||
<button type="button" class="btn gold" id="copyShareBtn" data-i18n="copyShareBtn">Share-Text kopieren</button>
|
||
<button type="button" class="btn ghost" id="downloadBtn" data-i18n="downloadBtn">Bild speichern</button>
|
||
<button type="button" class="btn ghost" id="downloadStegoBtn" data-i18n="downloadStegoBtn">Stego-Bild speichern</button>
|
||
<button type="button" class="btn ghost" id="downloadSecretBtn" data-i18n="downloadSecretBtn">.secret speichern</button>
|
||
<button type="button" class="btn ghost" id="downloadCountdownBtn" data-i18n="downloadCountdownBtn">Countdown exportieren</button>
|
||
</div>
|
||
|
||
<div class="compat-grid" id="compatGrid"></div>
|
||
</div>
|
||
|
||
<div class="view" id="decodeView">
|
||
<div class="scanner" id="scannerBox">
|
||
<video id="scannerVideo" playsinline muted></video>
|
||
<button type="button" class="btn ghost" id="stopScannerBtn" data-i18n="stopScannerBtn">Scanner stoppen</button>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="decodeInput" data-i18n="decodeLabel">Code oder Emoji-Code</label>
|
||
<textarea id="decodeInput" data-i18n-placeholder="decodePlaceholder" placeholder="Füge den Code hier ein..."></textarea>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="stegoInput" data-i18n="stegoInputLabel">Stego-Bild öffnen</label>
|
||
<input id="stegoInput" type="file" accept="image/png,image/*">
|
||
</div>
|
||
<div class="field">
|
||
<label for="secretFileInput" data-i18n="secretFileLabel">.secret öffnen</label>
|
||
<input id="secretFileInput" type="file" accept=".secret,application/json,text/plain">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label data-i18n="scannerLabel">QR-Scanner</label>
|
||
<button type="button" class="btn ghost" id="startScannerBtn" data-i18n="startScannerBtn">Kamera scannen</button>
|
||
</div>
|
||
<div class="field">
|
||
<label data-i18n="inboxTitle">Secret Inbox</label>
|
||
<button type="button" class="btn ghost" id="refreshInboxBtn" data-i18n="refreshInboxBtn">Inbox aktualisieren</button>
|
||
</div>
|
||
</div>
|
||
<div class="inbox-list" id="inboxList"></div>
|
||
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="decodePassword" data-i18n="decodePasswordLabel">Passwort</label>
|
||
<input id="decodePassword" type="password" autocomplete="current-password" data-i18n-placeholder="decodePasswordPlaceholder" placeholder="Nur bei verschlüsselten Nachrichten">
|
||
</div>
|
||
<div class="field">
|
||
<label for="revealMode" data-i18n="revealLabel">Reveal</label>
|
||
<select id="revealMode">
|
||
<option value="type" data-i18n="revealType">Typewriter</option>
|
||
<option value="fade" data-i18n="revealFade">Fade</option>
|
||
<option value="glitch" data-i18n="revealGlitch">Glitch</option>
|
||
<option value="puzzle" data-i18n="revealPuzzle">Puzzle</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hint-box" id="decodeHintBox"></div>
|
||
|
||
<div class="actions">
|
||
<button type="button" class="btn primary" id="openBtn" data-i18n="openBtn">Nachricht öffnen</button>
|
||
<button type="button" class="btn ghost" id="clearDecodeBtn" data-i18n="clearBtn">Leeren</button>
|
||
<button type="button" class="btn teal" id="replyBtn" data-i18n="replyBtn" hidden>Antwort erstellen</button>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="decodedResult" data-i18n="secretLabel">Geheime Nachricht</label>
|
||
<div class="playlist" id="playlistControls"></div>
|
||
<div class="result-box empty secret-reveal" id="decodedResult" aria-live="polite" tabindex="0" data-i18n="secretEmpty">Hier erscheint die Nachricht.</div>
|
||
<div id="destructBox" class="self-destruct" hidden></div>
|
||
<audio id="voicePlayer" controls hidden></audio>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="panel preview-panel" aria-label="Vorschau">
|
||
<div class="preview-head">
|
||
<h2 data-i18n="previewTitle">Story-Vorschau</h2>
|
||
<div class="segmented" aria-label="Bildformat">
|
||
<button type="button" class="active" data-format="story">Story</button>
|
||
<button type="button" data-format="square">Square</button>
|
||
</div>
|
||
</div>
|
||
<div class="canvas-shell">
|
||
<canvas id="previewCanvas" width="1080" height="1920"></canvas>
|
||
</div>
|
||
<div class="qr-wrap">
|
||
<canvas id="qrCanvas" width="112" height="112" aria-label="QR-Code"></canvas>
|
||
<div class="meta">
|
||
<strong data-i18n="qrTitle">QR-Code & Link</strong>
|
||
<span data-i18n="qrHint">Nach dem Erstellen führt der QR-Code direkt zur Öffnen-Ansicht mit vorausgefülltem Code.</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</main>
|
||
|
||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||
|
||
<script>
|
||
const state = {
|
||
lang: 'de',
|
||
tab: 'encode',
|
||
template: 0,
|
||
format: 'story',
|
||
payload: '',
|
||
displayCode: '',
|
||
shareUrl: '',
|
||
secure: false,
|
||
invisible: false,
|
||
bgImage: null,
|
||
voiceHintData: '',
|
||
mediaRecorder: null,
|
||
recordingChunks: [],
|
||
decodedMeta: null,
|
||
decodedMessages: [],
|
||
currentSlide: 0,
|
||
scannerStream: null,
|
||
scannerFrame: null,
|
||
selectedStickerPack: 'none',
|
||
design: {
|
||
qrX: 50,
|
||
qrY: 72,
|
||
codeY: 38,
|
||
fontScale: 100,
|
||
overlay: 24,
|
||
accent: '#e46f8b',
|
||
text: '#4b3746',
|
||
},
|
||
undoStack: [],
|
||
redoStack: [],
|
||
revealTimers: [],
|
||
destructTimer: null,
|
||
destructInterval: null,
|
||
};
|
||
window.state = state;
|
||
|
||
const i18n = {
|
||
de: {
|
||
subtitle: 'Baue schöne Story-Codes mit optionaler echter Passwort-Verschlüsselung, QR-Link und Reveal-Effekt.',
|
||
tabEncode: 'Kodieren',
|
||
tabDecode: 'Öffnen',
|
||
messageLabel: 'Nachricht',
|
||
messagePlaceholder: 'Schreibe hier deine geheime Nachricht...',
|
||
toLabel: 'Für',
|
||
toPlaceholder: 'z.B. Lea',
|
||
fromLabel: 'Von',
|
||
fromPlaceholder: 'z.B. M.',
|
||
passwordMode: 'Echte Verschlüsselung',
|
||
passwordModeHint: 'Mit Passwort wird AES-GCM genutzt. Ohne Passwort bleibt es Base64.',
|
||
passwordLabel: 'Passwort',
|
||
passwordPlaceholder: 'Nur nötig bei Verschlüsselung',
|
||
generatePasswordBtn: 'Passwort erzeugen',
|
||
copyPasswordBtn: 'Passwort kopieren',
|
||
strengthEmpty: 'Passwort-Stärke erscheint hier.',
|
||
strengthWeak: 'Schwach',
|
||
strengthMedium: 'Mittel',
|
||
strengthStrong: 'Stark',
|
||
strengthGreat: 'Sehr stark',
|
||
expiryLabel: 'Ablauf',
|
||
expiryNever: 'Kein Ablauf',
|
||
expiry10: '10 Sekunden nach Öffnen',
|
||
expiry30: '30 Sekunden nach Öffnen',
|
||
expiry60: '60 Sekunden nach Öffnen',
|
||
hintLabel: 'Passwort-Hinweis',
|
||
hintPlaceholder: 'z.B. unser Lieblingssong',
|
||
unlockAtLabel: 'Öffnen ab',
|
||
slidesLabel: 'Weitere Slides',
|
||
slidesPlaceholder: 'Optional: eine weitere Nachricht pro Zeile',
|
||
decoyLabel: 'Decoy-Nachricht',
|
||
decoyPlaceholder: 'Erscheint bei falschem Passwort',
|
||
decoyPasswordLabel: 'Decoy-Passwort',
|
||
decoyPasswordPlaceholder: 'Optional anderes Passwort',
|
||
advancedPasswordsTitle: 'Passwortstufen',
|
||
tierPasswordLabel: 'Zusatzpasswort',
|
||
tierMessageLabel: 'Zusatz-Slide',
|
||
tierHint: 'Wenn dieses Passwort genutzt wird, erscheint eine zusätzliche Slide.',
|
||
captionModeLabel: 'Caption-Modus',
|
||
captionRomantic: 'Romantisch',
|
||
captionMystery: 'Mystery',
|
||
captionBirthday: 'Birthday',
|
||
captionApology: 'Apology',
|
||
captionDare: 'Dare',
|
||
captionCrush: 'Crush',
|
||
voiceHintLabel: 'Voice-Hinweis',
|
||
recordHintBtn: 'Aufnehmen',
|
||
stopRecordBtn: 'Stoppen',
|
||
clearVoiceBtn: 'Voice löschen',
|
||
voiceEmpty: 'Kein Voice-Hinweis.',
|
||
voiceReady: 'Voice-Hinweis gespeichert.',
|
||
designProTitle: 'Design Editor Pro',
|
||
qrXLabel: 'QR X',
|
||
qrYLabel: 'QR Y',
|
||
codeYLabel: 'Code Y',
|
||
fontScaleLabel: 'Schrift',
|
||
overlayLabel: 'Overlay',
|
||
accentColorLabel: 'Akzent',
|
||
textColorLabel: 'Textfarbe',
|
||
undoBtn: 'Undo',
|
||
redoBtn: 'Redo',
|
||
stickersTitle: 'Sticker Packs',
|
||
oneTimeLabel: 'One-Time-Modus',
|
||
oneTimeHint: 'Nach dem Öffnen wird der Code auf diesem Gerät als verbraucht markiert.',
|
||
invisibleLabel: 'Unsichtbare Story',
|
||
invisibleHint: 'Im Bild erscheint nur Design und QR, nicht der Textcode.',
|
||
templateLabel: 'Story-Vorlage',
|
||
bgLabel: 'Eigenes Bild',
|
||
outputModeLabel: 'Code-Ansicht',
|
||
modeBase64: 'Base64/Text',
|
||
modeEmoji: 'Emoji-Code',
|
||
createBtn: 'Erstellen',
|
||
testSecretBtn: 'Secret testen',
|
||
clearBtn: 'Leeren',
|
||
codeLabel: 'Code',
|
||
codeEmpty: 'Hier erscheint dein Code.',
|
||
copyCodeBtn: 'Code kopieren',
|
||
copyLinkBtn: 'Link kopieren',
|
||
copyShareBtn: 'Share-Text kopieren',
|
||
downloadBtn: 'Bild speichern',
|
||
downloadStegoBtn: 'Stego-Bild speichern',
|
||
downloadSecretBtn: '.secret speichern',
|
||
downloadCountdownBtn: 'Countdown exportieren',
|
||
stopScannerBtn: 'Scanner stoppen',
|
||
stegoInputLabel: 'Stego-Bild öffnen',
|
||
secretFileLabel: '.secret öffnen',
|
||
inboxTitle: 'Secret Inbox',
|
||
refreshInboxBtn: 'Inbox aktualisieren',
|
||
scannerLabel: 'QR-Scanner',
|
||
startScannerBtn: 'Kamera scannen',
|
||
decodeLabel: 'Code oder Emoji-Code',
|
||
decodePlaceholder: 'Füge den Code hier ein...',
|
||
decodePasswordLabel: 'Passwort',
|
||
decodePasswordPlaceholder: 'Nur bei verschlüsselten Nachrichten',
|
||
revealLabel: 'Reveal',
|
||
revealType: 'Typewriter',
|
||
revealFade: 'Fade',
|
||
revealGlitch: 'Glitch',
|
||
revealPuzzle: 'Puzzle',
|
||
openBtn: 'Nachricht öffnen',
|
||
replyBtn: 'Antwort erstellen',
|
||
secretLabel: 'Geheime Nachricht',
|
||
secretEmpty: 'Hier erscheint die Nachricht.',
|
||
previewTitle: 'Story-Vorschau',
|
||
qrTitle: 'QR-Code & Link',
|
||
qrHint: 'Nach dem Erstellen führt der QR-Code direkt zur Öffnen-Ansicht mit vorausgefülltem Code.',
|
||
qrTooLarge: 'Link statt QR nutzen',
|
||
hintPrefix: 'Hinweis',
|
||
voiceAvailable: 'Voice-Hinweis verfügbar.',
|
||
unlockNotice: 'Diese Nachricht kann erst ab {date} geöffnet werden.',
|
||
usedNotice: 'Dieser One-Time-Code wurde auf diesem Gerät schon geöffnet.',
|
||
slideCounter: 'Slide {current} von {total}',
|
||
prevSlide: 'Zurück',
|
||
nextSlide: 'Weiter',
|
||
toastRecording: 'Aufnahme läuft.',
|
||
toastMicDenied: 'Mikrofon konnte nicht geöffnet werden.',
|
||
toastScannerMissing: 'QR-Scanner ist nicht verfügbar.',
|
||
toastScannerDenied: 'Kamera konnte nicht geöffnet werden.',
|
||
toastScanned: 'QR-Code gescannt.',
|
||
toastStegoSaved: 'Stego-Bild gespeichert.',
|
||
toastSecretSaved: '.secret-Datei gespeichert.',
|
||
toastCountdownSaved: 'Countdown-Bilder gespeichert.',
|
||
toastGeneratedPassword: 'Passwort erzeugt.',
|
||
toastTestOk: 'Test erfolgreich.',
|
||
toastInboxSaved: 'In Inbox gespeichert.',
|
||
toastNoInbox: 'Noch keine Secrets in der Inbox.',
|
||
toastImported: 'Secret importiert.',
|
||
toastStegoMissing: 'In diesem Bild wurde kein Secret-Code gefunden.',
|
||
toastTooLarge: 'Die Nachricht ist zu groß für dieses Bild.',
|
||
toastUsed: 'Dieser One-Time-Code ist verbraucht.',
|
||
toastLocked: 'Diese Nachricht ist noch gesperrt.',
|
||
toastReplyReady: 'Antwort vorbereitet.',
|
||
toastWrite: 'Schreibe zuerst eine Nachricht.',
|
||
toastPassword: 'Aktiviere Verschlüsselung nur mit Passwort.',
|
||
toastCreated: 'Code erstellt.',
|
||
toastCopied: 'Kopiert.',
|
||
toastNoCode: 'Erstelle zuerst einen Code.',
|
||
toastInvalid: 'Der Code konnte nicht geöffnet werden.',
|
||
toastPasswordNeeded: 'Dieses Geheimnis braucht das richtige Passwort.',
|
||
toastOpened: 'Nachricht geöffnet.',
|
||
toastSaved: 'Bild gespeichert.',
|
||
expired: 'Die Nachricht wurde ausgeblendet.',
|
||
destruct: 'Diese Nachricht verschwindet in {s} Sekunden.',
|
||
canvasTitle: 'Geheime Nachricht',
|
||
canvasSubtitle: 'Scanne den QR-Code oder kopiere den Code.',
|
||
encrypted: 'Passwortgeschützt',
|
||
encoded: 'Base64-Code',
|
||
safetyBase64: 'Nur Base64, nicht geheim',
|
||
safetyEncrypted: 'AES-verschlüsselt',
|
||
compatQrGood: 'QR geeignet',
|
||
compatQrWarn: 'QR wird lang',
|
||
compatLinkGood: 'Link teilbar',
|
||
compatLinkWarn: 'Link sehr lang',
|
||
compatEmojiGood: 'Emoji-Code ok',
|
||
compatEmojiWarn: 'Emoji-Code lang',
|
||
compatStegoGood: 'Stego passt',
|
||
compatStegoWarn: 'Stego knapp',
|
||
invisibleCanvas: 'Scanne den QR-Code, um das Geheimnis zu öffnen.',
|
||
shareText: 'Ich habe eine geheime Nachricht für dich: {url}',
|
||
captions: {
|
||
romantic: 'Für dich, ganz leise verpackt: {url}',
|
||
mystery: 'Da steckt etwas drin. Knackst du es? {url}',
|
||
birthday: 'Ein kleines Geburtstagsgeheimnis wartet hier: {url}',
|
||
apology: 'Ich habe dir etwas aufzuschreiben. Öffne es hier: {url}',
|
||
dare: 'Challenge: Öffne diesen Secret-Code. {url}',
|
||
crush: 'Nicht laut sagen. Nur öffnen. {url}',
|
||
},
|
||
},
|
||
en: {
|
||
subtitle: 'Create story-ready secret codes with optional password encryption, QR links, and reveal effects.',
|
||
tabEncode: 'Encode',
|
||
tabDecode: 'Open',
|
||
messageLabel: 'Message',
|
||
messagePlaceholder: 'Write your secret message here...',
|
||
toLabel: 'For',
|
||
toPlaceholder: 'e.g. Lea',
|
||
fromLabel: 'From',
|
||
fromPlaceholder: 'e.g. M.',
|
||
passwordMode: 'Real encryption',
|
||
passwordModeHint: 'With a password, AES-GCM is used. Without one, it stays Base64.',
|
||
passwordLabel: 'Password',
|
||
passwordPlaceholder: 'Only needed for encryption',
|
||
generatePasswordBtn: 'Generate password',
|
||
copyPasswordBtn: 'Copy password',
|
||
strengthEmpty: 'Password strength appears here.',
|
||
strengthWeak: 'Weak',
|
||
strengthMedium: 'Medium',
|
||
strengthStrong: 'Strong',
|
||
strengthGreat: 'Very strong',
|
||
expiryLabel: 'Expiry',
|
||
expiryNever: 'No expiry',
|
||
expiry10: '10 seconds after opening',
|
||
expiry30: '30 seconds after opening',
|
||
expiry60: '60 seconds after opening',
|
||
hintLabel: 'Password hint',
|
||
hintPlaceholder: 'e.g. our favorite song',
|
||
unlockAtLabel: 'Open from',
|
||
slidesLabel: 'More slides',
|
||
slidesPlaceholder: 'Optional: one extra message per line',
|
||
decoyLabel: 'Decoy message',
|
||
decoyPlaceholder: 'Shown for the wrong password',
|
||
decoyPasswordLabel: 'Decoy password',
|
||
decoyPasswordPlaceholder: 'Optional alternate password',
|
||
advancedPasswordsTitle: 'Password tiers',
|
||
tierPasswordLabel: 'Extra password',
|
||
tierMessageLabel: 'Extra slide',
|
||
tierHint: 'When this password is used, an extra slide appears.',
|
||
captionModeLabel: 'Caption mode',
|
||
captionRomantic: 'Romantic',
|
||
captionMystery: 'Mystery',
|
||
captionBirthday: 'Birthday',
|
||
captionApology: 'Apology',
|
||
captionDare: 'Dare',
|
||
captionCrush: 'Crush',
|
||
voiceHintLabel: 'Voice hint',
|
||
recordHintBtn: 'Record',
|
||
stopRecordBtn: 'Stop',
|
||
clearVoiceBtn: 'Clear voice',
|
||
voiceEmpty: 'No voice hint.',
|
||
voiceReady: 'Voice hint saved.',
|
||
designProTitle: 'Design Editor Pro',
|
||
qrXLabel: 'QR X',
|
||
qrYLabel: 'QR Y',
|
||
codeYLabel: 'Code Y',
|
||
fontScaleLabel: 'Font',
|
||
overlayLabel: 'Overlay',
|
||
accentColorLabel: 'Accent',
|
||
textColorLabel: 'Text color',
|
||
undoBtn: 'Undo',
|
||
redoBtn: 'Redo',
|
||
stickersTitle: 'Sticker packs',
|
||
oneTimeLabel: 'One-time mode',
|
||
oneTimeHint: 'After opening, this device marks the code as used.',
|
||
invisibleLabel: 'Invisible story',
|
||
invisibleHint: 'The image shows design and QR, but not the text code.',
|
||
templateLabel: 'Story template',
|
||
bgLabel: 'Custom image',
|
||
outputModeLabel: 'Code view',
|
||
modeBase64: 'Base64/Text',
|
||
modeEmoji: 'Emoji code',
|
||
createBtn: 'Create',
|
||
testSecretBtn: 'Test secret',
|
||
clearBtn: 'Clear',
|
||
codeLabel: 'Code',
|
||
codeEmpty: 'Your code appears here.',
|
||
copyCodeBtn: 'Copy code',
|
||
copyLinkBtn: 'Copy link',
|
||
copyShareBtn: 'Copy share text',
|
||
downloadBtn: 'Save image',
|
||
downloadStegoBtn: 'Save stego image',
|
||
downloadSecretBtn: 'Save .secret',
|
||
downloadCountdownBtn: 'Export countdown',
|
||
stopScannerBtn: 'Stop scanner',
|
||
stegoInputLabel: 'Open stego image',
|
||
secretFileLabel: 'Open .secret',
|
||
inboxTitle: 'Secret Inbox',
|
||
refreshInboxBtn: 'Refresh inbox',
|
||
scannerLabel: 'QR scanner',
|
||
startScannerBtn: 'Scan camera',
|
||
decodeLabel: 'Code or emoji code',
|
||
decodePlaceholder: 'Paste the code here...',
|
||
decodePasswordLabel: 'Password',
|
||
decodePasswordPlaceholder: 'Only for encrypted messages',
|
||
revealLabel: 'Reveal',
|
||
revealType: 'Typewriter',
|
||
revealFade: 'Fade',
|
||
revealGlitch: 'Glitch',
|
||
revealPuzzle: 'Puzzle',
|
||
openBtn: 'Open message',
|
||
replyBtn: 'Create reply',
|
||
secretLabel: 'Secret message',
|
||
secretEmpty: 'The message appears here.',
|
||
previewTitle: 'Story preview',
|
||
qrTitle: 'QR code & link',
|
||
qrHint: 'After creating, the QR code opens the decode view with the code already filled in.',
|
||
qrTooLarge: 'Use the link instead',
|
||
hintPrefix: 'Hint',
|
||
voiceAvailable: 'Voice hint available.',
|
||
unlockNotice: 'This message can only be opened from {date}.',
|
||
usedNotice: 'This one-time code has already been opened on this device.',
|
||
slideCounter: 'Slide {current} of {total}',
|
||
prevSlide: 'Previous',
|
||
nextSlide: 'Next',
|
||
toastRecording: 'Recording.',
|
||
toastMicDenied: 'Microphone could not be opened.',
|
||
toastScannerMissing: 'QR scanner is not available.',
|
||
toastScannerDenied: 'Camera could not be opened.',
|
||
toastScanned: 'QR code scanned.',
|
||
toastStegoSaved: 'Stego image saved.',
|
||
toastSecretSaved: '.secret file saved.',
|
||
toastCountdownSaved: 'Countdown images saved.',
|
||
toastGeneratedPassword: 'Password generated.',
|
||
toastTestOk: 'Test succeeded.',
|
||
toastInboxSaved: 'Saved to inbox.',
|
||
toastNoInbox: 'No secrets in the inbox yet.',
|
||
toastImported: 'Secret imported.',
|
||
toastStegoMissing: 'No secret code was found in this image.',
|
||
toastTooLarge: 'The message is too large for this image.',
|
||
toastUsed: 'This one-time code is used.',
|
||
toastLocked: 'This message is still locked.',
|
||
toastReplyReady: 'Reply prepared.',
|
||
toastWrite: 'Write a message first.',
|
||
toastPassword: 'Encryption needs a password.',
|
||
toastCreated: 'Code created.',
|
||
toastCopied: 'Copied.',
|
||
toastNoCode: 'Create a code first.',
|
||
toastInvalid: 'The code could not be opened.',
|
||
toastPasswordNeeded: 'This secret needs the right password.',
|
||
toastOpened: 'Message opened.',
|
||
toastSaved: 'Image saved.',
|
||
expired: 'The message was hidden.',
|
||
destruct: 'This message disappears in {s} seconds.',
|
||
canvasTitle: 'Secret Message',
|
||
canvasSubtitle: 'Scan the QR code or copy the code.',
|
||
encrypted: 'Password protected',
|
||
encoded: 'Base64 code',
|
||
safetyBase64: 'Base64 only, not secret',
|
||
safetyEncrypted: 'AES encrypted',
|
||
compatQrGood: 'QR friendly',
|
||
compatQrWarn: 'QR is long',
|
||
compatLinkGood: 'Link shareable',
|
||
compatLinkWarn: 'Link very long',
|
||
compatEmojiGood: 'Emoji code ok',
|
||
compatEmojiWarn: 'Emoji code long',
|
||
compatStegoGood: 'Stego fits',
|
||
compatStegoWarn: 'Stego tight',
|
||
invisibleCanvas: 'Scan the QR code to open the secret.',
|
||
shareText: 'I have a secret message for you: {url}',
|
||
captions: {
|
||
romantic: 'For you, wrapped quietly: {url}',
|
||
mystery: 'There is something inside. Can you crack it? {url}',
|
||
birthday: 'A tiny birthday secret is waiting here: {url}',
|
||
apology: 'I wrote something for you. Open it here: {url}',
|
||
dare: 'Challenge: open this secret code. {url}',
|
||
crush: 'Do not say it out loud. Just open it. {url}',
|
||
},
|
||
},
|
||
};
|
||
|
||
const templates = [
|
||
{ name: 'Blush Note', bg: ['#fff8eb', '#ffdce7'], accent: '#e46f8b', ink: '#4b3746', soft: 'rgba(255,253,248,0.86)', emoji: '💌' },
|
||
{ name: 'Kitchen Mint', bg: ['#f7f0df', '#ccefe4'], accent: '#64aa9a', ink: '#35534e', soft: 'rgba(255,253,248,0.84)', emoji: '🌿' },
|
||
{ name: 'Honey Letter', bg: ['#fff0c2', '#ffd0b1'], accent: '#d99549', ink: '#563d32', soft: 'rgba(255,253,248,0.8)', emoji: '🍯' },
|
||
{ name: 'Blueberry Stamp', bg: ['#eef4ff', '#dcd5f0'], accent: '#7f80bc', ink: '#3c3d66', soft: 'rgba(255,253,248,0.82)', emoji: '✦' },
|
||
{ name: 'Soft Graph', bg: ['#fffdf8', '#efe5d3'], accent: '#3f3440', ink: '#3f3440', soft: 'rgba(255,253,248,0.78)', emoji: '♡' },
|
||
];
|
||
|
||
const stickerPacks = {
|
||
none: [],
|
||
love: ['💌', '💖', '✨', '🫶', '🌙', '🎀'],
|
||
birthday: ['🎁', '🎂', '🥳', '✨', '🎈', '⭐'],
|
||
dark: ['🖤', '⚡', '🌙', '🔒', '💎', '🪐'],
|
||
cyber: ['🚀', '⚡', '🎧', '🎯', '🔑', '💎'],
|
||
minimal: ['✦', '•', '×', '+', '○', '□'],
|
||
};
|
||
|
||
const INBOX_KEY = 'secret-message-studio-inbox-v1';
|
||
|
||
const emojiAlphabet = ['😀','😁','😂','🤣','😄','😅','😉','😊','😍','😘','😎','🤩','🥳','😇','🙂','🙃','😌','😋','😜','🤗','🤫','🤭','😺','😻','💌','💖','💜','💙','💚','💛','🧡','💝','✨','⭐','🌙','🌞','🌈','🔥','🌸','🌿','🍒','🍓','🍑','🍋','🍀','💎','🎀','🎁','🎧','🎵','🎲','🎯','🚀','🪐','🔒','🔑','📩','📸','🖤','🤍','⚡','🌊','🍬','🫶'];
|
||
const textAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||
const emojiToText = new Map(emojiAlphabet.map((emoji, i) => [emoji, textAlphabet[i]]));
|
||
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||
const t = (key) => i18n[state.lang][key] || key;
|
||
|
||
function utf8ToBase64(text) {
|
||
const bytes = new TextEncoder().encode(text);
|
||
let binary = '';
|
||
bytes.forEach((byte) => binary += String.fromCharCode(byte));
|
||
return btoa(binary);
|
||
}
|
||
|
||
function base64ToUtf8(base64) {
|
||
const binary = atob(base64);
|
||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||
return new TextDecoder().decode(bytes);
|
||
}
|
||
|
||
function toBase64Url(base64) {
|
||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||
}
|
||
|
||
function fromBase64Url(value) {
|
||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4);
|
||
return padded;
|
||
}
|
||
|
||
function toEmojiCode(value) {
|
||
return [...value].map((char) => {
|
||
const index = textAlphabet.indexOf(char);
|
||
return index >= 0 ? emojiAlphabet[index] : char;
|
||
}).join('');
|
||
}
|
||
|
||
function fromEmojiCode(value) {
|
||
return [...value].map((char) => emojiToText.get(char) || char).join('');
|
||
}
|
||
|
||
function randomBytes(length) {
|
||
const bytes = new Uint8Array(length);
|
||
crypto.getRandomValues(bytes);
|
||
return bytes;
|
||
}
|
||
|
||
async function deriveKey(password, salt) {
|
||
const keyMaterial = await crypto.subtle.importKey(
|
||
'raw',
|
||
new TextEncoder().encode(password),
|
||
'PBKDF2',
|
||
false,
|
||
['deriveKey']
|
||
);
|
||
return crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt, iterations: 180000, hash: 'SHA-256' },
|
||
keyMaterial,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false,
|
||
['encrypt', 'decrypt']
|
||
);
|
||
}
|
||
|
||
function bytesToBase64Url(bytes) {
|
||
let binary = '';
|
||
bytes.forEach((byte) => binary += String.fromCharCode(byte));
|
||
return toBase64Url(btoa(binary));
|
||
}
|
||
|
||
function base64UrlToBytes(value) {
|
||
const binary = atob(fromBase64Url(value));
|
||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||
}
|
||
|
||
async function encryptText(text, password) {
|
||
const salt = randomBytes(16);
|
||
const iv = randomBytes(12);
|
||
const key = await deriveKey(password, salt);
|
||
const encrypted = await crypto.subtle.encrypt(
|
||
{ name: 'AES-GCM', iv },
|
||
key,
|
||
new TextEncoder().encode(text)
|
||
);
|
||
return {
|
||
alg: 'AES-GCM',
|
||
salt: bytesToBase64Url(salt),
|
||
iv: bytesToBase64Url(iv),
|
||
data: bytesToBase64Url(new Uint8Array(encrypted)),
|
||
};
|
||
}
|
||
|
||
async function decryptText(secret, password) {
|
||
const key = await deriveKey(password, base64UrlToBytes(secret.salt));
|
||
const decrypted = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: base64UrlToBytes(secret.iv) },
|
||
key,
|
||
base64UrlToBytes(secret.data)
|
||
);
|
||
return new TextDecoder().decode(decrypted);
|
||
}
|
||
|
||
async function sha256Text(text) {
|
||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||
return bytesToBase64Url(new Uint8Array(hash)).slice(0, 24);
|
||
}
|
||
|
||
function oneTimeKey(id) {
|
||
return `secret-message-used-${id}`;
|
||
}
|
||
|
||
function getSlides() {
|
||
const first = $('#messageInput').value.trim();
|
||
const extra = $('#extraSlidesInput').value
|
||
.split(/\n+/)
|
||
.map((slide) => slide.trim())
|
||
.filter(Boolean);
|
||
return [first, ...extra].filter(Boolean);
|
||
}
|
||
|
||
function getTierSecret() {
|
||
const password = $('#tierPasswordInput').value;
|
||
const message = $('#tierMessageInput').value.trim();
|
||
return password && message ? { password, message } : null;
|
||
}
|
||
|
||
function parsePayload(rawValue) {
|
||
let clean = rawValue.trim();
|
||
if (!clean) throw new Error('empty');
|
||
clean = extractMsgFromScannedValue(clean);
|
||
const normalized = clean.startsWith('sm2.') ? clean : fromEmojiCode(clean);
|
||
if (!normalized.startsWith('sm2.')) {
|
||
return {
|
||
payload: normalized,
|
||
envelope: {
|
||
v: 1,
|
||
kind: 'base64',
|
||
messages: [base64ToUtf8(fromBase64Url(normalized))],
|
||
expires: 0,
|
||
to: '',
|
||
from: '',
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
payload: normalized,
|
||
envelope: JSON.parse(base64ToUtf8(fromBase64Url(normalized.slice(4)))),
|
||
};
|
||
}
|
||
|
||
function publicHints(envelope) {
|
||
const hints = [];
|
||
if (envelope.passwordHint) hints.push(`${t('hintPrefix')}: ${envelope.passwordHint}`);
|
||
if (envelope.voiceHint) hints.push(t('voiceAvailable'));
|
||
if (envelope.unlockAt && Date.now() < envelope.unlockAt) {
|
||
hints.push(t('unlockNotice').replace('{date}', new Date(envelope.unlockAt).toLocaleString()));
|
||
}
|
||
return hints;
|
||
}
|
||
|
||
function showDecodeHints(envelope) {
|
||
const box = $('#decodeHintBox');
|
||
box.textContent = '';
|
||
publicHints(envelope).forEach((hint) => {
|
||
const div = document.createElement('div');
|
||
div.textContent = hint;
|
||
box.appendChild(div);
|
||
});
|
||
const player = $('#voicePlayer');
|
||
player.hidden = true;
|
||
player.removeAttribute('src');
|
||
if (envelope.voiceHint) {
|
||
player.src = envelope.voiceHint;
|
||
player.hidden = false;
|
||
}
|
||
}
|
||
|
||
function currentCaption() {
|
||
const mode = $('#captionMode').value;
|
||
return (i18n[state.lang].captions[mode] || t('shareText')).replace('{url}', state.shareUrl);
|
||
}
|
||
|
||
function buildShareUrl(payload) {
|
||
const url = new URL(window.location.href);
|
||
url.hash = '';
|
||
url.search = '';
|
||
url.searchParams.set('msg', payload);
|
||
return url.toString();
|
||
}
|
||
|
||
async function createPayload() {
|
||
const messages = getSlides();
|
||
const password = $('#passwordInput').value;
|
||
const secure = $('#secureToggle').checked;
|
||
const expires = Number($('#expirySelect').value);
|
||
const unlockAtValue = $('#unlockAtInput').value;
|
||
|
||
if (!messages.length) {
|
||
showToast(t('toastWrite'));
|
||
return null;
|
||
}
|
||
if (secure && !password) {
|
||
showToast(t('toastPassword'));
|
||
return null;
|
||
}
|
||
|
||
const envelope = {
|
||
v: 2,
|
||
kind: secure ? 'encrypted' : 'base64',
|
||
to: $('#toInput').value.trim(),
|
||
from: $('#fromInput').value.trim(),
|
||
passwordHint: $('#passwordHintInput').value.trim(),
|
||
voiceHint: state.voiceHintData,
|
||
expires,
|
||
unlockAt: unlockAtValue ? new Date(unlockAtValue).getTime() : 0,
|
||
oneTime: $('#oneTimeToggle').checked,
|
||
invisible: $('#invisibleToggle').checked,
|
||
captionMode: $('#captionMode').value,
|
||
decoy: $('#decoyInput').value.trim(),
|
||
createdAt: Date.now(),
|
||
secret: secure ? await encryptText(JSON.stringify(messages), password) : { data: toBase64Url(utf8ToBase64(JSON.stringify(messages))) },
|
||
};
|
||
const decoyPassword = $('#decoyPasswordInput').value;
|
||
if (secure && envelope.decoy && decoyPassword) {
|
||
envelope.decoySecret = await encryptText(envelope.decoy, decoyPassword);
|
||
envelope.decoy = '';
|
||
}
|
||
const tier = getTierSecret();
|
||
if (secure && tier) {
|
||
envelope.tierHint = 'extra';
|
||
envelope.tier = await encryptText(tier.message, tier.password);
|
||
}
|
||
|
||
return 'sm2.' + toBase64Url(utf8ToBase64(JSON.stringify(envelope)));
|
||
}
|
||
|
||
async function decodePayload(rawValue, password) {
|
||
const { payload, envelope } = parsePayload(rawValue);
|
||
envelope.payloadId = await sha256Text(payload);
|
||
showDecodeHints(envelope);
|
||
if (envelope.unlockAt && Date.now() < envelope.unlockAt) throw new Error('locked');
|
||
if (envelope.oneTime && localStorage.getItem(oneTimeKey(envelope.payloadId))) throw new Error('used');
|
||
|
||
if (envelope.kind === 'encrypted') {
|
||
if (!password) throw new Error('password');
|
||
let decrypted;
|
||
let tierMessage = '';
|
||
try {
|
||
decrypted = await decryptText(envelope.secret, password);
|
||
} catch (error) {
|
||
if (envelope.tier) {
|
||
try {
|
||
return {
|
||
messages: [await decryptText(envelope.tier, password)],
|
||
meta: { ...envelope, tierOpened: true },
|
||
};
|
||
} catch {}
|
||
}
|
||
if (envelope.decoySecret) {
|
||
try {
|
||
return {
|
||
messages: [await decryptText(envelope.decoySecret, password)],
|
||
meta: { ...envelope, decoyOpened: true },
|
||
};
|
||
} catch {}
|
||
}
|
||
if (envelope.decoy) {
|
||
return {
|
||
messages: [envelope.decoy],
|
||
meta: { ...envelope, decoyOpened: true },
|
||
};
|
||
}
|
||
throw error;
|
||
}
|
||
if (envelope.tier) {
|
||
try {
|
||
tierMessage = await decryptText(envelope.tier, password);
|
||
} catch {}
|
||
}
|
||
const messages = normalizeMessages(decrypted);
|
||
if (tierMessage) messages.push(tierMessage);
|
||
return {
|
||
messages,
|
||
meta: envelope,
|
||
};
|
||
}
|
||
return {
|
||
messages: envelope.messages || normalizeMessages(base64ToUtf8(fromBase64Url(envelope.secret.data))),
|
||
meta: envelope,
|
||
};
|
||
}
|
||
|
||
function normalizeMessages(value) {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
|
||
} catch {
|
||
return [String(value)];
|
||
}
|
||
}
|
||
|
||
function renderTemplates() {
|
||
const grid = $('#templateGrid');
|
||
grid.innerHTML = '';
|
||
templates.forEach((template, index) => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'template-option' + (index === state.template ? ' active' : '');
|
||
button.style.setProperty('--template-bg', `linear-gradient(135deg, ${template.bg[0]}, ${template.bg[1]})`);
|
||
button.title = template.name;
|
||
button.setAttribute('aria-label', template.name);
|
||
button.addEventListener('click', () => {
|
||
state.template = index;
|
||
renderTemplates();
|
||
renderPreview();
|
||
});
|
||
grid.appendChild(button);
|
||
});
|
||
}
|
||
|
||
function renderStickers() {
|
||
const grid = $('#stickerGrid');
|
||
grid.textContent = '';
|
||
Object.entries(stickerPacks).forEach(([id, stickers]) => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.textContent = stickers[0] || '×';
|
||
button.title = id;
|
||
button.className = id === state.selectedStickerPack ? 'active' : '';
|
||
button.addEventListener('click', () => {
|
||
state.selectedStickerPack = id;
|
||
renderStickers();
|
||
renderPreview();
|
||
});
|
||
grid.appendChild(button);
|
||
});
|
||
}
|
||
|
||
function renderCompatibility() {
|
||
const grid = $('#compatGrid');
|
||
grid.textContent = '';
|
||
if (!state.payload) return;
|
||
const linkLength = state.shareUrl.length;
|
||
const codeLength = state.displayCode.length;
|
||
const stegoCapacity = ($('#previewCanvas').width * $('#previewCanvas').height * 3 / 8) - 8;
|
||
const badges = [
|
||
[state.secure ? t('safetyEncrypted') : t('safetyBase64'), state.secure ? 'good' : 'bad'],
|
||
[linkLength < 1800 ? t('compatLinkGood') : t('compatLinkWarn'), linkLength < 1800 ? 'good' : 'warn'],
|
||
[state.payload.length < 1200 ? t('compatQrGood') : t('compatQrWarn'), state.payload.length < 1200 ? 'good' : 'warn'],
|
||
[codeLength < 2400 ? t('compatEmojiGood') : t('compatEmojiWarn'), codeLength < 2400 ? 'good' : 'warn'],
|
||
[state.payload.length < stegoCapacity * 0.7 ? t('compatStegoGood') : t('compatStegoWarn'), state.payload.length < stegoCapacity * 0.7 ? 'good' : 'warn'],
|
||
];
|
||
badges.forEach(([label, status]) => {
|
||
const badge = document.createElement('div');
|
||
badge.className = `badge ${status}`;
|
||
badge.textContent = label;
|
||
grid.appendChild(badge);
|
||
});
|
||
}
|
||
|
||
function renderQr() {
|
||
const canvas = $('#qrCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
if (!state.shareUrl || !window.QRCode) {
|
||
ctx.fillStyle = '#4b3746';
|
||
ctx.font = '800 18px Nunito, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('QR', canvas.width / 2, canvas.height / 2 + 6);
|
||
return;
|
||
}
|
||
try {
|
||
QRCode.toCanvas(canvas, state.shareUrl, { margin: 1, width: 112, color: { dark: '#4b3746', light: '#ffffff' } });
|
||
} catch {
|
||
ctx.fillStyle = '#4b3746';
|
||
ctx.font = '800 18px Nunito, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('LINK', canvas.width / 2, canvas.height / 2 + 6);
|
||
}
|
||
}
|
||
|
||
async function renderPreview() {
|
||
if (document.fonts?.ready) await document.fonts.ready.catch(() => {});
|
||
const canvas = $('#previewCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const isStory = state.format === 'story';
|
||
const W = 1080;
|
||
const H = isStory ? 1920 : 1080;
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
|
||
const template = templates[state.template];
|
||
const accent = state.design.accent || template.accent;
|
||
const ink = state.design.text || template.ink;
|
||
const gradient = ctx.createLinearGradient(0, 0, W, H);
|
||
gradient.addColorStop(0, template.bg[0]);
|
||
gradient.addColorStop(1, template.bg[1]);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, W, H);
|
||
drawPaperTexture(ctx, W, H);
|
||
|
||
if (state.bgImage) {
|
||
drawCoverImage(ctx, state.bgImage, 0, 0, W, H);
|
||
ctx.fillStyle = `rgba(0,0,0,${state.design.overlay / 100})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
drawDoodles(ctx, W, H, accent, ink);
|
||
|
||
const top = isStory ? 250 : 120;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = ink;
|
||
ctx.font = `800 ${62 * state.design.fontScale / 100}px Fraunces, Georgia, serif`;
|
||
ctx.fillText(template.emoji, W / 2, top);
|
||
ctx.font = `800 ${72 * state.design.fontScale / 100}px Fraunces, Georgia, serif`;
|
||
ctx.fillText(t('canvasTitle'), W / 2, top + 95);
|
||
ctx.font = `700 ${30 * state.design.fontScale / 100}px Nunito, sans-serif`;
|
||
ctx.fillText(t('canvasSubtitle'), W / 2, top + 148);
|
||
|
||
const to = $('#toInput').value.trim();
|
||
const from = $('#fromInput').value.trim();
|
||
if (to || from) {
|
||
ctx.font = `800 ${34 * state.design.fontScale / 100}px Nunito, sans-serif`;
|
||
ctx.fillStyle = accent;
|
||
ctx.fillText([to ? `${t('toLabel')} ${to}` : '', from ? `${t('fromLabel')} ${from}` : ''].filter(Boolean).join(' · '), W / 2, top + 210);
|
||
}
|
||
|
||
const boxW = W - 150;
|
||
const boxH = isStory ? 520 : 360;
|
||
const boxX = 75;
|
||
const boxY = Math.round(H * state.design.codeY / 100);
|
||
drawTape(ctx, boxX + 60, boxY - 28, accent, -0.09);
|
||
drawTape(ctx, boxX + boxW - 190, boxY - 22, accent, 0.08);
|
||
ctx.fillStyle = template.soft;
|
||
roundRect(ctx, boxX, boxY, boxW, boxH, 28);
|
||
ctx.fill();
|
||
ctx.strokeStyle = accent + '66';
|
||
ctx.lineWidth = 5;
|
||
ctx.setLineDash([16, 10]);
|
||
roundRect(ctx, boxX, boxY, boxW, boxH, 28);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
ctx.fillStyle = ink;
|
||
ctx.font = `800 ${28 * state.design.fontScale / 100}px Nunito, sans-serif`;
|
||
ctx.fillText((state.payload ? state.secure : $('#secureToggle').checked) ? t('encrypted') : t('encoded'), W / 2, boxY + 58);
|
||
ctx.font = `700 ${25 * state.design.fontScale / 100}px Nunito, sans-serif`;
|
||
const hideCode = state.payload ? state.invisible : $('#invisibleToggle').checked;
|
||
wrapText(ctx, hideCode ? t('invisibleCanvas') : (state.displayCode || t('codeEmpty')), W / 2, boxY + 118, boxW - 90, 36, Math.floor((boxH - 150) / 36));
|
||
|
||
if (state.shareUrl && window.QRCode) {
|
||
const qrTemp = document.createElement('canvas');
|
||
qrTemp.width = 210;
|
||
qrTemp.height = 210;
|
||
try {
|
||
await QRCode.toCanvas(qrTemp, state.shareUrl, { margin: 1, width: 210, color: { dark: '#4b3746', light: '#ffffff' } });
|
||
const qrX = W * state.design.qrX / 100;
|
||
const qrY = H * state.design.qrY / 100;
|
||
ctx.fillStyle = '#fff';
|
||
roundRect(ctx, qrX - 125, qrY - 25, 250, 250, 18);
|
||
ctx.fill();
|
||
ctx.strokeStyle = accent + '55';
|
||
ctx.lineWidth = 4;
|
||
ctx.stroke();
|
||
ctx.drawImage(qrTemp, qrX - 105, qrY - 5, 210, 210);
|
||
} catch {
|
||
ctx.fillStyle = ink;
|
||
ctx.font = '800 24px Nunito, sans-serif';
|
||
ctx.fillText(t('qrTooLarge'), W / 2, isStory ? 1500 : 910);
|
||
}
|
||
}
|
||
|
||
drawStickers(ctx, W, H, accent);
|
||
|
||
ctx.fillStyle = accent;
|
||
ctx.font = '800 26px Nunito, sans-serif';
|
||
ctx.fillText('Secret Message Studio', W / 2, H - 90);
|
||
}
|
||
|
||
function drawPaperTexture(ctx, W, H) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.16;
|
||
ctx.strokeStyle = '#7a5f4b';
|
||
ctx.lineWidth = 1;
|
||
for (let y = 0; y < H; y += 34) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y + 0.5);
|
||
ctx.lineTo(W, y + 0.5);
|
||
ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 0.1;
|
||
for (let x = 0; x < W; x += 34) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + 0.5, 0);
|
||
ctx.lineTo(x + 0.5, H);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawDoodles(ctx, W, H, accent, ink) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.36;
|
||
ctx.strokeStyle = accent;
|
||
ctx.lineWidth = 5;
|
||
const marks = [
|
||
[120, 310, '♡'], [W - 160, 360, '✦'], [135, H - 260, '✿'], [W - 160, H - 310, '♡'],
|
||
];
|
||
marks.forEach(([x, y, mark], index) => {
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate((index - 1.5) * 0.08);
|
||
ctx.fillStyle = index % 2 ? ink : accent;
|
||
ctx.font = '800 54px Nunito, sans-serif';
|
||
ctx.fillText(mark, 0, 0);
|
||
ctx.restore();
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawTape(ctx, x, y, color, rotation) {
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate(rotation);
|
||
ctx.globalAlpha = 0.58;
|
||
ctx.fillStyle = color;
|
||
roundRect(ctx, 0, 0, 145, 42, 8);
|
||
ctx.fill();
|
||
ctx.globalAlpha = 0.22;
|
||
ctx.fillStyle = '#ffffff';
|
||
for (let i = 8; i < 135; i += 22) {
|
||
ctx.fillRect(i, 0, 9, 42);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawStickers(ctx, W, H, color) {
|
||
const stickers = stickerPacks[state.selectedStickerPack] || [];
|
||
if (!stickers.length) return;
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.78;
|
||
ctx.fillStyle = color;
|
||
stickers.forEach((sticker, index) => {
|
||
const x = index % 2 === 0 ? 118 + index * 16 : W - 150 - index * 13;
|
||
const y = 500 + index * 182;
|
||
ctx.save();
|
||
ctx.translate(x, Math.min(H - 180, y));
|
||
ctx.rotate((index % 2 ? 0.11 : -0.08));
|
||
ctx.font = `${44 + (index % 3) * 8}px serif`;
|
||
ctx.fillText(sticker, 0, 0);
|
||
ctx.restore();
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawCoverImage(ctx, image, x, y, w, h) {
|
||
const scale = Math.max(w / image.naturalWidth, h / image.naturalHeight);
|
||
const sw = w / scale;
|
||
const sh = h / scale;
|
||
const sx = (image.naturalWidth - sw) / 2;
|
||
const sy = (image.naturalHeight - sh) / 2;
|
||
ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
|
||
}
|
||
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||
const chars = [...String(text)];
|
||
let line = '';
|
||
let lineY = y;
|
||
let lines = 0;
|
||
for (const char of chars) {
|
||
const next = line + char;
|
||
if (ctx.measureText(next).width > maxWidth && line) {
|
||
lines++;
|
||
if (lines >= maxLines) {
|
||
ctx.fillText(line.slice(0, Math.max(0, line.length - 3)) + '...', x, lineY);
|
||
return;
|
||
}
|
||
ctx.fillText(line, x, lineY);
|
||
line = char;
|
||
lineY += lineHeight;
|
||
} else {
|
||
line = next;
|
||
}
|
||
}
|
||
ctx.fillText(line, x, lineY);
|
||
}
|
||
|
||
async function handleCreate() {
|
||
const payload = await createPayload();
|
||
if (!payload) return;
|
||
|
||
state.payload = payload;
|
||
state.secure = $('#secureToggle').checked;
|
||
state.invisible = $('#invisibleToggle').checked;
|
||
state.shareUrl = buildShareUrl(payload);
|
||
updateDisplayCode();
|
||
renderQr();
|
||
await renderPreview();
|
||
saveInboxItem('created', payload);
|
||
renderCompatibility();
|
||
showToast(t('toastCreated'));
|
||
}
|
||
|
||
function updateDisplayCode() {
|
||
state.displayCode = state.payload && $('#outputMode').value === 'emoji' ? toEmojiCode(state.payload) : state.payload;
|
||
const result = $('#encodedResult');
|
||
result.textContent = state.displayCode || t('codeEmpty');
|
||
result.classList.toggle('empty', !state.displayCode);
|
||
renderCompatibility();
|
||
}
|
||
|
||
function stopReveal(clearDestruct = true) {
|
||
state.revealTimers.forEach(clearTimeout);
|
||
state.revealTimers = [];
|
||
if (clearDestruct) {
|
||
clearTimeout(state.destructTimer);
|
||
clearInterval(state.destructInterval);
|
||
$('#destructBox').hidden = true;
|
||
}
|
||
}
|
||
|
||
async function handleOpen() {
|
||
stopReveal();
|
||
try {
|
||
const decoded = await decodePayload($('#decodeInput').value, $('#decodePassword').value);
|
||
state.decodedMeta = decoded.meta;
|
||
state.decodedMessages = decoded.messages;
|
||
state.currentSlide = 0;
|
||
renderPlaylist();
|
||
revealSlide(0);
|
||
if (decoded.meta.oneTime) localStorage.setItem(oneTimeKey(decoded.meta.payloadId), String(Date.now()));
|
||
if (decoded.meta.expires) startSelfDestruct(decoded.meta.expires);
|
||
$('#replyBtn').hidden = false;
|
||
saveInboxItem('opened', $('#decodeInput').value.trim());
|
||
showToast(t('toastOpened'));
|
||
} catch (error) {
|
||
const messages = {
|
||
password: t('toastPasswordNeeded'),
|
||
OperationError: t('toastPasswordNeeded'),
|
||
used: t('toastUsed'),
|
||
locked: t('toastLocked'),
|
||
};
|
||
showToast(messages[error.message] || messages[error.name] || t('toastInvalid'));
|
||
}
|
||
}
|
||
|
||
function renderPlaylist() {
|
||
const controls = $('#playlistControls');
|
||
controls.textContent = '';
|
||
if (state.decodedMessages.length <= 1) return;
|
||
|
||
const prev = document.createElement('button');
|
||
prev.type = 'button';
|
||
prev.className = 'btn ghost';
|
||
prev.textContent = t('prevSlide');
|
||
prev.disabled = state.currentSlide === 0;
|
||
prev.addEventListener('click', () => revealSlide(state.currentSlide - 1));
|
||
|
||
const counter = document.createElement('strong');
|
||
counter.textContent = t('slideCounter')
|
||
.replace('{current}', state.currentSlide + 1)
|
||
.replace('{total}', state.decodedMessages.length);
|
||
|
||
const next = document.createElement('button');
|
||
next.type = 'button';
|
||
next.className = 'btn ghost';
|
||
next.textContent = t('nextSlide');
|
||
next.disabled = state.currentSlide >= state.decodedMessages.length - 1;
|
||
next.addEventListener('click', () => revealSlide(state.currentSlide + 1));
|
||
|
||
controls.append(prev, counter, next);
|
||
}
|
||
|
||
function revealSlide(index) {
|
||
if (index < 0 || index >= state.decodedMessages.length) return;
|
||
stopReveal(false);
|
||
state.currentSlide = index;
|
||
renderPlaylist();
|
||
revealMessage(state.decodedMessages[index], $('#revealMode').value);
|
||
}
|
||
|
||
function revealMessage(message, mode) {
|
||
const result = $('#decodedResult');
|
||
result.className = `result-box secret-reveal ${mode}`;
|
||
result.textContent = '';
|
||
result.classList.remove('empty');
|
||
|
||
if (mode === 'fade') {
|
||
result.textContent = message;
|
||
return;
|
||
}
|
||
|
||
const chars = [...message];
|
||
chars.forEach((char, index) => {
|
||
const timer = setTimeout(() => {
|
||
const span = document.createElement('span');
|
||
span.textContent = char;
|
||
result.appendChild(span);
|
||
}, mode === 'glitch' ? index * 8 : index * 24);
|
||
state.revealTimers.push(timer);
|
||
});
|
||
}
|
||
|
||
function startSelfDestruct(seconds) {
|
||
const box = $('#destructBox');
|
||
let remaining = seconds;
|
||
box.hidden = false;
|
||
box.textContent = t('destruct').replace('{s}', remaining);
|
||
state.destructInterval = setInterval(() => {
|
||
remaining -= 1;
|
||
box.textContent = remaining > 0 ? t('destruct').replace('{s}', remaining) : t('expired');
|
||
}, 1000);
|
||
state.destructTimer = setTimeout(() => {
|
||
stopReveal();
|
||
const result = $('#decodedResult');
|
||
result.textContent = t('expired');
|
||
result.className = 'result-box empty secret-reveal';
|
||
}, seconds * 1000);
|
||
}
|
||
|
||
function createReply() {
|
||
if (!state.decodedMeta) return;
|
||
$('#messageInput').value = '';
|
||
$('#extraSlidesInput').value = '';
|
||
$('#toInput').value = state.decodedMeta.from || '';
|
||
$('#fromInput').value = state.decodedMeta.to || '';
|
||
$('#passwordHintInput').value = state.decodedMeta.passwordHint || '';
|
||
$('#secureToggle').checked = state.decodedMeta.kind === 'encrypted';
|
||
setTab('encode');
|
||
renderPreview();
|
||
showToast(t('toastReplyReady'));
|
||
}
|
||
|
||
async function copyText(text) {
|
||
if (!text) {
|
||
showToast(t('toastNoCode'));
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch {
|
||
const input = document.createElement('textarea');
|
||
input.value = text;
|
||
document.body.appendChild(input);
|
||
input.select();
|
||
document.execCommand('copy');
|
||
input.remove();
|
||
}
|
||
showToast(t('toastCopied'));
|
||
}
|
||
|
||
function downloadImage() {
|
||
if (!state.payload) {
|
||
showToast(t('toastNoCode'));
|
||
return;
|
||
}
|
||
const link = document.createElement('a');
|
||
link.download = 'secret-message.png';
|
||
link.href = $('#previewCanvas').toDataURL('image/png');
|
||
link.click();
|
||
showToast(t('toastSaved'));
|
||
}
|
||
|
||
function clearEncode() {
|
||
['#messageInput', '#extraSlidesInput', '#toInput', '#fromInput', '#passwordInput', '#passwordHintInput', '#unlockAtInput', '#decoyInput', '#decoyPasswordInput', '#tierPasswordInput', '#tierMessageInput'].forEach((selector) => $(selector).value = '');
|
||
$('#secureToggle').checked = false;
|
||
$('#oneTimeToggle').checked = false;
|
||
$('#invisibleToggle').checked = false;
|
||
$('#expirySelect').value = '0';
|
||
$('#outputMode').value = 'base64';
|
||
$('#captionMode').value = 'romantic';
|
||
$('#bgInput').value = '';
|
||
state.payload = '';
|
||
state.displayCode = '';
|
||
state.shareUrl = '';
|
||
state.secure = false;
|
||
state.invisible = false;
|
||
state.bgImage = null;
|
||
clearVoiceHint();
|
||
updatePasswordStrength();
|
||
$('#encodedResult').textContent = t('codeEmpty');
|
||
$('#encodedResult').classList.add('empty');
|
||
$('#compatGrid').textContent = '';
|
||
renderQr();
|
||
renderPreview();
|
||
}
|
||
|
||
function clearDecode() {
|
||
stopReveal();
|
||
stopScanner();
|
||
$('#decodeInput').value = '';
|
||
$('#decodePassword').value = '';
|
||
$('#stegoInput').value = '';
|
||
$('#secretFileInput').value = '';
|
||
$('#decodeHintBox').textContent = '';
|
||
$('#playlistControls').textContent = '';
|
||
$('#replyBtn').hidden = true;
|
||
$('#voicePlayer').hidden = true;
|
||
$('#voicePlayer').removeAttribute('src');
|
||
state.decodedMeta = null;
|
||
state.decodedMessages = [];
|
||
$('#decodedResult').textContent = t('secretEmpty');
|
||
$('#decodedResult').className = 'result-box empty secret-reveal';
|
||
}
|
||
|
||
function setTab(tab) {
|
||
state.tab = tab;
|
||
$$('[data-tab]').forEach((button) => button.classList.toggle('active', button.dataset.tab === tab));
|
||
$('#encodeView').classList.toggle('active', tab === 'encode');
|
||
$('#decodeView').classList.toggle('active', tab === 'decode');
|
||
}
|
||
|
||
function setLanguage(lang) {
|
||
state.lang = lang;
|
||
document.documentElement.lang = lang;
|
||
$$('[data-lang]').forEach((button) => button.classList.toggle('active', button.dataset.lang === lang));
|
||
$$('[data-i18n]').forEach((el) => el.textContent = t(el.dataset.i18n));
|
||
$$('[data-i18n-placeholder]').forEach((el) => el.placeholder = t(el.dataset.i18nPlaceholder));
|
||
$$('#expirySelect option, #outputMode option, #revealMode option, #captionMode option').forEach((option) => {
|
||
if (option.dataset.i18n) option.textContent = t(option.dataset.i18n);
|
||
});
|
||
if ($('#encodedResult').classList.contains('empty')) $('#encodedResult').textContent = t('codeEmpty');
|
||
if ($('#decodedResult').classList.contains('empty')) $('#decodedResult').textContent = t('secretEmpty');
|
||
updatePasswordStrength();
|
||
renderCompatibility();
|
||
renderInbox();
|
||
renderPlaylist();
|
||
renderPreview();
|
||
}
|
||
|
||
function bindEvents() {
|
||
$$('[data-tab]').forEach((button) => button.addEventListener('click', () => setTab(button.dataset.tab)));
|
||
$$('[data-lang]').forEach((button) => button.addEventListener('click', () => setLanguage(button.dataset.lang)));
|
||
$$('[data-format]').forEach((button) => button.addEventListener('click', () => {
|
||
state.format = button.dataset.format;
|
||
$$('[data-format]').forEach((item) => item.classList.toggle('active', item === button));
|
||
renderPreview();
|
||
}));
|
||
$('#createBtn').addEventListener('click', handleCreate);
|
||
$('#testSecretBtn').addEventListener('click', testSecret);
|
||
$('#openBtn').addEventListener('click', handleOpen);
|
||
$('#clearEncodeBtn').addEventListener('click', clearEncode);
|
||
$('#clearDecodeBtn').addEventListener('click', clearDecode);
|
||
$('#generatePasswordBtn').addEventListener('click', generatePassword);
|
||
$('#copyPasswordBtn').addEventListener('click', () => copyText($('#passwordInput').value));
|
||
$('#copyCodeBtn').addEventListener('click', () => copyText(state.displayCode));
|
||
$('#copyLinkBtn').addEventListener('click', () => copyText(state.shareUrl));
|
||
$('#copyShareBtn').addEventListener('click', () => copyText(state.shareUrl ? currentCaption() : ''));
|
||
$('#downloadBtn').addEventListener('click', downloadImage);
|
||
$('#downloadStegoBtn').addEventListener('click', downloadStegoImage);
|
||
$('#downloadSecretBtn').addEventListener('click', downloadSecretFile);
|
||
$('#downloadCountdownBtn').addEventListener('click', downloadCountdown);
|
||
$('#replyBtn').addEventListener('click', createReply);
|
||
$('#recordHintBtn').addEventListener('click', toggleVoiceRecording);
|
||
$('#clearVoiceBtn').addEventListener('click', clearVoiceHint);
|
||
$('#startScannerBtn').addEventListener('click', startScanner);
|
||
$('#stopScannerBtn').addEventListener('click', stopScanner);
|
||
$('#stegoInput').addEventListener('change', handleStegoUpload);
|
||
$('#secretFileInput').addEventListener('change', handleSecretFile);
|
||
$('#refreshInboxBtn').addEventListener('click', renderInbox);
|
||
$('#passwordInput').addEventListener('input', updatePasswordStrength);
|
||
['#toInput', '#fromInput', '#messageInput', '#extraSlidesInput', '#secureToggle', '#invisibleToggle'].forEach((selector) => $(selector).addEventListener('input', renderPreview));
|
||
['#qrXRange', '#qrYRange', '#codeYRange', '#fontScaleRange', '#overlayRange', '#accentColorInput', '#textColorInput'].forEach((selector) => $(selector).addEventListener('input', updateDesignFromControl));
|
||
$('#undoDesignBtn').addEventListener('click', undoDesign);
|
||
$('#redoDesignBtn').addEventListener('click', redoDesign);
|
||
$('#outputMode').addEventListener('input', () => {
|
||
updateDisplayCode();
|
||
renderPreview();
|
||
});
|
||
$('#bgInput').addEventListener('change', handleImageUpload);
|
||
}
|
||
|
||
function handleImageUpload(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) {
|
||
state.bgImage = null;
|
||
renderPreview();
|
||
return;
|
||
}
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
state.bgImage = img;
|
||
renderPreview();
|
||
};
|
||
img.src = URL.createObjectURL(file);
|
||
}
|
||
|
||
function randomPassword() {
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%?';
|
||
const bytes = randomBytes(20);
|
||
return [...bytes].map((byte) => chars[byte % chars.length]).join('');
|
||
}
|
||
|
||
function generatePassword() {
|
||
const password = randomPassword();
|
||
$('#passwordInput').value = password;
|
||
$('#secureToggle').checked = true;
|
||
updatePasswordStrength();
|
||
renderPreview();
|
||
showToast(t('toastGeneratedPassword'));
|
||
}
|
||
|
||
async function testSecret() {
|
||
const payload = await createPayload();
|
||
if (!payload) return;
|
||
try {
|
||
await decodePayload(payload, $('#passwordInput').value);
|
||
$('#decodeHintBox').textContent = '';
|
||
$('#voicePlayer').hidden = true;
|
||
showToast(t('toastTestOk'));
|
||
} catch {
|
||
showToast(t('toastInvalid'));
|
||
}
|
||
}
|
||
|
||
function downloadSecretFile() {
|
||
if (!state.payload) {
|
||
showToast(t('toastNoCode'));
|
||
return;
|
||
}
|
||
const body = {
|
||
type: 'secret-message-studio',
|
||
version: 1,
|
||
payload: state.payload,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
downloadTextFile('secret-message.secret', JSON.stringify(body, null, 2), 'application/json');
|
||
showToast(t('toastSecretSaved'));
|
||
}
|
||
|
||
function handleSecretFile(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const parsed = JSON.parse(reader.result);
|
||
$('#decodeInput').value = parsed.payload || String(reader.result).trim();
|
||
} catch {
|
||
$('#decodeInput').value = String(reader.result).trim();
|
||
}
|
||
setTab('decode');
|
||
showToast(t('toastImported'));
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function downloadTextFile(filename, text, type = 'text/plain') {
|
||
const link = document.createElement('a');
|
||
link.download = filename;
|
||
link.href = URL.createObjectURL(new Blob([text], { type }));
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||
}
|
||
|
||
async function downloadCountdown() {
|
||
if (!state.payload) {
|
||
showToast(t('toastNoCode'));
|
||
return;
|
||
}
|
||
const originalTitle = t('canvasTitle');
|
||
const labels = state.lang === 'de' ? ['Noch 3 Tage', 'Noch 2 Tage', 'Morgen', 'Jetzt öffnen'] : ['3 days left', '2 days left', 'Tomorrow', 'Open now'];
|
||
for (let i = 0; i < labels.length; i++) {
|
||
await renderPreview();
|
||
const canvas = $('#previewCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.76)';
|
||
roundRect(ctx, 120, 115, canvas.width - 240, 120, 18);
|
||
ctx.fill();
|
||
ctx.fillStyle = state.design.accent;
|
||
ctx.font = '800 52px Nunito, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(labels[i], canvas.width / 2, 193);
|
||
ctx.restore();
|
||
const link = document.createElement('a');
|
||
link.download = `secret-countdown-${i + 1}.png`;
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
}
|
||
await renderPreview();
|
||
showToast(t('toastCountdownSaved') || originalTitle);
|
||
}
|
||
|
||
function inboxItems() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(INBOX_KEY) || '[]');
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function saveInboxItem(kind, payload) {
|
||
const items = inboxItems();
|
||
const id = await sha256Text(payload);
|
||
const item = {
|
||
id,
|
||
kind,
|
||
payload,
|
||
displayCode: state.displayCode || payload,
|
||
to: $('#toInput').value.trim(),
|
||
from: $('#fromInput').value.trim(),
|
||
secure: state.secure,
|
||
oneTime: $('#oneTimeToggle').checked,
|
||
createdAt: Date.now(),
|
||
};
|
||
const next = [item, ...items.filter((entry) => entry.id !== id)].slice(0, 30);
|
||
localStorage.setItem(INBOX_KEY, JSON.stringify(next));
|
||
renderInbox();
|
||
}
|
||
|
||
function renderInbox() {
|
||
const list = $('#inboxList');
|
||
list.textContent = '';
|
||
const items = inboxItems();
|
||
if (!items.length) return;
|
||
items.slice(0, 8).forEach((item) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'inbox-item';
|
||
const title = document.createElement('strong');
|
||
title.textContent = [item.to || item.from || 'Secret', item.secure ? t('encrypted') : t('encoded')].filter(Boolean).join(' · ');
|
||
const meta = document.createElement('span');
|
||
meta.textContent = new Date(item.createdAt).toLocaleString();
|
||
const actions = document.createElement('div');
|
||
actions.className = 'actions';
|
||
actions.style.margin = '0';
|
||
const open = document.createElement('button');
|
||
open.type = 'button';
|
||
open.className = 'btn ghost';
|
||
open.textContent = t('tabDecode');
|
||
open.addEventListener('click', () => {
|
||
$('#decodeInput').value = item.payload;
|
||
setTab('decode');
|
||
});
|
||
const copy = document.createElement('button');
|
||
copy.type = 'button';
|
||
copy.className = 'btn ghost';
|
||
copy.textContent = t('copyCodeBtn');
|
||
copy.addEventListener('click', () => copyText(item.displayCode || item.payload));
|
||
actions.append(open, copy);
|
||
row.append(title, meta, actions);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function snapshotDesign() {
|
||
return { ...state.design };
|
||
}
|
||
|
||
function pushDesignHistory() {
|
||
state.undoStack.push(snapshotDesign());
|
||
state.redoStack = [];
|
||
if (state.undoStack.length > 30) state.undoStack.shift();
|
||
}
|
||
|
||
function applyDesign(design) {
|
||
state.design = { ...state.design, ...design };
|
||
syncDesignControls();
|
||
renderPreview();
|
||
renderCompatibility();
|
||
}
|
||
|
||
function syncDesignControls() {
|
||
Object.entries({
|
||
qrXRange: 'qrX',
|
||
qrYRange: 'qrY',
|
||
codeYRange: 'codeY',
|
||
fontScaleRange: 'fontScale',
|
||
overlayRange: 'overlay',
|
||
}).forEach(([id, key]) => {
|
||
const input = $('#' + id);
|
||
input.value = state.design[key];
|
||
$('#' + id.replace('Range', 'Value')).textContent = state.design[key];
|
||
});
|
||
$('#accentColorInput').value = state.design.accent;
|
||
$('#textColorInput').value = state.design.text;
|
||
}
|
||
|
||
function updateDesignFromControl(event) {
|
||
pushDesignHistory();
|
||
const target = event.target;
|
||
const map = {
|
||
qrXRange: 'qrX',
|
||
qrYRange: 'qrY',
|
||
codeYRange: 'codeY',
|
||
fontScaleRange: 'fontScale',
|
||
overlayRange: 'overlay',
|
||
accentColorInput: 'accent',
|
||
textColorInput: 'text',
|
||
};
|
||
const key = map[target.id];
|
||
state.design[key] = target.type === 'range' ? Number(target.value) : target.value;
|
||
syncDesignControls();
|
||
renderPreview();
|
||
renderCompatibility();
|
||
}
|
||
|
||
function undoDesign() {
|
||
if (!state.undoStack.length) return;
|
||
state.redoStack.push(snapshotDesign());
|
||
applyDesign(state.undoStack.pop());
|
||
}
|
||
|
||
function redoDesign() {
|
||
if (!state.redoStack.length) return;
|
||
state.undoStack.push(snapshotDesign());
|
||
applyDesign(state.redoStack.pop());
|
||
}
|
||
|
||
function updatePasswordStrength() {
|
||
const value = $('#passwordInput').value;
|
||
let score = 0;
|
||
if (value.length >= 8) score++;
|
||
if (value.length >= 14) score++;
|
||
if (/[A-Z]/.test(value) && /[a-z]/.test(value)) score++;
|
||
if (/\d/.test(value)) score++;
|
||
if (/[^A-Za-z0-9]/.test(value)) score++;
|
||
const labels = [t('strengthEmpty'), t('strengthWeak'), t('strengthMedium'), t('strengthStrong'), t('strengthGreat'), t('strengthGreat')];
|
||
const colors = ['#a73442', '#a73442', '#d46a39', '#c9982f', '#2f9c95', '#2f9c95'];
|
||
$('#strengthMeter').style.width = value ? `${Math.min(100, score * 22)}%` : '0%';
|
||
$('#strengthMeter').style.background = colors[score];
|
||
$('#strengthText').textContent = value ? labels[score] : t('strengthEmpty');
|
||
}
|
||
|
||
async function toggleVoiceRecording() {
|
||
if (state.mediaRecorder && state.mediaRecorder.state === 'recording') {
|
||
state.mediaRecorder.stop();
|
||
$('#recordHintBtn').textContent = t('recordHintBtn');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
state.recordingChunks = [];
|
||
state.mediaRecorder = new MediaRecorder(stream);
|
||
state.mediaRecorder.addEventListener('dataavailable', (event) => {
|
||
if (event.data.size) state.recordingChunks.push(event.data);
|
||
});
|
||
state.mediaRecorder.addEventListener('stop', () => {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
const blob = new Blob(state.recordingChunks, { type: state.mediaRecorder.mimeType || 'audio/webm' });
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
state.voiceHintData = reader.result;
|
||
$('#voiceStatus').textContent = t('voiceReady');
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
state.mediaRecorder.start();
|
||
$('#recordHintBtn').textContent = t('stopRecordBtn');
|
||
showToast(t('toastRecording'));
|
||
} catch {
|
||
showToast(t('toastMicDenied'));
|
||
}
|
||
}
|
||
|
||
function clearVoiceHint() {
|
||
state.voiceHintData = '';
|
||
$('#voiceStatus').textContent = t('voiceEmpty');
|
||
}
|
||
|
||
async function startScanner() {
|
||
if (!window.jsQR) {
|
||
showToast(t('toastScannerMissing'));
|
||
return;
|
||
}
|
||
try {
|
||
state.scannerStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
||
const video = $('#scannerVideo');
|
||
video.srcObject = state.scannerStream;
|
||
await video.play();
|
||
$('#scannerBox').classList.add('active');
|
||
scanFrame();
|
||
} catch {
|
||
showToast(t('toastScannerDenied'));
|
||
}
|
||
}
|
||
|
||
function scanFrame() {
|
||
const video = $('#scannerVideo');
|
||
if (!state.scannerStream || video.readyState < 2) {
|
||
state.scannerFrame = requestAnimationFrame(scanFrame);
|
||
return;
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||
if (code) {
|
||
const value = extractMsgFromScannedValue(code.data);
|
||
$('#decodeInput').value = value;
|
||
stopScanner();
|
||
showToast(t('toastScanned'));
|
||
return;
|
||
}
|
||
state.scannerFrame = requestAnimationFrame(scanFrame);
|
||
}
|
||
|
||
function extractMsgFromScannedValue(value) {
|
||
try {
|
||
const url = new URL(value);
|
||
return url.searchParams.get('msg') || value;
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function stopScanner() {
|
||
cancelAnimationFrame(state.scannerFrame);
|
||
if (state.scannerStream) {
|
||
state.scannerStream.getTracks().forEach((track) => track.stop());
|
||
state.scannerStream = null;
|
||
}
|
||
$('#scannerVideo').srcObject = null;
|
||
$('#scannerBox').classList.remove('active');
|
||
}
|
||
|
||
async function downloadStegoImage() {
|
||
if (!state.payload) {
|
||
showToast(t('toastNoCode'));
|
||
return;
|
||
}
|
||
await renderPreview();
|
||
const source = $('#previewCanvas');
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = source.width;
|
||
canvas.height = source.height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(source, 0, 0);
|
||
if (!embedPayloadInCanvas(canvas, state.payload)) {
|
||
showToast(t('toastTooLarge'));
|
||
return;
|
||
}
|
||
const link = document.createElement('a');
|
||
link.download = 'secret-message-stego.png';
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
showToast(t('toastStegoSaved'));
|
||
}
|
||
|
||
function embedPayloadInCanvas(canvas, payload) {
|
||
const ctx = canvas.getContext('2d');
|
||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||
const payloadBytes = new TextEncoder().encode(payload);
|
||
const bytes = new Uint8Array(8 + payloadBytes.length);
|
||
bytes.set([83, 77, 83, 50], 0);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setUint32(4, payloadBytes.length);
|
||
bytes.set(payloadBytes, 8);
|
||
if (bytes.length * 8 > Math.floor(imageData.data.length / 4) * 3) return false;
|
||
writeBytesToImageData(imageData.data, bytes);
|
||
ctx.putImageData(imageData, 0, 0);
|
||
return true;
|
||
}
|
||
|
||
function writeBytesToImageData(data, bytes) {
|
||
let bitIndex = 0;
|
||
for (const byte of bytes) {
|
||
for (let bit = 7; bit >= 0; bit--) {
|
||
const channel = bitIndex % 3;
|
||
const pixel = Math.floor(bitIndex / 3);
|
||
const dataIndex = pixel * 4 + channel;
|
||
data[dataIndex] = (data[dataIndex] & 254) | ((byte >> bit) & 1);
|
||
bitIndex++;
|
||
}
|
||
}
|
||
}
|
||
|
||
function readBytesFromImageData(data, byteCount) {
|
||
const bytes = new Uint8Array(byteCount);
|
||
let bitIndex = 0;
|
||
for (let i = 0; i < byteCount; i++) {
|
||
let value = 0;
|
||
for (let bit = 7; bit >= 0; bit--) {
|
||
const channel = bitIndex % 3;
|
||
const pixel = Math.floor(bitIndex / 3);
|
||
const dataIndex = pixel * 4 + channel;
|
||
value |= (data[dataIndex] & 1) << bit;
|
||
bitIndex++;
|
||
}
|
||
bytes[i] = value;
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
function extractPayloadFromCanvas(canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||
const header = readBytesFromImageData(imageData.data, 8);
|
||
if (header[0] !== 83 || header[1] !== 77 || header[2] !== 83 || header[3] !== 50) return '';
|
||
const length = new DataView(header.buffer).getUint32(4);
|
||
const capacity = Math.floor(imageData.data.length / 4) * 3 / 8 - 8;
|
||
if (length <= 0 || length > capacity) return '';
|
||
return new TextDecoder().decode(readBytesFromImageData(imageData.data, 8 + length).slice(8));
|
||
}
|
||
|
||
function handleStegoUpload(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = image.naturalWidth;
|
||
canvas.height = image.naturalHeight;
|
||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||
const payload = extractPayloadFromCanvas(canvas);
|
||
if (!payload) {
|
||
showToast(t('toastStegoMissing'));
|
||
return;
|
||
}
|
||
$('#decodeInput').value = payload;
|
||
setTab('decode');
|
||
showToast(t('toastScanned'));
|
||
};
|
||
image.src = URL.createObjectURL(file);
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = $('#toast');
|
||
toast.textContent = message;
|
||
toast.classList.add('show');
|
||
clearTimeout(showToast.timer);
|
||
showToast.timer = setTimeout(() => toast.classList.remove('show'), 2400);
|
||
}
|
||
|
||
function loadFromUrl() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const msg = params.get('msg');
|
||
if (!msg) return;
|
||
$('#decodeInput').value = msg;
|
||
setTab('decode');
|
||
}
|
||
|
||
function registerServiceWorker() {
|
||
if (!('serviceWorker' in navigator)) return;
|
||
navigator.serviceWorker.register('sw.js').catch(() => {});
|
||
}
|
||
|
||
bindEvents();
|
||
syncDesignControls();
|
||
renderTemplates();
|
||
renderStickers();
|
||
renderInbox();
|
||
renderQr();
|
||
renderPreview();
|
||
loadFromUrl();
|
||
updatePasswordStrength();
|
||
registerServiceWorker();
|
||
</script>
|
||
</body>
|
||
</html>
|