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

1
backend/static-ts/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -0,0 +1,95 @@
(function () {
'use strict';
/* ── Background player ───────────────────────────────────── */
const bgAudio = new Audio();
let bgPlaying = false;
let bgBar: HTMLElement | null = null;
let bgTitle: HTMLElement | null = null;
let bgPlayBtn: HTMLButtonElement | null = null;
function createBgBar(): void {
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') as HTMLButtonElement;
bgPlayBtn.addEventListener('click', function () {
if (bgAudio.paused) void bgAudio.play(); else bgAudio.pause();
});
document.getElementById('bg-close')?.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: string, title: string): void {
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<HTMLAudioElement>('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: IntersectionObserverEntry[]) {
entries.forEach(function (e) {
const v = e.target as HTMLVideoElement;
if (e.isIntersecting) {
void v.play();
} else {
v.pause();
}
});
}, { threshold: 0.3 });
document.querySelectorAll<HTMLVideoElement>('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; }
});
});
})();

31
backend/static-ts/day.ts Normal file
View File

@@ -0,0 +1,31 @@
// GPS button
document.getElementById('btn-gps')?.addEventListener('click', function () {
const status = document.getElementById('gps-status') as HTMLElement;
if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar';
return;
}
status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition(
function (pos: GeolocationPosition) {
(document.getElementById('entry-lat') as HTMLInputElement).value = pos.coords.latitude.toFixed(6);
(document.getElementById('entry-lon') as HTMLInputElement).value = pos.coords.longitude.toFixed(6);
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
},
function (err: GeolocationPositionError) {
status.textContent = '// Fehler: ' + err.message;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
// Set current time as default
(function () {
const input = document.getElementById('entry-time') as HTMLInputElement | null;
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;
}
})();

View File

@@ -0,0 +1,81 @@
(function () {
'use strict';
interface UploadResponse {
filename: string;
mime: string;
ref: string;
}
function initEditor(ta: HTMLTextAreaElement): void {
async function upload(file: File): Promise<void> {
const form = new FormData();
form.append('file', file);
const statusEl = ta.parentElement?.querySelector<HTMLElement>('.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: UploadResponse = await res.json();
insertAtCursor('\n' + data.ref + '\n');
if (statusEl) statusEl.textContent = '';
} catch (_e) {
if (statusEl) statusEl.textContent = '✗ Fehler beim Hochladen';
}
}
function insertAtCursor(text: string): void {
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: ClipboardEvent) {
const items = e.clipboardData?.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: DragEvent) {
e.preventDefault();
ta.classList.add('drag-over');
});
ta.addEventListener('dragleave', function () {
ta.classList.remove('drag-over');
});
ta.addEventListener('drop', function (e: DragEvent) {
e.preventDefault();
ta.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files) return;
for (let i = 0; i < files.length; i++) void upload(files[i]);
});
// File picker button
const picker = ta.parentElement?.querySelector<HTMLButtonElement>('.media-picker');
const input = ta.parentElement?.querySelector<HTMLInputElement>('.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<HTMLTextAreaElement>('textarea[name="description"]').forEach(initEditor);
})();

30
backend/static-ts/package-lock.json generated Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "static-ts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "static-ts",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"typescript": "^6.0.2"
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "static-ts",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"typescript": "^6.0.2"
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2017", "DOM"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"module": "None",
"ignoreDeprecations": "6.0",
"outDir": "../internal/api/static"
},
"include": ["./*.ts"]
}