From c836a0f284d2fe0f8d59995babf033bd40886252 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Nov 2026 08:49:19 +0000 Subject: [PATCH 1/3] Hide WoW on a profile, and tidy the manager and upload page. None is a profile display choice. The public roster toggle only appears when both versions have visible characters. The manager lives in Account settings as well as on its own page, with Add characters and Your characters split apart. Co-authored-by: Lucrona --- apps/desktop/addon/curseforge/description.md | 22 +-- apps/desktop/addon/curseforge/summary.txt | 2 +- .../migrations/0059_wow_profile_none.sql | 6 + apps/server/src/account.rs | 16 ++ apps/server/src/profile.rs | 28 ++- apps/server/src/wowpage.rs | 51 ++++- apps/server/tests/wow_cards.rs | 44 ++++- apps/site-web/pages/faq.html | 2 +- apps/site-web/static/assets/app.js | 174 ++++++++++-------- apps/site-web/static/assets/site.css | 26 ++- 10 files changed, 269 insertions(+), 102 deletions(-) create mode 100644 apps/server/migrations/0059_wow_profile_none.sql diff --git a/apps/desktop/addon/curseforge/description.md b/apps/desktop/addon/curseforge/description.md index 9adef30..a1d0ae1 100644 --- a/apps/desktop/addon/curseforge/description.md +++ b/apps/desktop/addon/curseforge/description.md @@ -1,19 +1,17 @@ -Fraglink /played saves each character's /played time on your PC. You upload that file at [playfraglink.com](https://playfraglink.com). The addon does not send anything while you are in game. +Logs your /played time for each character so you can add it to your profile on [playfraglink.com](https://playfraglink.com). Works in both Retail and WoW Forever. -It is one addon for Retail and WoW Forever. When you log in, it asks the game for /played and marks the saved file Retail or Forever. - -An upload only adds time. A file with a lower total is refused, and the hours already stored are left as they are. Retail and Forever stay separate. +Nothing is sent while you play. The addon saves to a file on your PC, and you upload it on the site whenever you like. ## Install -1. Download the zip. -2. Unzip it into `World of Warcraft/_retail_/Interface/AddOns` and `World of Warcraft/_forever_/Interface/AddOns`. Each should contain a `FraglinkPlayed` folder. -3. Restart the game, or type `/reload`. Enable Fraglink /played on the addon list if it is off. +Unzip into your `_retail_/Interface/AddOns` or `_forever_/Interface/AddOns` folder (or both), then restart WoW or type `/reload`. + +## How to use it -## Use +1. Log in to each character you want to track. +2. Type `/reload` or log out so WoW saves the file. +3. On playfraglink.com, go to your WoW page, pick Retail or Forever, and upload `WTF/Account//SavedVariables/FraglinkPlayed.lua`. -1. Log in to each character once. Type `/reload` or log out so the game writes the file. -2. On [playfraglink.com](https://playfraglink.com), open the WoW page, pick Retail or Forever, and upload `WTF/Account//SavedVariables/FraglinkPlayed.lua`. -3. Link Battle.net so a Retail character can be verified. Forever characters stay unverified until that check is turned on. The file on your PC is kept either way, so you can upload it again later. +Link your Battle.net account to get your Retail characters verified. -A line in chat says when a character was recorded. `/fragq` hides that line. +Type `/fragq` to hide the chat message. diff --git a/apps/desktop/addon/curseforge/summary.txt b/apps/desktop/addon/curseforge/summary.txt index 1ccc0ce..0d52546 100644 --- a/apps/desktop/addon/curseforge/summary.txt +++ b/apps/desktop/addon/curseforge/summary.txt @@ -1 +1 @@ -Saves Retail and WoW Forever /played on your PC for Fraglink. Uploads only add time and never lower your hours. +Tracks your /played time in Retail and WoW Forever so you can add it to your Fraglink profile. diff --git a/apps/server/migrations/0059_wow_profile_none.sql b/apps/server/migrations/0059_wow_profile_none.sql new file mode 100644 index 0000000..796e2c5 --- /dev/null +++ b/apps/server/migrations/0059_wow_profile_none.sql @@ -0,0 +1,6 @@ +-- Let a profile hide its WoW section. Existing rows are not updated. +-- Null still means Both. 'wow', 'wow_forever' and 'both' stay valid. + +ALTER TABLE wow_prefs DROP CONSTRAINT IF EXISTS wow_prefs_profile_show_check; +ALTER TABLE wow_prefs ADD CONSTRAINT wow_prefs_profile_show_check + CHECK (profile_show IS NULL OR profile_show IN ('wow', 'wow_forever', 'both', 'none')); diff --git a/apps/server/src/account.rs b/apps/server/src/account.rs index 08f4b03..8b09c85 100644 --- a/apps/server/src/account.rs +++ b/apps/server/src/account.rs @@ -118,9 +118,25 @@ pub async fn me_extra(st: &AppState, uid: Uuid) -> ApiResult { "timezone": crate::week::saved_zone(st, uid).await, "avatar_upload_allowed": avatars::upload_block_reason(st, uid).await?.is_none(), "avatar_upload_block": avatars::upload_block_reason(st, uid).await?, + "has_wow": has_wow(st, uid).await?, })) } +/// True when this account has any WoW time: a character, an import, or a tracked session. +async fn has_wow(st: &AppState, uid: Uuid) -> ApiResult { + let on: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM wow_characters WHERE user_id = $1) + OR EXISTS(SELECT 1 FROM user_game_stats WHERE user_id = $1 + AND game_id IN ('wow', 'wow_forever') AND display_min > 0) + OR EXISTS(SELECT 1 FROM sessions WHERE user_id = $1 + AND game_id IN ('wow', 'wow_forever'))", + ) + .bind(uid) + .fetch_one(&st.db) + .await?; + Ok(on) +} + fn notify_defaults(p: &Value) -> Value { let b = |k: &str, d: bool| p.get(k).and_then(Value::as_bool).unwrap_or(d); json!({ diff --git a/apps/server/src/profile.rs b/apps/server/src/profile.rs index 950ac21..a57f183 100644 --- a/apps/server/src/profile.rs +++ b/apps/server/src/profile.rs @@ -494,7 +494,18 @@ async fn profile( // cards are included without a tick until that account list is on. A profile // set to one flavour omits the other from this response; the rows stay stored. let wow_show = crate::wowpage::profile_show(&st, p.id).await?; - let wow_characters = if can_see_games + let wow_default = crate::wowpage::default_flavour(&st, p.id).await?; + let wow_characters = if wow_show == "none" + || !(can_see_games + && show("wow_characters") + && crate::features::on(&st, "wow_characters").await) + { + if wow_show == "none" { + json!([]) + } else { + Value::Null + } + } else if can_see_games && show("wow_characters") && crate::features::on(&st, "wow_characters").await { @@ -526,6 +537,17 @@ async fn profile( } else { Value::Null }; + let (retail_n, forever_n) = match wow_characters.as_array() { + Some(rows) => ( + rows.iter().filter(|x| x["game_id"] == "wow").count() as i64, + rows.iter() + .filter(|x| x["game_id"] == "wow_forever") + .count() as i64, + ), + None => (0, 0), + }; + let (wow_open, wow_toggle) = + crate::wowpage::public_roster(&wow_show, &wow_default, retail_n, forever_n); if can_see_history { recent = recent_sessions(&st, p.id, is_self).await?; recent.retain(|r| { @@ -635,8 +657,10 @@ async fn profile( "events": events, "achievements": achievements, "wow_characters": wow_characters, - "wow_default": crate::wowpage::default_flavour(&st, p.id).await?, + "wow_default": wow_default, "wow_show": wow_show, + "wow_open": wow_open, + "wow_toggle": wow_toggle, "rig": if has_rig && show("rig") { rig } else { Value::Null }, "friends": friend_grid, "linked": linked, diff --git a/apps/server/src/wowpage.rs b/apps/server/src/wowpage.rs index ea52c0b..f63ed39 100644 --- a/apps/server/src/wowpage.rs +++ b/apps/server/src/wowpage.rs @@ -81,7 +81,7 @@ async fn summary(State(st): State, a: AuthUser) -> ApiResult ApiResult { let stored: Option = sqlx::query_scalar( "SELECT coalesce(profile_show, 'both') FROM wow_prefs WHERE user_id = $1", @@ -92,10 +92,34 @@ pub(crate) async fn profile_show(st: &AppState, uid: Uuid) -> ApiResult Ok(match stored.as_deref() { Some("wow") => "wow".into(), Some("wow_forever") => "wow_forever".into(), + Some("none") => "none".into(), _ => "both".into(), }) } +/// Which roster the public page opens on, and whether the Retail/Forever toggle shows. +/// Counts are visible characters. A side with nobody on it is not offered. +pub fn public_roster(show: &str, default: &str, retail: i64, forever: i64) -> (String, bool) { + if show == "none" { + return ("none".into(), false); + } + if show == "wow" || show == "wow_forever" { + return (show.into(), false); + } + if retail > 0 && forever > 0 { + let open = if default == "wow_forever" { + "wow_forever" + } else { + "wow" + }; + return (open.into(), true); + } + if forever > 0 { + return ("wow_forever".into(), false); + } + ("wow".into(), false) +} + #[derive(Deserialize)] struct ShowReq { show: String, @@ -108,10 +132,10 @@ async fn set_profile_show( Json(r): Json, ) -> ApiResult> { api::check_origin(&a, &headers, &st)?; - if r.show != "wow" && r.show != "wow_forever" && r.show != "both" { + if r.show != "wow" && r.show != "wow_forever" && r.show != "both" && r.show != "none" { return Err(AppError::bad_request( "invalid_show", - "Choose Retail only, Forever only, or Both.", + "Choose Retail, Forever, Both, or None.", )); } sqlx::query( @@ -636,6 +660,27 @@ mod tests { assert!(specs_of("DEATHKNIGHT").contains(&"Unholy")); assert!(specs_of("nope").is_empty()); } + + #[test] + fn roster_toggle_only_when_both_versions_are_visible() { + assert_eq!( + public_roster("both", "wow_forever", 2, 3), + ("wow_forever".into(), true) + ); + assert_eq!( + public_roster("both", "wow_forever", 2, 0), + ("wow".into(), false) + ); + assert_eq!( + public_roster("both", "wow", 0, 4), + ("wow_forever".into(), false) + ); + assert_eq!( + public_roster("wow", "wow_forever", 1, 1), + ("wow".into(), false) + ); + assert_eq!(public_roster("none", "wow", 3, 3), ("none".into(), false)); + } } // ------------------------------------------------------------------ featured diff --git a/apps/server/tests/wow_cards.rs b/apps/server/tests/wow_cards.rs index 34ed817..b6ca672 100644 --- a/apps/server/tests/wow_cards.rs +++ b/apps/server/tests/wow_cards.rs @@ -16,7 +16,8 @@ async fn names(v: &Value) -> Vec { #[sqlx::test(migrator = "fraglink_server::MIGRATOR")] async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { - let sql = include_str!("../migrations/0058_wow_default_flavour.sql").to_ascii_lowercase(); + let sql = include_str!("../migrations/0058_wow_default_flavour.sql").to_ascii_lowercase() + + include_str!("../migrations/0059_wow_profile_none.sql"); for line in sql.lines() { let t = line.trim(); if t.starts_with("--") { @@ -123,6 +124,42 @@ async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { vec!["Vale Mocken".to_string()] ); set("both").await; + let both = profile().await; + assert_eq!(both["wow_toggle"], true); + assert_eq!(both["wow_open"], "wow"); + sqlx::query( + "UPDATE wow_characters SET hidden = true WHERE user_id = $1 AND game_id = 'wow_forever'", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + let one = profile().await; + assert_eq!(one["wow_show"], "both"); + assert_eq!(one["wow_toggle"], false); + assert_eq!(one["wow_open"], "wow"); + assert!(one["wow_characters"] + .as_array() + .unwrap() + .iter() + .all(|c| c["game_id"] == "wow")); + sqlx::query( + "UPDATE wow_characters SET hidden = false WHERE user_id = $1 AND game_id = 'wow_forever'", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + set("none").await; + let hidden_page = profile().await; + assert_eq!(hidden_page["wow_show"], "none"); + assert_eq!(hidden_page["wow_toggle"], false); + assert_eq!(hidden_page["wow_characters"].as_array().unwrap().len(), 0); + let me_none = api(&app, "GET", "/api/v1/me/bnet", Some(&t), None).await; + assert_eq!(me_none.json()["profile_show"], "none"); + let me_user = api(&app, "GET", "/api/v1/me", Some(&t), None).await; + assert_eq!(me_user.json()["user"]["has_wow"], true); + set("both").await; let r = api( &app, "POST", @@ -135,7 +172,12 @@ async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { let p = profile().await; assert_eq!(p["wow_show"], "both"); assert_eq!(p["wow_default"], "wow_forever"); + assert_eq!(p["wow_open"], "wow_forever"); + assert_eq!(p["wow_toggle"], true); assert_eq!(names(&p).await.len(), 2); + let t2 = signup(&app, "nowow@example.com", "nowow").await; + let bare = api(&app, "GET", "/api/v1/me", Some(&t2), None).await; + assert_eq!(bare.json()["user"]["has_wow"], false); let stored: Vec<(String, i64, bool)> = sqlx::query_as( "SELECT name, played_s, hidden FROM wow_characters WHERE user_id = $1 ORDER BY name", diff --git a/apps/site-web/pages/faq.html b/apps/site-web/pages/faq.html index 2b8ed52..274358c 100644 --- a/apps/site-web/pages/faq.html +++ b/apps/site-web/pages/faq.html @@ -21,7 +21,7 @@
My game isn't detected. What now?

If an unknown program runs full screen for 5 minutes, Fraglink asks "Playing X? Add it!". Choose Add it! or Not a game. Missed it? Open Options › Games: unanswered ones wait under Detected, not added, and Not seeing your game? Add it! lets you pick the exe yourself. Scan for games searches your game folders. Anything you add is sent to us for review. Until then, the name you gave it is what friends and your profile show.

Can I bring my existing hours?

Yes, from Steam, and World of Warcraft /played hours with the Fraglink /played addon after linking Battle.net. Link Steam in Settings › Connections or in the app under Options › Connections. Your playtime is imported once (press Import hours again any time to refresh it), and hours Fraglink tracks after that add on top. The same goes for WoW /played, and time the app tracks in WoW's menus and loading screens (which /played leaves out) is kept when you upload again. Your Steam game details need to be public.

What do Tracked and Imported mean?

Tracked is time Fraglink recorded, plus Steam playtime. Imported is a game's own counter. A game's total is your latest import plus whatever Fraglink tracked after it, so nothing gets counted twice. You don't have to re-import, and if a new import comes in lower, the higher total stays. Leaderboards use the same total you see. Steam doesn't sync by itself, hit Import hours to refresh it.

WoW counts each character once (by its Battle.net character id) at its highest /played. Rename it or move realms and it's still one character. Sell it or move it to another Battle.net account and it only counts for the new owner. Delete it and the hours stay. WoW Forever isn't out yet, so the app tracks Forever hours for now, and Forever /played uploads open once it launches.

-
Can I choose which WoW characters my profile shows?

Yes. In Manage WoW (Settings › Connections › Battle.net) you can hide characters, which removes them from your profile for everyone, you included, and pick up to 3 featured characters in the order you want. Featured characters show first; with none picked your most played show first. Featured picks are for Retail for now (Forever once its uploads open), and hidden characters can't be featured. Hidden characters still count toward your hours.

