**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.
185 lines
4.5 KiB
Go
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
|
|
}
|