portal revamp 12-27-25
41
.gitignore
vendored
Normal 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
|
||||||
16
eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7351
package-lock.json
generated
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "zpack-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/react": "^5.2.0",
|
||||||
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"next": "15.1.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-circular-progressbar": "^2.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18.2.79",
|
||||||
|
"@types/react-dom": "^18.2.25",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.1.4",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/assets/curved-hud-bg.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
public/assets/hud/slices/hud-bg-base.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1
public/file.svg
Normal 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
public/globe.svg
Normal 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 |
BIN
public/images/zlhlogo_enlarged.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/img/games/minecraft.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/img/games/project_zomboid.png
Normal file
|
After Width: | Height: | Size: 5.5 MiB |
BIN
public/img/games/rust.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
public/img/games/terraria.png
Normal file
|
After Width: | Height: | Size: 901 KiB |
BIN
public/img/games/valheim.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
1
public/next.svg
Normal 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 |
BIN
public/textures/hdsteel-bg.webp
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
public/textures/hsteel-bg.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/textures/steel-bg.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/textures/steel-texture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
1
public/vercel.svg
Normal 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
public/window.svg
Normal 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 |
25
src/app/(auth)/account-suspended/page.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SuspendedPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-black to-darkGray text-white p-6 text-center">
|
||||||
|
<div className="bg-darkGray p-8 rounded shadow-lg max-w-lg w-full border border-red-500">
|
||||||
|
<h1 className="text-3xl font-bold text-red-500 mb-4">🚫 Account Suspended</h1>
|
||||||
|
<p className="mb-4 text-lightGray">
|
||||||
|
Your account is currently suspended due to a billing issue. All server access has been restricted.
|
||||||
|
</p>
|
||||||
|
<p className="mb-6 text-yellow-300">
|
||||||
|
Please resolve your payment to regain access. Servers may be permanently deleted after 7 days.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/billing"
|
||||||
|
className="inline-block bg-electricBlue text-black px-6 py-3 rounded font-bold hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Go to Billing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import axios from "axios";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useAuth } from "@/context/authContext";
|
||||||
|
import SteelLayout from "@/components/layouts/SteelLayout";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
const { setToken } = useAuth();
|
||||||
|
|
||||||
|
const fetchCsrfToken = async () => {
|
||||||
|
try {
|
||||||
|
let csrfToken = Cookies.get("XSRF-TOKEN");
|
||||||
|
|
||||||
|
if (!csrfToken) {
|
||||||
|
console.log("⚠️ CSRF Token missing. Fetching a new one...");
|
||||||
|
|
||||||
|
const response = await axios.get("https://api.zerolaghub.com/auth/csrf", {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
csrfToken = response.data.csrfToken;
|
||||||
|
console.log("✅ CSRF Token Retrieved:", csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return csrfToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to fetch CSRF Token:", error);
|
||||||
|
setError("CSRF Token could not be retrieved.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCsrfToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
let csrfToken = Cookies.get("XSRF-TOKEN") || await fetchCsrfToken();
|
||||||
|
if (!csrfToken) throw new Error("CSRF token missing.");
|
||||||
|
|
||||||
|
console.log("✅ Using CSRF Token for Login:", csrfToken);
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
"https://api.zerolaghub.com/auth/login",
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ Login successful!", response.data);
|
||||||
|
const token = response.data.token;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
sessionStorage.setItem("sso_token", token);
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response && err.response.status === 403) {
|
||||||
|
console.warn("⚠️ CSRF Token possibly expired. Retrying login...");
|
||||||
|
const newCsrfToken = await fetchCsrfToken();
|
||||||
|
if (newCsrfToken) {
|
||||||
|
return handleLogin(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("❌ Login failed:", err);
|
||||||
|
setError("Login failed. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
console.log("✅ Logging out...");
|
||||||
|
await axios.post("https://api.zerolaghub.com/auth/logout", {}, { withCredentials: true });
|
||||||
|
|
||||||
|
console.log("✅ Logged out successfully, clearing CSRF token...");
|
||||||
|
Cookies.remove("XSRF-TOKEN", { path: "/" });
|
||||||
|
|
||||||
|
await fetchCsrfToken();
|
||||||
|
router.push("/login");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Logout failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SteelLayout>
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="p-8 bg-darkGray rounded-lg shadow-lg max-w-sm text-center">
|
||||||
|
<Image
|
||||||
|
src="/images/zlhlogo_enlarged.png"
|
||||||
|
alt="ZeroLagHub Logo"
|
||||||
|
width={150}
|
||||||
|
height={150}
|
||||||
|
className="mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
<h1 className="text-electricBlue text-2xl font-bold mb-4">Login</h1>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-500 bg-red-100 p-2 rounded mb-4">{error}</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray text-left mb-1">Email:</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray text-left mb-1">Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full p-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SteelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
55
src/app/(auth)/logout/page.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
type ErrorResponse = {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAxiosError(error: unknown): error is import("axios").AxiosError<ErrorResponse> {
|
||||||
|
return typeof error === "object" && error !== null && "isAxiosError" in error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Logout = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) {
|
||||||
|
await api.post(
|
||||||
|
"/auth/logout",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
console.error("Logout error:", err.response?.data?.error || err.message);
|
||||||
|
alert(err.response?.data?.error || "Logout failed. Please try again.");
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected logout error:", err);
|
||||||
|
alert("An unexpected error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem("token"); // Clear the token regardless of success or failure
|
||||||
|
router.push("/"); // Redirect to the home page
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogout();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<p className="text-lightGray text-lg">Logging out...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logout;
|
||||||
149
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
const Register = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError("Passwords do not match!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post("/auth/register", formData);
|
||||||
|
setSuccess(true);
|
||||||
|
alert("Registration successful! You can now log in.");
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
setError(err.response?.data?.error || "Registration failed. Please try again.");
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected registration error:", err);
|
||||||
|
setError("An unexpected error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAxiosError(error: unknown): error is import("axios").AxiosError<{ error?: string }> {
|
||||||
|
return typeof error === "object" && error !== null && "isAxiosError" in error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="p-8 bg-darkGray rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<h1 className="text-3xl font-heading text-electricBlue mb-6 text-center">
|
||||||
|
Create an Account
|
||||||
|
</h1>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-500 bg-red-100 p-2 rounded mb-4">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-green-500 bg-green-100 p-2 rounded mb-4">
|
||||||
|
Registration successful!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">First Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Last Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Username:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Email:</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Confirm Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full p-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
114
src/app/(dashboard)/billing/page.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
type BillingStatus = "active" | "inactive" | "past_due" | "canceled" | null;
|
||||||
|
|
||||||
|
type UserProfile = {
|
||||||
|
billing_status: BillingStatus;
|
||||||
|
plan_name: string | null;
|
||||||
|
plan_price: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/api/users/profile", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setProfile(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load profile:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenPortal = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await api.post("/api/billing/portal", null, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error opening portal:", err);
|
||||||
|
alert("Error opening portal.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckout = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await api.post("/api/billing/checkout", null, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.data?.url) {
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
} else {
|
||||||
|
alert("Could not start checkout session.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Checkout error:", err);
|
||||||
|
alert("Checkout session failed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="bg-gray-800 p-8 rounded shadow max-w-md w-full text-center">
|
||||||
|
<h1 className="text-3xl text-electricBlue font-heading mb-4">Billing</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-lightGray">Loading...</p>
|
||||||
|
) : profile?.billing_status === "active" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-green-400 mb-2 font-medium">
|
||||||
|
✅ Your account is in good standing.
|
||||||
|
</p>
|
||||||
|
<p className="text-lightGray mb-6">
|
||||||
|
You're subscribed to{" "}
|
||||||
|
<strong>{profile.plan_name || "ZerolagHub Basic"}</strong> – $
|
||||||
|
{(profile.plan_price ?? 10).toFixed(2)} / month
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
className="bg-neonGreen hover:bg-electricBlue transition text-black font-semibold px-5 py-2 rounded"
|
||||||
|
>
|
||||||
|
Manage Subscription
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">
|
||||||
|
⚠️ Your account is not active.
|
||||||
|
</p>
|
||||||
|
<p className="text-lightGray mb-4">
|
||||||
|
Subscribe to activate your hosting plan.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckout}
|
||||||
|
className="bg-electricBlue text-black font-semibold px-5 py-2 rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Subscribe Now – $10/month
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/app/(dashboard)/dashboard/page.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
//import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
import "react-circular-progressbar/dist/styles.css";
|
||||||
|
import MessageBoard from "@/components/MessageBoard";
|
||||||
|
import BillingSummaryCard from "@/components/BillingSummaryCard";
|
||||||
|
import AuditLogPreview from "@/components/AuditLogPreview";
|
||||||
|
//import HudPanel from "@/components/HudPanel";
|
||||||
|
//import HUDBox from "@/components/HUDBox";
|
||||||
|
//import ServerHUDOverview from "@/components/ServerHUDOverview";
|
||||||
|
import RadialStat from "@/components/RadialStat";
|
||||||
|
//import HUDFramedPanel from "@/components/HUDFramedPanel";
|
||||||
|
//import SystemControlHUD from "@/components/SystemControlHUD";
|
||||||
|
import WelcomeCoreHUD from "@/components/WelcomeCoreHUD";
|
||||||
|
import TechFramePanel from "@/components/TechFramePanel";
|
||||||
|
//import HUDScaffold from "@/components/HUDScaffold";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type JwtPayload = { email: string };
|
||||||
|
type ServerStatus = {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
resources?: {
|
||||||
|
memory_bytes: number;
|
||||||
|
disk_bytes: number;
|
||||||
|
cpu_absolute: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [username, setUsername] = useState("User");
|
||||||
|
const [consoleUrl, setConsoleUrl] = useState("https://panel.zerolaghub.com");
|
||||||
|
const [serverList, setServerList] = useState<ServerStatus[]>([]);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<string>("");
|
||||||
|
const [suspensionDaysRemaining, setSuspensionDaysRemaining] = useState<number | null>(null);
|
||||||
|
const [accountStatus, setAccountStatus] = useState<string>("active");
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
online: 0,
|
||||||
|
offline: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedStats = serverList.find((s) => s.uuid === selectedServer);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const [serversRes, statusRes] = await Promise.all([
|
||||||
|
api.get("/api/environment/servers", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
api.get<{ statuses: ServerStatus[] }>("/api/environment/server-status", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const servers = serversRes.data.servers || [];
|
||||||
|
const statuses = statusRes.data.statuses || [];
|
||||||
|
|
||||||
|
setServerList(statuses);
|
||||||
|
setStats({
|
||||||
|
total: servers.length,
|
||||||
|
online: statuses.filter((s) => s.status === "running").length,
|
||||||
|
offline: statuses.filter((s) => s.status !== "running").length,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silent fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePowerAction = async (action: "start" | "stop" | "restart") => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const toastId = toast.loading(`${action.toUpperCase()}ING all servers...`);
|
||||||
|
try {
|
||||||
|
if (!token) return;
|
||||||
|
await Promise.allSettled(
|
||||||
|
serverList.map((s) =>
|
||||||
|
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/servers/${s.uuid}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success(`${action.toUpperCase()}ED all servers`, { id: toastId });
|
||||||
|
fetchStatus();
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to ${action} all servers`, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const ssoToken = sessionStorage.getItem("sso_token");
|
||||||
|
if (ssoToken) {
|
||||||
|
setConsoleUrl(`https://panel.zerolaghub.com/login/token?access_token=${ssoToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode<JwtPayload>(token);
|
||||||
|
const fallbackName = decoded.email?.split("@")[0] || "User";
|
||||||
|
|
||||||
|
const profileRes = await api.get("/api/users/profile", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nickname = profileRes.data.nickname;
|
||||||
|
const suspendedAt = profileRes.data.suspended_at;
|
||||||
|
const status = profileRes.data.billing_status;
|
||||||
|
|
||||||
|
setUsername(nickname || fallbackName);
|
||||||
|
setAccountStatus(status || "active");
|
||||||
|
|
||||||
|
if (suspendedAt) {
|
||||||
|
const suspendedDate = new Date(suspendedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const daysElapsed = Math.floor((now.getTime() - suspendedDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const daysLeft = 7 - daysElapsed;
|
||||||
|
setSuspensionDaysRemaining(daysLeft > 0 ? daysLeft : 0);
|
||||||
|
} else {
|
||||||
|
setSuspensionDaysRemaining(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const decoded = jwtDecode<JwtPayload>(token);
|
||||||
|
const fallbackName = decoded.email?.split("@")[0] || "User";
|
||||||
|
setUsername(fallbackName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [serversRes, statusRes] = await Promise.all([
|
||||||
|
api.get("/api/environment/servers", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
api.get<{ statuses: ServerStatus[] }>("/api/environment/server-status", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const servers = serversRes.data.servers || [];
|
||||||
|
const statuses = statusRes.data.statuses || [];
|
||||||
|
|
||||||
|
setServerList(statuses);
|
||||||
|
|
||||||
|
// ✅ Set default selected server to first one
|
||||||
|
if (statuses.length > 0 && !selectedServer) {
|
||||||
|
setSelectedServer(statuses[0].uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
total: servers.length,
|
||||||
|
online: statuses.filter((s) => s.status === "running").length,
|
||||||
|
offline: statuses.filter((s) => s.status !== "running").length,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silent fail
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(fetchStatus, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
}, [selectedServer]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-6 pt-24 pb-10">
|
||||||
|
<div className="max-w-screen-xl mx-auto flex flex-col gap-10 items-center">
|
||||||
|
{/* Welcome Header */}
|
||||||
|
<div className="w-full max-w-xl text-center">
|
||||||
|
<WelcomeCoreHUD username={username} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 w-full items-start">
|
||||||
|
{/* Account + Activity */}
|
||||||
|
<TechFramePanel title="Your Account + Recent Activity">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BillingSummaryCard status={accountStatus} daysRemaining={suspensionDaysRemaining ?? undefined} />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-mono text-cyan-300 uppercase mb-2 tracking-widest">Recent Activity</h3>
|
||||||
|
<AuditLogPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TechFramePanel>
|
||||||
|
|
||||||
|
{/* Server Stats */}
|
||||||
|
<TechFramePanel title="Server Management">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<label className="block text-lightGray text-sm mb-2">Select a Server</label>
|
||||||
|
<select
|
||||||
|
className="bg-darkGray text-white p-2 rounded w-full"
|
||||||
|
value={selectedServer}
|
||||||
|
onChange={(e) => setSelectedServer(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- Select a Server --</option>
|
||||||
|
{serverList.map((s) => (
|
||||||
|
<option key={s.uuid} value={s.uuid}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedStats && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<RadialStat value={selectedStats.resources?.cpu_absolute || 0} label="CPU" color="#00FF88" />
|
||||||
|
<RadialStat value={(selectedStats.resources?.memory_bytes || 0) / 1024 / 1024} label="Memory" max={4096} color="#40CFFF" />
|
||||||
|
<RadialStat value={(selectedStats.resources?.disk_bytes || 0) / 1024 / 1024} label="Disk" max={5120} color="#FF6B6B" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TechFramePanel>
|
||||||
|
|
||||||
|
{/* Message Board */}
|
||||||
|
<TechFramePanel title="📣 Message Board">
|
||||||
|
<p className="text-sm text-lightGray mb-2">Stay updated with announcements, system alerts, and platform updates.</p>
|
||||||
|
<MessageBoard />
|
||||||
|
</TechFramePanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Quick Actions */}
|
||||||
|
<div className="w-full">
|
||||||
|
<TechFramePanel title="⚡ Quick Actions">
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center mt-2">
|
||||||
|
<button onClick={() => handlePowerAction("start")} className="bg-green-500 text-black px-4 py-2 rounded hover:bg-green-600 hover:shadow-[0_0_10px_#00FF88]">Start All</button>
|
||||||
|
<button onClick={() => handlePowerAction("stop")} className="bg-yellow-500 text-black px-4 py-2 rounded hover:bg-yellow-600 hover:shadow-[0_0_10px_#FFD700]">Stop All</button>
|
||||||
|
<button onClick={() => handlePowerAction("restart")} className="bg-blue-500 text-black px-4 py-2 rounded hover:bg-blue-600 hover:shadow-[0_0_10px_#40CFFF]">Restart All</button>
|
||||||
|
<Link href="/servers/create" className="bg-gray-700 text-white px-4 py-2 rounded hover:bg-gray-600">Create Server</Link>
|
||||||
|
<a href={consoleUrl} target="_blank" className="bg-electricBlue text-black px-4 py-2 rounded hover:bg-blue-600">Go to Console</a>
|
||||||
|
</div>
|
||||||
|
</TechFramePanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
||||||
187
src/app/(dashboard)/profile/page.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
username: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
nickname: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [original, setOriginal] = useState({ ...form });
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 🧠 Fetch user data
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// Decode JWT to check is_admin
|
||||||
|
const decoded = jwtDecode<{ is_admin: number }>(token);
|
||||||
|
setIsAdmin(decoded?.is_admin === 1);
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/api/users/profile", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Normalize nulls to empty strings
|
||||||
|
const clean = {
|
||||||
|
email: res.data.email || "",
|
||||||
|
username: res.data.username || "",
|
||||||
|
first_name: res.data.first_name || "",
|
||||||
|
last_name: res.data.last_name || "",
|
||||||
|
nickname: res.data.nickname || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
setForm(clean);
|
||||||
|
setOriginal(clean);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load profile");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ✏️ Handle input change
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 💾 Save handler
|
||||||
|
const handleSave = async () => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// ✅ Sanitize values
|
||||||
|
const trimmed = {
|
||||||
|
...form,
|
||||||
|
first_name: form.first_name?.trim() || "User",
|
||||||
|
last_name: form.last_name?.trim() || "Updated",
|
||||||
|
nickname: form.nickname?.trim() || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Prevent whitespace-only names
|
||||||
|
if (!trimmed.first_name.trim() || !trimmed.last_name.trim()) {
|
||||||
|
return toast.error("First and last name cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.dismiss();
|
||||||
|
await api.put("/api/users/profile", trimmed, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
toast.success("Profile updated!");
|
||||||
|
setOriginal(trimmed);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = JSON.stringify(form) !== JSON.stringify(original);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-md mx-auto bg-gray-800 p-6 rounded shadow">
|
||||||
|
<h1 className="text-3xl text-electricBlue font-heading mb-6">👤 Profile</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-lightGray">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
{/* EMAIL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1 font-bold">Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-full p-2 bg-darkGray text-white rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* USERNAME */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1 font-bold">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-full p-2 bg-darkGray text-white rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FIRST NAME */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1 font-bold">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={form.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 bg-darkGray text-white rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LAST NAME */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1 font-bold">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={form.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 bg-darkGray text-white rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NICKNAME */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-lightGray mb-1 font-bold">Nickname (Display Name)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nickname"
|
||||||
|
value={form.nickname}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 bg-darkGray text-white rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className={`px-4 py-2 rounded transition font-semibold ${
|
||||||
|
hasChanges
|
||||||
|
? "bg-electricBlue text-black hover:bg-neonGreen"
|
||||||
|
: "bg-gray-600 text-gray-300 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
11
src/app/(dashboard)/servers/console/ServerConsole.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import TerminalView from "@/components/terminal/TerminalView";
|
||||||
|
|
||||||
|
export default function ServerConsole() {
|
||||||
|
return (
|
||||||
|
<div className="h-[600px] rounded-md bg-black p-2">
|
||||||
|
<TerminalView />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/(dashboard)/servers/console/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import ServerConsole from "./ServerConsole";
|
||||||
|
|
||||||
|
export default function ConsolePageWrapper() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="text-center text-white p-10">Loading Console...</div>}>
|
||||||
|
<ServerConsole />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
src/app/(dashboard)/servers/create/page.tsx
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { NESTS_ROUTE, EGGS_ROUTE, CREATE_SERVER_ROUTE, ALLOCATIONS_ROUTE } from "@/services/routes";
|
||||||
|
|
||||||
|
// Define types for clarity
|
||||||
|
type Nest = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Egg = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Allocation = {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateServer = () => {
|
||||||
|
const [serverName, setServerName] = useState("");
|
||||||
|
const [nests, setNests] = useState<Nest[]>([]);
|
||||||
|
const [selectedNestId, setSelectedNestId] = useState<number | null>(null);
|
||||||
|
const [eggs, setEggs] = useState<Egg[]>([]);
|
||||||
|
const [selectedEggId, setSelectedEggId] = useState<number | null>(null);
|
||||||
|
const [allocations, setAllocations] = useState<Allocation[]>([]);
|
||||||
|
const [selectedAllocationId, setSelectedAllocationId] = useState<number | null>(null);
|
||||||
|
const [memory, setMemory] = useState<number>(1024);
|
||||||
|
const [disk, setDisk] = useState<number>(10240);
|
||||||
|
const [cpu, setCpu] = useState<number>(100);
|
||||||
|
const [startup, setStartup] = useState("");
|
||||||
|
const [game, setGame] = useState<string | null>(null);
|
||||||
|
const [variant, setVariant] = useState<string | null>(null);
|
||||||
|
const [adminPassword, setAdminPassword] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
setError("User is not authenticated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nestsResponse = await api.get<Nest[]>(NESTS_ROUTE, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setNests(nestsResponse.data);
|
||||||
|
|
||||||
|
const allocationsResponse = await api.get<{ allocations: Allocation[] }>(ALLOCATIONS_ROUTE, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setAllocations(allocationsResponse.data.allocations || []);
|
||||||
|
setSelectedAllocationId(null); // Reset allocation on reload
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch data:", error);
|
||||||
|
setError("Failed to load nests or allocations.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNestChange = async (nestId: number) => {
|
||||||
|
setSelectedNestId(nestId);
|
||||||
|
const selectedNest = nests.find((nest) => nest.id === nestId);
|
||||||
|
setGame(selectedNest ? selectedNest.name : null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
setError("User is not authenticated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eggsResponse = await api.get<Egg[]>(EGGS_ROUTE(nestId), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setEggs(eggsResponse.data);
|
||||||
|
|
||||||
|
if (eggsResponse.data.length > 0) {
|
||||||
|
const firstEgg = eggsResponse.data[0];
|
||||||
|
setSelectedEggId(firstEgg.id);
|
||||||
|
setVariant(firstEgg.name);
|
||||||
|
|
||||||
|
const startupResponse = await api.get(`${EGGS_ROUTE(nestId)}/${firstEgg.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setStartup(startupResponse.data.startup_command || "");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching eggs or startup command:", error);
|
||||||
|
setError("Failed to load server types or startup command.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEggChange = async (eggId: number) => {
|
||||||
|
setSelectedEggId(eggId);
|
||||||
|
const selectedEgg = eggs.find((egg) => egg.id === eggId);
|
||||||
|
setVariant(selectedEgg ? selectedEgg.name : null);
|
||||||
|
|
||||||
|
if (selectedNestId) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
setError("User is not authenticated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startupResponse = await api.get(`${EGGS_ROUTE(selectedNestId)}/${eggId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setStartup(startupResponse.data.startup_command || "");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching startup command:", error);
|
||||||
|
setError("Failed to load startup command.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!serverName || !selectedNestId || !selectedEggId || !selectedAllocationId || !startup || !game || !variant) {
|
||||||
|
setError("All required fields must be filled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: serverName,
|
||||||
|
nestId: selectedNestId,
|
||||||
|
eggId: selectedEggId,
|
||||||
|
allocationId: selectedAllocationId,
|
||||||
|
memory,
|
||||||
|
disk,
|
||||||
|
cpu,
|
||||||
|
startup,
|
||||||
|
game,
|
||||||
|
variant,
|
||||||
|
...(game === "Project Zomboid" && { environment: { ADMIN_PASSWORD: adminPassword } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
setError("User is not authenticated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post(CREATE_SERVER_ROUTE, payload, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201) {
|
||||||
|
setSuccess("Server created successfully!");
|
||||||
|
resetForm();
|
||||||
|
// Redirect to servers page without reloading
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = "/servers";
|
||||||
|
}, 1000); // Optional delay
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating server:", error);
|
||||||
|
setError("Failed to create server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setServerName("");
|
||||||
|
setSelectedNestId(null);
|
||||||
|
setSelectedEggId(null);
|
||||||
|
setSelectedAllocationId(null);
|
||||||
|
setMemory(1024);
|
||||||
|
setDisk(10240);
|
||||||
|
setCpu(100);
|
||||||
|
setStartup("");
|
||||||
|
setGame(null);
|
||||||
|
setVariant(null);
|
||||||
|
setAdminPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-lg mx-auto">
|
||||||
|
<h1 className="text-4xl font-heading text-electricBlue mb-6">Create a New Server</h1>
|
||||||
|
{error && <p className="text-red-500 bg-red-100 p-2 rounded mb-4">{error}</p>}
|
||||||
|
{success && <p className="text-green-500 bg-green-100 p-2 rounded mb-4">{success}</p>}
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Server Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Game:</label>
|
||||||
|
<select
|
||||||
|
value={selectedNestId || ""}
|
||||||
|
onChange={(e) => handleNestChange(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
>
|
||||||
|
<option value="">Select a game</option>
|
||||||
|
{nests.map((nest) => (
|
||||||
|
<option key={nest.id} value={nest.id}>
|
||||||
|
{nest.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{selectedNestId && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Server Type:</label>
|
||||||
|
<select
|
||||||
|
value={selectedEggId || ""}
|
||||||
|
onChange={(e) => handleEggChange(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
>
|
||||||
|
<option value="">Select a server type</option>
|
||||||
|
{eggs.map((egg) => (
|
||||||
|
<option key={egg.id} value={egg.id}>
|
||||||
|
{egg.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Allocation:</label>
|
||||||
|
<select
|
||||||
|
value={selectedAllocationId || ""}
|
||||||
|
onChange={(e) => setSelectedAllocationId(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
>
|
||||||
|
<option value="">Select an allocation</option>
|
||||||
|
{allocations.map((allocation) => (
|
||||||
|
<option key={allocation.id} value={allocation.id}>
|
||||||
|
{allocation.ip}:{allocation.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Memory (MB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={memory}
|
||||||
|
onChange={(e) => setMemory(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Disk (MB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={disk}
|
||||||
|
onChange={(e) => setDisk(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">CPU (%):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cpu}
|
||||||
|
onChange={(e) => setCpu(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Startup Command:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={startup}
|
||||||
|
readOnly
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{game === "Project Zomboid" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Admin Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full p-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
{loading ? "Creating Server..." : "Create Server"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateServer;
|
||||||
201
src/app/(dashboard)/servers/page.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import GroupedServerList from "@/components/GroupedServerList";
|
||||||
|
import SteelLayout from "@/components/layouts/SteelLayout";
|
||||||
|
|
||||||
|
|
||||||
|
export type Server = {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
game: string;
|
||||||
|
variant?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServersPage = () => {
|
||||||
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
|
const [grouped, setGrouped] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
start: "Starting",
|
||||||
|
stop: "Stopping",
|
||||||
|
restart: "Restarting",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAndMergeServers = async () => {
|
||||||
|
try {
|
||||||
|
const [serverRes, statusRes] = await Promise.all([
|
||||||
|
api.get<{ servers: Server[] }>("/api/environment/servers", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
api.get<{ statuses: { uuid: string; status: string }[] }>(
|
||||||
|
"/api/environment/server-status",
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rawServers = serverRes.data.servers || [];
|
||||||
|
const statuses = statusRes.data.statuses || [];
|
||||||
|
|
||||||
|
const merged = rawServers.map((server) => {
|
||||||
|
const match = statuses.find((s) => s.uuid === server.uuid);
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
status: match?.status || "unknown",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setServers(merged);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update server status.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAndMergeServers();
|
||||||
|
const interval = setInterval(fetchAndMergeServers, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleConsole = (uuid: string) => {
|
||||||
|
const token = sessionStorage.getItem("sso_token");
|
||||||
|
if (!token) return toast.error("SSO token missing");
|
||||||
|
|
||||||
|
const url = `https://panel.zerolaghub.com/login/token?access_token=${token}&redirect=/server/${uuid}`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgrade = (uuid: string) => {
|
||||||
|
router.push(`/servers/upgrade?uuid=${uuid}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (uuid: string, action: string) => {
|
||||||
|
const toastId = toast.loading(`${actionLabels[action]}...`);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/servers/${uuid}/${action}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(`${actionLabels[action]} succeeded`, { id: toastId });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.error(data?.error || "Failed", { id: toastId });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Unexpected error", { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SteelLayout>
|
||||||
|
<div className="max-w-screen-lg mx-auto px-6 py-10">
|
||||||
|
<h1 className="text-4xl font-heading text-electricBlue mb-6 text-center">
|
||||||
|
Your Servers
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Toggle View */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 font-semibold rounded-l ${
|
||||||
|
grouped
|
||||||
|
? "bg-electricBlue text-black"
|
||||||
|
: "bg-gray-700 text-white hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
onClick={() => setGrouped(true)}
|
||||||
|
>
|
||||||
|
Group by Game
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 font-semibold rounded-r ${
|
||||||
|
!grouped
|
||||||
|
? "bg-electricBlue text-black"
|
||||||
|
: "bg-gray-700 text-white hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
onClick={() => setGrouped(false)}
|
||||||
|
>
|
||||||
|
Flat View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length > 0 ? (
|
||||||
|
grouped ? (
|
||||||
|
<GroupedServerList
|
||||||
|
servers={servers}
|
||||||
|
onAction={handleAction}
|
||||||
|
onConsole={handleConsole}
|
||||||
|
onUpgrade={handleUpgrade}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<li
|
||||||
|
key={server.uuid}
|
||||||
|
className="bg-darkGray p-4 rounded shadow flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-bold">{server.name}</p>
|
||||||
|
<p className="text-sm text-gray-400">{server.status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-electricBlue text-black rounded hover:bg-neonGreen transition"
|
||||||
|
onClick={() => handleConsole(server.uuid)}
|
||||||
|
>
|
||||||
|
Go to Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 text-sm bg-blue-700 text-white rounded hover:bg-blue-600 transition"
|
||||||
|
onClick={() => handleUpgrade(server.uuid)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
{["start", "stop", "restart"].map((action) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => handleAction(server.uuid, action)}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-700 text-white rounded hover:bg-gray-600 transition"
|
||||||
|
>
|
||||||
|
{action.charAt(0).toUpperCase() + action.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-lightGray text-lg">No servers found.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center mt-10">
|
||||||
|
<button
|
||||||
|
className="px-6 py-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
onClick={() => router.push("/servers/create")}
|
||||||
|
>
|
||||||
|
Create Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SteelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServersPage;
|
||||||
107
src/app/(dashboard)/servers/upgrade/page.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Suspense } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
function UpgradeServer() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const uuid = params.get("uuid");
|
||||||
|
|
||||||
|
const [memory, setMemory] = useState(1024);
|
||||||
|
const [disk, setDisk] = useState(10240);
|
||||||
|
const [cpu, setCpu] = useState(100);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uuid) return;
|
||||||
|
const fetchServerInfo = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const response = await api.get(`/api/servers/${uuid}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { limits } = response.data;
|
||||||
|
setMemory(limits.memory);
|
||||||
|
setDisk(limits.disk);
|
||||||
|
setCpu(limits.cpu);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load server info.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchServerInfo();
|
||||||
|
}, [uuid]);
|
||||||
|
|
||||||
|
const handleUpgrade = async () => {
|
||||||
|
if (!uuid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
await api.put(
|
||||||
|
`/api/servers/${uuid}/upgrade`,
|
||||||
|
{ memory, disk, cpu },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("Server upgraded successfully!");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to upgrade server.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-sm mx-auto bg-gray-800 p-6 rounded shadow">
|
||||||
|
<h1 className="text-3xl font-heading text-electricBlue mb-4 text-center">Upgrade Server</h1>
|
||||||
|
|
||||||
|
<label className="block text-lightGray mb-2">Memory (MB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={memory}
|
||||||
|
onChange={(e) => setMemory(Number(e.target.value))}
|
||||||
|
className="w-full p-2 mb-4 rounded bg-black border border-electricBlue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-lightGray mb-2">Disk (MB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={disk}
|
||||||
|
onChange={(e) => setDisk(Number(e.target.value))}
|
||||||
|
className="w-full p-2 mb-4 rounded bg-black border border-electricBlue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-lightGray mb-2">CPU (%):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cpu}
|
||||||
|
onChange={(e) => setCpu(Number(e.target.value))}
|
||||||
|
className="w-full p-2 mb-6 rounded bg-black border border-electricBlue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full p-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
{loading ? "Upgrading..." : "Upgrade Server"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Wrap with Suspense to avoid Next.js 13 App Router warning
|
||||||
|
export default function UpgradeServerPageWrapper() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="text-center text-white p-10">Loading Upgrade Form...</div>}>
|
||||||
|
<UpgradeServer />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/(dashboard)/support/page.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
import api from "@/lib/api-client"; // Import API utility
|
||||||
|
|
||||||
|
const Support = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
subject: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!formData.name || !formData.email || !formData.subject || !formData.message) {
|
||||||
|
setError("All fields are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post("api/support/create", formData);
|
||||||
|
if (response.status === 200) {
|
||||||
|
setSuccess("Your support request has been submitted successfully.");
|
||||||
|
setFormData({ name: "", email: "", subject: "", message: "" });
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to submit support request.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error submitting support request:", err);
|
||||||
|
setError("Failed to submit your request. Please try again later.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-md mx-auto">
|
||||||
|
<h1 className="text-4xl font-heading text-electricBlue mb-6 text-center">
|
||||||
|
Contact Support
|
||||||
|
</h1>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-500 bg-red-100 p-2 rounded mb-4 text-center">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-green-500 bg-green-100 p-2 rounded mb-4 text-center">
|
||||||
|
{success}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Email:</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Subject:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-lightGray mb-1">Message:</label>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
className="w-full p-3 bg-black border border-electricBlue rounded focus:outline-none focus:border-neonGreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full p-3 bg-electricBlue text-black font-bold rounded hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Support;
|
||||||
28
src/app/about/page.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const About = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-black to-darkGray text-foreground flex flex-col items-center justify-center px-4">
|
||||||
|
<div className="max-w-3xl text-center">
|
||||||
|
<h1 className="text-4xl font-heading text-electricBlue mb-5 text-shadow">
|
||||||
|
About ZeroLagHub
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-lightGray leading-relaxed mb-6">
|
||||||
|
ZeroLagHub is your ultimate solution for hosting open-source, indie, RPG,
|
||||||
|
and modded game servers. We focus on providing seamless, reliable, and
|
||||||
|
high-performance server hosting for gamers and developers alike.
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-lightGray leading-relaxed">
|
||||||
|
Our platform is built with cutting-edge automation, robust server management
|
||||||
|
capabilities, and an easy-to-use interface to ensure you can focus on
|
||||||
|
playing or developing while we handle the rest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default About;
|
||||||
43
src/app/faq/page.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
|
||||||
|
const FAQ = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-4xl font-heading text-electricBlue mb-6 text-center">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h1>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="border-b border-gray-600 pb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">What is ZeroLagHub?</h2>
|
||||||
|
<p className="text-lightGray text-lg leading-relaxed">
|
||||||
|
ZeroLagHub is a hosting platform specializing in open-source,
|
||||||
|
indie, RPG, and modded game servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-gray-600 pb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">What games are supported?</h2>
|
||||||
|
<p className="text-lightGray text-lg leading-relaxed">
|
||||||
|
We support a wide variety of games, including Minecraft, ARK: Survival Evolved,
|
||||||
|
CS:GO, and more. Custom games can also be hosted upon request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-gray-600 pb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">How do I create a server?</h2>
|
||||||
|
<p className="text-lightGray text-lg leading-relaxed">
|
||||||
|
Once registered, you can create a server directly from your dashboard.
|
||||||
|
Choose your game, allocate resources, and start playing!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FAQ;
|
||||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
70
src/app/features/page.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const Features = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-lg mx-auto text-center">
|
||||||
|
<h1 className="text-5xl font-heading text-electricBlue mb-6">
|
||||||
|
Platform Features
|
||||||
|
</h1>
|
||||||
|
<p className="text-lightGray text-lg mb-10">
|
||||||
|
Discover the powerful features of ZeroLagHub that set us apart.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Blazing Fast Performance",
|
||||||
|
description:
|
||||||
|
"Our servers are optimized for speed and reliability to give you the best experience.",
|
||||||
|
icon: "🔥",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Intuitive Dashboard",
|
||||||
|
description:
|
||||||
|
"Manage your game servers easily with our user-friendly dashboard.",
|
||||||
|
icon: "📊",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "24/7 Support",
|
||||||
|
description:
|
||||||
|
"Get help anytime with our around-the-clock support team.",
|
||||||
|
icon: "💡",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Custom Mod Support",
|
||||||
|
description:
|
||||||
|
"Run modded servers with full customization options tailored to your needs.",
|
||||||
|
icon: "🛠️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Scalable Plans",
|
||||||
|
description:
|
||||||
|
"Choose a plan that grows with your community, from small servers to large-scale environments.",
|
||||||
|
icon: "📈",
|
||||||
|
},
|
||||||
|
].map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-6 bg-darkGray rounded-lg shadow-md hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||||
|
<h2 className="text-electricBlue text-2xl font-bold mb-3">
|
||||||
|
{feature.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lightGray">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Features;
|
||||||
241
src/app/globals.css
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Reset styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body, "Roboto", sans-serif);
|
||||||
|
background-color: #000;
|
||||||
|
background-image: url('/textures/hdsteel-bg.webp');
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--foreground, #f4f4f8);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--electricBlue, #1f8eff);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--neonGreen, #32ff7e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading, "Geist", sans-serif);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
font-family: var(--font-body, "Roboto", sans-serif);
|
||||||
|
background-color: var(--electricBlue, #1f8eff);
|
||||||
|
color: var(--foreground, #f4f4f8);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--neonGreen, #32ff7e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: var(--font-body, "Roboto", sans-serif);
|
||||||
|
background-color: var(--darkGray, #1a1a1a);
|
||||||
|
color: var(--foreground, #f4f4f8);
|
||||||
|
border: 1px solid var(--lightGray, #bbbbbb);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--electricBlue, #1f8eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
background-color: var(--darkGray, #1a1a1a);
|
||||||
|
color: var(--foreground, #f4f4f8);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slowShift {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hudScanLine {
|
||||||
|
0%, 100% { transform: scaleX(1); }
|
||||||
|
50% { transform: scaleX(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 0.2; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slow-shift {
|
||||||
|
animation: slowShift 20s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-hudScanLine {
|
||||||
|
animation: hudScanLine 4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Icons */
|
||||||
|
.material-symbols-rounded {
|
||||||
|
font-family: 'Material Symbols Rounded';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: 'liga';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover glow effect */
|
||||||
|
.hover-glow {
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-glow:hover {
|
||||||
|
box-shadow: 0 0 8px rgba(31, 142, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Steel overlay effect */
|
||||||
|
.bg-steel-overlay {
|
||||||
|
background-image: url('/textures/hdsteel-bg.webp');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: brightness(1.25) contrast(1.15) saturate(1.1);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-steel-texture {
|
||||||
|
background-image: url('/textures/hdsteel-bg.webp');
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HUD beveled shape */
|
||||||
|
.clip-bevel-frame {
|
||||||
|
clip-path: polygon(
|
||||||
|
12px 0%, 100% 0%, 100% 100%, 12px 100%, 0% 92%, 0% 8%
|
||||||
|
);
|
||||||
|
background: linear-gradient(145deg, #0f1114, #0b0d11);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(0, 255, 255, 0.12),
|
||||||
|
0 0 20px rgba(0, 255, 255, 0.06),
|
||||||
|
0 0 80px rgba(0, 255, 255, 0.04);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HUD curved hologram illusion (NEW) */
|
||||||
|
.curved-holo-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) perspective(1200px) rotateX(10deg);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
rgba(0, 255, 255, 0.06) 0%,
|
||||||
|
rgba(0, 255, 255, 0.03) 50%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
|
border-radius: 60% / 15%;
|
||||||
|
filter: brightness(1.4) blur(1.5px) contrast(1.4);
|
||||||
|
opacity: 0.45;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-scanlines::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 255, 255, 0.05),
|
||||||
|
rgba(0, 255, 255, 0.05) 1px,
|
||||||
|
transparent 2px,
|
||||||
|
transparent 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-curved-shell {
|
||||||
|
perspective: 1800px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transform: scale(1.01);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-inner-curved {
|
||||||
|
transform: rotateX(12deg);
|
||||||
|
transform-origin: center top;
|
||||||
|
background: radial-gradient(ellipse at center, #0f1114 0%, #0b0d11 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 50px rgba(0, 255, 255, 0.05),
|
||||||
|
0 20px 40px rgba(0, 255, 255, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* global.css */
|
||||||
|
|
||||||
|
.curved-hud-background {
|
||||||
|
filter: brightness(1.2) contrast(1.1) saturate(1.1);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
31
src/app/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/context/authContext";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import ClientLayout from "../components/ClientLayout";
|
||||||
|
import SteelLayout from "@/components/layouts/SteelLayout"; // ✅ Apply steel wrapper globally
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "ZeroLagHub",
|
||||||
|
description: "Seamless hosting for open-source, indie, RPG, and modded game servers.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<body className="antialiased text-foreground min-h-screen flex flex-col">
|
||||||
|
<AuthProvider>
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<SteelLayout>
|
||||||
|
<ClientLayout>{children}</ClientLayout>
|
||||||
|
</SteelLayout>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/page.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
|
||||||
|
<header className="relative flex-grow flex flex-col items-center justify-center text-center px-6 py-20 bg-cover bg-center">
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
|
<Image
|
||||||
|
src="/images/zlhlogo_enlarged.png"
|
||||||
|
alt="ZeroLagHub Logo"
|
||||||
|
width={140}
|
||||||
|
height={140}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-heading text-electricBlue mb-6">
|
||||||
|
Next-Level Game Hosting
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-lightGray mb-8 max-w-2xl">
|
||||||
|
Optimized servers for open-source, indie, RPG, and modded games.
|
||||||
|
Experience unparalleled speed, scalability, and reliability.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
className="px-8 py-4 bg-electricBlue text-black font-bold text-lg rounded-lg hover:bg-neonGreen transition transform hover:scale-105 shadow-md"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/features"
|
||||||
|
className="px-8 py-4 border border-electricBlue text-electricBlue font-bold text-lg rounded-lg hover:bg-electricBlue hover:text-black transition transform hover:scale-105"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/app/pricing/page.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import GameCard from "@/components/GameCard"; // adjust path as needed
|
||||||
|
import { gameInfo } from "@/data/games";
|
||||||
|
|
||||||
|
const Pricing = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-lg mx-auto text-center">
|
||||||
|
<h1 className="text-5xl font-heading text-electricBlue mb-10">
|
||||||
|
Pricing Plans
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 💳 Pricing Tiers */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Basic",
|
||||||
|
price: "$5/month",
|
||||||
|
features: ["1 GB RAM", "10 GB Disk", "Community Support"],
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Standard",
|
||||||
|
price: "$10/month",
|
||||||
|
features: ["2 GB RAM", "20 GB Disk", "Priority Support"],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pro",
|
||||||
|
price: "$20/month",
|
||||||
|
features: ["4 GB RAM", "50 GB Disk", "24/7 Support"],
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
].map((plan, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-6 bg-darkGray rounded-lg shadow-md border border-gray-700 ${
|
||||||
|
plan.popular
|
||||||
|
? "border-electricBlue scale-105"
|
||||||
|
: "hover:scale-105 hover:border-electricBlue"
|
||||||
|
} transition-transform`}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-electricBlue mb-3">
|
||||||
|
{plan.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-neonGreen mb-4">{plan.price}</p>
|
||||||
|
<ul className="text-lightGray space-y-2">
|
||||||
|
{plan.features.map((feature, i) => (
|
||||||
|
<li key={i}>{feature}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="mt-4 text-sm font-bold text-neonGreen uppercase">
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🧩 GameCard Preview Section */}
|
||||||
|
<div className="mt-16 text-center">
|
||||||
|
<h2 className="text-3xl text-white font-bold mb-6">Supported Games Preview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 justify-items-center">
|
||||||
|
{[
|
||||||
|
"minecraft",
|
||||||
|
"valheim",
|
||||||
|
"rust",
|
||||||
|
"project_zomboid",
|
||||||
|
"terraria",
|
||||||
|
"nonexistent_key", // fallback test
|
||||||
|
].map((key) => (
|
||||||
|
<GameCard
|
||||||
|
key={key}
|
||||||
|
gameKey={key}
|
||||||
|
serverName={gameInfo[key]?.name || "Unknown Server"}
|
||||||
|
status="online"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 📄 Terms of Service */}
|
||||||
|
<div className="mt-16 text-sm text-lightGray">
|
||||||
|
<p>
|
||||||
|
By using ZeroLagHub, you agree to comply with our{" "}
|
||||||
|
<a
|
||||||
|
href="/terms-of-service"
|
||||||
|
className="text-electricBlue underline hover:text-neonGreen transition"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pricing;
|
||||||
68
src/app/privacy-policy/page.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const PrivacyPolicy = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-lg mx-auto">
|
||||||
|
<h1 className="text-5xl font-heading text-electricBlue mb-10">
|
||||||
|
Privacy Policy
|
||||||
|
</h1>
|
||||||
|
<div className="space-y-6 text-lightGray">
|
||||||
|
<p>
|
||||||
|
<strong>Effective Date:</strong> [Insert Date]
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
ZeroLagHub values your privacy. This Privacy Policy explains what
|
||||||
|
data we collect, how we use it, and your rights regarding your
|
||||||
|
information.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">1. Information We Collect</h2>
|
||||||
|
<p>
|
||||||
|
- <strong>Account Information:</strong> Name, email, payment details.
|
||||||
|
<br />
|
||||||
|
- <strong>Usage Data:</strong> IP address, browser type, and usage
|
||||||
|
activity.
|
||||||
|
<br />
|
||||||
|
- <strong>Cookies:</strong> Used to improve site performance and
|
||||||
|
user experience.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">2. How We Use Your Information</h2>
|
||||||
|
<p>
|
||||||
|
- To provide and maintain the Service.
|
||||||
|
<br />
|
||||||
|
- To improve our platform.
|
||||||
|
<br />
|
||||||
|
- To process payments.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">3. Sharing Your Information</h2>
|
||||||
|
<p>
|
||||||
|
- We only share data with third-party services for processing
|
||||||
|
payments, analytics, or as required by law.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">4. Data Security</h2>
|
||||||
|
<p>
|
||||||
|
We use industry-standard measures to secure your data but cannot
|
||||||
|
guarantee absolute security.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">5. User Rights</h2>
|
||||||
|
<p>
|
||||||
|
You can:
|
||||||
|
<br />
|
||||||
|
- Request access to your data.
|
||||||
|
<br />
|
||||||
|
- Request data correction or deletion.
|
||||||
|
<br />
|
||||||
|
- Opt-out of marketing communications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivacyPolicy;
|
||||||
67
src/app/terms-of-service/page.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const TermsOfService = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-transparent px-6 py-10">
|
||||||
|
<div className="max-w-screen-lg mx-auto">
|
||||||
|
<h1 className="text-5xl font-heading text-electricBlue mb-10">
|
||||||
|
Terms of Service
|
||||||
|
</h1>
|
||||||
|
<div className="space-y-6 text-lightGray">
|
||||||
|
<p>
|
||||||
|
<strong>Effective Date:</strong> [Insert Date]
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
These Terms of Service (“Terms”) govern your use of
|
||||||
|
ZeroLagHub’s website, services, and hosting platform. By using our
|
||||||
|
services, you agree to these Terms. Please read them carefully.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">1. Definitions</h2>
|
||||||
|
<p>
|
||||||
|
- “Service” refers to game server hosting and related
|
||||||
|
products provided by ZeroLagHub.
|
||||||
|
<br />
|
||||||
|
- “User” refers to anyone accessing or using the Service.
|
||||||
|
<br />
|
||||||
|
- “Account” refers to a registered profile created by the User.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">2. Acceptable Use</h2>
|
||||||
|
<p>
|
||||||
|
You agree to:
|
||||||
|
<br />
|
||||||
|
- Not use our Service for illegal activities, harassment, or abusive behavior.
|
||||||
|
<br />
|
||||||
|
- Not overuse or abuse resources, disrupting service for others.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">3. Account Responsibilities</h2>
|
||||||
|
<p>
|
||||||
|
- Maintain the confidentiality of your login credentials.
|
||||||
|
<br />
|
||||||
|
- Ensure all account information is accurate and up-to-date.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">4. Payments and Refunds</h2>
|
||||||
|
<p>
|
||||||
|
- Subscriptions are billed monthly.
|
||||||
|
<br />
|
||||||
|
- Refunds are not provided for partial periods or mid-cycle
|
||||||
|
cancellations unless explicitly stated in a promotion.
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl text-electricBlue mt-8">5. Service Availability</h2>
|
||||||
|
<p>
|
||||||
|
- We strive for 99.9% uptime but do not guarantee uninterrupted service.
|
||||||
|
<br />
|
||||||
|
- Downtime caused by third-party providers or scheduled maintenance
|
||||||
|
is not our liability.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TermsOfService;
|
||||||
48
src/components/AuditLogPreview.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
type LogItem = {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
timestamp: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuditLogPreview = () => {
|
||||||
|
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/api/audit");
|
||||||
|
setLogs(res.data.logs || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load audit logs", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-darkGray p-4 rounded shadow">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-2">📜 Recent Activity</h3>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-lightGray text-sm">No recent activity found.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="text-sm text-lightGray space-y-1">
|
||||||
|
{logs.slice(0, 5).map((log) => (
|
||||||
|
<li key={log.id}>
|
||||||
|
• {log.action} <span className="text-xs text-gray-400">({new Date(log.created_at).toLocaleString()})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditLogPreview;
|
||||||
24
src/components/BillingModal.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { useAccessControl } from "@/hooks/useAccessControl";
|
||||||
|
|
||||||
|
export default function BillingModal() {
|
||||||
|
const { isSuspended } = useAccessControl();
|
||||||
|
|
||||||
|
if (!isSuspended) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onConfirm={() => {
|
||||||
|
window.location.href = "/billing";
|
||||||
|
}}
|
||||||
|
title="Account Suspended"
|
||||||
|
message="Your account has been suspended due to billing issues. Please update your billing to continue using ZeroLagHub."
|
||||||
|
confirmText="Go to Billing"
|
||||||
|
cancelText="Close"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/BillingSummaryCard.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const BillingSummaryCard = ({ status, daysRemaining }: { status: string; daysRemaining?: number }) => {
|
||||||
|
const statusColor = {
|
||||||
|
active: "text-green-400",
|
||||||
|
past_due: "text-yellow-400",
|
||||||
|
suspended: "text-red-500",
|
||||||
|
}[status] || "text-white";
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
active: "Your account is in good standing.",
|
||||||
|
past_due: "Your account is past due. Please resolve soon.",
|
||||||
|
suspended: `Account suspended. ${daysRemaining ? `${daysRemaining} day(s) remaining before full lockout.` : ""}`,
|
||||||
|
}[status] || "Billing status unknown.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-darkGray p-4 rounded shadow text-center">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-2">💳 Billing Summary</h3>
|
||||||
|
<p className={`${statusColor} font-semibold`}>{message}</p>
|
||||||
|
<Link href="/billing" className="text-electricBlue underline text-sm mt-2 inline-block">Manage Billing</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingSummaryCard;
|
||||||
54
src/components/ClientLayout.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import HubNavbar from "@/components/HubNavbar";
|
||||||
|
import Navbar from "@/components/Navbar";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import { useAuth } from "@/context/authContext";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const bypassRoutes = ["/billing", "/logout", "/account-suspended"];
|
||||||
|
|
||||||
|
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { billingStatus, suspensionDaysRemaining } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const isBypassRoute = bypassRoutes.some((route) => pathname?.startsWith(route));
|
||||||
|
const isSuspended = billingStatus === "suspended" && suspensionDaysRemaining === 0;
|
||||||
|
|
||||||
|
if (isSuspended && !isBypassRoute) {
|
||||||
|
console.warn("🔒 Lockdown triggered. Redirecting to /account-suspended.");
|
||||||
|
router.replace("/account-suspended");
|
||||||
|
}
|
||||||
|
}, [billingStatus, suspensionDaysRemaining, pathname, mounted, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
|
||||||
|
{/* Show HubNavbar only on /dashboard or /servers */}
|
||||||
|
{pathname?.startsWith("/dashboard") || pathname?.startsWith("/servers") ? (
|
||||||
|
<HubNavbar />
|
||||||
|
) : (
|
||||||
|
<Navbar />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="flex-grow pt-16">{children}</main>
|
||||||
|
|
||||||
|
<footer className="bg-darkGray text-foreground text-center py-4">
|
||||||
|
<Footer />
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
37
src/components/ExpirationModal.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type ExpirationModalProps = {
|
||||||
|
onRefresh: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpirationModal: React.FC<ExpirationModalProps> = ({ onRefresh, onLogout }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 bottom-6 right-6 max-w-sm w-full bg-white border border-gray-200 shadow-lg rounded-xl p-4 animate-fade-in">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-2">
|
||||||
|
Your session is about to expire
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
You’ll be logged out soon. Would you like to stay signed in?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="px-4 py-1.5 text-sm rounded bg-red-500 text-white hover:bg-red-600 transition"
|
||||||
|
>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
Stay Logged In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpirationModal;
|
||||||
17
src/components/Footer.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// src/components/Footer.tsx
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-darkGray text-center text-gray-500 py-6">
|
||||||
|
© {new Date().getFullYear()} ZeroLagHub. All rights reserved.
|
||||||
|
<br />
|
||||||
|
<a href="/terms-of-service" className="text-electricBlue underline hover:text-neonGreen">
|
||||||
|
Terms of Service
|
||||||
|
</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="/privacy-policy" className="text-electricBlue underline hover:text-neonGreen">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/FramedHUDBox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// components/FramedHUDBox.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FramedHUDBox = ({ title, children, className }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
"relative border border-cyan-500 bg-darkGray/80 text-white rounded-md p-5 shadow-lg",
|
||||||
|
"before:absolute before:-top-2 before:left-4 before:h-2 before:w-6 before:bg-cyan-500",
|
||||||
|
"after:absolute after:-bottom-2 after:right-4 after:h-2 after:w-6 after:bg-cyan-500",
|
||||||
|
"hover:shadow-cyan-500/50 transition-all duration-300",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-xs uppercase tracking-wide text-cyan-400 mb-3">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FramedHUDBox;
|
||||||
42
src/components/GameCard.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { gameInfo } from "@/data/games";
|
||||||
|
|
||||||
|
type GameCardProps = {
|
||||||
|
gameKey: string;
|
||||||
|
serverName?: string;
|
||||||
|
status?: "online" | "offline" | "installing";
|
||||||
|
};
|
||||||
|
|
||||||
|
const GameCard = ({ gameKey, serverName, status }: GameCardProps) => {
|
||||||
|
const game = gameInfo[gameKey] || gameInfo["fallback"];
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
online: "text-green-400",
|
||||||
|
offline: "text-red-400",
|
||||||
|
installing: "text-yellow-400",
|
||||||
|
}[status || "offline"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-md hover:shadow-cyan-500/50 overflow-hidden w-full max-w-xs transition">
|
||||||
|
<div className="relative w-full h-36">
|
||||||
|
<Image
|
||||||
|
src={game.image}
|
||||||
|
alt={game.name}
|
||||||
|
fill
|
||||||
|
className="object-cover rounded-t-lg"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 min-h-[90px] flex flex-col justify-center items-center text-center">
|
||||||
|
<h3 className="text-white text-lg font-semibold break-words leading-tight">
|
||||||
|
{serverName || game.name}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm font-medium mt-1 ${statusColor}`}>
|
||||||
|
{status?.toUpperCase() || "OFFLINE"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameCard;
|
||||||
89
src/components/GroupedServerList.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { gameInfo } from "@/data/games";
|
||||||
|
|
||||||
|
// 🔁 Replace the broken import with a local type
|
||||||
|
export type Server = {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
game: string;
|
||||||
|
variant?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
servers: Server[];
|
||||||
|
onAction: (uuid: string, action: string) => void;
|
||||||
|
onConsole: (uuid: string) => void;
|
||||||
|
onUpgrade: (uuid: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupedServerList = ({ servers, onAction, onConsole, onUpgrade }: Props) => {
|
||||||
|
const grouped = servers.reduce<Record<string, Server[]>>((acc, server) => {
|
||||||
|
const game = server.game || "Unknown";
|
||||||
|
if (!acc[game]) acc[game] = [];
|
||||||
|
acc[game].push(server);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{Object.entries(grouped).map(([game, servers]) => {
|
||||||
|
const gameData = gameInfo[game];
|
||||||
|
return (
|
||||||
|
<div key={game}>
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
{gameData?.image && (
|
||||||
|
<Image
|
||||||
|
src={gameData.image}
|
||||||
|
alt={game}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h2 className="text-2xl text-electricBlue font-bold">{game}</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<li
|
||||||
|
key={server.uuid}
|
||||||
|
className="bg-darkGray p-4 rounded shadow flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-bold">{server.name}</p>
|
||||||
|
<p className="text-sm text-gray-400">{server.status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-electricBlue text-black rounded hover:bg-neonGreen transition"
|
||||||
|
onClick={() => onConsole(server.uuid)}
|
||||||
|
>
|
||||||
|
Go to Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 text-sm bg-blue-700 text-white rounded hover:bg-blue-600 transition"
|
||||||
|
onClick={() => onUpgrade(server.uuid)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
{["start", "stop", "restart"].map((action) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => onAction(server.uuid, action)}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-700 text-white rounded hover:bg-gray-600 transition"
|
||||||
|
>
|
||||||
|
{action.charAt(0).toUpperCase() + action.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupedServerList;
|
||||||
33
src/components/HUDBox.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// src/components/HUDBox.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface HUDBoxProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUDBox: React.FC<HUDBoxProps> = ({ children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative bg-black/40 p-6 rounded-lg overflow-hidden border border-cyan-400/20 shadow-[0_0_20px_#00fff733] backdrop-blur-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Outer ring glow layer */}
|
||||||
|
<div className="absolute inset-0 rounded-lg border border-cyan-500/10 blur-sm z-0 pointer-events-none" />
|
||||||
|
{/* Optional additional framing layer */}
|
||||||
|
<div className="absolute inset-1 rounded-lg border border-cyan-300/10 z-0 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HUDBox;
|
||||||
39
src/components/HUDFramedPanel.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface HUDFramedPanelProps {
|
||||||
|
title?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUDFramedPanel: React.FC<HUDFramedPanelProps> = ({ title, icon, children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative bg-black/50 rounded-lg overflow-hidden p-6 border border-cyan-400/20 shadow-[0_0_15px_#00fff733] backdrop-blur-sm",
|
||||||
|
"before:absolute before:inset-0 before:rounded-lg before:border before:border-cyan-300/10 before:blur-sm before:pointer-events-none",
|
||||||
|
"after:absolute after:-top-2 after:-left-2 after:w-6 after:h-6 after:border-t-2 after:border-l-2 after:border-cyan-500",
|
||||||
|
"after:content-[''] before:z-0 z-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(title || icon) && (
|
||||||
|
<div className="flex items-center gap-2 mb-4 z-10 relative">
|
||||||
|
{icon && <span className="text-xl text-electricBlue">{icon}</span>}
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-[10px] tracking-widest uppercase text-cyan-300 font-mono">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative z-10">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HUDFramedPanel;
|
||||||
35
src/components/HUDScaffold.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// src/components/HUDScaffold.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const HUDScaffold = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden bg-black text-white">
|
||||||
|
|
||||||
|
{/* Curved Display Background */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-no-repeat bg-center bg-cover z-0 pointer-events-none opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url('/assets/hud/slices/hud-bg-base.png')",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* HUD Glow Line (optional) */}
|
||||||
|
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-[80%] h-2 bg-gradient-to-r from-cyan-500/20 via-cyan-300/70 to-cyan-500/20 rounded-full blur-sm animate-hudScanLine" />
|
||||||
|
|
||||||
|
{/* Frame Ornaments (optional, can be tuned later) */}
|
||||||
|
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-cyan-500/30" />
|
||||||
|
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-cyan-500/30" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-cyan-500/30" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-cyan-500/30" />
|
||||||
|
|
||||||
|
{/* HUD Content */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HUDScaffold;
|
||||||
72
src/components/HubNavbar.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/features", label: "Features" },
|
||||||
|
{ href: "/pricing", label: "Pricing" },
|
||||||
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/servers", label: "Servers" },
|
||||||
|
{ href: "/faq", label: "FAQ" },
|
||||||
|
{ href: "/support", label: "Support" },
|
||||||
|
{ href: "/about", label: "About" },
|
||||||
|
{ href: "/profile", label: "Profile" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HubNavbar = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string) => pathname === href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Logo & Hub Icon Container */}
|
||||||
|
<div className="fixed top-4 left-4 z-50 flex flex-col items-start space-y-2">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-electricBlue text-lg font-bold">ZeroLagHub</div>
|
||||||
|
|
||||||
|
{/* Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="p-2 rounded-full bg-electricBlue text-black hover:bg-neonGreen transition shadow-md"
|
||||||
|
aria-label="Toggle Navigation Menu"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:hubspot"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
className={open ? "rotate-90 transition-transform duration-300" : "transition-transform duration-300"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown Menu – anchored below logo+button */}
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-[88px] left-4 z-40 w-48 bg-black/90 backdrop-blur border border-electricBlue rounded-md shadow-lg py-3 px-4 space-y-2">
|
||||||
|
{links.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={`block px-4 py-2 rounded text-sm font-medium transition-all duration-200 ${
|
||||||
|
isActive(href)
|
||||||
|
? "bg-electricBlue text-black font-bold shadow-[0_0_10px_#1f8eff80]"
|
||||||
|
: "text-lightGray hover:text-electricBlue hover:bg-darkGray hover:shadow-[0_0_8px_#1f8eff50]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HubNavbar;
|
||||||
64
src/components/HudPanel.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface HudPanelProps {
|
||||||
|
title?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HudPanel: React.FC<HudPanelProps> = ({ title, icon, children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative bg-black/30 rounded-2xl p-6 border border-cyan-500/20 backdrop-blur-md overflow-hidden",
|
||||||
|
"shadow-[0_0_20px_#00fff722]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Animated Glow Border */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl border border-cyan-400/10 animate-borderFlow z-0" />
|
||||||
|
|
||||||
|
{/* Signal Dot */}
|
||||||
|
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-cyan-400 animate-ping z-10" />
|
||||||
|
|
||||||
|
{/* Title Section */}
|
||||||
|
{(title || icon) && (
|
||||||
|
<div className="flex items-center gap-2 mb-4 relative z-10">
|
||||||
|
{icon && <span className="text-xl text-electricBlue">{icon}</span>}
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-[11px] font-mono uppercase tracking-widest text-cyan-300">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10">{children}</div>
|
||||||
|
|
||||||
|
{/* Keyframe Styles */}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes borderFlow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 6px rgba(0, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 6px rgba(0, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-borderFlow {
|
||||||
|
animation: borderFlow 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HudPanel;
|
||||||
50
src/components/MessageBoard.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
type Announcement = {
|
||||||
|
id: number;
|
||||||
|
content: string; // Stored as HTML
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MessageBoard = () => {
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/api/announcements");
|
||||||
|
setAnnouncements(res.data.announcements || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load announcements", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAnnouncements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-lightGray space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<p>No recent announcements.</p>
|
||||||
|
) : (
|
||||||
|
announcements.map((announcement) => (
|
||||||
|
<div
|
||||||
|
key={announcement.id}
|
||||||
|
className="bg-darkGray p-3 rounded"
|
||||||
|
dangerouslySetInnerHTML={{ __html: announcement.content }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageBoard;
|
||||||
52
src/components/Modal.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal: React.FC<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = "Are you sure?",
|
||||||
|
message = "Your session is about to expire.",
|
||||||
|
confirmText = "Stay Logged In",
|
||||||
|
cancelText = "Logout",
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-darkGray p-6 rounded-lg shadow-lg text-center w-full max-w-sm border border-electricBlue">
|
||||||
|
<h2 className="text-lg font-bold text-electricBlue mb-4">{title}</h2>
|
||||||
|
<p className="text-foreground mb-6">{message}</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded bg-red-600 text-white hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
{onConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 rounded bg-neonGreen text-black font-bold hover:bg-electricBlue transition"
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
119
src/components/Navbar.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { useAuth } from "@/context/authContext";
|
||||||
|
import SuspensionBanner from "@/components/SuspensionBanner";
|
||||||
|
import UserDropdownMenu from "@/components/UserDropdownMenu";
|
||||||
|
|
||||||
|
const Navbar: React.FC = () => {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
setToken,
|
||||||
|
showModal,
|
||||||
|
refreshToken,
|
||||||
|
logout,
|
||||||
|
profile,
|
||||||
|
suspensionDaysRemaining,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const [nickname, setNickname] = useState("User");
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile?.nickname) {
|
||||||
|
setNickname(profile.nickname);
|
||||||
|
} else {
|
||||||
|
setNickname("User");
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setToken(null);
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = (path: string) => pathname === path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SuspensionBanner daysRemaining={suspensionDaysRemaining} />
|
||||||
|
|
||||||
|
<nav className="bg-black/80 backdrop-blur-sm border-b border-electricBlue text-foreground flex items-center justify-between px-6 py-3 sticky top-0 z-50 shadow-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-electricBlue text-xl font-bold hover:text-neonGreen transition"
|
||||||
|
>
|
||||||
|
ZeroLagHub
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Nav Links */}
|
||||||
|
<div className="hidden md:flex space-x-4 text-sm">
|
||||||
|
{[
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/features", label: "Features" },
|
||||||
|
{ href: "/pricing", label: "Pricing" },
|
||||||
|
...(token
|
||||||
|
? [
|
||||||
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/servers", label: "Servers" },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ href: "/faq", label: "FAQ" },
|
||||||
|
{ href: "/support", label: "Support" },
|
||||||
|
{ href: "/about", label: "About" },
|
||||||
|
].map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`px-2 py-1 rounded-md transition
|
||||||
|
${
|
||||||
|
isActive(link.href)
|
||||||
|
? "text-electricBlue font-bold border-b-2 border-neonGreen"
|
||||||
|
: "text-lightGray font-medium hover:text-electricBlue hover:bg-darkGray"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Buttons or User Menu */}
|
||||||
|
{token ? (
|
||||||
|
<UserDropdownMenu nickname={nickname} onLogout={handleLogout} />
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:flex gap-2">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="px-3 py-1 rounded-md bg-darkGray text-lightGray font-semibold hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="px-3 py-1 rounded-md bg-electricBlue text-black font-semibold hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Session Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={logout}
|
||||||
|
onConfirm={refreshToken}
|
||||||
|
title="Session Expiring"
|
||||||
|
message="Your session is about to expire. Would you like to stay logged in?"
|
||||||
|
confirmText="Stay Logged In"
|
||||||
|
cancelText="Logout"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
41
src/components/RadialStat.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { CircularProgressbar, buildStyles } from "react-circular-progressbar";
|
||||||
|
import "react-circular-progressbar/dist/styles.css";
|
||||||
|
|
||||||
|
interface RadialStatProps {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadialStat: React.FC<RadialStatProps> = ({ value, label, color = "#00FFF7", max = 100 }) => {
|
||||||
|
const percent = Math.min((value / max) * 100, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative text-center">
|
||||||
|
{/* Outer glow ring */}
|
||||||
|
<div className="absolute inset-0 z-0 rounded-full shadow-[0_0_20px_4px_rgba(0,255,247,0.2)]" />
|
||||||
|
|
||||||
|
{/* Ring meter */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
<CircularProgressbar
|
||||||
|
value={percent}
|
||||||
|
text={`${Math.round(value)}${max === 100 ? "%" : " MB"}`}
|
||||||
|
styles={buildStyles({
|
||||||
|
pathColor: color,
|
||||||
|
trailColor: "#222",
|
||||||
|
textColor: "#fff",
|
||||||
|
textSize: "16px"
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs mt-2 uppercase tracking-wide text-cyan-300 font-mono">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadialStat;
|
||||||
58
src/components/ServerCard.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { gameInfo } from "@/data/games";
|
||||||
|
|
||||||
|
type ServerCardProps = {
|
||||||
|
name: string;
|
||||||
|
game: string;
|
||||||
|
status: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerCard = ({ name, game, status, children }: ServerCardProps) => {
|
||||||
|
const normalizedKey = typeof game === "string"
|
||||||
|
? game.toLowerCase().replace(/\s+/g, "_")
|
||||||
|
: "fallback";
|
||||||
|
const gameData = gameInfo[normalizedKey] || gameInfo["fallback"];
|
||||||
|
console.log("🧠 Raw game key:", game);
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
online: "text-green-400",
|
||||||
|
offline: "text-red-500",
|
||||||
|
starting: "text-yellow-400",
|
||||||
|
stopping: "text-gray-400",
|
||||||
|
suspended: "text-orange-400",
|
||||||
|
unknown: "text-gray-400",
|
||||||
|
}[status || "unknown"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center bg-darkGray rounded-lg shadow-lg overflow-hidden p-3 w-full">
|
||||||
|
{/* Game Thumbnail */}
|
||||||
|
<div className="relative w-52 h-50 rounded overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={gameData.image}
|
||||||
|
alt={gameData.name}
|
||||||
|
width={428}
|
||||||
|
height={400}
|
||||||
|
className="rounded object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Info */}
|
||||||
|
<div className="flex flex-col justify-center ml-4 flex-grow">
|
||||||
|
<p className="text-lightGray font-semibold text-base leading-tight">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<span className={`text-sm font-medium mt-1 ${statusColor}`}>
|
||||||
|
{status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 items-center">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerCard;
|
||||||
31
src/components/ServerHUDOverview.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// src/components/ServerHUDOverview.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
offline: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerHUDOverview: React.FC<Props> = ({ total, online, offline }) => {
|
||||||
|
const StatBlock = ({ label, value, color }: { label: string; value: number; color: string }) => (
|
||||||
|
<div className="text-center px-4 py-3 bg-black/60 rounded border border-cyan-400/10 shadow-inner">
|
||||||
|
<h3 className="text-[10px] text-cyan-200 tracking-widest uppercase font-mono mb-1">{label}</h3>
|
||||||
|
<div className={`text-3xl font-bold`} style={{ color }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-center">
|
||||||
|
<StatBlock label="Total Servers" value={total} color="#1f9fff" />
|
||||||
|
<StatBlock label="Online" value={online} color="#00ff88" />
|
||||||
|
<StatBlock label="Offline" value={offline} color="#ff4d4d" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerHUDOverview;
|
||||||
30
src/components/SuspendedRedirect.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/context/authContext";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
const bypassRoutes = ["/billing", "/logout", "/account-suspended"];
|
||||||
|
|
||||||
|
export default function SuspendedRedirect({ children }: { children: React.ReactNode }) {
|
||||||
|
const { billingStatus, suspensionDaysRemaining } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isBypassRoute = useMemo(
|
||||||
|
() => bypassRoutes.some((route) => pathname?.startsWith(route)),
|
||||||
|
[pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isSuspended = billingStatus === "suspended" && suspensionDaysRemaining === 0;
|
||||||
|
const hasSeenRedirect = sessionStorage.getItem("seenSuspensionRedirect") === "true";
|
||||||
|
|
||||||
|
if (isSuspended && !isBypassRoute && !hasSeenRedirect) {
|
||||||
|
sessionStorage.setItem("seenSuspensionRedirect", "true");
|
||||||
|
router.replace("/account-suspended");
|
||||||
|
}
|
||||||
|
}, [billingStatus, suspensionDaysRemaining, isBypassRoute, router]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
21
src/components/SuspensionBanner.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// components/SuspensionBanner.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daysRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuspensionBanner: React.FC<Props> = ({ daysRemaining }) => {
|
||||||
|
if (daysRemaining === null || daysRemaining <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-yellow-500 text-black text-center py-2 font-semibold">
|
||||||
|
⚠️ Account suspension in {daysRemaining} day{daysRemaining > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuspensionBanner;
|
||||||
35
src/components/SystemControlHUD.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
const SystemControlHUD: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative bg-black/50 border border-cyan-400/30 shadow-[0_0_25px_#00fff722] p-6 rounded-lg overflow-hidden"
|
||||||
|
style={{
|
||||||
|
clipPath:
|
||||||
|
"polygon(0 10px, 10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px))"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated Glow Ring */}
|
||||||
|
<div className="absolute inset-0 rounded-lg border-2 border-cyan-300 animate-pulse opacity-10 z-0" />
|
||||||
|
|
||||||
|
{/* Animated Background Grid (optional SVG pattern) */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle,#00fff722_1px,transparent_1px)] bg-[size:20px_20px] opacity-5 z-0" />
|
||||||
|
|
||||||
|
{/* Center Radar Ping */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
||||||
|
<Icon icon="mdi:radar" fontSize={64} className="text-cyan-400 animate-pulse opacity-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HUD Content */}
|
||||||
|
<div className="relative z-20 text-center">
|
||||||
|
<h2 className="text-[10px] font-mono uppercase tracking-widest text-cyan-300 mb-1">System Control</h2>
|
||||||
|
<p className="text-sm text-lightGray">Welcome to the central hub interface.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemControlHUD;
|
||||||
49
src/components/TechFramePanel.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// src/components/TechFramePanel.tsx
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TechFramePanel = ({ title, children, className }: Props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative p-5 text-white bg-gradient-to-br from-[#0f1114] to-[#0b0d11]",
|
||||||
|
"border border-cyan-400/20 shadow-[0_0_20px_#00ffff33]",
|
||||||
|
"backdrop-blur-md overflow-hidden",
|
||||||
|
"clip-bevel-frame",
|
||||||
|
"transform-gpu rotate-x-[3deg] hover:scale-[1.02] perspective-[1200px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
|
||||||
|
>
|
||||||
|
{/* 🔷 Curved Holographic Illusion */}
|
||||||
|
<div className="absolute inset-0 z-0 curved-holo-backdrop" />
|
||||||
|
|
||||||
|
{/* Glow Frame Overlay */}
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none before:absolute before:inset-0 before:rounded-md before:border-2 before:border-cyan-400/10 before:shadow-[inset_0_0_30px_#00ffff22] before:content-['']" />
|
||||||
|
|
||||||
|
{/* Bevel Edge */}
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none after:absolute after:inset-1 after:border-t after:border-l after:border-cyan-500/10 after:rounded-md after:content-['']" />
|
||||||
|
|
||||||
|
{/* Optional Scanning Line */}
|
||||||
|
<div className="absolute inset-0 z-0 bg-gradient-to-r from-transparent via-cyan-500/5 to-transparent animate-scanStripe" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-cyan-400 font-semibold mb-3">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechFramePanel;
|
||||||
7
src/components/TopHUDBar.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// components/TopHUDBar.tsx
|
||||||
|
const TopHUDBar = () => (
|
||||||
|
<div className="w-full h-3 bg-gradient-to-r from-cyan-500 via-transparent to-cyan-500 opacity-50 blur-sm mt-2 mb-4" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TopHUDBar;
|
||||||
|
|
||||||
64
src/components/UserDropdownMenu.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// components/UserDropdownMenu.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nickname: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserDropdownMenu: React.FC<Props> = ({ nickname, onLogout }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-full bg-electricBlue text-black font-bold text-sm hover:bg-neonGreen transition"
|
||||||
|
>
|
||||||
|
{nickname.charAt(0).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 bg-darkGray text-lightGray rounded shadow-lg w-40">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block px-4 py-2 hover:bg-electricBlue hover:text-black"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/billing"
|
||||||
|
className="block px-4 py-2 hover:bg-electricBlue hover:text-black"
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="block w-full text-left px-4 py-2 hover:bg-electricBlue hover:text-black"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDropdownMenu;
|
||||||
48
src/components/WelcomeCoreHUD.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
const WelcomeCoreHUD: React.FC<{ username: string }> = ({ username }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-2xl p-6 border border-cyan-500/20 bg-black/30 shadow-[0_0_20px_#00fff722] backdrop-blur-md overflow-hidden">
|
||||||
|
|
||||||
|
{/* Border Flow Animation Layer */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl pointer-events-none z-0">
|
||||||
|
<div className="absolute inset-0 rounded-2xl border border-cyan-400/20 animate-borderFlow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HUD Content */}
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="flex justify-center items-center gap-2 mb-1">
|
||||||
|
<Icon icon="mdi:account-circle-outline" fontSize={28} className="text-cyan-300" />
|
||||||
|
<h2 className="text-[11px] font-mono uppercase tracking-widest text-cyan-200">Welcome Back</h2>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-electricBlue drop-shadow-[0_0_4px_#1f9fff88]">
|
||||||
|
{username}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-lightGray mt-1">Your hub is connected and stable.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyframe style */}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes borderFlow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-borderFlow {
|
||||||
|
animation: borderFlow 6s linear infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WelcomeCoreHUD;
|
||||||
33
src/components/layouts/SteelLayout.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SteelLayout = ({ children }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen text-foreground overflow-hidden">
|
||||||
|
{/* Brushed Steel Background */}
|
||||||
|
<div
|
||||||
|
className="bg-steel-texture"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Optional Ambient Glow (subtle radial light effect) */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(ellipse at center, rgba(0,255,255,0.02), transparent)",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Layer */}
|
||||||
|
<main className="relative z-10">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SteelLayout;
|
||||||
49
src/components/server-controls/ServerControls.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type ServerControlsProps = {
|
||||||
|
status?: "online" | "offline" | "starting" | "stopping" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServerControls({ status = "unknown" }: ServerControlsProps) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-white/10 bg-black/40 p-4 text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-widest text-white/60">
|
||||||
|
Server Controls
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/80">Status: {status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/20 px-3 py-1 text-xs text-white/60"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/20 px-3 py-1 text-xs text-white/60"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/20 px-3 py-1 text-xs text-white/60"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-white/50">
|
||||||
|
Actions are disabled until the control plane API is wired in.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/terminal/TerminalView.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Terminal } from "xterm";
|
||||||
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
|
type TerminalViewProps = {
|
||||||
|
welcomeMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TerminalView({
|
||||||
|
welcomeMessage = "Terminal ready. Awaiting connection...",
|
||||||
|
}: TerminalViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 2000,
|
||||||
|
fontSize: 13,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.open(containerRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
terminal.writeln(welcomeMessage);
|
||||||
|
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
terminalRef.current?.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
};
|
||||||
|
}, [welcomeMessage]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="h-full w-full" />;
|
||||||
|
}
|
||||||
174
src/context/authContext.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
nickname?: string;
|
||||||
|
billing_status?: string;
|
||||||
|
suspended_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextProps {
|
||||||
|
token: string | null;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
isReady: boolean;
|
||||||
|
tokenRestored: boolean;
|
||||||
|
showModal: boolean;
|
||||||
|
refreshToken: () => void;
|
||||||
|
logout: () => void;
|
||||||
|
profile: UserProfile | null;
|
||||||
|
billingStatus: string | null;
|
||||||
|
suspensionDaysRemaining: number | null;
|
||||||
|
isLoadingProfile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [token, setTokenState] = useState<string | null>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [tokenRestored, setTokenRestored] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [billingStatus, setBillingStatus] = useState<string | null>(null);
|
||||||
|
const [suspensionDaysRemaining, setSuspensionDaysRemaining] = useState<number | null>(null);
|
||||||
|
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||||
|
|
||||||
|
// Load token from localStorage on initial mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedToken = localStorage.getItem("token");
|
||||||
|
if (storedToken) {
|
||||||
|
setTokenState(storedToken);
|
||||||
|
}
|
||||||
|
setTokenRestored(true);
|
||||||
|
setIsReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setToken = (newToken: string | null) => {
|
||||||
|
if (newToken) {
|
||||||
|
localStorage.setItem("token", newToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
}
|
||||||
|
setTokenState(newToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshToken = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/refresh-token`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("🔁 Token refreshed:", data.token);
|
||||||
|
setToken(data.token);
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("🔒 Refresh failed:", err);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null);
|
||||||
|
setProfile(null);
|
||||||
|
setBillingStatus(null);
|
||||||
|
setSuspensionDaysRemaining(null);
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile Fetch & Suspension Countdown
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!token) {
|
||||||
|
console.warn("⚠️ No token set for profile fetch.");
|
||||||
|
setIsLoadingProfile(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/profile`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("✅ Profile fetched:", data);
|
||||||
|
|
||||||
|
setProfile(data);
|
||||||
|
setBillingStatus(data.billing_status || null);
|
||||||
|
|
||||||
|
if (data.suspended_at) {
|
||||||
|
const suspendedAt = new Date(data.suspended_at);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - suspendedAt.getTime();
|
||||||
|
const elapsedDays = diffMs / (1000 * 60 * 60 * 24); // days as float
|
||||||
|
const daysRemaining = Math.max(0, Math.floor(7 - elapsedDays));
|
||||||
|
|
||||||
|
console.log("📆 suspended_at:", suspendedAt.toISOString());
|
||||||
|
console.log("⏳ suspensionDaysRemaining (floor):", daysRemaining);
|
||||||
|
|
||||||
|
setSuspensionDaysRemaining(daysRemaining);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ No suspended_at value found.");
|
||||||
|
setSuspensionDaysRemaining(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to fetch profile:", error);
|
||||||
|
setProfile(null);
|
||||||
|
setBillingStatus(null);
|
||||||
|
setSuspensionDaysRemaining(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokenRestored && token) {
|
||||||
|
fetchProfile();
|
||||||
|
}
|
||||||
|
}, [tokenRestored, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
token,
|
||||||
|
setToken,
|
||||||
|
isReady,
|
||||||
|
tokenRestored,
|
||||||
|
showModal,
|
||||||
|
refreshToken,
|
||||||
|
logout,
|
||||||
|
profile,
|
||||||
|
billingStatus,
|
||||||
|
suspensionDaysRemaining,
|
||||||
|
isLoadingProfile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
29
src/data/games.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export const gameInfo: Record<string, { name: string; image: string }> = {
|
||||||
|
minecraft: { name: "Minecraft", image: "/img/games/minecraft.png" },
|
||||||
|
valheim: { name: "Valheim", image: "/img/games/valheim.png" },
|
||||||
|
project_zomboid: { name: "Project Zomboid", image: "/img/games/project_zomboid.png" },
|
||||||
|
rust: { name: "Rust", image: "/img/games/rust.png" },
|
||||||
|
terraria: { name: "Terraria", image: "/img/games/terraria.png" },
|
||||||
|
|
||||||
|
// Minecraft variants
|
||||||
|
paper: { name: "Paper (Minecraft)", image: "/img/games/minecraft.png" },
|
||||||
|
forge_enhanced: { name: "Forge Enhanced", image: "/img/games/minecraft.png" },
|
||||||
|
fabric: { name: "Fabric", image: "/img/games/minecraft.png" },
|
||||||
|
vanilla_bedrock: { name: "Vanilla Bedrock", image: "/img/games/minecraft.png" },
|
||||||
|
pocketminemp: { name: "PocketmineMP", image: "/img/games/minecraft.png" },
|
||||||
|
|
||||||
|
// Valheim mods
|
||||||
|
valheim_plus_mod: { name: "Valheim Plus Mod", image: "/img/games/valheim.png" },
|
||||||
|
valheim_bepinex: { name: "Valheim BepInEx", image: "/img/games/valheim.png" },
|
||||||
|
|
||||||
|
// Rust variants
|
||||||
|
rust_autowipe: { name: "Rust Autowipe", image: "/img/games/rust.png" },
|
||||||
|
rust_staging: { name: "Rust Staging", image: "/img/games/rust.png" },
|
||||||
|
|
||||||
|
// Terraria mods
|
||||||
|
tmodloader: { name: "tModLoader", image: "/img/games/terraria.png" },
|
||||||
|
tshock: { name: "tShock", image: "/img/games/terraria.png" },
|
||||||
|
|
||||||
|
fallback: { name: "Unknown Game", image: "/img/games/fallback.png" },
|
||||||
|
};
|
||||||
|
|
||||||
46
src/hooks/useAccessControl.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// src/hooks/useAccessControl.ts
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/context/authContext";
|
||||||
|
import api from "@/lib/api-client"; // ✅ Using Axios instead of fetch
|
||||||
|
|
||||||
|
export const useAccessControl = (options = { autoRedirect: false }) => {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"unknown" | "active" | "suspended" | "past_due">("unknown");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/api/users/profile", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
setStatus(data.billing_status ?? "active"); // fallback to active if missing
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to fetch billing status:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) fetchStatus();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (options.autoRedirect && status === "suspended") {
|
||||||
|
router.push("/billing-issue");
|
||||||
|
}
|
||||||
|
}, [status, options.autoRedirect, router]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
billingStatus: status,
|
||||||
|
isSuspended: status === "suspended",
|
||||||
|
isActive: status === "active",
|
||||||
|
};
|
||||||
|
};
|
||||||
62
src/lib/api-client.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||||
|
import { refreshAccessToken } from "@/utils/tokenUtils";
|
||||||
|
|
||||||
|
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://api.zerolaghub.com";
|
||||||
|
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: apiBaseUrl,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBrowserToken = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.localStorage.getItem("token");
|
||||||
|
};
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = getBrowserToken();
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as RetryableRequest | undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
originalRequest &&
|
||||||
|
!originalRequest._retry
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
|
||||||
|
if (newToken && originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
14
src/lib/sockets.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type SocketConfig = {
|
||||||
|
url: string;
|
||||||
|
protocols?: string | string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSocket = ({ url, protocols }: SocketConfig) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
throw new Error("WebSocket can only be created in the browser.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WebSocket(url, protocols);
|
||||||
|
};
|
||||||
9
src/services/routes.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// src/services/routes.ts
|
||||||
|
|
||||||
|
export const NESTS_ROUTE = "/api/nests"; // For fetching nests
|
||||||
|
export const EGGS_ROUTE = (nestId: number) => `/api/nests/${nestId}/eggs`; // For fetching eggs in a specific nest
|
||||||
|
export const LOGIN_ROUTE = "/auth/login"; // For user login
|
||||||
|
export const REGISTER_ROUTE = "/auth/register"; // For user registration
|
||||||
|
export const ALLOCATIONS_ROUTE = "api/environment/allocations"; // For fetching allocations
|
||||||
|
export const CREATE_SERVER_ROUTE = "/servers/create"; // For creating a server
|
||||||
|
export const SERVERS_ROUTE = "/servers"; // For fetching servers
|
||||||
38
src/styles.old/About.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/* Page background */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #000000, #1a1a1a 70%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About container styling */
|
||||||
|
.about-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title styling */
|
||||||
|
.about-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About text */
|
||||||
|
.about-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
59
src/styles.old/App.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
body {
|
||||||
|
background-color: #000; /* Set a black background */
|
||||||
|
color: #00f5d4; /* Adjust text color for contrast */
|
||||||
|
font-family: 'Arial', sans-serif; /* Replace with your preferred font */
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: transparent; /* Ensure the root does not overwrite the body background */
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); /* Optional: Add a subtle shadow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
background-color: #111; /* Optional: Add a subtle card background */
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); /* Subtle shadow for better aesthetics */
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
92
src/styles.old/CreateServer.css
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/* General container styling */
|
||||||
|
.create-server-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form heading */
|
||||||
|
.create-server-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input and select fields styling */
|
||||||
|
.server-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-form input,
|
||||||
|
.server-form select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-form input:focus,
|
||||||
|
.server-form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Advanced options checkbox */
|
||||||
|
.server-form input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.server-form button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #00aaff;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-form button:disabled {
|
||||||
|
background-color: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-form button:hover:not(:disabled) {
|
||||||
|
background-color: #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success and error messages */
|
||||||
|
.success-text,
|
||||||
|
.error-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
72
src/styles.old/Dashboard.css
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/* Page background */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #000000, #1a1a1a 70%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%; /* Ensure the background covers the full height */
|
||||||
|
display: flex; /* Add flex to center the content vertically if needed */
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* General container styling */
|
||||||
|
.dashboard-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: none; /* Remove any conflicting background from the container */
|
||||||
|
color: #ffffff; /* Light text for contrast */
|
||||||
|
min-height: calc(100vh - 80px); /* Ensure it spans the full view height minus navbar */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard title styling */
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00b7ff; /* Neon blue for a modern look */
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-description {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 1.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Subtitle for instructions or descriptions */
|
||||||
|
.dashboard-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #cccccc; /* Lighter text for contrast */
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.button-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjustments for mobile and responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/styles.old/Faq.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* Page background */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #000000, #1a1a1a 70%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAQ container */
|
||||||
|
.faq-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title styling */
|
||||||
|
.faq-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAQ items */
|
||||||
|
.faq-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #444444;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
55
src/styles.old/Features.css
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/* Features Page Styling */
|
||||||
|
.features-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #1a1a1a, #000000);
|
||||||
|
min-height: calc(100vh - 80px); /* Adjust for navbar height */
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin: 10px 0 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
background-color: #121212;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 6px 15px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item p {
|
||||||
|
color: #bbbbbb;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
85
src/styles.old/Home.css
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/* Gradient Background */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #000000, #1a1a1a 70%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Inter", sans-serif; /* Adjust to match your global font */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Container */
|
||||||
|
.home-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.home-content {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #00b7ff; /* Neon blue */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #cccccc; /* Light gray */
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.home-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button {
|
||||||
|
background-color: #00b7ff;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background-color 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button:hover {
|
||||||
|
background-color: #007bbd;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button-alt {
|
||||||
|
background-color: #555555;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background-color 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button-alt:hover {
|
||||||
|
background-color: #444444;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.home-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaaaaa; /* Subtle gray for footer */
|
||||||
|
}
|
||||||
85
src/styles.old/Login.css
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/* Center the login container */
|
||||||
|
.login-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* Center horizontally */
|
||||||
|
align-items: center; /* Center vertically */
|
||||||
|
background: linear-gradient(135deg, #000, #111 70%); /* Subtle gradient background */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the login card */
|
||||||
|
.login-card {
|
||||||
|
display: flex; /* Use flexbox to align items within the card */
|
||||||
|
flex-direction: column; /* Stack items vertically */
|
||||||
|
justify-content: center; /* Center items vertically within the card */
|
||||||
|
align-items: center; /* Center items horizontally within the card */
|
||||||
|
background: #111; /* Dark card background */
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 183, 255, 0.8), 0 0 50px rgba(0, 183, 255, 0.4); /* Blue glow effect */
|
||||||
|
text-align: center;
|
||||||
|
width: 90%; /* Responsive width */
|
||||||
|
max-width: 400px; /* Ensure the card doesn't get too large */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the logo */
|
||||||
|
.login-logo {
|
||||||
|
width: 150px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 10px; /* Add rounded corners to the logo */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the login title */
|
||||||
|
h1 {
|
||||||
|
color: #00b7ff; /* Neon blue text */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the form layout */
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the form labels */
|
||||||
|
label {
|
||||||
|
color: #00b7ff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the input fields */
|
||||||
|
input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #00b7ff;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.3s ease; /* Smooth transition for border */
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: #666; /* Placeholder text color */
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: #007bbd; /* Change border color on focus */
|
||||||
|
outline: none; /* Remove default browser outline */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the login button */
|
||||||
|
button {
|
||||||
|
padding: 0.7rem;
|
||||||
|
background: #00b7ff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #007bbd; /* Slightly darker hover effect */
|
||||||
|
}
|
||||||
79
src/styles.old/Navbar.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/* Navbar container */
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #000; /* Black background */
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 2px solid #007bff; /* Blue border effect */
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar links container */
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar link styling */
|
||||||
|
.navbar-link {
|
||||||
|
color: #007bff; /* Blue text */
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: #000; /* Black background for links */
|
||||||
|
border: 2px solid #007bff; /* Blue border */
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logout button styling */
|
||||||
|
.logout-button {
|
||||||
|
color: #007bff;
|
||||||
|
background-color: #000; /* Match navbar background */
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects */
|
||||||
|
.navbar-link:hover,
|
||||||
|
.logout-button:hover {
|
||||||
|
background-color: #007bff; /* Blue background on hover */
|
||||||
|
color: #000; /* Black text on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active link styling */
|
||||||
|
.navbar-link.active {
|
||||||
|
background-color: #007bff; /* Active blue background */
|
||||||
|
color: #000; /* Black text */
|
||||||
|
border-color: #000; /* Black border */
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 183, 255, 0.8); /* Add glow for active link */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/styles.old/Pricing.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.terms-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-section h2 {
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-section p {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
69
src/styles.old/Register.css
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
.register-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 183, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form label {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button {
|
||||||
|
background-color: #00b7ff;
|
||||||
|
color: #000;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button:hover {
|
||||||
|
background-color: #007bbd;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ff4d4d;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: #4caf50;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
96
src/styles.old/Servers.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/* General container styling */
|
||||||
|
.servers-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #121212; /* Matches theme */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title styling */
|
||||||
|
.servers-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server list grid */
|
||||||
|
.servers-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual server item */
|
||||||
|
.server-item {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 6px 15px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Console button styling */
|
||||||
|
.console-button {
|
||||||
|
background-color: #00b7ff;
|
||||||
|
color: #000;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-button:hover {
|
||||||
|
background-color: #007bbd;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Server button styling */
|
||||||
|
.create-server-button {
|
||||||
|
background-color: #00b7ff;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px; /* Slightly larger padding for consistency */
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: background-color 0.3s, transform 0.3s ease;
|
||||||
|
margin-top: 20px; /* Adds spacing between the button and the list */
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto; /* Center the button */
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-server-button:hover {
|
||||||
|
background-color: #007bbd;
|
||||||
|
transform: scale(1.05); /* Slightly enlarge on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No servers message */
|
||||||
|
.no-servers-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #bbbbbb;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.error-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #ff4d4d;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
79
src/styles.old/Support.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/* Support container */
|
||||||
|
.support-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #121212; /* Dark background */
|
||||||
|
color: #ffffff; /* Light text */
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title styling */
|
||||||
|
.support-container h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 183, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styling */
|
||||||
|
.support-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form input,
|
||||||
|
.support-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #00b7ff;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #000; /* Dark input background */
|
||||||
|
color: #fff; /* Light text */
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form textarea {
|
||||||
|
resize: none; /* Disable resizing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form button {
|
||||||
|
background-color: #00b7ff;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form button:hover {
|
||||||
|
background-color: #007bbd; /* Slightly darker blue */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error and success messages */
|
||||||
|
.error-text {
|
||||||
|
color: #ff4d4d;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
44
src/styles.old/Terms.css
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/* Page background */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #000000, #1a1a1a 70%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terms container */
|
||||||
|
.terms-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title styling */
|
||||||
|
.terms-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00b7ff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 183, 255, 0.8), 0 0 25px rgba(0, 183, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section titles */
|
||||||
|
.terms-section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #bbbbbb;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
1
src/types/css.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
10
src/types/js-cookie.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
declare module "js-cookie" {
|
||||||
|
interface CookiesStatic {
|
||||||
|
get(name: string): string | undefined;
|
||||||
|
set(name: string, value: string, options?: { expires?: number | Date; path?: string }): void;
|
||||||
|
remove(name: string, options?: { path?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cookies: CookiesStatic;
|
||||||
|
export default Cookies;
|
||||||
|
}
|
||||||
46
src/utils/tokenUtils.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// src/utils/tokenUtils.ts
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const isBrowser = typeof window !== "undefined";
|
||||||
|
|
||||||
|
export const getAccessToken = async (): Promise<string | null> => {
|
||||||
|
if (!isBrowser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToken = window.localStorage.getItem("token");
|
||||||
|
if (!storedToken) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optional: validate token here
|
||||||
|
return storedToken;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to validate token:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshAccessToken = async (): Promise<string | null> => {
|
||||||
|
if (!isBrowser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post("https://api.zerolaghub.com/oauth/token", {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: window.localStorage.getItem("refresh_token"),
|
||||||
|
client_id: process.env.NEXT_PUBLIC_CLIENT_ID,
|
||||||
|
client_secret: process.env.NEXT_PUBLIC_CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token, refresh_token } = res.data;
|
||||||
|
window.localStorage.setItem("token", access_token);
|
||||||
|
window.localStorage.setItem("refresh_token", refresh_token);
|
||||||
|
return access_token;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to refresh token:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
61
tailwind.config.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
|
||||||
|
safelist: ["bg-steel-texture"],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "#111215",
|
||||||
|
foreground: "#F8F9FA",
|
||||||
|
electricBlue: "#1F8EFF",
|
||||||
|
neonGreen: "#32FF7E",
|
||||||
|
dangerRed: "#FF5E5E",
|
||||||
|
darkGray: "#1A1A1A",
|
||||||
|
lightGray: "#CCCCCC",
|
||||||
|
slateSatin: "#0f1218",
|
||||||
|
obsidian: "#121212",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
heading: ["Geist", "sans-serif"],
|
||||||
|
mono: ["Geist Mono", "monospace"],
|
||||||
|
body: ["Roboto", "sans-serif"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
subtle: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
glow: "0 0 8px rgba(31, 142, 255, 0.8)",
|
||||||
|
"glow-inner": "inset 0 0 200px rgba(0, 255, 255, 0.02)",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'steel-gradient': "linear-gradient(135deg, #101215, #0b0c0f)",
|
||||||
|
'glow-overlay': "radial-gradient(ellipse at center, rgba(0,255,255,0.03), transparent)",
|
||||||
|
'steel-texture': "url('/textures/hdsteel-bg.webp')",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
slowShift: {
|
||||||
|
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||||
|
'50%': { backgroundPosition: '100% 50%' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'slow-shift': 'slowShift 20s ease infinite',
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
xs: "480px",
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
screenLg: "1280px",
|
||||||
|
screenMd: "1024px",
|
||||||
|
screenSm: "768px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
darkMode: "class",
|
||||||
|
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
|
||||||
|
} satisfies Config;
|
||||||