1use 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 #[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 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 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 let tag_match = match &tag_filter {
419 Some(tag) => article.tags.contains(&tag.as_str()),
420 None => true,
421 };
422
423 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 aside { class: "news-sidebar",
462 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 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 { class: "news-main",
514 header { class: "news-header",
515 h1 { "News" }
516 p { "Latest updates, release notes, and announcements" }
517
518 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 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}