+
Can I choose which WoW characters my profile shows?

Yes. In Account settings › World of Warcraft you can pick Retail, Forever, both, or none, and choose which characters show first.

Can I add hours by hand?

No. Manual hour imports are turned off. You can still hide a game or reset its hours in Settings › Played games.

diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index f9a43c9..3048dd0 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -506,38 +506,48 @@ pages.changelog = async () => { pages.wowupload = async () => { const el = $("#wu"); if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname); return; } - const b = BRAND.bnet, logo = ``; const plural = (n, one, many) => `${nf.format(n)} ${n === 1 ? one : (many || one + "s")}`; const STEPS = [["read", "Reading the file"], ["send", "Sending it securely to Fraglink"], ["check", "Checking signatures, BattleTag and /played history"], ["match", "Matching your Battle.net characters"]]; - el.innerHTML = `
${logo}🔒

Upload WoW /played

-

Upload a character's /played from the addon. Retail and Forever each store that total on their own game. Shown hours use the imported total plus play after that snapshot, or the app's tracked time if that is higher. The two are not added together.

+ const PATHS = { + wow: "World of Warcraft\\_retail_\\WTF\\Account\\\\SavedVariables\\FraglinkPlayed.lua", + wow_forever: "World of Warcraft\\_forever_\\WTF\\Account\\\\SavedVariables\\FraglinkPlayed.lua", + }; + const asked = new URLSearchParams(location.search); + const raw = (asked.get("version") || asked.get("game") || "retail").toLowerCase(); + let game = raw === "forever" || raw === "wow_forever" ? "wow_forever" : "wow"; + el.innerHTML = `
+
+

Upload Retail /played

+

Log in to each character with the addon on, then /reload or log out so WoW saves the file.

+
-
Game
-

Retail and Forever are separate. A Forever character stays unverified until Battle.net's Forever account list confirms it. That check is off until it has been tested.

-
Before you upload: log in to every character. The addon only saves a character's /played when you log in to it (then log out or /reload). Characters you skip keep the hours from their last upload, and Fraglink always keeps your higher total, so a partial upload never lowers your hours.
- -
File
-
Where is FraglinkPlayed.lua? -

Log out of WoW (or type /reload) first so the game saves it, then open:

-

World of Warcraft\\_retail_\\WTF\\Account\\<ACCOUNT>\\SavedVariables\\FraglinkPlayed.lua

-

Forever uses the same file name under _forever_, not _retail_.

-

If you have several WoW accounts there (WoW1, WoW2, or a family member's), upload each account's file. Characters from a Battle.net account other than the one you linked are refused.

+ +
+

Got more than one WoW account? Upload each one's file.

+
`; const step = (k, state, note) => { const li = $(`[data-s="${k}"]`, el); li.className = state; if (note != null) $("small", li).textContent = note; }; const res = $("[data-res]", el), form = $("[data-form]", el), steps = $("[data-steps]", el); - const preset = new URLSearchParams(location.search).get("game"); - if (preset === "wow" || preset === "wow_forever") { - form.game.value = preset; - const pick = $("[data-gamepick]", form); - if (pick) pick.hidden = true; - } + const setGame = next => { + game = next === "wow_forever" ? "wow_forever" : "wow"; + form.game.value = game; + const label = game === "wow_forever" ? "Forever" : "Retail"; + $("[data-title]", el).textContent = `Upload ${label} /played`; + $("button[type=submit]", form).textContent = `Upload ${label} /played`; + const order = game === "wow_forever" ? ["wow_forever", "wow"] : ["wow", "wow_forever"]; + const names = { wow: "Retail", wow_forever: "Forever" }; + $("[data-paths]", el).innerHTML = order.map(id => `

${names[id]}${esc(PATHS[id])}

`).join(""); + $$("[data-ver]", el).forEach(b => { const on = b.dataset.ver === game; b.classList.toggle("on", on); b.setAttribute("aria-selected", on ? "true" : "false"); }); + }; + setGame(game); + el.addEventListener("click", e => { const b = e.target.closest("[data-ver]"); if (b) setGame(b.dataset.ver); }); const R = { wow_checksum: "Edited after the game saved it", wow_battletag: "From a different Battle.net account", wow_old_addon: "Addon too old, update it", wow_decrease: "/played went down", wow_impossible: "/played grew faster than real time", wow_clock: "Saved with a wrong clock" }; let rows = [], sortBy = "played", dir = -1, showAll = false, totals = null; const CAP = 25; - const STATUS = { v: [0, "vbadge", "✓ Verified via Battle.net"], s: [1, "srbadge", "◆ Not verified, not counted"], r: [2, "badbadge", "✕ Rejected"] }; + const STATUS = { v: [0, "vbadge", "Verified"], s: [1, "srbadge", "Not verified"], r: [2, "badbadge", "Rejected"] }; const draw = () => { if (!totals) return; const key = { name: r => r.name.toLowerCase(), realm: r => r.realm.toLowerCase(), level: r => r.level || 0, played: r => r.played_min, status: r => STATUS[r.st][0] }[sortBy]; @@ -546,14 +556,13 @@ pages.wowupload = async () => { const th = (k, t) => ``; const cls = c => c ? c.charAt(0) + c.slice(1).toLowerCase().replace(/knight$/, " Knight").replace(/hunter$/, " Hunter") : ""; res.innerHTML = `

Results

-
${hours(totals.verified, { zero: true })}Counted: verified via Battle.net
${totals.unverified ? `
${hours(totals.unverified)}Not counted until verified
` : ""}
+
${hours(totals.verified, { zero: true })}Verified
${totals.unverified ? `
${hours(totals.unverified)}Not verified
` : ""}
${rows.length ? `
${th("name", "Character")}${th("realm", "Realm")}${th("level", "Class / level")}${th("played", "Hours played")}${th("status", "Status")} ${shown.map(r => ``).join("")}
${esc(r.name)}${esc(r.realm)} ${esc(r.region.toUpperCase())}${r.game_id === "wow_forever" ? " · Forever" : ""} ${esc([cls(r.class), r.level ? "level " + r.level : ""].filter(Boolean).join(" · ")) || `–`} ${hours(r.played_min)}${r.chg ? `
${esc(r.chg)}` : ""}
${STATUS[r.st][2]}${r.why ? `
${esc(r.why)}` : r.st === "s" ? `
Verify WoW characters` : ""}
${list.length > CAP ? `

` : ""}` : `

No characters uploaded yet.

`} -

Only characters Battle.net confirms are on your account count toward your hours. Unverified ones are kept and start counting once they verify: Battle.net has to list the character on your linked account, and the file has to be saved under that account's BattleTag.

-

🔒 Verify WoW characters Back to Battle.net

`; +

Verify characters Back to World of Warcraft

`; }; res.addEventListener("click", e => { const s = e.target.closest("[data-sort]"); @@ -585,14 +594,14 @@ pages.wowupload = async () => { try { m = await api("/me/wow"); } catch (x) { return fail("match", x); } const game = form.game.value, mine = m.characters.filter(c => c.game_id === game); const nv = mine.filter(c => c.verified).length; - step("match", m.bnet === "linked" ? "ok" : "warn", m.bnet === "none" ? "Battle.net isn't linked, so these aren't counted yet" : m.bnet === "reconnect" ? "Reconnect Battle.net to verify characters" : `${nf.format(nv)} of ${plural(mine.length, "character")} verified`); + step("match", m.bnet === "linked" ? "ok" : "warn", m.bnet === "none" ? "Battle.net isn't linked." : m.bnet === "reconnect" ? "Reconnect Battle.net to verify characters." : `${nf.format(nv)} of ${plural(mine.length, "character")} verified`); const ck = c => [c.region, c.realm, c.name].join("|").toLowerCase(); const chg = new Map((u.changes || []).map(c => [ck(c), c.first ? "First upload" : c.delta_s > 0 ? `+${(c.delta_s / 3600).toFixed(1)} hours since last upload` : "No change since last upload"])); rows = mine.map(c => ({ ...c, st: c.verified ? "v" : "s", chg: chg.get(ck(c)) })) .concat(u.rejected.map(r => ({ ...r, game_id: game, st: "r", why: R[r.reason] || r.detail }))); totals = { verified: mine.filter(c => c.verified).reduce((a, c) => a + c.played_min, 0), unverified: mine.filter(c => !c.verified).reduce((a, c) => a + c.played_min, 0) }; showAll = false; draw(); - btn.disabled = false; form.reset(); form.game.value = game; + btn.disabled = false; form.reset(); setGame(game); }); }; @@ -637,29 +646,34 @@ const WOW = (() => { const imgOk = "data-hide-broken"; /* ---------- /connect/battlenet: the Battle.net account, verified WoW characters, spec picks, hide toggles ---------- */ -pages.bnet = async () => { - const el = $("#bn"); - if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname); return; } +pages.bnet = () => wowManager($("#bn")); +async function wowManager(el) { + if (!el) return; + if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search + location.hash); return; } + const embedded = el.dataset.embed === "1"; + const Hx = embedded ? "h3" : "h2"; + const Sec = embedded ? "bn-sec" : "panel bn-box"; const b = BRAND.bnet, logo = s => ``; let s; try { s = await api("/me/bnet"); } catch (x) { el.innerHTML = `

${esc(x.message)}

`; return; } const ago = iso => { const m = Math.round((Date.now() - new Date(iso)) / 60000); return m < 1 ? "just now" : m < 60 ? plural(m, "minute") + " ago" : m < 1440 ? plural(Math.round(m / 60), "hour") + " ago" : plural(Math.round(m / 1440), "day") + " ago"; }; - const show = s.profile_show === "wow" || s.profile_show === "wow_forever" ? s.profile_show : "both"; + const show = s.profile_show === "wow" || s.profile_show === "wow_forever" || s.profile_show === "none" ? s.profile_show : "both"; const def = s.default_flavour === "wow_forever" ? "wow_forever" : "wow"; const handle = (me() || {}).handle || ""; - const flavSeg = `
Show on profile
Profile opens on
`; + const flavSeg = `
Show on profile
Profile opens on
`; const head = s.linked ? `
${logo(26)}
${esc(s.battletag)} ${s.needs_reconnect ? `Reconnect` : `Verified`}
${[s.linked_at ? "Linked " + fullDate.format(new Date(s.linked_at)) : "", s.regions.length ? s.regions.map(r => r.toUpperCase()).join(", ") : "", s.last_verified ? "checked " + ago(s.last_verified) : ""].filter(Boolean).join(" · ")}
` : `
${logo(26)}
Not linked
Link Battle.net to verify Retail characters.
`; - el.innerHTML = `

Battle.net

${head}
-

Profile page display${handle ? `View profile` : ""}

-

Choose which WoW characters show on your public profile. This doesn't change your hours or characters here.

+ el.innerHTML = `
<${Hx}>Battle.net
${head}
+
<${Hx}>Profile page display${handle ? `View profile` : ""} ${flavSeg}${s.linked ? `
` : ""}
-
- +
+
<${Hx}>Add characters +
+
<${Hx}>Your characters
@@ -668,20 +682,13 @@ pages.bnet = async () => {

Loading…

- `; + `; const msgBox = $("[data-msg]", el), list = $("[data-list]", el), more = $("[data-more]", el), cnt = $("[data-cnt]", el); const st = { q: "", sort: "hours", class: "", vis: "", items: [], data: null, seq: 0, game: def, extra: "" }; const regions = ``; const addForm = game => game === "wow_forever" - ? `

Add by first name and surname. It's verified automatically when it matches your linked Battle.net account.

${regions}
` - : `

Add by name and realm. It's verified automatically when it matches your linked Battle.net account.

${regions}
`; - const tabIds = d => { - const retail = d ? d.retail || 0 : 0, forever = d ? d.forever || 0 : 0, none = !d || retail + forever === 0; - const ids = []; - if (retail > 0 || def === "wow" || st.extra === "wow" || (none && st.game === "wow")) ids.push("wow"); - if (forever > 0 || def === "wow_forever" || st.extra === "wow_forever" || (none && st.game === "wow_forever")) ids.push("wow_forever"); - return ids.length ? ids : [def]; - }; + ? `
${regions}
` + : `
${regions}
`; const row = c => { const hid = c.hidden, specs = WOW.specs(c.class); const was = [c.former_name && c.former_name.toLowerCase() !== c.name.toLowerCase() ? c.former_name : "", c.former_realm && c.former_realm !== c.realm ? c.former_realm : ""].filter(Boolean).join(" · "); @@ -700,14 +707,13 @@ pages.bnet = async () => { `; }; const render = () => { - const d = st.data, ids = tabIds(d), label = { wow: "Retail", wow_forever: "Forever" }; - if (!ids.includes(st.game)) st.game = ids[0]; - $("[data-tabs]", el).innerHTML = ids.map(id => ``).join("") - + (ids.length < 2 ? `` : ""); + const d = st.data, label = { wow: "Retail", wow_forever: "Forever" }; + $("[data-tabs]", el).innerHTML = ["wow", "wow_forever"].map(id => ``).join(""); $("[data-addbox]", el).innerHTML = addForm(st.game); - const up = $("[data-upload]", el); if (up) up.href = "/connect/wow?game=" + st.game; + const up = $("[data-upload]", el); if (up) up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail"); + const filtered = d && d.matched === 0 && (st.q || st.class || st.vis); list.innerHTML = st.items.length ? st.items.map(row).join("") - : `

${d && d.matched === 0 && st.q ? "No characters match." : "No characters yet."}

`; + : `

${filtered ? "No characters match." : "No characters yet. Add one above, or upload a /played file."}

`; cnt.textContent = d && d.matched ? plural(d.matched, "character") : ""; const left = d ? d.matched - st.items.length : 0; more.hidden = left <= 0; more.textContent = "Show more"; @@ -786,9 +792,7 @@ pages.bnet = async () => { return; } const tab = e.target.closest("[data-tab]"); - if (tab) { st.game = tab.dataset.tab; st.extra = ""; load(false); return; } - const addTab = e.target.closest("[data-addtab]"); - if (addTab) { st.game = addTab.dataset.addtab; st.extra = st.game; load(false); return; } + if (tab) { st.game = tab.dataset.tab; load(false); return; } const menuBtn = e.target.closest("[data-menu]"); if (menuBtn) { const pop = menuBtn.nextElementSibling; @@ -800,7 +804,7 @@ pages.bnet = async () => { const t = e.target.closest("[data-unlink],[data-link]"); if (!t) return; try { if (t.matches("[data-link]")) { t.disabled = true; const r = await api("/connect/bnet/start", { method: "POST" }); location.href = r.url; return; } - if (!confirm("Unlink Battle.net? Your characters stop counting toward your hours until you link it again. Their data is kept.")) return; + if (!confirm("Unlink Battle.net? You can link it again later.")) return; t.disabled = true; await api("/connect/bnet", { method: "DELETE" }); location.reload(); } catch (x) { t.disabled = false; showErr(msgBox, x); } }); @@ -861,7 +865,7 @@ pages.bnetverify = async () => { const b = BRAND.bnet, logo = ``; const STEPS = [["connect", "Connecting to Battle.net"], ["fetch", "Fetching your characters"], ["match", "Matching /played data"], ["sig", "Checking signatures"]]; el.innerHTML = `
${logo}🔒

Verify WoW characters

-

Fraglink asks Battle.net which characters are yours, then checks each one against the signed /played data from the Fraglink addon. Only verified characters count on leaderboards.

+

Checks which characters are on the Battle.net account you linked.

    ${STEPS.map(([k, t]) => `
  1. ${t}
  2. `).join("")}
`; const step = (k, state, note) => { const li = $(`[data-s="${k}"]`, el); li.className = state; if (note != null) $("small", li).textContent = note; }; @@ -900,7 +904,7 @@ pages.bnetverify = async () => { res.innerHTML = `

Results

    ${me_.characters.map(c => `
  • ${c.verified ? "✓ Verified" : "◆ Not verified, not counted"} ${esc(c.name)} ${esc(c.realm)} · ${esc(c.region.toUpperCase())}${c.game_id === "wow_forever" ? " · Forever" : ""} · ${Math.floor(c.played_min / 60)}h
  • `).join("") || `
  • No characters uploaded yet.
  • `} ${s.refused.map(r => `
  • ✕ Refused ${esc(r.character)} ${esc(R[r.reason] || r.reason)}
  • `).join("")}
-

Only characters Battle.net confirms are on your account count toward your hours. A character verifies when Battle.net lists it on the same account as the BattleTag saved by the addon.

`; +

Verified means the character is on your linked Battle.net account.

`; act.innerHTML = `Done`; }; @@ -1301,11 +1305,12 @@ pages.profile = async () => { const col = WOW.color(c.class), cls = WOW.cls(c.class); const tick = c.verified ? `✓` : ""; const guild = c.show_guild && c.guild ? `
<${esc(c.guild)}>
` : ""; + const realm = c.realm ? `
${esc(c.realm)}
` : ""; const ini = esc(String(c.name || "?").slice(0, 2)); const lvl = Number(c.level) > 0 ? `Level ${c.level}` : ""; - const line = [lvl && `${esc(lvl)}`, cls && esc(cls)].filter(Boolean).join(" "); + const line = [lvl && `${esc(lvl)}`, cls && `${esc(cls)}`].filter(Boolean).join(" "); const open = poppable(c); - return `
${ini}
${esc(c.name)}${tick}${line ? `
${line}
` : ""}${guild}
`; + return `
${ini}
${esc(c.name)}${tick}${line ? `
${line}
` : ""}${guild}${realm}
`; }; const wireWowPop = () => { if (root._wowpop) return; @@ -1323,18 +1328,29 @@ pages.profile = async () => { go(n); }); }; - const wowShow = p.wow_show === "wow" || p.wow_show === "wow_forever" ? p.wow_show : "both"; + const wowShow = p.wow_show === "wow" || p.wow_show === "wow_forever" || p.wow_show === "none" ? p.wow_show : "both"; const wowRows = flav => (p.wow_characters || []).filter(c => !c.hidden && (c.game_id === "wow_forever" ? "wow_forever" : "wow") === flav); - const wowOpen = wowShow === "both" ? (p.wow_default === "wow_forever" ? "wow_forever" : "wow") : wowShow; + const bothSides = wowRows("wow").length > 0 && wowRows("wow_forever").length > 0; + const wowToggle = typeof p.wow_toggle === "boolean" ? p.wow_toggle : wowShow === "both" && bothSides; + const wowOpen = p.wow_open === "wow" || p.wow_open === "wow_forever" || p.wow_open === "none" ? p.wow_open + : wowShow === "none" ? "none" + : wowShow === "wow" || wowShow === "wow_forever" ? wowShow + : bothSides ? (p.wow_default === "wow_forever" ? "wow_forever" : "wow") + : wowRows("wow_forever").length && !wowRows("wow").length ? "wow_forever" : "wow"; + const manage = p.relation === "self" ? `Manage` : ""; if (wowAll) { document.title = "WoW characters · " + (p.display_name || p.handle); window.flCrumb([[p.display_name || p.handle, "/u/" + encodeURIComponent(p.handle)], ["WoW characters"]]); + if (wowShow === "none" || wowOpen === "none") { + root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

No WoW characters on this profile.

${manage ? `

${manage}

` : ""}
`; + return; + } const paint = flav => { const box = $("[data-wowgal]", root); if (box) box.innerHTML = wowRows(flav).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`; $$("[data-flav]", root).forEach(b => b.setAttribute("aria-pressed", b.dataset.flav === flav ? "true" : "false")); }; - const tog = wowShow === "both" ? `
` : ""; - root.innerHTML = ``; + const tog = wowToggle ? `
` : ""; + root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

${tog}
${manage ? `

${manage}

` : ""}
`; paint(wowOpen); wireWowPop(); - if (wowShow === "both") root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); + if (wowToggle) root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); return; } const pres = p.presence; @@ -1423,10 +1439,10 @@ pages.profile = async () => { // Public cards: name, guild, class, avatar, verified tick. No hours and no past names. let wowc = ""; const WC = (p.wow_characters || []).filter(c => !c.hidden); - if (WC.length) { + if (wowShow !== "none" && WC.length) { const ordered = flav => wowRows(flav).slice().sort((a, b) => (a.featured || 9) - (b.featured || 9)); - const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`}

View all

${self ? `

Hidden characters stay off this page. Manage characters

` : ""}`; }; - const tog = wowShow === "both" ? `
` : ""; + const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`}

