Skip to main content

logicaffeine_web/ui/pages/registry/
package_detail.rs

1//! Package detail page.
2//!
3//! Displays comprehensive information about a single package including:
4//!
5//! - Package metadata (name, author, description)
6//! - README content (rendered markdown)
7//! - Version history with changelogs
8//! - Dependency graph
9//! - Installation instructions
10//!
11//! # Route
12//!
13//! Accessed via [`Route::PackageDetail`]
14//! with `name` and `version` parameters.
15
16use dioxus::prelude::*;
17#[cfg(all(feature = "split", target_arch = "wasm32"))]
18use dioxus::wasm_split;
19use crate::ui::router::Route;
20use crate::ui::state::PackageDetails;
21use crate::ui::components::main_nav::{MainNav, ActivePage};
22use crate::ui::seo::PageHead;
23
24const REGISTRY_API_URL: &str = "https://registry.logicaffeine.com";
25
26const DETAIL_STYLE: &str = r#"
27.detail-container {
28    min-height: 100vh;
29    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
30    color: #e8e8e8;
31    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
32}
33
34.detail-header {
35    padding: 24px 48px;
36    border-bottom: 1px solid rgba(255,255,255,0.1);
37}
38
39.back-link {
40    color: #888;
41    text-decoration: none;
42    font-size: 14px;
43    margin-bottom: 16px;
44    display: inline-block;
45}
46
47.back-link:hover {
48    color: #fff;
49}
50
51.package-title {
52    display: flex;
53    align-items: center;
54    gap: 16px;
55    margin-bottom: 8px;
56}
57
58.package-title h1 {
59    font-size: 32px;
60    font-weight: 700;
61    margin: 0;
62}
63
64.verified-badge {
65    display: inline-flex;
66    align-items: center;
67    gap: 4px;
68    background: linear-gradient(135deg, #22c55e, #16a34a);
69    color: white;
70    font-size: 12px;
71    font-weight: 700;
72    padding: 4px 12px;
73    border-radius: 999px;
74}
75
76.package-meta {
77    display: flex;
78    gap: 24px;
79    color: #888;
80    font-size: 14px;
81}
82
83.package-meta a {
84    color: #667eea;
85    text-decoration: none;
86}
87
88.package-meta a:hover {
89    text-decoration: underline;
90}
91
92.detail-content {
93    display: grid;
94    grid-template-columns: 1fr 320px;
95    gap: 48px;
96    padding: 48px;
97}
98
99.main-content {
100    min-width: 0;
101}
102
103.tab-nav {
104    display: flex;
105    gap: 4px;
106    margin-bottom: 24px;
107    border-bottom: 1px solid rgba(255,255,255,0.1);
108    padding-bottom: 0;
109}
110
111.tab-btn {
112    padding: 12px 24px;
113    background: transparent;
114    border: none;
115    color: #888;
116    font-size: 14px;
117    cursor: pointer;
118    border-bottom: 2px solid transparent;
119    margin-bottom: -1px;
120    transition: color 0.2s, border-color 0.2s;
121}
122
123.tab-btn:hover {
124    color: #fff;
125}
126
127.tab-btn.active {
128    color: #667eea;
129    border-bottom-color: #667eea;
130}
131
132.readme-content {
133    background: rgba(255,255,255,0.03);
134    border: 1px solid rgba(255,255,255,0.08);
135    border-radius: 12px;
136    padding: 32px;
137    line-height: 1.7;
138}
139
140.readme-content h1, .readme-content h2, .readme-content h3 {
141    color: #fff;
142    margin-top: 24px;
143    margin-bottom: 12px;
144}
145
146.readme-content code {
147    background: rgba(255,255,255,0.1);
148    padding: 2px 6px;
149    border-radius: 4px;
150    font-size: 0.9em;
151}
152
153.readme-content pre {
154    background: rgba(0,0,0,0.3);
155    padding: 16px;
156    border-radius: 8px;
157    overflow-x: auto;
158}
159
160.versions-list {
161    background: rgba(255,255,255,0.03);
162    border: 1px solid rgba(255,255,255,0.08);
163    border-radius: 12px;
164    overflow: hidden;
165}
166
167.version-item {
168    display: flex;
169    justify-content: space-between;
170    align-items: center;
171    padding: 16px 24px;
172    border-bottom: 1px solid rgba(255,255,255,0.05);
173}
174
175.version-item:last-child {
176    border-bottom: none;
177}
178
179.version-name {
180    font-weight: 600;
181    color: #fff;
182}
183
184.version-meta {
185    display: flex;
186    gap: 16px;
187    font-size: 13px;
188    color: #666;
189}
190
191.yanked-badge {
192    background: rgba(239, 68, 68, 0.2);
193    color: #ef4444;
194    padding: 2px 8px;
195    border-radius: 4px;
196    font-size: 11px;
197}
198
199.sidebar {
200    position: sticky;
201    top: 24px;
202}
203
204.sidebar-section {
205    background: rgba(255,255,255,0.03);
206    border: 1px solid rgba(255,255,255,0.08);
207    border-radius: 12px;
208    padding: 24px;
209    margin-bottom: 24px;
210}
211
212.sidebar-section h3 {
213    font-size: 14px;
214    font-weight: 600;
215    color: #888;
216    text-transform: uppercase;
217    letter-spacing: 0.5px;
218    margin: 0 0 16px;
219}
220
221.install-cmd {
222    display: block;
223    background: rgba(0,0,0,0.3);
224    padding: 12px 16px;
225    border-radius: 8px;
226    font-family: monospace;
227    font-size: 14px;
228    color: #22c55e;
229    word-break: break-all;
230}
231
232.meta-item {
233    display: flex;
234    justify-content: space-between;
235    padding: 8px 0;
236    font-size: 14px;
237    border-bottom: 1px solid rgba(255,255,255,0.05);
238}
239
240.meta-item:last-child {
241    border-bottom: none;
242}
243
244.meta-label {
245    color: #888;
246}
247
248.meta-value {
249    color: #fff;
250}
251
252.meta-value a {
253    color: #667eea;
254    text-decoration: none;
255}
256
257.meta-value a:hover {
258    text-decoration: underline;
259}
260
261.loading-spinner {
262    text-align: center;
263    padding: 48px;
264    color: #888;
265}
266
267.error-message {
268    text-align: center;
269    padding: 48px;
270    color: #f87171;
271}
272
273.keywords-list {
274    display: flex;
275    flex-wrap: wrap;
276    gap: 8px;
277}
278
279.keyword-tag {
280    font-size: 12px;
281    padding: 4px 10px;
282    background: rgba(102, 126, 234, 0.15);
283    color: #667eea;
284    border-radius: 4px;
285}
286"#;
287
288#[derive(Clone, PartialEq)]
289enum DetailTab {
290    Readme,
291    Versions,
292}
293
294#[component(lazy)]
295pub fn PackageDetail(name: String) -> Element {
296    let mut details = use_signal(|| None::<PackageDetails>);
297    let mut error = use_signal(|| None::<String>);
298    let mut is_loading = use_signal(|| true);
299    let mut active_tab = use_signal(|| DetailTab::Readme);
300
301    // Fetch package details
302    use_effect({
303        let name = name.clone();
304        move || {
305            let name = name.clone();
306            spawn(async move {
307                is_loading.set(true);
308                match fetch_package_details(&name).await {
309                    Ok(d) => details.set(Some(d)),
310                    Err(e) => error.set(Some(e)),
311                }
312                is_loading.set(false);
313            });
314        }
315    });
316
317    let page_title = format!("{} - LOGICAFFEINE Registry", name);
318    let page_path = format!("/registry/package/{}", name);
319
320    rsx! {
321        PageHead {
322            title: page_title,
323            description: "Package details and documentation on the LOGICAFFEINE package registry.",
324            canonical_path: page_path,
325        }
326        style { "{DETAIL_STYLE}" }
327
328        MainNav { active: ActivePage::Registry, subtitle: Some("Package Details") }
329
330        div { class: "detail-container",
331            if *is_loading.read() {
332                div { class: "loading-spinner", "Loading package..." }
333            } else if let Some(err) = error.read().as_ref() {
334                div { class: "error-message",
335                    p { "Error: {err}" }
336                }
337            } else if let Some(pkg) = details.read().as_ref() {
338                // Header
339                header { class: "detail-header",
340                    div { class: "package-title",
341                        h1 { "{pkg.name}" }
342                        if pkg.verified {
343                            span { class: "verified-badge", "Official" }
344                        }
345                    }
346                    div { class: "package-meta",
347                        span { "by {pkg.owner}" }
348                        if let Some(repo) = &pkg.repository {
349                            a { href: "{repo}", target: "_blank", "Repository" }
350                        }
351                        if let Some(license) = &pkg.license {
352                            span { "{license}" }
353                        }
354                    }
355                }
356
357                // Content
358                div { class: "detail-content",
359                    main { class: "main-content",
360                        nav { class: "tab-nav",
361                            button {
362                                class: if *active_tab.read() == DetailTab::Readme { "tab-btn active" } else { "tab-btn" },
363                                onclick: move |_| active_tab.set(DetailTab::Readme),
364                                "README"
365                            }
366                            button {
367                                class: if *active_tab.read() == DetailTab::Versions { "tab-btn active" } else { "tab-btn" },
368                                onclick: move |_| active_tab.set(DetailTab::Versions),
369                                "Versions ({pkg.versions.len()})"
370                            }
371                        }
372
373                        match *active_tab.read() {
374                            DetailTab::Readme => rsx! {
375                                div { class: "readme-content",
376                                    if let Some(readme) = &pkg.readme {
377                                        // Note: In production, render markdown properly
378                                        pre { "{readme}" }
379                                    } else {
380                                        p { "No README available." }
381                                    }
382                                }
383                            },
384                            DetailTab::Versions => rsx! {
385                                div { class: "versions-list",
386                                    for version in pkg.versions.iter() {
387                                        div { class: "version-item",
388                                            span { class: "version-name",
389                                                "v{version.version}"
390                                                if version.yanked {
391                                                    span { class: "yanked-badge", "yanked" }
392                                                }
393                                            }
394                                            div { class: "version-meta",
395                                                span { "{format_size(version.size)}" }
396                                                span { "{version.published_at}" }
397                                            }
398                                        }
399                                    }
400                                }
401                            },
402                        }
403                    }
404
405                    aside { class: "sidebar",
406                        div { class: "sidebar-section",
407                            h3 { "Install" }
408                            code { class: "install-cmd", "largo add {pkg.name}" }
409                        }
410
411                        div { class: "sidebar-section",
412                            h3 { "Details" }
413                            div { class: "meta-item",
414                                span { class: "meta-label", "Downloads" }
415                                span { class: "meta-value", "{pkg.downloads}" }
416                            }
417                            if !pkg.versions.is_empty() {
418                                div { class: "meta-item",
419                                    span { class: "meta-label", "Latest" }
420                                    span { class: "meta-value", "v{pkg.versions[0].version}" }
421                                }
422                            }
423                            if let Some(homepage) = &pkg.homepage {
424                                div { class: "meta-item",
425                                    span { class: "meta-label", "Homepage" }
426                                    span { class: "meta-value",
427                                        a { href: "{homepage}", target: "_blank", "Link" }
428                                    }
429                                }
430                            }
431                        }
432
433                        if !pkg.keywords.is_empty() {
434                            div { class: "sidebar-section",
435                                h3 { "Keywords" }
436                                div { class: "keywords-list",
437                                    for keyword in pkg.keywords.iter() {
438                                        span { class: "keyword-tag", "{keyword}" }
439                                    }
440                                }
441                            }
442                        }
443                    }
444                }
445            }
446        }
447    }
448}
449
450fn format_size(bytes: u64) -> String {
451    if bytes < 1024 {
452        format!("{} B", bytes)
453    } else if bytes < 1024 * 1024 {
454        format!("{:.1} KB", bytes as f64 / 1024.0)
455    } else {
456        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
457    }
458}
459
460async fn fetch_package_details(name: &str) -> Result<PackageDetails, String> {
461    #[cfg(target_arch = "wasm32")]
462    {
463        use gloo_net::http::Request;
464
465        let url = format!("{}/packages/{}", REGISTRY_API_URL, name);
466
467        let response = Request::get(&url)
468            .send()
469            .await
470            .map_err(|e| e.to_string())?;
471
472        if !response.ok() {
473            return Err("Package not found".to_string());
474        }
475
476        response.json().await.map_err(|e| e.to_string())
477    }
478
479    #[cfg(not(target_arch = "wasm32"))]
480    {
481        Err("Not available in non-WASM builds".to_string())
482    }
483}