Add TypeScript migration, image resizing, media upload UX, and multimedia support
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s

- Migrate static JS to TypeScript (static-ts/ → compiled to internal/api/static/)
- Add image resizing on upload: JPEG/PNG/WebP scaled to max 1920px at quality 80
- Extract shared upload logic into upload.go (saveUpload, saveResizedImage, saveResizedWebP)
- Add POST /media endpoint for drag-drop/paste media uploads with markdown ref return
- Add background music player with video/audio coordination (autoplay.ts)
- Add global nav, public feed, hashtags, visibility, Markdown rendering for entries
- Add Dockerfile stage for TypeScript compilation (static-ts-builder)
- Add goldmark, disintegration/imaging, golang.org/x/image dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-09 23:03:04 +02:00
parent 8eef933573
commit 17186e7b64
24 changed files with 890 additions and 179 deletions

View File

@@ -0,0 +1,103 @@
"use strict";
(function () {
'use strict';
/* ── Background player ───────────────────────────────────── */
const bgAudio = new Audio();
let bgPlaying = false;
let bgBar = null;
let bgTitle = null;
let bgPlayBtn = null;
function createBgBar() {
var _a;
if (bgBar)
return;
bgBar = document.createElement('div');
bgBar.id = 'bg-bar';
bgBar.innerHTML =
'<span id="bg-title"></span>' +
'<button id="bg-play" aria-label="Abspielen">▶</button>' +
'<button id="bg-close" aria-label="Schließen">✕</button>';
document.body.appendChild(bgBar);
bgTitle = document.getElementById('bg-title');
bgPlayBtn = document.getElementById('bg-play');
bgPlayBtn.addEventListener('click', function () {
if (bgAudio.paused)
void bgAudio.play();
else
bgAudio.pause();
});
(_a = document.getElementById('bg-close')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () {
bgAudio.pause();
if (bgBar)
bgBar.style.display = 'none';
});
bgAudio.addEventListener('play', function () { if (bgPlayBtn)
bgPlayBtn.textContent = '⏸'; });
bgAudio.addEventListener('pause', function () { if (bgPlayBtn)
bgPlayBtn.textContent = '▶'; });
bgAudio.addEventListener('ended', function () { if (bgPlayBtn)
bgPlayBtn.textContent = '▶'; });
}
function sendToBg(src, title) {
createBgBar();
if (bgBar)
bgBar.style.display = 'flex';
bgAudio.src = src;
if (bgTitle)
bgTitle.textContent = title;
void bgAudio.play();
}
// Attach "♪" button to every inline audio player
document.querySelectorAll('audio.media-audio').forEach(function (a) {
const btn = document.createElement('button');
btn.className = 'btn-bg-music';
btn.textContent = '♪ Hintergrundmusik';
btn.type = 'button';
const title = a.title || a.src.split('/').pop() || a.src;
btn.addEventListener('click', function () { sendToBg(a.src, title); });
a.insertAdjacentElement('afterend', btn);
});
/* ── Video autoplay + coordination ──────────────────────── */
const obs = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
const v = e.target;
if (e.isIntersecting) {
void v.play();
}
else {
v.pause();
}
});
}, { threshold: 0.3 });
document.querySelectorAll('video.media-embed').forEach(function (v) {
v.muted = true;
v.loop = true;
v.setAttribute('playsinline', '');
obs.observe(v);
// User unmutes → pause background music
v.addEventListener('volumechange', function () {
if (!v.muted && !v.paused) {
bgPlaying = !bgAudio.paused;
bgAudio.pause();
}
// Video muted again → resume background
if (v.muted && bgPlaying) {
void bgAudio.play();
bgPlaying = false;
}
});
// Video pauses or ends → resume background if it was playing
v.addEventListener('pause', function () {
if (bgPlaying) {
void bgAudio.play();
bgPlaying = false;
}
});
v.addEventListener('ended', function () {
if (bgPlaying) {
void bgAudio.play();
bgPlaying = false;
}
});
});
})();

View File

@@ -1,47 +1,28 @@
"use strict";
var _a;
// GPS button
document.getElementById('btn-gps')?.addEventListener('click', function () {
const status = document.getElementById('gps-status');
if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar';
return;
}
status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition(
function (pos) {
document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6);
document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6);
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
},
function (err) {
status.textContent = '// Fehler: ' + err.message;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
(_a = document.getElementById('btn-gps')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () {
const status = document.getElementById('gps-status');
if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar';
return;
}
status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition(function (pos) {
document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6);
document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6);
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
}, function (err) {
status.textContent = '// Fehler: ' + err.message;
}, { enableHighAccuracy: true, timeout: 10000 });
});
// Set current time as default
(function () {
const input = document.getElementById('entry-time');
if (input && !input.value) {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
input.value = hh + ':' + mm;
}
const input = document.getElementById('entry-time');
if (input && !input.value) {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
input.value = hh + ':' + mm;
}
})();
// Image preview
document.getElementById('image-input')?.addEventListener('change', function () {
const preview = document.getElementById('image-preview');
preview.innerHTML = '';
Array.from(this.files).forEach(function (file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = function (e) {
const img = document.createElement('img');
img.src = e.target.result;
preview.appendChild(img);
};
reader.readAsDataURL(file);
});
});

View File

