diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts
index b0d163d..c89dd39 100644
--- a/app/app/api/posts/route.ts
+++ b/app/app/api/posts/route.ts
@@ -95,7 +95,7 @@ const POST = async (request: NextRequest) => {
const body = parsed.data;
- const { title, description, type, winnerCount, endsAt, proofRequired } =
+ const { title, description, type, winnerCount, endsAt, proofRequired, category } =
body as {
title?: string;
description?: string;
@@ -103,6 +103,7 @@ const POST = async (request: NextRequest) => {
winnerCount?: unknown;
endsAt?: string;
proofRequired?: unknown;
+ category?: string;
};
if (!title || title.length < 10 || title.length > 200) {
@@ -132,6 +133,7 @@ const POST = async (request: NextRequest) => {
title,
slug: uniqueSlug,
description,
+ category: category as any ?? null,
maxWinners: winnerCount ? Number(winnerCount) : null,
postRequirementsId: requirements.id,
endsAt: new Date(endsAt),
diff --git a/app/components/media-upload.tsx b/app/components/media-upload.tsx
index ce3968e..e16131f 100644
--- a/app/components/media-upload.tsx
+++ b/app/components/media-upload.tsx
@@ -5,24 +5,33 @@ import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
-import { Upload, X, ImageIcon, Video, FileText, Loader2 } from "lucide-react"
+import { Upload, X, ImageIcon, Video, FileText, Loader2, CheckCircle2 } from "lucide-react"
import { toast } from "sonner"
import type { PostMedia } from "@/lib/types"
import { validateMedia } from "@/lib/media-utils"
import { uploadFile } from "@/lib/storage"
+import { compressImage, generateThumbnail, formatFileSize } from "@/lib/image-compression"
// ── Constants ─────────────────────────────────────────────────
-const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10 MB
+const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10 MB (before compression)
const MAX_VIDEO_SIZE = 100 * 1024 * 1024 // 100 MB
const ALLOWED_IMAGE = ["image/jpeg", "image/png", "image/webp", "image/gif"]
const ALLOWED_VIDEO = ["video/mp4", "video/webm", "video/quicktime"]
// ── Types ─────────────────────────────────────────────────────
+interface CompressionInfo {
+ originalSize: number
+ compressedSize: number
+ savedPercent: number
+}
+
// Extends PostMedia with transient UI state that never leaves this component.
interface ExtendedPostMedia extends PostMedia {
- isUploading?: boolean
- error?: string
+ isUploading?: boolean
+ isCompressing?: boolean
+ error?: string
+ compressionInfo?: CompressionInfo
}
interface MediaUploadProps {
@@ -64,7 +73,6 @@ export function MediaUpload({
continue
}
- // 2. Additional size checks using the constants
const isImage = ALLOWED_IMAGE.includes(file.type)
const isVideo = ALLOWED_VIDEO.includes(file.type)
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE
@@ -77,15 +85,16 @@ export function MediaUpload({
continue
}
- // 3. Add placeholder so the grid updates immediately
const tempId = `${Date.now()}-${file.name}`
+ // 2. Add compressing placeholder for images
const placeholder: ExtendedPostMedia = {
- id: tempId,
- type: isImage ? "image" : "video",
- url: URL.createObjectURL(file), // temporary preview
- thumbnail: isImage ? URL.createObjectURL(file) : undefined,
- isUploading: true,
+ id: tempId,
+ type: isImage ? "image" : "video",
+ url: URL.createObjectURL(file),
+ thumbnail: isImage ? URL.createObjectURL(file) : undefined,
+ isUploading: false,
+ isCompressing: isImage,
}
setMedia((prev) => {
@@ -94,10 +103,59 @@ export function MediaUpload({
return updated
})
+ // 3. Compress images before upload (Issue #64)
+ let fileToUpload = file
+ let compressionInfo: CompressionInfo | undefined
+
+ if (isImage) {
+ try {
+ const result = await compressImage(file)
+ const thumb = await generateThumbnail(result.file)
+
+ fileToUpload = result.file
+ compressionInfo = {
+ originalSize: result.originalSize,
+ compressedSize: result.compressedSize,
+ savedPercent: result.savedPercent,
+ }
+
+ // Update preview to the compressed + thumbnail versions
+ setMedia((prev) =>
+ prev.map((item) =>
+ item.id === tempId
+ ? {
+ ...item,
+ url: URL.createObjectURL(result.file),
+ thumbnail: thumb.dataUrl,
+ isCompressing: false,
+ isUploading: true,
+ compressionInfo,
+ }
+ : item,
+ ),
+ )
+ } catch {
+ // Compression failed — proceed with original, clear compressing flag
+ setMedia((prev) =>
+ prev.map((item) =>
+ item.id === tempId
+ ? { ...item, isCompressing: false, isUploading: true }
+ : item,
+ ),
+ )
+ }
+ } else {
+ setMedia((prev) =>
+ prev.map((item) =>
+ item.id === tempId ? { ...item, isUploading: true } : item,
+ ),
+ )
+ }
+
// 4. Real upload via /api/uploads (S3-backed)
try {
const form = new FormData()
- form.append("file", file)
+ form.append("file", fileToUpload)
form.append("folder", isVideo ? "videos" : "images")
const res = await fetch("/api/uploads", { method: "POST", body: form })
@@ -107,22 +165,32 @@ export function MediaUpload({
throw new Error(body.error ?? "Upload failed")
}
- const { url, key } = await res.json() as { url: string; key: string }
+ const { url } = await res.json() as { url: string; key: string }
- // Replace placeholder with permanent CDN-backed item
setMedia((prev) => {
const updated = prev.map((item) =>
item.id === tempId
- ? { ...item, url, thumbnail: isImage ? url : undefined, isUploading: false }
+ ? {
+ ...item,
+ url,
+ thumbnail: isImage ? url : undefined,
+ isUploading: false,
+ compressionInfo: item.compressionInfo,
+ }
: item,
)
onMediaChange(updated)
return updated
})
+
+ if (compressionInfo && compressionInfo.savedPercent > 0) {
+ toast.success("Image optimised", {
+ description: `Saved ${compressionInfo.savedPercent}% — ${formatFileSize(compressionInfo.originalSize)} → ${formatFileSize(compressionInfo.compressedSize)}`,
+ })
+ }
} catch (err) {
const message = err instanceof Error ? err.message : "Please try again."
toast.error("Upload failed", { description: message })
- // Remove the failed placeholder
setMedia((prev) => {
const updated = prev.filter((item) => item.id !== tempId)
onMediaChange(updated)
@@ -160,6 +228,15 @@ export function MediaUpload({
return
- Images up to 10 MB · Videos up to 100 MB · Max {maxFiles} files + Images auto-optimised to <500 KB · Videos up to 100 MB · Max {maxFiles} files