Add TypeScript migration, image resizing, media upload UX, and multimedia support
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s
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:
1
backend/static-ts/.gitignore
vendored
Normal file
1
backend/static-ts/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
95
backend/static-ts/autoplay.ts
Normal file
95
backend/static-ts/autoplay.ts
Normal 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
31
backend/static-ts/day.ts
Normal 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;
|
||||
}
|
||||
})();
|
||||
81
backend/static-ts/editor.ts
Normal file
81
backend/static-ts/editor.ts
Normal 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
30
backend/static-ts/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
backend/static-ts/package.json
Normal file
17
backend/static-ts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
backend/static-ts/tsconfig.json
Normal file
13
backend/static-ts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user