Initial commit: RTS Application

This commit is contained in:
ryano 2026-09-12 01:01:46 -07:00
commit 594a6abf33
66 changed files with 8750 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Docker / root env
.env
.env.local
.env*.local
# Next.js
node/node_modules/
node/.next/
node/next-env.d.ts
# Next.js app-level env (in case anything writes one here too)
node/.env
node/.env.local
node/.env*.local
# leftover/archived code
trash/
# misc
.DS_Store
*.log

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:current-alpine3.22
RUN apk update && apk add su-exec
WORKDIR /node
COPY ./node/package.json ./node/package-lock.json ./
RUN npm install && chown -R node:node .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["npm", "run", "dev"]

23
docker-compose.yml Executable file
View File

@ -0,0 +1,23 @@
services:
app:
container_name: rts-app
build: .
image: rts-app:latest
#user: "node"
environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
networks:
- proxy
volumes:
- ./node:/node
- nextjs_cache:/node/.next
- node_modules:/node/node_modules
networks:
proxy:
external: true
volumes:
nextjs_cache:
node_modules:

4
entrypoint.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
# Fix volume ownership then drop to node user
chown -R node:node /node/.next /node/node_modules
exec su-exec node "$@"

41
node/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
node/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

16
node/eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
node/jsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

7
node/next.config.mjs Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
allowedDevOrigins: ['rts.rynohome.com'],
};
export default nextConfig;

6813
node/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
node/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "node",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=1024' next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"next": "16.2.4",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4"
}
}

