1use base64::{Engine as _, engine::general_purpose::STANDARD};
6use maud::{DOCTYPE, Markup, PreEscaped, html};
7
8const SITE_URL: &str = "https://wowlab.gg";
9
10const LOGO_PNG: &[u8] = include_bytes!("logo.png");
11const VENDOR_BULMA_CSS: &str = include_str!("vendor/bulma.min.css");
12const VENDOR_ECHARTS_JS: &str = include_str!("vendor/echarts.min.js");
13const SHARED_CSS: &str = include_str!("report.css");
14const SHARED_JS: &str = include_str!("report.js");
15
16#[derive(Debug)]
17pub(crate) struct Tab {
18 pub id: &'static str,
19 pub label: &'static str,
20}
21
22#[derive(Debug)]
23pub(crate) struct Section {
24 pub id: &'static str,
25 pub body: Markup,
26}
27
28#[derive(Debug)]
29pub(crate) struct DataScript {
30 pub id: String,
31 pub json: String,
32}
33
34#[derive(Debug)]
35pub(crate) struct Page {
36 title: String,
37 subtitle: String,
38 footer_command: String,
39 header_extras: Option<Markup>,
40 tabs: Vec<Tab>,
41 sections: Vec<Section>,
42 data_scripts: Vec<DataScript>,
43 extra_js: Vec<Box<str>>,
44 logo_data_uri: String,
45}
46
47impl Page {
48 pub(crate) fn new(title: impl Into<String>, subtitle: impl Into<String>) -> Self {
49 Self {
50 title: title.into(),
51 subtitle: subtitle.into(),
52 footer_command: String::new(),
53 header_extras: None,
54 tabs: Vec::new(),
55 sections: Vec::new(),
56 data_scripts: Vec::new(),
57 extra_js: Vec::new(),
58 logo_data_uri: format!("data:image/png;base64,{}", STANDARD.encode(LOGO_PNG)),
59 }
60 }
61
62 pub(crate) fn footer_command(mut self, cmd: impl Into<String>) -> Self {
63 self.footer_command = cmd.into();
64
65 self
66 }
67
68 pub(crate) fn header_extras(mut self, markup: Markup) -> Self {
69 self.header_extras = Some(markup);
70
71 self
72 }
73
74 pub(crate) fn tab(mut self, id: &'static str, label: &'static str) -> Self {
75 self.tabs.push(Tab { id, label });
76
77 self
78 }
79
80 pub(crate) fn section(mut self, id: &'static str, body: Markup) -> Self {
81 self.sections.push(Section { id, body });
82
83 self
84 }
85
86 pub(crate) fn data_script(mut self, id: impl Into<String>, json: impl Into<String>) -> Self {
87 self.data_scripts.push(DataScript {
88 id: id.into(),
89 json: json.into(),
90 });
91
92 self
93 }
94
95 pub(crate) fn extra_js(mut self, js: impl Into<String>) -> Self {
96 self.extra_js.push(js.into().into_boxed_str());
97
98 self
99 }
100
101 pub(crate) fn render(&self) -> String {
102 self.render_markup().into_string()
103 }
104
105 fn render_markup(&self) -> Markup {
106 let first_tab = self.tabs.first().map_or("", |t| t.id);
107
108 html! {
109 (DOCTYPE)
110 html lang="en" data-theme="dark" {
111 head {
112 meta charset="utf-8";
113 meta name="viewport" content="width=device-width, initial-scale=1";
114 title { (self.title) }
115 style { (PreEscaped(VENDOR_BULMA_CSS)) }
116 style { (PreEscaped(SHARED_CSS)) }
117 script { (PreEscaped(VENDOR_ECHARTS_JS)) }
118 }
119 body {
120 header {
121 section class="section pb-3" {
122 div class="container is-max-widescreen" {
123 (self.render_brand())
124 @if let Some(extras) = &self.header_extras {
125 (extras)
126 }
127 (self.render_tabs(first_tab))
128 }
129 }
130 }
131
132 main {
133 section class="section pt-0 pb-3" {
134 div class="container is-max-widescreen" {
135 @for section in &self.sections {
136 section #(format!("section-{}", section.id))
137 class="report-section"
138 role="tabpanel"
139 aria-labelledby=(format!("tab-{}", section.id))
140 tabindex="0"
141 hidden[section.id != first_tab] {
142 (section.body)
143 }
144 }
145 }
146 }
147 }
148
149 footer class="footer py-3" {
150 div class="content has-text-centered is-size-7 has-text-weak" {
151 "Generated by " code { (self.footer_command) }
152 }
153 }
154
155 @for ds in &self.data_scripts {
156 script #(ds.id.as_str()) type="application/json" {
157 (PreEscaped(ds.json.as_str()))
158 }
159 }
160
161 script { (PreEscaped(SHARED_JS)) }
162 @for js in &self.extra_js {
163 script { (PreEscaped(js.as_ref())) }
164 }
165 }
166 }
167 }
168 }
169
170 fn render_brand(&self) -> Markup {
171 html! {
172 div class="is-flex is-align-items-center mb-4" {
173 a href=(SITE_URL)
174 target="_blank"
175 rel="noopener noreferrer"
176 aria-label="WoW Lab — open homepage"
177 class="mr-3" {
178 img src=(self.logo_data_uri.as_str()) alt="WoW Lab logo" width="32" height="32";
179 }
180 div {
181 h1 class="title is-4 mb-0" { (self.title) }
182 p class="is-size-7 has-text-weak" { (self.subtitle) }
183 }
184 }
185 }
186 }
187
188 fn render_tabs(&self, active: &str) -> Markup {
189 html! {
190 nav aria-label="Report sections" {
191 div #report-tabs class="buttons has-addons is-small mb-3" role="tablist" {
192 @for t in &self.tabs {
193 @let is_active = t.id == active;
194 button type="button"
195 id=(format!("tab-{}", t.id))
196 role="tab"
197 aria-selected=(if is_active { "true" } else { "false" })
198 aria-controls=(format!("section-{}", t.id))
199 tabindex=(if is_active { "0" } else { "-1" })
200 class=[Some(if is_active { "button is-small is-active" } else { "button is-small" })]
201 data-tab=(t.id)
202 onclick=(format!("switchSection('{}')", t.id)) {
203 (t.label)
204 }
205 }
206 }
207 }
208 }
209 }
210}
211
212pub(crate) fn chart_box(label: &str, css_class: &str, data_chart: &str) -> Markup {
213 html! {
214 div class="box" {
215 h2 class="chart-label" { (label) }
216 div class=(css_class)
217 data-chart=(data_chart)
218 role="img"
219 aria-label=(label) {}
220 }
221 }
222}