diff --git a/backend/.env.production b/backend/.env.production index e749afb..1c1b538 100644 --- a/backend/.env.production +++ b/backend/.env.production @@ -1,3 +1,9 @@ PORT=5000 DB_URI=mongodb://192.168.1.7:27017/ 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= diff --git a/backend/package-lock.json b/backend/package-lock.json index 59c23aa..bcc01ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^9.3.0", "nodemon": "^3.1.14", "socket.io": "^4.8.3" @@ -118,6 +120,15 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -187,6 +198,12 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -357,6 +374,15 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -806,6 +832,49 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", @@ -815,6 +884,48 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1208,6 +1319,26 @@ "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 411a23b..ced4947 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,9 +10,11 @@ "dev": "nodemon src/index.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^9.3.0", "nodemon": "^3.1.14", "socket.io": "^4.8.3" diff --git a/backend/src/index.js b/backend/src/index.js index 8e130b9..b9f1966 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -25,12 +25,16 @@ const io = new Server(httpServer, { }); const connectDB = require("./db"); +const seedAdmin = require("./seedAdmin"); const gridCellsRouter = require("./routes/gridCells"); +const authRouter = require("./routes/auth"); +const adminRouter = require("./routes/admin"); +const hiboliaRouter = require("./routes/hibolia"); gridCellsRouter.setIO(io); // connect database -connectDB(); +connectDB().then(seedAdmin); app.use( cors({ @@ -56,6 +60,9 @@ app.get("/api/test", (req, res) => { }); 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) => { const mem = process.memoryUsage(); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..4cb4f68 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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 }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..c47e3a8 --- /dev/null +++ b/backend/src/routes/admin.js @@ -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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..b5f70da --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/hibolia.js b/backend/src/routes/hibolia.js new file mode 100644 index 0000000..fbd5ab0 --- /dev/null +++ b/backend/src/routes/hibolia.js @@ -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; diff --git a/backend/src/schemas/AdminUser.js b/backend/src/schemas/AdminUser.js new file mode 100644 index 0000000..85804b8 --- /dev/null +++ b/backend/src/schemas/AdminUser.js @@ -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; diff --git a/backend/src/schemas/HiboliaOrder.js b/backend/src/schemas/HiboliaOrder.js new file mode 100644 index 0000000..9eef8bb --- /dev/null +++ b/backend/src/schemas/HiboliaOrder.js @@ -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; diff --git a/backend/src/seedAdmin.js b/backend/src/seedAdmin.js new file mode 100644 index 0000000..f9f5178 --- /dev/null +++ b/backend/src/seedAdmin.js @@ -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; diff --git a/frontend/app/app.vue b/frontend/app/app.vue index fa56e7c..0dd9628 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -4,9 +4,8 @@
-
- -
+ +
@@ -32,22 +31,12 @@ useHead({ onMounted(() => { setupTheme() }); + diff --git a/frontend/app/components/layouts/FullscreenLayout.vue b/frontend/app/components/layouts/FullscreenLayout.vue new file mode 100644 index 0000000..ce75669 --- /dev/null +++ b/frontend/app/components/layouts/FullscreenLayout.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/frontend/app/components/parts/HeaderLinks.vue b/frontend/app/components/parts/HeaderLinks.vue index 9bb2ca8..d08b50d 100644 --- a/frontend/app/components/parts/HeaderLinks.vue +++ b/frontend/app/components/parts/HeaderLinks.vue @@ -4,6 +4,7 @@ const route = useRoute() const scrollTargets: Record = { 'index': '#top', + 'projects': '#scroll-projects', 'blog': '#scroll-blog', 'contact': '#scroll-contact', 'art': '#scroll-art' @@ -32,6 +33,10 @@ function getToPath(targetKey: string): string { │ / + + │ + /{{ $t('header.links.projects') }} + │ /{{ $t('header.links.blog') }} diff --git a/frontend/app/components/parts/StickyHeader.vue b/frontend/app/components/parts/StickyHeader.vue index 72b1b09..ce07c59 100644 --- a/frontend/app/components/parts/StickyHeader.vue +++ b/frontend/app/components/parts/StickyHeader.vue @@ -5,11 +5,15 @@ import SiteOptions from './site_options/SiteOptions.vue'; import { ref, onMounted, onUnmounted } from 'vue'; 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() { - isVisible.value = window.scrollY > SCROLL_THRESHOLD; + isVisible.value = props.alwaysVisible || window.scrollY > SCROLL_THRESHOLD; } onMounted(() => window.addEventListener('scroll', onScroll, { passive: true })); diff --git a/frontend/app/components/widgets/HiboliaDeckPreview.vue b/frontend/app/components/widgets/HiboliaDeckPreview.vue new file mode 100644 index 0000000..361c21d --- /dev/null +++ b/frontend/app/components/widgets/HiboliaDeckPreview.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/frontend/app/components/widgets/HiboliaScene.vue b/frontend/app/components/widgets/HiboliaScene.vue new file mode 100644 index 0000000..dbaa924 --- /dev/null +++ b/frontend/app/components/widgets/HiboliaScene.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/frontend/app/composables/three-scene.ts b/frontend/app/composables/three-scene.ts new file mode 100644 index 0000000..0b67cc7 --- /dev/null +++ b/frontend/app/composables/three-scene.ts @@ -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) +} diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts index 4a98fc7..9a686ce 100644 --- a/frontend/app/composables/useApi.ts +++ b/frontend/app/composables/useApi.ts @@ -1,6 +1,16 @@ // composables/useApi.js 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 { return url.replace(/\/+$/, '') } @@ -12,7 +22,7 @@ export default function useApi() { // Generic GET request const get = async (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() } @@ -23,9 +33,31 @@ export default function useApi() { headers: { 'Content-Type': 'application/json' }, 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 { get, post } + // Authenticated requests (JWT stored in cookie by useAuth) + const authHeaders = (): Record => { + const token = useCookie(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 } } \ No newline at end of file diff --git a/frontend/app/composables/useAuth.ts b/frontend/app/composables/useAuth.ts new file mode 100644 index 0000000..5bbbd5b --- /dev/null +++ b/frontend/app/composables/useAuth.ts @@ -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(ADMIN_TOKEN_COOKIE, { + maxAge: TOKEN_TTL_SECONDS, + sameSite: 'lax', + path: '/', + default: () => null + }) + const user = useState('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 } +} diff --git a/frontend/app/middleware/admin-auth.ts b/frontend/app/middleware/admin-auth.ts new file mode 100644 index 0000000..d06ccfd --- /dev/null +++ b/frontend/app/middleware/admin-auth.ts @@ -0,0 +1,14 @@ +// Protects /admin pages: requires a stored admin token. +export default defineNuxtRouteMiddleware((to) => { + const token = useCookie('admin_token') + + const isLoginPage = to.path.endsWith('/admin/login') + + if (!token.value && !isLoginPage) { + return navigateTo('/admin/login') + } + + if (token.value && isLoginPage) { + return navigateTo('/admin') + } +}) diff --git a/frontend/app/pages/admin/index.vue b/frontend/app/pages/admin/index.vue new file mode 100644 index 0000000..1fdd8fb --- /dev/null +++ b/frontend/app/pages/admin/index.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/frontend/app/pages/admin/login.vue b/frontend/app/pages/admin/login.vue new file mode 100644 index 0000000..24f4354 --- /dev/null +++ b/frontend/app/pages/admin/login.vue @@ -0,0 +1,195 @@ + + + + + + + diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue index ed4f1c9..32ded21 100644 --- a/frontend/app/pages/index.vue +++ b/frontend/app/pages/index.vue @@ -165,118 +165,118 @@ const sectionTargets = { + + diff --git a/frontend/app/pages/projects/hibolia/thanks.vue b/frontend/app/pages/projects/hibolia/thanks.vue new file mode 100644 index 0000000..ef5652e --- /dev/null +++ b/frontend/app/pages/projects/hibolia/thanks.vue @@ -0,0 +1,121 @@ + + + + + + + diff --git a/frontend/app/pages/projects/index.vue b/frontend/app/pages/projects/index.vue new file mode 100644 index 0000000..0298448 --- /dev/null +++ b/frontend/app/pages/projects/index.vue @@ -0,0 +1,275 @@ + + + + + + + diff --git a/frontend/i18n/locales/ca.json b/frontend/i18n/locales/ca.json index 6bc2c21..f8abe53 100644 --- a/frontend/i18n/locales/ca.json +++ b/frontend/i18n/locales/ca.json @@ -23,6 +23,7 @@ }, "header": { "links": { + "projects": "projectes", "blog": "blog", "contact": "contacte", "art": "art" @@ -34,13 +35,13 @@ "projects_heading": "Projectes", "blog_heading": "Entrades de Blog", "art_heading": "Galeria d'Art", - "stats_heading": "Estad\u00edstiques i Info", - "stat_lines_of_code": "L\u00ednies de Codi Escrites", + "stats_heading": "Estadístiques i Info", + "stat_lines_of_code": "Línies de Codi Escrites", "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_years_programming": "Anys Programant", - "stat_deployments": "Desplegaments a Producci\u00f3", + "stat_deployments": "Desplegaments a Producció", "place_heading": "Pissarra de Píxels", "show_more": "mostrar més...", "show_less": "mostrar menys" @@ -59,5 +60,102 @@ } }, "prefooter": "Compilada des de la res at las 00:00:00", - "footer": "(C) 2026 Aran Roig. Tots els drets no sé que." -} \ No newline at end of file + "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" + } + } +} diff --git a/frontend/i18n/locales/en.json b/frontend/i18n/locales/en.json index 2445975..81f0824 100644 --- a/frontend/i18n/locales/en.json +++ b/frontend/i18n/locales/en.json @@ -23,6 +23,7 @@ }, "header": { "links": { + "projects": "projects", "blog": "blog", "contact": "contact", "art": "art" @@ -59,5 +60,102 @@ } }, "prefooter": "", - "footer": "(C) 2026 Aran Roig. All rights whatever." -} \ No newline at end of file + "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" + } + } +} diff --git a/frontend/i18n/locales/es.json b/frontend/i18n/locales/es.json index 91e70c3..655dde4 100644 --- a/frontend/i18n/locales/es.json +++ b/frontend/i18n/locales/es.json @@ -23,24 +23,25 @@ }, "header": { "links": { + "projects": "proyectos", "blog": "blog", "contact": "contacto", "art": "arte" } }, "pages": { - "intro_heading": "INTRODUCCI\u00d3N", + "intro_heading": "INTRODUCCIÓN", "contact_heading": "Contacto", "projects_heading": "Proyectos", "blog_heading": "Entradas de Blog", - "art_heading": "Galer\u00eda de Arte", - "stats_heading": "Estad\u00edsticas e Info", - "stat_lines_of_code": "L\u00edneas de C\u00f3digo Escritas", + "art_heading": "Galería de Arte", + "stats_heading": "Estadísticas e Info", + "stat_lines_of_code": "Líneas de Código Escritas", "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_years_programming": "A\u00f1os Programando", - "stat_deployments": "Desplegues a Producci\u00f3n", + "stat_years_programming": "Años Programando", + "stat_deployments": "Desplegues a Producción", "place_heading": "Pizarra de Píxeles", "show_more": "mostrar más...", "show_less": "mostrar menos" @@ -59,5 +60,102 @@ } }, "prefooter": "Compilada desde la nada a las 00:00:00", - "footer": "(C) 2026 Aran Roig. Todos los derechos no se que." -} \ No newline at end of file + "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" + } + } +} diff --git a/frontend/i18n/locales/zh.json b/frontend/i18n/locales/zh.json index 46e1f1a..5f4e510 100644 --- a/frontend/i18n/locales/zh.json +++ b/frontend/i18n/locales/zh.json @@ -23,6 +23,7 @@ }, "header": { "links": { + "projects": "项目", "blog": "博客", "contact": "联系", "art": "艺术" @@ -59,5 +60,102 @@ } }, "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": "配送" + } + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 30b5197..57e4b5d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,12 +14,14 @@ "nuxt": "^4.3.1", "sass": "^1.98.0", "socket.io-client": "^4.8.3", + "three": "^0.186.0", "vue": "^3.5.30", "vue-router": "^4.6.4" }, "devDependencies": { "@nuxt/test-utils": "^3.15.4", "@testing-library/vue": "^8.1.0", + "@types/three": "^0.186.0", "@vue/test-utils": "^2.4.11", "happy-dom": "^20.10.2", "jsdom": "^29.1.1", @@ -674,6 +676,13 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.4.0.tgz", @@ -2763,23 +2772,6 @@ "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": { "version": "3.0.3", "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": { "version": "0.10.1", "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", "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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": { "version": "3.0.2", "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -11301,6 +11336,13 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -12945,448 +12987,6 @@ "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": { "version": "3.2.4", "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", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -17058,6 +16615,12 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index b015b32..8de9c48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,12 +19,14 @@ "nuxt": "^4.3.1", "sass": "^1.98.0", "socket.io-client": "^4.8.3", + "three": "^0.186.0", "vue": "^3.5.30", "vue-router": "^4.6.4" }, "devDependencies": { "@nuxt/test-utils": "^3.15.4", "@testing-library/vue": "^8.1.0", + "@types/three": "^0.186.0", "@vue/test-utils": "^2.4.11", "happy-dom": "^20.10.2", "jsdom": "^29.1.1", diff --git a/package-lock.json b/package-lock.json index 98092bb..14f5ba3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -783,9 +783,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -800,9 +797,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +811,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -834,9 +825,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -851,9 +839,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -868,9 +853,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -885,9 +867,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -902,9 +881,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -919,9 +895,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -936,9 +909,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -953,9 +923,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -970,9 +937,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -987,9 +951,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [