Skip to main content

logicaffeine_web/ui/pages/news/
article.rs

1//! Individual news article page.
2
3use 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                                    // Article footer with browse tags
354                                    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
395/// Simple markdown to HTML converter for article content
396fn 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        // Code blocks
406        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        // Close list if line doesn't start with list marker
424        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        // Headers
430        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        // Blockquotes
438        else if trimmed.starts_with("> ") {
439            html.push_str(&format!("<blockquote>{}</blockquote>\n", &trimmed[2..]));
440        }
441        // Unordered lists
442        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        // Ordered lists
451        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        // Paragraphs (non-empty lines)
462        else if !trimmed.is_empty() {
463            html.push_str(&format!("<p>{}</p>\n", inline_markdown(trimmed)));
464        }
465    }
466
467    // Close any remaining list
468    if in_list {
469        html.push_str(&format!("</{}>", list_type));
470    }
471
472    html
473}
474
475/// Process inline markdown (bold, italic, code, links)
476fn inline_markdown(text: &str) -> String {
477    let mut result = html_escape(text);
478
479    // Code (backticks) - must be done first
480    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; // Skip past the inserted code tag
489        } else {
490            break;
491        }
492    }
493
494    // Bold (**text**)
495    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    // Links [text](url)
506    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
523/// Escape HTML special characters
524fn html_escape(text: &str) -> String {
525    text.replace('&', "&amp;")
526        .replace('<', "&lt;")
527        .replace('>', "&gt;")
528}