From 49e80814533797e33dd14ae65a4a31ff2ce5aa45 Mon Sep 17 00:00:00 2001 From: binhkid2 Date: Wed, 18 Feb 2026 23:11:46 +0900 Subject: [PATCH] it worked --- frontend/app/api/upload-image/route.ts | 86 ++++++++ .../dashboard/components/create-post-form.tsx | 35 +-- .../app/dashboard/components/posts-table.tsx | 49 ++++- .../components/dashboard/image-uploader.tsx | 203 ++++++++++++++++++ frontend/lib/api.ts | 21 ++ frontend/next.config.ts | 13 +- 6 files changed, 371 insertions(+), 36 deletions(-) create mode 100644 frontend/app/api/upload-image/route.ts create mode 100644 frontend/components/dashboard/image-uploader.tsx diff --git a/frontend/app/api/upload-image/route.ts b/frontend/app/api/upload-image/route.ts new file mode 100644 index 0000000..81231a2 --- /dev/null +++ b/frontend/app/api/upload-image/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; + +const R2_WORKER = process.env.UPLOAD_R2_WORKER_API; +const R2_API_KEY = process.env.R2_UPLOAD_API_KEY; + +export interface UploadImageResponse { + success: true; + url: string; + key: string; + contentType: string; +} + +/** + * POST /api/upload-image + * + * Accepts a multipart/form-data body with a single "file" field. + * Proxies the binary to the Cloudflare R2 worker using the server-side API key + * (the key is never exposed to the browser). + * + * Returns: { success, url, key, contentType } + */ +export async function POST(req: NextRequest) { + console.log(R2_WORKER); + console.log(R2_API_KEY); + if (!R2_WORKER || !R2_API_KEY) { + return NextResponse.json( + { error: "R2 upload is not configured. Set UPLOAD_R2_WORKER_API and R2_UPLOAD_API_KEY." }, + { status: 503 } + ); + } + + try { + const formData = await req.formData(); + const file = formData.get("file"); + + if (!file || !(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + // Validate file type + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/avif"]; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: `Unsupported file type: ${file.type}. Allowed: JPEG, PNG, GIF, WebP, SVG, AVIF.` }, + { status: 400 } + ); + } + + // Validate file size (max 10 MB) + const MAX_BYTES = 10 * 1024 * 1024; + if (file.size > MAX_BYTES) { + return NextResponse.json( + { error: `File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.` }, + { status: 400 } + ); + } + + // Send binary to R2 worker + const arrayBuffer = await file.arrayBuffer(); + const r2Res = await fetch(`${R2_WORKER}/upload`, { + method: "POST", + headers: { + "X-Api-Key": R2_API_KEY, + "Content-Type": file.type, + }, + body: arrayBuffer, + }); + + if (!r2Res.ok) { + let errMsg = `R2 worker error ${r2Res.status}`; + try { + const body = await r2Res.json(); + errMsg = body?.error || body?.message || errMsg; + } catch { + /* ignore */ + } + return NextResponse.json({ error: errMsg }, { status: 502 }); + } + + const data = (await r2Res.json()) as UploadImageResponse; + return NextResponse.json(data); + } catch (err) { + console.error("[upload-image] Unexpected error:", err); + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } +} diff --git a/frontend/app/dashboard/components/create-post-form.tsx b/frontend/app/dashboard/components/create-post-form.tsx index f3bb29a..4a64ef5 100644 --- a/frontend/app/dashboard/components/create-post-form.tsx +++ b/frontend/app/dashboard/components/create-post-form.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { ImageUploader } from "@/components/dashboard/image-uploader"; import { Loader2, PlusCircle } from "lucide-react"; import { PostStatus, ContentFormat, UserRole } from "@/lib/types"; @@ -30,6 +31,10 @@ export function CreatePostForm({ userRole, onCreated }: Props) { ); const [isFeatured, setIsFeatured] = useState("false"); + // Controlled image state (ImageUploader is not a native input element) + const [featuredImageUrl, setFeaturedImageUrl] = useState(""); + const [featuredImageAlt, setFeaturedImageAlt] = useState(""); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const fd = new FormData(e.currentTarget); @@ -43,8 +48,9 @@ export function CreatePostForm({ userRole, onCreated }: Props) { isFeatured: isFeatured === "true", categories: fd.get("categories") || undefined, tags: fd.get("tags") || undefined, - featuredImageUrl: fd.get("featuredImageUrl") || undefined, - featuredImageAlt: fd.get("featuredImageAlt") || undefined, + // image values come from controlled state, not FormData + featuredImageUrl: featuredImageUrl || undefined, + featuredImageAlt: featuredImageAlt || undefined, }; setLoading(true); @@ -55,6 +61,9 @@ export function CreatePostForm({ userRole, onCreated }: Props) { }); toast.success("Post created!"); (e.target as HTMLFormElement).reset(); + // Reset controlled image state + setFeaturedImageUrl(""); + setFeaturedImageAlt(""); onCreated(); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to create post"); @@ -174,20 +183,14 @@ export function CreatePostForm({ userRole, onCreated }: Props) { -
-
- - -
-
- - -
-
+ {/* ── Featured image upload ─────────────────────────────────────────── */} +
-
- - -
+ + {/* ── Image upload (replaces the old URL text inputs) ── */} + setEditImage((prev) => ({ ...prev, url }))} + altValue={editImage.alt} + onAltChange={(alt) => setEditImage((prev) => ({ ...prev, alt }))} + label="Featured Image" + />
diff --git a/frontend/components/dashboard/image-uploader.tsx b/frontend/components/dashboard/image-uploader.tsx new file mode 100644 index 0000000..94e622e --- /dev/null +++ b/frontend/components/dashboard/image-uploader.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, Upload, X, ImageIcon } from "lucide-react"; +import type { UploadImageResponse } from "@/app/api/upload-image/route"; + +interface Props { + /** Current image URL (controlled) */ + value?: string; + /** Called when a new image URL is ready (after upload) or cleared (empty string) */ + onChange: (url: string) => void; + /** Alt text value */ + altValue?: string; + /** Called when alt text changes */ + onAltChange?: (alt: string) => void; + /** Label shown above the dropzone */ + label?: string; +} + +const ACCEPTED = "image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif"; + +export function ImageUploader({ + value, + onChange, + altValue = "", + onAltChange, + label = "Featured Image", +}: Props) { + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + const uploadFile = useCallback( + async (file: File) => { + setUploading(true); + try { + const fd = new FormData(); + fd.append("file", file); + + const res = await fetch("/api/upload-image", { + method: "POST", + body: fd, + }); + + const data = (await res.json()) as UploadImageResponse & { error?: string }; + + if (!res.ok || !data.success) { + throw new Error(data.error || "Upload failed"); + } + + onChange(data.url); + toast.success("Image uploaded successfully!"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(false); + } + }, + [onChange] + ); + + // ── Drag & Drop handlers ─────────────────────────────────────────────────── + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = () => setDragOver(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) uploadFile(file); + }; + + // ── File input change ────────────────────────────────────────────────────── + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + uploadFile(file); + // Reset so the same file can be re-selected if removed + e.target.value = ""; + } + }; + + const handleRemove = () => { + onChange(""); + onAltChange?.(""); + }; + + return ( +
+ + + {value ? ( + /* ── Preview ──────────────────────────────────────────────────────── */ +
+ {/* Plain — domain is dynamic (R2/custom), avoids next/image remotePatterns config */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {altValue + + {/* Overlay — Replace / Remove actions */} +
+ + +
+ + {uploading && ( +
+ +
+ )} +
+ ) : ( + /* ── Dropzone ─────────────────────────────────────────────────────── */ + + )} + + {/* Hidden file input */} + + + {/* Alt text field — always shown */} +
+ + onAltChange?.(e.target.value)} + /> +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7d1322d..535caea 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -4,6 +4,15 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; +const STATE_CHANGING_METHODS = ["POST", "PATCH", "PUT", "DELETE"]; + +// ─── Read csrfToken cookie in the browser ───────────────────────────────────── +function getClientCsrfToken(): string { + if (typeof document === "undefined") return ""; + const match = document.cookie.match(/(?:^|;\s*)csrfToken=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ""; +} + // ─── Server-side fetch (use in Server Components / Route Handlers) ───────────── export async function apiFetch( @@ -13,6 +22,7 @@ export async function apiFetch( // Dynamic import so this module can also be imported in client components // The `cookies` call only runs on the server side let cookieHeader = ""; + let csrfToken = ""; try { const { cookies } = await import("next/headers"); const store = await cookies(); @@ -20,16 +30,22 @@ export async function apiFetch( .getAll() .map((c) => `${c.name}=${c.value}`) .join("; "); + // Forward the CSRF token from the cookie store for mutating server-side calls + csrfToken = store.get("csrfToken")?.value ?? ""; } catch { // We're on the client — skip cookie forwarding } + const method = (init?.method ?? "GET").toUpperCase(); + const needsCsrf = STATE_CHANGING_METHODS.includes(method); + const res = await fetch(`${API_URL}${path}`, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...(cookieHeader ? { Cookie: cookieHeader } : {}), + ...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}), ...(init?.headers as Record | undefined), }, }); @@ -57,11 +73,16 @@ export async function clientFetch( path: string, init?: RequestInit ): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const needsCsrf = STATE_CHANGING_METHODS.includes(method); + const csrfToken = needsCsrf ? getClientCsrfToken() : ""; + const res = await fetch(`${API_URL}${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", + ...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}), ...(init?.headers as Record | undefined), }, }); diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 6fe4c82..d0c6e12 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -4,18 +4,9 @@ import path from "path"; const nextConfig: NextConfig = { turbopack: { root: path.resolve(__dirname), - }, + }, images: { - remotePatterns: [ - { - protocol: "https", - hostname: "images.unsplash.com", - }, - { - protocol: "https", - hostname: "**.unsplash.com", - }, - ], + unoptimized: true, }, };