@@ -0,0 +1,84 @@
"use strict";
(function () {
'use strict';
function initEditor(ta) {
var _a, _b;
async function upload(file) {
var _a;
const form = new FormData();
form.append('file', file);
const statusEl = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.upload-status');
if (statusEl)
statusEl.textContent = '↑ ' + file.name + ' …';
try {
const res = await fetch('/media', { method: 'POST', body: form });
if (!res.ok) {
if (statusEl)
statusEl.textContent = '✗ Fehler beim Hochladen';
return;
}
const data = await res.json();
insertAtCursor('\n' + data.ref + '\n');
if (statusEl)
statusEl.textContent = '';
}
catch (_e) {
if (statusEl)
statusEl.textContent = '✗ Fehler beim Hochladen';
}
}
function insertAtCursor(text) {
const start = ta.selectionStart;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
}
// Paste: catch file pastes
ta.addEventListener('paste', function (e) {
var _a;
const items = (_a = e.clipboardData) === null || _a === void 0 ? void 0 : _a.items;
if (!items)
return;
for (let i = 0; i < items.length; i++) {
if (items[i].kind === 'file') {
e.preventDefault();
const file = items[i].getAsFile();
if (file)
void upload(file);
return;
}
}
});
// Drag & Drop onto textarea
ta.addEventListener('dragover', function (e) {
e.preventDefault();
ta.classList.add('drag-over');
});
ta.addEventListener('dragleave', function () {
ta.classList.remove('drag-over');
});
ta.addEventListener('drop', function (e) {
var _a;
e.preventDefault();
ta.classList.remove('drag-over');
const files = (_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.files;
if (!files)
return;
for (let i = 0; i < files.length; i++)
void upload(files[i]);
});
// File picker button
const picker = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.media-picker');
const input = (_b = ta.parentElement) === null || _b === void 0 ? void 0 : _b.querySelector('.media-file-input');
if (picker && input) {
picker.addEventListener('click', function () { input.click(); });
input.addEventListener('change', function () {
if (!input.files)
return;
Array.from(input.files).forEach(f => void upload(f));
input.value = '';
});
}
}
document.querySelectorAll('textarea[name="description"]').forEach(initEditor);
})();

View File

@@ -27,9 +27,6 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.source-gps { color: #060; }
.source-manual { color: #888; }
/* Top bar */
.page-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
/* GPS row */
.gps-row { display: flex; gap: .4rem; align-items: center; }
.gps-row input { flex: 1; margin-bottom: 0; }
@@ -38,10 +35,7 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
/* Narrow pages (login, register) */
.narrow { max-width: 400px; margin-top: 4rem; }
/* Image preview */
.image-preview { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .8rem; }
.image-preview img, .thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); }
.thumb { width: 100px; height: 100px; display: block; }
.thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); display: block; }
/* Journal entry cards */
.entry-card {
@@ -54,8 +48,16 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.entry-meta { font-size: .8rem; margin-bottom: .3rem; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; }
.entry-edit { margin-left: auto; font-size: .75rem; }
.entry-title { font-size: 1rem; margin-bottom: .3rem; }
.entry-desc { white-space: pre-wrap; font-size: .9rem; }
.entry-desc { font-size: .9rem; }
.entry-desc p { margin-bottom: .5rem; }
.entry-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; }
.entry-desc video { max-width: 100%; display: block; margin: .5rem 0; }
.entry-desc ul,
.entry-desc ol { padding-left: 1.2rem; margin-bottom: .5rem; }
.entry-desc h1, .entry-desc h2, .entry-desc h3 { font-weight: normal; margin: .8rem 0 .3rem; }
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
.media-embed { width: 100%; max-height: 360px; display: block; margin-top: .5rem; }
.media-audio { width: 100%; display: block; margin-top: .5rem; }
/* Public feed */
.pub-card { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--pico-muted-border-color); }
@@ -63,7 +65,10 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.pub-cover { width: 100%; max-height: 320px; object-fit: cover; display: block; margin-bottom: .7rem; }
.pub-meta { display: block; color: var(--pico-muted-color); margin-bottom: .3rem; }
.pub-title { display: block; font-size: 1rem; margin-bottom: .4rem; }
.pub-desc { margin: 0 0 .4rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; }
.pub-desc { margin: 0 0 .4rem; font-size: .9rem; }
.pub-desc p { margin-bottom: .5rem; }
.pub-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; }
.pub-desc video { max-width: 100%; display: block; margin: .5rem 0; }
.pub-tags { margin-top: .3rem; }
/* Login */
@@ -76,6 +81,25 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
/* Visibility badge */
.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; }
/* Background music player */
#bg-bar { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--pico-background-color); border-top: 1px solid var(--pico-muted-border-color); padding: .4rem 1rem; gap: .8rem; align-items: center; z-index: 100; font-size: .8rem; }
#bg-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pico-muted-color); }
#bg-play, #bg-close { background: none; border: none; cursor: pointer; padding: 0 .3rem; font-size: .9rem; margin: 0; }
.btn-bg-music { font-size: .75rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin-top: .3rem; display: block; }
/* Editor */
.editor-wrap textarea { margin-bottom: 0; border-bottom: none; border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0; }
.editor-wrap textarea.drag-over { outline: 2px dashed var(--pico-primary); }
.editor-bar { display: flex; align-items: center; gap: .6rem; padding: .3rem .5rem; border: 1px solid var(--pico-form-element-border-color); border-top: none; border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius); margin-bottom: 1rem; background: var(--pico-form-element-background-color); }
.editor-bar button { font-size: .78rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin: 0; }
.upload-status { font-size: .78rem; color: var(--pico-muted-color); }
/* Media reference rows (edit form) */
.media-refs { margin-bottom: 1rem; display: flex; flex-direction: column; gap: .5rem; }
.media-ref-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.media-ref-code { font-size: .75rem; background: var(--pico-muted-background-color); padding: .2rem .4rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-insert { font-size: .75rem; padding: .2rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; white-space: nowrap; }
/* Delete button */
.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; }
.btn-delete:hover { background: #c44; color: #fff; }