@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
npm-debug.log*
|
||||
@@ -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` (6–72 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
|
||||
Generated
+1348
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -0,0 +1,4 @@
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1><%= title %></h1>
|
||||
<% if (typeof subtitle !== 'undefined' && subtitle) { %>
|
||||
<p class="subtitle"><%= subtitle %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
@@ -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>
|
||||
<% } %>
|
||||
@@ -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>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
Reference in New Issue
Block a user