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,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"
}
}