Layout fixes (fluid main card, password field), no emoji in reports, empty modules page; Release 0.3.1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-01 16:30:12 +05:00
1 parent 81aacdf5bb
commit d01e401f3c
19 files changed
+100 -168

No files matched your search

Generated
+3 -3
View File
@@ -347,7 +347,7 @@ dependencies = [
[[package]]
name = "coa-core"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -375,7 +375,7 @@ dependencies = [
[[package]]
name = "coa-release"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"chrono",
"coa-core",
@@ -384,7 +384,7 @@ dependencies = [
[[package]]
name = "coa-server-manager"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"coa-core",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2021"
license = "AGPL-3.0-only"
+5
View File
@@ -20,6 +20,11 @@ no console, no config files, no database tools. Tauri 2 + Rust + React.
from the app.
* **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline,
despawn N to relieve the server, and choose how many log in at server start.
* **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a
drop-down next to PLAY; the file that was replaced is saved first.
* **Accounts.** See everyone who can log in, change passwords and access levels, rename an account.
* **Modules.** A page for the server's optional modules (switch on/off, GitHub, settings); it fills up as modules are added.
* **Report a problem.** A form that prepares a ready bug report for GitHub with your versions filled in.
* **Settings without files.** Bots and Server pages with plain-language options and presets, a console to the world
server, player list with account tools.
* **Play with friends.** Checks whether your server is reachable, opens firewall rules, finds your router (UPnP),
+50 -22
View File
@@ -36,8 +36,8 @@ pub fn catalog() -> Vec<Entry> {
serde_json::from_str::<Catalog>(CATALOG).map(|c| c.modules).unwrap_or_default()
}
fn entry(id: &str) -> Result<Entry> {
catalog().into_iter().find(|e| e.id == id).ok_or_else(|| Error::Invalid("That module is not known to the Manager.".into()))
fn entry_in(cat: &[Entry], id: &str) -> Result<Entry> {
cat.iter().find(|e| e.id == id).cloned().ok_or_else(|| Error::Invalid("That module is not known to the Manager.".into()))
}
fn dir(root: &Path) -> PathBuf {
@@ -64,8 +64,12 @@ pub struct ModuleView {
}
pub fn list(root: &Path) -> Vec<ModuleView> {
catalog()
.into_iter()
list_in(&catalog(), root)
}
fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
cat.iter()
.cloned()
.map(|e| {
let conf = dir(root).join(&e.conf);
let dist = dir(root).join(format!("{}.dist", e.conf));
@@ -97,7 +101,11 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> {
}
pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
let e = entry(id)?;
set_enabled_in(&catalog(), root, meta, id, on)
}
fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
let e = entry_in(cat, id)?;
let conf = active_file(root, &e)?;
let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?;
if file.get(&e.enable_key).map(truthy) == Some(on) {
@@ -121,7 +129,11 @@ pub struct Setting {
/// Every active setting of the module with its current value, its documented default and its description.
pub fn settings(root: &Path, id: &str) -> Result<Vec<Setting>> {
let e = entry(id)?;
settings_in(&catalog(), root, id)
}
fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result<Vec<Setting>> {
let e = entry_in(cat, id)?;
let conf = dir(root).join(&e.conf);
let dist = read(&dir(root).join(format!("{}.dist", e.conf)));
let active = read(&conf).or_else(|| dist.clone()).ok_or_else(|| Error::Invalid("This module is not part of this server.".into()))?;
@@ -138,7 +150,11 @@ pub fn settings(root: &Path, id: &str) -> Result<Vec<Setting>> {
/// Change values of settings the module already has. A value is one line of at most 500 characters.
pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
let e = entry(id)?;
save_settings_in(&catalog(), root, meta, id, changes)
}
fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
let e = entry_in(cat, id)?;
let conf = active_file(root, &e)?;
let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?;
let mut changed = Vec::new();
@@ -169,6 +185,18 @@ pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap<Stri
mod tests {
use super::*;
fn test_catalog() -> Vec<Entry> {
let one = |id: &str, name: &str, conf: &str, key: &str| Entry {
id: id.into(),
name: name.into(),
description: ["en", "ru", "de", "fr", "es"].iter().map(|l| (l.to_string(), format!("{name} ({l})"))).collect(),
repo: format!("https://github.com/Corfirean/example/{id}"),
conf: conf.into(),
enable_key: key.into(),
};
vec![one("war-games", "War Games", "war_games.conf", "WarGames.Enable"), one("spellbook", "Spellbook", "spellbook.conf", "Spellbook.Enable")]
}
fn server(d: &Path) -> (PathBuf, PathBuf) {
let (root, meta) = (d.join("srv"), d.join("meta"));
let m = dir(&root);
@@ -179,9 +207,9 @@ mod tests {
}
#[test]
fn the_bundled_catalog_is_complete() {
fn the_bundled_catalog_is_valid() {
let c = catalog();
assert!(c.len() >= 5);
// it may be empty (modules are added to it one by one); whatever is in it must be complete
for e in &c {
assert!(e.description.contains_key("en") && e.description.len() == 5, "{} has all five descriptions", e.id);
assert!(e.repo.starts_with("https://github.com/Corfirean/") && e.conf.ends_with(".conf") && e.enable_key.ends_with(".Enable"), "{}", e.id);
@@ -196,43 +224,43 @@ mod tests {
fn a_module_is_listed_as_installed_with_its_documented_default_until_a_file_exists() {
let d = tempfile::tempdir().unwrap();
let (root, _) = server(d.path());
let wg = list(&root).into_iter().find(|m| m.id == "war-games").unwrap();
let wg = list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "war-games").unwrap();
assert!(wg.installed && wg.enabled, "the .dist says 1");
assert!(!list(&root).into_iter().find(|m| m.id == "spellbook").unwrap().installed, "no configuration of that module on this server");
assert!(!list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "spellbook").unwrap().installed, "no configuration of that module on this server");
}
#[test]
fn switching_a_module_creates_its_file_saves_the_old_one_and_changes_only_that_line() {
let d = tempfile::tempdir().unwrap();
let (root, meta) = server(d.path());
set_enabled(&root, &meta, "war-games", false).unwrap();
set_enabled_in(&test_catalog(), &root, &meta, "war-games", false).unwrap();
let conf = fs::read_to_string(dir(&root).join("war_games.conf")).unwrap();
assert!(conf.contains("WarGames.Enable = 0") && conf.contains("WarGames.ChallengeSeconds = 60"), "{conf}");
assert!(!list(&root).into_iter().find(|m| m.id == "war-games").unwrap().enabled);
set_enabled(&root, &meta, "war-games", false).unwrap();
assert!(!list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "war-games").unwrap().enabled);
set_enabled_in(&test_catalog(), &root, &meta, "war-games", false).unwrap();
assert_eq!(fs::read_dir(meta.join("backups/modules")).unwrap().count(), 1, "no change, no backup");
set_enabled(&root, &meta, "war-games", true).unwrap();
set_enabled_in(&test_catalog(), &root, &meta, "war-games", true).unwrap();
assert!(fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("WarGames.Enable = 1"));
assert!(set_enabled(&root, &meta, "spellbook", true).is_err(), "not part of this server");
assert!(set_enabled(&root, &meta, "nonsense", true).is_err());
assert!(set_enabled_in(&test_catalog(), &root, &meta, "spellbook", true).is_err(), "not part of this server");
assert!(set_enabled_in(&test_catalog(), &root, &meta, "nonsense", true).is_err());
}
#[test]
fn settings_show_the_documentation_and_only_known_keys_with_one_line_values_can_be_saved() {
let d = tempfile::tempdir().unwrap();
let (root, meta) = server(d.path());
let s = settings(&root, "war-games").unwrap();
let s = settings_in(&test_catalog(), &root, "war-games").unwrap();
assert_eq!(s.len(), 2);
assert_eq!(s[1], Setting { key: "WarGames.ChallengeSeconds".into(), value: "60".into(), default: Some("60".into()), doc: "How long a challenge stays open.".into() });
assert_eq!(s[0].doc, "Turns War Games on. Second line.");
let ok = BTreeMap::from([("WarGames.ChallengeSeconds".to_string(), "90".to_string())]);
assert_eq!(save_settings(&root, &meta, "war-games", &ok).unwrap(), ["WarGames.ChallengeSeconds"]);
assert!(settings(&root, "war-games").unwrap()[1].value == "90");
assert!(save_settings(&root, &meta, "war-games", &ok).unwrap().is_empty(), "same value: nothing to do");
assert_eq!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &ok).unwrap(), ["WarGames.ChallengeSeconds"]);
assert!(settings_in(&test_catalog(), &root, "war-games").unwrap()[1].value == "90");
assert!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &ok).unwrap().is_empty(), "same value: nothing to do");
for bad in [("Other.Key", "1"), ("WarGames.ChallengeSeconds", ""), ("WarGames.ChallengeSeconds", "1\nInjected = 1")] {
let c = BTreeMap::from([(bad.0.to_string(), bad.1.to_string())]);
assert!(save_settings(&root, &meta, "war-games", &c).is_err(), "{bad:?}");
assert!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &c).is_err(), "{bad:?}");
}
assert!(!fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("Injected"));
}
+10 -2
View File
@@ -315,25 +315,33 @@ AC>";
}
#[test]
fn changes_a_password_and_an_access_level_with_the_expected_commands() {
fn changes_a_password_with_the_expected_command() {
let _lock = serial();
let (port, h) = fake_ra("The password was changed");
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
ra.set_account_password("Player1", "newpass9").unwrap();
drop(ra);
assert_eq!(h.join().unwrap()[2], "account set password Player1 newpass9 newpass9");
}
#[test]
fn changes_an_access_level_with_the_expected_command() {
let _lock = serial();
let (port, h) = fake_ra("You have changed security level of Player1 to 2.");
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
ra.set_account_access("Player1", 2).unwrap();
assert!(ra.set_account_access("Player1", 4).is_err(), "only 0 to 3");
drop(ra);
assert_eq!(h.join().unwrap()[2], "account set gmlevel Player1 2 -1");
}
#[test]
fn a_failed_account_command_is_reported_and_bad_names_never_reach_the_console() {
let _lock = serial();
let (port, _h) = fake_ra("Account not found.");
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found"));
assert!(ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent");
assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found"));
}
#[test]
+1 -128
View File
@@ -1,131 +1,4 @@
{
"schema": 1,
"modules": [
{
"id": "spellbook",
"name": "Spellbook",
"description": {
"en": "Restores the Books of Ascension as class trainers: right-click a book to see your class abilities by rank and train them for gold.",
"ru": "Возвращает Книги Вознесения как учителей класса: ПКМ по книге показывает умения вашего класса по рангам, их можно изучить за золото.",
"de": "Bringt die Bücher des Aufstiegs als Klassenlehrer zurück: Rechtsklick auf ein Buch zeigt die Fähigkeiten deiner Klasse nach Rang, die du für Gold erlernen kannst.",
"fr": "Rétablit les Livres de l’Ascension comme maîtres de classe : un clic droit sur un livre affiche les capacités de votre classe par rang, à apprendre contre de l’or.",
"es": "Restaura los Libros de Ascensión como instructores de clase: clic derecho en un libro muestra las habilidades de tu clase por rango para aprenderlas por oro."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-spellbook",
"conf": "spellbook.conf",
"enable_key": "Spellbook.Enable"
},
{
"id": "war-games",
"name": "War Games",
"description": {
"en": "Private arena matches between two groups, arranged by Teri Glozilk in Orgrimmar and Stormwind.",
"ru": "Частные матчи на арене между двумя группами: их устраивает Тери Глозилк в Оргриммаре и Штормграде.",
"de": "Private Arenakämpfe zwischen zwei Gruppen, vermittelt von Teri Glozilk in Orgrimmar und Sturmwind.",
"fr": "Matchs d’arène privés entre deux groupes, organisés par Teri Glozilk à Orgrimmar et à Hurlevent.",
"es": "Combates de arena privados entre dos grupos, organizados por Teri Glozilk en Orgrimmar y Ventormenta."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-war-games",
"conf": "war_games.conf",
"enable_key": "WarGames.Enable"
},
{
"id": "item-recovery",
"name": "Item Recovery",
"description": {
"en": "Answers the client's Item Recovery window from the vendor buyback slots: get back an item you just sold, for the price paid.",
"ru": "Отвечает окну «Восстановление предметов» клиента данными выкупа у торговца: можно вернуть недавно проданный предмет за ту же цену.",
"de": "Beantwortet das Fenster „Gegenstandswiederherstellung“ des Clients mit den Rückkauf-Plätzen des Händlers: kürzlich verkaufte Gegenstände zum gezahlten Preis zurückholen.",
"fr": "Répond à la fenêtre de récupération d’objets du client avec les emplacements de rachat du marchand : récupérez un objet vendu au prix payé.",
"es": "Responde a la ventana de recuperación de objetos del cliente con las ranuras de recompra del vendedor: recupera un objeto recién vendido por el precio pagado."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-item-recovery",
"conf": "item_recovery.conf",
"enable_key": "ItemRecovery.Enable"
},
{
"id": "hero-call-board",
"name": "Hero's Call Board",
"description": {
"en": "The Hero's Call Board (Stormwind) and the Warchief's Command Board (Orgrimmar): 47 daily quests in PvE, Profession and PvP categories.",
"ru": "Доска призыва героев (Штормград) и Доска приказов вождя (Оргриммар): 47 ежедневных заданий в категориях PvE, профессии и PvP.",
"de": "Das Heldenruf-Brett (Sturmwind) und das Kriegshäuptlings-Befehlsbrett (Orgrimmar): 47 tägliche Quests in den Kategorien PvE, Beruf und PvP.",
"fr": "Le tableau d’appel des héros (Hurlevent) et le tableau de commandement du chef de guerre (Orgrimmar) : 47 quêtes quotidiennes JcE, métiers et JcJ.",
"es": "El Tablón de Llamada del Héroe (Ventormenta) y el Tablón de Mando del Jefe de Guerra (Orgrimmar): 47 misiones diarias de JcE, profesión y JcJ."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-hero-call-board",
"conf": "hero_call_board.conf",
"enable_key": "HeroCallBoard.Enable"
},
{
"id": "path-to-ascension",
"name": "Path to Ascension",
"description": {
"en": "The tutorial book of the Ascension client: the server side of its tutorials, objectives and rewards.",
"ru": "Обучающая книга клиента Ascension: серверная часть её уроков, целей и наград.",
"de": "Das Tutorialbuch des Ascension-Clients: die Serverseite seiner Tutorials, Ziele und Belohnungen.",
"fr": "Le livre de tutoriels du client Ascension : la partie serveur de ses tutoriels, objectifs et récompenses.",
"es": "El libro de tutoriales del cliente Ascension: la parte del servidor de sus tutoriales, objetivos y recompensas."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-path-to-ascension",
"conf": "path_to_ascension.conf",
"enable_key": "PathToAscension.Enable"
},
{
"id": "craftsmans-codex",
"name": "Craftsman's Codex",
"description": {
"en": "Restores the Craftsman's Codex: a character may learn one more primary profession than the standard two, once per codex used.",
"ru": "Возвращает Кодекс мастера: персонаж может выучить ещё одну основную профессию сверх обычных двух, по разу за использованный кодекс.",
"de": "Bringt den Kodex des Handwerkers zurück: Ein Charakter kann einen weiteren Hauptberuf zusätzlich zu den üblichen zwei erlernen, einmal pro benutztem Kodex.",
"fr": "Rétablit le Codex de l’artisan : un personnage peut apprendre un métier principal de plus que les deux habituels, une fois par codex utilisé.",
"es": "Restaura el Códice del Artesano: un personaje puede aprender una profesión principal más que las dos habituales, una vez por códice usado."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-craftsmans-codex",
"conf": "craftsmans_codex.conf",
"enable_key": "CraftsmansCodex.Enable"
},
{
"id": "coa-prestige",
"name": "Prestige Mode",
"description": {
"en": "Chromie's Prestige Mode: at level 60 a character winds back to level 1 and levels again with an experience bonus and Prestige rewards.",
"ru": "Режим престижа Хроми: на 60 уровне персонаж откатывается на 1 уровень и снова растёт с бонусом к опыту и наградами за престиж.",
"de": "Chromies Prestigemodus: Auf Stufe 60 wird ein Charakter auf Stufe 1 zurückgesetzt und levelt erneut mit Erfahrungsbonus und Prestige-Belohnungen.",
"fr": "Le mode Prestige de Chromie : au niveau 60, un personnage revient au niveau 1 et remonte avec un bonus d’expérience et des récompenses de prestige.",
"es": "El modo Prestigio de Chromie: al nivel 60 un personaje vuelve al nivel 1 y sube de nuevo con bonificación de experiencia y recompensas de prestigio."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-coa-prestige",
"conf": "mod-coa-prestige.conf",
"enable_key": "CoAPrestige.Enable"
},
{
"id": "coa-challenges",
"name": "Challenges",
"description": {
"en": "The Conquest of Azeroth challenges / trials: lives, rules, objectives, completions and rewards behind the client's Challenge window.",
"ru": "Испытания Conquest of Azeroth: жизни, правила, цели, завершения и награды для окна испытаний клиента.",
"de": "Die Herausforderungen / Prüfungen von Conquest of Azeroth: Leben, Regeln, Ziele, Abschlüsse und Belohnungen hinter dem Herausforderungsfenster des Clients.",
"fr": "Les défis / épreuves de Conquest of Azeroth : vies, règles, objectifs, réussites et récompenses derrière la fenêtre de défis du client.",
"es": "Los desafíos / pruebas de Conquest of Azeroth: vidas, reglas, objetivos, finalizaciones y recompensas de la ventana de desafíos del cliente."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-coa-challenges",
"conf": "mod-coa-challenges.conf",
"enable_key": "CoAChallenges.Enable"
},
{
"id": "teleport-actionbar",
"name": "Teleport Action Bar",
"description": {
"en": "Makes CoA teleport tokens usable from the spellbook collection and from an action bar button, and keeps each stone on its own faction's side.",
"ru": "Делает телепорт-жетоны CoA доступными из коллекции книги заклинаний и с кнопки на панели действий и оставляет каждый камень на стороне своей фракции.",
"de": "Macht CoA-Teleportmarken aus der Zauberbuch-Sammlung und über eine Aktionsleisten-Schaltfläche nutzbar und hält jeden Stein auf der Seite seiner Fraktion.",
"fr": "Rend les jetons de téléportation CoA utilisables depuis la collection du grimoire et depuis un bouton de barre d’action, et garde chaque pierre du côté de sa faction.",
"es": "Hace que las fichas de teletransporte de CoA se puedan usar desde la colección del libro de hechizos y desde un botón de la barra de acción, y mantiene cada piedra del lado de su facción."
},
"repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-teleport-actionbar",
"conf": "mod_teleport_actionbar.conf",
"enable_key": "TeleportActionBar.Enable"
}
]
"modules": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "coa-server-manager",
"private": true,
"version": "0.3.0",
"version": "0.3.1",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CoA Server Manager",
"version": "0.3.0",
"version": "0.3.1",
"identifier": "com.coa.servermanager",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 cursor-pointer",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 cursor-pointer",
{
variants: {
variant: {
+1 -1
View File
@@ -12,7 +12,7 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, Omit<React.Input
const t = useT();
const [shown, setShown] = React.useState(false);
return (
<div className="relative inline-block">
<div className="relative block w-fit">
<input ref={ref} type={shown ? "text" : "password"} className={cn(className, "pr-11 [&::-ms-reveal]:hidden")} {...props} />
<button
type="button"
+1 -1
View File
@@ -53,7 +53,7 @@ export {};
if(cmd==="realmlist_activate"){(window as any).__rl.active=args.profileId;return [];}
if(cmd==="realmlist_save"){const w=window as any;const rl=w.__rl;if(args.profileId){const p=rl.profiles.find((x: any)=>x.id===args.profileId);p.name=args.name;p.data=args.data;return p;}if(rl.profiles.some((x: any)=>x.name.toLowerCase()===String(args.name).toLowerCase()))throw {human:{code:"unknown",title:"",message:"",actions:[]},technical:"A realmlist with that name already exists."};const p={id:String(args.name).toLowerCase().replace(/[^a-z0-9]+/g,"-"),name:args.name,data:(/\s/.test(args.data.trim())?args.data.trim():"set realmlist "+args.data.trim())+"\r\n"};rl.profiles.push(p);return p;}
if(cmd==="realmlist_delete"){const rl=(window as any).__rl;rl.profiles=rl.profiles.filter((x: any)=>x.id!==args.profileId);return null;}
if(cmd==="modules_list"){const w=window as any;w.__mods=w.__mods??[{id:"spellbook",name:"Spellbook",description:{en:"Restores the Books of Ascension as class trainers: right-click a book to see your class abilities by rank and train them for gold."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-spellbook",installed:true,enabled:true},{id:"war-games",name:"War Games",description:{en:"Private arena matches between two groups, arranged by Teri Glozilk in Orgrimmar and Stormwind."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-war-games",installed:true,enabled:false},{id:"coa-prestige",name:"Prestige Mode",description:{en:"Chromie's Prestige Mode."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-coa-prestige",installed:false,enabled:false}];return w.__mods;}
if(cmd==="modules_list") return [];
if(cmd==="module_set_enabled"){const m=(window as any).__mods.find((x: any)=>x.id===args.module);m.enabled=args.enabled;return null;}
if(cmd==="module_settings") return [{key:"WarGames.Enable",value:"1",default:"1",doc:"Turns War Games on."},{key:"WarGames.ChallengeSeconds",value:"60",default:"60",doc:"How long a challenge stays open."}];
if(cmd==="module_save_settings") return Object.keys(args.changes);
+3
View File
@@ -721,6 +721,9 @@ const de: Partial<Record<keyof typeof en, string>> = {
"mod.settings": "Einstellungen",
"mod.default": "Zurück zum Standard ({v})",
"mod.saved": "Gespeichert ({n} geändert).",
// Modules page: nothing yet
"mod.empty": "Hier ist noch nichts. Optionale Module erscheinen auf dieser Seite, sobald sie hinzugefügt werden.",
};
export default de;
+3
View File
@@ -721,6 +721,9 @@ const en = {
"mod.settings": "Settings",
"mod.default": "Back to the default ({v})",
"mod.saved": "Saved ({n} changed).",
// Modules page: nothing yet
"mod.empty": "There is nothing here yet. Optional modules will appear on this page as they are added.",
} as const;
export default en;
+3
View File
@@ -721,6 +721,9 @@ const es: Partial<Record<keyof typeof en, string>> = {
"mod.settings": "Ajustes",
"mod.default": "Volver al valor por defecto ({v})",
"mod.saved": "Guardado ({n} cambiado(s)).",
// Modules page: nothing yet
"mod.empty": "Aquí todavía no hay nada. Los módulos opcionales aparecerán en esta página a medida que se añadan.",
};
export default es;
+3
View File
@@ -721,6 +721,9 @@ const fr: Partial<Record<keyof typeof en, string>> = {
"mod.settings": "Réglages",
"mod.default": "Revenir à la valeur par défaut ({v})",
"mod.saved": "Enregistré ({n} modifié(s)).",
// Modules page: nothing yet
"mod.empty": "Il n’y a encore rien ici. Les modules optionnels apparaîtront sur cette page au fur et à mesure de leur ajout.",
};
export default fr;
+3
View File
@@ -721,6 +721,9 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"mod.settings": "Настройки",
"mod.default": "Вернуть по умолчанию ({v})",
"mod.saved": "Сохранено (изменено: {n}).",
// Modules page: nothing yet
"mod.empty": "Здесь пока пусто. Дополнительные модули появятся на этой странице по мере добавления.",
};
export default ru;
+2 -2
View File
@@ -12,12 +12,12 @@ export interface ReportInput {
}
export const NEW_ISSUE_URL = "https://github.com/Corfirean/coa-server-manager/issues/new";
const PRIVACY = "> ⚠️ Please remove passwords, tokens, private IP information or other sensitive data before posting logs.";
const PRIVACY = "> Please remove passwords, tokens, private IP information or other sensitive data before posting logs.";
export function reportBody(r: ReportInput): string {
const text = (s: string, fallback: string) => (s.trim() ? s.trim() : fallback);
return [
"## 🐛 Bug Report",
"## Bug Report",
"",
`**Manager version:** ${r.managerVersion || "unknown"} `,
`**Windows version:** ${r.windows || "unknown"} `,
+2 -1
View File
@@ -47,7 +47,8 @@ export function ModulesPage({ serverId }: { serverId: string }) {
{error && <p className="mt-3 text-sm text-bad" role="alert">{error.human.code === "unknown" ? error.technical : human(error.human).message}</p>}
<div className="mt-6 space-y-4">
{modules === null && <p className="text-muted">{t("overview.checking")}</p>}
{modules === null && !error && <p className="text-muted">{t("overview.checking")}</p>}
{modules?.length === 0 && <Card className="p-6 text-center text-muted">{t("mod.empty")}</Card>}
{modules?.map((m) => (
<Card key={m.id} className={cn("p-5", !m.installed && "opacity-60")}>
<div className="flex items-start justify-between gap-4">
+6 -4
View File
@@ -203,7 +203,7 @@ export function Overview({ server, onForget, onOpenUpdates }: { server: ServerSu
const tone = transitioning ? "text-warn" : running ? "text-ok" : anyUp ? "text-warn" : "text-muted";
return (
<div className="max-w-2xl">
<div className="w-full max-w-4xl">
<h1 className="text-2xl font-semibold">{server.name}</h1>
<p className="selectable mt-0.5 text-xs text-muted">{server.path}</p>
@@ -238,7 +238,7 @@ export function Overview({ server, onForget, onOpenUpdates }: { server: ServerSu
)}
{perf && <TickRate perf={perf} />}
<div className="mt-7 flex items-center gap-4">
<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-3">
{running ? (
<Button variant="secondary" size="xl" disabled={transitioning} onClick={() => run("stopping")} className="min-w-56">
{transitioning && <Loader2 className="h-5 w-5 animate-spin" aria-hidden />}
@@ -277,7 +277,8 @@ export function Overview({ server, onForget, onOpenUpdates }: { server: ServerSu
{t("btn.stopSmall")}
</Button>
)}
{client && <RealmlistMenu serverId={server.id} className="ml-auto" />}
<div className="ml-auto flex items-center gap-3">
{client && <RealmlistMenu serverId={server.id} />}
{client && clientStatus?.update_available ? (
<Button variant="primary" size="xl" disabled={playing} onClick={() => setDialog("update")} className="min-w-40">
{t("btn.updateClient")}
@@ -288,10 +289,11 @@ export function Overview({ server, onForget, onOpenUpdates }: { server: ServerSu
{running ? t("btn.play") : t("btn.startPlay")}
</Button>
) : client === null ? (
<Button variant="secondary" size="xl" onClick={() => setDialog("setup")} className="ml-auto min-w-40">
<Button variant="secondary" size="xl" onClick={() => setDialog("setup")} className="min-w-40">
{t("btn.setupClient")}
</Button>
) : null}
</div>
</div>
{anyUp && !running && !transitioning && (
<p className="mt-3 text-sm text-muted">{t("overview.partial")}</p>