tambahan
This commit is contained in:
@@ -12,54 +12,115 @@ import { basename, extname, join, relative } from "path";
|
|||||||
const PAGES_DIR = join(process.cwd(), "src/pages");
|
const PAGES_DIR = join(process.cwd(), "src/pages");
|
||||||
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
|
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Prefetch Helper Template
|
||||||
|
******************************/
|
||||||
|
const PREFETCH_HELPER = `
|
||||||
/**
|
/**
|
||||||
* ✅ Ubah nama file menjadi PascalCase
|
* Prefetch lazy component:
|
||||||
* - Support: snake_case, kebab-case, camelCase, PascalCase
|
* - Hover
|
||||||
|
* - Visible (viewport)
|
||||||
|
* - Browser idle
|
||||||
*/
|
*/
|
||||||
const toComponentName = (fileName: string): string => {
|
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
|
||||||
return fileName
|
if (!el) return;
|
||||||
.replace(/\.[^/.]+$/, "") // hilangkan ekstensi file
|
let done = false;
|
||||||
.replace(/[_-]+/g, " ") // snake_case & kebab-case → spasi
|
|
||||||
.replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase → spasi
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase()) // kapital tiap kata
|
|
||||||
.replace(/\s+/g, ""); // gabung semua → PascalCase
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const run = () => {
|
||||||
* ✅ Normalisasi nama menjadi path route (kebab-case)
|
if (done) return;
|
||||||
*/
|
done = true;
|
||||||
|
preload();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) On hover
|
||||||
|
el.addEventListener("pointerenter", run, { once: true });
|
||||||
|
|
||||||
|
// 2) On visible (IntersectionObserver)
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (entries && entries[0] && entries[0].isIntersecting) {
|
||||||
|
run();
|
||||||
|
io.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
io.observe(el);
|
||||||
|
|
||||||
|
// 3) On idle
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
requestIdleCallback(() => run());
|
||||||
|
} else {
|
||||||
|
setTimeout(run, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Component Name Generator
|
||||||
|
******************************/
|
||||||
|
const toComponentName = (fileName: string): string =>
|
||||||
|
fileName
|
||||||
|
.replace(/\.[^/.]+$/, "")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Route Path Normalizer
|
||||||
|
******************************/
|
||||||
function toRoutePath(name: string): string {
|
function toRoutePath(name: string): string {
|
||||||
name = name.replace(/\.[^/.]+$/, ""); // hapus ekstensi
|
name = name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
||||||
if (name.toLowerCase() === "home") return "/";
|
if (name.toLowerCase() === "home") return "/";
|
||||||
if (name.toLowerCase() === "login") return "/login";
|
if (name.toLowerCase() === "login") return "/login";
|
||||||
if (name.toLowerCase() === "notfound") return "/*";
|
if (name.toLowerCase() === "notfound") return "/*";
|
||||||
|
|
||||||
// Hapus prefix/suffix umum
|
if (name.startsWith("[") && name.endsWith("]"))
|
||||||
name = name.replace(/_page$/i, "").replace(/^form_/i, "");
|
return `:${name.slice(1, -1)}`;
|
||||||
|
|
||||||
// ✅ Normalisasi ke kebab-case
|
name = name.replace(/_page$/i, "").replace(/^form_/i, "");
|
||||||
return _.kebabCase(name);
|
return _.kebabCase(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧭 Scan folder pages secara rekursif
|
/******************************
|
||||||
|
* Scan Folder + Validation + Dynamic Duplicate Check
|
||||||
|
******************************/
|
||||||
function scan(dir: string): any[] {
|
function scan(dir: string): any[] {
|
||||||
const items = readdirSync(dir);
|
const items = readdirSync(dir);
|
||||||
const routes: any[] = [];
|
const routes: any[] = [];
|
||||||
|
const dynamicParams = new Set<string>();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const full = join(dir, item);
|
const full = join(dir, item);
|
||||||
const stat = statSync(full);
|
const stat = statSync(full);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
|
||||||
|
console.warn(`⚠️ Invalid folder name: ${item}`);
|
||||||
|
}
|
||||||
|
|
||||||
routes.push({
|
routes.push({
|
||||||
name: item,
|
name: item,
|
||||||
path: _.kebabCase(item),
|
path: _.kebabCase(item),
|
||||||
children: scan(full),
|
children: scan(full),
|
||||||
});
|
});
|
||||||
} else if (extname(item) === ".tsx") {
|
} else if (extname(item) === ".tsx") {
|
||||||
|
const base = basename(item, ".tsx");
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_[\]-]+$/.test(base)) {
|
||||||
|
console.warn(`⚠️ Invalid file name: ${item}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.startsWith("[") && base.endsWith("]")) {
|
||||||
|
const p = base.slice(1, -1);
|
||||||
|
if (dynamicParams.has(p)) {
|
||||||
|
console.error(`❌ Duplicate dynamic param "${p}" in ${dir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
dynamicParams.add(p);
|
||||||
|
}
|
||||||
|
|
||||||
routes.push({
|
routes.push({
|
||||||
name: basename(item, ".tsx"),
|
name: base,
|
||||||
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
|
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -67,163 +128,233 @@ function scan(dir: string): any[] {
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🏗️ Generate <Route> JSX dari struktur folder
|
/******************************
|
||||||
|
* Index Detection
|
||||||
|
******************************/
|
||||||
|
function findIndexFile(folderName: string, children: any[]) {
|
||||||
|
const lower = folderName.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
children.find((r: any) => r.name.toLowerCase().endsWith("_home")) ||
|
||||||
|
children.find((r: any) => r.name.toLowerCase() === "index") ||
|
||||||
|
children.find((r: any) => r.name.toLowerCase() === `${lower}_page`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Generate JSX <Route> (Lazy + Prefetch)
|
||||||
|
******************************/
|
||||||
function generateJSX(routes: any[], parentPath = ""): string {
|
function generateJSX(routes: any[], parentPath = ""): string {
|
||||||
let jsx = "";
|
let jsx = "";
|
||||||
|
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
if (route.children) {
|
if (route.children) {
|
||||||
const layout = route.children.find((r: any) => r.name.endsWith("_layout"));
|
const layout = route.children.find((r: any) =>
|
||||||
|
r.name.endsWith("_layout")
|
||||||
|
);
|
||||||
|
|
||||||
if (layout) {
|
if (layout) {
|
||||||
const LayoutComponent = toComponentName(layout.name.replace("_layout", "Layout"));
|
const LayoutComp = toComponentName(
|
||||||
const nested = route.children.filter((r: any) => r !== layout);
|
layout.name.replace("_layout", "Layout")
|
||||||
|
);
|
||||||
|
|
||||||
|
const nested = route.children.filter((x: any) => x !== layout);
|
||||||
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
|
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
|
||||||
|
|
||||||
const homeFile = route.children.find((r: any) =>
|
const indexFile = findIndexFile(route.name, route.children);
|
||||||
r.name.toLowerCase().endsWith("_home")
|
|
||||||
);
|
const indexRoute = indexFile
|
||||||
const indexRoute = homeFile
|
? `<Route index element={<${toComponentName(
|
||||||
? `<Route index element={<${toComponentName(homeFile.name)} />} />\n`
|
indexFile.name
|
||||||
: "";
|
)}.Component />} />`
|
||||||
|
: `<Route index element={<Navigate to="${(
|
||||||
|
parentPath +
|
||||||
|
"/" +
|
||||||
|
route.path +
|
||||||
|
"/" +
|
||||||
|
(nested[0]?.name ?? "")
|
||||||
|
).replace(/\/+/g, "/")}" replace />}/>`;
|
||||||
|
|
||||||
jsx += `
|
jsx += `
|
||||||
<Route path="${parentPath}/${route.path}" element={<${LayoutComponent} />}>
|
<Route path="${parentPath}/${route.path}" element={<${LayoutComp}.Component />}>
|
||||||
${indexRoute}
|
${indexRoute}
|
||||||
${nestedRoutes}
|
${nestedRoutes}
|
||||||
</Route>
|
</Route>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
|
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const Component = toComponentName(route.name);
|
const Comp = toComponentName(route.name);
|
||||||
const routePath = toRoutePath(route.name);
|
const routePath = toRoutePath(route.name);
|
||||||
|
|
||||||
const fullPath = routePath.startsWith("/")
|
const fullPath = routePath.startsWith("/")
|
||||||
? routePath
|
? routePath
|
||||||
: `${parentPath}/${routePath}`.replace(/\/+/g, "/");
|
: `${parentPath}/${routePath}`.replace(/\/+/g, "/");
|
||||||
|
|
||||||
jsx += `<Route path="${fullPath}" element={<${Component} />} />\n`;
|
jsx += `
|
||||||
|
<Route
|
||||||
|
path="${fullPath}"
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<${Comp}.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsx;
|
return jsx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧾 Generate import otomatis
|
/******************************
|
||||||
|
* Lazy Import + Prefetch Injection
|
||||||
|
******************************/
|
||||||
function generateImports(routes: any[]): string {
|
function generateImports(routes: any[]): string {
|
||||||
const imports = new Set<string>();
|
const list: string[] = [];
|
||||||
|
|
||||||
function collect(rs: any[]) {
|
function walk(rs: any[]) {
|
||||||
for (const r of rs) {
|
for (const r of rs) {
|
||||||
if (r.children) collect(r.children);
|
if (r.children) walk(r.children);
|
||||||
else {
|
else {
|
||||||
const Comp = toComponentName(r.name);
|
const C = toComponentName(r.name);
|
||||||
imports.add(`import ${Comp} from "./${r.filePath.replace(/\.tsx$/, "")}";`);
|
const file = r.filePath.replace(/\.tsx$/, "");
|
||||||
|
|
||||||
|
list.push(`
|
||||||
|
const ${C} = {
|
||||||
|
Component: React.lazy(() => import("./${file}")),
|
||||||
|
preload: () => import("./${file}")
|
||||||
|
};
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collect(routes);
|
walk(routes);
|
||||||
return Array.from(imports).join("\n");
|
return list.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Generate AppRoutes.tsx
|
||||||
|
******************************/
|
||||||
function generateRoutes() {
|
function generateRoutes() {
|
||||||
const allRoutes = scan(PAGES_DIR);
|
const allRoutes = scan(PAGES_DIR);
|
||||||
const imports = generateImports(allRoutes);
|
const imports = generateImports(allRoutes);
|
||||||
const jsxRoutes = generateJSX(allRoutes);
|
const jsx = generateJSX(allRoutes);
|
||||||
|
|
||||||
const finalCode = `
|
let loadingSkeleton = `
|
||||||
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
|
const SkeletonLoading = () => {
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
return (
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
const final = `
|
||||||
|
// ⚡ AUTO-GENERATED — DO NOT EDIT
|
||||||
|
import React from "react";
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { Skeleton } from "@mantine/core";
|
||||||
|
|
||||||
|
${loadingSkeleton}
|
||||||
|
${PREFETCH_HELPER}
|
||||||
${imports}
|
${imports}
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
${jsxRoutes}
|
${jsx}
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
writeFileSync(OUTPUT_FILE, finalCode);
|
writeFileSync(OUTPUT_FILE, final);
|
||||||
console.log(`✅ Routes generated → ${OUTPUT_FILE}`);
|
console.log(`✅ Routes generated → ${OUTPUT_FILE}`);
|
||||||
|
|
||||||
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]);
|
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Extract untuk clientRoutes.ts ---
|
/******************************
|
||||||
const SRC_DIR = path.resolve(process.cwd(), "src");
|
* Extract flat client routes
|
||||||
const APP_ROUTES_FILE = path.join(SRC_DIR, "AppRoutes.tsx");
|
******************************/
|
||||||
|
const SRC_DIR = path.resolve("src");
|
||||||
|
const APP_ROUTES_FILE = join(SRC_DIR, "AppRoutes.tsx");
|
||||||
|
|
||||||
interface RouteNode {
|
interface RouteNode {
|
||||||
path: string;
|
path: string;
|
||||||
children: RouteNode[];
|
children: RouteNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttributePath(attrs: (t.JSXAttribute | t.JSXSpreadAttribute)[]) {
|
function getAttributePath(attrs: any[]) {
|
||||||
const pathAttr = attrs.find(
|
const attr = attrs.find(
|
||||||
(attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "path" })
|
(a) => t.isJSXAttribute(a) && a.name.name === "path"
|
||||||
) as t.JSXAttribute | undefined;
|
) as any;
|
||||||
|
|
||||||
if (pathAttr && t.isStringLiteral(pathAttr.value)) return pathAttr.value.value;
|
return attr?.value?.value ?? "";
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRouteNodes(node: t.JSXElement): RouteNode | null {
|
function extractRouteNodes(node: t.JSXElement): RouteNode | null {
|
||||||
const opening = node.openingElement;
|
const op = node.openingElement;
|
||||||
if (!t.isJSXIdentifier(opening.name) || opening.name.name !== "Route") return null;
|
if (!t.isJSXIdentifier(op.name) || op.name.name !== "Route") return null;
|
||||||
|
|
||||||
const currentPath = getAttributePath(opening.attributes);
|
const cur = getAttributePath(op.attributes);
|
||||||
const children: RouteNode[] = [];
|
const children: RouteNode[] = [];
|
||||||
|
|
||||||
for (const child of node.children) {
|
for (const c of node.children) {
|
||||||
if (t.isJSXElement(child)) {
|
if (t.isJSXElement(c)) {
|
||||||
const childNode = extractRouteNodes(child);
|
const n = extractRouteNodes(c);
|
||||||
if (childNode) children.push(childNode);
|
if (n) children.push(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { path: currentPath, children };
|
return { path: cur, children };
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenRoutes(node: RouteNode, parentPath = ""): Record<string, string> {
|
function flattenRoutes(node: RouteNode, parent = ""): Record<string, string> {
|
||||||
const record: Record<string, string> = {};
|
const r: Record<string, string> = {};
|
||||||
let fullPath = node.path;
|
let full = node.path;
|
||||||
|
|
||||||
if (fullPath) {
|
if (full) {
|
||||||
if (!fullPath.startsWith("/")) {
|
if (!full.startsWith("/"))
|
||||||
if (parentPath) {
|
full =
|
||||||
if (fullPath === "/") fullPath = parentPath;
|
parent && full !== "/"
|
||||||
else fullPath = `${parentPath.replace(/\/$/, "")}/${fullPath}`;
|
? `${parent.replace(/\/$/, "")}/${full}`
|
||||||
}
|
: "/" + full;
|
||||||
if (!fullPath.startsWith("/")) fullPath = `/${fullPath}`;
|
full = full.replace(/\/+/g, "/");
|
||||||
}
|
r[full] = full;
|
||||||
fullPath = fullPath.replace(/\/+/g, "/");
|
|
||||||
record[fullPath] = fullPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of node.children) {
|
for (const c of node.children)
|
||||||
Object.assign(record, flattenRoutes(child, fullPath || parentPath));
|
Object.assign(r, flattenRoutes(c, full || parent));
|
||||||
}
|
|
||||||
return record;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRoutes(code: string): Record<string, string> {
|
function extractRoutes(code: string) {
|
||||||
const ast = parser.parse(code, {
|
const ast = parser.parse(code, {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
plugins: ["typescript", "jsx"],
|
plugins: ["jsx", "typescript"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const routes: Record<string, string> = {};
|
const routes: Record<string, string> = {};
|
||||||
|
|
||||||
traverse(ast, {
|
traverse(ast, {
|
||||||
JSXElement(path) {
|
JSXElement(p) {
|
||||||
const opening = path.node.openingElement;
|
const op = p.node.openingElement;
|
||||||
if (t.isJSXIdentifier(opening.name) && opening.name.name === "Routes") {
|
|
||||||
for (const child of path.node.children) {
|
if (t.isJSXIdentifier(op.name) && op.name.name === "Routes") {
|
||||||
if (t.isJSXElement(child)) {
|
for (const c of p.node.children) {
|
||||||
const node = extractRouteNodes(child);
|
if (t.isJSXElement(c)) {
|
||||||
if (node) Object.assign(routes, flattenRoutes(node));
|
const root = extractRouteNodes(c);
|
||||||
|
if (root) Object.assign(routes, flattenRoutes(root));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,28 +364,53 @@ function extractRoutes(code: string): Record<string, string> {
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function route() {
|
/******************************
|
||||||
generateRoutes();
|
* Type-Safe Route Builder
|
||||||
|
******************************/
|
||||||
|
function generateTypeSafe(routes: Record<string, string>) {
|
||||||
|
const keys = Object.keys(routes).filter((x) => !x.includes("*"));
|
||||||
|
const union = keys.map((x) => `"${x}"`).join(" | ");
|
||||||
|
|
||||||
if (!fs.existsSync(APP_ROUTES_FILE)) {
|
const code = `
|
||||||
console.error("❌ AppRoutes.tsx not found in src/");
|
export type AppRoute = ${union};
|
||||||
process.exit(1);
|
|
||||||
|
export function route(path: AppRoute, params?: Record<string,string|number>) {
|
||||||
|
if (!params) return path;
|
||||||
|
let final = path;
|
||||||
|
for (const k of Object.keys(params)) {
|
||||||
|
final = final.replace(":" + k, params[k] + "") as AppRoute;
|
||||||
}
|
}
|
||||||
|
return final;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(join(SRC_DIR, "routeTypes.ts"), code);
|
||||||
|
console.log("📄 routeTypes.ts generated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* MAIN
|
||||||
|
******************************/
|
||||||
|
export default function run() {
|
||||||
|
generateRoutes();
|
||||||
|
|
||||||
const code = fs.readFileSync(APP_ROUTES_FILE, "utf-8");
|
const code = fs.readFileSync(APP_ROUTES_FILE, "utf-8");
|
||||||
const routes = extractRoutes(code);
|
const routes = extractRoutes(code);
|
||||||
|
|
||||||
console.log("✅ Generated Routes:");
|
const out = join(SRC_DIR, "clientRoutes.ts");
|
||||||
console.log(routes);
|
|
||||||
|
|
||||||
const outPath = path.join(SRC_DIR, "clientRoutes.ts");
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
outPath,
|
out,
|
||||||
`// AUTO-GENERATED FILE\nconst clientRoutes = ${JSON.stringify(routes, null, 2)} as const;\n\nexport default clientRoutes;`
|
`// AUTO-GENERATED\nconst clientRoutes = ${JSON.stringify(
|
||||||
|
routes,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)} as const;\nexport default clientRoutes;`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📄 clientRoutes.ts saved → ${outPath}`);
|
console.log(`📄 clientRoutes.ts saved → ${out}`);
|
||||||
|
|
||||||
|
generateTypeSafe(routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
route()
|
run();
|
||||||
|
|
||||||
|
|||||||
8
bun.lock
8
bun.lock
@@ -14,7 +14,6 @@
|
|||||||
"@mantine/notifications": "^8.3.8",
|
"@mantine/notifications": "^8.3.8",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"elysia": "^1.4.16",
|
"elysia": "^1.4.16",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
|
"zod": "^4.1.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
@@ -148,8 +148,6 @@
|
|||||||
|
|
||||||
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""],
|
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""],
|
||||||
|
|
||||||
"add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""],
|
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""],
|
||||||
|
|
||||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||||
@@ -344,7 +342,7 @@
|
|||||||
|
|
||||||
"zhead": ["zhead@2.2.4", "", {}, ""],
|
"zhead": ["zhead@2.2.4", "", {}, ""],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "", {}, ""],
|
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||||
|
|
||||||
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, ""],
|
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, ""],
|
||||||
|
|
||||||
@@ -361,5 +359,7 @@
|
|||||||
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, ""],
|
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, ""],
|
||||||
|
|
||||||
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": "bin/nanoid.js" }, ""],
|
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": "bin/nanoid.js" }, ""],
|
||||||
|
|
||||||
|
"@scalar/themes/@scalar/types/zod": ["zod@3.25.76", "", {}, ""],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -22,7 +22,6 @@
|
|||||||
"@mantine/notifications": "^8.3.8",
|
"@mantine/notifications": "^8.3.8",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"elysia": "^1.4.16",
|
"elysia": "^1.4.16",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
@@ -30,21 +29,22 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"swr": "^2.3.6"
|
"swr": "^2.3.6",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"prisma": "^6.19.0",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19.2.6",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/lodash": "^4.17.21",
|
||||||
|
"@types/jwt-decode": "^3.1.0",
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
"@babel/traverse": "^7.28.5",
|
"@babel/traverse": "^7.28.5",
|
||||||
"@babel/types": "^7.28.5",
|
"@babel/types": "^7.28.5",
|
||||||
"@types/babel__traverse": "^7.28.0",
|
"@types/babel__traverse": "^7.28.0",
|
||||||
"@types/bun": "latest",
|
|
||||||
"@types/jwt-decode": "^3.1.0",
|
|
||||||
"@types/lodash": "^4.17.21",
|
|
||||||
"@types/react": "^19.2.6",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1"
|
||||||
"prisma": "^6.19.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AppRoutes from "./AppRoutes";
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider defaultColorScheme="dark">
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
|||||||
@@ -1,24 +1,136 @@
|
|||||||
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
|
// ⚡ AUTO-GENERATED — DO NOT EDIT
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import React from "react";
|
||||||
import Login from "./pages/Login";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import Home from "./pages/Home";
|
import { Skeleton } from "@mantine/core";
|
||||||
import ApikeyPage from "./pages/dashboard/apikey/apikey_page";
|
|
||||||
import DashboardPage from "./pages/dashboard/dashboard_page";
|
const SkeletonLoading = () => {
|
||||||
import DashboardLayout from "./pages/dashboard/dashboard_layout";
|
return (
|
||||||
import NotFound from "./pages/NotFound";
|
<div style={{ padding: "20px" }}>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch lazy component:
|
||||||
|
* - Hover
|
||||||
|
* - Visible (viewport)
|
||||||
|
* - Browser idle
|
||||||
|
*/
|
||||||
|
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
|
||||||
|
if (!el) return;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
preload();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) On hover
|
||||||
|
el.addEventListener("pointerenter", run, { once: true });
|
||||||
|
|
||||||
|
// 2) On visible (IntersectionObserver)
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (entries && entries[0] && entries[0].isIntersecting) {
|
||||||
|
run();
|
||||||
|
io.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
io.observe(el);
|
||||||
|
|
||||||
|
// 3) On idle
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
requestIdleCallback(() => run());
|
||||||
|
} else {
|
||||||
|
setTimeout(run, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = {
|
||||||
|
Component: React.lazy(() => import("./pages/Login")),
|
||||||
|
preload: () => import("./pages/Login"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const Home = {
|
||||||
|
Component: React.lazy(() => import("./pages/Home")),
|
||||||
|
preload: () => import("./pages/Home"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApikeyPage = {
|
||||||
|
Component: React.lazy(() => import("./pages/dashboard/apikey/apikey_page")),
|
||||||
|
preload: () => import("./pages/dashboard/apikey/apikey_page"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardPage = {
|
||||||
|
Component: React.lazy(() => import("./pages/dashboard/dashboard_page")),
|
||||||
|
preload: () => import("./pages/dashboard/dashboard_page"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardLayout = {
|
||||||
|
Component: React.lazy(() => import("./pages/dashboard/dashboard_layout")),
|
||||||
|
preload: () => import("./pages/dashboard/dashboard_layout"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotFound = {
|
||||||
|
Component: React.lazy(() => import("./pages/NotFound")),
|
||||||
|
preload: () => import("./pages/NotFound"),
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route
|
||||||
<Route path="/" element={<Home />} />
|
path="/login"
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<Login.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
<Route
|
||||||
<Route path="/dashboard/apikey/apikey" element={<ApikeyPage />} />
|
path="/"
|
||||||
<Route path="/dashboard/dashboard" element={<DashboardPage />} />
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<Home.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/dashboard" element={<DashboardLayout.Component />}>
|
||||||
|
<Route index element={<DashboardPage.Component />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/dashboard/apikey/apikey"
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<ApikeyPage.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/dashboard/dashboard"
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<DashboardPage.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/*" element={<NotFound />} />
|
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<SkeletonLoading />}>
|
||||||
|
<NotFound.Component />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
414
src/Landing.tsx
Normal file
414
src/Landing.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import clientRoutes from "./clientRoutes";
|
||||||
|
|
||||||
|
export function LandingPage() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NexaFlow - Modern AI Solutions</title>
|
||||||
|
<style>{`
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
nav {
|
||||||
|
padding: 20px 0;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(45deg, #fff, #e0e0e0);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-nav {
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: #260c668a;
|
||||||
|
color: #667eea;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-nav:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero {
|
||||||
|
padding: 150px 0 100px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
animation: fadeInUp 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
opacity: 0.9;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
animation: fadeInUp 1s ease 0.2s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeInUp 1s ease 0.4s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 16px 40px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features Section */
|
||||||
|
.features {
|
||||||
|
padding: 100px 0;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:nth-child(2) .feature-icon {
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:nth-child(3) .feature-icon {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
opacity: 0.9;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
|
.stats {
|
||||||
|
padding: 80px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item h3 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(45deg, #fff, #f0f0f0);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item p {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
padding: 60px 0;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links a {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links a:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features h2 {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div className="container">
|
||||||
|
<div className="nav-content">
|
||||||
|
<div className="logo">NexaFlow</div>
|
||||||
|
<ul className="nav-links">
|
||||||
|
<li><a href="#features">Features</a></li>
|
||||||
|
<li><a href="#about">About</a></li>
|
||||||
|
<li><a href="#contact">Contact</a></li>
|
||||||
|
<li><a href={clientRoutes["/dashboard"]} className="cta-nav">Get Started</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="hero">
|
||||||
|
<div className="container">
|
||||||
|
<h1>Transform Your Workflow with AI</h1>
|
||||||
|
<p>Powerful automation and intelligent insights to boost your productivity and streamline operations</p>
|
||||||
|
<div className="hero-buttons">
|
||||||
|
<a href="#" className="btn btn-primary">Start Free Trial</a>
|
||||||
|
<a href="#" className="btn btn-secondary">Watch Demo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="features" id="features">
|
||||||
|
<div className="container">
|
||||||
|
<h2>Why Choose NexaFlow?</h2>
|
||||||
|
<div className="features-grid">
|
||||||
|
<div className="feature-card">
|
||||||
|
<div className="feature-icon">⚡</div>
|
||||||
|
<h3>Lightning Fast</h3>
|
||||||
|
<p>Experience blazing fast performance with our optimized infrastructure and cutting-edge technology</p>
|
||||||
|
</div>
|
||||||
|
<div className="feature-card">
|
||||||
|
<div className="feature-icon">🔒</div>
|
||||||
|
<h3>Secure & Reliable</h3>
|
||||||
|
<p>Enterprise-grade security with 99.9% uptime guarantee to keep your data safe and accessible</p>
|
||||||
|
</div>
|
||||||
|
<div className="feature-card">
|
||||||
|
<div className="feature-icon">🎯</div>
|
||||||
|
<h3>Smart Analytics</h3>
|
||||||
|
<p>Gain actionable insights with AI-powered analytics and make data-driven decisions effortlessly</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stats">
|
||||||
|
<div className="container">
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-item">
|
||||||
|
<h3>50K+</h3>
|
||||||
|
<p>Active Users</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<h3>99.9%</h3>
|
||||||
|
<p>Uptime</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<h3>24/7</h3>
|
||||||
|
<p>Support</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<h3>150+</h3>
|
||||||
|
<p>Integrations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div className="container">
|
||||||
|
<div className="logo">NexaFlow</div>
|
||||||
|
<p>Empowering businesses with intelligent automation</p>
|
||||||
|
<div className="social-links">
|
||||||
|
<a href="#">𝕏</a>
|
||||||
|
<a href="#">in</a>
|
||||||
|
<a href="#">f</a>
|
||||||
|
</div>
|
||||||
|
<p style={{marginTop: '30px', fontSize: '14px'}}>© 2025 NexaFlow. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED FILE
|
// AUTO-GENERATED
|
||||||
const clientRoutes = {
|
const clientRoutes = {
|
||||||
"/login": "/login",
|
"/login": "/login",
|
||||||
"/": "/",
|
"/": "/",
|
||||||
@@ -7,5 +7,4 @@ const clientRoutes = {
|
|||||||
"/dashboard/dashboard": "/dashboard/dashboard",
|
"/dashboard/dashboard": "/dashboard/dashboard",
|
||||||
"/*": "/*"
|
"/*": "/*"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default clientRoutes;
|
export default clientRoutes;
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import Swagger from "@elysiajs/swagger";
|
import Swagger from "@elysiajs/swagger";
|
||||||
import html from "./index.html";
|
import html from "./index.html";
|
||||||
import Dashboard from "./server/routes/darmasaba";
|
|
||||||
import { apiAuth } from "./server/middlewares/apiAuth";
|
import { apiAuth } from "./server/middlewares/apiAuth";
|
||||||
import Auth from "./server/routes/auth_route";
|
import Auth from "./server/routes/auth_route";
|
||||||
import ApiKeyRoute from "./server/routes/apikey_route";
|
import ApiKeyRoute from "./server/routes/apikey_route";
|
||||||
import type { User } from "generated/prisma";
|
import type { User } from "generated/prisma";
|
||||||
|
import { LandingPage } from "./Landing";
|
||||||
|
import { renderToReadableStream } from "react-dom/server";
|
||||||
|
import { cors } from "@elysiajs/cors";
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
const Docs = new Elysia().use(
|
const Docs = new Elysia().use(
|
||||||
Swagger({
|
Swagger({
|
||||||
@@ -25,18 +29,39 @@ const ApiUser = new Elysia({
|
|||||||
const Api = new Elysia({
|
const Api = new Elysia({
|
||||||
prefix: "/api",
|
prefix: "/api",
|
||||||
})
|
})
|
||||||
|
|
||||||
.use(apiAuth)
|
.use(apiAuth)
|
||||||
.use(ApiKeyRoute)
|
.use(ApiKeyRoute)
|
||||||
.use(Dashboard)
|
|
||||||
.use(ApiUser);
|
.use(ApiUser);
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
.use(
|
||||||
|
cors({
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowedHeaders: ["Content-Type"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
.use(Api)
|
.use(Api)
|
||||||
.use(Docs)
|
.use(Docs)
|
||||||
.use(Auth)
|
.use(Auth)
|
||||||
.get("*", html)
|
.get("/", async () => {
|
||||||
.listen(3000, () => {
|
const stream = await renderToReadableStream(<LandingPage />);
|
||||||
console.log("Server running at http://localhost:3000");
|
return new Response(stream, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.get("/assets/:name", (ctx) => {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(`public/${encodeURIComponent(ctx.params.name)}`);
|
||||||
|
return new Response(file);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response("File not found", { status: 404 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get("/*", html)
|
||||||
|
.listen(PORT, () => {
|
||||||
|
console.log(`Server running at http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServerApp = typeof app;
|
export type ServerApp = typeof app;
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import apiFetch from "../lib/apiFetch";
|
import apiFetch from "../lib/apiFetch";
|
||||||
|
import clientRoutes from "@/clientRoutes";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -41,6 +44,22 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkSession() {
|
||||||
|
try {
|
||||||
|
// backend otomatis baca cookie JWT dari request
|
||||||
|
const res = await apiFetch.api.user.find.get();
|
||||||
|
setIsAuthenticated(res.status === 200);
|
||||||
|
} catch {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isAuthenticated === null) return null;
|
||||||
|
if (isAuthenticated) return <Navigate to={clientRoutes["/dashboard"]} replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} py={80}>
|
<Container size={420} py={80}>
|
||||||
<Card shadow="sm" radius="md" padding="xl">
|
<Card shadow="sm" radius="md" padding="xl">
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { Container, Text, Anchor } from "@mantine/core";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container>
|
||||||
<h1>404 Not Found</h1>
|
<Text size="xl" ta="center" mb="md">404 Not Found</Text>
|
||||||
</div>
|
<Text ta="center" mb="lg">The page you are looking for does not exist.</Text>
|
||||||
|
<Text ta="center">
|
||||||
|
<Anchor href="/" c="blue" underline="hover">Go back home</Anchor>
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function CreateApiKey() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
if (!name || !description ) {
|
if (!name || !description) {
|
||||||
showNotification({
|
showNotification({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "All fields are required",
|
message: "All fields are required",
|
||||||
@@ -48,11 +48,12 @@ function CreateApiKey() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const res = await apiFetch.api.apikey.create.post({
|
const res = await apiFetch.api.apikey.create.post({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
expiredAt: expiredAt ? new Date(expiredAt).toISOString() : new Date().toISOString(),
|
expiredAt: expiredAt
|
||||||
|
? new Date(expiredAt).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default function DashboardLayout() {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
padding="md"
|
padding="md"
|
||||||
|
|||||||
11
src/routeTypes.ts
Normal file
11
src/routeTypes.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
export type AppRoute = "/login" | "/" | "/dashboard" | "/dashboard/apikey/apikey" | "/dashboard/dashboard";
|
||||||
|
|
||||||
|
export function route(path: AppRoute, params?: Record<string,string|number>) {
|
||||||
|
if (!params) return path;
|
||||||
|
let final = path;
|
||||||
|
for (const k of Object.keys(params)) {
|
||||||
|
final = final.replace(":" + k, params[k] + "") as AppRoute;
|
||||||
|
}
|
||||||
|
return final;
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia
|
|||||||
import { type ElysiaCookie } from 'elysia/cookies'
|
import { type ElysiaCookie } from 'elysia/cookies'
|
||||||
|
|
||||||
import { prisma } from '@/server/lib/prisma'
|
import { prisma } from '@/server/lib/prisma'
|
||||||
import type { User } from 'generated/prisma'
|
|
||||||
|
|
||||||
const secret = process.env.JWT_SECRET
|
const secret = process.env.JWT_SECRET
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import Elysia from "elysia";
|
|
||||||
|
|
||||||
const Dashboard = new Elysia({
|
|
||||||
prefix: "/dashboard"
|
|
||||||
})
|
|
||||||
.get("/apa", () => "Hello World")
|
|
||||||
|
|
||||||
export default Dashboard
|
|
||||||
Reference in New Issue
Block a user