mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 20:11:02 +00:00
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:
1 parent
81aacdf5bb
commit
d01e401f3c
19 files changed
+100
-168
No files matched your search
Generated
+3
-3
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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"} `,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user