webapp
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-06 21:59:51 +02:00
parent 02d2af0993
commit 48a1b900c7
279 changed files with 7488 additions and 238 deletions
+5
View File
@@ -0,0 +1,5 @@
PORT=3000
# Chemin vers la base SQLite du plugin MMORPG (relatif à Webapp/ ou absolu)
DB_PATH=../mmorpg.db
# Secret pour les sessions de connexion (changez en production)
SESSION_SECRET=change-me-in-production
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
npm-debug.log*
+59
View File
@@ -0,0 +1,59 @@
# Webapp Disklexar
Tableau de bord Node.js pour consulter les données du serveur MMORPG Hytale (base SQLite `mmorpg.db`).
## Pages
| Route | Description |
|-------|-------------|
| `/` | Dashboard — joueurs connectés, statistiques globales |
| `/players` | Liste de tous les joueurs (clic → profil) |
| `/players/:uuid` | Profil joueur (données actuelles + extensions à venir) |
| `/market` | Marché — à implémenter |
| `/profile` | **Mon profil** — connexion pseudo / mot de passe, stats personnelles |
## Prérequis
- **Node.js 20+**
- Base **`mmorpg.db`** à la racine du monorepo (générée par le plugin Hytale)
## Installation
```bash
cd Webapp
npm install
cp .env.example .env
```
## Lancement
```bash
npm start
# ou en mode watch :
npm run dev
```
Site accessible sur [http://localhost:3000](http://localhost:3000).
## Configuration
| Variable | Défaut | Description |
|----------|--------|-------------|
| `PORT` | `3000` | Port HTTP |
| `DB_PATH` | `../mmorpg.db` | Chemin vers la base SQLite (relatif à `Webapp/` ou absolu) |
| `SESSION_SECRET` | `change-me-in-production` | Secret des cookies de session |
## Connexion joueur
1. Rejoindre le serveur Hytale (création automatique du profil)
2. En jeu : `/mmorpg password MonMotDePasse` (672 caractères)
3. Sur le site : **Mon profil** → pseudo + mot de passe
Pour modifier le mot de passe, réutilisez la commande en jeu.
## Stack
- [Express](https://expressjs.com/) — serveur HTTP
- [EJS](https://ejs.co/) — templates
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — lecture SQLite en lecture seule
+1348
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "disklexar-web",
"version": "0.1.0",
"description": "Tableau de bord web du serveur MMORPG Hytale Disklexar",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.21.2",
"express-session": "^1.18.1"
}
}
+520
View File
@@ -0,0 +1,520 @@
:root {
--bg: #0f1419;
--bg-elevated: #171d25;
--bg-panel: #1c2430;
--border: #2a3544;
--text: #e8edf4;
--text-muted: #8b9cb0;
--accent: #5eb3ff;
--accent-soft: rgba(94, 179, 255, 0.12);
--success: #4ade80;
--warning: #fbbf24;
--danger: #f87171;
--radius: 12px;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
--font: "Segoe UI", system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
background: linear-gradient(135deg, #0b1015 0%, #121820 50%, #0f1419 100%);
color: var(--text);
min-height: 100vh;
}
code {
font-family: ui-monospace, monospace;
font-size: 0.9em;
background: rgba(255, 255, 255, 0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--bg-elevated);
border-right: 1px solid var(--border);
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #3d7fd6);
display: grid;
place-items: center;
font-weight: 700;
font-size: 1.2rem;
}
.brand small {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
}
.nav {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.nav-link {
color: var(--text-muted);
text-decoration: none;
padding: 0.65rem 0.85rem;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover,
.nav-link.active {
background: var(--accent-soft);
color: var(--text);
}
.sidebar-footer {
margin-top: auto;
font-size: 0.85rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
}
.main {
padding: 2rem;
max-width: 1200px;
}
.page-header {
margin-bottom: 1.75rem;
}
.page-header h1 {
margin: 0 0 0.35rem;
font-size: 1.85rem;
}
.subtitle {
margin: 0;
color: var(--text-muted);
}
.alert {
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1.5rem;
border: 1px solid transparent;
}
.alert-warning {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(251, 191, 36, 0.25);
color: #fde68a;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.25rem;
box-shadow: var(--shadow);
}
.stat-label {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.75rem;
line-height: 1.1;
}
.stat-value.accent {
color: var(--success);
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.panel-header h2 {
margin: 0;
font-size: 1.1rem;
}
.badge {
background: var(--accent-soft);
color: var(--accent);
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.85rem;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 0.75rem 0.65rem;
border-bottom: 1px solid var(--border);
}
.table th {
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table-clickable tbody tr {
cursor: pointer;
transition: background 0.12s;
}
.table-clickable tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
.player-name {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
}
.muted {
color: var(--text-muted);
font-size: 0.82rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.status-dot.online {
background: var(--success);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.6);
}
.status-dot.offline {
background: #64748b;
}
.status-dot.inline {
width: 7px;
height: 7px;
}
.tag {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
}
.tag-online {
background: rgba(74, 222, 128, 0.15);
color: var(--success);
}
.tag-offline {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}
.link {
color: var(--accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.empty {
color: var(--text-muted);
margin: 0;
}
.profile-layout {
display: grid;
gap: 1.5rem;
}
.profile-head {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #334155, #1e293b);
display: grid;
place-items: center;
font-size: 1.5rem;
font-weight: 700;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin: 0;
}
.detail-grid div {
background: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 0.85rem;
}
.detail-grid dt {
margin: 0 0 0.25rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.detail-grid dd {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
.chip-list {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.chip-label {
color: var(--text-muted);
font-size: 0.85rem;
margin-right: 0.25rem;
}
.chip {
background: var(--accent-soft);
color: var(--accent);
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.85rem;
}
.coming-soon {
border-style: dashed;
}
.coming-soon-page,
.error-page {
text-align: center;
padding: 3rem 2rem;
}
.coming-soon-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.btn {
display: inline-block;
margin-top: 1.25rem;
background: var(--accent);
color: #0b1015;
text-decoration: none;
padding: 0.65rem 1.1rem;
border-radius: 8px;
font-weight: 600;
}
.btn:hover {
filter: brightness(1.08);
}
.back-link {
margin-top: -0.5rem;
}
.group-info {
margin-top: 1rem;
color: var(--text-muted);
}
.auth-panel {
max-width: 480px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.auth-form label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.auth-form input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.7rem 0.85rem;
font-size: 1rem;
}
.auth-form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.btn-block {
width: 100%;
margin-top: 0.25rem;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
text-decoration: none;
padding: 0.65rem 1.1rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.04);
}
.profile-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.auth-help h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.auth-help p {
margin: 0 0 0.75rem;
color: var(--text-muted);
}
.auth-help pre {
margin: 0 0 0.75rem;
padding: 0.85rem 1rem;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
overflow-x: auto;
}
.alert-error {
background: rgba(248, 113, 113, 0.1);
border-color: rgba(248, 113, 113, 0.25);
color: #fecaca;
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
}
.nav {
flex-direction: row;
flex-wrap: wrap;
}
.main {
padding: 1rem;
}
.table {
display: block;
overflow-x: auto;
}
}
+259
View File
@@ -0,0 +1,259 @@
require("dotenv").config();
const express = require("express");
const session = require("express-session");
const path = require("path");
const db = require("./src/db");
const auth = require("./src/auth");
const labels = require("./src/labels");
const fmt = require("./src/format");
const app = express();
const PORT = process.env.PORT || 3000;
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(express.static(path.join(__dirname, "public")));
app.use(express.urlencoded({ extended: false }));
app.use(
session({
secret: process.env.SESSION_SECRET || "change-me-in-production",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: "lax",
},
}),
);
app.use((req, res, next) => {
res.locals.nav = [
{ href: "/", label: "Dashboard", key: "dashboard" },
{ href: "/players", label: "Joueurs", key: "players" },
{ href: "/market", label: "Marché", key: "market" },
{ href: "/profile", label: "Mon profil", key: "profile" },
];
res.locals.fmt = fmt;
res.locals.auth = auth;
res.locals.dbStatus = db.getDbStatus();
res.locals.sessionUser = req.session.playerUuid
? {
uuid: req.session.playerUuid,
displayName: req.session.displayName,
}
: null;
next();
});
function enrichPlayer(player) {
if (!player) return null;
const classLabel = labels.labelClass(
player.class_id,
db.getReferenceLabel("classes", player.class_id),
);
const raceLabel = labels.labelRace(
player.race_id,
db.getReferenceLabel("races", player.race_id),
);
const jobs = labels.parseJsonArray(player.jobs).map(labels.labelJob);
const powers = labels.parseJsonArray(player.powers).map(labels.labelPower);
return {
...player,
classLabel,
raceLabel,
jobs,
powers,
isOnline: player.is_connected === 1,
};
}
function requireLogin(req, res, next) {
if (!req.session.playerUuid) {
return res.redirect("/profile");
}
next();
}
app.get("/", (req, res) => {
const stats = db.getDashboardStats();
const onlinePlayers = db.getOnlinePlayers().map(enrichPlayer);
const recentPlayers = db.getRecentPlayers().map(enrichPlayer);
res.render("index", {
page: "dashboard",
title: "Dashboard",
stats,
onlinePlayers,
recentPlayers,
});
});
app.get("/players", (req, res) => {
const players = db.getAllPlayers().map(enrichPlayer);
res.render("players/list", {
page: "players",
title: "Joueurs",
players,
});
});
app.get("/players/:uuid", (req, res) => {
const player = enrichPlayer(db.getPlayerByUuid(req.params.uuid));
if (!player) {
return res.status(404).render("error", {
page: "players",
title: "Joueur introuvable",
message: "Ce joueur n'existe pas dans la base de données.",
});
}
const group = db.getGroupForPlayer(player.uuid);
res.render("players/profile", {
page: "players",
title: player.display_name,
player,
group,
comingSoon: true,
isOwner: req.session.playerUuid === player.uuid,
});
});
app.get("/market", (req, res) => {
res.render("coming-soon", {
page: "market",
title: "Marché",
heading: "Marché du serveur",
description:
"La place de marché permettra bientôt d'échanger des objets et consulter les annonces entre joueurs.",
});
});
app.get("/profile", (req, res) => {
if (req.session.playerUuid) {
const player = enrichPlayer(db.getPlayerByUuid(req.session.playerUuid));
if (!player) {
req.session.destroy(() => {
res.redirect("/profile");
});
return;
}
const group = db.getGroupForPlayer(player.uuid);
return res.render("profile/account", {
page: "profile",
title: "Mon profil",
player,
group,
});
}
res.render("profile/login", {
page: "profile",
title: "Connexion",
error: null,
pseudo: "",
needsMigration: db.getDbStatus().exists && !db.hasPasswordColumn(),
});
});
app.post("/profile/login", async (req, res) => {
const pseudo = (req.body.pseudo || "").trim();
const password = req.body.password || "";
if (!pseudo || !password) {
return res.status(400).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Pseudo et mot de passe requis.",
pseudo,
needsMigration: db.getDbStatus().exists && !db.hasPasswordColumn(),
});
}
if (!db.getDbStatus().exists) {
return res.status(503).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Base de données indisponible.",
pseudo,
needsMigration: false,
});
}
if (!db.hasPasswordColumn()) {
return res.status(503).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Migration web manquante. Redémarrez le serveur Hytale pour mettre à jour la base.",
pseudo,
needsMigration: true,
});
}
const record = db.getPlayerAuthByDisplayName(pseudo);
if (!record) {
return res.status(401).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Pseudo introuvable. Connectez-vous d'abord au serveur Hytale pour créer votre profil.",
pseudo,
needsMigration: false,
});
}
if (!record.password_hash) {
return res.status(401).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Aucun mot de passe défini. Utilisez /mmorpg password <mot_de_passe> en jeu.",
pseudo,
needsMigration: false,
});
}
const valid = await auth.verifyPassword(password, record.password_hash);
if (!valid) {
return res.status(401).render("profile/login", {
page: "profile",
title: "Connexion",
error: "Mot de passe incorrect.",
pseudo,
needsMigration: false,
});
}
req.session.playerUuid = record.uuid;
req.session.displayName = record.display_name;
res.redirect("/profile");
});
app.post("/profile/logout", requireLogin, (req, res) => {
req.session.destroy(() => {
res.redirect("/profile");
});
});
app.use((req, res) => {
res.status(404).render("error", {
page: "",
title: "Page introuvable",
message: "La page demandée n'existe pas.",
});
});
app.listen(PORT, () => {
const status = db.getDbStatus();
console.log(`Orion.ovh Web → http://localhost:${PORT}`);
if (!status.exists) {
console.warn(`Base SQLite introuvable : ${status.path}`);
console.warn("Lancez le serveur Hytale pour créer mmorpg.db à la racine du monorepo.");
}
});
+26
View File
@@ -0,0 +1,26 @@
const bcrypt = require("bcrypt");
const MIN_PASSWORD_LENGTH = 6;
const MAX_PASSWORD_LENGTH = 72;
async function verifyPassword(plainPassword, passwordHash) {
if (!passwordHash) {
return false;
}
return bcrypt.compare(plainPassword, passwordHash);
}
function validatePasswordLength(password) {
if (!password || typeof password !== "string") {
return false;
}
const length = password.length;
return length >= MIN_PASSWORD_LENGTH && length <= MAX_PASSWORD_LENGTH;
}
module.exports = {
verifyPassword,
validatePasswordLength,
MIN_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
};
+189
View File
@@ -0,0 +1,189 @@
const Database = require("better-sqlite3");
const path = require("path");
const fs = require("fs");
const dbPath = path.resolve(
__dirname,
"..",
process.env.DB_PATH || "../mmorpg.db",
);
let db = null;
function getDb() {
if (!fs.existsSync(dbPath)) {
return null;
}
if (!db) {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
}
return db;
}
function getDbStatus() {
return {
path: dbPath,
exists: fs.existsSync(dbPath),
};
}
function queryAll(sql, params = []) {
const connection = getDb();
if (!connection) {
return [];
}
return connection.prepare(sql).all(...params);
}
function queryOne(sql, params = []) {
const connection = getDb();
if (!connection) {
return null;
}
return connection.prepare(sql).get(...params) ?? null;
}
function getDashboardStats() {
const connection = getDb();
if (!connection) {
return {
totalPlayers: 0,
onlinePlayers: 0,
totalMoney: 0,
averageLevel: 0,
totalGroups: 0,
};
}
const row = queryOne(`
SELECT
COUNT(*) AS totalPlayers,
COALESCE(SUM(CASE WHEN is_connected = 1 THEN 1 ELSE 0 END), 0) AS onlinePlayers,
COALESCE(SUM(money), 0) AS totalMoney,
COALESCE(AVG(level), 0) AS averageLevel
FROM player_profiles
`);
const groups = queryOne(`SELECT COUNT(*) AS count FROM groups`);
return {
totalPlayers: row.totalPlayers,
onlinePlayers: row.onlinePlayers,
totalMoney: row.totalMoney,
averageLevel: Math.round(row.averageLevel * 10) / 10,
totalGroups: groups?.count ?? 0,
};
}
function getOnlinePlayers() {
return queryAll(`
SELECT uuid, display_name, level, class_id, race_id, money, last_date_connected
FROM player_profiles
WHERE is_connected = 1
ORDER BY display_name COLLATE NOCASE ASC
`);
}
function getRecentPlayers(limit = 8) {
return queryAll(
`
SELECT uuid, display_name, level, is_connected, last_date_connected
FROM player_profiles
ORDER BY last_date_connected DESC
LIMIT ?
`,
[limit],
);
}
function getAllPlayers() {
return queryAll(`
SELECT
uuid,
display_name,
level,
experience,
class_id,
race_id,
money,
is_connected,
total_time_play,
last_date_connected,
date_creation
FROM player_profiles
ORDER BY display_name COLLATE NOCASE ASC
`);
}
function getPlayerByUuid(uuid) {
return queryOne(
`
SELECT
uuid, display_name, level, experience, class_id, powers, jobs,
guild_id, group_id, is_connected, total_time_play, last_date_connected,
date_creation, money, race_id, created_at, updated_at
FROM player_profiles
WHERE uuid = ?
`,
[uuid],
);
}
function getPlayerAuthByDisplayName(displayName) {
return queryOne(
`
SELECT uuid, display_name, password_hash
FROM player_profiles
WHERE display_name = ? COLLATE NOCASE
LIMIT 1
`,
[displayName.trim()],
);
}
function hasPasswordColumn() {
const connection = getDb();
if (!connection) {
return false;
}
const columns = connection
.prepare("PRAGMA table_info(player_profiles)")
.all();
return columns.some((column) => column.name === "password_hash");
}
function getGroupForPlayer(uuid) {
return queryOne(
`
SELECT g.id, g.owner_uuid, g.created_at
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.player_uuid = ?
`,
[uuid],
);
}
function getReferenceLabel(table, id) {
if (!id) {
return null;
}
const row = queryOne(
`SELECT display_name FROM ${table} WHERE id = ?`,
[id],
);
return row?.display_name ?? null;
}
module.exports = {
getDbStatus,
getDashboardStats,
getOnlinePlayers,
getRecentPlayers,
getAllPlayers,
getPlayerByUuid,
getPlayerAuthByDisplayName,
hasPasswordColumn,
getGroupForPlayer,
getReferenceLabel,
};
+36
View File
@@ -0,0 +1,36 @@
function formatTimestamp(ms) {
if (!ms || ms <= 0) {
return "—";
}
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(ms));
}
function formatDuration(seconds) {
if (!seconds || seconds <= 0) {
return "0 min";
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours === 0) {
return `${minutes} min`;
}
return `${hours} h ${minutes} min`;
}
function formatMoney(value) {
return new Intl.NumberFormat("fr-FR").format(value ?? 0);
}
function formatNumber(value) {
return new Intl.NumberFormat("fr-FR").format(value ?? 0);
}
module.exports = {
formatTimestamp,
formatDuration,
formatMoney,
formatNumber,
};
+58
View File
@@ -0,0 +1,58 @@
const CLASS_LABELS = {
knight: "Chevalier",
archer: "Archer",
mage: "Mage",
assassin: "Assassin",
};
const RACE_LABELS = {
human: "Humain",
};
const JOB_LABELS = {
monster_slayer: "Tueur de monstres",
miner: "Mineur",
};
const POWER_LABELS = {
sprinter: "Sprinteur",
resilient: "Résilient",
};
function labelClass(id, dbLabel) {
if (dbLabel) return dbLabel;
if (!id) return "Aucune";
return CLASS_LABELS[id] ?? id;
}
function labelRace(id, dbLabel) {
if (dbLabel) return dbLabel;
if (!id) return "Inconnue";
return RACE_LABELS[id] ?? id;
}
function labelJob(id) {
return JOB_LABELS[id] ?? id;
}
function labelPower(id) {
return POWER_LABELS[id] ?? id;
}
function parseJsonArray(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
module.exports = {
labelClass,
labelRace,
labelJob,
labelPower,
parseJsonArray,
};
+10
View File
@@ -0,0 +1,10 @@
<%- include('partials/page-start') %>
<section class="panel coming-soon-page">
<div class="coming-soon-icon">⏳</div>
<h1><%= heading %></h1>
<p><%= description %></p>
<a class="btn" href="/">Retour au dashboard</a>
</section>
<%- include('partials/page-end') %>
+9
View File
@@ -0,0 +1,9 @@
<%- include('partials/page-start') %>
<section class="panel error-page">
<h1><%= title %></h1>
<p><%= message %></p>
<a class="btn" href="/">Retour au dashboard</a>
</section>
<%- include('partials/page-end') %>
+106
View File
@@ -0,0 +1,106 @@
<%- include('partials/page-start') %>
<%- include('partials/page-header', { title, subtitle: 'Vue d\'ensemble du serveur en temps réel' }) %>
<section class="stats-grid">
<article class="stat-card">
<span class="stat-label">Joueurs en ligne</span>
<strong class="stat-value accent"><%= fmt.formatNumber(stats.onlinePlayers) %></strong>
</article>
<article class="stat-card">
<span class="stat-label">Joueurs inscrits</span>
<strong class="stat-value"><%= fmt.formatNumber(stats.totalPlayers) %></strong>
</article>
<article class="stat-card">
<span class="stat-label">Niveau moyen</span>
<strong class="stat-value"><%= stats.averageLevel %></strong>
</article>
<article class="stat-card">
<span class="stat-label">Money en circulation</span>
<strong class="stat-value"><%= fmt.formatMoney(stats.totalMoney) %></strong>
</article>
<article class="stat-card">
<span class="stat-label">Groupes actifs</span>
<strong class="stat-value"><%= fmt.formatNumber(stats.totalGroups) %></strong>
</article>
</section>
<section class="panel">
<div class="panel-header">
<h2>Joueurs connectés</h2>
<span class="badge"><%= onlinePlayers.length %></span>
</div>
<% if (onlinePlayers.length === 0) { %>
<p class="empty">Aucun joueur connecté pour le moment.</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>Joueur</th>
<th>Niveau</th>
<th>Classe</th>
<th>Race</th>
<th>Money</th>
<th></th>
</tr>
</thead>
<tbody>
<% onlinePlayers.forEach(function(p) { %>
<tr>
<td>
<span class="player-name">
<span class="status-dot online inline"></span>
<%= p.display_name %>
</span>
</td>
<td><%= p.level %></td>
<td><%= p.classLabel %></td>
<td><%= p.raceLabel %></td>
<td><%= fmt.formatMoney(p.money) %></td>
<td><a class="link" href="/players/<%= p.uuid %>">Profil</a></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
</section>
<section class="panel">
<div class="panel-header">
<h2>Dernières connexions</h2>
</div>
<% if (recentPlayers.length === 0) { %>
<p class="empty">Aucun joueur enregistré.</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>Joueur</th>
<th>Niveau</th>
<th>Statut</th>
<th>Dernière connexion</th>
<th></th>
</tr>
</thead>
<tbody>
<% recentPlayers.forEach(function(p) { %>
<tr>
<td><%= p.display_name %></td>
<td><%= p.level %></td>
<td>
<% if (p.isOnline) { %>
<span class="tag tag-online">En ligne</span>
<% } else { %>
<span class="tag tag-offline">Hors ligne</span>
<% } %>
</td>
<td><%= fmt.formatTimestamp(p.last_date_connected) %></td>
<td><a class="link" href="/players/<%= p.uuid %>">Profil</a></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
</section>
<%- include('partials/page-end') %>
+4
View File
@@ -0,0 +1,4 @@
</main>
</div>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<header class="page-header">
<div>
<h1><%= title %></h1>
<% if (typeof subtitle !== 'undefined' && subtitle) { %>
<p class="subtitle"><%= subtitle %></p>
<% } %>
</div>
</header>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> · Orion.ovh</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">O</span>
<div>
<strong>Orion.ovh</strong>
<small>Serveur MMORPG</small>
</div>
</div>
<nav class="nav">
<% nav.forEach(function(item) { %>
<a href="<%= item.href %>" class="nav-link<%= page === item.key ? ' active' : '' %>">
<%= item.label %>
</a>
<% }); %>
</nav>
<div class="sidebar-footer">
<% if (sessionUser) { %>
<span class="status-dot online"></span>
<span><%= sessionUser.displayName %></span>
<% } else if (dbStatus.exists) { %>
<span class="status-dot online"></span> Base connectée
<% } else { %>
<span class="status-dot offline"></span> Base indisponible
<% } %>
</div>
</aside>
<main class="main">
<% if (!dbStatus.exists) { %>
<div class="alert alert-warning">
Fichier <code>mmorpg.db</code> introuvable. Démarrez le serveur Hytale pour initialiser la base à la racine du monorepo.
</div>
<% } %>
+80
View File
@@ -0,0 +1,80 @@
<div class="profile-layout">
<section class="panel profile-card">
<div class="profile-head">
<div class="avatar"><%= player.display_name.charAt(0).toUpperCase() %></div>
<div>
<h2><%= player.display_name %></h2>
<p class="muted"><%= player.uuid %></p>
<% if (player.isOnline) { %>
<span class="tag tag-online">En ligne</span>
<% } else { %>
<span class="tag tag-offline">Hors ligne</span>
<% } %>
</div>
</div>
<dl class="detail-grid">
<div>
<dt>Niveau</dt>
<dd><%= player.level %></dd>
</div>
<div>
<dt>Expérience</dt>
<dd><%= fmt.formatNumber(player.experience) %></dd>
</div>
<div>
<dt>Classe</dt>
<dd><%= player.classLabel %></dd>
</div>
<div>
<dt>Race</dt>
<dd><%= player.raceLabel %></dd>
</div>
<div>
<dt>Money</dt>
<dd><%= fmt.formatMoney(player.money) %></dd>
</div>
<div>
<dt>Temps de jeu</dt>
<dd><%= fmt.formatDuration(player.total_time_play) %></dd>
</div>
<div>
<dt>Création du compte</dt>
<dd><%= fmt.formatTimestamp(player.date_creation || player.created_at) %></dd>
</div>
<div>
<dt>Dernière connexion</dt>
<dd><%= fmt.formatTimestamp(player.last_date_connected) %></dd>
</div>
</dl>
<% if (player.jobs.length > 0) { %>
<div class="chip-list">
<span class="chip-label">Métiers</span>
<% player.jobs.forEach(function(job) { %>
<span class="chip"><%= job %></span>
<% }); %>
</div>
<% } %>
<% if (player.powers.length > 0) { %>
<div class="chip-list">
<span class="chip-label">Pouvoirs</span>
<% player.powers.forEach(function(power) { %>
<span class="chip"><%= power %></span>
<% }); %>
</div>
<% } %>
<% if (group) { %>
<p class="group-info">Membre du groupe <code><%= group.id %></code></p>
<% } %>
</section>
<% if (typeof comingSoon !== 'undefined' && comingSoon) { %>
<section class="panel coming-soon">
<h2>Fonctionnalités à venir</h2>
<p>Inventaire, compétences détaillées, historique de combat et statistiques avancées seront disponibles prochainement sur cette page.</p>
</section>
<% } %>
</div>
+49
View File
@@ -0,0 +1,49 @@
<%- include('../partials/page-start') %>
<%- include('../partials/page-header', { title, subtitle: players.length + ' joueur(s) enregistré(s)' }) %>
<section class="panel">
<% if (players.length === 0) { %>
<p class="empty">Aucun joueur trouvé dans la base de données.</p>
<% } else { %>
<table class="table table-clickable">
<thead>
<tr>
<th>Joueur</th>
<th>Niveau</th>
<th>XP</th>
<th>Classe</th>
<th>Race</th>
<th>Money</th>
<th>Statut</th>
<th>Temps de jeu</th>
</tr>
</thead>
<tbody>
<% players.forEach(function(p) { %>
<tr class="row-link" onclick="window.location='/players/<%= p.uuid %>'">
<td>
<span class="player-name"><%= p.display_name %></span>
<small class="muted"><%= p.uuid %></small>
</td>
<td><%= p.level %></td>
<td><%= fmt.formatNumber(p.experience) %></td>
<td><%= p.classLabel %></td>
<td><%= p.raceLabel %></td>
<td><%= fmt.formatMoney(p.money) %></td>
<td>
<% if (p.isOnline) { %>
<span class="tag tag-online">En ligne</span>
<% } else { %>
<span class="tag tag-offline">Hors ligne</span>
<% } %>
</td>
<td><%= fmt.formatDuration(p.total_time_play) %></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
</section>
<%- include('../partials/page-end') %>
+9
View File
@@ -0,0 +1,9 @@
<%- include('../partials/page-start') %>
<%- include('../partials/page-header', { title: player.display_name, subtitle: 'Profil joueur' }) %>
<%- include('../partials/player-card', { player, group, comingSoon }) %>
<p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p>
<%- include('../partials/page-end') %>
+19
View File
@@ -0,0 +1,19 @@
<%- include('../partials/page-start') %>
<%- include('../partials/page-header', { title: 'Mon profil', subtitle: 'Connecté en tant que ' + player.display_name }) %>
<div class="profile-actions">
<form method="post" action="/profile/logout">
<button type="submit" class="btn btn-secondary">Se déconnecter</button>
</form>
<a class="link" href="/players/<%= player.uuid %>">Voir le profil public</a>
</div>
<%- include('../partials/player-card', { player, group, comingSoon: false }) %>
<section class="panel coming-soon">
<h2>À venir</h2>
<p>Inventaire, historique de combat et gestion avancée du compte seront ajoutés prochainement.</p>
</section>
<%- include('../partials/page-end') %>
+38
View File
@@ -0,0 +1,38 @@
<%- include('../partials/page-start') %>
<%- include('../partials/page-header', { title: 'Mon profil', subtitle: 'Connexion à Orion.ovh' }) %>
<section class="panel auth-panel">
<% if (needsMigration) { %>
<div class="alert alert-warning">
La colonne <code>password_hash</code> est absente. Redémarrez le serveur Hytale pour appliquer la migration.
</div>
<% } %>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form class="auth-form" method="post" action="/profile/login">
<label>
Pseudo
<input type="text" name="pseudo" value="<%= pseudo %>" autocomplete="username" required autofocus>
</label>
<label>
Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="btn btn-block">Se connecter</button>
</form>
<div class="auth-help">
<h2>Première connexion ?</h2>
<p>
Rejoignez le serveur Hytale, puis définissez votre mot de passe web en jeu :
</p>
<pre><code>/mmorpg password MonMotDePasse</code></pre>
<p class="muted">Minimum <%= auth.MIN_PASSWORD_LENGTH %> caractères. Vous pourrez le modifier à tout moment avec la même commande.</p>
</div>
</section>
<%- include('../partials/page-end') %>