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:
@@ -2,8 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,10 +20,17 @@ const (
|
||||
)
|
||||
|
||||
var allowedMIME = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/heic": ".heic",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/heic": ".heic",
|
||||
"image/gif": ".gif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/aac": ".aac",
|
||||
}
|
||||
|
||||
type JournalHandler struct {
|
||||
@@ -108,51 +115,8 @@ func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Handle new image uploads
|
||||
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||
files := r.MultipartForm.File["images"]
|
||||
for _, fh := range files {
|
||||
if fh.Size == 0 || fh.Size > maxSingleImage {
|
||||
continue
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
mime := http.DetectContentType(buf[:n])
|
||||
ext, ok := allowedMIME[mime]
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
filename := sanitizeFilename(entryID+"_"+fh.Filename) + ext
|
||||
destPath := filepath.Join(h.uploadDir, filename)
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
if _, err := out.Write(buf[:n]); err != nil {
|
||||
out.Close(); f.Close(); os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
out.Close(); f.Close(); os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
out.Close()
|
||||
f.Close()
|
||||
img := domain.JournalImage{
|
||||
EntryID: entryID, Filename: filename,
|
||||
OriginalName: fh.Filename, MimeType: mime, SizeBytes: fh.Size,
|
||||
}
|
||||
if _, err := h.store.InsertImage(r.Context(), img); err != nil {
|
||||
slog.Error("insert image", "entry_id", entryID, "err", err)
|
||||
os.Remove(destPath)
|
||||
}
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
h.saveJournalImages(r, entryID, r.MultipartForm.File["images"])
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther)
|
||||
@@ -219,71 +183,56 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Handle image uploads
|
||||
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||
files := r.MultipartForm.File["images"]
|
||||
for _, fh := range files {
|
||||
if fh.Size > maxSingleImage {
|
||||
continue // skip oversized images silently
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect MIME type from first 512 bytes
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
mime := http.DetectContentType(buf[:n])
|
||||
ext, ok := allowedMIME[mime]
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
filename := saved.EntryID + "_" + fh.Filename
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
destPath := filepath.Join(h.uploadDir, filename)
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Write already-read bytes + rest; clean up file on any write error
|
||||
if _, err := out.Write(buf[:n]); err != nil {
|
||||
out.Close()
|
||||
f.Close()
|
||||
os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
out.Close()
|
||||
f.Close()
|
||||
os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
out.Close()
|
||||
f.Close()
|
||||
|
||||
img := domain.JournalImage{
|
||||
EntryID: saved.EntryID,
|
||||
Filename: filename,
|
||||
OriginalName: fh.Filename,
|
||||
MimeType: mime,
|
||||
SizeBytes: fh.Size,
|
||||
}
|
||||
if _, err := h.store.InsertImage(r.Context(), img); err != nil {
|
||||
slog.Error("insert image", "entry_id", saved.EntryID, "filename", filename, "err", err)
|
||||
os.Remove(destPath)
|
||||
}
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
h.saveJournalImages(r, saved.EntryID, r.MultipartForm.File["images"])
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// saveJournalImages saves uploaded files for a journal entry, with image resizing.
|
||||
// Errors per file are logged and silently skipped so the entry is always saved.
|
||||
func (h *JournalHandler) saveJournalImages(r *http.Request, entryID string, files []*multipart.FileHeader) {
|
||||
ctx := r.Context()
|
||||
for _, fh := range files {
|
||||
if fh.Size == 0 || fh.Size > maxSingleImage {
|
||||
continue
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
mime := http.DetectContentType(buf[:n])
|
||||
if _, ok := allowedMIME[mime]; !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
baseName := sanitizeFilename(entryID + "_" + fh.Filename)
|
||||
filename, err := saveUpload(h.uploadDir, baseName, mime, buf[:n], f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
slog.Error("save upload", "entry_id", entryID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
img := domain.JournalImage{
|
||||
EntryID: entryID,
|
||||
Filename: filename,
|
||||
OriginalName: fh.Filename,
|
||||
MimeType: mime,
|
||||
SizeBytes: fh.Size,
|
||||
}
|
||||
if _, err := h.store.InsertImage(ctx, img); err != nil {
|
||||
slog.Error("insert image", "entry_id", entryID, "err", err)
|
||||
os.Remove(filepath.Join(h.uploadDir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename strips path separators and non-printable characters.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
|
||||
76
backend/internal/api/media.go
Normal file
76
backend/internal/api/media.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MediaHandler struct {
|
||||
uploadDir string
|
||||
}
|
||||
|
||||
func NewMediaHandler(uploadDir string) *MediaHandler {
|
||||
return &MediaHandler{uploadDir: uploadDir}
|
||||
}
|
||||
|
||||
// HandleUpload handles POST /media — uploads a single file and returns its markdown reference.
|
||||
func (h *MediaHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
fh, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, _ := fh.Read(buf)
|
||||
mime := http.DetectContentType(buf[:n])
|
||||
if _, ok := allowedMIME[mime]; !ok {
|
||||
http.Error(w, "unsupported type", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := saveUpload(h.uploadDir, randomID(), mime, buf[:n], fh)
|
||||
if err != nil {
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ref := markdownRef(mime, filename)
|
||||
slog.Info("media uploaded", "filename", filename, "mime", mime)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"filename": filename,
|
||||
"mime": mime,
|
||||
"ref": ref,
|
||||
})
|
||||
}
|
||||
|
||||
func markdownRef(mime, filename string) string {
|
||||
url := "/uploads/" + filename
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return ""
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return ""
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return "[" + filename + "](" + url + ")"
|
||||
default:
|
||||
return "[" + filename + "](" + url + ")"
|
||||
}
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 12)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func NewRouter(
|
||||
|
||||
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
|
||||
journalHandler := NewJournalHandler(journalStore, uploadDir)
|
||||
mediaHandler := NewMediaHandler(uploadDir)
|
||||
authMW := RequireAuth(authStore)
|
||||
|
||||
// Health
|
||||
@@ -70,6 +71,7 @@ func NewRouter(
|
||||
r.Get("/days", webUI.HandleDaysList)
|
||||
r.Get("/days/redirect", webUI.HandleDaysRedirect)
|
||||
r.Get("/days/{date}", webUI.HandleDayDetail)
|
||||
r.Post("/media", mediaHandler.HandleUpload)
|
||||
r.Post("/entries", journalHandler.HandleCreateEntry)
|
||||
r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry)
|
||||
r.Post("/entries/{id}", journalHandler.HandleUpdateEntry)
|
||||
|
||||
103
backend/internal/api/static/autoplay.js
Normal file
103
backend/internal/api/static/autoplay.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
84
backend/internal/api/static/editor.js
Normal file
84
backend/internal/api/static/editor.js
Normal 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);
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</nav>
|
||||
{{block "content" .}}{{end}}
|
||||
{{block "scripts" .}}{{end}}
|
||||
<script src="/static/autoplay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="title" placeholder="Überschrift">
|
||||
<textarea name="description" rows="4" placeholder="Beschreibung"></textarea>
|
||||
<div class="editor-wrap">
|
||||
<textarea name="description" rows="6" placeholder="Beschreibung — Markdown unterstützt Medien: per Drag & Drop oder Einfügen (Strg+V)"></textarea>
|
||||
<div class="editor-bar">
|
||||
<button type="button" class="media-picker">📎 Datei anhängen</button>
|
||||
<span class="upload-status"></span>
|
||||
<input type="file" class="media-file-input" multiple accept="image/*,video/*,audio/*" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
<div class="gps-row">
|
||||
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite">
|
||||
<input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge">
|
||||
@@ -23,8 +30,6 @@
|
||||
</div>
|
||||
<small id="gps-status"></small>
|
||||
<input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)">
|
||||
<input type="file" name="images" multiple accept="image/*" id="image-input">
|
||||
<div id="image-preview" class="image-preview"></div>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
|
||||
@@ -38,15 +43,21 @@
|
||||
<a href="/entries/{{.EntryID}}/edit" class="entry-edit">bearbeiten</a>
|
||||
</div>
|
||||
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
|
||||
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
|
||||
{{if .Description}}<div class="entry-desc">{{markdown .Description}}</div>{{end}}
|
||||
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
|
||||
{{if .Images}}
|
||||
<div class="entry-images">
|
||||
{{range .Images}}
|
||||
{{if isVideo .MimeType}}
|
||||
<video src="/uploads/{{.Filename}}" controls class="media-embed"></video>
|
||||
{{else if isAudio .MimeType}}
|
||||
<audio src="/uploads/{{.Filename}}" controls class="media-audio"></audio>
|
||||
{{else}}
|
||||
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -96,6 +107,7 @@
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/day.js"></script>
|
||||
<script src="/static/editor.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="title" placeholder="Überschrift" value="{{.Entry.Title}}">
|
||||
<textarea name="description" rows="4" placeholder="Beschreibung">{{.Entry.Description}}</textarea>
|
||||
<div class="editor-wrap">
|
||||
<textarea name="description" rows="6" placeholder="Beschreibung — Markdown unterstützt Medien: per Drag & Drop oder Einfügen (Strg+V)">{{.Entry.Description}}</textarea>
|
||||
<div class="editor-bar">
|
||||
<button type="button" class="media-picker">📎 Datei anhängen</button>
|
||||
<span class="upload-status"></span>
|
||||
<input type="file" class="media-file-input" multiple accept="image/*,video/*,audio/*" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
<div class="gps-row">
|
||||
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite"{{if .Entry.Lat}} value="{{printf "%.6f" (deref .Entry.Lat)}}"{{end}}>
|
||||
<input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge"{{if .Entry.Lon}} value="{{printf "%.6f" (deref .Entry.Lon)}}"{{end}}>
|
||||
@@ -23,16 +30,22 @@
|
||||
<small id="gps-status"></small>
|
||||
<input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)" value="{{join .Entry.Hashtags ", "}}">
|
||||
{{if .Entry.Images}}
|
||||
<div class="entry-images" style="margin-bottom:.5rem">
|
||||
<div class="media-refs">
|
||||
{{range .Entry.Images}}
|
||||
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||
<div class="media-ref-row">
|
||||
{{if isVideo .MimeType}}
|
||||
<code class="media-ref-code"></code>
|
||||
{{else if isAudio .MimeType}}
|
||||
<code class="media-ref-code">[{{.OriginalName}}](/uploads/{{.Filename}})</code>
|
||||
{{else}}
|
||||
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
|
||||
</a>
|
||||
<code class="media-ref-code"></code>
|
||||
{{end}}
|
||||
<button type="button" class="btn-insert" data-ref="{{if isVideo .MimeType}}{{else if isAudio .MimeType}}[{{.OriginalName}}](/uploads/{{.Filename}}){{else}}{{end}}">↩ einfügen</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<input type="file" name="images" multiple accept="image/*" id="image-input">
|
||||
<div id="image-preview" class="image-preview"></div>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</main>
|
||||
@@ -40,6 +53,7 @@
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/day.js"></script>
|
||||
<script src="/static/editor.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
@@ -12,14 +12,22 @@
|
||||
{{range .Entries}}
|
||||
<article class="pub-card">
|
||||
{{if .Images}}
|
||||
<a href="/uploads/{{(index .Images 0).Filename}}" target="_blank">
|
||||
<img class="pub-cover" src="/uploads/{{(index .Images 0).Filename}}" alt="">
|
||||
{{with (index .Images 0)}}
|
||||
{{if isVideo .MimeType}}
|
||||
<video src="/uploads/{{.Filename}}" controls class="media-embed"></video>
|
||||
{{else if isAudio .MimeType}}
|
||||
<audio src="/uploads/{{.Filename}}" controls class="media-audio"></audio>
|
||||
{{else}}
|
||||
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||
<img class="pub-cover" src="/uploads/{{.Filename}}" alt="">
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
<div class="pub-body">
|
||||
<small class="pub-meta">{{.EntryDate}} · {{.EntryTime}}</small>
|
||||
{{if .Title}}<strong class="pub-title">{{.Title}}</strong>{{end}}
|
||||
{{if .Description}}<p class="pub-desc">{{.Description}}</p>{{end}}
|
||||
{{if .Description}}<div class="pub-desc">{{markdown .Description}}</div>{{end}}
|
||||
{{if .Hashtags}}<div class="pub-tags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
125
backend/internal/api/upload.go
Normal file
125
backend/internal/api/upload.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxImageDimension = 1920
|
||||
const jpegQuality = 80
|
||||
|
||||
// saveUpload writes an uploaded file to uploadDir, resizing images where applicable.
|
||||
// peeked contains the first bytes already read for MIME detection.
|
||||
// Returns the saved filename (including extension).
|
||||
func saveUpload(uploadDir, baseName, mime string, peeked []byte, rest io.Reader) (string, error) {
|
||||
full := io.MultiReader(bytes.NewReader(peeked), rest)
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png":
|
||||
return saveResizedImage(uploadDir, baseName, mime, full)
|
||||
case "image/webp":
|
||||
return saveResizedWebP(uploadDir, baseName, full)
|
||||
default:
|
||||
ext := allowedMIME[mime]
|
||||
dest := filepath.Join(uploadDir, baseName+ext)
|
||||
return baseName + ext, saveRaw(dest, full)
|
||||
}
|
||||
}
|
||||
|
||||
func saveResizedImage(uploadDir, baseName, mime string, r io.Reader) (string, error) {
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
// Fallback: save raw if decode fails
|
||||
slog.Warn("image decode failed, saving raw", "mime", mime, "err", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
|
||||
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
|
||||
}
|
||||
|
||||
ext := allowedMIME[mime]
|
||||
filename := baseName + ext
|
||||
dest := filepath.Join(uploadDir, filename)
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
switch mime {
|
||||
case "image/jpeg":
|
||||
err = jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality})
|
||||
case "image/png":
|
||||
err = png.Encode(out, img)
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func saveResizedWebP(uploadDir, baseName string, r io.Reader) (string, error) {
|
||||
img, err := webp.Decode(r)
|
||||
if err != nil {
|
||||
slog.Warn("webp decode failed, saving raw", "err", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
|
||||
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Re-encode as JPEG (no pure-Go WebP encoder available)
|
||||
filename := baseName + ".jpg"
|
||||
dest := filepath.Join(uploadDir, filename)
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if err := jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func saveRaw(dest string, r io.Reader) error {
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
// mimeCategory returns "image", "video", "audio" or "other".
|
||||
func mimeCategory(mime string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return "video"
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return "audio"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,28 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/jacek/pamietnik/backend/internal/auth"
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
)
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithHardWraps()),
|
||||
)
|
||||
|
||||
func renderMarkdown(src string) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(src), &buf); err != nil {
|
||||
return template.HTML(template.HTMLEscapeString(src))
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
//go:embed static templates
|
||||
var assets embed.FS
|
||||
|
||||
@@ -28,7 +46,11 @@ var funcMap = template.FuncMap{
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"join": strings.Join,
|
||||
"join": strings.Join,
|
||||
"isVideo": func(mime string) bool { return strings.HasPrefix(mime, "video/") },
|
||||
"isAudio": func(mime string) bool { return strings.HasPrefix(mime, "audio/") },
|
||||
"isImage": func(mime string) bool { return strings.HasPrefix(mime, "image/") },
|
||||
"markdown": renderMarkdown,
|
||||
}
|
||||
|
||||
var tmpls = template.Must(
|
||||
|
||||
Reference in New Issue
Block a user