Files
2026-07-25 21:13:35 +02:00

2807 lines
98 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>