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 } + const getStatusLabel = (item: ExtendedPostMedia) => { + if (item.isCompressing) return "Optimising…" + if (item.isUploading) return "Uploading…" + return item.type + } + + const isProcessing = (item: ExtendedPostMedia) => + item.isCompressing || item.isUploading + // ── Render ───────────────────────────────────────────────── return ( @@ -182,7 +259,7 @@ export function MediaUpload({ Drag and drop media files here, or click to browse

- 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

+ + {/* Compression info badge */} + {item.compressionInfo && item.compressionInfo.savedPercent > 0 && ( +

+ {formatFileSize(item.compressionInfo.originalSize)} → {formatFileSize(item.compressionInfo.compressedSize)} (−{item.compressionInfo.savedPercent}%) +

+ )} ))} @@ -271,4 +358,4 @@ export function MediaUpload({ )} ) -} \ No newline at end of file +} diff --git a/app/lib/image-compression.ts b/app/lib/image-compression.ts new file mode 100644 index 0000000..b88716a --- /dev/null +++ b/app/lib/image-compression.ts @@ -0,0 +1,100 @@ +import imageCompression from "browser-image-compression"; + +export interface CompressionResult { + file: File; + originalSize: number; + compressedSize: number; + savedBytes: number; + savedPercent: number; +} + +export interface ThumbnailResult { + file: File; + dataUrl: string; +} + +const MAX_SIZE_MB = 0.5; +const MAX_DIMENSION = 1200; +const THUMB_DIMENSION = 300; +const QUALITY = 0.82; + +function supportsWebP(): boolean { + if (typeof document === "undefined") return false; + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + return canvas.toDataURL("image/webp").startsWith("data:image/webp"); +} + +/** + * Compress an image file to <500 KB with max 1200×1200 dimensions. + * Converts to WebP when the browser supports it. + */ +export async function compressImage(file: File): Promise { + const originalSize = file.size; + + const outputType = supportsWebP() ? "image/webp" : "image/jpeg"; + + const options = { + maxSizeMB: MAX_SIZE_MB, + maxWidthOrHeight: MAX_DIMENSION, + useWebWorker: true, + fileType: outputType, + initialQuality: QUALITY, + preserveExif: false, + }; + + let compressed: File; + try { + compressed = await imageCompression(file, options); + } catch { + // Return the original on failure so uploads never silently break. + compressed = file; + } + + const compressedSize = compressed.size; + const savedBytes = originalSize - compressedSize; + + return { + file: compressed, + originalSize, + compressedSize, + savedBytes, + savedPercent: originalSize > 0 ? Math.round((savedBytes / originalSize) * 100) : 0, + }; +} + +/** + * Generate a 300×300 thumbnail from an image file. + * Returns both a File object and a data URL for immediate preview. + */ +export async function generateThumbnail(file: File): Promise { + const options = { + maxSizeMB: 0.1, + maxWidthOrHeight: THUMB_DIMENSION, + useWebWorker: true, + fileType: supportsWebP() ? "image/webp" : "image/jpeg", + initialQuality: 0.75, + preserveExif: false, + }; + + let thumbFile: File; + try { + thumbFile = await imageCompression(file, options); + } catch { + thumbFile = file; + } + + const dataUrl = await imageCompression.getDataUrlFromFile(thumbFile); + + return { file: thumbFile, dataUrl }; +} + +/** + * Human-readable file size string, e.g. "1.2 MB" or "450 KB". + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} diff --git a/app/package.json b/app/package.json index 2134e2e..fe5ce31 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "browser-image-compression": "^2.0.2", "@aws-sdk/client-s3": "^3.1018.0", "@hookform/resolvers": "^3.10.0", "@prisma/adapter-pg": "^7.2.0", diff --git a/app/prisma/migrations/20260423000000_add_post_category/migration.sql b/app/prisma/migrations/20260423000000_add_post_category/migration.sql new file mode 100644 index 0000000..343a9a8 --- /dev/null +++ b/app/prisma/migrations/20260423000000_add_post_category/migration.sql @@ -0,0 +1,19 @@ +-- CreateEnum +CREATE TYPE "PostCategory" AS ENUM ( + 'electronics', + 'clothing', + 'books', + 'furniture', + 'toys', + 'food', + 'sports', + 'beauty', + 'automotive', + 'other' +); + +-- AlterTable +ALTER TABLE "posts" ADD COLUMN "category" "PostCategory"; + +-- CreateIndex +CREATE INDEX "posts_category_idx" ON "posts"("category"); diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 4146bca..5668fd0 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -43,6 +43,23 @@ enum PostType { request } +/** + * Predefined categories for posts (Issue #204). + * Keeps category values consistent across the platform. + */ +enum PostCategory { + electronics + clothing + books + furniture + toys + food + sports + beauty + automotive + other +} + enum SelectionMethod { random merit_based @@ -271,6 +288,7 @@ model Post { title String @db.VarChar(200) description String? duration Int? @map("duration") // Duration in seconds for time-limited posts + category PostCategory? @map("category") helpType HelpType? @map("help_type") urgency Urgency? @map("urgency") status PostStatus @default(open) @@ -298,6 +316,7 @@ model Post { @@index([userId]) @@index([status]) + @@index([category]) @@index([createdAt(sort: Desc)]) @@map("posts") }