Skip to main content

logicaffeine_web/ui/pages/news/
index.rs

1//! News index page - displays list of all articles.
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, BreadcrumbItem, pages as seo_pages};
9use crate::ui::router::Route;
10use super::data::{get_articles, get_all_tags, get_articles_by_tag, format_tag};
11
12const NEWS_STYLES: &str = r#"
13.news-page {
14    min-height: 100vh;
15    background: var(--bg-dark, #060814);
16    display: flex;
17    flex-direction: column;
18}
19
20.news-layout {
21    flex: 1;
22    display: flex;
23    max-width: 1200px;
24    margin: 0 auto;
25    padding: 48px 24px;
26    width: 100%;
27    gap: 48px;
28}
29
30/* Sidebar - Desktop only */
31.news-sidebar {
32    width: 240px;
33    flex-shrink: 0;
34    position: sticky;
35    top: 100px;
36    height: fit-content;
37}
38
39.news-sidebar-section {
40    margin-bottom: 32px;
41}
42
43.news-sidebar-title {
44    font-size: var(--font-caption-md, 12px);
45    font-weight: 600;
46    color: var(--text-tertiary, #909090);
47    text-transform: uppercase;
48    letter-spacing: 1px;
49    margin: 0 0 16px;
50    padding-bottom: 8px;
51    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
52}
53
54.news-search {
55    position: relative;
56    margin-bottom: 24px;
57}
58
59.news-search-input {
60    width: 100%;
61    padding: 10px 14px 10px 38px;
62    background: rgba(255, 255, 255, 0.05);
63    border: 1px solid rgba(255, 255, 255, 0.1);
64    border-radius: var(--radius-lg, 12px);
65    color: var(--text-primary, #f0f0f0);
66    font-size: var(--font-body-sm, 14px);
67    outline: none;
68    transition: all 0.2s ease;
69}
70
71.news-search-input::placeholder {
72    color: var(--text-tertiary, #909090);
73}
74
75.news-search-input:focus {
76    border-color: rgba(102, 126, 234, 0.5);
77    background: rgba(255, 255, 255, 0.08);
78}
79
80.news-search-icon {
81    position: absolute;
82    left: 12px;
83    top: 50%;
84    transform: translateY(-50%);
85    color: var(--text-tertiary, #909090);
86    pointer-events: none;
87}
88
89.news-tag-list {
90    display: flex;
91    flex-direction: column;
92    gap: 6px;
93}
94
95.news-filter-tag {
96    display: flex;
97    align-items: center;
98    justify-content: space-between;
99    padding: 8px 12px;
100    background: transparent;
101    border: 1px solid transparent;
102    border-radius: var(--radius-md, 8px);
103    color: var(--text-secondary, #b0b0b0);
104    font-size: var(--font-body-sm, 14px);
105    cursor: pointer;
106    transition: all 0.2s ease;
107    text-align: left;
108    width: 100%;
109}
110
111.news-filter-tag:hover {
112    background: rgba(255, 255, 255, 0.05);
113    color: var(--text-primary, #f0f0f0);
114}
115
116.news-filter-tag.active {
117    background: rgba(102, 126, 234, 0.15);
118    border-color: rgba(102, 126, 234, 0.3);
119    color: var(--color-accent-blue, #60a5fa);
120}
121
122.news-filter-tag-count {
123    font-size: 11px;
124    padding: 2px 8px;
125    background: rgba(255, 255, 255, 0.1);
126    border-radius: var(--radius-full, 9999px);
127    color: var(--text-tertiary, #909090);
128}
129
130.news-filter-tag.active .news-filter-tag-count {
131    background: rgba(102, 126, 234, 0.3);
132    color: var(--color-accent-blue, #60a5fa);
133}
134
135.news-clear-filter {
136    margin-top: 12px;
137    padding: 8px 12px;
138    background: transparent;
139    border: 1px solid rgba(255, 255, 255, 0.1);
140    border-radius: var(--radius-md, 8px);
141    color: var(--text-secondary, #b0b0b0);
142    font-size: var(--font-body-sm, 14px);
143    cursor: pointer;
144    transition: all 0.2s ease;
145    width: 100%;
146}
147
148.news-clear-filter:hover {
149    background: rgba(255, 255, 255, 0.05);
150    border-color: rgba(255, 255, 255, 0.2);
151    color: var(--text-primary, #f0f0f0);
152}
153
154/* Main content area */
155.news-main {
156    flex: 1;
157    min-width: 0;
158}
159
160.news-header {
161    margin-bottom: 48px;
162}
163
164.news-header h1 {
165    font-size: var(--font-display-lg, 42px);
166    font-weight: 800;
167    background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.85) 100%);
168    -webkit-background-clip: text;
169    -webkit-text-fill-color: transparent;
170    background-clip: text;
171    margin: 0 0 16px;
172}
173
174.news-header p {
175    color: var(--text-secondary, #b0b0b0);
176    font-size: var(--font-body-lg, 18px);
177    margin: 0;
178}
179
180.news-active-filter {
181    display: flex;
182    align-items: center;
183    gap: 12px;
184    margin-top: 16px;
185    padding: 12px 16px;
186    background: rgba(102, 126, 234, 0.1);
187    border: 1px solid rgba(102, 126, 234, 0.2);
188    border-radius: var(--radius-lg, 12px);
189}
190
191.news-active-filter-label {
192    color: var(--text-secondary, #b0b0b0);
193    font-size: var(--font-body-sm, 14px);
194}
195
196.news-active-filter-tag {
197    display: inline-flex;
198    align-items: center;
199    gap: 8px;
200    padding: 4px 12px;
201    background: rgba(102, 126, 234, 0.2);
202    border-radius: var(--radius-full, 9999px);
203    color: var(--color-accent-blue, #60a5fa);
204    font-size: var(--font-body-sm, 14px);
205    font-weight: 600;
206    letter-spacing: 0.3px;
207}
208
209.news-active-filter-clear {
210    background: transparent;
211    border: none;
212    color: var(--text-tertiary, #909090);
213    cursor: pointer;
214    padding: 2px;
215    display: flex;
216    align-items: center;
217    justify-content: center;
218    transition: color 0.2s ease;
219}
220
221.news-active-filter-clear:hover {
222    color: var(--text-primary, #f0f0f0);
223}
224
225.news-list {
226    display: flex;
227    flex-direction: column;
228    gap: 24px;
229}
230
231.news-card {
232    background: rgba(255, 255, 255, 0.03);
233    border: 1px solid rgba(255, 255, 255, 0.08);
234    border-radius: var(--radius-xl, 16px);
235    padding: 24px;
236    transition: all 0.2s ease;
237    text-decoration: none;
238    display: block;
239}
240
241.news-card:hover {
242    background: rgba(255, 255, 255, 0.06);
243    border-color: rgba(255, 255, 255, 0.12);
244    transform: translateY(-2px);
245}
246
247.news-card-date {
248    font-size: var(--font-caption-md, 12px);
249    color: var(--text-tertiary, #909090);
250    margin-bottom: 8px;
251    text-transform: uppercase;
252    letter-spacing: 0.5px;
253}
254
255.news-card-title {
256    font-size: var(--font-heading-md, 22px);
257    font-weight: 700;
258    color: var(--text-primary, #f0f0f0);
259    margin: 0 0 12px;
260    line-height: 1.3;
261}
262
263.news-card-summary {
264    font-size: var(--font-body-md, 16px);
265    color: var(--text-secondary, #b0b0b0);
266    line-height: 1.6;
267    margin: 0 0 16px;
268}
269
270.news-card-tags {
271    display: flex;
272    gap: 8px;
273    flex-wrap: wrap;
274}
275
276.news-tag {
277    font-size: 11px;
278    padding: 4px 10px;
279    border-radius: var(--radius-full, 9999px);
280    background: rgba(102, 126, 234, 0.15);
281    color: var(--color-accent-blue, #60a5fa);
282    letter-spacing: 0.3px;
283    font-weight: 600;
284}
285
286.news-no-results {
287    text-align: center;
288    padding: 48px 24px;
289    color: var(--text-secondary, #b0b0b0);
290}
291
292.news-no-results h3 {
293    font-size: var(--font-heading-md, 22px);
294    color: var(--text-primary, #f0f0f0);
295    margin: 0 0 12px;
296}
297
298.news-no-results p {
299    margin: 0;
300}
301
302/* Mobile filter (bottom) */
303.news-mobile-filters {
304    display: none;
305    padding: 16px;
306    background: rgba(255, 255, 255, 0.03);
307    border: 1px solid rgba(255, 255, 255, 0.08);
308    border-radius: var(--radius-xl, 16px);
309    margin-bottom: 24px;
310}
311
312.news-mobile-filters-header {
313    display: flex;
314    align-items: center;
315    justify-content: space-between;
316    margin-bottom: 12px;
317}
318
319.news-mobile-filters-title {
320    font-size: var(--font-body-sm, 14px);
321    font-weight: 600;
322    color: var(--text-secondary, #b0b0b0);
323}
324
325.news-mobile-tags {
326    display: flex;
327    gap: 8px;
328    flex-wrap: wrap;
329}
330
331.news-mobile-tag {
332    padding: 6px 12px;
333    background: rgba(255, 255, 255, 0.05);
334    border: 1px solid rgba(255, 255, 255, 0.1);
335    border-radius: var(--radius-full, 9999px);
336    color: var(--text-secondary, #b0b0b0);
337    font-size: 12px;
338    cursor: pointer;
339    transition: all 0.2s ease;
340    letter-spacing: 0.3px;
341    font-weight: 600;
342}
343
344.news-mobile-tag:hover {
345    background: rgba(255, 255, 255, 0.1);
346}
347
348.news-mobile-tag.active {
349    background: rgba(102, 126, 234, 0.2);
350    border-color: rgba(102, 126, 234, 0.4);
351    color: var(--color-accent-blue, #60a5fa);
352}
353
354/* Mobile */
355@media (max-width: 900px) {
356    .news-sidebar {
357        display: none;
358    }
359
360    .news-mobile-filters {
361        display: block;
362    }
363
364    .news-layout {
365        padding: 32px 16px;
366        flex-direction: column;
367        gap: 0;
368    }
369
370    .news-header h1 {
371        font-size: var(--font-display-md, 32px);
372    }
373
374    .news-card {
375        padding: 20px;
376    }
377
378    .news-card-title {
379        font-size: var(--font-heading-sm, 18px);
380    }
381}
382"#;
383
384#[component(lazy)]
385pub fn News(tag: Option<String>) -> Element {
386    // Untagged visits serialize as `/news?` — keep the bar on the canonical path.
387    #[cfg(target_arch = "wasm32")]
388    {
389        let untagged = tag.is_none();
390        use_effect(move || {
391            if untagged {
392                crate::ui::router::replace_bar_url("/news");
393            }
394        });
395    }
396
397    let mut active_tag = use_signal(move || tag.clone());
398    let mut search_query = use_signal(|| String::new());
399
400    let all_tags = get_all_tags();
401    let all_articles = get_articles();
402
403    // Count articles per tag
404    let tag_counts: std::collections::HashMap<&str, usize> = all_tags
405        .iter()
406        .map(|tag| (*tag, get_articles_by_tag(tag).len()))
407        .collect();
408
409    // Filter articles based on active tag and search query
410    let filtered_articles: Vec<_> = {
411        let query = search_query.read().to_lowercase();
412        let tag_filter = active_tag.read().clone();
413
414        all_articles
415            .into_iter()
416            .filter(|article| {
417                // Tag filter
418                let tag_match = match &tag_filter {
419                    Some(tag) => article.tags.contains(&tag.as_str()),
420                    None => true,
421                };
422
423                // Search filter
424                let search_match = if query.is_empty() {
425                    true
426                } else {
427                    article.title.to_lowercase().contains(&query)
428                        || article.summary.to_lowercase().contains(&query)
429                        || article.tags.iter().any(|t| t.to_lowercase().contains(&query))
430                };
431
432                tag_match && search_match
433            })
434            .collect()
435    };
436
437    let breadcrumbs = vec![
438        BreadcrumbItem { name: "Home", path: "/" },
439        BreadcrumbItem { name: "News", path: "/news" },
440    ];
441
442    let schemas = vec![
443        organization_schema(),
444        breadcrumb_schema(&breadcrumbs),
445    ];
446
447    rsx! {
448        PageHead {
449            title: seo_pages::NEWS.title,
450            description: seo_pages::NEWS.description,
451            canonical_path: seo_pages::NEWS.canonical_path,
452        }
453        style { "{NEWS_STYLES}" }
454        JsonLdMultiple { schemas }
455
456        div { class: "news-page",
457            MainNav { active: ActivePage::News, subtitle: Some("Latest updates") }
458
459            div { class: "news-layout",
460                // Desktop sidebar
461                aside { class: "news-sidebar",
462                    // Search
463                    div { class: "news-search",
464                        span { class: "news-search-icon", "🔍" }
465                        input {
466                            class: "news-search-input",
467                            r#type: "text",
468                            placeholder: "Search articles...",
469                            value: "{search_query}",
470                            oninput: move |e| search_query.set(e.value())
471                        }
472                    }
473
474                    // Tags
475                    div { class: "news-sidebar-section",
476                        h3 { class: "news-sidebar-title", "Filter by Topic" }
477                        div { class: "news-tag-list",
478                            for tag in all_tags.iter() {
479                                {
480                                    let tag_str = *tag;
481                                    let tag_display = format_tag(tag_str);
482                                    let is_active = active_tag.read().as_deref() == Some(tag_str);
483                                    let count = tag_counts.get(tag_str).copied().unwrap_or(0);
484                                    rsx! {
485                                        button {
486                                            class: if is_active { "news-filter-tag active" } else { "news-filter-tag" },
487                                            onclick: move |_| {
488                                                if active_tag.read().as_deref() == Some(tag_str) {
489                                                    active_tag.set(None);
490                                                } else {
491                                                    active_tag.set(Some(tag_str.to_string()));
492                                                }
493                                            },
494                                            span { "{tag_display}" }
495                                            span { class: "news-filter-tag-count", "{count}" }
496                                        }
497                                    }
498                                }
499                            }
500                        }
501
502                        if active_tag.read().is_some() {
503                            button {
504                                class: "news-clear-filter",
505                                onclick: move |_| active_tag.set(None),
506                                "Clear filter"
507                            }
508                        }
509                    }
510                }
511
512                // Main content
513                main { class: "news-main",
514                    header { class: "news-header",
515                        h1 { "News" }
516                        p { "Latest updates, release notes, and announcements" }
517
518                        // Active filter indicator
519                        if let Some(tag) = active_tag.read().as_ref() {
520                            {
521                                let tag_display = format_tag(tag);
522                                rsx! {
523                                    div { class: "news-active-filter",
524                                        span { class: "news-active-filter-label", "Showing:" }
525                                        span { class: "news-active-filter-tag",
526                                            "{tag_display}"
527                                            button {
528                                                class: "news-active-filter-clear",
529                                                onclick: move |_| active_tag.set(None),
530                                                "✕"
531                                            }
532                                        }
533                                    }
534                                }
535                            }
536                        }
537                    }
538
539                    // Mobile filters
540                    div { class: "news-mobile-filters",
541                        div { class: "news-mobile-filters-header",
542                            span { class: "news-mobile-filters-title", "Filter by topic" }
543                            if active_tag.read().is_some() {
544                                button {
545                                    class: "news-clear-filter",
546                                    onclick: move |_| active_tag.set(None),
547                                    "Clear"
548                                }
549                            }
550                        }
551                        div { class: "news-mobile-tags",
552                            for tag in all_tags.iter() {
553                                {
554                                    let tag_str = *tag;
555                                    let tag_display = format_tag(tag_str);
556                                    let is_active = active_tag.read().as_deref() == Some(tag_str);
557                                    rsx! {
558                                        button {
559                                            class: if is_active { "news-mobile-tag active" } else { "news-mobile-tag" },
560                                            onclick: move |_| {
561                                                if active_tag.read().as_deref() == Some(tag_str) {
562                                                    active_tag.set(None);
563                                                } else {
564                                                    active_tag.set(Some(tag_str.to_string()));
565                                                }
566                                            },
567                                            "{tag_display}"
568                                        }
569                                    }
570                                }
571                            }
572                        }
573                    }
574
575                    if filtered_articles.is_empty() {
576                        div { class: "news-no-results",
577                            h3 { "No articles found" }
578                            p { "Try adjusting your search or filter." }
579                        }
580                    } else {
581                        div { class: "news-list",
582                            for article in filtered_articles {
583                                Link {
584                                    to: Route::NewsArticle { slug: article.slug.to_string() },
585                                    class: "news-card",
586                                    div { class: "news-card-date", "{article.date}" }
587                                    h2 { class: "news-card-title", "{article.title}" }
588                                    p { class: "news-card-summary", "{article.summary}" }
589                                    div { class: "news-card-tags",
590                                        for tag in article.tags.iter() {
591                                            {
592                                                let tag_display = format_tag(tag);
593                                                rsx! { span { class: "news-tag", "{tag_display}" } }
594                                            }
595                                        }
596                                    }
597                                }
598                            }
599                        }
600                    }
601                }
602            }
603
604            Footer {}
605        }
606    }
607}