added the products page, with a threejs render of the products (still WIP), as well as a create/update form
This commit is contained in:
18
src/app/api/products.ts
Normal file
18
src/app/api/products.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Pool } from "pg"
|
||||
|
||||
const db = new Pool({
|
||||
connectionString: process.env.DB_STRING
|
||||
})
|
||||
|
||||
|
||||
export async function GET(req: NextRequest){
|
||||
try{
|
||||
|
||||
const result = await db.query("SELECT * from products");
|
||||
return NextResponse.json(result.rows ?? []);
|
||||
} catch(error){
|
||||
|
||||
return NextResponse.json({ message: "Serverside eception occurred" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
BIN
src/app/icon.jpg
Normal file
BIN
src/app/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -4,7 +4,7 @@ import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tartarus",
|
||||
description: "you shouldn't be here"
|
||||
description: "you shouldn't be here",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -14,6 +14,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<link
|
||||
rel="icon"
|
||||
href="/icon?<generated>"
|
||||
type="image/<generated>"
|
||||
sizes="<generated>"
|
||||
/>
|
||||
<body className="main">
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { paths } from "@/paths";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Products", path: "/products" },
|
||||
{ label: "Products", path: paths.products.home },
|
||||
{ label: "Pricing", path: "/pricing" },
|
||||
{ label: "About", path: "/about" },
|
||||
{ label: "Contact", path: "/contact" },
|
||||
@@ -128,6 +129,10 @@ export default function Home() {
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
<br />
|
||||
<Typography color="textPrimary" style={{ fontSize: 12, color: "##808080" }}>
|
||||
Tartarus
|
||||
</Typography>
|
||||
</Card>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
463
src/app/products/page.tsx
Normal file
463
src/app/products/page.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
|
||||
const Canvas = dynamic(() => import("@react-three/fiber").then((m) => m.Canvas), {
|
||||
ssr: false,
|
||||
});
|
||||
const OrbitControls = dynamic(
|
||||
() => import("@react-three/drei").then((m) => m.OrbitControls),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type Product = {
|
||||
record_num: number;
|
||||
product_id: number;
|
||||
name: string;
|
||||
exp_date: string; // ISO yyyy-mm-dd
|
||||
};
|
||||
|
||||
function isoTodayPlus(days: number) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const seed: Product[] = [
|
||||
{ record_num: 1, product_id: 1001, name: "Milk", exp_date: isoTodayPlus(30) },
|
||||
{ record_num: 2, product_id: 1002, name: "Eggs", exp_date: isoTodayPlus(12) },
|
||||
{ record_num: 3, product_id: 1003, name: "Bread", exp_date: isoTodayPlus(6) },
|
||||
{ record_num: 4, product_id: 1004, name: "Cheese", exp_date: isoTodayPlus(90) },
|
||||
{ record_num: 5, product_id: 1005, name: "Yogurt", exp_date: isoTodayPlus(20) },
|
||||
{ record_num: 6, product_id: 1006, name: "Butter", exp_date: isoTodayPlus(120) },
|
||||
];
|
||||
|
||||
const glassPaperSx = {
|
||||
borderRadius: 3,
|
||||
// glossy glass look
|
||||
background: `
|
||||
linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.05) 100%),
|
||||
radial-gradient(1200px 600px at 20% 0%, rgba(130,170,255,0.20) 0%, rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 500px at 80% 10%, rgba(255,140,180,0.14) 0%, rgba(0,0,0,0) 60%)
|
||||
`,
|
||||
border: "1px solid rgba(255,255,255,0.14)",
|
||||
backdropFilter: "blur(14px) saturate(130%)",
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.35)",
|
||||
color: "rgba(245,245,245,0.92)",
|
||||
};
|
||||
|
||||
const PLANE_SIDE = 5;
|
||||
const CUBE_SIZE = 1.0;
|
||||
const GAP = 0.25;
|
||||
const GAP_Z = 0.9;
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = React.useState<Product[]>(seed);
|
||||
const [selected, setSelected] = React.useState<Product | null>(null);
|
||||
const [mode, setMode] = React.useState<"view3d" | "form">("view3d");
|
||||
|
||||
const sorted = React.useMemo(() => {
|
||||
return [...products].sort((a, b) => (a.exp_date < b.exp_date ? 1 : -1));
|
||||
}, [products]);
|
||||
|
||||
const startCreate = () => {
|
||||
setSelected({
|
||||
record_num: -1,
|
||||
product_id: -1,
|
||||
name: "",
|
||||
exp_date: isoTodayPlus(1),
|
||||
});
|
||||
setMode("form");
|
||||
};
|
||||
|
||||
const startEdit = (p: Product) => {
|
||||
setSelected(p);
|
||||
setMode("form");
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
setMode("view3d");
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const saveForm = (p: Product) => {
|
||||
const isNew = p.record_num === -1;
|
||||
|
||||
if (isNew) {
|
||||
const nextRecord =
|
||||
Math.max(0, ...products.map((x) => x.record_num)) + 1;
|
||||
const nextProductId =
|
||||
Math.max(0, ...products.map((x) => x.product_id)) + 1;
|
||||
|
||||
const inserted: Product = {
|
||||
...p,
|
||||
record_num: nextRecord,
|
||||
product_id: nextProductId,
|
||||
};
|
||||
setProducts((prev) => [inserted, ...prev]);
|
||||
} else {
|
||||
setProducts((prev) =>
|
||||
prev.map((x) => (x.record_num === p.record_num ? p : x))
|
||||
);
|
||||
}
|
||||
|
||||
setMode("view3d");
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: { xs: "100%", md: 520 }, flex: "0 0 auto" }}>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{
|
||||
...glassPaperSx,
|
||||
height: "calc(100vh - 16px)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Table stickyHeader size="small" aria-label="products table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
fontWeight: 800,
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "rgba(15, 18, 28, 0.65)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
||||
color: "rgba(245,245,245,0.92)",
|
||||
}}
|
||||
>
|
||||
Products
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
fontWeight: 800,
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "rgba(15, 18, 28, 0.65)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
||||
color: "rgba(245,245,245,0.92)",
|
||||
}}
|
||||
>
|
||||
Exp Date
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 4,
|
||||
width: 1,
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative", height: 1 }}>
|
||||
<Tooltip title="Add product">
|
||||
<IconButton
|
||||
onClick={startCreate}
|
||||
size="small"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: -18,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
bgcolor: "background.paper",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
boxShadow: 1,
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
aria-label="add product"
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{sorted.map((p) => (
|
||||
<TableRow
|
||||
key={p.record_num}
|
||||
hover
|
||||
onClick={() => startEdit(p)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography fontWeight={700}>{p.name}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>
|
||||
record #{p.record_num} • product_id {p.product_id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>{p.exp_date}</TableCell>
|
||||
<TableCell sx={{ width: 1 }} />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
position: "relative",
|
||||
borderRadius: 0,
|
||||
bgcolor: "transparent",
|
||||
}}
|
||||
>
|
||||
{mode === "view3d" ? (
|
||||
<Box sx={{ width: "100%", height: "calc(100vh - 16px)" }}>
|
||||
<World3D products={sorted} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ width: "100%", height: "calc(100vh - 16px)" }}>
|
||||
<ProductForm
|
||||
value={selected}
|
||||
onCancel={cancelForm}
|
||||
onSave={saveForm}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function rowsUsedForPlane(total: number, planeIndex: number, side: number) {
|
||||
const perPlane = side * side;
|
||||
const start = planeIndex * perPlane;
|
||||
const remaining = Math.max(0, total - start);
|
||||
const countThisPlane = Math.min(perPlane, remaining);
|
||||
return Math.max(1, Math.ceil(countThisPlane / side));
|
||||
}
|
||||
|
||||
function computePosition(
|
||||
i: number,
|
||||
total: number,
|
||||
side: number,
|
||||
cubeSize: number,
|
||||
gap: number,
|
||||
gapZ: number
|
||||
) {
|
||||
const perPlane = side * side;
|
||||
const plane = Math.floor(i / perPlane);
|
||||
const within = i % perPlane;
|
||||
|
||||
// We fill "down" first, then left:
|
||||
// row changes slowest? Actually: for down-first, row = within % rowsUsed, col = floor(within / rowsUsed)
|
||||
// But we still want a bounded side x side layout. Easiest is:
|
||||
// row = within % side (down)
|
||||
// col = floor(within / side) (left)
|
||||
const row = within % side; // 0..side-1 (top->down)
|
||||
const col = Math.floor(within / side); // 0..side-1 (right->left)
|
||||
|
||||
// How many rows are actually used in this plane?
|
||||
const rowsUsed = rowsUsedForPlane(total, plane, side);
|
||||
|
||||
// Center the grid in X around 0, but start at right-most column
|
||||
const step = cubeSize + gap;
|
||||
const xHalfSpan = ((side - 1) * step) / 2;
|
||||
|
||||
// Top-right start:
|
||||
// col=0 should be right-most => x positive
|
||||
const x = (xHalfSpan - col * step);
|
||||
|
||||
// FLOOR-LOCK:
|
||||
// Bottom row sits on floor (grid) at y = cubeSize/2
|
||||
// If rowsUsed < side, we compress downward so the lowest row hits the floor.
|
||||
// row=0 is the top row; we want top row higher.
|
||||
const rowInUsedRange = row; // row already 0..side-1
|
||||
const clampedRow = Math.min(rowInUsedRange, rowsUsed - 1);
|
||||
const y = cubeSize / 2 + (rowsUsed - 1 - clampedRow) * step;
|
||||
|
||||
// Next plane goes "forward" (you said z is forward/back); using negative z to go "forward"
|
||||
const z = -plane * (cubeSize + gapZ);
|
||||
|
||||
return [x, y, z] as const;
|
||||
}
|
||||
|
||||
|
||||
function World3D({ products }: { products: Product[] }) {
|
||||
return (
|
||||
|
||||
<Canvas
|
||||
camera={{ position: [7, 6, 13], fov: 45, near: 0.1, far: 200 }}
|
||||
onCreated={({ camera }) => {
|
||||
camera.lookAt(2.8, 2.6, -2);
|
||||
}}
|
||||
>
|
||||
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<ambientLight intensity={0.8} />
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<directionalLight position={[8, 10, 6]} intensity={1.2} />
|
||||
<gridHelper args={[60, 60]} />
|
||||
|
||||
{/* products */}
|
||||
{products.map((p, i) => (
|
||||
<ProductBox key={p.record_num} product={p} index={i} total={products.length} />
|
||||
))}
|
||||
|
||||
<OrbitControls
|
||||
enableRotate={true}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
minDistance={10}
|
||||
maxDistance={14}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductBox({
|
||||
product,
|
||||
index,
|
||||
total,
|
||||
}: {
|
||||
product: Product;
|
||||
index: number;
|
||||
total: number;
|
||||
}) {
|
||||
const [x, y, z] = computePosition(
|
||||
index,
|
||||
total,
|
||||
PLANE_SIDE,
|
||||
CUBE_SIZE,
|
||||
GAP,
|
||||
GAP_Z
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<mesh position={[x, y, z]}>
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<meshStandardMaterial />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ProductForm({
|
||||
value,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
value: Product | null;
|
||||
onCancel: () => void;
|
||||
onSave: (p: Product) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState<Product | null>(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
const isNew = draft.record_num === -1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...glassPaperSx,
|
||||
width: "min(720px, 100%)",
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" fontWeight={800} gutterBottom>
|
||||
{isNew ? "Create Product" : "Update Product"}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => (d ? { ...d, name: e.target.value } : d))
|
||||
}
|
||||
inputProps={{ maxLength: 20 }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Expiration Date"
|
||||
type="date"
|
||||
value={draft.exp_date}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => (d ? { ...d, exp_date: e.target.value } : d))
|
||||
}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
||||
<Button onClick={onCancel} variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onSave(draft)}
|
||||
variant="contained"
|
||||
disabled={!draft.name.trim() || !draft.exp_date}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const paths = {
|
||||
home: '/',
|
||||
products: {
|
||||
home: '/demo',
|
||||
home: '/products',
|
||||
},
|
||||
auth: {
|
||||
signIn: '/auth/sign-in',
|
||||
|
||||
9
src/proxy.ts
Normal file
9
src/proxy.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/products/:path*',
|
||||
}
|
||||
Reference in New Issue
Block a user