7
node/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
node/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
node/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
node/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
node/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
node/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,25 @@
import style from '@/styles/pages/Dashboard.module.css';
export default function DashboardPage()
{
return (
<div className={style["container"]}>
<div>
<h1>Dashboard</h1>
<p>This is the dashboard page. You can add your dashboard content here.</p>
</div>
<div>
<h1>Dashboard</h1>
<p>This is the dashboard page. You can add your dashboard content here.</p>
</div>
<div>
<h1>Dashboard</h1>
<p>This is the dashboard page. You can add your dashboard content here.</p>
</div>
<div>
<h1>Dashboard</h1>
<p>This is the dashboard page. You can add your dashboard content here.</p>
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import style from '@/styles/pages/Dashboard.module.css';
export default function InventoryPage()
{
return (
<div className={style["container"]}>
<div>
<h1>Inventory</h1>
<p>This is the inventory page. You can add your inventory content here.</p>
</div>
</div>
)
}

View File

@ -0,0 +1,48 @@
'use client'
import "@/styles/global.css"
import { useRouter } from 'next/navigation';
import NextImage from 'next/image';
import RtsLogo from'@/res/rts_logo.svg';
import styles from '@/styles/Layout.module.css';
import Navbar from '@/components/navbar/Navbar';
export default function RootLayout({ children }) {
const router = useRouter();
async function handleLogout() {
const response = await fetch('/api/auth/logout', { method: 'POST' });
if (response.ok) {
router.push('/login');
}
}
return (
<div className={styles["container"]}>
<div className={styles["header"]}>
<div className={styles["logo-container"]}>
<NextImage src={RtsLogo} alt="RTS Logo" fill style={{ objectFit: 'contain' }} />
</div>
<div className={styles["navbar-container"]}>
<Navbar links={[{ label: 'Dashboard', type: 'link', href: '/admin/dashboard' },
{ label: 'Inventory', type: 'link', href: '/admin/inventory' },
{ label: 'Work Orders', type: 'link', href: '/admin/work-orders' },
{ label: 'Procurement', type: 'link', href: '/admin/procurement' },
{ label: 'ECO', type: 'link', href: '/eco' },
]}/>
</div>
<div className={styles["userbar-container"]}>
<Navbar links={[{ label: 'Home', type: 'link', href: '/' },
{ label: 'Account', type: 'link', href: '/account' },
{ label: 'Logout', type: 'action', onClick: handleLogout },
]}/>
</div>
</div>
<div className={styles["content-container"]}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
export default function AccountPage()
{
return (
<div>
<h1>Account</h1>
<p>Welcome to the account page! This page is protected and requires authentication.</p>
</div>
)
}

View File

@ -0,0 +1,66 @@
'use client'
import "@/styles/global.css"
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import NextImage from 'next/image';
import RtsLogo from'@/res/rts_logo.svg';
import styles from '@/styles/Layout.module.css';
import Navbar from '@/components/navbar/Navbar';
export default function RootLayout({ children }) {
const router = useRouter();
const [userData, setUserData] = useState(null);
useEffect(() => {
async function fetchUserData() {
const result = await fetch('/api/auth/me', { method: 'GET' });
const data = await result.json();
setUserData(data);
}
fetchUserData();
}, []);
async function handleLogout() {
const response = await fetch('/api/auth/logout', { method: 'POST' });
if (response.ok) {
router.push('/login');
}
}
const userbarLinks = userData?.admin === true ? [ { label: 'Dashboard', type: 'link', href: '/admin/dashboard' },
{ label: 'Account', type: 'link', href: '/account' },
{ label: 'Logout', type: 'action', onClick: handleLogout },
] :
[ { label: 'Account', type: 'link', href: '/account' },
{ label: 'Logout', type: 'action', onClick: handleLogout },
]
console.log('userData:', userData);
console.log('userbarLinks:', userbarLinks);
return (
<div className={styles["container"]}>
<div className={styles["header"]}>
<div className={styles["logo-container"]}>
<NextImage src={RtsLogo} alt="RTS Logo" fill style={{ objectFit: 'contain' }} />
</div>
<div className={styles["navbar-container"]}>
<Navbar links={[{ label: 'Home', type: 'link', href: '/' },
{ label: 'Shop', type: 'link', href: '/shop' },
]}/>
</div>
<div className={styles["userbar-container"]}>
<Navbar links={userbarLinks}/>
</div>
</div>
<div className={styles["content-container"]}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
export default function HomePage()
{
return (
<div>
<h1>Home</h1>
<p>Welcome to the home page! This page is protected and requires authentication.</p>
</div>
)
}

View File

@ -0,0 +1,9 @@
export default function ShopPage()
{
return (
<div>
<h1>Shop</h1>
<p>Welcome to the shop page! This page is protected and requires authentication.</p>
</div>
)
}

View File

@ -0,0 +1,9 @@
import "@/styles/global.css"
export default function RootLayout({ children }) {
return (
<div>
<div>{children}</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
'use client'
import '@/styles/global.css'
import '@/styles/card.css'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
setError(null)
const res = await fetch('/api/public/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember })
})
if (res.ok) {
router.push('/')
} else {
const data = await res.json()
setError(data.message)
setLoading(false)
}
}
return (
<div id="login-page" className="center-page">
<div className="card-container">
<div className="card-header">
<h1>Welcome Back!</h1>
<p>Please enter your credentials to log in.</p>
</div>
<form className="card-body" onSubmit={handleSubmit}>
<div className="card-entry">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="card-error">*{error}</p>}
<div className="card-actions">
<label>
<input
type="checkbox"
checked={remember}
onChange={e => setRemember(e.target.checked)}
/>
Remember me
</label>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</div>
</form>
<div className="card-footer">
<p>Don't have an account? <a href="/signup">Sign Up</a></p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,12 @@
import {Navbar } from '@/components/navbar/Navbar';
export default function ShopPage()
{
return (
<div>
<Navbar />
<h1>Shop</h1>
<p>Welcome to the shop page! This page is protected and requires authentication.</p>
</div>
)
}

View File

@ -0,0 +1,156 @@
'use client'
import '@/styles/global.css'
import '@/styles/card.css'
import bcrypt from 'bcryptjs'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function SignupPage() {
const [fname, setFName] = useState('')
const [lname, setLName] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [rpassword, setRPassword] = useState('')
const [email, setEmail] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
function validate(fname, lname, username, email, password) {
if (fname.length < 2) return 'First name must be at least 2 characters'
if (lname.length < 2) return 'Last name must be at least 2 characters'
if (username.length < 3) return 'Username must be at least 3 characters'
if (!/^[a-zA-Z0-9_]+$/.test(username)) return 'Username can only contain letters, numbers, and underscores'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Invalid email format'
if (password.length < 8) return 'Password must be at least 8 characters'
return null
}
async function handleSubmit(e) {
e.preventDefault()
setError(null)
if(rpassword !== password) {
setError('Passwords must match')
return
}
const validationError = validate(fname, lname, username, email, password)
if (validationError) {
setError(validationError)
return
}
setLoading(true)
const res = await fetch('/api/public/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fname, lname, username, email, password })
})
if (res.ok) {
router.push('/login')
} else {
const data = await res.json()
setError(data.message)
setLoading(false)
}
}
return (
<div id="signup-page" className="center-page">
<div className="card-container">
<div className="card-header">
<h1>Create an Account</h1>
<p>Please enter your details to sign up.</p>
</div>
<form className="card-body" onSubmit={handleSubmit}>
<div className="card-entry">
<label htmlFor="fname">First Name</label>
<input
id="fname"
type="text"
placeholder="First Name"
value={fname}
onChange={e => setFName(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="lname">Last Name</label>
<input
id="lname"
type="text"
placeholder="Last Name"
value={lname}
onChange={e => setLName(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="card-entry">
<label htmlFor="confirmPassword">Repeat Password</label>
<input
id="confirmPassword"
type="password"
placeholder="Repeat Password"
value={rpassword}
onChange={e => setRPassword(e.target.value)}
required
/>
</div>
{error && <p className="card-error">{error}</p>}
<div className="card-actions">
<button type="submit" disabled={loading}>
{loading ? 'Signing up...' : 'Sign Up'}
</button>
</div>
</form>
<div className="card-footer">
<p>Already have an account? <a href="/login">Log In</a></p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,14 @@
// app/api/auth/logout/route.js
import { NextResponse } from 'next/server'
export async function POST() {
const response = NextResponse.json({ message: 'Ok' }, { status: 200 })
response.cookies.set('session', '', {
httpOnly: true,
maxAge: 0, // immediately expires
path: '/'
})
return response
}

View File

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { SECRET, ADMIN_ROLE_ID} from '@/lib/auth';
import { cookies } from 'next/headers';
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get('session')?.value;
console.log('token:', token);
if (!token) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const { payload } = await jwtVerify(token, SECRET);
console.log('payload:', payload);
const isAdmin = (payload.roleId === ADMIN_ROLE_ID) ? true : false;
return NextResponse.json({ admin: isAdmin, username: payload.username });
} catch (err) {
//loveyou
return NextResponse.json({ message: 'Invalid session' }, { status: 401 });
}
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { SignJWT } from 'jose'
import { loginUser } from '@/lib/db/UserService'
import { SECRET } from '@/lib/auth'
export async function POST(request) {
try {
const { username, password, remember } = await request.json()
if (!username || !password) {
return NextResponse.json(
{ message: 'Username and password are required' },
{ status: 400 }
)
}
const user = await loginUser(username, password);
// Short JWT for normal login — 8 hours
// Long JWT for remember me — 30 days
const expiresIn = remember ? '30d' : '8h'
const maxAge = remember ? 60 * 60 * 24 * 30 : 60 * 60 * 8
// create JWT token
const token = await new SignJWT({
userId: user.id,
username: user.username,
roleId: user.role_id
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(SECRET)
// Set cookie on response
const response = NextResponse.json({ message: 'Ok' }, { status: 200 })
response.cookies.set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: maxAge,
path: '/'
})
return response
} catch (error) {
const errorMessages = {
USER_NOT_FOUND: { message: 'User not found', status: 404 },
INVALID_CREDENTIALS: { message: 'Invalid credentials', status: 401 }
}
const known = errorMessages[error.message]
if (!known) console.error('Login error:', error)
const { message, status } = known || { message: 'Internal server error', status: 500 }
return NextResponse.json({ message }, { status })
}
}

View File

@ -0,0 +1,45 @@
import {NextResponse} from 'next/server'
import { Query } from '@/lib/db/db'
import { addUser } from '@/lib/db/UserService'
export async function POST(request) {
try {
const { fname, lname, username, email, password } = await request.json()
if (!fname || !lname || !username || !password || !email) {
return NextResponse.json(
{ message: 'First name, last name, username, password, and email are required' },
{ status: 400 }
)
}
const user = await addUser(fname, lname, username, email, password)
if (!user) {
return NextResponse.json(
{ message: 'Failed to create user' },
{ status: 500 }
)
}
return NextResponse.json(
{
message: 'Ok',
user: user
},
{ status: 200 }
)
} catch (error) {
const errorMessages = {
USERNAME_EXISTS: { message: 'Username already exists', status: 409 },
EMAIL_EXISTS: { message: 'Email already exists', status: 409 }
}
const known = errorMessages[error.message]
if (!known) console.error('Signup error:', error)
const { message, status } = known || { message: 'Internal server error', status: 500 }
return NextResponse.json({ message }, { status })
}
}

9
node/src/app/icon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg viewBox="0 0 126 144" role="img" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(63, 72)">
<polygon points="0,-66 57,-33 57,33 0,66 -57,33 -57,-33" fill="#273b6b"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="#1e40af"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="none" stroke="#3370d1" stroke-width="1" opacity="0.5"/>
<text text-anchor="middle" dominant-baseline="central" y="1" style="fill:#ffffff;font-family:system-ui,sans-serif;font-size:32px;font-weight:500;">RTS</text>
<rect x="-26" y="42" width="52" height="2" rx="1" fill="#dbeafe" opacity="0.35"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 650 B

14
node/src/app/layout.js Normal file
View File

@ -0,0 +1,14 @@
import "@/styles/global.css"
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<title>Rynotech Services</title>
</head>
<body>
{children}
</body>
</html>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import Link from 'next/link';
import styles from './Navbar.module.css';
export default function Navbar({ links = [] }) {
return (
<div className={styles["navbar"]}>
<ul className={styles["links"]}>
{links.map((link) => (
<li className={styles["link-container"]} key={link.label}>
{link.type === 'action' ? (
<button onClick={link.onClick} className={styles["link"]}>
{link.label}
</button>
) : (
<Link href={link.href} className={styles["link"]}>
{link.label}
</Link>
)}
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,57 @@
.navbar
{
--text-size: var(--text-size-medium);
display: flex;
height: var(--navbar-height);
align-items: center;
}
.links
{
display: flex;
flex-direction: row;
height: 100%;
align-items: center;
gap: 1rem;
}
.link-container
{
display: flex;
align-items: center;
height: 100%;
list-style: none;
}
.link
{
display: flex;
height: 100%;
align-items: center;
text-decoration: none;
color: white;
background-color: var(--primary-color);
border: 2px solid var(--primary-color-dark);
padding-left: 1rem;
padding-right: 1rem;
font-size: var(--text-size-medium);
border-radius: 10px;
letter-spacing: 0.1rem;
}
.link:hover
{
background-color: var(--primary-color-highlight);
}
.link:active
{
background-color: var(--primary-color-dark);
}
.link.visited
{
color: rgb(183, 18, 18);
}

View File

@ -0,0 +1,12 @@
import '@/styles/itc/machines/itc-machine.css'
import Itc55100 from '@/components/itc/machines/itc55100/Itc55100'
export default function ItcMachine()
{
return (
<div id="itc-machine-container" >
<Itc55100 />
</div>
)
}

View File

@ -0,0 +1,9 @@
import '@/styles/itc/comps/itc-button.css'
export default function ItcButton( {style, name, onClick} ) {
return (
<div id="itc-button-container">
<button className={style} onClick={onClick}>{name}</button>
</div>
);
}

View File

@ -0,0 +1,21 @@
import styles from '@/styles/itc/comps/itc-combo.module.css'
export default function ItcCombo( { label, values, unit, onChange, disable} ) {
return (
<div id="itc-entry-container" className={styles['container']}>
<div className={styles['label-container']}>
<p>{label}:</p>
</div>
<div className={`${styles['entry-container']} ${disable ? styles['disabled'] : ''}`}>
<select onChange={onChange}>
{values.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</div>
<div className={styles['unit-container']}>
<p>{unit}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import styles from '@/styles/itc/comps/itc-entry.module.css'
export default function ItcEntry( { label, value = '', unit, disable} ) {
return (
<div id="itc-entry-container" className={styles['container']}>
<div className={styles['label-container']}>
<p>{label}:</p>
</div>
<div className={styles['entry-container']}>
<input defaultValue={value}/>
</div>
<div className={styles['unit-container']}>
<p>{unit}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
'use client'
import { useState } from 'react'
import NextImage from 'next/image';
import styles from '@/styles/itc/machines/itc55100/itc55100.module.css';
import LeftEdge from'@/res/itc/machines/itc55100/LeftEdgeCurve.svg';
import Home from '@/components/itc/machines/itc55100/panels/Home';
import TestSpec from '@/components/itc/machines/itc55100/panels/TestSpec'
const panels = {
home: Home,
testspec : TestSpec
};
export default function Itc55100()
{
const nextConfig = {
images: {
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
},
};
const [page, setPage] = useState('home')
const Panel = panels[page];
return (
<div id="itc55100-container" className={styles.container}>
<div className={styles['left-edge']}>
<NextImage src={LeftEdge} alt="Left Edge" fill style={{ objectFit: 'fill' }} />
</div>
<div className={styles['panel'] }>
{Panel ? <Panel navigate={setPage}/> : <div>Unknown panel</div> }
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
import styles from '@/styles/itc/machines/itc55100/panels/home.module.css'
import ItcButton from '@/components/itc/comps/ItcButton'
export default function HomePanel({ navigate })
{
return (
<div id="itc55100-home-panel" className={styles['container']}>
<div className={styles['info-bar']}>
<p>GUI Revision: 30-May-2026</p>
<p>PLD Revision: 49, 12W</p>
<p>Firmware Revision: 30-May-2026</p>
</div>
<div className={styles['title-bar']}>
<p>ITC55100C-WSS ZR</p>
</div>
<div className={styles['button-grid']}>
<ItcButton name="Test Specification" style="b lrg" onClick={() => navigate('testspec')}/>
<ItcButton name="Test Modes" style="b lrg" onClick={() => navigate('testmode')}/>
<ItcButton name="Package Configure" style="b lrg" onClick={() => navigate('package')}/>
<ItcButton name="Test Results" style="b lrg" onClick={() => navigate('testresult')}/>
<ItcButton name="Communications" style="b lrg" onClick={() => navigate('communication')}/>
<ItcButton name="Settings" style="b lrg" onClick={() => navigate('setting')}/>
</div>
<div className={styles['hw-config']}>
<p>Serial Number: 123456</p>
<p>Board Revision: T</p>
<p>Crowbar: 200A</p>
<p>Sidepanel: 0</p>
</div>
<div className={styles['copyright']}>
<p>© 2026 Integrated Technology Corporation. All rights reserved.</p>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import styles from '@/styles/itc/machines/itc55100/panels/testspec.module.css'
import ItcButton from '@/components/itc/comps/ItcButton'
import ItcEntry from '@/components/itc/comps/ItcEntry'
import ItcCombo from '@/components/itc/comps/ItcCombo'
export default function TestSpecPanel({ navigate })
{
const enModes = ["Disable", "Enable"];
const cbModes = ["Disable", "2uS Delay", "5uS Delay", "10uS Delay", "20uS Test Fire"]
const lkModes = ["Disable", "Pre", "Post", "Both"];
return (
<div id="itc55100-test-spec-panel" className={styles['container']}>
<div className={styles['spec-name']}>
<ItcEntry label="Name" value="Test Specification 1" unit=""/>
</div>
<div className={styles['spec-grid']}>
<ItcEntry label="Drain" value="50.0" unit="V"/>
<ItcEntry label="Peak" value="10.0" unit="A"/>
<ItcEntry label="Inductor" value="1.0" unit="mH"/>
<ItcEntry label="Rated Vds" value="250.0" unit="V"/>
<ItcEntry label="Channel" value="N" unit=""/>
<ItcEntry label="Gate On" value="20.0" unit="V"/>
<ItcEntry label="Gate Off" value="0.0" unit="V"/>
<ItcCombo label="Leakage" values={lkModes} unit=""/>
<ItcEntry label="Leakage" value="50.0" unit="V"/>
<ItcEntry label="Leakage" value="1.0" unit="mA"/>
<ItcCombo label="Energy" values={enModes} unit=""/>
<ItcEntry label="Energy" value="1500.0" unit="mJ"/>
<ItcEntry label="T1 Timeout" value="50.0" unit="uS" />
<ItcEntry label="T2 Timeout" value="50.0" unit="uS"/>
<ItcCombo label="Multipulse" values={enModes} unit=""/>
<ItcCombo label="Crowbar" values={cbModes} unit="" disable="true"/>
<ItcEntry label="Threshold" value="500.0" unit="V"/>
<ItcEntry label="Overvoltage" value="1500.0" unit="V"/>
</div>
</div>
)
}

View File

@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 400" width="42" height="400">
<defs>
<linearGradient id="redGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e8183a"/>
<stop offset="40%" stop-color="#c8102e"/>
<stop offset="100%" stop-color="#8a0820"/>
</linearGradient>
<radialGradient id="cg" cx="30%" cy="25%" r="65%">
<stop offset="0%" stop-color="#777"/>
<stop offset="45%" stop-color="#444"/>
<stop offset="100%" stop-color="#1a1a1a"/>
</radialGradient>
<radialGradient id="ch" cx="28%" cy="22%" r="50%">
<stop offset="0%" stop-color="#cccccc" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="rim" cx="30%" cy="25%" r="70%">
<stop offset="60%" stop-color="transparent"/>
<stop offset="100%" stop-color="#000" stop-opacity="0.6"/>
</radialGradient>
</defs>
<path d="M 0 0 L 13 0 C 13 0, 42 100, 42 200 C 42 300, 13 400, 13 400 L 0 400 Z"
fill="url(#redGrad)"/>
<!-- Circle 1: y=124, r=6, cx=18.6 -->
<circle cx="18.6" cy="124" r="7.2" fill="#111"/>
<circle cx="18.6" cy="124" r="6" fill="url(#cg)"/>
<circle cx="18.6" cy="124" r="6" fill="url(#ch)"/>
<circle cx="18.6" cy="124" r="6" fill="url(#rim)"/>
<circle cx="18.6" cy="124" r="2.2" fill="#3a3a3a"/>
<circle cx="17.2" cy="122.8" r="0.9" fill="#bbb" opacity="0.6"/>
<!-- Circle 2: y=162, r=8, cx=20.6 -->
<circle cx="20.6" cy="162" r="9.5" fill="#111"/>
<circle cx="20.6" cy="162" r="8" fill="url(#cg)"/>
<circle cx="20.6" cy="162" r="8" fill="url(#ch)"/>
<circle cx="20.6" cy="162" r="8" fill="url(#rim)"/>
<circle cx="20.6" cy="162" r="3" fill="#3a3a3a"/>
<circle cx="19.0" cy="160.6" r="1.2" fill="#bbb" opacity="0.6"/>
<!-- Circle 3: y=200, r=10, cx=22.6 -->
<circle cx="22.6" cy="200" r="11.8" fill="#111"/>
<circle cx="22.6" cy="200" r="10" fill="url(#cg)"/>
<circle cx="22.6" cy="200" r="10" fill="url(#ch)"/>
<circle cx="22.6" cy="200" r="10" fill="url(#rim)"/>
<circle cx="22.6" cy="200" r="3.8" fill="#3a3a3a"/>
<circle cx="20.8" cy="198.4" r="1.5" fill="#bbb" opacity="0.6"/>
<!-- Circle 4: y=238, r=8, cx=20.6 -->
<circle cx="20.6" cy="238" r="9.5" fill="#111"/>
<circle cx="20.6" cy="238" r="8" fill="url(#cg)"/>
<circle cx="20.6" cy="238" r="8" fill="url(#ch)"/>
<circle cx="20.6" cy="238" r="8" fill="url(#rim)"/>
<circle cx="20.6" cy="238" r="3" fill="#3a3a3a"/>
<circle cx="19.0" cy="236.6" r="1.2" fill="#bbb" opacity="0.6"/>
<!-- Circle 5: y=276, r=6, cx=18.6 -->
<circle cx="18.6" cy="276" r="7.2" fill="#111"/>
<circle cx="18.6" cy="276" r="6" fill="url(#cg)"/>
<circle cx="18.6" cy="276" r="6" fill="url(#ch)"/>
<circle cx="18.6" cy="276" r="6" fill="url(#rim)"/>
<circle cx="18.6" cy="276" r="2.2" fill="#3a3a3a"/>
<circle cx="17.2" cy="274.8" r="0.9" fill="#bbb" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,61 @@
#itc-button-container
{
--button-width: 300px;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
border: 5px rgb(255,255,255);
}
#itc-button-container button
{
font-family: var(--font-family);
font-size: var(--text-size-small);
padding: 0.5rem 0.5rem;
border-radius: var(--border-radius-medium);
border-width: 1px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25);
}
#itc-button-container button:hover
{
background-color: var(--color-secondary);
}
#itc-button-container button:active
{
background-color: var(--color-secondary-light);
}
#itc-button-container button.a
{
font-size: var(--text-size-large);
}
#itc-button-container button.b
{
font-size: var(--text-size-medium);
}
#itc-button-container button.c
{
font-size: var(--text-size-small);
}
#itc-button-container button.lrg
{
width: calc(var(--button-width)*1);
}
#itc-button-container button.med
{
width: calc(var(--button-width)*.75);
}
#itc-button-container button.sm
{
width: calc(var(--button-width)*.50);
}

View File

@ -0,0 +1,24 @@
.container
{
display: flex;
flex: row;
font-size: var(--text-size-medium);
gap: .75rem;
}
.container .label-container
{
display: flex;
width: 125px;
justify-content: right;
}
.container .entry-container select
{
font-size: var(--text-size-medium);
width: 150px;
}
.disabled
{
background-color: rgba(0,0,0,0);
}

View File

@ -0,0 +1,24 @@
.container
{
display: flex;
flex: row;
font-size: var(--text-size-medium);
gap: .75rem;
}
.container .label-container
{
display: flex;
width: 125px;
justify-content: right;
}
.container .entry-container input
{
font-size: var(--text-size-medium);
width: 150px;
}
.disabled
{
background-color: rgba(0,0,0,0);
}

View File

@ -0,0 +1,33 @@
#itc-machine-container
{
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
--text-size-small: 1rem;
--text-size-medium: 1.25rem;
--text-size-large: 2rem;
--color-primary-light: #e8183a;
--color-primary: #c8102e;
--color-primary-dark: #8a0820;
--color-secondary-light: #bfbfbf;
--color-secondary:#838383;
--color-secondary-dark: #535353;
--border-radius-medium: 10px;
font-family: var(--font-family);
font-size: var(--text-size-large);
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
}
.outline
{
border: 1px solid #3498db;
}

View File

@ -0,0 +1,23 @@
.container {
--panel-buffer: 10px;
display: flex;
flex-direction: row;
background: linear-gradient(180deg, rgb(230, 230, 230) 0%, var(--color-secondary-light) 2%, var(--color-secondary) 98%, var(--color-secondary-dark) 100%);
height: 100%;
width: 100%;
padding: 0px 51px 0px 0px;
}
.left-edge {
position: relative;
width: 51px;
height: 100%;
flex-shrink: 0;
}
.panel {
flex: 1;
min-width: 0;
padding: var(--panel-buffer) 0px var(--panel-buffer) 0px;
}

View File

@ -0,0 +1,54 @@
.container
{
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0);
width: 100%;
height: 100%;
gap: 2rem;
}
.button-grid
{
display: grid;
grid-template-columns: auto auto;
gap: 1.5rem;
}
.info-bar
{
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: var(--text-size-small);
}
.title-bar
{
display: flex;
border-bottom: 1px solid #000000;
width: 100%;
justify-content: center;
font-size: var(--text-size-large);
font-weight: bold;
padding-bottom: 5px;
}
.hw-config
{
display: grid;
grid-template-columns: auto auto;
justify-content: space-around;
gap: 1rem;
font-size: var(--text-size-small);
}
.copyright
{
display: flex;
justify-content: center;
font-size: var(--text-size-small);
font-style: italic;
}

View File

@ -0,0 +1,24 @@
.container
{
display: flex;
flex-direction: column;
gap: 1.5rem;
background-color: rgba(0, 0, 0, 0);
width: 100%;
height: 100%;
border-radius: inherit;
padding: 1rem;
}
.container .spec-name
{
display: flex;
justify-content: center;
}
.container .spec-grid
{
display: grid;
grid-template-columns: auto auto;
gap: 1rem;
}

5
node/src/lib/auth.js Normal file
View File

@ -0,0 +1,5 @@
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
const ADMIN_ROLE_ID = 1
export { SECRET, ADMIN_ROLE_ID }

View File

@ -0,0 +1,69 @@
import bcrypt from 'bcryptjs'
import { Query } from '@/lib/db/db'
export async function addUser(fname, lname, username, email, password) {
username = username.toLowerCase().trim()
email = email.toLowerCase().trim()
const existingUser = await getUserByUsername(username)
if (existingUser) {
throw new Error('USERNAME_EXISTS')
}
const existingEmail = await getUserByEmail(email)
if (existingEmail) {
throw new Error('EMAIL_EXISTS')
}
const status = 1; // active
const role = 4;
const hash = await bcrypt.hash(password, 10)
const result = await Query(
`INSERT INTO users (first_name, last_name, username, email, password_hash, role_id, status_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, first_name, last_name, username, email, password_hash, role_id, status_id`,
[fname, lname, username, email, hash, role, status]
)
return result.rows[0] || null
}
export async function getUserByUsername(username) {
username = username.toLowerCase().trim()
const result = await Query(
`SELECT id, first_name, last_name, username, email, password_hash, role_id, status_id
FROM users
WHERE username = $1`,
[username]
)
return result.rows[0] || null
}
export async function getUserByEmail(email) {
email = email.toLowerCase().trim()
const result = await Query(
`SELECT id, first_name, last_name, username, email, password_hash, role_id, status_id
FROM users
WHERE email = $1`,
[email]
)
return result.rows[0] || null
}
export async function loginUser(usernameOrEmail, password) {
const user = await getUserByUsername(usernameOrEmail) || await getUserByEmail(usernameOrEmail)
if (!user) {
throw new Error('USER_NOT_FOUND')
}
const isMatch = await bcrypt.compare(password, user.password_hash)
if (!isMatch) {
throw new Error('INVALID_CREDENTIALS')
}
return user
}

15
node/src/lib/db/db.js Normal file
View File

@ -0,0 +1,15 @@
import bcrypt from 'bcryptjs'
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
export { pool }
export async function Query(query, params = []) {
const result = await pool.query(query, params)
return result
}

62
node/src/proxy.js Normal file
View File

@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose';
import { SECRET } from '@/lib/auth';
import { ADMIN_ROLE_ID } from '@/lib/auth';
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|public/).*)'
]
}
export default async function proxy(request) {
const { pathname } = request.nextUrl
const token = request.cookies.get('session')?.value
const publicRoutes = ['/login', '/signup', '/shop']
if (publicRoutes.some(route => pathname.startsWith(route)) ) {
if (token) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
const publicApis = ['/api/public']
if (publicApis.some(route => pathname.startsWith(route)) ) {
if (token) {
return NextResponse.json({ message: 'Already authenticated' }, { status: 403 })
}
return NextResponse.next()
}
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const adminId = ADMIN_ROLE_ID
const { payload } = await jwtVerify(token, SECRET)
const adminRoutes = ['/admin']
if (adminRoutes.some(route => pathname.startsWith(route))) {
if (payload.roleId !== adminId) {
return NextResponse.redirect(new URL('/', request.url))
}
}
const adminApis = ['/api/admin']
if (adminApis.some(route => pathname.startsWith(route))) {
if (payload.roleId !== adminId) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.next()
} catch (err) {
return NextResponse.redirect(new URL('/login', request.url))
}
}

View File

@ -0,0 +1,9 @@
<svg viewBox="0 0 126 144" role="img" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(63, 72)">
<polygon points="0,-66 57,-33 57,33 0,66 -57,33 -57,-33" fill="#273b6b"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="#1e40af"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="none" stroke="#3370d1" stroke-width="1" opacity="0.5"/>
<text text-anchor="middle" dominant-baseline="central" y="1" style="fill:#ffffff;font-family:system-ui,sans-serif;font-size:32px;font-weight:500;">RTS</text>
<rect x="-26" y="42" width="52" height="2" rx="1" fill="#dbeafe" opacity="0.35"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 650 B

View File

@ -0,0 +1,24 @@
<svg width="100%" viewBox="0 0 680 200" role="img" xmlns="http://www.w3.org/2000/svg" style="">
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">RynoTech Services horizontal logo</title>
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Horizontal layout logo for RynoTech Services with hexagonal RTS mark on the left and stacked wordmark on the right.</desc>
<defs>
</defs>
<!-- ── hex mark, centered vertically at y=100 ── -->
<g transform="translate(180,100)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<polygon points="0,-66 57,-33 57,33 0,66 -57,33 -57,-33" fill="#273b6b" style="fill:rgb(39, 59, 107);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="#1e40af" style="fill:rgb(30, 64, 175);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<polygon points="0,-54 46,-27 46,27 0,54 -46,27 -46,-27" fill="none" stroke="#3370d1" stroke-width="1" opacity="0.5" style="fill:none;stroke:rgb(51, 112, 209);color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.5;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<text text-anchor="middle" dominant-baseline="central" y="1" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, system-ui, sans-serif;font-size:32px;font-weight:500;text-anchor:middle;dominant-baseline:central">RTS</text>
<rect x="-26" y="42" width="52" height="2" rx="1" fill="#dbeafe" opacity="0.35" style="fill:rgb(219, 234, 254);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.35;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<!-- ── vertical divider ── -->
<line x1="258" y1="60" x2="258" y2="140" stroke="#3370d1" stroke-width="1" opacity="0.3" style="fill:rgb(0, 0, 0);stroke:rgb(51, 112, 209);color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.3;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- ── wordmark, vertically centered ── -->
<text x="278" y="96" style="fill:rgb(30, 64, 175);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, system-ui, sans-serif;font-size:28px;font-weight:500;text-anchor:start;dominant-baseline:auto">RYNOTECH</text>
<text x="280" y="118" style="fill:rgb(51, 112, 209);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, system-ui, sans-serif;font-size:11.5px;font-weight:400;text-anchor:start;dominant-baseline:auto">SERVICES</text>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,48 @@
.container {
--logo-height:80px;
--logo-width:80px;
--navbar-height: 40px;
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
}
.header {
display: flex;
flex-direction: row;
align-content: center;
height: var(--logo-height);
min-height: var(--logo-height);
background-color: var(--secondary-color-highlight);
border-radius: 10px;
padding: 0.5rem;
}
.logo-container {
position: relative;
height: 100%;
width: fit-content;
min-width: var(--logo-width);
}
.navbar-container {
height: 100%;
align-content: center;
flex: 1;
}
.userbar-container {
height: 100%;
align-content: center;
}
.content-container
{
height: 100%;
padding: 1rem;
background-color: var(--secondary-color-highlight);
border-radius: 10px;
}

98
node/src/styles/card.css Normal file
View File

@ -0,0 +1,98 @@
.card-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.card-header {
background-color: var(--primary-color);
color: var(--text-bg-color);
border-top-left-radius: 10px;
border-top-right-radius: 10px;
padding: 1rem 1.5rem;
}
.card-header h1 {
margin: 0 0 0.25rem 0;
}
.card-header p {
margin: 0;
}
.card-body {
display: flex;
flex-direction: column;
background-color: var(--secondary-color-highlight);
padding: 1.5rem;
gap: 1rem;
}
.card-entry {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.card-entry label {
font-weight: bold;
}
.card-entry input {
padding: 1rem;
border-radius: 50px;
border: 1px solid #ccc;
border-radius: 4px;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-actions label {
display: flex;
align-items: center;
gap: 0.4rem;
}
button {
background-color: var(--primary-color);
color: var(--text-bg-color);
padding: 1rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
min-width: 125px;
}
button:hover {
opacity: 0.9;
}
.card-footer {
background-color: var(--primary-color);
color: var(--text-bg-color);
padding: 1rem 1.5rem;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.card-footer p {
float: right;
}
.card-footer a {
color: var(--text-bg-color);
}
.card-error {
color: var(--danger-color);
text-align: right;
}

View File

@ -0,0 +1,88 @@
* {
box-sizing: border-box;
margin: 0px;
padding: 0px;
}
:root
{
--text-fg-color: #171717;
--text-bg-color: #ffffff;
/* Primary — navy/blue, main actions and branding */
--primary-color-dark: #273b6b;
--primary-color: #1e40af;
--primary-color-highlight: #3370d1;
--primary-color-subtle: #dbeafe;
/* Secondary — slate, UI chrome, borders, inactive elements */
--secondary-color-dark: #1e293b;
--secondary-color: #64748b;
--secondary-color-highlight:#94a3b8;
--secondary-color-subtle: #f8fafc;
/* Surface — backgrounds, cards, panels */
--surface-color-base: #0f172a;
--surface-color-raised: #1e293b;
--surface-color-overlay: #334155;
--surface-color-border: #475569;
--background-color: #ffffff;
/* Text */
--text-color-primary: #f8fafc;
--text-color-secondary: #94a3b8;
--text-color-muted: #64748b;
--text-color-inverse: #0f172a;
/* Success — stock received, work order complete, ECO approved */
--success-color-dark: #14532d;
--success-color: #16a34a;
--success-color-highlight: #4ade80;
--success-color-subtle: #dcfce7;
/* Warning — low stock, reorder point, calibration due */
--warning-color-dark: #78350f;
--warning-color: #d97706;
--warning-color-highlight: #fbbf24;
--warning-color-subtle: #fef3c7;
/* Danger — obsolete profile, overdue PO, reservation conflict */
--danger-color-dark: #7f1d1d;
--danger-color: #dc2626;
--danger-color-highlight: #f87171;
--danger-color-subtle: #fee2e2;
/* Info — general alerts, ECO pending, notifications */
--info-color-dark: #1e3a5f;
--info-color: #0284c7;
--info-color-highlight: #38bdf8;
--info-color-subtle: #e0f2fe;
--font-family:Tahoma,system-ui,sans-serif;
--text-size-small: 0.875rem;
--text-size-medium: 1rem;
--text-size-large: 1.25rem;
--no-border: none;
--on-border: 1px solid red;
--debug-border: var(--on-border);
}
body {
font-family: var(--font-family);
font-size: var(--text-size-medium);
background-color: var(--background-color);
color: var(--text-fg-color);
height: 100vh;
padding: 1rem;
}
.center-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
}

View File

@ -0,0 +1,7 @@
.container
{
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
height: 100%;
}

View File

@ -0,0 +1,6 @@
.container
{
border: var(--debug-border);
height: 100%;
background-color: var(--secondary-color-highlight);
}

View File

@ -0,0 +1,101 @@
/* Login Page */
#login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
}
.login-page {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.login-header {
background-color: var(--primary-color);
color: var(--text-bg-color);
border-top-left-radius: 10px;
border-top-right-radius: 10px;
padding: 1rem 1.5rem;
}
.login-header h1 {
margin: 0 0 0.25rem 0;
}
.login-header p {
margin: 0;
}
.login-body {
display: flex;
flex-direction: column;
background-color: var(--secondary-color-highlight);
padding: 1.5rem;
gap: 1rem;
}
.login-entry {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.login-entry label {
font-weight: bold;
}
.login-entry input {
padding: 1rem;
border-radius: 50px;
border: 1px solid #ccc;
border-radius: 4px;
}
.login-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.login-actions label {
display: flex;
align-items: center;
gap: 0.4rem;
}
button {
background-color: var(--primary-color);
color: var(--text-bg-color);
padding: 1rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
width: 75px;
}
button:hover {
opacity: 0.9;
}
.login-footer {
background-color: var(--primary-color);
color: var(--text-bg-color);
padding: 1rem 1.5rem;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.login-footer p {
float: right;
}
.login-footer a {
color: var(--text-bg-color);
}

15
rebuild.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
COMPOSE_DIR="./"
CACHE_VOLUME="app_nextjs_cache"
echo "Bringing down containers..."
cd $COMPOSE_DIR && docker compose down
echo "Removing Next.js cache volume..."
docker volume rm $CACHE_VOLUME
echo "Bringing containers back up..."
docker compose up -d
echo "Done."