feat(index): add character search screen
This commit is contained in:
parent
4d63acf21d
commit
46f3f24c4f
9 changed files with 449 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "azerothcore-armory",
|
"name": "azerothcore-armory",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export class Armory {
|
||||||
|
|
||||||
const indexController = new IndexController(this);
|
const indexController = new IndexController(this);
|
||||||
app.get("/", indexController.index.bind(indexController));
|
app.get("/", indexController.index.bind(indexController));
|
||||||
|
app.get("/search", indexController.search.bind(indexController));
|
||||||
|
|
||||||
const charsController = new CharacterController(this);
|
const charsController = new CharacterController(this);
|
||||||
await charsController.load();
|
await charsController.load();
|
||||||
|
|
|
||||||
235
src/armory/DataTablesSsp.ts
Normal file
235
src/armory/DataTablesSsp.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { Query } from "express-serve-static-core";
|
||||||
|
import { Connection, RowDataPacket } from "mysql2/promise";
|
||||||
|
|
||||||
|
export interface IResult {
|
||||||
|
recordsTotal: number;
|
||||||
|
recordsFiltered: number;
|
||||||
|
data: any[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IColumnSettings {
|
||||||
|
name: string;
|
||||||
|
collation?: string;
|
||||||
|
formatter?: (data: string | number | null, row: any) => string;
|
||||||
|
table?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IColumnJoin {
|
||||||
|
table1: string;
|
||||||
|
column1: string;
|
||||||
|
table2: string;
|
||||||
|
column2: string;
|
||||||
|
kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataTablesSsp {
|
||||||
|
public draw: number;
|
||||||
|
public joins: IColumnJoin[] = [];
|
||||||
|
public extraDataColumns: string[] = [];
|
||||||
|
|
||||||
|
private db: Connection;
|
||||||
|
private table: string;
|
||||||
|
private primaryKey: string;
|
||||||
|
private columnSettings: IColumnSettings[];
|
||||||
|
|
||||||
|
private start: number;
|
||||||
|
private length: number;
|
||||||
|
private _order: {
|
||||||
|
column: number,
|
||||||
|
dir: string,
|
||||||
|
}[];
|
||||||
|
private columns: {
|
||||||
|
data: number,
|
||||||
|
name: string,
|
||||||
|
searchable: boolean,
|
||||||
|
orderable: boolean,
|
||||||
|
search: {
|
||||||
|
value: string,
|
||||||
|
regex: boolean,
|
||||||
|
}
|
||||||
|
}[];
|
||||||
|
private search: {
|
||||||
|
value: string,
|
||||||
|
regex: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
private wheres: string[] = [];
|
||||||
|
private filterBindings: (string | number)[] = [];
|
||||||
|
private customBindings: (string | number)[] = [];
|
||||||
|
private filterWhereSql: string = "1";
|
||||||
|
private customWhereSql: string = "1";
|
||||||
|
private limitSql: string = "";
|
||||||
|
private orderSql: string = "";
|
||||||
|
private joinSql: string = "";
|
||||||
|
|
||||||
|
public constructor(query: Query, db: Connection, table: string, primaryKey: string, columnSettings: IColumnSettings[]) {
|
||||||
|
this.start = parseInt(query.start as string, 10);
|
||||||
|
this.length = parseInt(query.length as string, 10);
|
||||||
|
this.draw = parseInt(query.draw as string, 10);
|
||||||
|
this._order = (query.order as { column: string, dir: string }[])
|
||||||
|
.map(order => { return { column: parseInt(order.column, 10), dir: order.dir, }; });
|
||||||
|
this.columns = (query.columns as { data: string, name: string, searchable: string, orderable: string, search: any }[])
|
||||||
|
.map(column => {
|
||||||
|
return {
|
||||||
|
data: parseInt(column.data, 10),
|
||||||
|
name: column.name,
|
||||||
|
searchable: column.searchable === "true",
|
||||||
|
orderable: column.orderable === "true",
|
||||||
|
search: { value: column.search.value, regex: column.search.regex === "true" },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.search = {
|
||||||
|
value: (query.search as any).value as string,
|
||||||
|
regex: (query.search as any).regex === "true",
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db = db;
|
||||||
|
this.table = table;
|
||||||
|
this.primaryKey = primaryKey;
|
||||||
|
this.columnSettings = columnSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private limit() {
|
||||||
|
if (this.start !== undefined && this.length !== -1) {
|
||||||
|
this.limitSql = `LIMIT ${this.length} OFFSET ${this.start}`;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private order() {
|
||||||
|
if (this._order === undefined) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy = [];
|
||||||
|
for (const order of this._order) {
|
||||||
|
const requestColumn = this.columns[order.column];
|
||||||
|
if (!requestColumn.orderable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colSettings = this.columnSettings[requestColumn.data];
|
||||||
|
orderBy.push(`\`${colSettings.table || this.table}\`.\`${colSettings.name}\` ${order.dir}`);
|
||||||
|
}
|
||||||
|
orderBy.push(`\`${this.table}\`.\`${this.primaryKey}\``);
|
||||||
|
|
||||||
|
if (orderBy.length > 0) {
|
||||||
|
this.orderSql = "ORDER BY " + orderBy.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private join() {
|
||||||
|
for (const join of this.joins) {
|
||||||
|
this.joinSql += `${join.kind} JOIN \`${join.table2}\` ON \`${join.table2}\`.\`${join.column2}\` = \`${join.table1}\`.\`${join.column1}\`\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filter() {
|
||||||
|
if (this.search.value?.length > 0) {
|
||||||
|
const filterWheres = [];
|
||||||
|
this.filterBindings = [];
|
||||||
|
|
||||||
|
for (const col of this.columns) {
|
||||||
|
if (!col.searchable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colSettings = this.columnSettings[col.data];
|
||||||
|
const collate = colSettings.collation !== undefined ? `COLLATE ${colSettings.collation} ` : "";
|
||||||
|
filterWheres.push(`\`${colSettings.table || this.table}\`.\`${colSettings.name}\` ${collate}LIKE ?`);
|
||||||
|
this.filterBindings.push(`%${this.search.value}%`);
|
||||||
|
}
|
||||||
|
if (filterWheres.length > 0) {
|
||||||
|
this.filterWhereSql = "(" + filterWheres.map(w => `(${w})`).join(" OR ") + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.customWhereSql = this.wheres.map(w => `(${w})`).join(" AND ");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSql(): string {
|
||||||
|
const columns = [
|
||||||
|
...this.columnSettings.map(c => `\`${c.table || this.table}\`.\`${c.name}\``),
|
||||||
|
...this.extraDataColumns,
|
||||||
|
];
|
||||||
|
return `
|
||||||
|
SELECT ${columns.join(", ")}
|
||||||
|
FROM ${this.table}
|
||||||
|
${this.joinSql}
|
||||||
|
WHERE
|
||||||
|
${this.filterWhereSql} AND
|
||||||
|
${this.customWhereSql}
|
||||||
|
${this.orderSql}
|
||||||
|
${this.limitSql}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTotalCountSql(): string {
|
||||||
|
return `
|
||||||
|
SELECT COUNT(\`${this.table}\`.\`${this.primaryKey}\`) AS \`count\`
|
||||||
|
FROM ${this.table}
|
||||||
|
${this.joinSql}
|
||||||
|
WHERE ${this.customWhereSql}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFilteredCountSql(): string {
|
||||||
|
return `
|
||||||
|
SELECT COUNT(\`${this.table}\`.\`${this.primaryKey}\`) AS \`count\`
|
||||||
|
FROM ${this.table}
|
||||||
|
${this.joinSql}
|
||||||
|
WHERE
|
||||||
|
${this.filterWhereSql} AND
|
||||||
|
${this.customWhereSql}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async run(): Promise<IResult> {
|
||||||
|
this.limit()
|
||||||
|
.order()
|
||||||
|
.join()
|
||||||
|
.filter();
|
||||||
|
|
||||||
|
const bindings = [...this.filterBindings, ...this.customBindings];
|
||||||
|
|
||||||
|
let [rows, fields] = await this.db.query(this.buildTotalCountSql(), this.customBindings);
|
||||||
|
const recordsTotal = rows[0].count;
|
||||||
|
|
||||||
|
[rows, fields] = await this.db.query(this.buildFilteredCountSql(), bindings);
|
||||||
|
const recordsFiltered = rows[0].count;
|
||||||
|
|
||||||
|
[rows, fields] = await this.db.query({
|
||||||
|
sql: this.buildSql(),
|
||||||
|
rowsAsArray: true,
|
||||||
|
values: bindings,
|
||||||
|
});
|
||||||
|
rows = (rows as any[][]).map(row => {
|
||||||
|
for (let i = 0; i < this.columnSettings.length; ++i) {
|
||||||
|
const col = this.columnSettings[i];
|
||||||
|
if (col.formatter !== undefined) {
|
||||||
|
row[i] = col.formatter(row[i], row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordsTotal,
|
||||||
|
recordsFiltered,
|
||||||
|
data: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public where(condition: string, binding?: string | number) {
|
||||||
|
this.wheres.push(condition);
|
||||||
|
if (binding !== undefined) {
|
||||||
|
this.customBindings.push(binding);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,32 @@
|
||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
|
|
||||||
import { Armory } from "../Armory";
|
import { Armory } from "../Armory";
|
||||||
|
import { DataTablesSsp } from "../DataTablesSsp";
|
||||||
|
|
||||||
|
const raceFiles = {
|
||||||
|
1: "human",
|
||||||
|
2: "orc",
|
||||||
|
3: "dwarf",
|
||||||
|
4: "nightelf",
|
||||||
|
5: "scourge",
|
||||||
|
6: "tauren",
|
||||||
|
7: "gnome",
|
||||||
|
8: "troll",
|
||||||
|
10: "bloodelf",
|
||||||
|
11: "draenei",
|
||||||
|
};
|
||||||
|
const classFiles = {
|
||||||
|
1: "warrior",
|
||||||
|
2: "paladin",
|
||||||
|
3: "hunter",
|
||||||
|
4: "rogue",
|
||||||
|
5: "priest",
|
||||||
|
6: "deathknight",
|
||||||
|
7: "shaman",
|
||||||
|
8: "mage",
|
||||||
|
9: "warlock",
|
||||||
|
11: "druid",
|
||||||
|
};
|
||||||
|
|
||||||
export class IndexController {
|
export class IndexController {
|
||||||
private armory: Armory;
|
private armory: Armory;
|
||||||
|
|
@ -12,6 +38,51 @@ export class IndexController {
|
||||||
public async index(req: express.Request, res: express.Response): Promise<void> {
|
public async index(req: express.Request, res: express.Response): Promise<void> {
|
||||||
res.render("index.html", {
|
res.render("index.html", {
|
||||||
title: "Armory",
|
title: "Armory",
|
||||||
|
realms: this.armory.config.realms.map(r => r.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(req: express.Request, res: express.Response): Promise<void> {
|
||||||
|
const realmName = req.query.realm as string;
|
||||||
|
const realm = realmName === undefined ?
|
||||||
|
this.armory.config.realms[0] :
|
||||||
|
this.armory.config.realms.find(r => r.name === realmName);
|
||||||
|
if (realm === undefined) {
|
||||||
|
res.status(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = this.armory.getCharactersDb(realm.name);
|
||||||
|
let [rows, fields] = await db.query(`
|
||||||
|
SELECT CCSA.character_set_name FROM information_schema.\`TABLES\` T,
|
||||||
|
information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA
|
||||||
|
WHERE CCSA.collation_name = T.table_collation
|
||||||
|
AND T.table_schema = "${db.config.database}"
|
||||||
|
AND T.table_name = "characters"
|
||||||
|
`);
|
||||||
|
const charSet = rows[0].character_set_name;
|
||||||
|
|
||||||
|
const ssp = new DataTablesSsp(req.query, db, "characters", "guid", [
|
||||||
|
{ name: "name", collation: `${charSet}_general_ci` },
|
||||||
|
{ name: "name", table: "guild" },
|
||||||
|
{ name: "level" },
|
||||||
|
{ name: "race", formatter: (race, row) => `${raceFiles[race]}_${row[6] === 0 ? "male" : "female"}` },
|
||||||
|
{ name: "class", formatter: cls => classFiles[cls] },
|
||||||
|
{ name: "online", formatter: online => online === 1 },
|
||||||
|
]);
|
||||||
|
ssp.joins = [
|
||||||
|
{ table1: "characters", column1: "guid", table2: "guild_member", column2: "guid", kind: "LEFT" },
|
||||||
|
{ table1: "guild_member", column1: "guildid", table2: "guild", column2: "guildid", kind: "LEFT" },
|
||||||
|
];
|
||||||
|
ssp.extraDataColumns = ["`characters`.`gender`"];
|
||||||
|
|
||||||
|
const result = await ssp
|
||||||
|
.where("`deleteInfos_Account` IS NULL")
|
||||||
|
.run();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
draw: ssp.draw,
|
||||||
|
...result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@
|
||||||
const talentsData = JSON.parse(`{{{data}}}`);
|
const talentsData = JSON.parse(`{{{data}}}`);
|
||||||
|
|
||||||
for (let spec = 0; spec < 2; ++spec) {
|
for (let spec = 0; spec < 2; ++spec) {
|
||||||
|
const $spec = $(`#talents-spec-${spec}`);
|
||||||
|
|
||||||
let nbLearned = 0;
|
let nbLearned = 0;
|
||||||
for (const tree of talentsData.trees) {
|
for (const tree of talentsData.trees) {
|
||||||
const learned = {};
|
const learned = {};
|
||||||
|
|
@ -90,7 +92,7 @@
|
||||||
const $tree = $("#talent-tree-template")
|
const $tree = $("#talent-tree-template")
|
||||||
.clone(false)
|
.clone(false)
|
||||||
.removeAttr("id")
|
.removeAttr("id")
|
||||||
.prependTo($(`#talents-spec-${spec}`));
|
.appendTo($spec);
|
||||||
$tree.find(".header .icon").attr("src", `{{aowow}}/static/images/wow/icons/medium/${tree.icon}.jpg`);
|
$tree.find(".header .icon").attr("src", `{{aowow}}/static/images/wow/icons/medium/${tree.icon}.jpg`);
|
||||||
$tree.find(".header .name").text(tree.name);
|
$tree.find(".header .name").text(tree.name);
|
||||||
|
|
||||||
|
|
@ -98,18 +100,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nbLearned === 0) {
|
if (nbLearned === 0) {
|
||||||
$(`#talents-spec-${spec}`).hide();
|
$spec.hide();
|
||||||
$("#spec-links").hide();
|
$("#spec-links").hide();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $glyphs = $(`#talents-spec-${spec}`).find(".glyphs");
|
const $glyphs = $spec.find(".glyphs");
|
||||||
for (const spell of talentsData.glyphs[spec]) {
|
for (const spell of talentsData.glyphs[spec]) {
|
||||||
const $a = $("<a>");
|
const $a = $("<a>");
|
||||||
$a.attr("href", `{{aowow}}?spell=${spell}`);
|
$a.attr("href", `{{aowow}}?spell=${spell}`);
|
||||||
$glyphs.append($a);
|
$glyphs.append($a);
|
||||||
$glyphs.append("<br>");
|
$glyphs.append("<br>");
|
||||||
}
|
}
|
||||||
|
$glyphs.parent().appendTo($spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTree(data, learnedTalents, container) {
|
function createTree(data, learnedTalents, container) {
|
||||||
|
|
|
||||||
45
static/css/datatables.css
Normal file
45
static/css/datatables.css
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
.dataTables_wrapper .dataTables_info,
|
||||||
|
.dataTables_wrapper .dataTables_filter,
|
||||||
|
.dataTables_wrapper .dataTables_length {
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dataTables_filter input,
|
||||||
|
.dataTables_wrapper .dataTables_length select {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dataTables_wrapper .dataTables_length select option {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable tbody tr {
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable.stripe tbody tr.odd,
|
||||||
|
table.dataTable.display tbody tr.odd {
|
||||||
|
background-color: #313131;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable.hover tbody tr:hover,
|
||||||
|
table.dataTable.display tbody tr:hover {
|
||||||
|
background-color: #3f3f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled,
|
||||||
|
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover,
|
||||||
|
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active {
|
||||||
|
color: #a3a3a3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dataTables_paginate .paginate_button,
|
||||||
|
.dataTables_wrapper .dataTables_paginate {
|
||||||
|
color: #e6e6e6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable.row-border tbody th,
|
||||||
|
table.dataTable.row-border tbody td,
|
||||||
|
table.dataTable.display tbody th,
|
||||||
|
table.dataTable.display tbody td {
|
||||||
|
border-top: 1px solid #222222;
|
||||||
|
}
|
||||||
11
static/css/index.css
Normal file
11
static/css/index.css
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#select-realm {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
@ -1 +1,72 @@
|
||||||
Armory
|
<link rel="stylesheet" href="/css/index.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.3/css/jquery.dataTables.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/datatables.css">
|
||||||
|
<script type="application/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||||
|
|
||||||
|
<h1>Armory</h1>
|
||||||
|
|
||||||
|
{{#if (not (equalsLength realms 1))}}
|
||||||
|
<select id="select-realm">
|
||||||
|
{{#each realms}}
|
||||||
|
<option>{{this}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<table id="results" class="stripe hover row-border">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Character name</th>
|
||||||
|
<th>Guild</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Race</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Online</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script type="application/javascript">
|
||||||
|
let dt;
|
||||||
|
|
||||||
|
function initDataTables() {
|
||||||
|
dt = $("#results").DataTable({
|
||||||
|
processing: true,
|
||||||
|
serverSide: true,
|
||||||
|
ajax: {
|
||||||
|
url: `/search`,
|
||||||
|
data: d => {
|
||||||
|
d.realm = $("#select-realm").val();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnDefs: [
|
||||||
|
{
|
||||||
|
targets: 0,
|
||||||
|
render: name => `<a href="/character/${$("#select-realm").val()}/${name}">${name}</a>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
searchable: false,
|
||||||
|
targets: 3,
|
||||||
|
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/race_${data}.jpg">`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
searchable: false,
|
||||||
|
targets: 4,
|
||||||
|
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/class_${data}.jpg">`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
searchable: false,
|
||||||
|
targets: 5,
|
||||||
|
render: online => online ? "🟢" : "🔴",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#select-realm").on("change", () => {
|
||||||
|
dt.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
initDataTables();
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,12 @@
|
||||||
<div>
|
<div>
|
||||||
<span class="char-realm">{{realm}}</span>
|
<span class="char-realm">{{realm}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
{{#if online}}
|
||||||
|
Online 🟢
|
||||||
|
{{else}}
|
||||||
|
Offline 🔴
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue