diff --git a/Cargo.lock b/Cargo.lock index c7bd8f4..8500312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,6 +786,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-plugin-window-state", "tracing", ] @@ -4656,6 +4657,21 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-runtime" version = "2.12.0" diff --git a/crates/coa-core/examples/catalogue_categories.rs b/crates/coa-core/examples/catalogue_categories.rs new file mode 100644 index 0000000..7bf6327 --- /dev/null +++ b/crates/coa-core/examples/catalogue_categories.rs @@ -0,0 +1,23 @@ +//! How the module categories fall on a real catalogue: counts per category and a few modules of each. +//! usage: catalogue_categories +fn main() { + let path = std::env::args().nth(1).expect("usage: catalogue_categories "); + let raw: coa_core::catalogue::OfficialCatalogueRaw = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let overlay = coa_core::catalogue::load_coa_overlay(None); + let items = coa_core::catalogue::normalize_catalogue(&raw, &overlay); + let mut by: std::collections::BTreeMap> = Default::default(); + for i in &items { + let c = coa_core::catalogue::categorize(&i.full_name, &i.name, &i.description, &i.topics); + by.entry(c).or_default().push((i.stars, i.full_name.clone(), i.description.chars().take(70).collect())); + } + println!("modules: {}", items.len()); + for c in coa_core::catalogue::categories() { + let mut list = by.remove(&c.code).unwrap_or_default(); + list.sort_by(|a, b| b.0.cmp(&a.0)); + println!("\n== {} ({}) {}", c.code, c.ru, list.len()); + for (s, n, d) in list.iter().take(6) { + println!(" {s:>5} {n:<45} {d}"); + } + } +} diff --git a/crates/coa-core/src/catalogue.rs b/crates/coa-core/src/catalogue.rs index c10808a..5022932 100644 --- a/crates/coa-core/src/catalogue.rs +++ b/crates/coa-core/src/catalogue.rs @@ -170,6 +170,124 @@ pub struct PublicCatalogueItem { pub coa_status_source: String, // "Overlay", "VerifiedCatalog", "AutoPublic" pub conflict_reason: Option, pub is_coa_recommended: bool, + /// Functional category code (see `coa-module-categories.json`); "other" when nothing fits. + #[serde(default)] + pub category: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryInfo { + pub code: String, + pub ru: String, + pub en: String, + #[serde(default)] + pub keywords: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct CategoryDocument { + categories: Vec, + /// Owner-reviewed assignments (lowercase `owner/repo` -> category code). + repos: BTreeMap, +} + +static EMBEDDED_CATEGORIES_JSON: &str = include_str!("coa-module-categories.json"); + +fn category_document() -> &'static CategoryDocument { + static DOC: std::sync::OnceLock = std::sync::OnceLock::new(); + DOC.get_or_init(|| { + serde_json::from_str(EMBEDDED_CATEGORIES_JSON).unwrap_or(CategoryDocument { categories: Vec::new(), repos: BTreeMap::new() }) + }) +} + +pub fn categories() -> Vec { + category_document().categories.clone() +} + +/// Lowercase words of a text: letters and digits, so `mod-pvp-titles` gives `mod`, `pvp`, `titles`. +fn words(text: &str) -> Vec { + text.to_lowercase() + .split(|c: char| !c.is_alphanumeric() && c != '+') + .filter(|w| !w.is_empty()) + .map(str::to_string) + .collect() +} + +fn keyword_hits(keyword: &str, text_words: &[String]) -> usize { + let kw = words(keyword); + match kw.as_slice() { + [] => 0, + // one word: whole-word match; words of 4+ letters also match their plural or longer forms + [k] => text_words.iter().filter(|w| *w == k || (k.chars().count() >= 4 && w.starts_with(k.as_str()))).count(), + // a phrase: its words in a row + many => text_words.windows(many.len()).filter(|win| win.iter().zip(many).all(|(a, b)| a == b)).count(), + } +} + +/// The category of a module: the owner-reviewed list first, then the keywords of each category against the +/// repository's name (counted twice), description and topics; "other" when no keyword matches. +pub fn categorize(slug: &str, name: &str, description: &str, topics: &[String]) -> String { + let doc = category_document(); + if let Some(code) = doc.repos.get(&normalize_repo_slug(slug)) { + return code.clone(); + } + let name_words = words(name); + let mut body = words(description); + for t in topics.iter().filter(|t| !t.starts_with("azerothcore") && !matches!(t.as_str(), "wow" | "wotlk" | "world-of-warcraft")) { + body.extend(words(t)); + } + let mut best: Option<(&str, usize)> = None; + for c in &doc.categories { + let score: usize = c.keywords.iter().map(|k| 2 * keyword_hits(k, &name_words) + keyword_hits(k, &body)).sum(); + if score > 0 && best.is_none_or(|(_, s)| score > s) { + best = Some((&c.code, score)); + } + } + best.map(|(c, _)| c.to_string()).unwrap_or_else(|| "other".into()) +} + +/// Statuses and categories come from the current overlay and category list on every read, never from what a +/// cache stored: a corrected overlay must show at once. Entries an earlier version made up for overlay rules the +/// public catalogue does not contain (marked with the `coa-verified` topic) are dropped. +fn finalize(items: Vec, overlay: &CoaOverlayDocument) -> Vec { + let rules: BTreeMap = + overlay.rules.iter().map(|r| (normalize_repo_slug(&r.repo_slug), r)).collect(); + items + .into_iter() + .filter(|i| !i.topics.iter().any(|t| t == "coa-verified")) + .map(|mut i| { + let slug = normalize_repo_slug(&i.full_name); + match rules.get(&slug) { + Some(rule) => { + i.coa_status = rule.status; + i.coa_status_source = "Overlay".into(); + i.conflict_reason = rule.reason.clone(); + i.is_coa_recommended = rule.is_recommended || rule.status == CoaCompatibilityStatus::Recommended; + } + None => { + i.coa_status = CoaCompatibilityStatus::Unverified; + i.coa_status_source = "AutoPublic".into(); + i.conflict_reason = None; + i.is_coa_recommended = false; + } + } + i.category = categorize(&slug, &i.name, &i.description, &i.topics); + i + }) + .collect() +} + +fn respond(items: Vec, overlay: &CoaOverlayDocument, cached_at: String, is_offline: bool, etag: Option) -> PublicCatalogueResponse { + let items = finalize(items, overlay); + PublicCatalogueResponse { + total_count: items.len(), + recommended_count: items.iter().filter(|i| i.is_coa_recommended).count(), + cached_at, + is_offline, + etag, + items, + categories: categories().into_iter().map(|c| CategoryInfo { keywords: Vec::new(), ..c }).collect(), + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -191,6 +309,8 @@ pub struct PublicCatalogueResponse { pub etag: Option, pub total_count: usize, pub recommended_count: usize, + #[serde(default)] + pub categories: Vec, } // --------------------------------------------------------------------------- @@ -368,44 +488,14 @@ pub fn normalize_catalogue( coa_status_source: coa_source, conflict_reason: reason, is_coa_recommended: is_rec, + category: String::new(), }); } - // 4. Also ensure any Recommended overlay rules that were not present in the public raw catalog - // are included so they are never lost - for rule in &overlay.rules { - let slug = normalize_repo_slug(&rule.repo_slug); - if seen_slugs.insert(slug.clone()) { - let parts: Vec<&str> = slug.split('/').collect(); - let (owner, name) = if parts.len() >= 2 { - (parts[0].to_string(), parts[1].to_string()) - } else { - ("corfirean".to_string(), slug.clone()) - }; + // Overlay rules for repositories the public catalogue does not list are not turned into entries: the + // catalogue shows only what azerothcore.org lists (CoA's own modules are in the Manager's curated list). - normalized.push(PublicCatalogueItem { - id: rule.repo_id.unwrap_or(0), - name: name.clone(), - full_name: slug.clone(), - owner, - html_url: format!("https://github.com/{}", slug), - description: rule.reason.clone().unwrap_or_default(), - stars: 0, - forks: 0, - default_branch: "master".to_string(), - pushed_at: None, - updated_at: None, - license: Some("AGPL-3.0".to_string()), - topics: vec!["azerothcore-module".to_string(), "coa-verified".to_string()], - coa_status: rule.status, - coa_status_source: "Overlay".to_string(), - conflict_reason: rule.reason.clone(), - is_coa_recommended: rule.is_recommended || rule.status == CoaCompatibilityStatus::Recommended, - }); - } - } - - // 5. Sort: + // 4. Sort: // Group A (Recommended/Verified) first, then by Stars desc, then by pushed_at desc normalized.sort_by(|a, b| { // Priority 1: Recommended modules first @@ -461,15 +551,7 @@ pub fn fetch_or_load_catalogue( if let Ok(cached) = serde_json::from_str::(&content) { let age = now_ts - cached.timestamp; if age < DEFAULT_CACHE_TTL_SECS && !cached.items.is_empty() { - let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); - return Ok(PublicCatalogueResponse { - total_count: cached.items.len(), - recommended_count: rec_count, - cached_at: cached.cached_at, - is_offline: false, - etag: cached.etag, - items: cached.items, - }); + return Ok(respond(cached.items, &overlay, cached.cached_at, false, cached.etag)); } } } @@ -507,15 +589,7 @@ pub fn fetch_or_load_catalogue( cached.timestamp = now_ts; cached.cached_at = Utc::now().to_rfc3339(); let _ = fsx::atomic_write_json(&cache_file, &cached); - let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); - return Ok(PublicCatalogueResponse { - total_count: cached.items.len(), - recommended_count: rec_count, - cached_at: cached.cached_at, - is_offline: false, - etag: cached.etag, - items: cached.items, - }); + return Ok(respond(cached.items, &overlay, cached.cached_at, false, cached.etag)); } } } @@ -540,7 +614,6 @@ pub fn fetch_or_load_catalogue( })?; let items = normalize_catalogue(&raw, &overlay); - let rec_count = items.iter().filter(|i| i.is_coa_recommended).count(); let now_str = Utc::now().to_rfc3339(); let cached_doc = CachedDocument { @@ -557,14 +630,7 @@ pub fn fetch_or_load_catalogue( let _ = fs::write(&etag_file, tag); } - return Ok(PublicCatalogueResponse { - total_count: items.len(), - recommended_count: rec_count, - cached_at: now_str, - is_offline: false, - etag: new_etag, - items, - }); + return Ok(respond(items, &overlay, now_str, false, new_etag)); } // If non-200 and non-304 HTTP status, fallback to existing cache if available @@ -585,15 +651,7 @@ fn try_offline_fallback( if cache_file.is_file() { if let Ok(content) = fs::read_to_string(cache_file) { if let Ok(cached) = serde_json::from_str::(&content) { - let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); - return Ok(PublicCatalogueResponse { - total_count: cached.items.len(), - recommended_count: rec_count, - cached_at: cached.cached_at, - is_offline: true, - etag: cached.etag.or(stored_etag), - items: cached.items, - }); + return Ok(respond(cached.items, overlay, cached.cached_at, true, cached.etag.or(stored_etag))); } } } @@ -604,16 +662,7 @@ fn try_offline_fallback( organizations: BTreeMap::new(), }; let items = normalize_catalogue(&empty_raw, overlay); - let rec_count = items.iter().filter(|i| i.is_coa_recommended).count(); - - Ok(PublicCatalogueResponse { - total_count: items.len(), - recommended_count: rec_count, - cached_at: Utc::now().to_rfc3339(), - is_offline: true, - etag: None, - items, - }) + Ok(respond(items, overlay, Utc::now().to_rfc3339(), true, None)) } // --------------------------------------------------------------------------- @@ -808,6 +857,7 @@ mod tests { coa_status_source: "CacheTest".into(), conflict_reason: None, is_coa_recommended: false, + category: String::new(), } ]; @@ -833,6 +883,56 @@ mod tests { let overlay = load_coa_overlay(None); let fallback_resp = try_offline_fallback(&cache_file, None, &overlay).expect("fallback must succeed"); assert!(fallback_resp.is_offline); - assert!(!fallback_resp.items.is_empty(), "Fallback must supply overlay items even on corrupt cache"); + // nothing is made up from overlay rules when there is no catalogue to show + assert!(fallback_resp.items.is_empty()); + } + + fn item(full_name: &str, name: &str, description: &str, topics: &[&str]) -> PublicCatalogueItem { + PublicCatalogueItem { + id: 1, + name: name.into(), + full_name: full_name.into(), + owner: full_name.split('/').next().unwrap().into(), + html_url: format!("https://github.com/{full_name}"), + description: description.into(), + stars: 0, + forks: 0, + default_branch: "master".into(), + pushed_at: None, + updated_at: None, + license: None, + topics: topics.iter().map(|t| t.to_string()).collect(), + coa_status: CoaCompatibilityStatus::Verified, + coa_status_source: "Overlay".into(), + conflict_reason: Some("stale".into()), + is_coa_recommended: true, + category: String::new(), + } + } + + #[test] + fn statuses_come_from_the_current_overlay_not_the_cache_and_made_up_entries_are_dropped() { + let overlay = load_coa_overlay(None); + let cached = vec![ + item("azerothcore/mod-duel-reset", "mod-duel-reset", "Duel reset module", &["azerothcore-module"]), + item("azerothcore/mod-cfbg", "mod-cfbg", "Cross-faction battlegrounds", &["azerothcore-module", "coa-verified"]), + ]; + let out = finalize(cached, &overlay); + assert_eq!(out.len(), 1, "the made-up mod-cfbg entry is dropped"); + assert_eq!(out[0].coa_status, CoaCompatibilityStatus::Unverified); + assert!(out[0].conflict_reason.is_none() && !out[0].is_coa_recommended); + assert_eq!(out[0].category, "pvp"); + } + + #[test] + fn modules_get_a_category_from_the_reviewed_list_or_keywords() { + // reviewed list (the owner's file) + assert_eq!(categorize("DustinHendrickson/mod-ollama-chat", "mod-ollama-chat", "", &[]), "bots"); + // keywords: whole words, not parts of other words + assert_eq!(categorize("x/mod-arena-points", "mod-arena-points", "Gives arena points", &[]), "pvp"); + assert_eq!(categorize("x/mod-guild-chat", "mod-guild-chat", "", &[]), "social"); + assert_eq!(categorize("x/mod-maintain", "mod-maintain", "Maintains things", &[]), "other"); + assert_eq!(categorize("x/mod-nothing", "mod-nothing", "", &["azerothcore-module".to_string(), "wotlk".to_string()]), "other"); + assert!(categories().iter().any(|c| c.code == "other")); } } diff --git a/crates/coa-core/src/coa-module-categories.json b/crates/coa-core/src/coa-module-categories.json new file mode 100644 index 0000000..e032c86 --- /dev/null +++ b/crates/coa-core/src/coa-module-categories.json @@ -0,0 +1,473 @@ +{ + "version": 1, + "source": "azerothcore_catalogue_categorized_2026-11-07.xlsx (owner), modules with high or medium confidence", + "categories": [ + { + "code": "bots", + "ru": "Боты и искусственный интеллект", + "en": "Bots and AI", + "keywords": [ + "playerbots", + "bots", + "npcbot", + "llm", + "ollama", + "ai", + "автономные спутники" + ] + }, + { + "code": "pve", + "ru": "PvE, подземелья и рейды", + "en": "PvE, dungeons and raids", + "keywords": [ + "pve", + "dungeon", + "raid", + "boss", + "mythic+", + "scaling", + "rdf", + "инстансы" + ] + }, + { + "code": "pvp", + "ru": "PvP, арены и поля боя", + "en": "PvP, arenas and battlegrounds", + "keywords": [ + "pvp", + "arena", + "battleground", + "bg", + "duel", + "ffa", + "фракции" + ] + }, + { + "code": "hardcore", + "ru": "Hardcore и режимы сложности", + "en": "Hardcore and difficulty", + "keywords": [ + "hardcore", + "ironman", + "permadeath", + "high risk", + "difficulty" + ] + }, + { + "code": "progression", + "ru": "Прокачка и прогрессия", + "en": "Leveling and progression", + "keywords": [ + "xp", + "level", + "rates", + "leveling", + "rewards", + "prestige", + "progression", + "experience", + "reputation", + "rested" + ] + }, + { + "code": "classes", + "ru": "Классы, расы и способности", + "en": "Classes, races and abilities", + "keywords": [ + "race", + "class", + "talent", + "spells", + "specialization", + "pets" + ] + }, + { + "code": "cosmetics", + "ru": "Косметика и трансмог", + "en": "Cosmetics and transmog", + "keywords": [ + "transmog", + "appearance", + "morph", + "visual", + "vanity", + "cosmetics" + ] + }, + { + "code": "items", + "ru": "Лут, предметы и экипировка", + "en": "Loot, items and gear", + "keywords": [ + "loot", + "item", + "gear", + "enchant", + "reforging", + "bank", + "inventory", + "items", + "reagent" + ] + }, + { + "code": "economy", + "ru": "Торговля, экономика и профессии", + "en": "Trade, economy and professions", + "keywords": [ + "auction", + "market", + "vendor", + "trade", + "gold", + "shop", + "profession", + "ahbot", + "auction house", + "gather", + "herbs", + "mining", + "fishing", + "crafting" + ] + }, + { + "code": "account", + "ru": "Аккаунты и персонажи", + "en": "Accounts and characters", + "keywords": [ + "account", + "character", + "mounts", + "collection", + "migration", + "recruit", + "login", + "alts" + ] + }, + { + "code": "social", + "ru": "Гильдии, чат и социальные", + "en": "Guilds, chat and social", + "keywords": [ + "guild", + "chat", + "messages", + "announcements", + "friends", + "mail" + ] + }, + { + "code": "npc", + "ru": "NPC и игровые сервисы", + "en": "NPCs and services", + "keywords": [ + "npc", + "buffer", + "trainer", + "enchanter", + "services", + "assistant", + "vendor npc", + "gossip" + ] + }, + { + "code": "world", + "ru": "Мир, зоны и перемещение", + "en": "World, zones and travel", + "keywords": [ + "world", + "zone", + "spawn", + "teleport", + "portal", + "travel", + "respawn" + ] + }, + { + "code": "events", + "ru": "События и мини-игры", + "en": "Events and mini-games", + "keywords": [ + "events", + "holidays", + "quests", + "trivia", + "minigames", + "treasure" + ] + }, + { + "code": "server", + "ru": "Сервер, хостинг и автоматизация", + "en": "Server and automation", + "keywords": [ + "server", + "deployment", + "backup", + "cache", + "relay", + "restart", + "monitoring" + ] + }, + { + "code": "admin", + "ru": "Администрирование и безопасность", + "en": "Administration and security", + "keywords": [ + "gm", + "admin", + "moderation", + "logs", + "security", + "bans", + "abuse" + ] + }, + { + "code": "web", + "ru": "Сайты, API и интеграции", + "en": "Websites, API and integrations", + "keywords": [ + "api", + "cms", + "website", + "discord", + "telegram", + "web", + "metrics" + ] + }, + { + "code": "dev", + "ru": "Разработка, SQL и инструменты", + "en": "Development, SQL and tools", + "keywords": [ + "editor", + "db", + "sql", + "lua", + "eluna", + "ide", + "tooling", + "sdk" + ] + }, + { + "code": "client", + "ru": "Клиент, патчи и интерфейс", + "en": "Client, patches and UI", + "keywords": [ + "client", + "patch", + "addon", + "ui", + "launcher", + "controller", + "mpq" + ] + }, + { + "code": "other", + "ru": "Прочее", + "en": "Other", + "keywords": [ + + ] + } + ], + "repos": { + "liyunfan1223/mod-playerbots": "bots", + "dustinhendrickson/mod-ollama-chat": "bots", + "dustinhendrickson/mod-player-bot-level-brackets": "bots", + "dustinhendrickson/mod-ollama-bot-buddy": "bots", + "azerothcore/mod-autobalance": "pve", + "zhengpeiru21/mod-challenge-modes": "pve", + "anchydev/dungeonrespawn": "pve", + "azerothcore/mod-rdf-expansion": "pve", + "silviu20092/mod-mythic-plus": "pve", + "avarishd/mod-brawlers-guild": "pve", + "sogladev/mod-vanilla-naxxramas": "pve", + "pangolp/mod-cfpve": "pve", + "bewarellamas/mod-dungeonmaster": "pve", + "araxiaonline/mod-mythic-plus": "pve", + "sogladev/mod-reset-raid-cooldowns": "pve", + "hallgaeuer/mod-quickbalance": "pve", + "valsan-azerty-boi/mod-ony-naxx-logout-teleport": "pve", + "stealthyape/raidteleporter": "pve", + "zesman21/mod-nordf": "pve", + "helias/mod-bg-slaveryvalley": "pvp", + "azerothcore/mod-duel-reset": "pvp", + "gitdalisar/mod-faction-free": "pvp", + "azerothcore/mod-pvp-titles": "pvp", + "helias/mod-bg-twinpeaks": "pvp", + "helias/mod-bg-battle-for-gilneas": "pvp", + "talamortis/mod-pvpscript": "pvp", + "gozzim/mod-npc-spectator": "pvp", + "azerothcore/mod-bg-reward": "pvp", + "helias/mod-arena-tigerspeak": "pvp", + "helias/mod-arena-tolviron": "pvp", + "tbcstar/mod-high-risk-system": "pvp", + "azerothcore/mod-pvp-zones": "pvp", + "azerothcore/mod-bg-item-reward": "pvp", + "azerothcore/mod-desertion-warnings": "pvp", + "azerothcore/mod-low-level-rbg": "pvp", + "youpeoples/ultimate-full-loot-pvp": "pvp", + "azerothcore/mod-pvpstats-announcer": "pvp", + "anchydev/ffafix": "pvp", + "azerothcore/mod-low-level-arena": "pvp", + "trembon/mod-peacekeeper": "pvp", + "anchydev/bgqueuechecker": "pvp", + "anchydev/hardmode": "hardcore", + "kjack9/mod-dead-means-dead": "hardcore", + "conan1996-2/mod-hard-modes": "hardcore", + "jorge990125/mod-hardcore-makgora": "hardcore", + "stuntmonkey4u/mod-trial-of-finality": "hardcore", + "zindokar/mod-hardcore": "hardcore", + "zhengpeiru21/mod-individual-progression": "progression", + "azerothcore/mod-congrats-on-level": "progression", + "noisiver/mod-weekendbonus": "progression", + "youpeoples/prestige-and-draft-mode": "progression", + "anchydev/attriboost": "progression", + "azerothcore/mod-fireworks-on-level": "progression", + "anchydev/extendedxp": "progression", + "anchydev/prestigious": "progression", + "noisiver/mod-appreciation": "progression", + "betayi/mod_spec_reward": "progression", + "brian8544/setxpbar": "progression", + "nhobodyimportant/prestige": "progression", + "azerothcore/mod-alpha-rewards": "progression", + "michaeldelago/mod-quest-count-level": "progression", + "regrad/update-mob-level-to-player-and-random-item-stats": "progression", + "azerothcore/mod-chromie-xp": "progression", + "kadan-gx/mod-login-rewards": "progression", + "talamortis/mod-individual-xp": "progression", + "talamortis/reward-system": "progression", + "brian8544/mod-setxpbar": "progression", + "fischerlol/mod-level-15-boost": "progression", + "notepadguyofficial/mod-level-rewards": "progression", + "thearktect/mod-weekend-xp": "progression", + "heyitsbench/mod-arac": "classes", + "heyitsbench/mod-worgoblin": "classes", + "azerothcore/mod-npc-beastmaster": "classes", + "zhengpeiru21/mod-leech": "classes", + "azerothcore/mod-racial-trait-swap": "classes", + "noisiver/mod-learnspells": "classes", + "silviu20092/mod-warlock-pet-rename": "classes", + "vhiperdev/mod-spellregulator": "classes", + "xiii-hearts/mod-npc-subclass": "classes", + "gozzim/mod-starting-pet": "classes", + "bewarellamas/mod-profspecs": "classes", + "sogladev/mod-demonic-pact-classic": "classes", + "brian8544/mod-talentbutton": "classes", + "lenzork/mod-petequip": "classes", + "azerothcore/mod-transmog": "cosmetics", + "azerothcore/mod-weapon-visual": "cosmetics", + "azerothcore/mod-morphsummon": "cosmetics", + "azerothcore/mod-costumes": "cosmetics", + "azerothcore/mod-npc-morph": "cosmetics", + "azerothcore/mod-aoe-loot": "items", + "azerothcore/mod-npc-enchanter": "items", + "anchydev/statbooster": "items", + "zhengpeiru21/mod-reagent-bank": "items", + "silviu20092/mod-item-upgrade": "items", + "silviu20092/mod-reforging": "items", + "terrabyte-tbwps/mod-aoe-loot": "items", + "silviu20092/mod-improved-bank": "items", + "pangolp/mod-quest-loot-party": "items", + "anchydev/recycleditems": "items", + "anchydev/statboosterreroller": "items", + "hallgaeuer/mod-dynamic-loot-rates": "items", + "seaferer/aoe-loot---merge": "items", + "protonull/mod-starter-wands": "items", + "talamortis/mod-itemlevel": "items", + "noisiver/mod-junk-to-gold": "economy", + "youpeoples/black-market-auction-house": "economy", + "wowhellgarve-holydeew/mod-multi-vendor": "economy", + "azerothcore/mod-money-for-kills": "economy", + "azerothcore/mod-npc-gambler": "economy", + "elmsroth/mod-trade-items-filter": "economy", + "talamortis/mod_reward_shop": "economy", + "silviu20092/mod-sell-items": "economy", + "tereneckla/mod-profession-experience": "economy", + "jorge990125/mod-black-market": "economy", + "heyitsbench/mod-ptr-template": "account", + "pangolp/mod-recruit-friend": "account", + "azerothcore/mod-npc-all-mounts": "account", + "gozzim/mod-firstlogin-aio": "account", + "talamortis/mod-premium": "account", + "zerkenn/mod-character-services": "account", + "lucafolloni/mod-account-vanity-pets": "account", + "noisiver/mod-accountbound": "account", + "pangolp/mod-mounts-on-account": "account", + "microbica/mod-share-mounts": "account", + "gozzim/mod-globalchat": "social", + "azerothcore/mod-guild-zone-system": "social", + "azerothcore/mod-starter-guild": "social", + "anchydev/itembroadcastguildchat": "social", + "azerothcore/mod-chat-transmitter": "social", + "azerothcore/mod-global-chat": "social", + "noisiver/mod-guildfunds": "social", + "jorge990125/mod-streams": "social", + "noisiver/mod-assistant": "npc", + "azerothcore/mod-npc-services": "npc", + "azerothcore/mod-npc-buffer": "npc", + "pangolp/mod-npc-promotion": "npc", + "azerothcore/mod-npc-codebox": "npc", + "azerothcore/mod-buff-command": "npc", + "jorge990125/mod-bienvenida": "npc", + "dunjeon/mod-timeistime": "world", + "bytesgalore/mod-no-hearthstone-cooldown": "world", + "justin-kaufmann/mod-changeablespawnrates": "world", + "hallgaeuer/mod-quick-respawn": "world", + "justin-kaufmann/mod-noclip": "world", + "eldoba/mod-dynamic-resurrections-": "world", + "elmegaard/mod-auto-resurrect": "world", + "noisiver/mod-spawnpoints": "world", + "tralenor/mod-objscale": "world", + "silviu20092/mod-flightmaster-whistle": "world", + "sogladev/mod-ghost-speed": "world", + "talamortis/playerteleport": "world", + "vhiperdev/mod-no-farming": "world", + "noisiver/mod-activatezones": "world", + "zyggy123/treasure-chest-system": "events", + "azerothcore/mod-tic-tac-toe": "events", + "pangolp/mod-quest-status": "events", + "azerothcore/mod-war-effort": "events", + "youpeoples/quizmaster": "events", + "azerothcore/mod-cta-switch": "events", + "azerothcore/mod-dmf-switch": "events", + "azerothcore/mod-server-auto-shutdown": "server", + "anchydev/recache": "server", + "azerothcore/mod-queue-list-cache": "server", + "maluramichael/mod-influxdb": "server", + "sogladev/mod-increment-cache-version": "server", + "anchydev/aci": "server", + "azerothcore/mod-ip-tracker": "admin", + "tralenor/mod-gomove": "admin", + "talamortis/mod-whologged": "admin", + "talamortis/mod_character_tools": "admin", + "azerothcore/mod-notify-muted": "admin", + "mellianstudios/mod-encounter-logs": "admin", + "miithrandiir/mod_sponsorship": "web", + "exitare/mod-discord-webhook": "web", + "abutbul/mod-game-state-api": "web", + "thearktect/mod-discord-announce": "web", + "azerothcore/skeleton-module": "dev", + "azerothcore/eluna-ts": "dev", + "azerothcore/mod-premium-lib": "dev", + "anchydev/breakingnewsoverride": "client", + "gitdalisar/addon-faction-free-unit-popup": "client" + } +} \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bf45e14..ea10cfb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" tauri-plugin-opener = "2" +tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" coa-core = { path = "../crates/coa-core" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5224e01..4829739 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3509,6 +3509,16 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + // Size, position and maximized state come back after a restart. Visibility is left to the app (tray, start + // options), so a window last hidden in the tray still opens normally. + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::all() + - tauri_plugin_window_state::StateFlags::VISIBLE, + ) + .build(), + ) .invoke_handler(tauri::generate_handler![ server_logging_preset, release_history, diff --git a/src/lib/api.ts b/src/lib/api.ts index 72f524c..d9c5d66 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -299,6 +299,14 @@ export interface PublicCatalogueItem { coa_status_source: string; conflict_reason?: string | null; is_coa_recommended: boolean; + /** Functional category code; "other" when nothing fits. */ + category?: string; +} + +export interface CatalogueCategory { + code: string; + ru: string; + en: string; } export interface PublicCatalogueResponse { @@ -308,6 +316,7 @@ export interface PublicCatalogueResponse { etag?: string | null; total_count: number; recommended_count: number; + categories?: CatalogueCategory[]; } export interface CompatibilityReport { diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index ba6ea71..ee0af4e 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -13,9 +13,9 @@ import { Gavel, GitFork, Hammer, - Info, Layers, Loader2, + MoreHorizontal, Plug, Puzzle, RefreshCw, @@ -480,6 +480,25 @@ function CoaStatusBadge({ status, conflictReason }: { status: CoaCompatibilitySt } } +/** Categories of the curated CoA list (the public catalogue items get theirs from the backend). */ +const CURATED_CATEGORY: Record = { + companions: "bots", + playerbots: "bots", + "content-scaling": "pve", + "tbc-content": "pve", + "wotlk-content": "pve", + "custom-races": "classes", + "time-is-time": "world", + "npc-buffer": "npc", + "npc-enchanter": "npc", + "ah-bot": "economy", + "congrats-on-level": "progression", + "reward-played-time": "progression", +}; +function curatedCategory(id: string): string { + return CURATED_CATEGORY[id] ?? "other"; +} + function humanizeRawError(raw: string, locale: string): string { if (raw.includes("[NO_LOADER_FOUND]")) { return locale === "ru" @@ -603,6 +622,8 @@ export function ModulesPage({ // Four groups that do not overlap: checked by CoA (curated list + community modules CoA recommends or verified), // the rest of the AzerothCore community, and modules known not to work with CoA. const [catalogFilter, setCatalogFilter] = useState<"all" | "verified" | "community" | "incompatible">("all"); + const [catalogCategory, setCatalogCategory] = useState("all"); + const [allCategoryChips, setAllCategoryChips] = useState(false); const [selectedCatalogModule, setSelectedCatalogModule] = useState(null); // Public AzerothCore Catalog state @@ -615,6 +636,9 @@ export function ModulesPage({ // GitHub Intake & Analysis state const [githubUrl, setGithubUrl] = useState(""); + // "Add from a link" stays folded away until asked for; the menu holds the rarely used build tools. + const [showUrlBar, setShowUrlBar] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [analyzing, setAnalyzing] = useState(false); const [analysisError, setAnalysisError] = useState(null); const [report, setReport] = useState(null); @@ -1111,24 +1135,36 @@ export function ModulesPage({ return (a.name || "").localeCompare(b.name || ""); }); + // "Checked by CoA" is the Manager's own curated list only; a community module is "incompatible" when the overlay + // says so and "community" otherwise. type CatalogGroup = "verified" | "community" | "incompatible"; const communityGroup = (status: string | undefined): CatalogGroup => - status === "Recommended" || status === "Verified" - ? "verified" - : status === "Incompatible" || status === "Blocked" - ? "incompatible" - : "community"; - // Counts follow the search, so each segment says how many modules it will show. + status === "Incompatible" || status === "Blocked" ? "incompatible" : "community"; + const inCategory = (code: string) => catalogCategory === "all" || code === catalogCategory; + const inGroup = (group: CatalogGroup) => catalogFilter === "all" || group === catalogFilter; + // Each control counts what it would show with the other one as it is now. const catalogCounts: Record<"all" | CatalogGroup, number> = { - all: searchedVerifiedCatalog.length + searchedCommunityCatalog.length, - verified: searchedVerifiedCatalog.length + searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === "verified").length, - community: searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === "community").length, - incompatible: searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === "incompatible").length, + verified: searchedVerifiedCatalog.filter((i) => inCategory(curatedCategory(i.id))).length, + community: searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === "community" && inCategory(i.category ?? "other")).length, + incompatible: searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === "incompatible" && inCategory(i.category ?? "other")).length, + all: 0, }; - const filteredVerifiedCatalog = - catalogFilter === "all" || catalogFilter === "verified" ? searchedVerifiedCatalog : []; - const filteredCommunityCatalog = - catalogFilter === "all" ? searchedCommunityCatalog : searchedCommunityCatalog.filter((i) => communityGroup(i.coa_status) === catalogFilter); + catalogCounts.all = catalogCounts.verified + catalogCounts.community + catalogCounts.incompatible; + const categoryCounts = new Map(); + for (const i of inGroup("verified") ? searchedVerifiedCatalog : []) { + const c = curatedCategory(i.id); + categoryCounts.set(c, (categoryCounts.get(c) ?? 0) + 1); + } + for (const i of searchedCommunityCatalog) { + if (!inGroup(communityGroup(i.coa_status))) continue; + const c = i.category ?? "other"; + categoryCounts.set(c, (categoryCounts.get(c) ?? 0) + 1); + } + const catalogCategories = (publicCatalog?.categories ?? []).filter((c) => (categoryCounts.get(c.code) ?? 0) > 0 || c.code === catalogCategory); + const filteredVerifiedCatalog = inGroup("verified") ? searchedVerifiedCatalog.filter((i) => inCategory(curatedCategory(i.id))) : []; + const filteredCommunityCatalog = searchedCommunityCatalog.filter( + (i) => catalogFilter !== "verified" && inGroup(communityGroup(i.coa_status)) && inCategory(i.category ?? "other") + ); // Helper to check whether any module/plugin is installed function checkModuleInstalled(repoOrId: string, name: string) { @@ -1158,35 +1194,60 @@ export function ModulesPage({ return (
- {/* Header & Sub-navigation */} -
-
-

- - {locale === "ru" ? "Модули и плагины" : "Modules & Plugins"} - - MODULES PREVIEW - -

-

- {locale === "ru" - ? "Автономная тестовая редакция для управления официальными модулями и установки сторонних модулей AzerothCore в виде DLL." - : "Standalone preview for official module management and dynamic third-party DLL plugin installation."} -

-
- - {/* Global actions */} -
+ {/* Header: title, add-from-link, and a menu with the build tools */} +
+

{locale === "ru" ? "Модули" : "Modules"}

+
+ + {menuOpen && ( + <> +
setMenuOpen(false)} aria-hidden /> +
+ {([ + ["builds", Terminal, locale === "ru" ? "Сборки и логи" : "Builds and logs"], + ["toolchain", Shield, locale === "ru" ? "Среда сборки и SDK" : "Build tools and SDK"], + ] as const).map(([tab, Icon, label]) => ( + + ))} + +
+ + )}
@@ -1210,53 +1271,30 @@ export function ModulesPage({
)} - {/* Toolchain Health Status Bar */} - {toolchain && ( -
-
- {toolchain.msvc_available ? ( - - ) : ( - - )} - - MSVC (vcvars64): {toolchain.msvc_available ? "Ready" : "Missing"} + {/* Build tools: said only when something is missing */} + {toolchain && activeTab !== "toolchain" && (() => { + const missing = [ + !toolchain.msvc_available && "MSVC", + !toolchain.cmake_available && "CMake", + !toolchain.ninja_available && "Ninja", + !toolchain.sdk_available && "Plugin SDK", + ].filter(Boolean) as string[]; + return missing.length > 0 ? ( +
+ + + {locale === "ru" ? "Для сборки модулей не хватает: " : "Missing for building modules: "} + {missing.join(", ")} +
-
- {toolchain.cmake_available ? ( - - ) : ( - - )} - - CMake: {toolchain.cmake_available ? "Ready" : "Missing"} - -
-
- {toolchain.ninja_available ? ( - - ) : ( - - )} - - Ninja: {toolchain.ninja_available ? "Ready" : "Missing"} - -
-
- {toolchain.sdk_available ? ( - - ) : ( - - )} - - Plugin SDK v0.3: {toolchain.sdk_available ? "Ready" : "Missing"} - -
-
- )} + ) : null; + })()} - {/* GitHub URL Intake Bar on General Modules Screen */} + {/* Add from a GitHub link: folded away until asked for, or while a build from it is in progress */} + {(showUrlBar || report || analysisError || building) && (
@@ -1369,69 +1407,33 @@ export function ModulesPage({
)} + )} - {/* Tabs Header */} -
- - - + ))} + {(activeTab === "builds" || activeTab === "toolchain") && ( + + {activeTab === "builds" ? (locale === "ru" ? "Сборки и логи" : "Builds and logs") : (locale === "ru" ? "Среда сборки и SDK" : "Build tools and SDK")} - - - - - + )}
{/* Sticky Active Build Banner when user is outside the builds tab */} @@ -1830,550 +1832,258 @@ export function ModulesPage({ {/* TAB 1: AZEROTHCORE & COA MODULE CATALOG */} {activeTab === "catalog" && (
- {/* Top header & offline/refresh bar */} -
-
-

- - {locale === "ru" ? "Каталог модулей AzerothCore & CoA" : "AzerothCore & CoA Module Catalogue"} -

-

- {locale === "ru" - ? "Рекомендованные модули Conquest of Azeroth и публичный каталог сообщества AzerothCore (500+ модулей)." - : "Curated Conquest of Azeroth modules alongside the official AzerothCore community catalog (500+ modules)."} -

-
- -
- -
-
- {/* Offline notice banner */} {publicCatalog?.is_offline && ( -
-
- - - {locale === "ru" - ? `Автономный режим: официальный каталог загружен из локального кэша (${formatDate(publicCatalog.cached_at)})` - : `Offline mode: official catalog loaded from local cache (${formatDate(publicCatalog.cached_at)})`} - -
-
)} - {/* Catalog error notice */} {publicError && ( -
+
{publicError} -
)} - {/* Controls: Filter pills, Search, Sort */} -
- {/* Filter: one segmented control, groups that do not overlap */} -
+
+ + setCatalogQuery(e.target.value)} + placeholder={locale === "ru" ? "Поиск по названию, автору, тегу" : "Search by name, author, topic"} + aria-label={locale === "ru" ? "Поиск по каталогу" : "Search the catalogue"} + className="selectable w-full rounded-md border border-line bg-bg py-2 pl-8 pr-3 text-sm text-ink placeholder:text-muted/60 focus:border-gold focus:outline-none" + /> +
+ setCatalogQuery(e.target.value)} - placeholder={ - locale === "ru" - ? "Поиск по каталогу (название, автор, тег)..." - : "Search catalogue (name, author, topic)..." - } - className="w-full rounded-md border border-line bg-bg pl-8 pr-3 py-1.5 text-xs text-ink placeholder:text-muted/60 focus:border-gold focus:outline-none" - /> -
- - -
+ + + + + + +
- {/* GROUP A: RECOMMENDED FOR COA (ALWAYS ON TOP) */} - {filteredVerifiedCatalog.length > 0 && ( -
-
-
- -

- {locale === "ru" ? "Рекомендовано" : "Recommended"} -

- - {filteredVerifiedCatalog.length} - -
- - {locale === "ru" ? "Проверенная интеграция и оптимизация для CoA" : "Verified compatibility and optimization for CoA"} - -
- -
- {filteredVerifiedCatalog.map((item) => { - const matchedModule = modules?.find( - (m) => - m.id === item.id || - m.id === `coa-${item.id}` || - m.id === `mod-coa-${item.id}` || - m.name.toLowerCase() === item.name.toLowerCase() - ); - const matchedPlugin = installedPlugins?.find( - (p) => - p.id === item.id || - p.id === `coa-${item.id}` || - p.name.toLowerCase() === item.name.toLowerCase() - ); - const isInstalled = Boolean(matchedModule?.installed || matchedPlugin); - const isEnabled = Boolean(matchedModule?.enabled ?? matchedPlugin?.enabled ?? false); - + {/* Categories */} + {catalogCategories.length > 0 && ( +
+ {[{ code: "all", ru: "Все", en: "All" }, ...catalogCategories] + .filter((c, index) => allCategoryChips || index <= 8 || c.code === catalogCategory) + .map((c) => { + const on = catalogCategory === c.code; + const count = c.code === "all" ? catalogCounts.all : (categoryCounts.get(c.code) ?? 0); return ( - -
-
-
- -
-
-

{item.name}

- - ★ CoA - -
- ({item.id}) -

{locale === "ru" ? item.description.ru : item.description.en}

-
-
-
- - {/* Qualification Badges */} -
- {item.qualification === "OPTIMIZED_COA" || item.isOfficial ? ( - - ✓ {locale === "ru" ? "Оптимизировано" : "Optimized"} - - ) : item.qualification === "INSTALL_VERIFIED" ? ( - - ✓ INSTALL_VERIFIED - - ) : item.qualification === "LINK_VERIFIED" ? ( - - ✓ LINK_VERIFIED - - ) : ( - - ● BUILT - - )} - - {item.tier} - {item.sqlRequired && ( - SQL - )} -
- -

- {locale === "ru" ? item.notes.ru : item.notes.en} -

-
- -
- - -
- - - {isInstalled ? ( -
- - ✓ {locale === "ru" ? (isEnabled ? "Включен" : "Установлен") : (isEnabled ? "Active" : "Installed")} - - -
- ) : item.id === "tbc-content" || item.id === "wotlk-content" ? ( - !isScalingInstalled ? ( - - ) : ( - - ) - ) : item.id === "time-is-time" ? ( - - ) : item.id === "custom-races" ? ( - - ) : ( - - )} -
-
-
+ ); })} -
+ {catalogCategories.length > 8 && ( + + )}
)} - {/* GROUP B: AZEROTHCORE COMMUNITY MODULES */} -
-
-
- -

- {locale === "ru" ? "Каталог AzerothCore" : "AzerothCore Catalogue"} -

- - {filteredCommunityCatalog.length} - -
+ {publicLoading && !publicCatalog && ( +
+ + {locale === "ru" ? "Загрузка каталога AzerothCore…" : "Loading the AzerothCore catalogue…"} +
+ )} + + {/* The list: CoA's own modules first, then the AzerothCore catalogue */} +
+ {filteredVerifiedCatalog.length + filteredCommunityCatalog.length === 0 && !publicLoading && ( +

{locale === "ru" ? "Ничего не найдено." : "Nothing found."}

+ )} + + {filteredVerifiedCatalog.map((item) => { + const matchedModule = modules?.find( + (m) => m.id === item.id || m.id === `coa-${item.id}` || m.id === `mod-coa-${item.id}` || m.name.toLowerCase() === item.name.toLowerCase() + ); + const matchedPlugin = installedPlugins?.find( + (p) => p.id === item.id || p.id === `coa-${item.id}` || p.name.toLowerCase() === item.name.toLowerCase() + ); + const isInstalled = Boolean(matchedModule?.installed || matchedPlugin); + const isEnabled = Boolean(matchedModule?.enabled ?? matchedPlugin?.enabled ?? false); + const enable = async (id: string) => { + try { + await api.moduleSetEnabled(serverId, id, true); + setChanged(true); + await load(); + setOpenSettings(id); + setActiveTab("installed"); + onChanged?.(); + } catch (e) { + setError(asUiError(e)); + } + }; + const cat = (publicCatalog?.categories ?? []).find((c) => c.code === curatedCategory(item.id)); + return ( +
+ + {cat ? (locale === "ru" ? cat.ru : cat.en) : ""} + +
+ {isInstalled ? ( + + ) : (item.id === "tbc-content" || item.id === "wotlk-content") && !isScalingInstalled ? ( + {locale === "ru" ? "Нужен Content Scaling" : "Needs Content Scaling"} + ) : item.id === "tbc-content" || item.id === "wotlk-content" || item.id === "time-is-time" ? ( + + ) : item.id === "custom-races" ? ( + + ) : ( + + )} +
+
+ ); + })} + + {filteredCommunityCatalog.slice(0, communityDisplayLimit).map((item) => { + const status = checkModuleInstalled(item.html_url, item.name); + const isBlocked = item.coa_status === "Blocked"; + const isIncompatible = item.coa_status === "Incompatible"; + const cat = (publicCatalog?.categories ?? []).find((c) => c.code === (item.category ?? "other")); + return ( +
+ + {cat ? (locale === "ru" ? cat.ru : cat.en) : ""} + + {item.stars} + +
+ {status.installed ? ( + + ) : isBlocked || isIncompatible ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + {filteredCommunityCatalog.length > communityDisplayLimit && ( +
+ - {locale === "ru" ? "Официальный каталог azerothcore.org" : "Official azerothcore.org catalogue"} + {locale === "ru" + ? `Показано ${communityDisplayLimit} из ${filteredCommunityCatalog.length}` + : `Showing ${communityDisplayLimit} of ${filteredCommunityCatalog.length}`}
- - {publicLoading && !publicCatalog && ( -
- - {locale === "ru" ? "Загрузка каталога AzerothCore..." : "Loading AzerothCore catalogue..."} -
- )} - - {filteredCommunityCatalog.length === 0 && !publicLoading && ( -
- {locale === "ru" ? "Модули сообщества по заданным фильтрам не найдены." : "No community modules match the active filters."} -
- )} - -
- {filteredCommunityCatalog.slice(0, communityDisplayLimit).map((item) => { - const status = checkModuleInstalled(item.html_url, item.name); - const isBlocked = item.coa_status === "Blocked"; - const isIncompatible = item.coa_status === "Incompatible"; - - return ( - -
- {/* Card Header: Icon, Name, Metrics */} -
-
- -
-

- {item.name} -

- {item.full_name} -

- {item.description || (locale === "ru" ? "Нет описания репозитория." : "No description provided.")} -

-
-
-
- - {/* Status and Metric Badges */} -
- - - - - {item.stars} - - - - - {item.forks} - - - {item.license && ( - - {item.license} - - )} -
- - {/* Conflict / Engineering Notes */} - {item.conflict_reason && (isBlocked || isIncompatible) ? ( -
- - {item.conflict_reason} -
- ) : item.conflict_reason ? ( - // A note on a module that works (for example why CoA recommends it) is not a warning. -

{item.conflict_reason}

- ) : item.topics.length > 0 ? ( -
- {item.topics.slice(0, 3).map((topic) => ( - - #{topic} - - ))} -
- ) : null} -
- - {/* Card Footer: GitHub Link, Details, Action */} -
- - -
- - - {status.installed ? ( -
- - ✓ {locale === "ru" ? (status.enabled ? "Включен" : "Установлен") : (status.enabled ? "Active" : "Installed")} - - -
- ) : isBlocked ? ( - - ) : ( - - )} -
-
-
- ); - })} -
- - {/* Pagination / Load more button */} - {filteredCommunityCatalog.length > communityDisplayLimit && ( -
- - - {locale === "ru" - ? `Показано ${Math.min(communityDisplayLimit, filteredCommunityCatalog.length)} из ${filteredCommunityCatalog.length}` - : `Showing ${Math.min(communityDisplayLimit, filteredCommunityCatalog.length)} of ${filteredCommunityCatalog.length}`} - -
- )} -
+ )} {/* Details Modal for Verified Modules */} {selectedCatalogModule && ( @@ -2419,6 +2129,9 @@ export function ModulesPage({
+