logicaffeine_web/ui/pages/news/
article.rs1use dioxus::prelude::*;
4#[cfg(all(feature = "split", target_arch = "wasm32"))]
5use dioxus::wasm_split;
6use crate::ui::components::main_nav::{MainNav, ActivePage};
7use crate::ui::components::footer::Footer;
8use crate::ui::seo::{JsonLdMultiple, PageHead, organization_schema, breadcrumb_schema, article_schema, BreadcrumbItem};
9use crate::ui::router::Route;
10use super::data::{get_article_by_slug, get_all_tags, format_tag};
11
12const ARTICLE_STYLES: &str = r#"
13.article-page {
14 min-height: 100vh;
15 background: var(--bg-dark, #060814);
16 display: flex;
17 flex-direction: column;
18}
19
20.article-content {
21 flex: 1;
22 max-width: 720px;
23 margin: 0 auto;
24 padding: 48px 24px;
25 width: 100%;
26}
27
28.article-back {
29 display: inline-flex;
30 align-items: center;
31 gap: 8px;
32 color: var(--text-secondary, #b0b0b0);
33 text-decoration: none;
34 font-size: var(--font-body-sm, 14px);
35 margin-bottom: 32px;
36 transition: color 0.2s ease;
37}
38
39.article-back:hover {
40 color: var(--text-primary, #f0f0f0);
41}
42
43.article-header {
44 margin-bottom: 40px;
45}
46
47.article-meta {
48 display: flex;
49 align-items: center;
50 gap: 16px;
51 margin-bottom: 16px;
52 flex-wrap: wrap;
53}
54
55.article-date {
56 font-size: var(--font-caption-md, 12px);
57 color: var(--text-tertiary, #909090);
58 text-transform: uppercase;
59 letter-spacing: 0.5px;
60}
61
62.article-tags {
63 display: flex;
64 gap: 8px;
65}
66
67.article-tag {
68 font-size: 11px;
69 padding: 4px 10px;
70 border-radius: var(--radius-full, 9999px);
71 background: rgba(102, 126, 234, 0.15);
72 color: var(--color-accent-blue, #60a5fa);
73 letter-spacing: 0.3px;
74 font-weight: 600;
75}
76
77.article-title {
78 font-size: var(--font-display-md, 36px);
79 font-weight: 800;
80 background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.85) 100%);
81 -webkit-background-clip: text;
82 -webkit-text-fill-color: transparent;
83 background-clip: text;
84 margin: 0 0 16px;
85 line-height: 1.2;
86}
87
88.article-author {
89 font-size: var(--font-body-md, 16px);
90 color: var(--text-secondary, #b0b0b0);
91}
92
93.article-body {
94 color: var(--text-primary, #f0f0f0);
95 font-size: var(--font-body-lg, 18px);
96 line-height: 1.8;
97}
98
99.article-body h2 {
100 font-size: var(--font-heading-lg, 26px);
101 font-weight: 700;
102 margin: 48px 0 24px;
103 color: var(--text-primary, #f0f0f0);
104}
105
106.article-body h3 {
107 font-size: var(--font-heading-md, 22px);
108 font-weight: 600;
109 margin: 32px 0 16px;
110 color: var(--text-primary, #f0f0f0);
111}
112
113.article-body p {
114 margin: 0 0 24px;
115}
116
117.article-body ul, .article-body ol {
118 margin: 0 0 24px;
119 padding-left: 24px;
120}
121
122.article-body li {
123 margin-bottom: 8px;
124}
125
126.article-body blockquote {
127 margin: 24px 0;
128 padding: 16px 24px;
129 background: rgba(255, 255, 255, 0.03);
130 border-left: 3px solid var(--color-accent-blue, #60a5fa);
131 border-radius: 0 var(--radius-md, 8px) var(--radius-md, 8px) 0;
132 font-style: italic;
133 color: var(--text-secondary, #b0b0b0);
134}
135
136.article-body code {
137 font-family: var(--font-mono, 'SF Mono', monospace);
138 font-size: 0.9em;
139 padding: 2px 6px;
140 background: rgba(255, 255, 255, 0.08);
141 border-radius: 4px;
142 color: var(--color-accent-purple, #a78bfa);
143}
144
145.article-body pre {
146 margin: 24px 0;
147 padding: 20px;
148 background: rgba(0, 0, 0, 0.3);
149 border-radius: var(--radius-lg, 12px);
150 overflow-x: auto;
151}
152
153.article-body pre code {
154 padding: 0;
155 background: none;
156 font-size: var(--font-body-sm, 14px);
157 color: var(--text-primary, #f0f0f0);
158}
159
160.article-body a {
161 color: var(--color-accent-blue, #60a5fa);
162 text-decoration: none;
163 border-bottom: 1px solid transparent;
164 transition: border-color 0.2s ease;
165}
166
167.article-body a:hover {
168 border-color: var(--color-accent-blue, #60a5fa);
169}
170
171.article-body strong {
172 font-weight: 600;
173 color: var(--text-primary, #f0f0f0);
174}
175
176.article-not-found {
177 text-align: center;
178 padding: 80px 24px;
179}
180
181.article-not-found h1 {
182 font-size: var(--font-display-md, 36px);
183 margin-bottom: 16px;
184 color: var(--text-primary, #f0f0f0);
185}
186
187.article-not-found p {
188 color: var(--text-secondary, #b0b0b0);
189 margin-bottom: 32px;
190}
191
192.article-not-found a {
193 display: inline-block;
194 padding: 12px 24px;
195 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
196 color: white;
197 text-decoration: none;
198 border-radius: var(--radius-lg, 12px);
199 font-weight: 600;
200}
201
202/* Article footer with tags */
203.article-footer {
204 margin-top: 64px;
205 padding-top: 32px;
206 border-top: 1px solid rgba(255, 255, 255, 0.08);
207}
208
209.article-footer-section {
210 margin-bottom: 24px;
211}
212
213.article-footer-title {
214 font-size: var(--font-caption-md, 12px);
215 font-weight: 600;
216 color: var(--text-tertiary, #909090);
217 text-transform: uppercase;
218 letter-spacing: 1px;
219 margin: 0 0 16px;
220}
221
222.article-browse-tags {
223 display: flex;
224 gap: 8px;
225 flex-wrap: wrap;
226}
227
228.article-browse-tag {
229 padding: 8px 16px;
230 background: rgba(255, 255, 255, 0.05);
231 border: 1px solid rgba(255, 255, 255, 0.1);
232 border-radius: var(--radius-full, 9999px);
233 color: var(--text-secondary, #b0b0b0);
234 font-size: var(--font-body-sm, 14px);
235 text-decoration: none;
236 transition: all 0.2s ease;
237 letter-spacing: 0.3px;
238 font-weight: 600;
239}
240
241.article-browse-tag:hover {
242 background: rgba(102, 126, 234, 0.15);
243 border-color: rgba(102, 126, 234, 0.3);
244 color: var(--color-accent-blue, #60a5fa);
245}
246
247.article-browse-tag.current {
248 background: rgba(102, 126, 234, 0.2);
249 border-color: rgba(102, 126, 234, 0.4);
250 color: var(--color-accent-blue, #60a5fa);
251}
252
253/* Mobile */
254@media (max-width: 768px) {
255 .article-content {
256 padding: 32px 16px;
257 }
258
259 .article-title {
260 font-size: var(--font-heading-lg, 28px);
261 }
262
263 .article-body {
264 font-size: var(--font-body-md, 16px);
265 }
266
267 .article-body h2 {
268 font-size: var(--font-heading-md, 22px);
269 }
270
271 .article-body h3 {
272 font-size: var(--font-heading-sm, 18px);
273 }
274
275 .article-footer {
276 margin-top: 48px;
277 }
278}
279"#;
280
281#[component(lazy)]
282pub fn NewsArticle(slug: String) -> Element {
283 let article = get_article_by_slug(&slug);
284 let (page_title, page_desc) = match article {
285 Some(a) => (
286 format!("{} - LOGICAFFEINE", a.title),
287 a.summary.to_string(),
288 ),
289 None => (
290 "Article Not Found - LOGICAFFEINE".to_string(),
291 "Latest news and updates from LOGICAFFEINE.".to_string(),
292 ),
293 };
294 let page_path = format!("/news/{}", slug);
295
296 rsx! {
297 PageHead {
298 title: page_title,
299 description: page_desc,
300 canonical_path: page_path,
301 }
302 style { "{ARTICLE_STYLES}" }
303
304 div { class: "article-page",
305 MainNav { active: ActivePage::News, subtitle: Some("Latest updates") }
306
307 if let Some(article) = article {
308 {
309 let breadcrumbs = vec![
310 BreadcrumbItem { name: "Home", path: "/" },
311 BreadcrumbItem { name: "News", path: "/news" },
312 ];
313
314 let schemas = vec![
315 organization_schema(),
316 breadcrumb_schema(&breadcrumbs),
317 article_schema(article.title, article.summary, article.date, article.slug),
318 ];
319
320 {
321 let all_tags = get_all_tags();
322 let current_tags: Vec<&str> = article.tags.iter().copied().collect();
323 rsx! {
324 JsonLdMultiple { schemas }
325
326 main { class: "article-content",
327 Link { to: Route::News { tag: None }, class: "article-back",
328 "← Back to News"
329 }
330
331 article {
332 header { class: "article-header",
333 div { class: "article-meta",
334 span { class: "article-date", "{article.date}" }
335 div { class: "article-tags",
336 for tag in article.tags.iter() {
337 {
338 let tag_display = format_tag(tag);
339 rsx! { span { class: "article-tag", "{tag_display}" } }
340 }
341 }
342 }
343 }
344 h1 { class: "article-title", "{article.title}" }
345 p { class: "article-author", "By {article.author}" }
346 }
347
348 div {
349 class: "article-body",
350 dangerous_inner_html: markdown_to_html(article.content)
351 }
352
353 footer { class: "article-footer",
355 div { class: "article-footer-section",
356 h3 { class: "article-footer-title", "Browse by Topic" }
357 div { class: "article-browse-tags",
358 for tag in all_tags.iter() {
359 {
360 let tag_str = *tag;
361 let tag_display = format_tag(tag_str);
362 let is_current = current_tags.contains(&tag_str);
363 rsx! {
364 Link {
365 to: Route::News { tag: Some(tag_str.to_string()) },
366 class: if is_current { "article-browse-tag current" } else { "article-browse-tag" },
367 "{tag_display}"
368 }
369 }
370 }
371 }
372 }
373 }
374 }
375 }
376 }
377 }
378 }
379 }
380 } else {
381 main { class: "article-content",
382 div { class: "article-not-found",
383 h1 { "Article Not Found" }
384 p { "The article you're looking for doesn't exist." }
385 Link { to: Route::News { tag: None }, "Back to News" }
386 }
387 }
388 }
389
390 Footer {}
391 }
392 }
393}
394
395fn markdown_to_html(markdown: &str) -> String {
397 let mut html = String::new();
398 let mut in_code_block = false;
399 let mut in_list = false;
400 let mut list_type = "ul";
401
402 for line in markdown.lines() {
403 let trimmed = line.trim();
404
405 if trimmed.starts_with("```") {
407 if in_code_block {
408 html.push_str("</code></pre>\n");
409 in_code_block = false;
410 } else {
411 html.push_str("<pre><code>");
412 in_code_block = true;
413 }
414 continue;
415 }
416
417 if in_code_block {
418 html.push_str(&html_escape(trimmed));
419 html.push('\n');
420 continue;
421 }
422
423 if in_list && !trimmed.starts_with("- ") && !trimmed.starts_with("* ") && !trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
425 html.push_str(&format!("</{}>", list_type));
426 in_list = false;
427 }
428
429 if trimmed.starts_with("### ") {
431 html.push_str(&format!("<h3>{}</h3>\n", &trimmed[4..]));
432 } else if trimmed.starts_with("## ") {
433 html.push_str(&format!("<h2>{}</h2>\n", &trimmed[3..]));
434 } else if trimmed.starts_with("# ") {
435 html.push_str(&format!("<h1>{}</h1>\n", &trimmed[2..]));
436 }
437 else if trimmed.starts_with("> ") {
439 html.push_str(&format!("<blockquote>{}</blockquote>\n", &trimmed[2..]));
440 }
441 else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
443 if !in_list {
444 html.push_str("<ul>\n");
445 in_list = true;
446 list_type = "ul";
447 }
448 html.push_str(&format!("<li>{}</li>\n", inline_markdown(&trimmed[2..])));
449 }
450 else if let Some(rest) = trimmed.strip_prefix(|c: char| c.is_ascii_digit()) {
452 if rest.starts_with(". ") {
453 if !in_list {
454 html.push_str("<ol>\n");
455 in_list = true;
456 list_type = "ol";
457 }
458 html.push_str(&format!("<li>{}</li>\n", inline_markdown(&rest[2..])));
459 }
460 }
461 else if !trimmed.is_empty() {
463 html.push_str(&format!("<p>{}</p>\n", inline_markdown(trimmed)));
464 }
465 }
466
467 if in_list {
469 html.push_str(&format!("</{}>", list_type));
470 }
471
472 html
473}
474
475fn inline_markdown(text: &str) -> String {
477 let mut result = html_escape(text);
478
479 let mut i = 0;
481 while let Some(start) = result[i..].find('`') {
482 let start = i + start;
483 if let Some(end) = result[start + 1..].find('`') {
484 let end = start + 1 + end;
485 let code = result[start + 1..end].to_string();
486 let code_len = code.len();
487 result = format!("{}<code>{}</code>{}", &result[..start], code, &result[end + 1..]);
488 i = start + 13 + code_len; } else {
490 break;
491 }
492 }
493
494 while let Some(start) = result.find("**") {
496 if let Some(end) = result[start + 2..].find("**") {
497 let end = start + 2 + end;
498 let bold = &result[start + 2..end];
499 result = format!("{}<strong>{}</strong>{}", &result[..start], bold, &result[end + 2..]);
500 } else {
501 break;
502 }
503 }
504
505 while let Some(start) = result.find('[') {
507 if let Some(mid) = result[start..].find("](") {
508 let mid = start + mid;
509 if let Some(end) = result[mid + 2..].find(')') {
510 let end = mid + 2 + end;
511 let text = &result[start + 1..mid];
512 let url = &result[mid + 2..end];
513 result = format!("{}<a href=\"{}\">{}</a>{}", &result[..start], url, text, &result[end + 1..]);
514 continue;
515 }
516 }
517 break;
518 }
519
520 result
521}
522
523fn html_escape(text: &str) -> String {
525 text.replace('&', "&")
526 .replace('<', "<")
527 .replace('>', ">")
528}