Files
matrix-chat-export/app/templates/chat_export.html
Patrick Asmus 4dae5477ce
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 5s
Add application, Dockerfile, CI workflow and documentation
- FastAPI-based Matrix chat export tool with E2E support
- Dockerfile with Python 3.12-slim base image
- Docker Compose with dedicated network configuration
- Gitea Actions workflow for automated image builds
- README with setup instructions and feature overview
- MIT license

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 08:04:27 +02:00

267 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Chat Export</title>
<style>
:root {
--bg:#0b141a;--bg-chat:#0b141a;--msg-own:#005c4b;--msg-other:#202c33;
--msg-own-h:#006d5a;--msg-other-h:#263845;--text:#e9edef;--text2:#8696a0;
--border:#222d34;--accent:#00a884;--accent2:#06cf9c;--pill-bg:#182229;
--header:#202c33;--link:#53bdeb;--shadow:0 1px 2px rgba(0,0,0,.3);
--sys-bg:#182229;--sys-text:#8696a0;
}
@media(prefers-color-scheme:light){:root{
--bg:#efeae2;--bg-chat:#efeae2;--msg-own:#d9fdd3;--msg-other:#fff;
--msg-own-h:#c8f0c2;--msg-other-h:#f5f5f5;--text:#111b21;--text2:#667781;
--border:#e9edef;--accent:#008069;--accent2:#00a884;--pill-bg:#fff;
--header:#008069;--link:#027eb5;--shadow:0 1px 3px rgba(0,0,0,.12);
--sys-bg:rgba(255,255,255,.9);--sys-text:#54656f;
}}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
background:var(--bg);color:var(--text);line-height:1.45;font-size:14.2px}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-thumb{background:#374045;border-radius:3px}
.app{max-width:1200px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column;
background:var(--bg-chat);box-shadow:0 0 60px rgba(0,0,0,.3)}
.header{background:var(--header);padding:14px 20px;display:flex;align-items:center;
gap:14px;position:sticky;top:0;z-index:100;border-bottom:1px solid var(--border)}
.header-avatar{width:42px;height:42px;border-radius:50%;background:var(--accent);
display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;flex-shrink:0}
.header-info h1{font-size:16px;font-weight:600;color:#fff}
.header-info .sub{font-size:12px;color:rgba(255,255,255,.7);margin-top:1px}
.chat{flex:1;padding:8px 40px 20px;overflow-y:auto}
.date-separator{display:flex;justify-content:center;margin:16px 0 8px}
.date-pill{background:var(--pill-bg);color:var(--text2);padding:5px 12px;
border-radius:8px;font-size:12.5px;box-shadow:var(--shadow)}
.msg-group{margin-bottom:2px;display:flex}
.msg-group.own{justify-content:flex-end}
.msg-group.other{justify-content:flex-start}
.msg{max-width:75%;min-width:120px;padding:6px 7px 8px 9px;border-radius:8px;
position:relative;box-shadow:var(--shadow);word-wrap:break-word;overflow-wrap:break-word}
.msg-group.own .msg{background:var(--msg-own);border-top-right-radius:0}
.msg-group.other .msg{background:var(--msg-other);border-top-left-radius:0}
.msg-group.own .msg:hover{background:var(--msg-own-h)}
.msg-group.other .msg:hover{background:var(--msg-other-h)}
.msg .sender{font-size:13px;font-weight:600;margin-bottom:2px;display:block}
.msg .body{white-space:pre-wrap;font-size:14.2px;line-height:1.45;overflow-x:auto}
.msg .body pre{background:rgba(0,0,0,.2);padding:8px 10px;border-radius:4px;
overflow-x:auto;white-space:pre;font-size:13px;line-height:1.4;margin:4px 0}
.msg .body code{background:rgba(0,0,0,.15);padding:1px 4px;border-radius:3px;font-size:13px}
.msg .body pre code{background:none;padding:0}
.msg .body a{color:var(--link);text-decoration:none}
.msg .body a:hover{text-decoration:underline}
.msg .meta{display:flex;justify-content:flex-end;gap:4px;margin-top:2px;
float:right;margin-left:12px;position:relative;top:5px}
.msg .meta .time{font-size:11px;color:var(--text2)}
.msg .media-wrap{margin:4px -3px;border-radius:6px;overflow:hidden}
.msg .media-wrap img{max-width:100%;max-height:400px;display:block;cursor:pointer;
border-radius:6px;transition:opacity .15s}
.msg .media-wrap img:hover{opacity:.92}
.msg .media-wrap video{max-width:100%;max-height:400px;display:block;border-radius:6px;background:#000}
.msg .media-wrap audio{width:100%;min-width:240px;height:40px;margin:4px 0}
.msg .file-attach{display:flex;align-items:center;gap:10px;padding:8px 10px;
background:rgba(0,0,0,.1);border-radius:6px;margin:4px 0;text-decoration:none;
color:var(--text);transition:background .15s}
.msg .file-attach:hover{background:rgba(0,0,0,.18)}
.msg .file-attach .file-icon{font-size:28px;flex-shrink:0}
.msg .file-attach .file-name{font-size:13.5px;font-weight:500;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.msg .file-attach .file-size{font-size:11.5px;color:var(--text2)}
.system-event{display:flex;justify-content:center;margin:6px 0}
.system-event span{background:var(--sys-bg);color:var(--sys-text);padding:4px 12px;
border-radius:6px;font-size:12px;box-shadow:var(--shadow);text-align:center}
.emote-msg{font-style:italic;color:var(--text2)}
.redacted,.encrypted-msg{font-style:italic;color:var(--text2);font-size:13px}
.footer{background:var(--header);padding:12px 20px;text-align:center;
font-size:12px;color:var(--text2);border-top:1px solid var(--border)}
.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center}
.lightbox.active{display:flex}
.lightbox img{max-width:95%;max-height:95%;object-fit:contain;border-radius:4px}
.scroll-top{position:fixed;bottom:20px;right:20px;width:44px;height:44px;
border-radius:50%;background:var(--accent);color:#fff;border:none;cursor:pointer;
display:none;align-items:center;justify-content:center;font-size:20px;
box-shadow:0 2px 10px rgba(0,0,0,.3);z-index:50}
.scroll-top.visible{display:flex}
#loading{display:flex;justify-content:center;align-items:center;padding:40px;color:var(--text2);font-size:15px}
@media(max-width:600px){.chat{padding:8px 12px 20px}.msg{max-width:92%}}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div class="header-avatar">&#128172;</div>
<div class="header-info">
<h1 id="room-name">Lade...</h1>
<div class="sub" id="room-info"></div>
</div>
</header>
<div class="chat" id="chat">
<div id="loading">Nachrichten werden geladen...</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="">
</div>
<button class="scroll-top" id="scrollTop" title="Nach oben">&#8593;</button>
<div class="footer" id="footer"></div>
<script>
var D = __DATA__;
document.title = "Chat: " + D.roomName;
document.getElementById("room-name").textContent = D.roomName;
document.getElementById("room-info").textContent = D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate;
document.getElementById("footer").innerHTML = "Matrix Chat Export · " + D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate + " · Erstellt am " + D.exportDate;
var CHUNK = 200;
var rendered = 0;
var lastSender = "";
var chat = document.getElementById("chat");
var msgs = D.messages;
document.getElementById("loading").style.display = "none";
function esc(s) {
if (!s) return "";
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function linkify(t) {
return t.replace(/(https?:\/\/[^\s<>&]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
}
function renderBody(m) {
var mt = m.mt;
if (mt === "m.text" || mt === "m.notice") {
return m.fb ? '<div class="body">' + m.fb + "</div>"
: '<div class="body">' + linkify(esc(m.b)) + "</div>";
}
if (mt === "m.emote") {
return '<div class="body emote-msg">* ' + esc(m.b) + "</div>";
}
if (mt === "m.image") {
if (m.m) return '<div class="media-wrap"><a href="media/' + m.m + '" target="_blank"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" loading="lazy" onclick="openLightbox(this.src);return false"></a></div>';
return '<div class="body">[Bild: ' + esc(m.b) + "]</div>";
}
if (mt === "m.video") {
if (m.m) return '<div class="media-wrap"><video controls preload="metadata" src="media/' + m.m + '"></video></div>';
return '<div class="body">[Video: ' + esc(m.b) + "]</div>";
}
if (mt === "m.audio") {
var label = m.voice ? "🎤 Sprachnachricht" : "🎵 " + esc(m.b);
if (m.m) return '<div class="body" style="font-size:12.5px;color:var(--text2);margin-bottom:2px">' + label + '</div><div class="media-wrap"><audio controls preload="metadata" src="media/' + m.m + '"></audio></div>';
return '<div class="body">[Audio: ' + esc(m.b) + "]</div>";
}
if (mt === "m.file") {
if (m.m) return '<a class="file-attach" href="media/' + m.m + '" download="' + esc(m.b) + '"><span class="file-icon">&#128206;</span><div class="file-info"><div class="file-name">' + esc(m.b) + '</div><div class="file-size">' + (m.fs || "") + "</div></div></a>";
return '<div class="body">[Datei: ' + esc(m.b) + "]</div>";
}
if (mt === "m.location") {
if (m.geo) {
var coords = m.geo.replace("geo:", "").split(";")[0];
var p = coords.split(",");
return '<div class="body">&#128205; <a href="https://www.openstreetmap.org/?mlat=' + p[0] + "&mlon=" + (p[1] || "0") + "#map=16/" + p[0] + "/" + (p[1] || "0") + '" target="_blank" rel="noopener">' + esc(m.b) + "</a></div>";
}
return '<div class="body">&#128205; ' + esc(m.b) + "</div>";
}
return '<div class="body">' + esc(m.b || "") + "</div>";
}
function renderMessage(m) {
if (m.t === "date") {
lastSender = "";
return '<div class="date-separator"><span class="date-pill">' + esc(m.text) + "</span></div>";
}
if (m.t === "sys") {
lastSender = "";
return '<div class="system-event"><span>' + esc(m.text) + "</span></div>";
}
if (m.t === "avatar") {
lastSender = "";
var inner = "";
if (m.m) {
inner += '<img src="media/' + m.m + '" style="width:28px;height:28px;border-radius:50%;vertical-align:middle;margin-right:6px" loading="lazy">';
}
inner += esc(m.text);
return '<div class="system-event"><span>' + inner + "</span></div>";
}
if (m.t === "enc") {
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg"><div class="body encrypted-msg">&#128274; Verschlüsselte Nachricht</div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "sticker") {
if (!m.m) return "";
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg" style="background:transparent;box-shadow:none;padding:4px"><div class="media-wrap"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" style="max-width:160px;max-height:160px" loading="lazy"></div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "msg") {
var cls = m.o ? "own" : "other";
var sender = "";
if (!m.o && m.s !== lastSender) {
sender = '<span class="sender" style="color:' + m.c + '">' + esc(m.n) + "</span>";
}
lastSender = m.s;
var body = m.del ? '<div class="body redacted">Diese Nachricht wurde gelöscht</div>' : renderBody(m);
return '<div class="msg-group ' + cls + '"><div class="msg">' + sender + body + '<div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
return "";
}
function renderChunk() {
var end = Math.min(rendered + CHUNK, msgs.length);
var html = "";
for (var i = rendered; i < end; i++) {
html += renderMessage(msgs[i]);
}
var sentinel = document.getElementById("sentinel");
if (sentinel) sentinel.remove();
var frag = document.createElement("div");
frag.innerHTML = html;
while (frag.firstChild) chat.appendChild(frag.firstChild);
rendered = end;
if (rendered < msgs.length) {
var s = document.createElement("div");
s.id = "sentinel";
s.style.height = "1px";
chat.appendChild(s);
observer.observe(s);
}
}
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) renderChunk();
}, {rootMargin: "1200px"});
renderChunk();
function openLightbox(s) {
var l = document.getElementById("lightbox");
document.getElementById("lightbox-img").src = s;
l.classList.add("active");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("active");
}
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closeLightbox();
});
var scrollBtn = document.getElementById("scrollTop");
window.addEventListener("scroll", function() {
scrollBtn.classList.toggle("visible", window.scrollY > 800);
});
scrollBtn.addEventListener("click", function() {
window.scrollTo({top: 0, behavior: "smooth"});
});
</script>
</body>
</html>