Skip to main content

logicaffeine_web/ui/pages/registry/
browse.rs

1//! Package registry browse page.
2//!
3//! Main page for discovering and searching LOGOS packages. Features:
4//!
5//! - Package search with fuzzy matching
6//! - Category filtering
7//! - GitHub OAuth authentication for publishing
8//! - Package cards with download counts and versions
9//!
10//! # Authentication
11//!
12//! Uses GitHub OAuth for publisher authentication. Users can publish packages
13//! under their GitHub username namespace.
14//!
15//! # Route
16//!
17//! Accessed via [`Route::Registry`].
18
19use dioxus::prelude::*;
20#[cfg(all(feature = "split", target_arch = "wasm32"))]
21use dioxus::wasm_split;
22use crate::ui::router::Route;
23use crate::ui::state::{RegistryAuthState, RegistryPackage, GitHubUser};
24use crate::ui::components::main_nav::{MainNav, ActivePage};
25use crate::ui::components::footer::Footer;
26use crate::ui::seo::{JsonLdMultiple, PageHead, organization_schema, webpage_schema, breadcrumb_schema, BreadcrumbItem, pages as seo_pages};
27
28const REGISTRY_API_URL: &str = "https://registry.logicaffeine.com";
29
30const REGISTRY_STYLE: &str = r#"
31.registry-container {
32    min-height: 100vh;
33    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
34    color: #e8e8e8;
35    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
36}
37
38.registry-header {
39    display: flex;
40    justify-content: space-between;
41    align-items: center;
42    padding: 24px 48px;
43    border-bottom: 1px solid rgba(255,255,255,0.1);
44}
45
46.header-left {
47    display: flex;
48    align-items: center;
49    gap: 24px;
50}
51
52.header-left h1 {
53    font-size: 24px;
54    font-weight: 700;
55    background: linear-gradient(135deg, #667eea, #764ba2);
56    -webkit-background-clip: text;
57    -webkit-text-fill-color: transparent;
58    margin: 0;
59}
60
61.back-link {
62    color: #888;
63    text-decoration: none;
64    font-size: 14px;
65}
66
67.back-link:hover {
68    color: #fff;
69}
70
71.login-btn {
72    display: inline-flex;
73    align-items: center;
74    gap: 8px;
75    padding: 10px 20px;
76    background: #24292e;
77    color: white;
78    text-decoration: none;
79    border-radius: 8px;
80    font-size: 14px;
81    font-weight: 500;
82    transition: background 0.2s;
83}
84
85.login-btn:hover {
86    background: #2f363d;
87}
88
89.user-menu {
90    display: flex;
91    align-items: center;
92    gap: 12px;
93}
94
95.user-avatar {
96    width: 36px;
97    height: 36px;
98    border-radius: 50%;
99    border: 2px solid rgba(255,255,255,0.2);
100}
101
102.user-name {
103    font-size: 14px;
104    color: #e8e8e8;
105}
106
107.logout-btn {
108    padding: 6px 12px;
109    background: transparent;
110    border: 1px solid rgba(255,255,255,0.2);
111    color: #888;
112    border-radius: 6px;
113    cursor: pointer;
114    font-size: 13px;
115}
116
117.logout-btn:hover {
118    border-color: rgba(255,255,255,0.4);
119    color: #fff;
120}
121
122.search-section {
123    padding: 48px;
124    text-align: center;
125}
126
127.search-title {
128    font-size: 32px;
129    font-weight: 700;
130    margin-bottom: 8px;
131}
132
133.search-subtitle {
134    color: #888;
135    margin-bottom: 32px;
136}
137
138.search-bar {
139    width: 100%;
140    max-width: 600px;
141    padding: 16px 24px;
142    font-size: 16px;
143    background: rgba(255,255,255,0.05);
144    border: 1px solid rgba(255,255,255,0.1);
145    border-radius: 12px;
146    color: #fff;
147    outline: none;
148    transition: border-color 0.2s, box-shadow 0.2s;
149}
150
151.search-bar:focus {
152    border-color: #667eea;
153    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
154}
155
156.search-bar::placeholder {
157    color: #666;
158}
159
160.package-grid {
161    display: grid;
162    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
163    gap: 24px;
164    padding: 0 48px 48px;
165}
166
167.package-card {
168    background: rgba(255,255,255,0.03);
169    border: 1px solid rgba(255,255,255,0.08);
170    border-radius: 12px;
171    padding: 24px;
172    text-decoration: none;
173    color: inherit;
174    transition: transform 0.2s, border-color 0.2s, background 0.2s;
175}
176
177.package-card:hover {
178    transform: translateY(-2px);
179    border-color: rgba(102, 126, 234, 0.4);
180    background: rgba(255,255,255,0.05);
181}
182
183.package-header {
184    display: flex;
185    justify-content: space-between;
186    align-items: flex-start;
187    margin-bottom: 12px;
188}
189
190.package-name {
191    font-size: 18px;
192    font-weight: 600;
193    color: #fff;
194    margin: 0;
195    display: flex;
196    align-items: center;
197    gap: 8px;
198}
199
200.verified-badge {
201    display: inline-flex;
202    align-items: center;
203    gap: 4px;
204    background: linear-gradient(135deg, #22c55e, #16a34a);
205    color: white;
206    font-size: 11px;
207    font-weight: 700;
208    padding: 3px 8px;
209    border-radius: 999px;
210}
211
212.package-version {
213    font-size: 13px;
214    color: #888;
215    background: rgba(255,255,255,0.05);
216    padding: 4px 8px;
217    border-radius: 4px;
218}
219
220.package-description {
221    color: #aaa;
222    font-size: 14px;
223    line-height: 1.5;
224    margin-bottom: 16px;
225    display: -webkit-box;
226    -webkit-line-clamp: 2;
227    -webkit-box-orient: vertical;
228    overflow: hidden;
229}
230
231.package-meta {
232    display: flex;
233    gap: 16px;
234    font-size: 13px;
235    color: #666;
236}
237
238.package-meta span {
239    display: flex;
240    align-items: center;
241    gap: 4px;
242}
243
244.package-keywords {
245    display: flex;
246    gap: 6px;
247    flex-wrap: wrap;
248    margin-top: 12px;
249}
250
251.keyword-tag {
252    font-size: 11px;
253    padding: 3px 8px;
254    background: rgba(102, 126, 234, 0.15);
255    color: #667eea;
256    border-radius: 4px;
257}
258
259.loading-spinner {
260    text-align: center;
261    padding: 48px;
262    color: #888;
263}
264
265.error-message {
266    text-align: center;
267    padding: 48px;
268    color: #f87171;
269}
270
271.empty-state {
272    text-align: center;
273    padding: 48px;
274    color: #888;
275}
276
277.stats-section {
278    display: flex;
279    justify-content: center;
280    gap: 48px;
281    padding: 24px 48px;
282    border-bottom: 1px solid rgba(255,255,255,0.1);
283}
284
285.stat-item {
286    text-align: center;
287}
288
289.stat-value {
290    font-size: 24px;
291    font-weight: 700;
292    color: #667eea;
293}
294
295.stat-label {
296    font-size: 13px;
297    color: #666;
298}
299"#;
300
301#[component(lazy)]
302pub fn Registry(
303    token: Option<String>,
304    login: Option<String>,
305    error: Option<String>,
306    q: Option<String>,
307) -> Element {
308    let mut auth_state = use_context::<RegistryAuthState>();
309    let auth_state_for_check = auth_state.clone();
310    let mut packages = use_signal(Vec::<RegistryPackage>::new);
311    #[cfg(target_arch = "wasm32")]
312    let q_for_bar = q.clone();
313    let mut search_query = use_signal(move || q.clone().unwrap_or_default());
314    let mut is_loading = use_signal(|| true);
315    #[cfg(target_arch = "wasm32")]
316    let oauth_error = error;
317    let mut error = use_signal(|| None::<String>);
318
319    // Consume OAuth callback params from the route
320    use_effect(move || {
321        #[cfg(target_arch = "wasm32")]
322        {
323            if let (Some(token), Some(login)) = (token.clone(), login.clone()) {
324                // Login successful
325                let user = GitHubUser {
326                    id: String::new(),
327                    login,
328                    name: None,
329                    avatar_url: None,
330                };
331                auth_state.login(token, user);
332            }
333
334            if let Some(err) = oauth_error.clone() {
335                error.set(Some(err));
336            }
337
338            // One-shot OAuth params consumed (or none present): keep the bar
339            // canonical, preserving a shareable ?q= search deep link.
340            let canonical = if q_for_bar.is_some() {
341                crate::ui::router::Route::Registry {
342                    token: None,
343                    login: None,
344                    error: None,
345                    q: q_for_bar.clone(),
346                }
347                .to_string()
348            } else {
349                "/registry".to_string()
350            };
351            crate::ui::router::replace_bar_url(&canonical);
352        }
353    });
354
355    // Fetch packages
356    use_effect(move || {
357        spawn(async move {
358            is_loading.set(true);
359            match fetch_packages(None).await {
360                Ok(pkgs) => packages.set(pkgs),
361                Err(e) => error.set(Some(e)),
362            }
363            is_loading.set(false);
364        });
365    });
366
367    let filtered_packages: Vec<RegistryPackage> = {
368        let query = search_query.read().to_lowercase();
369        if query.is_empty() {
370            packages.read().clone()
371        } else {
372            packages
373                .read()
374                .iter()
375                .filter(|p| {
376                    p.name.to_lowercase().contains(&query)
377                        || p.description
378                            .as_ref()
379                            .map(|d| d.to_lowercase().contains(&query))
380                            .unwrap_or(false)
381                        || p.keywords.iter().any(|k| k.to_lowercase().contains(&query))
382                })
383                .cloned()
384                .collect()
385        }
386    };
387
388    let breadcrumbs = vec![
389        BreadcrumbItem { name: "Home", path: "/" },
390        BreadcrumbItem { name: "Registry", path: "/registry" },
391    ];
392    let schemas = vec![
393        organization_schema(),
394        webpage_schema("Package Registry", "Browse and discover community-contributed logic modules and packages for LOGICAFFEINE.", "/registry"),
395        breadcrumb_schema(&breadcrumbs),
396    ];
397
398    rsx! {
399        PageHead {
400            title: seo_pages::REGISTRY.title,
401            description: seo_pages::REGISTRY.description,
402            canonical_path: seo_pages::REGISTRY.canonical_path,
403        }
404        style { "{REGISTRY_STYLE}" }
405        JsonLdMultiple { schemas }
406
407        div { class: "registry-container",
408            MainNav {
409                active: ActivePage::Registry,
410                subtitle: Some("Package Registry"),
411            }
412
413            // Registry auth section (kept separate from main nav)
414            header { class: "registry-header",
415                div { class: "header-left",
416                    h1 { "Package Registry" }
417                }
418                div { class: "header-right",
419                    if auth_state_for_check.is_authenticated() {
420                        UserMenu { auth_state: auth_state_for_check.clone() }
421                    } else {
422                        a {
423                            class: "login-btn",
424                            href: "{RegistryAuthState::get_auth_url()}",
425                            // GitHub icon
426                            svg {
427                                width: "20",
428                                height: "20",
429                                view_box: "0 0 24 24",
430                                fill: "currentColor",
431                                path {
432                                    d: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
433                                }
434                            }
435                            "Login with GitHub"
436                        }
437                    }
438                }
439            }
440
441            div { class: "search-section",
442                h2 { class: "search-title", "Find LOGOS Packages" }
443                p { class: "search-subtitle", "Discover libraries to enhance your logic programs" }
444                input {
445                    class: "search-bar",
446                    r#type: "search",
447                    placeholder: "Search packages by name, description, or keyword...",
448                    value: "{search_query}",
449                    oninput: move |e| search_query.set(e.value()),
450                }
451            }
452
453            if *is_loading.read() {
454                div { class: "loading-spinner", "Loading packages..." }
455            } else if let Some(err) = error.read().as_ref() {
456                div { class: "error-message", "Error: {err}" }
457            } else if filtered_packages.is_empty() {
458                div { class: "empty-state",
459                    if search_query.read().is_empty() {
460                        "No packages published yet. Be the first!"
461                    } else {
462                        "No packages match your search."
463                    }
464                }
465            } else {
466                div { class: "package-grid",
467                    for package in filtered_packages {
468                        PackageCard { package: package }
469                    }
470                }
471            }
472
473            Footer {}
474        }
475    }
476}
477
478#[component]
479fn PackageCard(package: RegistryPackage) -> Element {
480    rsx! {
481        Link {
482            to: Route::PackageDetail { name: package.name.clone() },
483            class: "package-card",
484            div { class: "package-header",
485                h3 { class: "package-name",
486                    "{package.name}"
487                    if package.verified {
488                        span { class: "verified-badge", "Official" }
489                    }
490                }
491                if let Some(version) = &package.latest_version {
492                    span { class: "package-version", "v{version}" }
493                }
494            }
495            p { class: "package-description",
496                "{package.description.as_deref().unwrap_or(\"No description\")}"
497            }
498            div { class: "package-meta",
499                span { "{package.downloads} downloads" }
500                span { "by {package.owner}" }
501            }
502            if !package.keywords.is_empty() {
503                div { class: "package-keywords",
504                    for keyword in package.keywords.iter().take(3) {
505                        span { class: "keyword-tag", "{keyword}" }
506                    }
507                }
508            }
509        }
510    }
511}
512
513#[component]
514fn UserMenu(auth_state: RegistryAuthState) -> Element {
515    let user = auth_state.user.read().clone();
516    let auth_for_logout = auth_state.clone();
517
518    rsx! {
519        div { class: "user-menu",
520            if let Some(u) = user.as_ref() {
521                if let Some(avatar) = &u.avatar_url {
522                    img {
523                        class: "user-avatar",
524                        src: "{avatar}",
525                        alt: "{u.login}"
526                    }
527                }
528                span { class: "user-name", "{u.login}" }
529            }
530            button {
531                class: "logout-btn",
532                onclick: move |_| {
533                    let mut auth = auth_for_logout.clone();
534                    auth.logout();
535                },
536                "Logout"
537            }
538        }
539    }
540}
541
542async fn fetch_packages(search: Option<&str>) -> Result<Vec<RegistryPackage>, String> {
543    #[cfg(target_arch = "wasm32")]
544    {
545        use gloo_net::http::Request;
546
547        let url = match search {
548            Some(q) if !q.is_empty() => format!("{}/packages?search={}", REGISTRY_API_URL, q),
549            _ => format!("{}/packages", REGISTRY_API_URL),
550        };
551
552        let response = Request::get(&url)
553            .send()
554            .await
555            .map_err(|e| e.to_string())?;
556
557        if !response.ok() {
558            return Err("Failed to fetch packages".to_string());
559        }
560
561        #[derive(serde::Deserialize)]
562        struct PackagesResponse {
563            packages: Vec<RegistryPackage>,
564        }
565
566        let data: PackagesResponse = response.json().await.map_err(|e| e.to_string())?;
567        Ok(data.packages)
568    }
569
570    #[cfg(not(target_arch = "wasm32"))]
571    {
572        Ok(vec![])
573    }
574}