sojorn/go-backend/internal/handlers/media_handler.go
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

185 lines
4.5 KiB
Go

package handlers
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// MediaHandler uploads media to Cloudflare R2. If s3Client is provided, it uses
// the S3-compatible API. Otherwise, it falls back to Cloudflare R2 HTTP API
// using account ID + API token from the config.
type MediaHandler struct {
s3Client *s3.Client
useS3 bool
accountID string
apiToken string
bucket string
videoBucket string
publicDomain string
videoDomain string
}
func NewMediaHandler(s3Client *s3.Client, accountID string, apiToken string, bucket string, videoBucket string, publicDomain string, videoDomain string) *MediaHandler {
return &MediaHandler{
s3Client: s3Client,
useS3: s3Client != nil,
accountID: accountID,
apiToken: apiToken,
bucket: bucket,
videoBucket: videoBucket,
publicDomain: publicDomain,
videoDomain: videoDomain,
}
}
func (h *MediaHandler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("media")
if err != nil {
fileHeader, err = c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No media file found"})
return
}
}
mediaType := c.PostForm("type")
if mediaType == "" {
if strings.HasPrefix(fileHeader.Header.Get("Content-Type"), "video/") {
mediaType = "video"
} else {
mediaType = "image"
}
}
ext := filepath.Ext(fileHeader.Filename)
if ext == "" {
if mediaType == "video" {
ext = ".mp4"
} else {
ext = ".jpg"
}
}
userID := c.GetString("user_id")
if userID == "" {
userID = "anon"
}
objectKey := fmt.Sprintf("uploads/%s/%s%s", userID, uuid.New().String(), ext)
targetBucket := h.bucket
targetDomain := h.publicDomain
if mediaType == "video" {
if h.videoBucket != "" {
targetBucket = h.videoBucket
}
if h.videoDomain != "" {
targetDomain = h.videoDomain
}
}
var publicURL string
if h.useS3 {
publicURL, err = h.putObjectS3(c, fileHeader, targetBucket, objectKey, targetDomain)
} else {
publicURL, err = h.putObjectR2API(c, fileHeader, targetBucket, objectKey, targetDomain)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to upload media: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"url": publicURL,
"publicUrl": publicURL,
"signedUrl": publicURL,
"fileName": objectKey,
"file_name": objectKey,
"fileSize": fileHeader.Size,
"file_size": fileHeader.Size,
"type": mediaType,
})
}
func (h *MediaHandler) putObjectS3(c *gin.Context, fileHeader *multipart.FileHeader, bucket string, key string, publicDomain string) (string, error) {
src, err := fileHeader.Open()
if err != nil {
return "", err
}
defer src.Close()
ctx := c.Request.Context()
_, err = h.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: src,
ContentType: &[]string{fileHeader.Header.Get("Content-Type")}[0],
})
if err != nil {
return "", err
}
if publicDomain != "" {
return fmt.Sprintf("https://%s/%s", publicDomain, key), nil
}
// Fallback to path (relative); AssetService can sign it later.
return key, nil
}
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileHeader *multipart.FileHeader, bucket string, key string, publicDomain string) (string, error) {
if h.accountID == "" || h.apiToken == "" {
return "", fmt.Errorf("R2 API credentials missing")
}
src, err := fileHeader.Open()
if err != nil {
return "", err
}
defer src.Close()
fileBytes, err := io.ReadAll(src)
if err != nil {
return "", err
}
endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/r2/buckets/%s/objects/%s",
h.accountID, bucket, key)
req, err := http.NewRequestWithContext(c.Request.Context(), "PUT", endpoint, bytes.NewReader(fileBytes))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+h.apiToken)
req.Header.Set("Content-Type", fileHeader.Header.Get("Content-Type"))
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("R2 upload failed (%d): %s", resp.StatusCode, string(body))
}
if publicDomain != "" {
return fmt.Sprintf("https://%s/%s", publicDomain, key), nil
}
return fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", h.accountID, bucket, key), nil
}