@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<UploadedFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [authInitialized, setAuthInitialized] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12">
|
||||||
|
<div className="max-w-5xl mx-auto px-4">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-8">My Renders</h1>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-2xl font-bold text-orange-600 mb-4">
|
||||||
|
Uploaded Files
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="text-gray-500">No files uploaded yet.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full bg-white rounded-lg shadow-md">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-orange-50">
|
||||||
|
<th className="p-3 text-left font-semibold">Filename</th>
|
||||||
|
<th className="p-3 text-left font-semibold">Status</th>
|
||||||
|
<th className="p-3 text-left font-semibold">Uploaded At</th>
|
||||||
|
<th className="p-3 text-left font-semibold">Download</th>
|
||||||
|
<th className="p-3 text-left font-semibold">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{files.map((f) => (
|
||||||
|
<tr key={f.id} className="border-t">
|
||||||
|
<td className="p-3">{f.original_name}</td>
|
||||||
|
<td className="p-3 capitalize">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-bold ${
|
||||||
|
f.upload_status === "COMPLETE"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.upload_status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{new Date(f.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
f.file_path
|
||||||
|
? `/uploads/${f.file_path.split("/").pop()}`
|
||||||
|
: "#"
|
||||||
|
}
|
||||||
|
className="underline text-blue-700"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download={f.original_name}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-sm rounded-md bg-red-500 text-white hover:bg-red-600 disabled:bg-red-300"
|
||||||
|
disabled={deletingId === f.id}
|
||||||
|
onClick={() => handleDelete(f.id)}
|
||||||
|
>
|
||||||
|
{deletingId === f.id ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-600 mb-4">
|
||||||
|
Rendered Files (outputs)
|
||||||
|
</h2>
|
||||||
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400">
|
||||||
|
Placeholder: Your rendered products will appear here in the future!
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |