logicaffeine_web/ui/pages/registry/
browse.rs1use 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 use_effect(move || {
321 #[cfg(target_arch = "wasm32")]
322 {
323 if let (Some(token), Some(login)) = (token.clone(), login.clone()) {
324 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 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 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 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 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}