${self ? `Manage·` : ""}View all

`; }; + const tog = wowToggle ? `
` : ""; wowc = sec("WoW characters", grid(wowOpen), tog); } @@ -1477,7 +1493,7 @@ pages.profile = async () => { wireWowPop(); root.addEventListener("click", e => { - const b = e.target.closest("[data-wflav]"); if (!b || wowShow !== "both") return; + const b = e.target.closest("[data-wflav]"); if (!b || !wowToggle) return; $$("[data-wflav]", root).forEach(x => x.setAttribute("aria-pressed", x === b ? "true" : "false")); const box = $("[data-wowcards]", root); if (!box) return; const rows = wowRows(b.dataset.wflav).slice().sort((a, c) => (a.featured || 9) - (c.featured || 9)); @@ -1622,7 +1638,7 @@ function zoneOptions(cur, term) { const auto = ``; return auto + Array.from(groups.keys()).sort().map(g => `${groups.get(g).sort((a, b) => a[1].localeCompare(b[1])).map(([z, l]) => ``).join("")}`).join(""); } -const TABS = [["account", "Account & profile"], ["rig", "Gaming rig"], ["games", "Played games"], ["privacy", "Privacy"], ["notifications", "Notifications"], ["connections", "Connections"], ["security", "Security"]]; +const BASE_TABS = [["account", "Account & profile"], ["rig", "Gaming rig"], ["games", "Played games"], ["privacy", "Privacy"], ["notifications", "Notifications"], ["connections", "Connections"], ["security", "Security"]]; pages.settings = async () => { if (!requireLogin()) return; const root = $("#settings"); @@ -1631,7 +1647,12 @@ pages.settings = async () => { let u = m.user; const keep = () => setSession(token(), Object.assign(me() || {}, { id: u.id, handle: u.handle, display_name: u.display_name, email: u.email, avatar: u.avatar, email_verified: u.email_verified, mail_live: u.mail_live, staff_role: u.staff_role || null, timezone: u.timezone || null })); keep(); renderHeader(); verifyBar(); - const tab = () => { const t = location.hash.replace("#", ""); return TABS.some(x => x[0] === t) ? t : "account"; }; + const tabs = () => { + const t = BASE_TABS.slice(); + if (u.has_wow) { const i = t.findIndex(x => x[0] === "games"); t.splice(i + 1, 0, ["wow", "World of Warcraft"]); } + return t; + }; + const tab = () => { const t = location.hash.replace("#", "").split("/")[0]; return tabs().some(x => x[0] === t) ? t : "account"; }; const row = (label, html, hint) => `
${html}${hint ? `
${hint}
` : ""}
`; const msg = ``; const ok = (el, t) => showOk($("[data-msg]", el), t), bad = (el, e) => showErr($("[data-msg]", el), e); @@ -1776,7 +1797,7 @@ pages.settings = async () => { const v = c[p] || {}, l = v.linked; if (!l && !v.off && !v.available) return ""; // not built yet: hide rather than show a "Coming soon" stub const right = l - ? `${p === "steam" && v.import ? ` ` : ""}${p === "bnet" ? `Manage WoW ` : ""}` + ? `${p === "steam" && v.import ? ` ` : ""}${p === "bnet" && u.has_wow ? `Manage WoW ` : ""}` : v.off ? `Turned off for now` : v.available ? `` : `Coming soon`; const who = l ? `${p === "steam" ? `${esc(l.display || l.uid)}` : `${esc(l.display || l.uid)}`} ${l.verified ? `✓ Verified` : `(not verified)`}${l.last_sync_at ? `
Hours imported ${fullDate.format(new Date(l.last_sync_at))}${l.sync_status === "private" ? " · Steam game details were private" : ""}
` : ""}${p === "bnet" ? (v.wow_imported_at ? `
Hours imported ${fullDate.format(new Date(v.wow_imported_at))}
` : ``) : ""}` : `${blurb}`; @@ -1786,7 +1807,7 @@ pages.settings = async () => { ${card("steam", ``, c.steam && c.steam.import ? "Verify your Steam account and import your Steam hours." : "Verify your Steam account. It shows as a badge on your profile.")} ${card("discord", ``, "Show your Discord name on your profile.")} ${card("bnet", ``, "Show your BattleTag on your profile.")} - ${c.bnet && c.bnet.needs_reconnect ? `
Reconnect Battle.net to verify characters. Manage WoW
` : ""} + ${c.bnet && c.bnet.needs_reconnect ? `
Reconnect Battle.net to verify characters. ${u.has_wow ? `Manage WoW` : ""}
` : ""}

Verified accounts show as badges on your profile. You're sent to the provider's own sign-in page; Fraglink never sees your password there.

`; if (q.get("connected")) { ok(box, `${NAMES[q.get("connected")] || "Account"} linked.`); history.replaceState(null, "", "/settings#connections"); } else if (q.get("connect_error")) { bad(box, new Error(q.get("connect_error") === "expired" ? "That link expired. Try again." : q.get("connect_error") === "cancelled" ? "Linking was cancelled." : q.get("connect_error"))); history.replaceState(null, "", "/settings#connections"); } @@ -1825,11 +1846,12 @@ pages.settings = async () => { function render() { const t = tab(); - root.innerHTML = `

Account settings

Your profile, gaming rig, privacy, notifications and linked accounts.
`; + root.innerHTML = `

Account settings

Your profile, gaming rig, privacy, notifications and linked accounts.
`; const el = $("#pane"); if (t === "account") el.innerHTML = accountTab(); else if (t === "rig") el.innerHTML = rigTab(); else if (t === "games") gamesTab(el); + else if (t === "wow") { el.dataset.embed = "1"; el.classList.add("bn"); wowManager(el); } else if (t === "privacy") el.innerHTML = privacyTab(); else if (t === "notifications") el.innerHTML = notificationsTab(); else if (t === "connections") connectionsTab(el); @@ -2054,7 +2076,7 @@ function applyFeatures() { const CRUMBS = { download: [["Download", "/download"]], forgot: [["Sign in", "/login"], ["Forgot password"]], friends: [["Friends", "/friends"]], login: [["Sign in"]], news: [["News", "/news"]], privacy: [["Privacy Policy"]], reset: [["Sign in", "/login"], ["New password"]], settings: [["Account Settings", "/settings"]], signup: [["Create an account"]], terms: [["Terms of Use"]], profile: [["Profile"]], - verify: [["Confirm your email"]], wowupload: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net", "/connect/battlenet"], ["Upload WoW /played"]], bnetverify: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net", "/connect/battlenet"], ["Verify WoW characters"]], bnet: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net"]], faq: [["FAQ", "/faq"]], changelog: [["Changelog", "/changelog"]], contact: [["Contact", "/contact"]], status: [["Status", "/status"]], media: [["Media", "/media"]], admin: [["Admin", "/admin"]], notfound: [["Page not found"]] }; + verify: [["Confirm your email"]], wowupload: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"], ["Upload /played"]], bnetverify: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"], ["Verify characters"]], bnet: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"]], faq: [["FAQ", "/faq"]], changelog: [["Changelog", "/changelog"]], contact: [["Contact", "/contact"]], status: [["Status", "/status"]], media: [["Media", "/media"]], admin: [["Admin", "/admin"]], notfound: [["Page not found"]] }; function setCrumb(parts) { const nav = document.getElementById("ycrumb"); if (!nav || !parts) return; const all = [["Home", "/"], ...parts]; @@ -2123,7 +2145,7 @@ const WOWPOP = (() => { const slot = (g, k) => { const it = g[k]; const lk = it && it.id && WH.ok, tag = lk ? "a" : "div"; return it ? `<${tag} class="cp-slot q-${esc((it.quality || "common").toLowerCase())}"${lk ? ` href="https://www.wowhead.com/item=${Number(it.id)}" data-wowhead="${esc(whp(it))}" target="_blank" rel="noopener"` : it.id ? ` data-whi="${Number(it.id)}" data-whp="${esc(whp(it))}" role="button" tabindex="0" aria-expanded="false"` : ` title="${esc(it.name)}"`}>${it.icon ? `` : ``}
${esc(it.name)}${esc(SN[k])}${it.ilvl ? " · " + it.ilvl : ""}
` : `
Empty${esc(SN[k])}
`; }; const raid = r => r.instances.map(i => `
${esc(i.name)}
${[...i.modes].sort((a, b) => DO.indexOf(a.difficulty) - DO.indexOf(b.difficulty)).map(m => `${esc(m.label)} ${m.done}/${m.total}`).join("")}
`).join(""); const body = (d, c) => { - if (d.status === "missing") return `

${esc(c.name)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Profile private or not found on Blizzard.

`; + if (d.status === "missing") return `

${esc(c.name)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Not on the Armory.

`; const g = {}; (d.gear || []).forEach(x => g[x.slot] = x); const col = CC[d.class_id] || "#eef2f6", mp = d.mplus || {}, pv = d.pvp || {}, rd = d.raids || {}, co = d.collections || {}, pr = d.professions || []; const stat = (l, v) => `
${l}${v}
`; @@ -2174,7 +2196,7 @@ const WOWPOP = (() => { const im = $(".cp-rd", box); if (im) { const t = localStorage.getItem(TK), r = await fetch(`${API}/users/${encodeURIComponent(handle)}/wow/${encodeURIComponent(c.id)}/render.png`, { headers: t ? { authorization: "Bearer " + t } : {} }); if (r.ok && box === me) { im.src = URL.createObjectURL(await r.blob()); im.onload = () => URL.revokeObjectURL(im.src); im.hidden = false; } } } - catch (x) { if (box === me) $(".cp-in", box).innerHTML = `

Couldn't load character details right now. Try again in a minute.

`; } + catch (x) { if (box === me) $(".cp-in", box).innerHTML = `

Couldn't load this character.

`; } }; return { open, close }; })(); diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index ec5136b..7a22d5e 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -352,8 +352,14 @@ dl.rig{display:grid;grid-template-columns:110px 1fr;gap:4px 10px;margin:0}dl.rig .delete-bar{background:linear-gradient(#5a1d1d,#3e1010);color:#f6c9c9} .tabs a{cursor:pointer}.tabs a:hover{text-decoration:none;color:#fff} .tabpane{border:1px solid #2d4560;border-radius:0 5px 5px 5px;background:linear-gradient(#141b24,#11161d);padding:0 0 12px} -.tabpane h3{margin:0 0 10px;display:flex;align-items:center;min-height:32px;padding:7px 12px;font-size:11.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--hd-orange);background:linear-gradient(90deg,var(--hd1) 0,var(--hd2) 150px,#141b24 190px,#12171e);border-bottom:1px solid #0b1118;box-shadow:inset 0 1px 0 #ffffff12} -.tabpane h3:not(:first-child){border-top:1px solid #24384f;margin-top:14px} +.tabpane .bn-sec{margin:0} +.tabpane .bn-sec + .bn-sec{margin-top:16px} +.tabpane .bn-view{margin-left:auto;text-transform:none;letter-spacing:0;font-size:12px;font-weight:600} +.tabpane h3,.bn h3{margin:0 0 10px;display:flex;align-items:center;min-height:32px;padding:7px 12px;font-size:11.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--hd-orange);background:linear-gradient(90deg,var(--hd1) 0,var(--hd2) 150px,#141b24 190px,#12171e);border-bottom:1px solid #0b1118;box-shadow:inset 0 1px 0 #ffffff12} +.tabpane h3:not(:first-child),.bn h3:not(:first-of-type){border-top:1px solid #24384f;margin-top:14px} +.bn-addblock{padding:10px 12px 12px} +.bn-or{margin:10px 0 0} +.bn-tabs{margin-bottom:0} .frm .alert{margin:0}.frm .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .frm input[type=checkbox]{vertical-align:middle;margin-right:6px} .presets{display:flex;flex-wrap:wrap;gap:6px}.presets button{padding:0;border:2px solid transparent;border-radius:6px;background:none;cursor:pointer}.presets button.on{border-color:#f0b060} @@ -727,6 +733,12 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .wc-hid{opacity:.5} /* /connect/wow */ +.wu-page h2{margin:12px 0 6px;font-size:18px;font-weight:650} +.wu-paths{margin:10px 0 12px} +.wu-paths p{margin:0 0 8px} +.wu-paths span{display:block;color:var(--dim);font-size:11px;font-weight:650;letter-spacing:.4px;text-transform:uppercase} +.wu-paths code{display:block;margin-top:2px;word-break:break-all;color:var(--silver)} +.wu-paths p.on code{color:#eef2f6} .wu-form{margin:6px 0 14px}.wu-row{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;padding:6px 0} .wu-l{width:48px;color:var(--dim)}.wu-help{margin:8px 0;font-size:13px}.wu-help code{word-break:break-all} .wu-tot{display:flex;gap:12px;margin:8px 0 12px}.wu-tot div{flex:1;border:1px solid rgba(255,255,255,.1);border-radius:6px;padding:10px;background:rgba(0,0,0,.15)} @@ -823,17 +835,19 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .wc-p .r{width:48px;height:48px;border-radius:4px;border:0;background:transparent;position:relative;z-index:1} .wc-ini{position:absolute;inset:0;display:grid;place-items:center;font-weight:700;color:#fff;background:color-mix(in srgb,var(--cls) 62%,#1a120c);letter-spacing:.3px} .wc-ic{position:absolute;right:-6px;bottom:-6px;display:flex;z-index:2}.wc-ic img{width:20px;height:20px;border-radius:50%;border:1px solid #000;margin-left:-4px} -.wc-t{min-width:0}.wc-t b{color:#eef2f6}.wc-cls{color:var(--cls);font-weight:700;font-size:12px}.wc-lv{font-weight:600;opacity:.8;margin-right:3px} +.wc-t{min-width:0}.wc-t b{color:#eef2f6}.wc-meta{font-size:12px;line-height:1.3}.wc-lv{color:var(--muted);font-weight:600;margin-right:4px}.wc-cl{color:var(--cls);font-weight:700}.wc-realm{color:var(--dim);font-size:11px;font-weight:400} .wc-tick{color:#8fdc79;font-weight:700;margin-left:4px} .wc .wc-h{font-size:12px;font-weight:400;margin:2px 0 0;color:var(--label)} .seg{display:inline-flex;border:1px solid #3d5168;border-radius:3px;overflow:hidden;text-transform:none;letter-spacing:0} .seg button{background:transparent;color:#c5d0db;border:0;border-right:1px solid #3d5168;padding:4px 10px;font:inherit;font-size:12px;font-weight:650;cursor:pointer} .seg button:last-child{border-right:0} .seg button[aria-pressed="true"]{background:#2c4d6b;color:#fff} -.wow-foot{display:flex;justify-content:flex-end;padding:0 14px 10px} +.wow-foot{display:flex;justify-content:flex-end;align-items:baseline;gap:6px;margin:0;padding:0 14px 8px;font-size:12px} +.wcs{padding-bottom:4px} .wow-page{max-width:980px;margin:16px auto;padding:0 16px 28px} -.wow-page .bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:8px 0 14px} -.wow-page h1{margin:8px 0 0;font-size:20px} +.wow-back{display:inline-block;margin:4px 0} +.wow-page .bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:10px 0 14px;height:auto;overflow:visible;background:none;border-radius:0} +.wow-page h1{margin:0;font-size:20px;line-height:1.2} .bn-head{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap} .bn-def{display:flex;align-items:center;gap:10px;padding:10px 14px;flex-wrap:wrap} .bn-split{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:10px 14px} From eb98188eb7697adb7f3efe0384a6a632a284cf11 Mon Sep 17 00:00:00 2001 From: Lucrona <113807308+Lucrona@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:08:47 +1100 Subject: [PATCH 2/3] Group WoW characters by server and add faction crests and upload actions --- apps/server/migrations/0060_wow_faction.sql | 3 + apps/server/src/wow.rs | 47 ++++++++++- apps/server/src/wowchar.rs | 8 +- apps/server/src/wowpage.rs | 6 +- apps/server/tests/wow_faction.rs | 92 +++++++++++++++++++++ apps/site-web/static/assets/app.js | 51 +++++++++--- apps/site-web/static/assets/site.css | 8 ++ apps/site-web/tests/wow-ui.test.cjs | 42 ++++++++++ 8 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 apps/server/migrations/0060_wow_faction.sql create mode 100644 apps/server/tests/wow_faction.rs create mode 100644 apps/site-web/tests/wow-ui.test.cjs diff --git a/apps/server/migrations/0060_wow_faction.sql b/apps/server/migrations/0060_wow_faction.sql new file mode 100644 index 0000000..5c84122 --- /dev/null +++ b/apps/server/migrations/0060_wow_faction.sql @@ -0,0 +1,3 @@ +-- Profile metadata only. No backfill: populated by normal Blizzard profile refreshes. +ALTER TABLE wow_characters ADD COLUMN faction text + CHECK (faction IN ('HORDE', 'ALLIANCE')); diff --git a/apps/server/src/wow.rs b/apps/server/src/wow.rs index 76c4cdc..34d5e5a 100644 --- a/apps/server/src/wow.rs +++ b/apps/server/src/wow.rs @@ -308,6 +308,14 @@ pub async fn add_armory_character( .await?; id }; + sqlx::query( + "UPDATE wow_characters SET faction = coalesce($2, faction) WHERE id = $1 AND user_id = $3", + ) + .bind(id) + .bind(profile_faction(profile)) + .bind(uid) + .execute(&st.db) + .await?; // Same check as a Battle.net refresh. Does not store a new hours total for this user. set_verified(st, uid, None).await; let verified: bool = @@ -585,7 +593,7 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { let _ = sqlx::query( "UPDATE wow_characters SET bnet_level = coalesce($2, bnet_level), bnet_spec = coalesce($3, bnet_spec), race = coalesce($4, race), - gender = coalesce($5, gender), bnet_checked_at = now() + gender = coalesce($5, gender), bnet_checked_at = now(), faction = coalesce($6, faction) WHERE id = $1", ) .bind(id) @@ -593,6 +601,7 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { .bind(name_of(&v["active_spec"])) .bind(name_of(&v["race"])) .bind(v["gender"]["type"].as_str().map(|g| g.to_lowercase())) + .bind(profile_faction(&v)) .execute(&st.db) .await .inspect_err(|err| tracing::warn!("forever profile: update {id}: {err}")); @@ -2018,7 +2027,7 @@ async fn check_bnet_level(st: &AppState, uid: Uuid, gid: &str, id: Uuid, e: &Cha let _ = sqlx::query( "UPDATE wow_characters SET bnet_level = $2, bnet_last_login = $3, bnet_checked_at = now(), bnet_spec = coalesce($4, bnet_spec), race = coalesce($5, race), gender = coalesce($6, gender), - bnet_id = coalesce(bnet_id, $7) + bnet_id = coalesce(bnet_id, $7), faction = coalesce($8, faction) WHERE id = $1", ) .bind(id) @@ -2028,6 +2037,7 @@ async fn check_bnet_level(st: &AppState, uid: Uuid, gid: &str, id: Uuid, e: &Cha .bind(name_of(&v["race"])) .bind(v["gender"]["type"].as_str().map(|g| g.to_lowercase())) .bind(v["id"].as_i64()) + .bind(profile_faction(&v)) .execute(&st.db) .await .inspect_err(|e| tracing::warn!("wow armory: update character {id}: {e}")); @@ -2056,6 +2066,13 @@ pub(crate) fn name_of(v: &Value) -> Option { // ------------------------------------------------------------------ profile +/// Blizzard's stable API value, not a localized faction name or a race guess. +pub fn profile_faction(profile: &Value) -> Option<&str> { + profile["faction"]["type"] + .as_str() + .filter(|f| matches!(*f, "HORDE" | "ALLIANCE")) +} + /// Characters for a profile page. `all`: the owner's own view (hidden ones too). /// Profile cards list Battle.net-verified Retail characters (`verified_only`). Forever /// characters are included too, without a tick, until `BNET_FOREVER_ACCOUNT` verifies @@ -2085,6 +2102,7 @@ pub async fn characters( show_guild: bool, guild: Option, featured: Option, + faction: Option, } let rows: Vec = sqlx::query_as( concat!("SELECT id, game_id, region, realm, name, class, ", shown_level!(), " AS level, played_s, played_at, verified, @@ -2092,7 +2110,7 @@ pub async fn characters( race, gender, coalesce(spec, bnet_spec) AS spec, show_guild, CASE WHEN show_guild THEN (SELECT d.data->'guild'->>'name' FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok') END AS guild, - featured + featured, coalesce(faction, (SELECT CASE upper(d.data->>'faction') WHEN 'HORDE' THEN 'HORDE' WHEN 'ALLIANCE' THEN 'ALLIANCE' END FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok')) AS faction FROM wow_characters WHERE user_id = $1 AND ($2 OR NOT (hidden OR coalesce((SELECT hide_all FROM wow_prefs WHERE user_id = $1), false))) AND (NOT $3 OR verified OR game_id = 'wow_forever') @@ -2110,7 +2128,7 @@ pub async fn characters( "name": c.name, "class": c.class, "level": c.level, "played_min": c.played_s / 60, "played_at": c.played_at, "verified": c.verified, "hidden": c.hid, "race": c.race, "gender": c.gender, "spec": c.spec, "show_guild": c.show_guild, "guild": c.guild, - "featured": c.featured }) + "featured": c.featured, "faction": c.faction }) }) .collect()) } @@ -2279,6 +2297,27 @@ mod tests { const NOW: i64 = 1_800_000_000; + #[test] + fn faction_uses_only_stable_blizzard_type() { + assert_eq!( + profile_faction(&json!({"faction": {"type": "HORDE", "name": "Horde"}})), + Some("HORDE") + ); + assert_eq!( + profile_faction(&json!({"faction": {"type": "ALLIANCE"}})), + Some("ALLIANCE") + ); + assert_eq!( + profile_faction(&json!({"faction": {"type": "NEUTRAL"}})), + None + ); + assert_eq!( + profile_faction(&json!({"faction": {"name": "Alliance"}})), + None + ); + assert_eq!(profile_faction(&json!({})), None); + } + #[test] fn forever_profile_url_has_no_realm() { let url = forever_profile_url("eu", "Vale", "Mocken"); diff --git a/apps/server/src/wowchar.rs b/apps/server/src/wowchar.rs index 317d203..bff68b3 100644 --- a/apps/server/src/wowchar.rs +++ b/apps/server/src/wowchar.rs @@ -409,6 +409,12 @@ pub async fn refresh(st: &AppState, id: Uuid) -> Result<(), ()> { .await .inspect_err(|e| tracing::warn!("wowchar: level for {id}: {e}")); } + let _ = sqlx::query("UPDATE wow_characters SET faction = coalesce($2, faction) WHERE id = $1") + .bind(id) + .bind(crate::wow::profile_faction(&root)) + .execute(&st.db) + .await + .inspect_err(|e| tracing::warn!("wowchar: faction for {id}: {e}")); let sub = |p: &str| format!("{base}/{p}?{ns}"); let u: Vec = [ "character-media", @@ -783,7 +789,7 @@ pub fn summarise(p: &Parts) -> Value { "name": name, "region": p.region, "realm": name_of(&r["realm"]), "realm_slug": realm_slug, "level": r["level"].as_i64(), "race": name_of(&r["race"]), "class": name_of(&r["character_class"]), "class_id": r["character_class"]["id"].as_i64(), - "spec": name_of(&r["active_spec"]), "faction": name_of(&r["faction"]), + "spec": name_of(&r["active_spec"]), "faction": crate::wow::profile_faction(r).map(str::to_string).or_else(|| name_of(&r["faction"])), "title": r["active_title"]["display_string"].as_str().map(|t| t.replace("{name}", name)), "ilvl": r["equipped_item_level"].as_i64(), "achievement_points": r["achievement_points"].as_i64(), "last_login": r["last_login_timestamp"].as_i64(), diff --git a/apps/server/src/wowpage.rs b/apps/server/src/wowpage.rs index f63ed39..882ac72 100644 --- a/apps/server/src/wowpage.rs +++ b/apps/server/src/wowpage.rs @@ -281,6 +281,7 @@ async fn list( bnet_spec: Option, show_guild: bool, verified: bool, + faction: Option, former: Option, uploaded_at: DateTime, } @@ -290,7 +291,7 @@ async fn list( WHERE n.char_id = wow_characters.id AND (lower(n.name) <> lower(wow_characters.name) OR n.realm <> wow_characters.realm) ORDER BY n.recorded_at DESC, n.id DESC LIMIT 1) AS former, - uploaded_at + uploaded_at, coalesce(faction, (SELECT CASE upper(d.data->>'faction') WHEN 'HORDE' THEN 'HORDE' WHEN 'ALLIANCE' THEN 'ALLIANCE' END FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok')) AS faction FROM wow_characters WHERE {filter} ORDER BY {order} LIMIT $7 OFFSET $8", lvl = crate::wow::shown_level!() ))) @@ -347,6 +348,7 @@ async fn list( show_guild: sg, verified, former, + faction, uploaded_at, } = c; let (former_name, former_realm) = former @@ -373,7 +375,7 @@ async fn list( "spec": spec.clone().or(bspec.clone()), "spec_picked": spec.is_some(), "active_spec": bspec, "show_guild": sg, "verified": verified, "former_name": former_name, "former_realm": former_realm, - "uploaded_at": uploaded_at }) + "uploaded_at": uploaded_at, "faction": faction }) }) .collect(); let (retail, forever): (i64, i64) = sqlx::query_as( diff --git a/apps/server/tests/wow_faction.rs b/apps/server/tests/wow_faction.rs new file mode 100644 index 0000000..35a605f --- /dev/null +++ b/apps/server/tests/wow_faction.rs @@ -0,0 +1,92 @@ +mod common; +use axum::http::StatusCode; +use common::*; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +#[sqlx::test(migrator = "fraglink_server::MIGRATOR")] +async fn faction_metadata_is_optional_and_does_not_change_hours(pool: PgPool) { + let migration = include_str!("../migrations/0060_wow_faction.sql"); + assert!(!migration.to_lowercase().contains("update ")); + assert!(!migration.to_lowercase().contains("delete ")); + let st = state(pool.clone()); + let app = fraglink_server::router(st.clone()); + let token = signup(&app, "factions@example.com", "factiontest").await; + let uid: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'factiontest'") + .fetch_one(&pool) + .await + .unwrap(); + let mut ids = Vec::new(); + for (i, faction) in [Some("HORDE"), Some("ALLIANCE"), None, None] + .iter() + .enumerate() + { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO wow_characters(id,user_id,game_id,region,realm,realm_slug,name,class,played_s,played_at,verified,faction) VALUES($1,$2,'wow','us','Area 52','area-52',$3,'MAGE',7200,to_timestamp(1700000000),true,$4)") + .bind(id).bind(uid).bind(format!("Sample{i}")).bind(faction).execute(&pool).await.unwrap(); + ids.push(id); + } + // Old cached Blizzard summaries can supply a faction without updating the character. + sqlx::query( + "INSERT INTO wow_char_details(char_id,status,data,fetched_at) VALUES($1,'ok',$2,now())", + ) + .bind(ids[3]) + .bind(json!({"faction":"Alliance"})) + .execute(&pool) + .await + .unwrap(); + let before: Vec<(Uuid, i64, chrono::DateTime)> = sqlx::query_as( + "SELECT id,played_s,played_at FROM wow_characters WHERE user_id=$1 ORDER BY id", + ) + .bind(uid) + .fetch_all(&pool) + .await + .unwrap(); + let chars = fraglink_server::wow::characters(&st, uid, true, false) + .await + .unwrap(); + for (id, expected) in ids.iter().zip([ + json!("HORDE"), + json!("ALLIANCE"), + json!(null), + json!("ALLIANCE"), + ]) { + assert_eq!( + chars.iter().find(|c| c["id"] == id.to_string()).unwrap()["faction"], + expected + ); + } + let manager = api(&app, "GET", "/api/v1/me/wow/list", Some(&token), None).await; + assert_eq!(manager.status, StatusCode::OK, "{}", manager.text); + let rows = manager.json(); + let items = rows["items"].as_array().unwrap(); + assert!(items.iter().any(|c| c["faction"] == "HORDE")); + assert!(items.iter().any(|c| c["faction"].is_null())); + // A normal profile lookup fills/changes faction; absent type keeps the last value. + for profile in [ + json!({"name":"Sample0","level":80,"faction":{"type":"ALLIANCE"}}), + json!({"name":"Sample0","level":80}), + ] { + fraglink_server::wow::add_armory_character( + &st, uid, "us", "Area 52", "Sample0", &profile, "wow", + ) + .await + .unwrap(); + } + let faction: Option = + sqlx::query_scalar("SELECT faction FROM wow_characters WHERE id=$1") + .bind(ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(faction.as_deref(), Some("ALLIANCE")); + let after: Vec<(Uuid, i64, chrono::DateTime)> = sqlx::query_as( + "SELECT id,played_s,played_at FROM wow_characters WHERE user_id=$1 ORDER BY id", + ) + .bind(uid) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(before, after); +} diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index 3048dd0..564fdfc 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -607,6 +607,33 @@ pages.wowupload = async () => { /* ---------- /connect/battlenet/verify: the real verification steps, shown as they run ---------- */ /* ---------- WoW icons (Blizzard render CDN through /api/v1/wow/icons, cached by the server) ---------- */ +// Faction comes from Blizzard, never from a character's race or class. +const factionCrest = value => { + const faction = String(value || "").toUpperCase(); + if (faction !== "HORDE" && faction !== "ALLIANCE") return ""; + const name = faction === "HORDE" ? "Horde" : "Alliance"; + const path = faction === "HORDE" + ? '' + : ''; + return `${name}${path}`; +}; +const groupWowRealms = rows => { + const groups = new Map(); + for (const c of rows) { + const realm = (c.realm || "").trim() || "Unknown server", region = (c.region || "").toUpperCase(); + const key = realm.toLowerCase() + "|" + region; + if (!groups.has(key)) groups.set(key, { realm, region, rows: [] }); + groups.get(key).rows.push(c); + } + const realms = [...groups.values()]; + const regions = new Map(); + for (const g of realms) { + const key = g.realm.toLowerCase(); + if (!regions.has(key)) regions.set(key, new Set()); + regions.get(key).add(g.region); + } + return realms.sort((a, b) => b.rows.length - a.rows.length || a.realm.localeCompare(b.realm, "en", { sensitivity: "base" }) || a.region.localeCompare(b.region)).map(g => ({ ...g, showRegion: regions.get(g.realm.toLowerCase()).size > 1 })); +}; const WOW = (() => { const S = { WARRIOR: [["Arms", "ability_warrior_savageblow"], ["Fury", "ability_warrior_innerrage"], ["Protection", "ability_warrior_defensivestance"]], PALADIN: [["Holy", "spell_holy_holybolt"], ["Protection", "ability_paladin_shieldofthetemplar"], ["Retribution", "spell_holy_auraoflight"]], @@ -672,7 +699,7 @@ async function wowManager(el) { ${flavSeg}${s.linked ? `
` : ""}
<${Hx}>Add characters -
+
<${Hx}>Your characters
@@ -695,7 +722,7 @@ async function wowManager(el) { const cls = [Number(c.level) > 0 ? "Level " + c.level : "", WOW.cls(c.class)].filter(Boolean).join(" "); const imp = c.played_min > 0 && c.uploaded_at ? ago(c.uploaded_at) : "—"; return `
-
${esc(c.name)}${c.verified ? `✓` : ""}${was ? `
formerly ${esc(was)}
` : ""}
+
${esc(c.name)}${factionCrest(c.faction)}${c.verified ? `✓` : ""}${was ? `
formerly ${esc(was)}
` : ""}
${c.realm ? esc(c.realm) + " " : ""}${esc((c.region || "").toUpperCase())}
${esc(cls || "—")}
${hours(c.played_min, { zero: true })}
@@ -710,7 +737,7 @@ async function wowManager(el) { const d = st.data, label = { wow: "Retail", wow_forever: "Forever" }; $("[data-tabs]", el).innerHTML = ["wow", "wow_forever"].map(id => ``).join(""); $("[data-addbox]", el).innerHTML = addForm(st.game); - const up = $("[data-upload]", el); if (up) up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail"); + const up = $("[data-upload]", el); if (up) { up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail"); up.textContent = "Upload " + (st.game === "wow_forever" ? "Forever" : "Retail") + " /played"; } const filtered = d && d.matched === 0 && (st.q || st.class || st.vis); list.innerHTML = st.items.length ? st.items.map(row).join("") : `

${filtered ? "No characters match." : "No characters yet. Add one above, or upload a /played file."}

`; @@ -1301,16 +1328,16 @@ pages.profile = async () => { // Gear, Mythic+, raids and collections open only for a verified Retail character, and // only while Admin has "Show WoW character details" on. Hidden and Forever cards stay plain. const poppable = c => FEATURES.wow_characters && c.verified && c.game_id === "wow" && !c.hidden; - const wowCard = c => { + const wowCard = (c, showRealm = true) => { const col = WOW.color(c.class), cls = WOW.cls(c.class); const tick = c.verified ? `✓` : ""; const guild = c.show_guild && c.guild ? `
<${esc(c.guild)}>
` : ""; - const realm = c.realm ? `
${esc(c.realm)}
` : ""; + const realm = showRealm && c.realm ? `
${esc(c.realm)}
` : ""; const ini = esc(String(c.name || "?").slice(0, 2)); const lvl = Number(c.level) > 0 ? `Level ${c.level}` : ""; const line = [lvl && `${esc(lvl)}`, cls && `${esc(cls)}`].filter(Boolean).join(" "); const open = poppable(c); - return `
${ini}
${esc(c.name)}${tick}${line ? `
${line}
` : ""}${guild}${realm}
`; + return `
${ini}
${esc(c.name)}${factionCrest(c.faction)}${tick}${line ? `
${line}
` : ""}${guild}${realm}
`; }; const wireWowPop = () => { if (root._wowpop) return; @@ -1345,9 +1372,9 @@ pages.profile = async () => { root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

No WoW characters on this profile.

${manage ? `

${manage}

` : ""}
`; return; } - const paint = flav => { const box = $("[data-wowgal]", root); if (box) box.innerHTML = wowRows(flav).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`; $$("[data-flav]", root).forEach(b => b.setAttribute("aria-pressed", b.dataset.flav === flav ? "true" : "false")); }; + const paint = flav => { const box = $("[data-wowgal]", root); if (box) box.innerHTML = groupWowRealms(wowRows(flav)).map(g => `

${esc(g.realm)}${g.showRegion ? ` ${esc(g.region)}` : ""}${g.rows.length}

${g.rows.map(c => wowCard(c, false)).join("")}
`).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`; $$("[data-flav]", root).forEach(b => b.setAttribute("aria-pressed", b.dataset.flav === flav ? "true" : "false")); }; const tog = wowToggle ? `
` : ""; - root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

${tog}
${manage ? `

${manage}

` : ""}
`; + root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

${tog}
${manage ? `

${manage}

` : ""}
`; paint(wowOpen); wireWowPop(); if (wowToggle) root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); @@ -1441,7 +1468,7 @@ pages.profile = async () => { const WC = (p.wow_characters || []).filter(c => !c.hidden); if (wowShow !== "none" && WC.length) { const ordered = flav => wowRows(flav).slice().sort((a, b) => (a.featured || 9) - (b.featured || 9)); - const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`}

${self ? `Manage·` : ""}View all

`; }; + const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(c => wowCard(c)).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`}

${self ? `Manage·` : ""}View all

`; }; const tog = wowToggle ? `
` : ""; wowc = sec("WoW characters", grid(wowOpen), tog); } @@ -1497,7 +1524,7 @@ pages.profile = async () => { $$("[data-wflav]", root).forEach(x => x.setAttribute("aria-pressed", x === b ? "true" : "false")); const box = $("[data-wowcards]", root); if (!box) return; const rows = wowRows(b.dataset.wflav).slice().sort((a, c) => (a.featured || 9) - (c.featured || 9)); - box.innerHTML = rows.slice(0, 6).map(wowCard).join("") || `

No ${b.dataset.wflav === "wow_forever" ? "Forever" : "Retail"} characters.

`; + box.innerHTML = rows.slice(0, 6).map(c => wowCard(c)).join("") || `

No ${b.dataset.wflav === "wow_forever" ? "Forever" : "Retail"} characters.

`; }); root.innerHTML = `
` + head + pendingDelete + welcome + `
${details}${games}${wowc}${ach}${miniprofile}
`; $$('a[href="#friends"]', root).forEach(a => a.addEventListener("click", e => { const f = $("#friends", root); if (!f) return; e.preventDefault(); f.scrollIntoView({ behavior: "smooth", block: "center" }); f.classList.add("flash"); setTimeout(() => f.classList.remove("flash"), 1200); })); @@ -2145,7 +2172,7 @@ const WOWPOP = (() => { const slot = (g, k) => { const it = g[k]; const lk = it && it.id && WH.ok, tag = lk ? "a" : "div"; return it ? `<${tag} class="cp-slot q-${esc((it.quality || "common").toLowerCase())}"${lk ? ` href="https://www.wowhead.com/item=${Number(it.id)}" data-wowhead="${esc(whp(it))}" target="_blank" rel="noopener"` : it.id ? ` data-whi="${Number(it.id)}" data-whp="${esc(whp(it))}" role="button" tabindex="0" aria-expanded="false"` : ` title="${esc(it.name)}"`}>${it.icon ? `` : ``}
${esc(it.name)}${esc(SN[k])}${it.ilvl ? " · " + it.ilvl : ""}
` : `
Empty${esc(SN[k])}
`; }; const raid = r => r.instances.map(i => `
${esc(i.name)}
${[...i.modes].sort((a, b) => DO.indexOf(a.difficulty) - DO.indexOf(b.difficulty)).map(m => `${esc(m.label)} ${m.done}/${m.total}`).join("")}
`).join(""); const body = (d, c) => { - if (d.status === "missing") return `

${esc(c.name)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Not on the Armory.

`; + if (d.status === "missing") return `

${esc(c.name)}${factionCrest(c.faction)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Not on the Armory.

`; const g = {}; (d.gear || []).forEach(x => g[x.slot] = x); const col = CC[d.class_id] || "#eef2f6", mp = d.mplus || {}, pv = d.pvp || {}, rd = d.raids || {}, co = d.collections || {}, pr = d.professions || []; const stat = (l, v) => `
${l}${v}
`; @@ -2153,7 +2180,7 @@ const WOWPOP = (() => { return `
${d.render ? `` : ""}
${d.title ? `
${esc(d.title.replace("{name}", d.name))}
` : ""}

${esc(d.name)}

Level ${d.level} ${esc([d.race, d.spec, d.class].filter(Boolean).join(" "))}
-
${esc(d.realm)} ${esc(d.region.toUpperCase())}${d.faction ? ` · ${esc(d.faction)}` : ""}
+
${esc(d.realm)} ${esc(d.region.toUpperCase())}${factionCrest(d.faction || c.faction)}
${d.guild ? `
<${esc(d.guild.name)}>${d.guild.rank != null ? ` · ${d.guild.rank === 0 ? "Guild Master" : "Rank " + d.guild.rank}` : ""}
` : ""}
${stat("Item level", d.ilvl ? nf.format(d.ilvl) : "—")}${stat("Achievements", d.achievement_points ? nf.format(d.achievement_points) : "—")}${stat("M+ rating", mp.rating ? nf.format(Math.round(mp.rating)) : "—")}

Gear

${d.gear && d.gear.length ? `
${L.map(k => slot(g, k)).join("")}
${R.map(k => slot(g, k)).join("")}
${W.map(k => slot(g, k)).join("")}
` : `

No gear equipped.

`}
diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index 7a22d5e..117d238 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -1281,3 +1281,11 @@ tr.hc-flag td{background:#1d1010} .hl .k{font-size:10px;letter-spacing:.8px;text-transform:uppercase;color:var(--label);font-weight:700} .hl .v{color:#fff;font-weight:700;font-size:15px;margin-top:3px}.hl .d{font-size:11px;color:#8ea2b8;margin-top:2px;overflow:hidden;text-overflow:ellipsis} @media (max-width:1100px){.hc-3,.hl-row{grid-template-columns:1fr 1fr}} + +/* WoW upload alternative and server groups. */ +.bn-upload{border-top:1px solid var(--line);margin-top:18px;padding-top:16px;display:flex;align-items:center;justify-content:space-between;gap:16px} +.bn-upload p{margin:4px 0 0}.bn-upload-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.bn-upload-actions>a.sub{font-size:12px} +.faction-crest{display:inline-block;width:16px;height:18px;vertical-align:-3px;margin-left:6px;flex-shrink:0;fill:currentColor} +.faction-crest.horde{color:#bc6663}.faction-crest.alliance{color:#b9a879} +.wow-realms{display:grid;gap:28px}.wow-realm{min-width:0}.wow-realm h2{font-size:16px;margin:0 0 12px;font-weight:600}.wow-realm-count{font-size:12px;font-weight:400;color:var(--dim);margin-left:10px} +@media(max-width:600px){.bn-upload{align-items:flex-start;flex-direction:column}.bn-upload-actions{gap:12px}.wow-realm h2{font-size:15px}} diff --git a/apps/site-web/tests/wow-ui.test.cjs b/apps/site-web/tests/wow-ui.test.cjs new file mode 100644 index 0000000..ada6d04 --- /dev/null +++ b/apps/site-web/tests/wow-ui.test.cjs @@ -0,0 +1,42 @@ +// No browser or network needed: exercise the actual shared render/group helpers. +const { readFileSync } = require('node:fs'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const source = readFileSync(__dirname + '/../static/assets/app.js', 'utf8'); +const helpers = source.slice(source.indexOf('const factionCrest ='), source.indexOf('const WOW =')); +const { factionCrest, groupWowRealms } = vm.runInNewContext(helpers + '; ({ factionCrest, groupWowRealms })'); + +test('servers sorted by count then alphabetically, same-name regions distinguished', () => { + const rows = [ + {realm:'Zuljin',region:'us',name:'a'}, {realm:'Zuljin',region:'us',name:'b'}, + {realm:'Area 52',region:'us',name:'c'}, {realm:'Area 52',region:'eu',name:'d'}, + {realm:'Frostmourne',region:'us',name:'e'}, + ]; + const before = JSON.stringify(rows); + const groups = groupWowRealms(rows); + assert.equal(JSON.stringify(groups.map(g => [g.realm, g.region, g.rows.length, g.showRegion])), JSON.stringify([ + ['Zuljin','US',2,false],['Area 52','EU',1,true],['Area 52','US',1,true],['Frostmourne','US',1,false], + ])); + assert.equal(JSON.stringify(rows),before); + assert.equal(groupWowRealms([]).length,0); +}); +test('case variants group into the same server', () => { + const groups = groupWowRealms([{realm:'Area 52',region:'us'},{realm:'area 52',region:'US'}]); + assert.equal(groups.length,1); assert.equal(groups[0].rows.length,2); +}); +test('crests are accessible inline SVG; unknown factions render nothing', () => { + for (const name of ['Horde','Alliance']) { + assert.match(factionCrest(name), /']) assert.equal(factionCrest(value),''); +}); +test('gallery omits realm on cards; profile retains it; upload follows active tab', () => { + assert.ok(source.includes('g.rows.map(c => wowCard(c, false))')); + assert.ok(source.includes('const wowCard = (c, showRealm = true)')); + assert.ok(source.includes('const realm = showRealm && c.realm')); + assert.ok(source.includes('up.textContent = "Upload " + (st.game === "wow_forever" ? "Forever" : "Retail") + " /played"')); + assert.ok(source.includes('up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail")')); +}); From 3081860024bb6b6c1cc9c28709a839d0fa97619a Mon Sep 17 00:00:00 2001 From: Lucrona <113807308+Lucrona@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:46:34 +1100 Subject: [PATCH 3/3] Use authentic Blizzard faction artwork for WoW crests --- apps/site-web/static/assets/app.js | 6 ++-- apps/site-web/static/assets/site.css | 3 +- .../static/assets/wow-factions/README.md | 28 ++++++++++++++++++ .../assets/wow-factions/alliance-128.webp | Bin 0 -> 20396 bytes .../assets/wow-factions/alliance-64.webp | Bin 0 -> 6886 bytes .../static/assets/wow-factions/horde-128.webp | Bin 0 -> 9876 bytes .../static/assets/wow-factions/horde-64.webp | Bin 0 -> 3962 bytes apps/site-web/tests/wow-ui.test.cjs | 16 +++++++--- 8 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 apps/site-web/static/assets/wow-factions/README.md create mode 100644 apps/site-web/static/assets/wow-factions/alliance-128.webp create mode 100644 apps/site-web/static/assets/wow-factions/alliance-64.webp create mode 100644 apps/site-web/static/assets/wow-factions/horde-128.webp create mode 100644 apps/site-web/static/assets/wow-factions/horde-64.webp diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index 564fdfc..65f8c6c 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -612,10 +612,8 @@ const factionCrest = value => { const faction = String(value || "").toUpperCase(); if (faction !== "HORDE" && faction !== "ALLIANCE") return ""; const name = faction === "HORDE" ? "Horde" : "Alliance"; - const path = faction === "HORDE" - ? '' - : ''; - return `${name}${path}`; + const asset = `/assets/wow-factions/${faction.toLowerCase()}`; + return `${name}`; }; const groupWowRealms = rows => { const groups = new Map(); diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index 117d238..5901a12 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -1285,7 +1285,6 @@ tr.hc-flag td{background:#1d1010} /* WoW upload alternative and server groups. */ .bn-upload{border-top:1px solid var(--line);margin-top:18px;padding-top:16px;display:flex;align-items:center;justify-content:space-between;gap:16px} .bn-upload p{margin:4px 0 0}.bn-upload-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.bn-upload-actions>a.sub{font-size:12px} -.faction-crest{display:inline-block;width:16px;height:18px;vertical-align:-3px;margin-left:6px;flex-shrink:0;fill:currentColor} -.faction-crest.horde{color:#bc6663}.faction-crest.alliance{color:#b9a879} +.faction-crest,.bn-row img.faction-crest{display:inline-block;width:16px;height:18px;object-fit:contain;vertical-align:-3px;margin-left:6px;flex-shrink:0;border:0;border-radius:0;background:none;box-shadow:none;filter:none} .wow-realms{display:grid;gap:28px}.wow-realm{min-width:0}.wow-realm h2{font-size:16px;margin:0 0 12px;font-weight:600}.wow-realm-count{font-size:12px;font-weight:400;color:var(--dim);margin-left:10px} @media(max-width:600px){.bn-upload{align-items:flex-start;flex-direction:column}.bn-upload-actions{gap:12px}.wow-realm h2{font-size:15px}} diff --git a/apps/site-web/static/assets/wow-factions/README.md b/apps/site-web/static/assets/wow-factions/README.md new file mode 100644 index 0000000..4696ea9 --- /dev/null +++ b/apps/site-web/static/assets/wow-factions/README.md @@ -0,0 +1,28 @@ +# World of Warcraft faction emblems + +Blizzard's original Horde symbol and Alliance lion, not traced/recreated icons. +The high-resolution transparent sources were downloaded from Warcraft Wiki on +10 November 2026. The file pages credit Blizzard's **World of Warcraft 5 Year +Anniversary: BattleCry** PSD artwork. + +| Asset | Source page | Original image | +| --- | --- | --- | +| `horde-{64,128}.webp` | https://warcraft.wiki.gg/wiki/File:HordeLogo.png | https://warcraft.wiki.gg/images/HordeLogo.png | +| `alliance-{64,128}.webp` | https://warcraft.wiki.gg/wiki/File:AllianceLogo.png | https://warcraft.wiki.gg/images/AllianceLogo.png | + +Original Blizzard campaign (archived): +https://web.archive.org/web/20100207205430/http://www.worldofwarcraft.com/wowanniversary/battlecry/ + +Copyright © Blizzard Entertainment, Inc. Warcraft and World of Warcraft are +Blizzard trademarks. These third-party game identifiers are not covered by +Fraglink's repository licence. The source pages describe Blizzard's limited +personal/noncommercial-use permission and the wiki's fair-use rationale; they +are not a general open-content or commercial-use licence. Their use here identifies +characters' in-game factions and does not imply Blizzard endorsement. + +Processing: cropped transparent margins, aspect-preserving Lanczos downsampling +to fit 64×64 and 128×128, then lossless WebP compression with alpha preserved and +metadata removed. No recolouring, glow, shadow or other effects added. Sources +are raster, so no invented SVG tracing is substituted. Display is 16×18 CSS pixels +with `object-fit: contain`; assets are served locally, never hotlinked. Unknown +factions render no image. diff --git a/apps/site-web/static/assets/wow-factions/alliance-128.webp b/apps/site-web/static/assets/wow-factions/alliance-128.webp new file mode 100644 index 0000000000000000000000000000000000000000..7fb604aeceb793bdcb367dde6609c8bc3232c748 GIT binary patch literal 20396 zcmV(!K;^$uNk&GbPXGW{MM6+kP&iDOPXGWfW56E}0f&v`#trw5GD*qqzu>$jMD%|G z_-tp`$sgmR{|1D=J48vmev=2D0rAj)4LAS<0~Zg80w5!}z%aVHrHcfJAO>J@^qOqI zM5OJinz>TQCZVp(jG6s%u1svJ7j<>VsLmPbbRKZcQ3Iu!v4>2!p9b4~rAj&Ds>-aI z)9dlx-P!FJtyNVii6d$6{XA0KdXu)!L7CaSC|*{TO>4YMYpoBElGf^Woug%tLb4B2 zV&y$mGYyTD(lAq*OOkA_hi>Vdd$d+nVkP$TJV!#oEooKNK^%8K&pAP4gr&9m=xIqW zqXa`%d+xB`!5h3zZomXgHEju!RNHPO=}_ML{V%77zDK@%i9m=)5(trqMAEBm%ha4s2lyC$zi;dd9eHqv zy7&Lb-B#tL>C)SsCHCbkZ0e3@vMbJms1DaAx#j~0k2Qj_}FSN9v_|9w_QUK^Yg zsXmCCq48>pP1D_;mCAsF%M+gydPp$^VUJFxks`NnSZpOKk1CL|1 zuBs)LFpML*Rb%93@3Wr;Toe~Y#b~2#ZLia2*tHwnq>(a8OUX^CBp+ZH0}K?(B=rLX zb9`wh^kxWR0fk!4MdGB$%jGtgmmX`Rpj7E8cDN))jQefV=_4aIRvOLFYekNVQ0sBg z;ien<5=|~$E>bY)>zbyGVqozeWznw583F46nAcET2yVROsf)$KH>{^$- zH3$4P5(1x?TMT=E0p1P*m*HRW3BgI92?Ajj2n75`>^O^dZ8+h?f5ByB=XYbn`GNEQ z0?3UK!U@FRh=D=dHi8!OXMMZ-hY%4HFwf>+`zIeC`ZBN1ZZ_A}UX z%s4j|=P`s3V$5nS9hgV79`g);4JyosnL(I8t^dux#=O#vZCkbF>}F(nhuU4g2Pv{`)m4X?nHdFS z$`;-%UY^HDhz0lzaI|ewq*T>kwYa;xyE|m=u@kz)dj{MxB2GZYJ>0nfM1;Fj6JY5A zAVt!Yh{&q$_1*vf7jgS`W~wp*`4fRP_T5%;vBdhcGRXH5t;;@K3QM%l>xm}r&}eb0 z+X_}_pI8^XPxuPMp^@+5YbV?ac~5dA$*!$r_xWhpVg~*j#9nK7rzr?<8#j^)kes+P z*Z%*D_q`ok0wVQa|95$h>9_jBqzINuLL7|Ifee|L?p%p3m1SsT*v?n$GTf zw%@jG+qU1++xGk2*|u$Vs?DiPn=Z-g`FKo#ft9%n+3^YTZj-Nlw8K4q;?Rq4J>lRU z1giHWIN5doGfS>>aCcS`J)rtc2DiWoOqB`Nc5Cn1x5ph%Z@tcYuGY1{xCtPtaEMy) z36Z=OB!eqK+Uzv~6k-SxXs7X)-rRQZsO^EhP42pS-M7I5g|W?T=(s*oJq8VXU2e)( znuxXkpZA*9#;!_|BxyfF-41nSDD;QGr~@0=X$ksRId`}Y?le=Y$?f9#hfNs`?5 zN4~24jLtJN+8?c^n?G7*4IQ`1BKb)(^Q=h;Z-=wee+F;;p?BMU&{MnT4?A{s(>;$p z`@K8W-~ZUQxvw0PBp^il$@lC^_WtvCf6boyOxb~(yt5q2&3<-2eEFZfnT3RC`!~9h zB)R+ihfbPbed$$?9=rF8hi|{R`>wmMHtw-+v+bJk;_Dgi;>ms!qqxuMw4xz(){kAj z`i=k3(|_CaA6|H2@8?H%*#De+O}@0?PE#-c`A&oHb}XPs4*pLcH|>z8Us}FHv@f^% z*?rzC9GiK`(Jhbe?*eEix8rBdyzgm;?R&w;+Xe;?r66U9ASz}Dfhf<@9y{PT-Uvs-V z@0HyT?3)8iM|X&ywO{;{-8q(h!&7z(M>}w^qa5tcA>Y>DGp};bKFN&(tH;hf4ldqG z|M-}M#KfjXyDiX)FSL9X!z?ZYB?c(*FN%}-M~L>Hpl+R|PLhmxaN?%@0Gpe(Ah%|&AxUo&y#z04M#i6!A>0Pz}_Bmu#4Q}dAje+)0@%5Chyty z2M-S?I;cM2Fx@&(B}x2!og_aH@zI%Tnw=8<78YdwcbWrj=TKYEwC30n(nq44Eu_dp zJZQV2Y_h~J%GA#h$?J?Xt=W%v+qG@(CtX- zaPyzsYx~)~-Q{3M-P>FC_LIGR+;!)$?FUS|M7Q>g1DF5>t)X%suE6?v@4WfjO_J*m?|J88B7z{FFgs0o_U&2! zihO0$L8j|Cc5fLivsi@#8@8UN){mUYZe}_l5iqeanaaNVpB_4B_w!2*+wtNJcN%}? zv=+P&XD=D!?of6AX9S3c%{-VB@2Wq`1ZY8_PI zB;qTUo^O`BzM@LZ8LY(IV#cydPH*1yMw3^EZ_2ad5(DK9(6!xBKQ`yiwG9_T7 zNbrS}23)%4jbW8r?*kBrh7Aem0s$e4k8>m{hEVDPj*#3uewlwf=G1(acXD|OpowE3 zr{IA{oA5&-fcKN*?{%yHx%utno%&|fp+iJ9Gl^Uv5yfz+kPs!vU3TSA4z+vs4#%>i z?D;M+01JR}(tio2APm-b4l?Sv!3U8bQ{cxpi=oi7?VT-&sKi8BB6Oi}OoWIl(FB!T z?R_ym`^IdMO1x<63CP;*y@Z|fBKl1DUbFysKNvPDu>v$elKQO*j;P#W% zk?9js6=1OAaK9&?(%IUo-Q3k~w#E_M@@H@CRld&qC3_zN1%wy^BoQc9hz&Fe6c!o= z4Fp(NP1MzRwSDWxS#{CsM6yt&Yu+tSKb z(PgVzEt}cXPi-IkE;iL?Ee+IF zn?*}f%3|D0_Wlo*o(t2>!zM|p2od)$dx@%uk2A5F)QVCK(o^XS|G?(D2g~zRQh(> zx80fX-V8LP{D`h)6@f!AeNV#!Vbd01Q3?XJYJJVxXX=(PXFZ`DmeKPBs zdmf}abqq296L8$+Il2B1KIM(S?f38~s32dFk6;8!x=iGQbQJMoPsEemeV*xU#*(Ud zHOl*oxuvxFCiByZTq2dwapo=5uk>V*SU zE?N|845UzWQ@`&3!T_uwWRi04Vkh2LKtR5kJd3maKwuMsDGUd949jo5({uh>22N#+ zjb>oEg^(GL0f9@E8^F!~_+Z+(8iw|5Hq9!9yhAcC&~J)tS{v+Z(2pJ@u( zI=z1|iejPQz)=Z6?md~(3X{c76v*Vz+UWv<3*b*q{E%5Rwksa?$6fE4#9@zVrs*P} z3dD#EwBgu<$ywX~dx|S?0}Q}buu!@|{>thf=iaQAaJ<@feHUKqWE6}IiE>^-h36K7 z&VBH{nxOFv#S|rx z_ADe|1%Sfo*)k5m6b*5BAO6OcrixN2of#RDftU{AFO3jJv0{NRt($aQs_8g_y*i5iUI~f&n|-2$spzD9kshgfuthnBx@{cUl^EsyBF+C3aH<6!Du z^nv)ycF#QdPy1t3tw+i8AvHk2EH3l~3P8q6iB&=RqjQB^(b70c5}O&+FkTchAT1`FExxz6b&u6wf|>nOYJ%D@|HqcfbfD;K&!{1^=)`-zlwv zf=~{`-e`q^O&Y7sj0_fM9I5le1{*@3;9&s0E1j27(7>w*O++so`zr49`^AfnKa*<9 zy=2oA-L$O$QUEzaqOx=(=ci?p8=OQTa_vN=`y6s!Ob@HxveAL%&Jo^4u zze)1?!*;1B=F(0)=MaKZ?p5SF8ZaTCEkS^hkY0&E{*`{e7@n@WmjlBSNeo3^-3v zEljTTzP|CW|Fw0u|5Ai&&61Za_KAM03wOwI0ob( zUYI_)zU;xY@57VWi+N@!-=DUm*PoxFBN)o!Mc<1X^I3$e@tIQA=>hBg4hh$@G-qEYPntYz*zkkSr*b9bu>l9*uSchrr61hmCDKExwrlc(?uQL%I60Og#kJ#v4_pv1aVf z& z0~xs~LanpYKL#1kCVwY6Ooqvkd8T`miXb`;NHa-^bwGs#h=j->Mr2ruzXW1}nWTC` z{{yyO<(%(VcwDxYcnBSPzfYQe;9NiQK%r&AP(^v#5XmkXn0c8fgD^>ZguX%ARZCMo zUZ=b`qy^94GX3otFHhIP5Y5>i9FN)jo!ejUabUatvG-g%^y_vv1JpWDC2fa3u1dB@ zKlIyIUw_RGtCdTml>-QyXXB@I^K|5btN(sv_oDXeF+f7Nkgc-HzqG`nJc2{{U`tXG zt|t$OqOqC)TPp)>ijYZ40Qv4F2`iGJ38|u5@wUumKryz8u!gmhVBH#_%6}k&+FK+} zd>6gZ+A!y#0{0dmHGQIk)HbcsH8Ut%5;-s^6lS8r5uOsAZi?gkEuMQTN~$}ibL!oK z3rM}Q(2YCZaPiIalVtYnYR|T}Z!f*~<2%>vZr+r%e-ky#{ZURX^5paH-sopw)`KZVvn1w-OxWsV9*U7vi#SIcJQ46a^MGCJRCUDG7 zkP09_b9ZsEq63jD|LYHE`MR+1^~a*+(T|+@kZkllEBWrMe?C)sN5|ED?sp?lCPVMz z1dREhsU&CrxO(vwk~?APO(YhYj%3dy1COwZ=E-@Z*^TeCvmdUj*$T)C5zS8Xw(iXDlZ#ACcUS>276=d^6H}y&V4;9`D%l!jVYFeX!C)m=d;(AWh3})<*&l1A`R^O|T|!Bx z4`3N=5X}@A8OO-zT2T-721Jl#$cqw@J`*%CP2i;xsC+5L(%;1ZdUeDCkzm&mTU#?q z!(Jm8kXcqlqqku!N;OF1%-e4G;m?-c0&@u9tej-Ua>mTSR5G48zUR^9Q|VGn0mTyH zR-l}?G~Z(S91I!@S+DEaF(01wz9@XT@~#wUb?_ERXtm7C<3vZq66wRkF!}<{6TrZd z1xAJkRLiGx%Tf)HlK5$d62T<@9LFLu$qm5}=pv!ikczDV5}qU=h`%-cL`;kX-a0loFnXi^@!52OXU~_!ou6@4rv%)g60r`!DIdbS7 z`IUU*6{=N+3k)HSRf!!9Kkm!bQI&`v>4VaF0T(bQwo4}2x01aY2u_%&4SrNhr?nD{ z>gqE6&c@pL#H0gVb}LvE`mZ$Q!lYUto%RShI_sg%pGH`ifj!OR>FhZx-Bs)zut?a! z)CXJYM=J4_mn6om-EEJazY{;k% zq627MZdza1F&Lx{JLJ!tDezXc?)dZ4RGVPK5}x35&Z*FXS&=Q}RH- z18aXJ&}_&WU@sg>#YH{PB=T`37A5dX%|3YZ5ZpBC2$(H_-fVl%$h1jrbl{f@RqevC8evjG4)di;xNUi;Lhho{?^}W*(8R^ z&x;%)5NvR%A!?wH6t{(5fkkSo{{P$att)zDQ#@T{IG> zG0eX=kx1j9Wt+16&FZmFC(n2e!Y64(DjU=~6!Xkoy&)1}2w?63 zYX6f-9*V~7uDF|4MH>l>1Q2nnMV)Nr{`L&FM5|WF zH1x2`s4U4!tw3ah<`T_12>l(@XvzX)Jj?ep)f%~j9V(KP2H#1?rzC2vRW2{S(cBuW zT9WcI`i&^Dj6X6t%D-^WF`v9P`5;nTGF+hd{K*mPp|WH+tk~(LOe|(0ItYlA#TJ~J zJ>@U%9Q%LeIe-0}&JSCy584?HStHp{(J7bl`4ph^`gf-F{dWADyS=?xYvp^4qLL3# z2X#p&f&v%HR^oAQdSQ2R(*6Q<(iVD3ZV~<89-H!K+a)~=AspZ~Ay-FOf|5o{FbJ{4 zAAtwL(8NI0G9)T2g;Z&Sc?oH-2zA2hoxy?@mCQE$)nE_81oa6{4JEKLjB@wqf~yC- z`d_R)XRtmB&W%*I!g@1vYj=_%&}RzZc^><8hJ(?M0rv$ zp}cDW-8eMaBIDztP3>yEl>~qwNV>{mQ@?4V%xDl~idz;pFU2EtX`3jg;Z6uR8@?@ zPGAOx6gVVNnsmLI^(~2)z*BK!3@)Lt4CEh*ib_bJ3@{DLQ3O@OItvRDS+vqDJtIbY z$D#u@Q_ey{X2@6!Qw<|r8&W;%ZbSZkFH%DhNN5YJ&|P+(Oa*&GWXH8@0|l4Kkij0f zgX)XO6oF9o`n7|rJ%9Cuq^(J7NBOGGmH%RMFV{9bNJtD41CU|5&(0@C!U z)@;6Ai>(g|6RD;5G(HcYH*5mq4gl+M_rk!JWcClE^SKj~qtB6{TFvB&^rqExQ_ZN>Ea|6bJDXc(0s0~RP0kvZI)!!Mk$7lIO7DQjx77D#Bg zw^1rm^k5wrU7+E_yVA_OBbjoa$x-}wAb4qJS5a!=un%G%CnI7&6;ippET$@JP=-nS zExm1G_JuM*B#|tD418eNGk)R+M4ULBX%Zz(cG@5$l}jL2AlAwbzch=GDA5FyjaUo7 zHGw397V6vh_Zaza1bnJ*frX7mJFXbw?cw%RiOveA=WuvU7GASMNMZDYzgqaTBN@tYW8q$oBpELcPOTSPjU$fG97f z&lD5iQ$6pW-{Q9pW*h_$0sD&gn!b})>YB|N9@;wk*aMZk5Zn`qVNIxHSTZUF2h;`L zG%y*hA|@yo*1Ir3|Hw@Zz;FnFN&n&-YTs3z`&(q%4j5S$r5NnbNFvw)cQ#%o!KaR_ zm1=LRRCo*$b6mX5?r)lm^48L-1Sph|Z73rSM(_4mGBqM*Cc?_-I{Q%PXD9y4Bb;u~ zquMyjAV^Stq;oe?bvRK-WuS70&EuP-y4H!LgU^u^yY+LSd(^4u*r7;4Au0Sn10!*ut^^?X05C1tU_-YZMDF zuXqoZLLmZC5!8kp{U;D>May29I3xAjJGrTgBy@k_3GZS@LUF-&{igx7-Y*u>y3KqT zemDvBgiSYZr4!Y@x>&q0df+P{4E8(W)HEN(gO8drfukRzEGNCj+%33MP)3 zA)Q7TR-B$GRJ`?&pEp|USQJtg5X|P_j|CZ3#9W0Gjiv$$erco96&!ssUm2M#JUDEz)n_HR*S_Ku&fv~%IpXn5{hsf1fn{ubW&^-#LdG!v z8u~961thA&Dhi$jMfi%JgH@_(=@6uZr60p(?*9i%TgkD15iA;o1dCyIv{u+SGy1cd zC>d-cBjN-MAyEjKN@hnH_C~8Hwz23{3l?m>CTL7hZTW2%@B%VMwrc;65{i-4SZ7+K z&$7=l{{D{NGfFDzpM!7K}Kf*G$yfpz9Np6F-bV;55WdgeYFd1vW6f)NHN#SCfoS zYNtB?e0aUa`cKWKB+zv!bXQv9BYrKjXM|5SB++@QCiI595T)`lp| zMO~#ov-zxFxp&6(3am2}1~YjT%jX7WGMRW^ zA^SjTQVdM&nixY(c^R1@I1`9g3XlbuRj!4WD5UDhcd~TUrN%|12n$)wR75#@(*KaI z4_|}pTNWKrlO_TJ%5X9W>cRnaUPZqa92z2FAP2aIyaS1t&Bc zKpa2ukZORuX*+$W97ib|ol`1EIWv2cp#+)>aAKu@CXw|X$-%Ns?*t$k4LOv&A{3J3 zD=?4*61t5Z1W6>o0C6K*x+)#Z;*vBrfl8C0D1f&`+(Nu0RV?gH0g}+)4VldXks$0( zg17v2tGr$iWQ{i+NipBdaGb3gM_qzHsng9mkA_pHvxkuC=#P6R&G7a ztK-{3D^~PSQ*cpd?|xD!Es81){(}|?Iw;R)p9uju00+llox5~!k;ivBBu$R(!U2h< z(OKqq5r)PUK!8rrc`C`d1_niT?cAF&@cM>#D-bC%BbYFDNA}FE0+$BM&caAU86sdf zKnk7P4>fdg{w`_*)by;QU{_>!KT$&%d(cJ-NYk0hTfBvj1OaYD+pJO@b)CO41wP7< z102y^-C3Bn>%4ASnl20n3iVRGW#PgK0iul?fe{Q~V$u-uK-3HgDLUlRtIVkZB)3Qm z%VnoB!3TGrSjl<`N*0JXTZBf)Q5Yh^2j9a-NV8)l>3mgw-G5VJB8OWwJ1HndTj1}* zIC#x2XJp2_>BSNho;8e3CM009?obVts!F3GQ0gLEu#o0G?PR)Uul3v_ZUDIs;g-Ge zpfB7%(p|r}0}?_7vT!zZ38H62Y{lGZV2#-~Xc(%c{X2;dqo|@OVxG_p?7=2CWGWYq zP=}!w{PP=w-4T_pK7-SNFJdE9?Hm6$OC*fCd`G-_6gN!x!~{Gwdl0lK0+V`f(JfM= z284hl)UZ13RpbzE4)SdDEzc}3-RA^BjfgQ6xb1=e_rKmuJGi$GF~Pw37eDkGoVy#8 z1i6_J!UZTyYiBqZMbe?r5haX6r>@Z)GcXv^ckLKC<2QAY%w7Tk$|Jv&8TqL9#9N%YWF^cHq%Ts`6VHw zb|eZcV3%xlq5aG+CEkV^CKN@K%ajI(xBZWG4VVxRl|AF1JTcnLD~?#2|1G&cQA?i| zSw{o$n@X3$T4{@nFvLoH;}~+=qQ*zv-eIh*~mEsEUz+~X+33j4@5`3gK z$z#qoq{b#8lds7@EfJaCP!v)NgYSwEvptc#hrHtJ3YQ85sy--#rRuk**S&h;?LSqb zM?XPhw>Mt$a~$2m1t`PkOF%$_cG!GD8koKQfIbBZxv*x+HcLrFzH{kjnZpsl%b;p+w|uUjl zt&IeqzB0mi5UYUM5M1gIogG013hK0JyelLs>lq$|NE?8&!37jHq*fHvRBW%hWsCY2 zzc>vK%r_ACE)jHiZK%n^j#|FFyVUlzsfMR8piu!Yd<}A4-V0l@=ttE5x*MH-Eg-Gq zJOU$2umAWf@X|~a5SCzp3&2oI$RZlcl&rEUIBQY?O2EDO7YD5%whBe64%KVO_(B?9 z3L@14UcmT>EKKHBSprxg3cwbbdF>g2#fK8WHy~!%iZ7@x!OY+yT2!=`sas-)Xa7&W z+y7F&nI%@M^6-1TX<0e3mJoup<>Kg5Szik!V>U3{+c2Y%RCJg@X=_AfOCB z8d$L^RdiE?PyU1eA1O*}%SsW$YvT$3Mybp^{LoE+P zB-J2X&Yx?f<`;$%)J`=tg0xOjDLOSCs`hq)&-c&X8z8Xu*01|U;=wzREEF@3x5OI|%zGp$vseYm zft8FXnITEAC9}R8vMM?V770a!c=`_nE69)Z4RWyiOVt9jA|I;|KuqLq!@}GKx^IM> z4--&~k!?5JpCSq)PF> zOcNjR3QBzZ*gSIy{`TUFj~`B8PDcob7|NRcpAni0yDtaDTG2Z zi^7SNlaCw*_ADM#5C%xiEp>Io(e%oYEgZbgD7LA+))mWDWT@4#il_qr!Al?`YuJ$t zQiKaoNh0&YR51|~TR-nl^@P!XaN0+pg2BplPCb#Pk(?ogAD+-`VO9|4aZ56xm`(Nx zQWqdLtiZSb`_Ley4Pj#p6EXqr8r;G_7DEL5a_Iu`MnWdzTYhv%g#`{Xy*4;CEpJBg)Is)Qht zV6ak?MbCf0h)jimG(-S^22cIS+D=u3Ve>25HNprn+lU1y~4EWB)s1CWk@fNm(rOMi(j(hxzY8$i5^Vcz@&L>0BE<*=}Hr&?~N_;BV2 zii4Xrw}Wzl*jmm2U8i)x$x4e-9IYXesHDxW?pkhlsqsb# zR$oY8>Z?|~mIY-IUT9g4Sr1Fkf^{KP{+0v?tp6`CCQ*3mVd1MdOqIW6iUg|wK_KE; zM&il0%UOTqzDavAm}5mI5oxCT7`!CQj$7lncxFg~+Yk~2LxKX;E`x*+1red>OdD_k z41o=G4vwE(ueo*gyQG|m-$l?B3!x>63K#~664jKt-PtNk23@QK(Q!jIFkAW4*`Y9( zl#u3{5P>2N$|qjgem1SilIs!0-oAb8*dR2a`6vHd1PNm!zf#h{S5}_**!Qu@ zw(&dvmb(>ECLv;o_?6*L*-(aJG@+UaSRv^t+4@#kJ!%j@#J*%l&aQd%vn{%5FPcMP z9^P6->)7Dk16eVt_JO)5$O9+nIV0K0Orl~dgt2{D*BcDf1Ck`uNR{Q`1-YBb4z5l<+iPe1Pf+)V}Exv8BjsHBrOeg*Rq&44O z7r`CC_MaDYFMxXyD7>R_=b5#uTmEz=H+DD^Oh8QD;=~Jp2qPBwk)c^Cwn~n_r5(7i zsqp-~Q8v#DJ&|0G{FK)lrVUj=1q#m0buyCkurp+xnWIqJSkip3iy(}XS!5sl7YjRp zJp;lTDg&^=v7gH*-KpPn?cG3_KX2AO^oJ9%ulRM=HuIO?$*zq&EDHGc_%l=g%Ib8i*<+Jt(F11xy&Rjg*uJ?G3C55>3aXYS>;xJ#<}tCk?uq%S@UFfg~u~F_D*5H>OX-O z{L4dOBBkY%hD7CuN67l$E>*Blz6lpv^^4y8Cm$6QJrL`ZWym26Q5Yd5_*9KKae{^` zuUed`AI|%31Y4|xp&*+){_C3V5iSYQLNJ2C+69DOyei!VEhc>6)J@4`EUb@{I?C4qX2&;~`SYB(o)Pw#}3!NKB|xbpsOFzb>xu zz>GULO3UUORt6nRZDzLCku0&QE-GY}3K6XTigCY>!U_xGK~UMPU98=xSnj7$fR+fM zumK{VD9rFFSF}2fNTi{hS(jOJ(G`+p@wNMkSMMMyMKkj%NCr6pL5nFudYhPP!Z7lU zo6Uc>%lZ!xPRf}Kz6`7&vXXys<%?fCvR(q;QBwQ6#{Q!Af;QnVVT_QqQIq!>SkZjy zg3n}y)sysFo8nEOUgkXDf{H9R7@J!voZL_<6QWGC5+h>>D$zsla-Y|3s`fuXB@WSC zq3L`s&zBoCmZu7YQdSrynNlyf7E$Q~*8=f!_*FdQ!{4cv4CO`s^#ldc(ja89V6bM< zzA#n{k7_zy5e|evn3Pa64O+khpo*oZf>_-72FYXj`K13ONL6r<2wOi2BO4);RX@$H z+G;|9Fh#dA>IcHbo>T)&N5a~%$JP){1PHgT_pd*Npkh(Rc*$u_L-%6mRHP(?LJ=?u zc8Z|Q;PD^2UVLf~A_*+T?}4D)F@9-3XXle|QSC=khYvPXamq(hr&zWacd#@Mc|o$< zKvnD_!>hn;pBUWih=9Q;A?hG(0V6REtlDcTouJMb4!Bfv{#^G`t9A!RDB_%Pup1i4 zw|tc;Vy4PU1w`SADD#M$t>&Xl_iR1*+DruHJ>qt#FqAtrpFJcqcD}`HwjSDpO66n) zKEbF{p7y*q-TyNW-aKuL=TbqYZKtqW_LGr{mX4PUQaDAeChdqcI^=Xf15DO`R(da@ zZYpCTF%{f^2+-+JWDFy2Hv7ln9yk+Jz?scT*uht%B9nO_KEV#fD-te_#w82U#}&++ zz!*m~7s)t}GqOe1pwe+NR%*5|ZcvnX*SaMiPogFKWF7|7512F7 zG##w4pV_J5Fz}YK{8hBv4SNXHHFUiY)SGJtHaxt=j0wOHb=if&(|B(At+s{cQ(%RM z+0Bk7u!7J4WZvo7bLV;erZckUq;Z2x9OUtmR;(?23*iv}jO1;w&hp=+*$UIL0J; zrj-H}jBGr+*{c2fP(!R>8yf_OQFb&Vr7nZ~Z-AKn9<>Lru7BKv))<~zi8@$Rhlpw7 z360fLhexq5fF0(@aR%t=%ii8UoF{%u!(D-zK*@N^!I&onJy%@z*AZCC971VK3>9J| zaGH(q%{nHeP-_FSXco&qrN5(urtlPjnW9ezGGalAAQHf|teoe8k(!y6a}lXbey=R} zf0AF?GD7$6jP4`>uqvKSs0Y4mu#jXJ*8=;lV+8`UjbGc{ z1C6Ub9Y_GF2Z0H}s-?94tUto&N2s1(I`Vbh2B)<0J?=aE@~_dr{K4CHxKso+T16_1 zESt0`5q_d+JhA|czZcRCd!mm6WFbPhkzG`(X~S7$W<{=drV3H>)hYzk?U>omBPuF~ z?P11}!Z^XtFs>pqZ(Fcg_9L^^sB@$QCfC(IopA`WB4bb?OI2ny(^BmSbtK+LT`j+C z>9+HddGY(X;rDRWrnOhv0OK!s$nRlEKR(rQEbICnfUe{d6S0vvj^mHK$+j0?J2$Mi zcMI1J3~-Fo&`yxjxDekxaWJELqRk*_3^T$FPR{@d!Jz5UK=zzlHidLSJCF`Q?IU0F zs`uv#A@Y&$TwUcWi4oLY*A)Coj!Z z?eY{$0EwUi|2;i{x?KpRgY1#zcbBuqjQl4gAz&hepb?>iwTf*CohbnpAP0u59fM#( z84hQOOqmur`Wr}%7D2XUS0+t_5HA+(a^L5R)u`@Rbys(2LE}2E?@25Ym|u)ldq~Et9IyG2Z-JY zhNy+xAU8x-v$G`X1iYjBD@hv3q~uz1>GZMeh451lRZyGvpBKUor_TSq%Ie4WCS71C zT@HQfX7~H9Z?C(fx4mcu1x9*1Jhjc0H@gNzbIhs>%W2Ly3l=TvhuKC%5H;Kp#ZV)I zZWOt8L;9Y9v!*tJ85;}WDxnD%`R!^*3!0=;m`K8hn`P-a1635%C}XWnE|^%*_N|7# z_f&8D($`94(=q7CR;BdxdCq#ooBqY|qVI;Hky|`*H#|?eebPR6+XwHTz;8qPjoXqs zWAn4sy1O=QFW217C#T!Qn*cg{{q1kJI6<^IK8=K$K+z`&c3bx7s*xjKHrO0+WKV7y(LEisXDTcRzh{m5_MA$>6VhT-axbI)CV!!Ljj6~fOmNvCfB}*ZZSYXB$J+bZfcqEH zp9Sb63DN%B)OUFEF!4YbeE8U4H|eIo<9cdpg*#F`(ZOmlL%YIS0yXiW9 zkPtb%Q#Rg7NVJM51~s?M>{{TUS`tzM5?MZ zOU}i)jVBsxfiS#^8!#hC>565VNoO5IQWfOAbOq+Lrk}F^_3vSDr{tll4bPYBKy|idA2GlQ4}dMm8=2`zHVTG zAotd-r9lXCU1%9lO`)063{DgIi^+nI9?48F39|Aj=UF^CFp{qCd33+dUTN8I_>9v3~~!tcR--p1{e$Xxl44(zorm z@8YGs!|ssegMv^WWo{UdZ$>oAMj%_zL{6+`=vxO+N#Y#aNJNVSB-3W(EHgE`GZiXD z3OuZe9P@8`NB#HrW$DJtkSWw0E@!-r${Pd5q~~hDUIzM*%L65$=Ox&C#mJ5@h$M^z zN$qHNu3yH0J_MPW0qK5jN~@_f-$Pj;0#<-}5IU<`EqFJniQsMo>Zov_n1KeMOb~1y z8la{i(c$}AZQu8Q|GUAFf0q9I8sd5pP!+=yi0|F(b2k6l&EIeF{TEMc|G@Q|+y9U{ z8G;+Yjapj^DT+v5iP}tEWfeu>M};|CMWr{VQnEPjV`|R))3*@~IZ6oHX#RB+It`xi z{m`u&7z0D786`OGDT|gb82O~IEKwHnYl8C&SZ2#VVfBwS)CC{eUG@VQWp8T=?upWJ zVisceqY$4W4N^0?o_sHvCTAf5dyBB1)fJF70Urg_(Veq_p)i8R!4%yzO@eH77n3&K zeD}+_cK;mvFh>~vHS~WKC-5*+=5lf3R(s#*hB!mfaqCM}i08u{4-5Vz$rxd#aI2J| zwjBV0?(NB{h0FicP)_?$tCn0l#b8#Ssukr%DF+5=6jmcT&o6QV9!^jJKMTi)`8O>g_jX=p z6D}1EJ5sPlxbt5@KcTtn6m=zAr2%y;DCw3%-e?E=8@jWB)ZaKZ^k530b@|cQN#9Rj zl3cIJFl35E4+W$pVBP<~(_h+k`KrLXhrN@@8ZQQ@@Hq0}$G1^RCg2uA7e+F1qca|d zKH|~CtMxmD`Tt&5*Z#P-Hy^tD=>{`o;ud5Fie?}N2C<0Pa0gvHH3(VokqN&bl)%@= z!7mUE*dU}>l8C^jK!<5mrmE{WKRf)lm!HQ^t)G5b(taDtEmt&<+yVlKM4+^|4I>Ux zRKw!=`Q%E^+|1f__9<6-^ZKoXe+m!@)D#$eIKqTDfFvCfAFjXld$yM*ZJ%XK?1{(n zgs$>(DyVr|xLYVUNkttE2x7J@T)MjlAP)#W_OmXHT#IQPorWHzpLcCki0IpQWfffqh|&PRUkP#dxU!5G3& zEAPUG)|O!<5Hcy%uOTF?Be3(DRniD%N-L}(?9DFs^uL(-%m1)^`KNqx>ns%#T(G{M zk;cc1oWk2wO_O%FFDchY>;f~ZDe>(l$e*Z<1S3qC{o9*qG?b2%MQ|lalEhuJlsMPa z@wa$g zG%4EX&E#FmmwEhQI?KT7sMQx-A%0QveLcMDAn*W!Es}$KwaCGZg(?!*F>FduQR;zc z{I>DvE@g+AKN=;YRL!!$ql^{*sGNq-@P49>h!oFJ} z3l@?E=%Q+9u0hmP=e3qO8~((e6R+K~sl9Z_j#tPvW4nzJ3D&6l(dgkRKRh-p8h}E+ z4hdnwLD8jr%SIwK_sF)hifRbZ&}_x!^_*2S zw?21{DGDJ0Sc}D!VR~~*(`38Km2wN;9v_=Kc)Ia?|G4X^d28x30g9BCp=(ZjWtkH| z%KV!v)#h!+=5dEqU1{qs5)k$ zWRQ`7H6cdUJc1R0VdD5hljt4Mc*X0d=l%E+Y#I zdav`bceO4V5tH%KMYx+=qbgA;=koA;IBlcXuK#X&Mc5}Ty5u>g%PL*D^ynL8(Tr9eY_rdFbI2%oS{Uaned(KR;bQ-4+_+u~bA7PsKaT zU8nr!A9*Upk!g7~F>IvUiH7oP`4ov$J@KCN?HhMnRHWGj7!*0-Y@egLR-G)NK*3gB zkF+_QECcP>j-ZVLyEk3jfO}|}=>;n+;>g}Mi(0?DSykM^uE*dw_GbBMT#;y80Yj@} zm_vTYoF-x;c%zl6Sv8vJX$&}FsC2#ax9weztJ?u$3xECZG^LArnudWEfi*}2!Z;_A zO4}ruda=9oB2$TUt_hk>*n&yI3ruL9!%LP@lhbp~J9ppGM785vUtPZKJC1fwEr;QT z@$qUkk4``vj2G;s7HE@BCZ$Eg!Pi%hnR&%{w=5 z#3LAf*dOtWs8v5rj{8m#`7MAFAJtpt9(i`T!eA!EAPJHr?H{5}WX)sKKDTh!d1{kr z7z;8KR11uCwPPR1p8Ck3DA6HhYzZO=P*{{~^HCHcWj>_wWOEt;2slEWByNRoF;IM^t zz{VCBU<9^7RWxKyfo@IEHihI9u{09uXrS+AJpR`|u?u3lXqQeQ$8JbsXI>$B{)}^SlR8a^9aB5~~ zXz+?K2&)oSg_G965l~?fT%De@9;Lu$#u+htsnjS6FylTnPjw~`W=KW##tw=71gkc) zM^381Rxsq$nn(vEtw)hm)nFS`2=cbc;T2PBag<-EF;VgnH?oFlOK*ne(xhjRv*?qY zh)WYDv+U$aR0NI~q6%4*$-8)4&93dND?-x>DXtN-AQt5%3j3p(3S{Ic^(`Ytc2cx7 zo|ruNSl`8L^l(`Xoz*2jN`s@+k!3lYLGRecdG51a+6){=T4o_&<~!O2z@ zuBp+H^q|rmr*+}77XQpdtj(h*kV8qzseCOVk*RFolYSdfpp!=Z@ z_h0S5BgWnBXSatrN!r3A6%a}}Qj<$vI$mSdUIT65F843}Pk&&+_HX4lCw&r=5bzoCZQ0Oy}KbNQ{5Enz#rC=$E_0g6ER%o*s|x7qo_dvXP7c!m%3O literal 0 HcmV?d00001 diff --git a/apps/site-web/static/assets/wow-factions/alliance-64.webp b/apps/site-web/static/assets/wow-factions/alliance-64.webp new file mode 100644 index 0000000000000000000000000000000000000000..a029c82ff067b3d052957e9ac71cc8c1ed4d2761 GIT binary patch literal 6886 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 literal 0 HcmV?d00001 diff --git a/apps/site-web/static/assets/wow-factions/horde-128.webp b/apps/site-web/static/assets/wow-factions/horde-128.webp new file mode 100644 index 0000000000000000000000000000000000000000..cf526e9511238a26370435e4de62e21c0df13767 GIT binary patch literal 9876 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

I(TUx0?4U8~`uN9=@AY+UNnjG81} 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 Ga_t`> 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 U9~fe-*7Y8vT6bA|hER6{0AoCkP5=M^ literal 0 HcmV?d00001 diff --git a/apps/site-web/tests/wow-ui.test.cjs b/apps/site-web/tests/wow-ui.test.cjs index ada6d04..d8d14da 100644 --- a/apps/site-web/tests/wow-ui.test.cjs +++ b/apps/site-web/tests/wow-ui.test.cjs @@ -25,11 +25,19 @@ test('case variants group into the same server', () => { const groups = groupWowRealms([{realm:'Area 52',region:'us'},{realm:'area 52',region:'US'}]); assert.equal(groups.length,1); assert.equal(groups[0].rows.length,2); }); -test('crests are accessible inline SVG; unknown factions render nothing', () => { +test('crests are accessible self-hosted artwork; unknown factions render nothing', () => { for (const name of ['Horde','Alliance']) { - assert.match(factionCrest(name), /']) assert.equal(factionCrest(value),''); });