diff --git a/app/api/renders/in/route.ts b/app/api/renders/in/route.ts new file mode 100644 index 0000000..c62f392 --- /dev/null +++ b/app/api/renders/in/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/lib/database"; +import { promises as fs } from "fs"; + +export async function GET(request: NextRequest) { + const userId = request.headers.get("user-id"); + if (!userId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + try { + const pool = getPool(); + const result = await pool.query( + `SELECT id, original_name, file_path, upload_status, created_at + FROM uploaded_files + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId] + ); + return NextResponse.json({ files: result.rows }); + } catch (err: any) { + return NextResponse.json( + { error: err.message || "Server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const userId = request.headers.get("user-id"); + if (!userId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + try { + const body = await request.json(); + const fileId: string | undefined = body?.id; + if (!fileId) { + return NextResponse.json({ error: "Missing file id" }, { status: 400 }); + } + + const pool = getPool(); + // Fetch file to validate ownership and get path + const fileRes = await pool.query( + `SELECT id, user_id, file_path FROM uploaded_files WHERE id = $1`, + [fileId] + ); + if (fileRes.rows.length === 0) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + const fileRow = fileRes.rows[0]; + if (fileRow.user_id !== userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Try to delete file from disk (best-effort) + if (fileRow.file_path) { + try { + await fs.unlink(fileRow.file_path); + } catch (_) { + // ignore missing files + } + } + + // Delete DB row + await pool.query(`DELETE FROM uploaded_files WHERE id = $1`, [fileId]); + + return NextResponse.json({ success: true }); + } catch (err: any) { + return NextResponse.json( + { error: err.message || "Server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..4977334 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { getPool } from "@/lib/database"; +import { randomUUID } from "crypto"; + +export const config = { + api: { + bodyParser: false, // We handle parsing + }, +}; + +export async function POST(req: NextRequest) { + // Get user-id (for dev); in real world, extract from session/token + const userId = req.headers.get("user-id"); + if (!userId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + // Parse the form data + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + + // Only allow .blend, .zip, .jpg, .png, .svg for now + const filename = file.name; + if (!filename.match(/\.(blend|zip|jpg|jpeg|png|svg)$/i)) { + return NextResponse.json( + { error: "File type not allowed" }, + { status: 400 } + ); + } + + const saveAs = randomUUID() + path.extname(filename); + const uploadsDir = path.resolve(process.cwd(), "uploads"); + await fs.mkdir(uploadsDir, { recursive: true }); + const filepath = path.join(uploadsDir, saveAs); + + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(filepath, buffer); + + // Write metadata to uploaded_files + const pool = getPool(); + const result = await pool.query( + `INSERT INTO uploaded_files (user_id, original_name, file_path, upload_status) VALUES ($1, $2, $3, $4) RETURNING *`, + [userId, filename, filepath, "COMPLETE"] + ); + const uploadedFile = result.rows[0]; + + return NextResponse.json({ success: true, file: uploadedFile }); + } catch (err: any) { + console.error(err); + return NextResponse.json( + { error: err.message || "Upload failed" }, + { status: 500 } + ); + } +} diff --git a/app/renders/page.tsx b/app/renders/page.tsx new file mode 100644 index 0000000..9922f52 --- /dev/null +++ b/app/renders/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/lib/auth-context"; +import { useRouter } from "next/navigation"; + +interface UploadedFile { + id: string; + original_name: string; + file_path: string; + upload_status: string; + created_at: string; +} + +export default function RendersPage() { + const { isAuthenticated, user } = useAuth(); + const router = useRouter(); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [authInitialized, setAuthInitialized] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => setAuthInitialized(true), 100); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!authInitialized) return; + if (!isAuthenticated || !user) { + router.push("/login?redirect=/renders"); + return; + } + fetchFiles(); + // eslint-disable-next-line + }, [authInitialized, isAuthenticated, user]); + + async function fetchFiles() { + try { + const res = await fetch("/api/renders/in", { + headers: { "user-id": user?.id }, + }); + const data = await res.json(); + setFiles(data.files || []); + } catch { + setFiles([]); + } finally { + setLoading(false); + } + } + + async function handleDelete(id: string) { + if (!user) return; + const ok = confirm("Delete this file? This cannot be undone."); + if (!ok) return; + setDeletingId(id); + try { + const res = await fetch("/api/renders/in", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "user-id": user.id, + }, + body: JSON.stringify({ id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Delete failed"); + await fetchFiles(); + } catch (e) { + alert((e as any).message || "Delete failed"); + } finally { + setDeletingId(null); + } + } + + if (!authInitialized || !isAuthenticated) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+
+

My Renders

+ +
+

+ Uploaded Files +

+ {loading ? ( +
Loading...
+ ) : files.length === 0 ? ( +
No files uploaded yet.
+ ) : ( + + + + + + + + + + + + {files.map((f) => ( + + + + + + + + ))} + +
FilenameStatusUploaded AtDownloadActions
{f.original_name} + + {f.upload_status} + + + {new Date(f.created_at).toLocaleString()} + + + Download + + + +
+ )} +
+ +
+

+ Rendered Files (outputs) +

+
+ Placeholder: Your rendered products will appear here in the future! +
+
+
+
+ ); +} diff --git a/app/subscriptions/page.tsx b/app/subscriptions/page.tsx index b1a615d..ded4b6c 100644 --- a/app/subscriptions/page.tsx +++ b/app/subscriptions/page.tsx @@ -228,28 +228,6 @@ export default function SubscriptionsPage() { - {/* Storage Usage Bar */} -
-

Storage Usage

-
-
-
-

- {parseFloat(subscription.current_usage_gb).toFixed(2)} GB of{" "} - {subscription.storage_limit_gb} GB used -

-
- {/* Billing Info */}

Member Since

diff --git a/app/upload/page.tsx b/app/upload/page.tsx index 6eea1e7..c18bc4c 100644 --- a/app/upload/page.tsx +++ b/app/upload/page.tsx @@ -1,9 +1,8 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; -import { useState } from "react"; import UploadArea from "@/components/UploadArea"; export default function UploadPage() { @@ -11,6 +10,28 @@ export default function UploadPage() { const router = useRouter(); const [userSubscription, setUserSubscription] = useState(null); const [authInitialized, setAuthInitialized] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadMessage, setUploadMessage] = useState(null); + + function handleFilesSelected(newFiles: File[]) { + setSelectedFiles((prev) => { + const merged = [...prev]; + for (const file of newFiles) { + if ( + !merged.some( + (f) => + f.name === file.name && + f.size === file.size && + f.lastModified === file.lastModified + ) + ) { + merged.push(file); + } + } + return merged; + }); + } // Wait for auth to initialize from localStorage useEffect(() => { @@ -66,6 +87,35 @@ export default function UploadPage() { const hasActiveSubscription = userSubscription && userSubscription.status === "ACTIVE"; + async function handleStartUpload() { + if (!selectedFiles.length || !hasActiveSubscription || !user) return; + + setUploading(true); + setUploadMessage(null); + try { + for (const file of selectedFiles) { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/upload", { + method: "POST", + headers: { + "user-id": user.id, + }, + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Upload failed"); + } + setUploadMessage("Upload successful!"); + setSelectedFiles([]); // Reset picker + // Optionally: also refresh renders dashboard, etc + } catch (err: any) { + setUploadMessage(err.message || "Upload failed"); + } finally { + setUploading(false); + } + } + return (
@@ -98,33 +148,6 @@ export default function UploadPage() {
)} - {/* Subscription Info - Only show if active */} - {hasActiveSubscription && userSubscription && ( -
-

- Your {userSubscription.tier} Plan -

-

- Storage:{" "} - {parseFloat(userSubscription.current_usage_gb).toFixed(2)} GB /{" "} - {userSubscription.storage_limit_gb} GB used -

-
-
-
-
- )} - {/* Upload Area */}

@@ -135,8 +158,45 @@ export default function UploadPage() { !hasActiveSubscription ? "pointer-events-none opacity-50" : "" } > - +

+ + {/* Selected files indicator */} + {selectedFiles.length > 0 && ( +
+
+ Selected files ({selectedFiles.length}): +
+
    + {selectedFiles.map((f, idx) => ( +
  • + {f.name}{" "} + + ({(f.size / 1024).toFixed(1)} KB) + +
  • + ))} +
+
+ )} + + + {uploadMessage && ( +
+ {uploadMessage} +
+ )}
diff --git a/components/UploadArea.tsx b/components/UploadArea.tsx index 2a5feb5..1518dc3 100644 --- a/components/UploadArea.tsx +++ b/components/UploadArea.tsx @@ -3,7 +3,7 @@ import React, { useRef, useState } from "react"; type UploadAreaProps = { - onFilesSelected?: (files: FileList) => void; + onFilesSelected?: (files: File[]) => void; className?: string; }; @@ -29,13 +29,13 @@ export default function UploadArea({ e.preventDefault(); setIsDragging(false); if (e.dataTransfer?.files?.length) { - onFilesSelected?.(e.dataTransfer.files); + onFilesSelected?.(Array.from(e.dataTransfer.files)); } }; const handleChange: React.ChangeEventHandler = (e) => { if (e.target.files?.length) { - onFilesSelected?.(e.target.files); + onFilesSelected?.(Array.from(e.target.files)); // reset input to allow re-selecting the same file e.currentTarget.value = ""; } @@ -81,6 +81,7 @@ export default function UploadArea({ ref={fileInputRef} type="file" multiple + accept=".blend,.zip,.jpg,.jpeg,.png,.svg" className="hidden" onChange={handleChange} /> diff --git a/db-migration-uploaded-rendered-files.sql b/db-migration-uploaded-rendered-files.sql new file mode 100644 index 0000000..a82891a --- /dev/null +++ b/db-migration-uploaded-rendered-files.sql @@ -0,0 +1,24 @@ +-- IN: Table for uploaded files +CREATE TABLE IF NOT EXISTS uploaded_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + upload_status VARCHAR(50) DEFAULT 'PENDING', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + render_job_id UUID -- nullable, links to rendered_files +); + +-- OUT: Table for rendered files corresponding to uploads +CREATE TABLE IF NOT EXISTS rendered_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + uploaded_file_id UUID REFERENCES uploaded_files(id) ON DELETE CASCADE NOT NULL, + render_status VARCHAR(50) DEFAULT 'PENDING', + output_file_path VARCHAR(500), + rendered_at TIMESTAMP, + error_message TEXT +); + +-- Some useful indices +CREATE INDEX IF NOT EXISTS idx_uploaded_files_user_id ON uploaded_files(user_id); +CREATE INDEX IF NOT EXISTS idx_rendered_files_uploaded_file_id ON rendered_files(uploaded_file_id); diff --git a/uploads/11d45987-80cb-43de-a10d-5cff169b5a57.svg b/uploads/11d45987-80cb-43de-a10d-5cff169b5a57.svg new file mode 100644 index 0000000..2d884c1 --- /dev/null +++ b/uploads/11d45987-80cb-43de-a10d-5cff169b5a57.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + diff --git a/uploads/6e98ece7-4120-4755-8930-1815f2a4181c.svg b/uploads/6e98ece7-4120-4755-8930-1815f2a4181c.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/uploads/6e98ece7-4120-4755-8930-1815f2a4181c.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/uploads/845b7a32-df9c-4a14-9c59-4b5aa1562929.png b/uploads/845b7a32-df9c-4a14-9c59-4b5aa1562929.png new file mode 100644 index 0000000..285958d Binary files /dev/null and b/uploads/845b7a32-df9c-4a14-9c59-4b5aa1562929.png differ diff --git a/uploads/926c77d6-30c0-4a0d-bd3e-f5c50b54a2ff.svg b/uploads/926c77d6-30c0-4a0d-bd3e-f5c50b54a2ff.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/uploads/926c77d6-30c0-4a0d-bd3e-f5c50b54a2ff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/uploads/a5d10f54-17fb-4d5c-814f-a661f9b28285.png b/uploads/a5d10f54-17fb-4d5c-814f-a661f9b28285.png new file mode 100644 index 0000000..af92815 Binary files /dev/null and b/uploads/a5d10f54-17fb-4d5c-814f-a661f9b28285.png differ diff --git a/uploads/c8bbe19b-ea99-4e2a-a1dd-ae666cf14471.png b/uploads/c8bbe19b-ea99-4e2a-a1dd-ae666cf14471.png new file mode 100644 index 0000000..1115358 Binary files /dev/null and b/uploads/c8bbe19b-ea99-4e2a-a1dd-ae666cf14471.png differ diff --git a/uploads/d5e4e861-be87-41df-bc89-be89e1b3789b.png b/uploads/d5e4e861-be87-41df-bc89-be89e1b3789b.png new file mode 100644 index 0000000..af92815 Binary files /dev/null and b/uploads/d5e4e861-be87-41df-bc89-be89e1b3789b.png differ diff --git a/uploads/fec11d85-1704-4c40-87ef-0da77b3960e2.svg b/uploads/fec11d85-1704-4c40-87ef-0da77b3960e2.svg new file mode 100644 index 0000000..2d884c1 --- /dev/null +++ b/uploads/fec11d85-1704-4c40-87ef-0da77b3960e2.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + +