This commit is contained in:
@@ -1,3 +1,9 @@
|
|||||||
PORT=5000
|
PORT=5000
|
||||||
DB_URI=mongodb://192.168.1.7:27017/
|
DB_URI=mongodb://192.168.1.7:27017/
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Admin dashboard (used once to seed the admin account on first boot)
|
||||||
|
ADMIN_USERNAME=
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
# Secret for signing admin JWT tokens (generate: openssl rand -hex 32)
|
||||||
|
JWT_SECRET=
|
||||||
|
|||||||
131
backend/package-lock.json
generated
131
backend/package-lock.json
generated
@@ -9,9 +9,11 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "^9.3.0",
|
"mongoose": "^9.3.0",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
@@ -118,6 +120,15 @@
|
|||||||
"node": "^4.5.0 || >= 5.9"
|
"node": "^4.5.0 || >= 5.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -187,6 +198,12 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -357,6 +374,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -806,6 +832,49 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kareem": {
|
"node_modules/kareem": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz",
|
||||||
@@ -815,6 +884,48 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1208,6 +1319,26 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "^9.3.0",
|
"mongoose": "^9.3.0",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ const io = new Server(httpServer, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connectDB = require("./db");
|
const connectDB = require("./db");
|
||||||
|
const seedAdmin = require("./seedAdmin");
|
||||||
const gridCellsRouter = require("./routes/gridCells");
|
const gridCellsRouter = require("./routes/gridCells");
|
||||||
|
const authRouter = require("./routes/auth");
|
||||||
|
const adminRouter = require("./routes/admin");
|
||||||
|
const hiboliaRouter = require("./routes/hibolia");
|
||||||
|
|
||||||
gridCellsRouter.setIO(io);
|
gridCellsRouter.setIO(io);
|
||||||
|
|
||||||
// connect database
|
// connect database
|
||||||
connectDB();
|
connectDB().then(seedAdmin);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -56,6 +60,9 @@ app.get("/api/test", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use("/api/grid-cells", gridCellsRouter);
|
app.use("/api/grid-cells", gridCellsRouter);
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/admin", adminRouter);
|
||||||
|
app.use("/api/hibolia", hiboliaRouter);
|
||||||
|
|
||||||
app.get("/api/status", (req, res) => {
|
app.get("/api/status", (req, res) => {
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
|
|||||||
33
backend/src/middleware/auth.js
Normal file
33
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
let fallbackSecret = null;
|
||||||
|
|
||||||
|
function getJwtSecret() {
|
||||||
|
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
||||||
|
if (!fallbackSecret) {
|
||||||
|
fallbackSecret = crypto.randomBytes(32).toString("hex");
|
||||||
|
console.warn(
|
||||||
|
"JWT_SECRET is not set — using an ephemeral secret. Sessions will reset on every restart."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallbackSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || "";
|
||||||
|
const [scheme, token] = header.split(" ");
|
||||||
|
|
||||||
|
if (scheme !== "Bearer" || !token) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, getJwtSecret());
|
||||||
|
return next();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(401).json({ error: "Invalid or expired session" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, getJwtSecret };
|
||||||
35
backend/src/routes/admin.js
Normal file
35
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const GridCell = require("../schemas/GridCell");
|
||||||
|
const { requireAuth } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/admin/stats — dashboard overview
|
||||||
|
router.get("/stats", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uptime = Math.floor(process.uptime());
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
|
||||||
|
const totalCells = await GridCell.countDocuments();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: "online",
|
||||||
|
mongo: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
|
||||||
|
uptime,
|
||||||
|
memory: {
|
||||||
|
rss: mem.rss,
|
||||||
|
heapUsed: mem.heapUsed,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
totalCells,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
82
backend/src/routes/auth.js
Normal file
82
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const AdminUser = require("../schemas/AdminUser");
|
||||||
|
const { requireAuth, getJwtSecret } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const attempts = new Map();
|
||||||
|
|
||||||
|
function isRateLimited(key) {
|
||||||
|
const entry = attempts.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (Date.now() > entry.resetAt) {
|
||||||
|
attempts.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return entry.count >= MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerFailure(key) {
|
||||||
|
const entry = attempts.get(key);
|
||||||
|
if (!entry || Date.now() > entry.resetAt) {
|
||||||
|
attempts.set(key, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
||||||
|
} else {
|
||||||
|
entry.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const key = req.ip;
|
||||||
|
if (isRateLimited(key)) {
|
||||||
|
return res.status(429).json({ error: "Too many attempts. Try again later." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: "username and password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await AdminUser.findOne({
|
||||||
|
username: String(username).trim().toLowerCase(),
|
||||||
|
});
|
||||||
|
const valid = admin && (await bcrypt.compare(String(password), admin.passwordHash));
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
registerFailure(key);
|
||||||
|
return res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.delete(key);
|
||||||
|
admin.lastLoginAt = new Date();
|
||||||
|
await admin.save();
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: admin._id.toString(), username: admin.username },
|
||||||
|
getJwtSecret(),
|
||||||
|
{ expiresIn: "12h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ token, username: admin.username });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me — validates the stored token
|
||||||
|
router.get("/me", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admin = await AdminUser.findById(req.user.sub).lean();
|
||||||
|
if (!admin) return res.status(401).json({ error: "Invalid or expired session" });
|
||||||
|
res.json({ username: admin.username, lastLoginAt: admin.lastLoginAt });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
51
backend/src/routes/hibolia.js
Normal file
51
backend/src/routes/hibolia.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const HiboliaOrder = require("../schemas/HiboliaOrder");
|
||||||
|
const { requireAuth } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// POST /api/hibolia/orders — public order submission
|
||||||
|
router.post("/orders", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, surname, email, message, color, shipping } = req.body || {};
|
||||||
|
if (!name || !surname || !email || !message || !color || !shipping) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "name, surname, email, message, color and shipping are required" });
|
||||||
|
}
|
||||||
|
if (!HiboliaOrder.COLORS.includes(color)) {
|
||||||
|
return res.status(400).json({ error: "invalid color" });
|
||||||
|
}
|
||||||
|
if (!EMAIL_RE.test(String(email))) {
|
||||||
|
return res.status(400).json({ error: "invalid email" });
|
||||||
|
}
|
||||||
|
const order = await HiboliaOrder.create({
|
||||||
|
name,
|
||||||
|
surname,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
shipping,
|
||||||
|
});
|
||||||
|
res.status(201).json({ id: order._id });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/hibolia/orders — admin only (dashboard)
|
||||||
|
router.get("/orders", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const orders = await HiboliaOrder.find()
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.lean();
|
||||||
|
res.json(orders);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
20
backend/src/schemas/AdminUser.js
Normal file
20
backend/src/schemas/AdminUser.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const adminUserSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
lowercase: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
passwordHash: { type: String, required: true },
|
||||||
|
lastLoginAt: { type: Date },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdminUser = mongoose.model("AdminUser", adminUserSchema);
|
||||||
|
|
||||||
|
module.exports = AdminUser;
|
||||||
34
backend/src/schemas/HiboliaOrder.js
Normal file
34
backend/src/schemas/HiboliaOrder.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"red",
|
||||||
|
"gold",
|
||||||
|
"silver",
|
||||||
|
"orange",
|
||||||
|
"yellow",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"purple",
|
||||||
|
"black",
|
||||||
|
"white",
|
||||||
|
"pink",
|
||||||
|
"none",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hiboliaOrderSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 100 },
|
||||||
|
surname: { type: String, required: true, trim: true, maxlength: 100 },
|
||||||
|
email: { type: String, required: true, trim: true, lowercase: true },
|
||||||
|
message: { type: String, required: true, trim: true, maxlength: 2000 },
|
||||||
|
color: { type: String, required: true, enum: COLORS },
|
||||||
|
shipping: { type: String, required: true, trim: true, maxlength: 2000 },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const HiboliaOrder = mongoose.model("HiboliaOrder", hiboliaOrderSchema);
|
||||||
|
|
||||||
|
HiboliaOrder.COLORS = COLORS;
|
||||||
|
|
||||||
|
module.exports = HiboliaOrder;
|
||||||
30
backend/src/seedAdmin.js
Normal file
30
backend/src/seedAdmin.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
const AdminUser = require("./schemas/AdminUser");
|
||||||
|
|
||||||
|
// Seeds the single admin account from env vars on first boot.
|
||||||
|
// ADMIN_PASSWORD is only used to generate the initial bcrypt hash;
|
||||||
|
// after seeding, change credentials directly in the database.
|
||||||
|
const seedAdmin = async () => {
|
||||||
|
try {
|
||||||
|
const count = await AdminUser.countDocuments();
|
||||||
|
if (count > 0) return;
|
||||||
|
|
||||||
|
const username = process.env.ADMIN_USERNAME;
|
||||||
|
const password = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
console.warn(
|
||||||
|
"No admin account exists. Set ADMIN_USERNAME and ADMIN_PASSWORD to seed one."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
await AdminUser.create({ username, passwordHash });
|
||||||
|
console.log(`Admin account seeded for user "${username}"`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin seeding error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = seedAdmin;
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
<div class="app-root" :class="{ 'zh': locale === 'zh' }" :data-lang="locale">
|
<div class="app-root" :class="{ 'zh': locale === 'zh' }" :data-lang="locale">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<div class="container">
|
<NuxtPage />
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,22 +31,12 @@ useHead({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupTheme()
|
setupTheme()
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.container {
|
.app-root {
|
||||||
align-items: stretch; /* make items grow horizontally to fill container */
|
|
||||||
margin-bottom: 30px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 600px) {
|
|
||||||
.container {
|
|
||||||
padding-left: 12px;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
12
frontend/app/components/layouts/FullscreenLayout.vue
Normal file
12
frontend/app/components/layouts/FullscreenLayout.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed-layout">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.fixed-layout {
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ const route = useRoute()
|
|||||||
|
|
||||||
const scrollTargets: Record<string, string> = {
|
const scrollTargets: Record<string, string> = {
|
||||||
'index': '#top',
|
'index': '#top',
|
||||||
|
'projects': '#scroll-projects',
|
||||||
'blog': '#scroll-blog',
|
'blog': '#scroll-blog',
|
||||||
'contact': '#scroll-contact',
|
'contact': '#scroll-contact',
|
||||||
'art': '#scroll-art'
|
'art': '#scroll-art'
|
||||||
@@ -32,6 +33,10 @@ function getToPath(targetKey: string): string {
|
|||||||
<span class="tab-sep" v-if="false">│</span>
|
<span class="tab-sep" v-if="false">│</span>
|
||||||
<span class="tab-label">/</span>
|
<span class="tab-label">/</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink :to="getToPath('projects')" class="tui-tab" :class="{ 'tui-tab-active': isActive('projects') }">
|
||||||
|
<span class="tab-sep">│</span>
|
||||||
|
<span class="tab-label">/{{ $t('header.links.projects') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink :to="getToPath('blog')" class="tui-tab" :class="{ 'tui-tab-active': isActive('blog') }">
|
<NuxtLink :to="getToPath('blog')" class="tui-tab" :class="{ 'tui-tab-active': isActive('blog') }">
|
||||||
<span class="tab-sep">│</span>
|
<span class="tab-sep">│</span>
|
||||||
<span class="tab-label">/{{ $t('header.links.blog') }}</span>
|
<span class="tab-label">/{{ $t('header.links.blog') }}</span>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import SiteOptions from './site_options/SiteOptions.vue';
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
const SCROLL_THRESHOLD = 120; // px scrolled before sticky header appears
|
const SCROLL_THRESHOLD = 120; // px scrolled before sticky header appears
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
alwaysVisible: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(props.alwaysVisible);
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
isVisible.value = window.scrollY > SCROLL_THRESHOLD;
|
isVisible.value = props.alwaysVisible || window.scrollY > SCROLL_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }));
|
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }));
|
||||||
|
|||||||
386
frontend/app/components/widgets/HiboliaDeckPreview.vue
Normal file
386
frontend/app/components/widgets/HiboliaDeckPreview.vue
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ color?: string }>(),
|
||||||
|
{ color: '#d8d2c6' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ATLAS_URL = 'https://cdn.aranroig.com/web/assets/cards.png';
|
||||||
|
const CARD_COLS = 5;
|
||||||
|
const CARD_ROWS = 11;
|
||||||
|
const CARD_BACK_FACE = 9 * CARD_COLS + 2;
|
||||||
|
const DECK_SIZE = 24;
|
||||||
|
const CARD_WIDTH = 1.15;
|
||||||
|
const CARD_DEPTH = 0.008;
|
||||||
|
const FAN_RADIUS = 1.7;
|
||||||
|
const FAN_SPREAD = 0.03;
|
||||||
|
const FOV = 40;
|
||||||
|
const FIT_HALF_H = 1.05;
|
||||||
|
const FIT_HALF_W = 1.8;
|
||||||
|
|
||||||
|
const root = ref<HTMLDivElement | null>(null);
|
||||||
|
let disposeScene: (() => void) | null = null;
|
||||||
|
let pendingColor: string | null = props.color;
|
||||||
|
let cancelled = false;
|
||||||
|
let cancelSchedule: (() => void) | null = null;
|
||||||
|
let io: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.color,
|
||||||
|
(c) => {
|
||||||
|
pendingColor = c;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const el = root.value;
|
||||||
|
if (!el || cancelled) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
Scene,
|
||||||
|
PerspectiveCamera,
|
||||||
|
WebGLRenderer,
|
||||||
|
TextureLoader,
|
||||||
|
SRGBColorSpace,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
BoxGeometry,
|
||||||
|
Mesh,
|
||||||
|
Group,
|
||||||
|
Color,
|
||||||
|
Vector2,
|
||||||
|
Clock,
|
||||||
|
MathUtils
|
||||||
|
} = await import('three');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
|
||||||
|
const camera = new PerspectiveCamera(FOV, 1, 0.1, 100);
|
||||||
|
|
||||||
|
const renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
el.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
let texture;
|
||||||
|
try {
|
||||||
|
texture = await new TextureLoader().loadAsync(ATLAS_URL);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('HiboliaDeckPreview: failed to load card atlas (CORS?)', e);
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
texture.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentElement === el) el.removeChild(renderer.domElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||||
|
|
||||||
|
const cellW = texture.image.width / CARD_COLS;
|
||||||
|
const cellH = texture.image.height / CARD_ROWS;
|
||||||
|
const cardHeight = CARD_WIDTH * (cellH / cellW);
|
||||||
|
|
||||||
|
const marginU = 0.5 / cellW;
|
||||||
|
const marginV = 0.5 / cellH;
|
||||||
|
|
||||||
|
const faceMaterial = new MeshBasicMaterial({ map: texture, transparent: true });
|
||||||
|
const backMaterial = new MeshBasicMaterial({ map: texture, transparent: true });
|
||||||
|
const sideMaterial = new MeshBasicMaterial({
|
||||||
|
color: new Color(pendingColor || '#d8d2c6'),
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
pendingColor = null;
|
||||||
|
|
||||||
|
// Round the card corners with an anti-aliased rounded-rect SDF in the shader.
|
||||||
|
// The half-size is widened by a sliver so the box's side faces sit strictly
|
||||||
|
// inside the SDF (they lie exactly on the boundary otherwise).
|
||||||
|
const CORNER_RADIUS = 0.09;
|
||||||
|
const CORNER_SDF_EPS = 0.01;
|
||||||
|
const BORDER_WIDTH = 0.014;
|
||||||
|
const uEdgeColor = { value: new Color().copy(sideMaterial.color) };
|
||||||
|
const applyRoundedCorners = (mat: any, withBorder: boolean) => {
|
||||||
|
mat.onBeforeCompile = (shader: any) => {
|
||||||
|
shader.uniforms.uHalfSize = {
|
||||||
|
value: new Vector2(
|
||||||
|
CARD_WIDTH / 2 + CORNER_SDF_EPS,
|
||||||
|
cardHeight / 2 + CORNER_SDF_EPS
|
||||||
|
)
|
||||||
|
};
|
||||||
|
shader.uniforms.uRadius = { value: CORNER_RADIUS };
|
||||||
|
shader.uniforms.uEdgeColor = uEdgeColor;
|
||||||
|
shader.uniforms.uBorderWidth = { value: BORDER_WIDTH };
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace(
|
||||||
|
'#include <common>',
|
||||||
|
'#include <common>\nvarying vec2 vCardPos;'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'#include <begin_vertex>',
|
||||||
|
'#include <begin_vertex>\nvCardPos = position.xy;'
|
||||||
|
);
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace(
|
||||||
|
'#include <common>',
|
||||||
|
`#include <common>
|
||||||
|
varying vec2 vCardPos;
|
||||||
|
uniform vec2 uHalfSize;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform vec3 uEdgeColor;
|
||||||
|
uniform float uBorderWidth;`
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'#include <color_fragment>',
|
||||||
|
`#include <color_fragment>
|
||||||
|
vec2 q = abs(vCardPos) - uHalfSize + uRadius;
|
||||||
|
float sd = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - uRadius;
|
||||||
|
float fw = fwidth(sd);${
|
||||||
|
withBorder
|
||||||
|
? `
|
||||||
|
float border = smoothstep(-uBorderWidth - fw, -uBorderWidth + fw, sd);
|
||||||
|
diffuseColor.rgb = mix(diffuseColor.rgb, uEdgeColor, border);`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
diffuseColor.a *= 1.0 - smoothstep(-fw, fw, sd);`
|
||||||
|
);
|
||||||
|
mat.userData.shader = shader;
|
||||||
|
};
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
};
|
||||||
|
applyRoundedCorners(faceMaterial, true);
|
||||||
|
applyRoundedCorners(backMaterial, true);
|
||||||
|
applyRoundedCorners(sideMaterial, false);
|
||||||
|
|
||||||
|
// BoxGeometry face order: +X, -X, +Y, -Y, +Z(front), -Z(back); 4 verts each.
|
||||||
|
const applyFaceUV = (geometry: any, vertexStart: number, face: number) => {
|
||||||
|
const col = face % CARD_COLS;
|
||||||
|
const row = Math.floor(face / CARD_COLS);
|
||||||
|
const uv = geometry.attributes.uv;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const idx = vertexStart + i;
|
||||||
|
const u = (col + marginU + uv.getX(idx) * (1 - 2 * marginU)) / CARD_COLS;
|
||||||
|
const v = (CARD_ROWS - 1 - row + marginV + uv.getY(idx) * (1 - 2 * marginV)) / CARD_ROWS;
|
||||||
|
uv.setXY(idx, u, v);
|
||||||
|
}
|
||||||
|
uv.needsUpdate = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rig = new Group();
|
||||||
|
const pitchGroup = new Group();
|
||||||
|
rig.add(pitchGroup);
|
||||||
|
scene.add(rig);
|
||||||
|
|
||||||
|
const fan = new Group();
|
||||||
|
fan.rotation.x = Math.PI / 2 - 0.32;
|
||||||
|
pitchGroup.add(fan);
|
||||||
|
|
||||||
|
const geometries: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < DECK_SIZE; i++) {
|
||||||
|
const geometry = new BoxGeometry(CARD_WIDTH, cardHeight, CARD_DEPTH);
|
||||||
|
applyFaceUV(geometry, 16, Math.floor(Math.random() * CARD_COLS * CARD_ROWS));
|
||||||
|
applyFaceUV(geometry, 20, CARD_BACK_FACE);
|
||||||
|
geometries.push(geometry);
|
||||||
|
|
||||||
|
const mesh = new Mesh(geometry, [
|
||||||
|
sideMaterial,
|
||||||
|
sideMaterial,
|
||||||
|
sideMaterial,
|
||||||
|
sideMaterial,
|
||||||
|
faceMaterial,
|
||||||
|
backMaterial
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pivot = new Group();
|
||||||
|
pivot.rotation.z = (i - (DECK_SIZE - 1) / 2) * FAN_SPREAD;
|
||||||
|
mesh.position.y = -FAN_RADIUS;
|
||||||
|
mesh.position.z = (i - (DECK_SIZE - 1) / 2) * CARD_DEPTH;
|
||||||
|
pivot.add(mesh);
|
||||||
|
fan.add(pivot);
|
||||||
|
}
|
||||||
|
fan.position.set(
|
||||||
|
0,
|
||||||
|
FAN_RADIUS * Math.cos(fan.rotation.x),
|
||||||
|
FAN_RADIUS * Math.sin(fan.rotation.x)
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetColor = new Color(sideMaterial.color);
|
||||||
|
const resize = () => {
|
||||||
|
const width = el.clientWidth;
|
||||||
|
const height = el.clientHeight;
|
||||||
|
if (!width || !height) return;
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
const tanHalfFov = Math.tan(MathUtils.degToRad(FOV / 2));
|
||||||
|
const dist = Math.max(
|
||||||
|
FIT_HALF_H / tanHalfFov,
|
||||||
|
FIT_HALF_W / (tanHalfFov * camera.aspect)
|
||||||
|
);
|
||||||
|
const elevation = 1.22;
|
||||||
|
camera.position.set(
|
||||||
|
0,
|
||||||
|
Math.sin(elevation) * dist + 0.15,
|
||||||
|
Math.cos(elevation) * dist
|
||||||
|
);
|
||||||
|
camera.lookAt(0, 0.1, 0);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
let yaw = 0;
|
||||||
|
let pitch = 0;
|
||||||
|
let targetYaw = 0;
|
||||||
|
let targetPitch = 0;
|
||||||
|
let dragging = false;
|
||||||
|
let interacted = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
dragging = true;
|
||||||
|
interacted = true;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
targetYaw -= (e.clientX - lastX) * 0.008;
|
||||||
|
targetPitch = MathUtils.clamp(
|
||||||
|
targetPitch + (e.clientY - lastY) * 0.006,
|
||||||
|
-1.35,
|
||||||
|
1.35
|
||||||
|
);
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
const onPointerUp = () => {
|
||||||
|
dragging = false;
|
||||||
|
};
|
||||||
|
el.addEventListener('pointerdown', onPointerDown);
|
||||||
|
el.addEventListener('pointermove', onPointerMove);
|
||||||
|
el.addEventListener('pointerup', onPointerUp);
|
||||||
|
el.addEventListener('pointercancel', onPointerUp);
|
||||||
|
|
||||||
|
const clock = new Clock();
|
||||||
|
let frameId = 0;
|
||||||
|
let running = false;
|
||||||
|
const animate = () => {
|
||||||
|
if (!running) return;
|
||||||
|
frameId = requestAnimationFrame(animate);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
if (pendingColor) {
|
||||||
|
targetColor.set(pendingColor);
|
||||||
|
pendingColor = null;
|
||||||
|
}
|
||||||
|
sideMaterial.color.lerp(targetColor, 1 - Math.exp(-10 * dt));
|
||||||
|
uEdgeColor.value.copy(sideMaterial.color);
|
||||||
|
if (!interacted) {
|
||||||
|
targetYaw = Math.sin(clock.elapsedTime * 0.45) * 0.22;
|
||||||
|
}
|
||||||
|
const ease = 1 - Math.exp(-8 * dt);
|
||||||
|
yaw += (targetYaw - yaw) * ease;
|
||||||
|
pitch += (targetPitch - pitch) * ease;
|
||||||
|
rig.rotation.z = yaw;
|
||||||
|
pitchGroup.rotation.x = pitch;
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pause the loop while the tab is hidden to free the main thread and GPU.
|
||||||
|
const startLoop = () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
clock.getDelta();
|
||||||
|
frameId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
const stopLoop = () => {
|
||||||
|
if (!running) return;
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
const removeVisibility = onVisibility((hidden) => (hidden ? stopLoop() : startLoop()));
|
||||||
|
startLoop();
|
||||||
|
|
||||||
|
disposeScene = () => {
|
||||||
|
stopLoop();
|
||||||
|
removeVisibility();
|
||||||
|
observer.disconnect();
|
||||||
|
el.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
el.removeEventListener('pointermove', onPointerMove);
|
||||||
|
el.removeEventListener('pointerup', onPointerUp);
|
||||||
|
el.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
for (const geometry of geometries) {
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
|
faceMaterial.dispose();
|
||||||
|
backMaterial.dispose();
|
||||||
|
sideMaterial.dispose();
|
||||||
|
texture.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentElement === el) {
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = root.value;
|
||||||
|
if (!el) return;
|
||||||
|
// Defer the heavy scene until the preview is actually near the viewport.
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((e) => e.isIntersecting)) return;
|
||||||
|
io?.disconnect();
|
||||||
|
io = null;
|
||||||
|
cancelSchedule = scheduleIdle(() => {
|
||||||
|
init();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelled = true;
|
||||||
|
io?.disconnect();
|
||||||
|
cancelSchedule?.();
|
||||||
|
disposeScene?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="three-root" role="img" aria-label="Vista previa de la baraja">
|
||||||
|
<div class="three-bg" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.three-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(canvas) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
303
frontend/app/components/widgets/HiboliaScene.vue
Normal file
303
frontend/app/components/widgets/HiboliaScene.vue
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const ATLAS_URL = 'https://cdn.aranroig.com/web/assets/cards.png';
|
||||||
|
const CARD_COLS = 5;
|
||||||
|
const CARD_ROWS = 11;
|
||||||
|
const CARD_COUNT = 20;
|
||||||
|
const CARD_LANES = 5;
|
||||||
|
const CARD_SPEED = 1.1;
|
||||||
|
const CARD_WIDTH = 1.15;
|
||||||
|
const CARD_BACK_FACE = 9 * CARD_COLS + 2;
|
||||||
|
|
||||||
|
const root = ref<HTMLDivElement | null>(null);
|
||||||
|
let disposeScene: (() => void) | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
let cancelSchedule: (() => void) | null = null;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const el = root.value;
|
||||||
|
if (!el || cancelled) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
Scene,
|
||||||
|
PerspectiveCamera,
|
||||||
|
WebGLRenderer,
|
||||||
|
TextureLoader,
|
||||||
|
SRGBColorSpace,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
PlaneGeometry,
|
||||||
|
Mesh,
|
||||||
|
Vector2,
|
||||||
|
Vector3,
|
||||||
|
Color,
|
||||||
|
Clock,
|
||||||
|
MathUtils
|
||||||
|
} = await import('three');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
|
||||||
|
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
|
||||||
|
camera.position.z = 4;
|
||||||
|
|
||||||
|
const renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
el.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
let texture;
|
||||||
|
try {
|
||||||
|
texture = await new TextureLoader().loadAsync(ATLAS_URL);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('HiboliaScene: failed to load card atlas (CORS?)', e);
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
texture.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentElement === el) el.removeChild(renderer.domElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||||
|
|
||||||
|
const cellW = texture.image.width / CARD_COLS;
|
||||||
|
const cellH = texture.image.height / CARD_ROWS;
|
||||||
|
const cardHeight = CARD_WIDTH * (cellH / cellW);
|
||||||
|
|
||||||
|
const material = new MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const backMaterial = new MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round the card corners with an anti-aliased rounded-rect SDF in the shader.
|
||||||
|
const CORNER_RADIUS = 0.09;
|
||||||
|
const applyRoundedCorners = (mat: any) => {
|
||||||
|
mat.onBeforeCompile = (shader: any) => {
|
||||||
|
shader.uniforms.uHalfSize = { value: new Vector2(CARD_WIDTH / 2, cardHeight / 2) };
|
||||||
|
shader.uniforms.uRadius = { value: CORNER_RADIUS };
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace(
|
||||||
|
'#include <common>',
|
||||||
|
'#include <common>\nvarying vec2 vCardPos;'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'#include <begin_vertex>',
|
||||||
|
'#include <begin_vertex>\nvCardPos = position.xy;'
|
||||||
|
);
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace(
|
||||||
|
'#include <common>',
|
||||||
|
`#include <common>
|
||||||
|
varying vec2 vCardPos;
|
||||||
|
uniform vec2 uHalfSize;
|
||||||
|
uniform float uRadius;`
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'#include <color_fragment>',
|
||||||
|
`#include <color_fragment>
|
||||||
|
vec2 q = abs(vCardPos) - uHalfSize + uRadius;
|
||||||
|
float sd = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - uRadius;
|
||||||
|
diffuseColor.a *= 1.0 - smoothstep(-fwidth(sd), fwidth(sd), sd);`
|
||||||
|
);
|
||||||
|
mat.userData.shader = shader;
|
||||||
|
};
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
};
|
||||||
|
applyRoundedCorners(material);
|
||||||
|
applyRoundedCorners(backMaterial);
|
||||||
|
|
||||||
|
let viewHalfW = 2;
|
||||||
|
let viewHalfH = 1.66;
|
||||||
|
const updateView = () => {
|
||||||
|
viewHalfH = camera.position.z * Math.tan(MathUtils.degToRad(camera.fov / 2));
|
||||||
|
viewHalfW = viewHalfH * camera.aspect;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inset UVs half a pixel inside each atlas cell to stop neighbors bleeding in.
|
||||||
|
const marginU = 0.5 / cellW;
|
||||||
|
const marginV = 0.5 / cellH;
|
||||||
|
|
||||||
|
// Shift the plane's UVs into one cell of the atlas: col across 5 columns,
|
||||||
|
// row from the top of the image (v origin is bottom-left in three.js).
|
||||||
|
const applyCardFace = (geometry: any, face: number) => {
|
||||||
|
const col = face % CARD_COLS;
|
||||||
|
const row = Math.floor(face / CARD_COLS);
|
||||||
|
const uv = geometry.attributes.uv;
|
||||||
|
if (!geometry.userData.baseUv) {
|
||||||
|
geometry.userData.baseUv = Float32Array.from(uv.array);
|
||||||
|
}
|
||||||
|
const base = geometry.userData.baseUv;
|
||||||
|
for (let i = 0; i < uv.count; i++) {
|
||||||
|
const u = (col + marginU + base[i * 2] * (1 - 2 * marginU)) / CARD_COLS;
|
||||||
|
const v = (CARD_ROWS - 1 - row + marginV + base[i * 2 + 1] * (1 - 2 * marginV)) / CARD_ROWS;
|
||||||
|
uv.setXY(i, u, v);
|
||||||
|
}
|
||||||
|
uv.needsUpdate = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cards: any[] = [];
|
||||||
|
|
||||||
|
const respawn = (card: any, initial: boolean) => {
|
||||||
|
const topY = viewHalfH + cardHeight;
|
||||||
|
const bottomY = -viewHalfH - cardHeight;
|
||||||
|
card.mesh.position.set(
|
||||||
|
-viewHalfW + (viewHalfW * 2 / CARD_LANES) * (card.slot % CARD_LANES + 0.5) + MathUtils.randFloatSpread(0.3),
|
||||||
|
initial
|
||||||
|
? bottomY + (topY - bottomY) * (1 - (card.slot + 0.5) / CARD_COUNT)
|
||||||
|
: topY,
|
||||||
|
MathUtils.randFloat(-1.5, 0.5)
|
||||||
|
);
|
||||||
|
card.mesh.rotation.set(
|
||||||
|
MathUtils.randFloat(-0.3, 0.3),
|
||||||
|
MathUtils.randFloat(-0.4, 0.4),
|
||||||
|
MathUtils.randFloat(-0.3, 0.3)
|
||||||
|
);
|
||||||
|
card.spin.set(
|
||||||
|
MathUtils.randFloatSpread(1.6),
|
||||||
|
MathUtils.randFloatSpread(1.6),
|
||||||
|
MathUtils.randFloatSpread(0.9)
|
||||||
|
);
|
||||||
|
applyCardFace(card.geometry, Math.floor(Math.random() * CARD_COLS * CARD_ROWS));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < CARD_COUNT; i++) {
|
||||||
|
const geometry = new PlaneGeometry(CARD_WIDTH, cardHeight);
|
||||||
|
const mesh = new Mesh(geometry, material);
|
||||||
|
const backGeometry = new PlaneGeometry(CARD_WIDTH, cardHeight);
|
||||||
|
applyCardFace(backGeometry, CARD_BACK_FACE);
|
||||||
|
const backMesh = new Mesh(backGeometry, backMaterial);
|
||||||
|
backMesh.rotation.y = Math.PI;
|
||||||
|
backMesh.position.z = -0.001;
|
||||||
|
mesh.add(backMesh);
|
||||||
|
scene.add(mesh);
|
||||||
|
const card = { mesh, geometry, backGeometry, slot: i, spin: new Vector3() };
|
||||||
|
respawn(card, true);
|
||||||
|
cards.push(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const width = el.clientWidth;
|
||||||
|
const height = el.clientHeight;
|
||||||
|
if (!width || !height) return;
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
updateView();
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
// Solid canvas background tracking the active theme's --color-background.
|
||||||
|
const applyBackground = () => {
|
||||||
|
const bg = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--color-background')
|
||||||
|
.trim();
|
||||||
|
renderer.setClearColor(new Color(bg || '#0e0e0e'), 1);
|
||||||
|
};
|
||||||
|
applyBackground();
|
||||||
|
|
||||||
|
const themeObserver = new MutationObserver(applyBackground);
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
const clock = new Clock();
|
||||||
|
let frameId = 0;
|
||||||
|
let running = false;
|
||||||
|
const animate = () => {
|
||||||
|
if (!running) return;
|
||||||
|
frameId = requestAnimationFrame(animate);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
for (const card of cards) {
|
||||||
|
card.mesh.position.y -= CARD_SPEED * dt;
|
||||||
|
card.mesh.rotation.x += card.spin.x * dt;
|
||||||
|
card.mesh.rotation.y += card.spin.y * dt;
|
||||||
|
card.mesh.rotation.z += card.spin.z * dt;
|
||||||
|
if (card.mesh.position.y < -viewHalfH - cardHeight) {
|
||||||
|
respawn(card, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pause the loop while the tab is hidden to free the main thread and GPU.
|
||||||
|
const startLoop = () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
clock.getDelta();
|
||||||
|
frameId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
const stopLoop = () => {
|
||||||
|
if (!running) return;
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
const removeVisibility = onVisibility((hidden) => (hidden ? stopLoop() : startLoop()));
|
||||||
|
startLoop();
|
||||||
|
|
||||||
|
disposeScene = () => {
|
||||||
|
stopLoop();
|
||||||
|
removeVisibility();
|
||||||
|
observer.disconnect();
|
||||||
|
themeObserver.disconnect();
|
||||||
|
for (const card of cards) {
|
||||||
|
card.geometry.dispose();
|
||||||
|
card.backGeometry.dispose();
|
||||||
|
}
|
||||||
|
material.dispose();
|
||||||
|
backMaterial.dispose();
|
||||||
|
texture.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentElement === el) {
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
cancelSchedule = scheduleIdle(() => {
|
||||||
|
init();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelSchedule?.();
|
||||||
|
disposeScene?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="three-root">
|
||||||
|
<div class="three-bg" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.three-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.three-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(canvas) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/app/composables/three-scene.ts
Normal file
22
frontend/app/composables/three-scene.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Helpers to keep heavy WebGL scene setup off the page's critical path.
|
||||||
|
|
||||||
|
// Runs fn once the main thread is idle (falling back to a short timeout),
|
||||||
|
// returning a cancel handle so an early unmount never leaks a scheduled init.
|
||||||
|
export const scheduleIdle = (fn: () => void, timeout = 1500): (() => void) => {
|
||||||
|
const ric = (window as any).requestIdleCallback as
|
||||||
|
| ((cb: () => void, opts?: { timeout: number }) => number)
|
||||||
|
| undefined
|
||||||
|
if (typeof ric === 'function') {
|
||||||
|
const id = ric(fn, { timeout })
|
||||||
|
return () => (window as any).cancelIdleCallback?.(id)
|
||||||
|
}
|
||||||
|
const id = setTimeout(fn, 200)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls cb whenever tab visibility changes, returning an unsubscribe function.
|
||||||
|
export const onVisibility = (cb: (hidden: boolean) => void): (() => void) => {
|
||||||
|
const handler = () => cb(document.hidden)
|
||||||
|
document.addEventListener('visibilitychange', handler)
|
||||||
|
return () => document.removeEventListener('visibilitychange', handler)
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
// composables/useApi.js
|
// composables/useApi.js
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const ADMIN_TOKEN_COOKIE = 'admin_token'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
return url.replace(/\/+$/, '')
|
return url.replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
@@ -12,7 +22,7 @@ export default function useApi() {
|
|||||||
// Generic GET request
|
// Generic GET request
|
||||||
const get = async (endpoint) => {
|
const get = async (endpoint) => {
|
||||||
const res = await fetch(`${baseUrl}${endpoint}`)
|
const res = await fetch(`${baseUrl}${endpoint}`)
|
||||||
if (!res.ok) throw new Error(`API GET ${endpoint} failed`)
|
if (!res.ok) throw new ApiError(`API GET ${endpoint} failed`, res.status)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,9 +33,31 @@ export default function useApi() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`API POST ${endpoint} failed`)
|
if (!res.ok) throw new ApiError(`API POST ${endpoint} failed`, res.status)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { get, post }
|
// Authenticated requests (JWT stored in cookie by useAuth)
|
||||||
|
const authHeaders = (): Record<string, string> => {
|
||||||
|
const token = useCookie<string | null>(ADMIN_TOKEN_COOKIE).value
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuth = async (endpoint) => {
|
||||||
|
const res = await fetch(`${baseUrl}${endpoint}`, { headers: authHeaders() })
|
||||||
|
if (!res.ok) throw new ApiError(`API GET ${endpoint} failed`, res.status)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const postAuth = async (endpoint, payload) => {
|
||||||
|
const res = await fetch(`${baseUrl}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new ApiError(`API POST ${endpoint} failed`, res.status)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, post, getAuth, postAuth }
|
||||||
}
|
}
|
||||||
62
frontend/app/composables/useAuth.ts
Normal file
62
frontend/app/composables/useAuth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { ADMIN_TOKEN_COOKIE, ApiError } from './useApi'
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_TTL_SECONDS = 60 * 60 * 12
|
||||||
|
|
||||||
|
export default function useAuth() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const baseUrl = `${config.public.apiBaseUrl.replace(/\/+$/, '')}/api`
|
||||||
|
|
||||||
|
const token = useCookie<string | null>(ADMIN_TOKEN_COOKIE, {
|
||||||
|
maxAge: TOKEN_TTL_SECONDS,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
default: () => null
|
||||||
|
})
|
||||||
|
const user = useState<AdminUser | null>('admin-user', () => null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${baseUrl}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new ApiError(data.error || 'Login failed', res.status)
|
||||||
|
token.value = data.token
|
||||||
|
user.value = { username: data.username }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates the stored token against the backend; logs out on failure.
|
||||||
|
const fetchMe = async () => {
|
||||||
|
if (!token.value) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.value}` }
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
logout()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error('auth check failed')
|
||||||
|
const data = await res.json()
|
||||||
|
user.value = { username: data.username }
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, user, isAuthenticated, login, logout, fetchMe }
|
||||||
|
}
|
||||||
14
frontend/app/middleware/admin-auth.ts
Normal file
14
frontend/app/middleware/admin-auth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Protects /admin pages: requires a stored admin token.
|
||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const token = useCookie<string | null>('admin_token')
|
||||||
|
|
||||||
|
const isLoginPage = to.path.endsWith('/admin/login')
|
||||||
|
|
||||||
|
if (!token.value && !isLoginPage) {
|
||||||
|
return navigateTo('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.value && isLoginPage) {
|
||||||
|
return navigateTo('/admin')
|
||||||
|
}
|
||||||
|
})
|
||||||
350
frontend/app/pages/admin/index.vue
Normal file
350
frontend/app/pages/admin/index.vue
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin-auth' });
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Admin Dashboard',
|
||||||
|
meta: [{ name: 'robots', content: 'noindex, nofollow', tagPriority: 'high' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAuth } = useApi();
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
|
const stats = ref<any>(null);
|
||||||
|
const orders = ref<any[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
const COLOR_HEX: Record<string, string> = {
|
||||||
|
red: '#d64545',
|
||||||
|
gold: '#d4af37',
|
||||||
|
silver: '#c0c0c0',
|
||||||
|
orange: '#e0823c',
|
||||||
|
yellow: '#e2cf3c',
|
||||||
|
green: '#5fae4e',
|
||||||
|
blue: '#4c8be0',
|
||||||
|
purple: '#9b59d0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number) => {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${h}h ${m}m ${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
auth.logout();
|
||||||
|
navigateTo('/admin/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
const res = await getAuth('/admin/stats');
|
||||||
|
stats.value = res;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 401) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorMsg.value = $t('admin.dashboard.load_error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
try {
|
||||||
|
orders.value = await getAuth('/hibolia/orders');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 401) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string) => new Date(value).toLocaleString();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const me = await auth.fetchMe();
|
||||||
|
if (!me) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all([loadStats(), loadOrders()]);
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
loadStats();
|
||||||
|
loadOrders();
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLogout = () => redirectToLogin();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dash-screen">
|
||||||
|
<Container class="dash-frame">
|
||||||
|
<div class="dash-top">
|
||||||
|
<h1 class="dash-title">> {{ $t('admin.dashboard.title') }}</h1>
|
||||||
|
<button class="logout-btn" @click="onLogout">>_ {{ $t('admin.dashboard.logout') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="auth.user.value" class="dash-welcome">
|
||||||
|
{{ $t('admin.dashboard.welcome') }} <span class="hl">{{ auth.user.value.username }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="dash-error">✗ {{ errorMsg }}</p>
|
||||||
|
<p v-if="loading" class="dash-loading blink">{{ $t('admin.dashboard.loading') }}</p>
|
||||||
|
|
||||||
|
<template v-if="stats && !loading">
|
||||||
|
<h2 class="dash-section">{{ $t('admin.dashboard.section_status') }}</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.status') }}</span>
|
||||||
|
<span class="stat-value ok">{{ stats.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.mongo') }}</span>
|
||||||
|
<span class="stat-value" :class="stats.mongo === 'connected' ? 'ok' : 'bad'">{{ stats.mongo }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.uptime') }}</span>
|
||||||
|
<span class="stat-value">{{ formatUptime(stats.uptime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.memory_rss') }}</span>
|
||||||
|
<span class="stat-value">{{ formatMB(stats.memory.rss) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.memory_heap') }}</span>
|
||||||
|
<span class="stat-value">{{ formatMB(stats.memory.heapUsed) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{{ $t('admin.dashboard.total_cells') }}</span>
|
||||||
|
<span class="stat-value">{{ stats.grid.totalCells }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="dash-section">{{ $t('admin.dashboard.section_orders') }} <span class="hl">[{{ orders.length }}]</span></h2>
|
||||||
|
<p v-if="!orders.length" class="dash-loading">{{ $t('admin.dashboard.orders_empty') }}</p>
|
||||||
|
<table v-else class="orders-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('admin.dashboard.col_date') }}</th>
|
||||||
|
<th>{{ $t('admin.dashboard.col_name') }}</th>
|
||||||
|
<th>{{ $t('admin.dashboard.col_email') }}</th>
|
||||||
|
<th>{{ $t('admin.dashboard.col_message') }}</th>
|
||||||
|
<th>{{ $t('admin.dashboard.col_color') }}</th>
|
||||||
|
<th>{{ $t('admin.dashboard.col_shipping') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="order in orders" :key="order._id">
|
||||||
|
<td class="nowrap">{{ formatDate(order.createdAt) }}</td>
|
||||||
|
<td>{{ order.name }} {{ order.surname }}</td>
|
||||||
|
<td>{{ order.email }}</td>
|
||||||
|
<td class="msg-cell">{{ order.message }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="color-chip">
|
||||||
|
<span class="swatch" :style="{ background: COLOR_HEX[order.color] || 'transparent' }" aria-hidden="true"></span>
|
||||||
|
{{ $t(`order.hibolia.colors.${order.color}`) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="msg-cell">{{ order.shipping }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-background);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-frame {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.tui-inner) {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms steps(3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-link);
|
||||||
|
border-color: var(--color-link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-welcome {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.75;
|
||||||
|
|
||||||
|
.hl {
|
||||||
|
color: var(--color-link);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-loading {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
color: var(--color-link);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-error {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e05c5c;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-section {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
margin: 28px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.hl {
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--color-link);
|
||||||
|
background: var(--color-background-line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-cell {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #62ac4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
color: #e05c5c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50%, 100% { opacity: 1; }
|
||||||
|
25%, 75% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
195
frontend/app/pages/admin/login.vue
Normal file
195
frontend/app/pages/admin/login.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TableHeader from '~/components/parts/TableHeader.vue';
|
||||||
|
import FixedLayout from '~/components/layouts/FixedLayout.vue';
|
||||||
|
|
||||||
|
definePageMeta({ middleware: 'admin-auth' });
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Admin — Login',
|
||||||
|
meta: [{ name: 'robots', content: 'noindex, nofollow', tagPriority: 'high' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (loading.value) return;
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value);
|
||||||
|
await navigateTo('/admin');
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.status === 401
|
||||||
|
? $t('admin.login.error_invalid')
|
||||||
|
: $t('admin.login.error_generic');
|
||||||
|
password.value = '';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="no-sprite">
|
||||||
|
<TableHeader />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FixedLayout>
|
||||||
|
<Container>
|
||||||
|
<h1 class="admin-title">> {{ $t('admin.login.title') }}</h1>
|
||||||
|
<p class="admin-prompt">{{ $t('admin.login.prompt') }}</p>
|
||||||
|
|
||||||
|
<form class="login-form" @submit.prevent="onSubmit">
|
||||||
|
<div class="field">
|
||||||
|
<label for="admin-user">{{ $t('admin.login.username_label') }}:</label>
|
||||||
|
<input
|
||||||
|
id="admin-user"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
autocomplete="username"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="admin-pass">{{ $t('admin.login.password_label') }}:</label>
|
||||||
|
<input
|
||||||
|
id="admin-pass"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="login-error" role="alert">✗ {{ error }}</p>
|
||||||
|
|
||||||
|
<button type="submit" class="login-btn" :disabled="loading">
|
||||||
|
<span v-if="!loading">> {{ $t('admin.login.submit') }}</span>
|
||||||
|
<span v-else class="blink">{{ $t('admin.login.submitting') }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
</FixedLayout>
|
||||||
|
<Footer />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-prompt {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
box-shadow: 2px -2px 0 0 var(--color-frame-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e05c5c;
|
||||||
|
margin: 4px 0 12px 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
margin-left: 90px;
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-background);
|
||||||
|
background: var(--color-link);
|
||||||
|
border: none;
|
||||||
|
padding: 8px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms steps(3);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50%, 100% { opacity: 1; }
|
||||||
|
25%, 75% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.field {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error,
|
||||||
|
.login-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.no-sprite .undertable-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -165,118 +165,118 @@ const sectionTargets = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeader></TableHeader>
|
<TableHeader></TableHeader>
|
||||||
|
|
||||||
<h1 style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;">Aran Roig — Developer, Artist & Designer</h1>
|
<h1 style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;">Aran Roig — Developer, Artist & Designer</h1>
|
||||||
<div id="top" style="scroll-margin-top: 60px;"> </div>
|
<div id="top" style="scroll-margin-top: 60px;"> </div>
|
||||||
|
|
||||||
<FixedLayout>
|
<FixedLayout>
|
||||||
<section class="intro-section">
|
<section class="intro-section">
|
||||||
<div class="intro-layout">
|
<div class="intro-layout">
|
||||||
<div class="intro-content">
|
<div class="intro-content">
|
||||||
<Container>
|
<Container>
|
||||||
<ContentRenderer v-if="markdown" :value="markdown"></ContentRenderer>
|
<ContentRenderer v-if="markdown" :value="markdown"></ContentRenderer>
|
||||||
</Container>
|
</Container>
|
||||||
|
</div>
|
||||||
|
<div class="intro-sidebar">
|
||||||
|
<ServerStatus />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="intro-sidebar">
|
</section>
|
||||||
<ServerStatus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="normal-section" id="place">
|
<section class="normal-section" id="place">
|
||||||
<Container>
|
<Container>
|
||||||
<h2 class="section-title">{{ t('pages.place_heading') }}</h2>
|
<h2 class="section-title">{{ t('pages.place_heading') }}</h2>
|
||||||
<Place></Place>
|
<Place></Place>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="normal-section projects-section" id="scroll-projects" v-if="projects && projects.length > 0">
|
<section class="normal-section projects-section" id="scroll-projects" v-if="projects && projects.length > 0">
|
||||||
<Container>
|
<Container>
|
||||||
<h2 class="section-title">{{ t('pages.projects_heading') }}</h2>
|
<h2 class="section-title">{{ t('pages.projects_heading') }}</h2>
|
||||||
|
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
<div
|
|
||||||
v-for="project in projects"
|
|
||||||
|
|
||||||
:key="project.slug"
|
|
||||||
class="project-card-wrapper"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="project-card"
|
v-for="project in projects"
|
||||||
@click="navigateToProject(project)"
|
|
||||||
|
:key="project.slug"
|
||||||
|
class="project-card-wrapper"
|
||||||
>
|
>
|
||||||
<div class="project-layout">
|
<div
|
||||||
<img
|
class="project-card"
|
||||||
v-if="project.preview"
|
@click="navigateToProject(project)"
|
||||||
:src="project.preview"
|
>
|
||||||
:alt="`Preview of ${project.title}`"
|
<div class="project-layout">
|
||||||
class="project-preview"
|
<img
|
||||||
/>
|
v-if="project.preview"
|
||||||
<div class="project-info">
|
:src="project.preview"
|
||||||
<p class="project-title">{{ project.title }}</p>
|
:alt="`Preview of ${project.title}`"
|
||||||
<p class="project-description">{{ project.description }}</p>
|
class="project-preview"
|
||||||
<div class="project-tech">
|
/>
|
||||||
<span v-for="tech in project.tech" :key="tech" class="tech-tag">{{ tech }}</span>
|
<div class="project-info">
|
||||||
|
<p class="project-title">{{ project.title }}</p>
|
||||||
|
<p class="project-description">{{ project.description }}</p>
|
||||||
|
<div class="project-tech">
|
||||||
|
<span v-for="tech in project.tech" :key="tech" class="tech-tag">{{ tech }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="normal-section" id="scroll-blog">
|
<section class="normal-section" id="scroll-blog">
|
||||||
<Container>
|
<Container>
|
||||||
<h2 class="section-title">{{ t('pages.blog_heading') }}</h2>
|
<h2 class="section-title">{{ t('pages.blog_heading') }}</h2>
|
||||||
<ul class="tui-list">
|
<ul class="tui-list">
|
||||||
<li v-for="post in displayedBlogPosts" :key="post.slug" class="blog-entry">
|
<li v-for="post in displayedBlogPosts" :key="post.slug" class="blog-entry">
|
||||||
<NuxtLink :to="localePath({ name: 'blog-slug', params: { slug: post.slug } })" class="entry-link">
|
<NuxtLink :to="localePath({ name: 'blog-slug', params: { slug: post.slug } })" class="entry-link">
|
||||||
<span class="entry-title">{{ post.title }}</span>
|
<span class="entry-title">{{ post.title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<span class="entry-meta">[{{ post.date }}] {{ post.description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="posts && posts.length > 0" class="show-more-link">
|
||||||
|
<NuxtLink :to="localePath('/blog')" class="tui-link">{{ t('pages.show_more') }}</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="normal-section" id="scroll-art">
|
||||||
|
<Container>
|
||||||
|
<h2 class="section-title">{{ t('pages.art_heading') }}</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<NuxtLink v-for="art in displayedArtPosts" :key="art.slug"
|
||||||
|
class="selector"
|
||||||
|
:to="localePath(`/art/${art.slug}`)">
|
||||||
|
<span class="selector-border-top" aria-hidden="true">────────</span>
|
||||||
|
<img
|
||||||
|
:src="art.thumb"
|
||||||
|
:alt="art.title"
|
||||||
|
class="selector-img"
|
||||||
|
/>
|
||||||
|
<span class="selector-border-bottom" aria-hidden="true">────────</span>
|
||||||
|
<div class="overlay-label">{{ art.title }}</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="entry-meta">[{{ post.date }}] {{ post.description }}</span>
|
</div>
|
||||||
</li>
|
<p v-if="artPosts && artPosts.length > 0" class="show-more-link">
|
||||||
</ul>
|
<NuxtLink :to="localePath('/art')" class="tui-link">{{ t('pages.show_more') }}</NuxtLink>
|
||||||
<p v-if="posts && posts.length > 0" class="show-more-link">
|
</p>
|
||||||
<NuxtLink :to="localePath('/blog')" class="tui-link">{{ t('pages.show_more') }}</NuxtLink>
|
</Container>
|
||||||
</p>
|
</section>
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="normal-section" id="scroll-art">
|
<section class="normal-section" id="scroll-contact">
|
||||||
<Container>
|
<Container>
|
||||||
<h2 class="section-title">{{ t('pages.art_heading') }}</h2>
|
<h2 class="section-title">{{ t('pages.contact_heading') }}</h2>
|
||||||
<div class="grid">
|
<ContentRenderer v-if="contactMarkdown" :value="contactMarkdown"></ContentRenderer>
|
||||||
<NuxtLink v-for="art in displayedArtPosts" :key="art.slug"
|
<ContactLinks />
|
||||||
class="selector"
|
</Container>
|
||||||
:to="localePath(`/art/${art.slug}`)">
|
</section>
|
||||||
<span class="selector-border-top" aria-hidden="true">────────</span>
|
</FixedLayout>
|
||||||
<img
|
<Footer></Footer>
|
||||||
:src="art.thumb"
|
|
||||||
:alt="art.title"
|
|
||||||
class="selector-img"
|
|
||||||
/>
|
|
||||||
<span class="selector-border-bottom" aria-hidden="true">────────</span>
|
|
||||||
<div class="overlay-label">{{ art.title }}</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<p v-if="artPosts && artPosts.length > 0" class="show-more-link">
|
|
||||||
<NuxtLink :to="localePath('/art')" class="tui-link">{{ t('pages.show_more') }}</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="normal-section" id="scroll-contact">
|
|
||||||
<Container>
|
|
||||||
<h2 class="section-title">{{ t('pages.contact_heading') }}</h2>
|
|
||||||
<ContentRenderer v-if="contactMarkdown" :value="contactMarkdown"></ContentRenderer>
|
|
||||||
<ContactLinks />
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
</FixedLayout>
|
|
||||||
<Footer></Footer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
506
frontend/app/pages/projects/hibolia/order.vue
Normal file
506
frontend/app/pages/projects/hibolia/order.vue
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FullscreenLayout from '~/components/layouts/FullscreenLayout.vue';
|
||||||
|
import StickyHeader from '~/components/parts/StickyHeader.vue';
|
||||||
|
import HiboliaScene from '~/components/widgets/HiboliaScene.vue';
|
||||||
|
import HiboliaDeckPreview from '~/components/widgets/HiboliaDeckPreview.vue';
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Deck Request | Aran Roig',
|
||||||
|
meta: [{ name: 'robots', content: 'noindex, nofollow', tagPriority: 'high' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { post } = useApi();
|
||||||
|
|
||||||
|
const COLOR_OPTIONS = [
|
||||||
|
{ value: 'red', hex: '#d64545' },
|
||||||
|
{ value: 'gold', hex: '#d4af37' },
|
||||||
|
{ value: 'silver', hex: '#c0c0c0' },
|
||||||
|
{ value: 'orange', hex: '#e0823c' },
|
||||||
|
{ value: 'yellow', hex: '#e2cf3c' },
|
||||||
|
{ value: 'green', hex: '#5fae4e' },
|
||||||
|
{ value: 'blue', hex: '#4c8be0' },
|
||||||
|
{ value: 'purple', hex: '#9b59d0' },
|
||||||
|
{ value: 'black', hex: '#1a1a1a' },
|
||||||
|
{ value: 'white', hex: '#f2f2f2' },
|
||||||
|
{ value: 'pink', hex: '#e88fb3' },
|
||||||
|
{ value: 'none', hex: '#9e9e9e' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
color: '',
|
||||||
|
shipping: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_EDGE_COLOR = '#d8d2c6';
|
||||||
|
|
||||||
|
const selectedColorHex = computed(
|
||||||
|
() => COLOR_OPTIONS.find((o) => o.value === form.color)?.hex ?? DEFAULT_EDGE_COLOR
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (submitting.value) return;
|
||||||
|
errorMsg.value = '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.surname.trim() ||
|
||||||
|
!form.email.trim() ||
|
||||||
|
!form.message.trim() ||
|
||||||
|
!form.color ||
|
||||||
|
!form.shipping.trim()
|
||||||
|
) {
|
||||||
|
errorMsg.value = $t('order.hibolia.error_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await post('/hibolia/orders', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
surname: form.surname.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
message: form.message.trim(),
|
||||||
|
color: form.color,
|
||||||
|
shipping: form.shipping.trim()
|
||||||
|
});
|
||||||
|
form.name = '';
|
||||||
|
form.surname = '';
|
||||||
|
form.email = '';
|
||||||
|
form.message = '';
|
||||||
|
form.color = '';
|
||||||
|
form.shipping = '';
|
||||||
|
await navigateTo(localePath('/projects/hibolia/thanks'));
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e?.status === 400
|
||||||
|
? $t('order.hibolia.error_invalid')
|
||||||
|
: $t('order.hibolia.error_generic');
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FullscreenLayout>
|
||||||
|
<HiboliaScene class="order-scene" aria-hidden="true" />
|
||||||
|
|
||||||
|
<Container class="full-bg">
|
||||||
|
<div class="order-layout">
|
||||||
|
<form class="order-form" @submit.prevent="onSubmit">
|
||||||
|
<h1 class="order-title">{{ $t('order.hibolia.form_title') }}</h1>
|
||||||
|
|
||||||
|
<p>{{ $t('order.hibolia.intro') }}</p>
|
||||||
|
<p>{{ $t('order.hibolia.includes') }}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{{ $t('order.hibolia.includes_box') }}</li>
|
||||||
|
<li>
|
||||||
|
{{ $t('order.hibolia.includes_cards') }}
|
||||||
|
<ul>
|
||||||
|
<li>{{ $t('order.hibolia.includes_suits') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.includes_jokers') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.includes_extra') }}</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>{{ $t('order.hibolia.basics_intro') }}</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="order-name">{{ $t('order.hibolia.name_label') }}:</label>
|
||||||
|
<input
|
||||||
|
id="order-name"
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
autocomplete="given-name"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="order-surname">{{ $t('order.hibolia.surname_label') }}:</label>
|
||||||
|
<input
|
||||||
|
id="order-surname"
|
||||||
|
v-model="form.surname"
|
||||||
|
type="text"
|
||||||
|
name="surname"
|
||||||
|
autocomplete="family-name"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="order-email">{{ $t('order.hibolia.email_label') }}:</label>
|
||||||
|
<input
|
||||||
|
id="order-email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>{{ $t('order.hibolia.dragon_intro') }}</p>
|
||||||
|
<div class="field field-block">
|
||||||
|
<label for="order-message">{{ $t('order.hibolia.message_label') }}:</label>
|
||||||
|
<textarea
|
||||||
|
id="order-message"
|
||||||
|
v-model="form.message"
|
||||||
|
name="message"
|
||||||
|
rows="6"
|
||||||
|
maxlength="2000"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>{{ $t('order.hibolia.edges_intro') }}</p>
|
||||||
|
<p>{{ $t('order.hibolia.edges_color_prompt') }}</p>
|
||||||
|
<fieldset class="field field-block color-field">
|
||||||
|
<legend>{{ $t('order.hibolia.color_label') }}:</legend>
|
||||||
|
<div class="color-grid">
|
||||||
|
<label
|
||||||
|
v-for="option in COLOR_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
class="color-option"
|
||||||
|
:class="{ selected: form.color === option.value }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.color"
|
||||||
|
type="radio"
|
||||||
|
name="color"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="submitting"
|
||||||
|
:id="`order-color-${option.value}`"
|
||||||
|
/>
|
||||||
|
<span class="swatch" :style="{ background: option.hex }" aria-hidden="true"></span>
|
||||||
|
<span class="color-name">{{ $t(`order.hibolia.colors.${option.value}`) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p>{{ $t('order.hibolia.preview_note') }}</p>
|
||||||
|
|
||||||
|
<HiboliaDeckPreview class="deck-preview" :color="selectedColorHex" />
|
||||||
|
|
||||||
|
<i18n-t keypath="order.hibolia.edges_advice" tag="p">
|
||||||
|
<template #please><b>{{ $t('order.hibolia.please') }}</b></template>
|
||||||
|
</i18n-t>
|
||||||
|
|
||||||
|
<p>{{ $t('order.hibolia.final_intro') }}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{{ $t('order.hibolia.final_design') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_delivery_time') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_delivery_inperson') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_price') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_payment_methods') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_payment_deadline') }}</li>
|
||||||
|
<li>{{ $t('order.hibolia.final_tip') }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{{ $t('order.hibolia.comments_prompt') }}</p>
|
||||||
|
<div class="field field-block">
|
||||||
|
<label for="order-shipping">{{ $t('order.hibolia.comments_label') }}:</label>
|
||||||
|
<textarea
|
||||||
|
id="order-shipping"
|
||||||
|
v-model="form.shipping"
|
||||||
|
name="shipping"
|
||||||
|
rows="6"
|
||||||
|
maxlength="2000"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="order-error" role="alert">✗ {{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<button type="submit" class="order-btn" :disabled="submitting">
|
||||||
|
<span v-if="!submitting">> {{ $t('order.hibolia.submit') }}</span>
|
||||||
|
<span v-else class="blink">{{ $t('order.hibolia.submitting') }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</FullscreenLayout>
|
||||||
|
<StickyHeader always-visible />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tui-frame.full-bg {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
:deep(.tui-inner) {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 52px;
|
||||||
|
background: var(--color-background-fore);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.frame-corner-tr),
|
||||||
|
:deep(.frame-corner-br) {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-prompt {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-form {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 860px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 100px 0;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 28px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul ul {
|
||||||
|
list-style: circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 2px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-scene {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
box-shadow: 2px -2px 0 0 var(--color-frame-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-block {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-preview {
|
||||||
|
height: 300px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms steps(3);
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
input[type='radio'] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
box-shadow: 2px -2px 0 0 var(--color-frame-glow);
|
||||||
|
background: var(--color-hover);
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
color: var(--color-link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='radio']:focus-visible ~ .swatch {
|
||||||
|
outline: 1px dashed var(--color-link);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-error {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e05c5c;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #d4d4d4;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 14px 42px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms steps(3);
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50%, 100% { opacity: 1; }
|
||||||
|
25%, 75% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.field {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.no-sprite .undertable-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
frontend/app/pages/projects/hibolia/thanks.vue
Normal file
121
frontend/app/pages/projects/hibolia/thanks.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FullscreenLayout from '~/components/layouts/FullscreenLayout.vue';
|
||||||
|
import StickyHeader from '~/components/parts/StickyHeader.vue';
|
||||||
|
import HiboliaScene from '~/components/widgets/HiboliaScene.vue';
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Thanks | Aran Roig',
|
||||||
|
meta: [{ name: 'robots', content: 'noindex, nofollow', tagPriority: 'high' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FullscreenLayout>
|
||||||
|
<HiboliaScene class="thanks-scene" aria-hidden="true" />
|
||||||
|
|
||||||
|
<Container class="full-bg">
|
||||||
|
<div class="thanks-layout">
|
||||||
|
<div class="thanks-panel">
|
||||||
|
<h1 class="thanks-title">✓ {{ $t('order.hibolia.thanks.title') }}</h1>
|
||||||
|
<p class="thanks-message">{{ $t('order.hibolia.thanks.message') }}</p>
|
||||||
|
<p class="thanks-next">{{ $t('order.hibolia.thanks.next') }}</p>
|
||||||
|
<NuxtLink class="thanks-back" :to="localePath('/')">
|
||||||
|
> {{ $t('order.hibolia.thanks.back_home') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</FullscreenLayout>
|
||||||
|
<StickyHeader always-visible />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tui-frame.full-bg {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
:deep(.tui-inner) {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 52px;
|
||||||
|
background: var(--color-background-fore);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.frame-corner-tr),
|
||||||
|
:deep(.frame-corner-br) {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-panel {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 720px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 100px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-message {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-next {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 12px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-back {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #d4d4d4;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
padding: 14px 42px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 120ms steps(3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-scene {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.no-sprite .undertable-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
275
frontend/app/pages/projects/index.vue
Normal file
275
frontend/app/pages/projects/index.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FixedLayout from '~/components/layouts/FixedLayout.vue';
|
||||||
|
import TableHeader from '~/components/parts/TableHeader.vue';
|
||||||
|
import { useSeo } from '~/composables/seo';
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
|
useSeo({
|
||||||
|
title: 'Projects',
|
||||||
|
description: 'Explore the projects of Aran Roig. Software engineering, tools, games and creative experiments.',
|
||||||
|
canonicalUrl: '/projects'
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
headline: 'Projects by Aran Roig',
|
||||||
|
description: 'Explore the projects of Aran Roig. Software engineering, tools, games and creative experiments.',
|
||||||
|
url: 'https://aranroig.com/projects',
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'Aran Roig'
|
||||||
|
},
|
||||||
|
inLanguage: ['en', 'es', 'ca', 'zh']
|
||||||
|
})
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projects } = useAsyncData('projects-list', async () => {
|
||||||
|
const currentLocale = locale.value;
|
||||||
|
|
||||||
|
const enProjects = await queryCollection('projects')
|
||||||
|
.where('path', 'LIKE', `/projects/en/%`)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (currentLocale === 'en') return enProjects;
|
||||||
|
|
||||||
|
const localeProjects = await queryCollection('projects')
|
||||||
|
.where('path', 'LIKE', `/projects/${currentLocale}/%`)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const translatedSlugs = new Set(
|
||||||
|
localeProjects.map((p) => p.path.replace(`/projects/${currentLocale}/`, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
const enFallbacks = enProjects.filter(
|
||||||
|
(p) => !translatedSlugs.has(p.path.replace('/projects/en/', ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...localeProjects, ...enFallbacks];
|
||||||
|
}, { watch: [locale, () => useRoute().path] });
|
||||||
|
|
||||||
|
function resolveProjectLink(project) {
|
||||||
|
if (project.link) return project.link;
|
||||||
|
const slugToProjectMap = {
|
||||||
|
'dragonroll': 'https://dragonroll.aranroig.com',
|
||||||
|
};
|
||||||
|
return slugToProjectMap[project.slug] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToProject(project) {
|
||||||
|
const link = resolveProjectLink(project);
|
||||||
|
if (link) window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="no-sprite">
|
||||||
|
<TableHeader></TableHeader>
|
||||||
|
</div>
|
||||||
|
<FixedLayout>
|
||||||
|
<Container class="top-margin">
|
||||||
|
<h1 class="section-title">{{ t('pages.projects_heading') }}</h1>
|
||||||
|
<div class="projects-grid">
|
||||||
|
<div
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.slug"
|
||||||
|
class="project-card-wrapper"
|
||||||
|
>
|
||||||
|
<div class="project-card" @click="navigateToProject(project)">
|
||||||
|
<div class="project-layout">
|
||||||
|
<img
|
||||||
|
v-if="project.preview"
|
||||||
|
:src="project.preview"
|
||||||
|
:alt="`Preview of ${project.title}`"
|
||||||
|
class="project-preview"
|
||||||
|
/>
|
||||||
|
<div class="project-info">
|
||||||
|
<p class="project-title">{{ project.title }}</p>
|
||||||
|
<p class="project-description">{{ project.description }}</p>
|
||||||
|
<div class="project-tech">
|
||||||
|
<span v-for="tech in project.tech" :key="tech" class="tech-tag">{{ tech }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</FixedLayout>
|
||||||
|
<Footer></Footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.section-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 28px 0 16px 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "├";
|
||||||
|
color: var(--color-link);
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--color-link);
|
||||||
|
margin-left: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 4px var(--color-link);
|
||||||
|
animation: blink-cursor 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-cursor {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-background-fore);
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
box-shadow: 4px -4px 0px 0px var(--color-container-shadow);
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: all 0.1s steps(2, end);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "[ PROJECT ]";
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
left: 12px;
|
||||||
|
background-color: var(--color-background-fore);
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-link);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-preview {
|
||||||
|
width: 140px;
|
||||||
|
min-width: 140px;
|
||||||
|
height: 85px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--color-border-color);
|
||||||
|
image-rendering: auto;
|
||||||
|
transition: border-color 0.1s steps(2, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-wrapper:hover .project-card {
|
||||||
|
border-color: var(--color-link);
|
||||||
|
box-shadow: 4px -4px 0px 0px var(--color-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-shadow: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-description {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 8px 0 12px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tech {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-tag {
|
||||||
|
font-family: 'Hurmit', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-background-fore);
|
||||||
|
background-color: var(--color-link);
|
||||||
|
padding: 1px 6px;
|
||||||
|
box-shadow: 2px -2px 0px 0px rgba(0, 0, 0, 0.3);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-layout {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-preview {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
height: 160px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-description {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.no-sprite .undertable-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"links": {
|
"links": {
|
||||||
|
"projects": "projectes",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
"contact": "contacte",
|
"contact": "contacte",
|
||||||
"art": "art"
|
"art": "art"
|
||||||
@@ -34,13 +35,13 @@
|
|||||||
"projects_heading": "Projectes",
|
"projects_heading": "Projectes",
|
||||||
"blog_heading": "Entrades de Blog",
|
"blog_heading": "Entrades de Blog",
|
||||||
"art_heading": "Galeria d'Art",
|
"art_heading": "Galeria d'Art",
|
||||||
"stats_heading": "Estad\u00edstiques i Info",
|
"stats_heading": "Estadístiques i Info",
|
||||||
"stat_lines_of_code": "L\u00ednies de Codi Escrites",
|
"stat_lines_of_code": "Línies de Codi Escrites",
|
||||||
"stat_projects": "Projectes Completats",
|
"stat_projects": "Projectes Completats",
|
||||||
"stat_coffee": "Tasses de Caf\u00e8 Consumides",
|
"stat_coffee": "Tasses de Cafè Consumides",
|
||||||
"stat_board_games": "Jocs de Taula Propis",
|
"stat_board_games": "Jocs de Taula Propis",
|
||||||
"stat_years_programming": "Anys Programant",
|
"stat_years_programming": "Anys Programant",
|
||||||
"stat_deployments": "Desplegaments a Producci\u00f3",
|
"stat_deployments": "Desplegaments a Producció",
|
||||||
"place_heading": "Pissarra de Píxels",
|
"place_heading": "Pissarra de Píxels",
|
||||||
"show_more": "mostrar més...",
|
"show_more": "mostrar més...",
|
||||||
"show_less": "mostrar menys"
|
"show_less": "mostrar menys"
|
||||||
@@ -59,5 +60,102 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prefooter": "Compilada des de la res at las 00:00:00",
|
"prefooter": "Compilada des de la res at las 00:00:00",
|
||||||
"footer": "(C) 2026 Aran Roig. Tots els drets no sé que."
|
"order": {
|
||||||
}
|
"hibolia": {
|
||||||
|
"title": "COMANDA HIBOLIA",
|
||||||
|
"prompt": "Fes la teva comanda Hibolia. Omple el formulari i s'enviarà directament.",
|
||||||
|
"name_label": "nom",
|
||||||
|
"surname_label": "cognoms",
|
||||||
|
"email_label": "correu",
|
||||||
|
"message_label": "missatge",
|
||||||
|
"color_label": "color",
|
||||||
|
"shipping_label": "enviament",
|
||||||
|
"colors": {
|
||||||
|
"red": "Vermell",
|
||||||
|
"gold": "Or",
|
||||||
|
"silver": "Plata",
|
||||||
|
"orange": "Taronja",
|
||||||
|
"yellow": "Groc",
|
||||||
|
"green": "Verd",
|
||||||
|
"blue": "Blau",
|
||||||
|
"purple": "Morat",
|
||||||
|
"black": "Negre",
|
||||||
|
"white": "Blanc",
|
||||||
|
"pink": "Rosa",
|
||||||
|
"none": "Cap"
|
||||||
|
},
|
||||||
|
"submit": "enviar comanda",
|
||||||
|
"submitting": "enviant...",
|
||||||
|
"success": "Comanda enviada correctament.",
|
||||||
|
"error_required": "Tots els camps són obligatoris.",
|
||||||
|
"error_invalid": "Algunes dades no són vàlides. Revisa el formulari i torna-ho a provar.",
|
||||||
|
"error_generic": "No s'ha pogut enviar la comanda. Torna-ho a provar més tard.",
|
||||||
|
"form_title": "Formulari de comanda de baralla de cartes",
|
||||||
|
"intro": "Aquest formulari servirà per registrar oficialment la teva comanda de baralla de cartes.",
|
||||||
|
"includes": "La baralla inclou:",
|
||||||
|
"includes_box": "Una capsa decorada per guardar les cartes",
|
||||||
|
"includes_cards": "55 cartes Linen M31 amb costats de colors diferents, de les quals:",
|
||||||
|
"includes_suits": "52 cartes de 4 pals de 13 cartes cadascun",
|
||||||
|
"includes_jokers": "2 Jòquers",
|
||||||
|
"includes_extra": "Una carta addicional que conté una guia per jugar al pòquer en una carta i un drac dibuixat ràpidament en l'altra i una signatura",
|
||||||
|
"basics_intro": "Primer, necessito unes quantes dades bàsiques:",
|
||||||
|
"dragon_intro": "En la carta addicional dibuixaré un drac dibuixat ràpidament. Abans de dibuixar-lo llegiré el que posis en aquest camp de text. Fes el que creguis necessari amb aquesta informació:",
|
||||||
|
"edges_intro": "Si vols, puc pintar les vores de les cartes amb pintures acríliques. Em surt més barat que si ho demano als fabricants. Si no vols que ho faci, també ho pots marcar.",
|
||||||
|
"edges_color_prompt": "Introdueix de quin color vols el costat de les cartes:",
|
||||||
|
"preview_note": "Això és una petita previsualització una mica cutre:",
|
||||||
|
"edges_advice": "Si creus que pintar les vores de les cartes amb una capa fina de pintura acrílica mentre estan sotmeses a una alta pressió {please} digues-me què és el que hauria de fer en lloc d'això (no sóc expert en papers i pintures).",
|
||||||
|
"please": "si us plau",
|
||||||
|
"final_intro": "I per últim,",
|
||||||
|
"final_design": "D'aquí a uns dies, quan tingui acabat el disseny exterior de la capsa, faré la comanda de fabricació.",
|
||||||
|
"final_delivery_time": "En aproximadament 2-3 setmanes arriba, a partir de llavors et podré entregar la baralla.",
|
||||||
|
"final_delivery_inperson": "Si és possible, te la entregaré presencialment.",
|
||||||
|
"final_price": "El preu final de fabricació serà de 12,75 €.",
|
||||||
|
"final_payment_methods": "Accepto efectiu o Bizum. Tens temps per fer el pagament just abans que et lliuri la baralla.",
|
||||||
|
"final_payment_deadline": "Si pots fer el pagament abans del 26 de setembre, millor.",
|
||||||
|
"final_tip": "Si em pagues més que això ho acceptaré com a propina i t'estaré molt agraït per permetre'm estalviar per a un entrepà.",
|
||||||
|
"comments_prompt": "Si tens algun altre dubte, queixa, comentari, etc., emplena'l aquí:",
|
||||||
|
"comments_label": "Comentaris",
|
||||||
|
"thanks": {
|
||||||
|
"title": "Gràcies!",
|
||||||
|
"message": "La teva comanda de la baralla Hibolia s'ha rebut correctament.",
|
||||||
|
"next": "La revisaré i em posaré en contacte amb tu al correu que ens has deixat.",
|
||||||
|
"back_home": "tornar a l'inici"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": "(C) 2026 Aran Roig. Tots els drets no sé que.",
|
||||||
|
"admin": {
|
||||||
|
"login": {
|
||||||
|
"title": "ACCÉS D'ADMINISTRADOR",
|
||||||
|
"prompt": "Àrea restringida. Cal autenticació d'administrador.",
|
||||||
|
"username_label": "usuari",
|
||||||
|
"password_label": "contrasenya",
|
||||||
|
"submit": "autenticar",
|
||||||
|
"submitting": "autenticant...",
|
||||||
|
"error_invalid": "Accés denegat: credencials no vàlides.",
|
||||||
|
"error_generic": "L'autenticació ha fallat. Torna-ho a provar més tard."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "TAULER D'ADMINISTRACIÓ",
|
||||||
|
"welcome": "sessió activa com a",
|
||||||
|
"logout": "tancar sessió",
|
||||||
|
"loading": "carregant dades del sistema...",
|
||||||
|
"load_error": "No s'han pogut carregar les dades del tauler.",
|
||||||
|
"section_status": "estat del sistema",
|
||||||
|
"status": "estat",
|
||||||
|
"mongo": "base de dades",
|
||||||
|
"uptime": "temps actiu",
|
||||||
|
"memory_rss": "memòria (rss)",
|
||||||
|
"memory_heap": "heap usat",
|
||||||
|
"total_cells": "cel·les pintades",
|
||||||
|
"section_orders": "comandes hibolia",
|
||||||
|
"orders_empty": "encara no hi ha comandes.",
|
||||||
|
"col_date": "data",
|
||||||
|
"col_name": "nom",
|
||||||
|
"col_email": "correu",
|
||||||
|
"col_message": "missatge",
|
||||||
|
"col_color": "color",
|
||||||
|
"col_shipping": "enviament"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"links": {
|
"links": {
|
||||||
|
"projects": "projects",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
"contact": "contact",
|
"contact": "contact",
|
||||||
"art": "art"
|
"art": "art"
|
||||||
@@ -59,5 +60,102 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prefooter": "",
|
"prefooter": "",
|
||||||
"footer": "(C) 2026 Aran Roig. All rights whatever."
|
"order": {
|
||||||
}
|
"hibolia": {
|
||||||
|
"title": "HIBOLIA ORDER",
|
||||||
|
"prompt": "Place your Hibolia order. Fill in the form and it will be sent directly.",
|
||||||
|
"name_label": "name",
|
||||||
|
"surname_label": "surname",
|
||||||
|
"email_label": "email",
|
||||||
|
"message_label": "message",
|
||||||
|
"color_label": "color",
|
||||||
|
"shipping_label": "shipping",
|
||||||
|
"colors": {
|
||||||
|
"red": "Red",
|
||||||
|
"gold": "Gold",
|
||||||
|
"silver": "Silver",
|
||||||
|
"orange": "Orange",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"green": "Green",
|
||||||
|
"blue": "Blue",
|
||||||
|
"purple": "Purple",
|
||||||
|
"black": "Black",
|
||||||
|
"white": "White",
|
||||||
|
"pink": "Pink",
|
||||||
|
"none": "None"
|
||||||
|
},
|
||||||
|
"submit": "send order",
|
||||||
|
"submitting": "sending...",
|
||||||
|
"success": "Order sent successfully.",
|
||||||
|
"error_required": "All fields are required.",
|
||||||
|
"error_invalid": "Some data is invalid. Check the form and try again.",
|
||||||
|
"error_generic": "Could not send the order. Try again later.",
|
||||||
|
"form_title": "Playing card deck order form",
|
||||||
|
"intro": "This form will be used to officially register your playing card deck order.",
|
||||||
|
"includes": "The deck includes:",
|
||||||
|
"includes_box": "A decorated box to store the cards",
|
||||||
|
"includes_cards": "55 Linen M31 cards with different colored edges, of which:",
|
||||||
|
"includes_suits": "52 cards in 4 suits of 13 cards each",
|
||||||
|
"includes_jokers": "2 Jokers",
|
||||||
|
"includes_extra": "An extra card containing a poker guide on one side, a quickly sketched dragon on the other, and a signature",
|
||||||
|
"basics_intro": "First, I need a few basic details:",
|
||||||
|
"dragon_intro": "On the extra card I will draw a quickly sketched dragon. Before drawing it, I will read whatever you write in this text field. Use this information however you see fit:",
|
||||||
|
"edges_intro": "If you want, I can paint the edges of the cards with acrylic paints. It's cheaper for me than having the manufacturers do it. If you don't want me to do it, you can mark that too.",
|
||||||
|
"edges_color_prompt": "Enter which color you want the card edges:",
|
||||||
|
"preview_note": "This is a bit of a crappy little preview:",
|
||||||
|
"edges_advice": "If you think that painting the card edges with a thin layer of acrylic paint while they are under high pressure is a bad idea, {please} tell me what I should do instead (I'm no expert with papers and paints).",
|
||||||
|
"please": "please",
|
||||||
|
"final_intro": "And finally,",
|
||||||
|
"final_design": "In a few days, once the outer design of the box is finished, I will place the manufacturing order.",
|
||||||
|
"final_delivery_time": "It arrives in approximately 2-3 weeks; from then on I will be able to hand you the deck.",
|
||||||
|
"final_delivery_inperson": "If possible, I will deliver it to you in person.",
|
||||||
|
"final_price": "The final manufacturing price will be €12.75.",
|
||||||
|
"final_payment_methods": "I accept cash or Bizum. You have time to pay right before I hand you the deck.",
|
||||||
|
"final_payment_deadline": "It would be better if you could pay before September 26.",
|
||||||
|
"final_tip": "If you pay me more than that, I will accept it as a tip, and I will be very grateful for letting me save up for a sandwich.",
|
||||||
|
"comments_prompt": "If you have any other question, complaint, comment, etc., fill it in here:",
|
||||||
|
"comments_label": "Comments",
|
||||||
|
"thanks": {
|
||||||
|
"title": "Thank you!",
|
||||||
|
"message": "Your Hibolia deck order has been received.",
|
||||||
|
"next": "I will check it and get in touch with you at the email address you left.",
|
||||||
|
"back_home": "back to home"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": "(C) 2026 Aran Roig. All rights whatever.",
|
||||||
|
"admin": {
|
||||||
|
"login": {
|
||||||
|
"title": "ADMIN ACCESS",
|
||||||
|
"prompt": "Restricted area. Administrator authentication required.",
|
||||||
|
"username_label": "login",
|
||||||
|
"password_label": "password",
|
||||||
|
"submit": "authenticate",
|
||||||
|
"submitting": "authenticating...",
|
||||||
|
"error_invalid": "Access denied: invalid credentials.",
|
||||||
|
"error_generic": "Authentication failed. Try again later."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "ADMIN DASHBOARD",
|
||||||
|
"welcome": "session active as",
|
||||||
|
"logout": "logout",
|
||||||
|
"loading": "loading system data...",
|
||||||
|
"load_error": "Could not load dashboard data.",
|
||||||
|
"section_status": "system status",
|
||||||
|
"status": "status",
|
||||||
|
"mongo": "database",
|
||||||
|
"uptime": "uptime",
|
||||||
|
"memory_rss": "memory (rss)",
|
||||||
|
"memory_heap": "heap used",
|
||||||
|
"total_cells": "cells painted",
|
||||||
|
"section_orders": "hibolia orders",
|
||||||
|
"orders_empty": "no orders yet.",
|
||||||
|
"col_date": "date",
|
||||||
|
"col_name": "name",
|
||||||
|
"col_email": "email",
|
||||||
|
"col_message": "message",
|
||||||
|
"col_color": "color",
|
||||||
|
"col_shipping": "shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,24 +23,25 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"links": {
|
"links": {
|
||||||
|
"projects": "proyectos",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
"contact": "contacto",
|
"contact": "contacto",
|
||||||
"art": "arte"
|
"art": "arte"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"intro_heading": "INTRODUCCI\u00d3N",
|
"intro_heading": "INTRODUCCIÓN",
|
||||||
"contact_heading": "Contacto",
|
"contact_heading": "Contacto",
|
||||||
"projects_heading": "Proyectos",
|
"projects_heading": "Proyectos",
|
||||||
"blog_heading": "Entradas de Blog",
|
"blog_heading": "Entradas de Blog",
|
||||||
"art_heading": "Galer\u00eda de Arte",
|
"art_heading": "Galería de Arte",
|
||||||
"stats_heading": "Estad\u00edsticas e Info",
|
"stats_heading": "Estadísticas e Info",
|
||||||
"stat_lines_of_code": "L\u00edneas de C\u00f3digo Escritas",
|
"stat_lines_of_code": "Líneas de Código Escritas",
|
||||||
"stat_projects": "Proyectos Completados",
|
"stat_projects": "Proyectos Completados",
|
||||||
"stat_coffee": "Tazas de Caf\u00e9 Consumidas",
|
"stat_coffee": "Tazas de Café Consumidas",
|
||||||
"stat_board_games": "Juegos de Mesa Propios",
|
"stat_board_games": "Juegos de Mesa Propios",
|
||||||
"stat_years_programming": "A\u00f1os Programando",
|
"stat_years_programming": "Años Programando",
|
||||||
"stat_deployments": "Desplegues a Producci\u00f3n",
|
"stat_deployments": "Desplegues a Producción",
|
||||||
"place_heading": "Pizarra de Píxeles",
|
"place_heading": "Pizarra de Píxeles",
|
||||||
"show_more": "mostrar más...",
|
"show_more": "mostrar más...",
|
||||||
"show_less": "mostrar menos"
|
"show_less": "mostrar menos"
|
||||||
@@ -59,5 +60,102 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prefooter": "Compilada desde la nada a las 00:00:00",
|
"prefooter": "Compilada desde la nada a las 00:00:00",
|
||||||
"footer": "(C) 2026 Aran Roig. Todos los derechos no se que."
|
"order": {
|
||||||
}
|
"hibolia": {
|
||||||
|
"title": "PEDIDO HIBOLIA",
|
||||||
|
"prompt": "Haz tu pedido Hibolia. Rellena el formulario y se enviará directamente.",
|
||||||
|
"name_label": "nombre",
|
||||||
|
"surname_label": "apellidos",
|
||||||
|
"email_label": "email",
|
||||||
|
"message_label": "mensaje",
|
||||||
|
"color_label": "color",
|
||||||
|
"shipping_label": "envío",
|
||||||
|
"colors": {
|
||||||
|
"red": "Rojo",
|
||||||
|
"gold": "Oro",
|
||||||
|
"silver": "Plata",
|
||||||
|
"orange": "Naranja",
|
||||||
|
"yellow": "Amarillo",
|
||||||
|
"green": "Verde",
|
||||||
|
"blue": "Azul",
|
||||||
|
"purple": "Morado",
|
||||||
|
"black": "Negro",
|
||||||
|
"white": "Blanco",
|
||||||
|
"pink": "Rosa",
|
||||||
|
"none": "Ninguno"
|
||||||
|
},
|
||||||
|
"submit": "enviar pedido",
|
||||||
|
"submitting": "enviando...",
|
||||||
|
"success": "Pedido enviado correctamente.",
|
||||||
|
"error_required": "Todos los campos son obligatorios.",
|
||||||
|
"error_invalid": "Algunos datos no son válidos. Revisa el formulario e inténtalo de nuevo.",
|
||||||
|
"error_generic": "No se pudo enviar el pedido. Inténtalo más tarde.",
|
||||||
|
"form_title": "Formulario de orden de baraja de cartas",
|
||||||
|
"intro": "Este formulario será para registrar oficialmente tu pedido de baraja de cartas.",
|
||||||
|
"includes": "El mazo incluye:",
|
||||||
|
"includes_box": "Una caja decorada para guardar las cartas",
|
||||||
|
"includes_cards": "55 cartas Linen M31 con lados de diferentes colores, de las cuales:",
|
||||||
|
"includes_suits": "52 cartas de 4 palos de 13 cartas cada una",
|
||||||
|
"includes_jokers": "2 Jokers",
|
||||||
|
"includes_extra": "Una carta adicional que contiene una guía para jugar al póker en una carta y un dragón dibujado rápidamente en la otra y una firma",
|
||||||
|
"basics_intro": "Primero, necesito unos cuantos datos básicos:",
|
||||||
|
"dragon_intro": "En la carta adicional dibujaré un dragón dibujado rápidamente. Antes de dibujarlo leeré lo que pongas en este campo de texto. Haz lo que creas necesario con esta información:",
|
||||||
|
"edges_intro": "Si quieres puedo pintar los bordes de las cartas con pinturas acrílicas. Me sale más barato que si lo pido a los fabricantes. Si no quieres que lo haga lo puedes marcar también.",
|
||||||
|
"edges_color_prompt": "Introduce de qué color quieres el lado de las cartas:",
|
||||||
|
"preview_note": "Esto es una pequeña preview un poco cutre:",
|
||||||
|
"edges_advice": "Si crees que pintar los bordes de las cartas con una capa fina de pintura acrílica mientras están sometidas a una alta presión {please} dime qué es lo que tendría que hacer en vez de eso (no soy experto en papeles y pinturas).",
|
||||||
|
"please": "por favor",
|
||||||
|
"final_intro": "Y por último,",
|
||||||
|
"final_design": "De aquí unos días en cuanto tenga acabado el diseño exterior de la caja haré el pedido de fabricación.",
|
||||||
|
"final_delivery_time": "En aproximadamente 2-3 semanas llega, a partir de entonces te podré entregar la baraja.",
|
||||||
|
"final_delivery_inperson": "Si es posible te la entregaré presencialmente.",
|
||||||
|
"final_price": "El precio final de fabricación será de 12,75€.",
|
||||||
|
"final_payment_methods": "Acepto efectivo o Bizum. Tienes tiempo para hacer el pago justo antes de que te entregue la baraja.",
|
||||||
|
"final_payment_deadline": "Si puedes hacer el pago antes del 26 de septiembre mejor.",
|
||||||
|
"final_tip": "Si me pagas más de eso lo aceptaré como propina y te estaré muy agradecido por permitirme ahorrar para un bocata.",
|
||||||
|
"comments_prompt": "Si tienes alguna otra duda, queja, comentario, etc. rellénalo aquí:",
|
||||||
|
"comments_label": "Comentarios",
|
||||||
|
"thanks": {
|
||||||
|
"title": "¡Gracias!",
|
||||||
|
"message": "Tu pedido de la baraja Hibolia se ha recibido correctamente.",
|
||||||
|
"next": "Lo revisaré y me pondré en contacto contigo en el correo que nos has dejado.",
|
||||||
|
"back_home": "volver al inicio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": "(C) 2026 Aran Roig. Todos los derechos no se que.",
|
||||||
|
"admin": {
|
||||||
|
"login": {
|
||||||
|
"title": "ACCESO DE ADMINISTRADOR",
|
||||||
|
"prompt": "Área restringida. Se requiere autenticación de administrador.",
|
||||||
|
"username_label": "usuario",
|
||||||
|
"password_label": "contraseña",
|
||||||
|
"submit": "autenticar",
|
||||||
|
"submitting": "autenticando...",
|
||||||
|
"error_invalid": "Acceso denegado: credenciales no válidas.",
|
||||||
|
"error_generic": "La autenticación ha fallado. Inténtalo más tarde."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "PANEL DE ADMINISTRACIÓN",
|
||||||
|
"welcome": "sesión activa como",
|
||||||
|
"logout": "cerrar sesión",
|
||||||
|
"loading": "cargando datos del sistema...",
|
||||||
|
"load_error": "No se pudieron cargar los datos del panel.",
|
||||||
|
"section_status": "estado del sistema",
|
||||||
|
"status": "estado",
|
||||||
|
"mongo": "base de datos",
|
||||||
|
"uptime": "tiempo activo",
|
||||||
|
"memory_rss": "memoria (rss)",
|
||||||
|
"memory_heap": "heap usado",
|
||||||
|
"total_cells": "células pintadas",
|
||||||
|
"section_orders": "pedidos hibolia",
|
||||||
|
"orders_empty": "aún no hay pedidos.",
|
||||||
|
"col_date": "fecha",
|
||||||
|
"col_name": "nombre",
|
||||||
|
"col_email": "email",
|
||||||
|
"col_message": "mensaje",
|
||||||
|
"col_color": "color",
|
||||||
|
"col_shipping": "envío"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"links": {
|
"links": {
|
||||||
|
"projects": "项目",
|
||||||
"blog": "博客",
|
"blog": "博客",
|
||||||
"contact": "联系",
|
"contact": "联系",
|
||||||
"art": "艺术"
|
"art": "艺术"
|
||||||
@@ -59,5 +60,102 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prefooter": "",
|
"prefooter": "",
|
||||||
"footer": "(C) 2026 Aran Roig. 版权所有,随便用。"
|
"order": {
|
||||||
|
"hibolia": {
|
||||||
|
"title": "HIBOLIA 订单",
|
||||||
|
"prompt": "填写表格,提交您的 Hibolia 订单。",
|
||||||
|
"name_label": "名字",
|
||||||
|
"surname_label": "姓氏",
|
||||||
|
"email_label": "邮箱",
|
||||||
|
"message_label": "留言",
|
||||||
|
"color_label": "颜色",
|
||||||
|
"shipping_label": "配送",
|
||||||
|
"colors": {
|
||||||
|
"red": "红色",
|
||||||
|
"gold": "金色",
|
||||||
|
"silver": "银色",
|
||||||
|
"orange": "橙色",
|
||||||
|
"yellow": "黄色",
|
||||||
|
"green": "绿色",
|
||||||
|
"blue": "蓝色",
|
||||||
|
"purple": "紫色",
|
||||||
|
"black": "黑色",
|
||||||
|
"white": "白色",
|
||||||
|
"pink": "粉色",
|
||||||
|
"none": "无"
|
||||||
|
},
|
||||||
|
"submit": "提交订单",
|
||||||
|
"submitting": "正在提交...",
|
||||||
|
"success": "订单提交成功。",
|
||||||
|
"error_required": "所有字段均为必填。",
|
||||||
|
"error_invalid": "部分数据无效,请检查表单后重试。",
|
||||||
|
"error_generic": "无法提交订单,请稍后再试。",
|
||||||
|
"form_title": "纸牌订购表单",
|
||||||
|
"intro": "此表单用于正式登记您的纸牌订单。",
|
||||||
|
"includes": "这副牌包括:",
|
||||||
|
"includes_box": "一个用于存放纸牌的装饰盒",
|
||||||
|
"includes_cards": "55 张 Linen M31 纸牌,侧边颜色各异,其中:",
|
||||||
|
"includes_suits": "52 张牌,4 种花色,每种 13 张",
|
||||||
|
"includes_jokers": "2 张王牌(Joker)",
|
||||||
|
"includes_extra": "一张额外的牌:一面是扑克玩法指南,另一面是速写龙,并附有签名",
|
||||||
|
"basics_intro": "首先,我需要一些基本信息:",
|
||||||
|
"dragon_intro": "我会在额外的牌上画一条速写龙。动笔之前,我会阅读您在这个文本框里写的内容。这些信息如何使用由您决定:",
|
||||||
|
"edges_intro": "如果您愿意,我可以用丙烯颜料为卡牌刷边,这样比让厂家做更便宜。如果您不希望我这样做,也可以注明。",
|
||||||
|
"edges_color_prompt": "请输入您想要的牌边颜色:",
|
||||||
|
"preview_note": "这是一个比较简陋的预览:",
|
||||||
|
"edges_advice": "如果您认为在高压下用一层薄薄的丙烯颜料刷边有不妥之处,{please}告诉我该怎么做(我不是纸张和颜料方面的专家)。",
|
||||||
|
"please": "请",
|
||||||
|
"final_intro": "最后,",
|
||||||
|
"final_design": "再过几天,等我完成牌盒的外部设计,就会下生产订单。",
|
||||||
|
"final_delivery_time": "大约 2-3 周后到货,届时我就可以把牌交给您。",
|
||||||
|
"final_delivery_inperson": "如果可能的话,我会当面交给您。",
|
||||||
|
"final_price": "最终制作费用为 12.75 欧元。",
|
||||||
|
"final_payment_methods": "接受现金或 Bizum 支付。您可以在我交付纸牌之前完成付款。",
|
||||||
|
"final_payment_deadline": "如果您能在 9 月 26 日之前付款就更好了。",
|
||||||
|
"final_tip": "如果您付的比这多,我会将其作为小费收下,并将非常感谢您让我攒钱买个三明治。",
|
||||||
|
"comments_prompt": "如果您还有其他疑问、投诉、意见等,请填写在这里:",
|
||||||
|
"comments_label": "备注",
|
||||||
|
"thanks": {
|
||||||
|
"title": "谢谢!",
|
||||||
|
"message": "您的 Hibolia 纸牌订单已成功提交。",
|
||||||
|
"next": "我们会尽快查看,并通过您留下的邮箱与您联系。",
|
||||||
|
"back_home": "返回首页"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": "(C) 2026 Aran Roig. 版权所有,随便用。",
|
||||||
|
"admin": {
|
||||||
|
"login": {
|
||||||
|
"title": "管理员访问",
|
||||||
|
"prompt": "受限区域。需要管理员身份验证。",
|
||||||
|
"username_label": "用户名",
|
||||||
|
"password_label": "密码",
|
||||||
|
"submit": "验证",
|
||||||
|
"submitting": "正在验证...",
|
||||||
|
"error_invalid": "访问被拒绝:凭据无效。",
|
||||||
|
"error_generic": "验证失败,请稍后再试。"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "管理面板",
|
||||||
|
"welcome": "当前会话用户",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"loading": "正在加载系统数据...",
|
||||||
|
"load_error": "无法加载面板数据。",
|
||||||
|
"section_status": "系统状态",
|
||||||
|
"status": "状态",
|
||||||
|
"mongo": "数据库",
|
||||||
|
"uptime": "运行时间",
|
||||||
|
"memory_rss": "内存 (rss)",
|
||||||
|
"memory_heap": "已用堆内存",
|
||||||
|
"total_cells": "已绘制像素",
|
||||||
|
"section_orders": "hibolia 订单",
|
||||||
|
"orders_empty": "暂无订单。",
|
||||||
|
"col_date": "日期",
|
||||||
|
"col_name": "姓名",
|
||||||
|
"col_email": "邮箱",
|
||||||
|
"col_message": "留言",
|
||||||
|
"col_color": "颜色",
|
||||||
|
"col_shipping": "配送"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
567
frontend/package-lock.json
generated
567
frontend/package-lock.json
generated
@@ -14,12 +14,14 @@
|
|||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
"three": "^0.186.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^3.15.4",
|
"@nuxt/test-utils": "^3.15.4",
|
||||||
"@testing-library/vue": "^8.1.0",
|
"@testing-library/vue": "^8.1.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"happy-dom": "^20.10.2",
|
"happy-dom": "^20.10.2",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
@@ -674,6 +676,13 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@dxup/nuxt": {
|
"node_modules/@dxup/nuxt": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.4.0.tgz",
|
||||||
@@ -2763,23 +2772,6 @@
|
|||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/test-utils/node_modules/crossws": {
|
|
||||||
"version": "0.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.6.tgz",
|
|
||||||
"integrity": "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
|
||||||
"srvx": ">=0.11.5"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"srvx": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/test-utils/node_modules/estree-walker": {
|
"node_modules/@nuxt/test-utils/node_modules/estree-walker": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
@@ -5250,6 +5242,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -5360,12 +5359,41 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.186.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
|
||||||
|
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.3",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/whatwg-mimetype": {
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
@@ -8538,6 +8566,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -11301,6 +11336,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
@@ -12945,448 +12987,6 @@
|
|||||||
"url": "https://github.com/sponsors/bobbiegoede"
|
"url": "https://github.com/sponsors/bobbiegoede"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nuxt/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/@nuxt/devtools": {
|
"node_modules/nuxt/node_modules/@nuxt/devtools": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/devtools/-/devtools-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/devtools/-/devtools-3.2.4.tgz",
|
||||||
@@ -13909,49 +13509,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz",
|
||||||
"integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="
|
"integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="
|
||||||
},
|
},
|
||||||
"node_modules/nuxt/node_modules/esbuild": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.27.7",
|
|
||||||
"@esbuild/android-arm": "0.27.7",
|
|
||||||
"@esbuild/android-arm64": "0.27.7",
|
|
||||||
"@esbuild/android-x64": "0.27.7",
|
|
||||||
"@esbuild/darwin-arm64": "0.27.7",
|
|
||||||
"@esbuild/darwin-x64": "0.27.7",
|
|
||||||
"@esbuild/freebsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/freebsd-x64": "0.27.7",
|
|
||||||
"@esbuild/linux-arm": "0.27.7",
|
|
||||||
"@esbuild/linux-arm64": "0.27.7",
|
|
||||||
"@esbuild/linux-ia32": "0.27.7",
|
|
||||||
"@esbuild/linux-loong64": "0.27.7",
|
|
||||||
"@esbuild/linux-mips64el": "0.27.7",
|
|
||||||
"@esbuild/linux-ppc64": "0.27.7",
|
|
||||||
"@esbuild/linux-riscv64": "0.27.7",
|
|
||||||
"@esbuild/linux-s390x": "0.27.7",
|
|
||||||
"@esbuild/linux-x64": "0.27.7",
|
|
||||||
"@esbuild/netbsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/netbsd-x64": "0.27.7",
|
|
||||||
"@esbuild/openbsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/openbsd-x64": "0.27.7",
|
|
||||||
"@esbuild/openharmony-arm64": "0.27.7",
|
|
||||||
"@esbuild/sunos-x64": "0.27.7",
|
|
||||||
"@esbuild/win32-arm64": "0.27.7",
|
|
||||||
"@esbuild/win32-ia32": "0.27.7",
|
|
||||||
"@esbuild/win32-x64": "0.27.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nuxt/node_modules/escape-string-regexp": {
|
"node_modules/nuxt/node_modules/escape-string-regexp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||||
@@ -17058,6 +16615,12 @@
|
|||||||
"b4a": "^1.6.4"
|
"b4a": "^1.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.186.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz",
|
||||||
|
"integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
|||||||
@@ -19,12 +19,14 @@
|
|||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
"three": "^0.186.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^3.15.4",
|
"@nuxt/test-utils": "^3.15.4",
|
||||||
"@testing-library/vue": "^8.1.0",
|
"@testing-library/vue": "^8.1.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"happy-dom": "^20.10.2",
|
"happy-dom": "^20.10.2",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|||||||
39
package-lock.json
generated
39
package-lock.json
generated
@@ -783,9 +783,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -800,9 +797,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -817,9 +811,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -834,9 +825,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -851,9 +839,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -868,9 +853,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -885,9 +867,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -902,9 +881,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -919,9 +895,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -936,9 +909,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -953,9 +923,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -970,9 +937,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -987,9 +951,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
Reference in New Issue
Block a user