spxkth 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/config/db.js +20 -0
- package/controllers/reportsController.js +33 -0
- package/controllers/sparePartsController.js +77 -0
- package/controllers/usersController.js +79 -0
- package/index.js +70 -0
- package/middlewares/index.js +47 -0
- package/package.json +31 -0
- package/routes/reports.js +10 -0
- package/routes/spareParts.js +20 -0
- package/routes/users.js +20 -0
- package/themes/DashboardTheme.jsx +45 -0
- package/themes/DetailTheme.jsx +55 -0
- package/themes/FormTheme.jsx +58 -0
- package/themes/ListTheme.jsx +75 -0
- package/themes/ThemeWrapper.jsx +102 -0
- package/themes/index.js +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# spxkth
|
|
2
|
+
|
|
3
|
+
A modular, domain-agnostic Node.js backend framework with universal UI themes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g spxkth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Domain-Agnostic Backend**: Automatically registers routes and controllers for any business domain.
|
|
14
|
+
- **Universal Themes**: Build your entire frontend using only 4 highly-configurable themes (Dashboard, List, Form, Detail).
|
|
15
|
+
- **RESTful Standards**: Enforced CRUD patterns for predictable development.
|
|
16
|
+
- **CLI Support**: Run `spxkth` to start your framework-powered server.
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
1. Install the package globally.
|
|
21
|
+
2. Run `spxkth` in your project directory.
|
|
22
|
+
3. Access the API at `http://localhost:3000/api`.
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT © spxkth
|
package/config/db.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
|
|
4
|
+
dotenv.config();
|
|
5
|
+
|
|
6
|
+
export const dbConfig = {
|
|
7
|
+
host: process.env.DB_HOST || 'localhost',
|
|
8
|
+
user: process.env.DB_USER || 'root',
|
|
9
|
+
password: process.env.DB_PASSWORD || '',
|
|
10
|
+
database: process.env.DB_NAME || 'spxkth_db',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const pool = mysql.createPool(dbConfig);
|
|
14
|
+
|
|
15
|
+
// Standardized query wrapper to match the required await db.query pattern
|
|
16
|
+
export const db = {
|
|
17
|
+
query: (sql, params) => pool.execute(sql, params)
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default db;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pool from "../config/db.js";
|
|
2
|
+
|
|
3
|
+
export const getInventoryReport = async (req, res) => {
|
|
4
|
+
try {
|
|
5
|
+
const [rows] = await pool.execute("SELECT name, quantity, price, (quantity * price) as total_value FROM spare_parts");
|
|
6
|
+
res.status(200).json({
|
|
7
|
+
message: "Inventory report generated",
|
|
8
|
+
data: rows,
|
|
9
|
+
});
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error("Error in getInventoryReport:", error);
|
|
12
|
+
res.status(500).json({ error: "Internal server error" });
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const getSystemStats = async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
// Mock stats for demonstration
|
|
19
|
+
const stats = {
|
|
20
|
+
totalUsers: 150,
|
|
21
|
+
activeSessions: 12,
|
|
22
|
+
databaseSize: "45MB",
|
|
23
|
+
uptime: process.uptime(),
|
|
24
|
+
};
|
|
25
|
+
res.status(200).json({
|
|
26
|
+
message: "System stats retrieved",
|
|
27
|
+
data: stats,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error("Error in getSystemStats:", error);
|
|
31
|
+
res.status(500).json({ error: "Internal server error" });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { db } from "../config/db.js";
|
|
2
|
+
|
|
3
|
+
export const createSparePart = async (req, res) => {
|
|
4
|
+
try {
|
|
5
|
+
const { name, description, quantity, price } = req.body;
|
|
6
|
+
const [result] = await db.query(
|
|
7
|
+
"INSERT INTO spare_parts (name, description, quantity, price) VALUES (?, ?, ?, ?)",
|
|
8
|
+
[name, description, quantity, price]
|
|
9
|
+
);
|
|
10
|
+
res.status(201).json({
|
|
11
|
+
message: "Spare part created successfully",
|
|
12
|
+
data: { id: result.insertId, name, description, quantity, price },
|
|
13
|
+
});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error("Error in createSparePart:", error);
|
|
16
|
+
res.status(500).json({ error: "Internal server error" });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const listSpareParts = async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const [rows] = await db.query("SELECT * FROM spare_parts");
|
|
23
|
+
res.status(200).json({
|
|
24
|
+
message: "Spare parts retrieved successfully",
|
|
25
|
+
data: rows,
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error("Error in listSpareParts:", error);
|
|
29
|
+
res.status(500).json({ error: "Internal server error" });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const getSparePart = async (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const { id } = req.params;
|
|
36
|
+
const [rows] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [id]);
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
return res.status(404).json({ error: "Spare part not found" });
|
|
39
|
+
}
|
|
40
|
+
res.status(200).json({
|
|
41
|
+
message: "Spare part retrieved successfully",
|
|
42
|
+
data: rows[0],
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error("Error in getSparePart:", error);
|
|
46
|
+
res.status(500).json({ error: "Internal server error" });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const updateSparePart = async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { id } = req.params;
|
|
53
|
+
const { name, description, quantity, price } = req.body;
|
|
54
|
+
await db.query(
|
|
55
|
+
"UPDATE spare_parts SET name = ?, description = ?, quantity = ?, price = ? WHERE id = ?",
|
|
56
|
+
[name, description, quantity, price, id]
|
|
57
|
+
);
|
|
58
|
+
res.status(200).json({
|
|
59
|
+
message: "Spare part updated successfully",
|
|
60
|
+
data: { id, name, description, quantity, price },
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("Error in updateSparePart:", error);
|
|
64
|
+
res.status(500).json({ error: "Internal server error" });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const deleteSparePart = async (req, res) => {
|
|
69
|
+
try {
|
|
70
|
+
const { id } = req.params;
|
|
71
|
+
await db.query("DELETE FROM spare_parts WHERE id = ?", [id]);
|
|
72
|
+
res.status(200).json({ message: "Spare part deleted successfully" });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error("Error in deleteSparePart:", error);
|
|
75
|
+
res.status(500).json({ error: "Internal server error" });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { db } from "../config/db.js";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
|
|
4
|
+
export const createUser = async (req, res) => {
|
|
5
|
+
try {
|
|
6
|
+
const { username, email, password, role } = req.body;
|
|
7
|
+
const hashedPassword = await bcrypt.hash(password, 10);
|
|
8
|
+
const [result] = await db.query(
|
|
9
|
+
"INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)",
|
|
10
|
+
[username, email, hashedPassword, role || 'user']
|
|
11
|
+
);
|
|
12
|
+
res.status(201).json({
|
|
13
|
+
message: "User created successfully",
|
|
14
|
+
data: { id: result.insertId, username, email, role },
|
|
15
|
+
});
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error("Error in createUser:", error);
|
|
18
|
+
res.status(500).json({ error: "Internal server error" });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const listUsers = async (req, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const [rows] = await db.query("SELECT id, username, email, role FROM users");
|
|
25
|
+
res.status(200).json({
|
|
26
|
+
message: "Users retrieved successfully",
|
|
27
|
+
data: rows,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error("Error in listUsers:", error);
|
|
31
|
+
res.status(500).json({ error: "Internal server error" });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const getUser = async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const { id } = req.params;
|
|
38
|
+
const [rows] = await db.query("SELECT id, username, email, role FROM users WHERE id = ?", [id]);
|
|
39
|
+
if (rows.length === 0) {
|
|
40
|
+
return res.status(404).json({ error: "User not found" });
|
|
41
|
+
}
|
|
42
|
+
res.status(200).json({
|
|
43
|
+
message: "User retrieved successfully",
|
|
44
|
+
data: rows[0],
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error("Error in getUser:", error);
|
|
48
|
+
res.status(500).json({ error: "Internal server error" });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const updateUser = async (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const { id } = req.params;
|
|
55
|
+
const { username, email, role } = req.body;
|
|
56
|
+
await db.query(
|
|
57
|
+
"UPDATE users SET username = ?, email = ?, role = ? WHERE id = ?",
|
|
58
|
+
[username, email, role, id]
|
|
59
|
+
);
|
|
60
|
+
res.status(200).json({
|
|
61
|
+
message: "User updated successfully",
|
|
62
|
+
data: { id, username, email, role },
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("Error in updateUser:", error);
|
|
66
|
+
res.status(500).json({ error: "Internal server error" });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const deleteUser = async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const { id } = req.params;
|
|
73
|
+
await db.query("DELETE FROM users WHERE id = ?", [id]);
|
|
74
|
+
res.status(200).json({ message: "User deleted successfully" });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("Error in deleteUser:", error);
|
|
77
|
+
res.status(500).json({ error: "Internal server error" });
|
|
78
|
+
}
|
|
79
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import express from "express";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
8
|
+
import { loggingMiddleware, errorHandler } from "./middlewares/index.js";
|
|
9
|
+
|
|
10
|
+
dotenv.config();
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
const PORT = process.env.PORT || 3000;
|
|
17
|
+
|
|
18
|
+
// Global Middlewares
|
|
19
|
+
app.use(express.json());
|
|
20
|
+
app.use(loggingMiddleware);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dynamic Route Loader
|
|
24
|
+
* Automatically registers all route files from the /routes directory
|
|
25
|
+
*/
|
|
26
|
+
const registerRoutes = async () => {
|
|
27
|
+
const routesPath = path.join(__dirname, "routes");
|
|
28
|
+
const files = fs.readdirSync(routesPath);
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
if (file.endsWith(".js")) {
|
|
32
|
+
const routeName = file.split(".")[0];
|
|
33
|
+
const routePath = pathToFileURL(path.join(routesPath, file)).href;
|
|
34
|
+
const { default: router } = await import(routePath);
|
|
35
|
+
|
|
36
|
+
if (router) {
|
|
37
|
+
app.use(`/api/${routeName}`, router);
|
|
38
|
+
console.log(`Registered domain: ${routeName} -> /api/${routeName}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Health check
|
|
45
|
+
app.get("/health", (req, res) => {
|
|
46
|
+
res.json({ status: "ok", service: "spxkth-framework" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const startServer = async () => {
|
|
50
|
+
try {
|
|
51
|
+
await registerRoutes();
|
|
52
|
+
|
|
53
|
+
// Error Handling Middleware (must be last)
|
|
54
|
+
app.use(errorHandler);
|
|
55
|
+
|
|
56
|
+
if (import.meta.url === pathToFileURL(__filename).href || process.argv[1]?.endsWith('spxkth')) {
|
|
57
|
+
app.listen(PORT, () => {
|
|
58
|
+
console.log(`Spxkth framework started on port ${PORT}`);
|
|
59
|
+
console.log(`API available at http://localhost:${PORT}/api`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("Failed to start Spxkth framework:", error);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
startServer();
|
|
69
|
+
|
|
70
|
+
export default app;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import { db } from "../config/db.js";
|
|
4
|
+
|
|
5
|
+
dotenv.config();
|
|
6
|
+
|
|
7
|
+
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Standardized Auth Middleware
|
|
11
|
+
* Checks session/token, queries DB for user, and attaches to req.user
|
|
12
|
+
*/
|
|
13
|
+
export const authMiddleware = async (req, res, next) => {
|
|
14
|
+
try {
|
|
15
|
+
const authHeader = req.headers.authorization;
|
|
16
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
17
|
+
return res.status(401).json({ error: "Unauthorized: No token provided" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const token = authHeader.split(" ")[1];
|
|
21
|
+
const decoded = jwt.verify(token, JWT_SECRET);
|
|
22
|
+
|
|
23
|
+
// Standardized DB check for user
|
|
24
|
+
const [rows] = await db.query("SELECT id, username, email, role FROM users WHERE id = ?", [decoded.id]);
|
|
25
|
+
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return res.status(401).json({ error: "Unauthorized: User no longer exists" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
req.user = rows[0];
|
|
31
|
+
next();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error("Auth Middleware Error:", error.message);
|
|
34
|
+
return res.status(403).json({ error: "Forbidden: Invalid or expired token" });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const loggingMiddleware = (req, res, next) => {
|
|
39
|
+
const timestamp = new Date().toISOString();
|
|
40
|
+
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
|
41
|
+
next();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const errorHandler = (err, req, res, next) => {
|
|
45
|
+
console.error("Global Error Handler:", err);
|
|
46
|
+
res.status(500).json({ error: "Internal server error" });
|
|
47
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spxkth",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "A modular, domain-agnostic Node.js backend framework with universal UI themes.",
|
|
5
|
+
"author": "spxkth",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "index.js",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"spxkth": "index.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node index.js",
|
|
17
|
+
"dev": "node --watch index.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"express": "^4.18.2",
|
|
21
|
+
"mysql2": "^3.6.0",
|
|
22
|
+
"dotenv": "^16.3.1",
|
|
23
|
+
"bcryptjs": "^2.4.3",
|
|
24
|
+
"jsonwebtoken": "^9.0.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.1.6",
|
|
28
|
+
"@types/node": "^20.4.5",
|
|
29
|
+
"@types/express": "^4.17.17"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { getInventoryReport, getSystemStats } from "../controllers/reportsController.js";
|
|
3
|
+
import { authMiddleware } from "../middlewares/index.js";
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get("/inventory", authMiddleware, getInventoryReport);
|
|
8
|
+
router.get("/stats", authMiddleware, getSystemStats);
|
|
9
|
+
|
|
10
|
+
export default router;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import {
|
|
3
|
+
createSparePart,
|
|
4
|
+
listSpareParts,
|
|
5
|
+
getSparePart,
|
|
6
|
+
updateSparePart,
|
|
7
|
+
deleteSparePart,
|
|
8
|
+
} from "../controllers/sparePartsController.js";
|
|
9
|
+
import { authMiddleware } from "../middlewares/index.js";
|
|
10
|
+
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
// RESTful Route Pattern
|
|
14
|
+
router.post("/", authMiddleware, createSparePart);
|
|
15
|
+
router.get("/", authMiddleware, listSpareParts);
|
|
16
|
+
router.get("/:id", authMiddleware, getSparePart);
|
|
17
|
+
router.put("/:id", authMiddleware, updateSparePart);
|
|
18
|
+
router.delete("/:id", authMiddleware, deleteSparePart);
|
|
19
|
+
|
|
20
|
+
export default router;
|
package/routes/users.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import {
|
|
3
|
+
createUser,
|
|
4
|
+
listUsers,
|
|
5
|
+
getUser,
|
|
6
|
+
updateUser,
|
|
7
|
+
deleteUser,
|
|
8
|
+
} from "../controllers/usersController.js";
|
|
9
|
+
import { authMiddleware } from "../middlewares/index.js";
|
|
10
|
+
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
// RESTful Route Pattern
|
|
14
|
+
router.post("/", authMiddleware, createUser);
|
|
15
|
+
router.get("/", authMiddleware, listUsers);
|
|
16
|
+
router.get("/:id", authMiddleware, getUser);
|
|
17
|
+
router.put("/:id", authMiddleware, updateUser);
|
|
18
|
+
router.delete("/:id", authMiddleware, deleteUser);
|
|
19
|
+
|
|
20
|
+
export default router;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ThemeWrapper from "./ThemeWrapper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal Dashboard Theme
|
|
6
|
+
* @param {Object} props.config - { title, description, stats: [], widgets: [], quickLinks, relatedRoutes, relatedTables }
|
|
7
|
+
*/
|
|
8
|
+
const DashboardTheme = ({ config }) => {
|
|
9
|
+
const { title, description, stats = [], widgets = [], ...wrapperProps } = config;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ThemeWrapper title={title} description={description} {...wrapperProps}>
|
|
13
|
+
<div className="space-y-8">
|
|
14
|
+
{/* Stats Row */}
|
|
15
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
16
|
+
{stats.map((stat, idx) => (
|
|
17
|
+
<div key={idx} className="p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
|
18
|
+
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.label}</p>
|
|
19
|
+
<h3 className="text-2xl font-bold mt-1">{stat.value}</h3>
|
|
20
|
+
{stat.trend && (
|
|
21
|
+
<p className={`text-xs mt-2 ${stat.trend > 0 ? 'text-green-500' : 'text-red-500'}`}>
|
|
22
|
+
{stat.trend > 0 ? '↑' : '↓'} {Math.abs(stat.trend)}% from last month
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
{/* Widgets Grid */}
|
|
30
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
31
|
+
{widgets.map((widget, idx) => (
|
|
32
|
+
<div key={idx} className="p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 min-h-[300px]">
|
|
33
|
+
<h3 className="text-lg font-bold mb-4">{widget.title}</h3>
|
|
34
|
+
<div className="flex items-center justify-center h-full text-gray-400 italic">
|
|
35
|
+
{widget.content || "[Chart/Widget Placeholder]"}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</ThemeWrapper>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default DashboardTheme;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ThemeWrapper from "./ThemeWrapper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal Detail Theme
|
|
6
|
+
* @param {Object} props.config - { title, description, sections: [], actions: [], quickLinks, relatedRoutes, relatedTables }
|
|
7
|
+
*/
|
|
8
|
+
const DetailTheme = ({ config }) => {
|
|
9
|
+
const { title, description, sections = [], actions = [], ...wrapperProps } = config;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ThemeWrapper title={title} description={description} {...wrapperProps}>
|
|
13
|
+
<div className="space-y-6">
|
|
14
|
+
{/* Action Bar */}
|
|
15
|
+
<div className="flex justify-end gap-3">
|
|
16
|
+
{actions.map((action, idx) => (
|
|
17
|
+
<button
|
|
18
|
+
key={idx}
|
|
19
|
+
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all ${
|
|
20
|
+
action.primary
|
|
21
|
+
? 'bg-[var(--color-primary)] text-white shadow-md'
|
|
22
|
+
: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700'
|
|
23
|
+
}`}
|
|
24
|
+
>
|
|
25
|
+
{action.label}
|
|
26
|
+
</button>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{/* Details Content */}
|
|
31
|
+
<div className="grid grid-cols-1 gap-6">
|
|
32
|
+
{sections.map((section, sIdx) => (
|
|
33
|
+
<div key={sIdx} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
|
34
|
+
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/30">
|
|
35
|
+
<h3 className="text-lg font-bold">{section.title}</h3>
|
|
36
|
+
</div>
|
|
37
|
+
<div className="p-6">
|
|
38
|
+
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-8">
|
|
39
|
+
{section.items.map((item, iIdx) => (
|
|
40
|
+
<div key={iIdx} className="space-y-1">
|
|
41
|
+
<dt className="text-xs font-bold text-gray-500 uppercase tracking-wide">{item.label}</dt>
|
|
42
|
+
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.value || "-"}</dd>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</dl>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</ThemeWrapper>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default DetailTheme;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ThemeWrapper from "./ThemeWrapper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal Form Theme
|
|
6
|
+
* @param {Object} props.config - { title, description, fields: [], submitLabel, quickLinks, relatedRoutes, relatedTables }
|
|
7
|
+
*/
|
|
8
|
+
const FormTheme = ({ config }) => {
|
|
9
|
+
const { title, description, fields = [], submitLabel = "Save Changes", ...wrapperProps } = config;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ThemeWrapper title={title} description={description} {...wrapperProps}>
|
|
13
|
+
<div className="max-w-2xl bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 p-8">
|
|
14
|
+
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
|
15
|
+
<div className="grid grid-cols-1 gap-6">
|
|
16
|
+
{fields.map((field, idx) => (
|
|
17
|
+
<div key={idx} className="space-y-2">
|
|
18
|
+
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300">
|
|
19
|
+
{field.label} {field.required && <span className="text-red-500">*</span>}
|
|
20
|
+
</label>
|
|
21
|
+
{field.type === "textarea" ? (
|
|
22
|
+
<textarea
|
|
23
|
+
placeholder={field.placeholder}
|
|
24
|
+
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-[var(--color-primary)] outline-none min-h-[120px]"
|
|
25
|
+
/>
|
|
26
|
+
) : field.type === "select" ? (
|
|
27
|
+
<select className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-[var(--color-primary)] outline-none">
|
|
28
|
+
{field.options?.map((opt, oIdx) => (
|
|
29
|
+
<option key={oIdx} value={opt.value}>{opt.label}</option>
|
|
30
|
+
))}
|
|
31
|
+
</select>
|
|
32
|
+
) : (
|
|
33
|
+
<input
|
|
34
|
+
type={field.type || "text"}
|
|
35
|
+
placeholder={field.placeholder}
|
|
36
|
+
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-[var(--color-primary)] outline-none"
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
{field.helpText && <p className="text-xs text-gray-500">{field.helpText}</p>}
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="pt-4 flex gap-4">
|
|
45
|
+
<button type="submit" className="px-8 py-3 bg-[var(--color-primary)] text-white font-bold rounded-xl shadow-lg hover:brightness-110 transition-all">
|
|
46
|
+
{submitLabel}
|
|
47
|
+
</button>
|
|
48
|
+
<button type="button" className="px-8 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 font-bold rounded-xl hover:bg-gray-200 transition-all">
|
|
49
|
+
Cancel
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
</form>
|
|
53
|
+
</div>
|
|
54
|
+
</ThemeWrapper>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default FormTheme;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ThemeWrapper from "./ThemeWrapper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal List Theme
|
|
6
|
+
* @param {Object} props.config - { title, description, columns: [], data: [], actions: [], quickLinks, relatedRoutes, relatedTables }
|
|
7
|
+
*/
|
|
8
|
+
const ListTheme = ({ config }) => {
|
|
9
|
+
const { title, description, columns = [], data = [], actions = [], ...wrapperProps } = config;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ThemeWrapper title={title} description={description} {...wrapperProps}>
|
|
13
|
+
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
|
14
|
+
{/* Table Toolbar */}
|
|
15
|
+
<div className="p-4 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center">
|
|
16
|
+
<input
|
|
17
|
+
type="text"
|
|
18
|
+
placeholder="Search..."
|
|
19
|
+
className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm w-64"
|
|
20
|
+
/>
|
|
21
|
+
<div className="flex gap-2">
|
|
22
|
+
<button className="px-4 py-2 text-sm font-medium bg-gray-100 dark:bg-gray-700 rounded-lg">Filter</button>
|
|
23
|
+
<button className="px-4 py-2 text-sm font-medium bg-[var(--color-primary)] text-white rounded-lg">Export</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
{/* Responsive Table */}
|
|
28
|
+
<div className="overflow-x-auto">
|
|
29
|
+
<table className="w-full text-left border-collapse">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr className="bg-gray-50 dark:bg-gray-900/50">
|
|
32
|
+
{columns.map((col, idx) => (
|
|
33
|
+
<th key={idx} className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
|
34
|
+
{col.header}
|
|
35
|
+
</th>
|
|
36
|
+
))}
|
|
37
|
+
{actions.length > 0 && <th className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider text-right">Actions</th>}
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
|
41
|
+
{data.length > 0 ? data.map((row, rowIdx) => (
|
|
42
|
+
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-900/30 transition-colors">
|
|
43
|
+
{columns.map((col, colIdx) => (
|
|
44
|
+
<td key={colIdx} className="px-6 py-4 text-sm">
|
|
45
|
+
{row[col.key] || "-"}
|
|
46
|
+
</td>
|
|
47
|
+
))}
|
|
48
|
+
{actions.length > 0 && (
|
|
49
|
+
<td className="px-6 py-4 text-sm text-right">
|
|
50
|
+
<div className="flex justify-end gap-3">
|
|
51
|
+
{actions.map((action, actIdx) => (
|
|
52
|
+
<button key={actIdx} className="text-[var(--color-primary)] hover:underline font-medium">
|
|
53
|
+
{action.label}
|
|
54
|
+
</button>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</td>
|
|
58
|
+
)}
|
|
59
|
+
</tr>
|
|
60
|
+
)) : (
|
|
61
|
+
<tr>
|
|
62
|
+
<td colSpan={columns.length + (actions.length > 0 ? 1 : 0)} className="px-6 py-12 text-center text-gray-400 italic">
|
|
63
|
+
No records found.
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
)}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</ThemeWrapper>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default ListTheme;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal Theme Wrapper Component
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} props
|
|
8
|
+
* @param {string} props.title - Page title
|
|
9
|
+
* @param {string} props.description - Page description
|
|
10
|
+
* @param {Array} props.quickLinks - [{ label, to }]
|
|
11
|
+
* @param {Array} props.relatedRoutes - [{ method, path, description }]
|
|
12
|
+
* @param {Array} props.relatedTables - [{ name, purpose }]
|
|
13
|
+
* @param {React.ReactNode} props.children - Main content of the theme
|
|
14
|
+
*/
|
|
15
|
+
const ThemeWrapper = ({
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
quickLinks = [],
|
|
19
|
+
relatedRoutes = [],
|
|
20
|
+
relatedTables = [],
|
|
21
|
+
children
|
|
22
|
+
}) => {
|
|
23
|
+
return (
|
|
24
|
+
<section className="p-8 space-y-8 bg-[var(--color-surface)] min-h-screen font-sans text-gray-900 dark:text-gray-100">
|
|
25
|
+
{/* Header Section */}
|
|
26
|
+
<header className="space-y-2">
|
|
27
|
+
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--color-primary)]">{title}</h1>
|
|
28
|
+
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-3xl">{description}</p>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
{/* Quick Links Section */}
|
|
32
|
+
{quickLinks.length > 0 && (
|
|
33
|
+
<nav className="flex flex-wrap gap-3">
|
|
34
|
+
{quickLinks.map((link, index) => (
|
|
35
|
+
<Link
|
|
36
|
+
key={index}
|
|
37
|
+
to={link.to}
|
|
38
|
+
className="px-5 py-2.5 bg-[var(--color-primary)] text-white rounded-full text-sm font-semibold shadow-sm hover:brightness-110 transition-all active:scale-95"
|
|
39
|
+
>
|
|
40
|
+
{link.label}
|
|
41
|
+
</Link>
|
|
42
|
+
))}
|
|
43
|
+
</nav>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{/* Main Content Area */}
|
|
47
|
+
<main className="w-full">
|
|
48
|
+
{children}
|
|
49
|
+
</main>
|
|
50
|
+
|
|
51
|
+
{/* Metadata Grid */}
|
|
52
|
+
<footer className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-8 border-t border-gray-200 dark:border-gray-800">
|
|
53
|
+
{/* Related Routes */}
|
|
54
|
+
<div className="space-y-4">
|
|
55
|
+
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
56
|
+
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
|
|
57
|
+
Developer Routes
|
|
58
|
+
</h2>
|
|
59
|
+
<div className="grid gap-3">
|
|
60
|
+
{relatedRoutes.length > 0 ? (
|
|
61
|
+
relatedRoutes.map((route, index) => (
|
|
62
|
+
<div key={index} className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-gray-100 dark:border-gray-800">
|
|
63
|
+
<div className="flex items-center gap-3 mb-1">
|
|
64
|
+
<span className="text-[10px] font-black px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-md uppercase">
|
|
65
|
+
{route.method}
|
|
66
|
+
</span>
|
|
67
|
+
<code className="text-xs font-mono text-gray-700 dark:text-gray-300">{route.path}</code>
|
|
68
|
+
</div>
|
|
69
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">{route.description}</p>
|
|
70
|
+
</div>
|
|
71
|
+
))
|
|
72
|
+
) : (
|
|
73
|
+
<p className="text-sm text-gray-400 italic">No associated routes.</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Related Tables */}
|
|
79
|
+
<div className="space-y-4">
|
|
80
|
+
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
81
|
+
<span className="w-1.5 h-6 bg-purple-500 rounded-full"></span>
|
|
82
|
+
Data Sources
|
|
83
|
+
</h2>
|
|
84
|
+
<div className="grid gap-3">
|
|
85
|
+
{relatedTables.length > 0 ? (
|
|
86
|
+
relatedTables.map((table, index) => (
|
|
87
|
+
<div key={index} className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-gray-100 dark:border-gray-800">
|
|
88
|
+
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-200">{table.name}</h3>
|
|
89
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">{table.purpose}</p>
|
|
90
|
+
</div>
|
|
91
|
+
))
|
|
92
|
+
) : (
|
|
93
|
+
<p className="text-sm text-gray-400 italic">No associated tables.</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</footer>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default ThemeWrapper;
|
package/themes/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as DashboardTheme } from "./DashboardTheme.jsx";
|
|
2
|
+
export { default as ListTheme } from "./ListTheme.jsx";
|
|
3
|
+
export { default as FormTheme } from "./FormTheme.jsx";
|
|
4
|
+
export { default as DetailTheme } from "./DetailTheme.jsx";
|
|
5
|
+
export { default as ThemeWrapper } from "./ThemeWrapper.jsx";
|