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:
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user