1use std::rc::Rc;
4use wasm_bindgen_futures::spawn_local;
5use yew::prelude::*;
6use yew_router::prelude::*;
7use yewdux::prelude::*;
8
9use crate::api::{Api, SkillSummary as ApiSkillSummary};
10use crate::components::card::Card;
11use crate::components::icons::{PlusIcon, SearchIcon, SkillsIcon, PlayIcon};
12use crate::components::{
13 ImportConfigModal, InstallSkillModal, use_import_config_modal, use_install_skill_modal,
14};
15use crate::router::Route;
16use crate::store::skills::{
17 SkillRuntime, SkillSortBy, SkillStatus, SkillSummary, SkillsAction, SkillsStore,
18};
19
20fn api_to_store_skill(api: ApiSkillSummary) -> SkillSummary {
22 SkillSummary {
23 name: api.name,
24 version: api.version,
25 description: api.description,
26 source: api.source,
27 runtime: match api.runtime.as_str() {
28 "docker" => SkillRuntime::Docker,
29 "native" => SkillRuntime::Native,
30 _ => SkillRuntime::Wasm,
31 },
32 tools_count: api.tools_count,
33 instances_count: api.instances_count,
34 status: SkillStatus::Configured,
35 last_used: api.last_used,
36 execution_count: api.execution_count,
37 }
38}
39
40#[derive(Clone, PartialEq, Default)]
42pub enum SourceFilter {
43 #[default]
44 All,
45 GitHub,
46 Local,
47 Http,
48}
49
50impl SourceFilter {
51 fn matches(&self, source: &str) -> bool {
52 match self {
53 SourceFilter::All => true,
54 SourceFilter::GitHub => {
55 source.starts_with("github:") || source.contains("github.com")
56 }
57 SourceFilter::Local => source.starts_with("local:") || source.starts_with("./"),
58 SourceFilter::Http => source.starts_with("http://") || source.starts_with("https://"),
59 }
60 }
61
62 fn label(&self) -> &'static str {
63 match self {
64 SourceFilter::All => "All Sources",
65 SourceFilter::GitHub => "GitHub",
66 SourceFilter::Local => "Local",
67 SourceFilter::Http => "HTTP",
68 }
69 }
70}
71
72#[derive(Clone, PartialEq, Default)]
74pub enum StatusFilter {
75 #[default]
76 All,
77 Configured,
78 Unconfigured,
79 Error,
80}
81
82impl StatusFilter {
83 fn matches(&self, status: &SkillStatus) -> bool {
84 match self {
85 StatusFilter::All => true,
86 StatusFilter::Configured => matches!(status, SkillStatus::Configured),
87 StatusFilter::Unconfigured => matches!(status, SkillStatus::Unconfigured),
88 StatusFilter::Error => matches!(status, SkillStatus::Error),
89 }
90 }
91
92 fn label(&self) -> &'static str {
93 match self {
94 StatusFilter::All => "All Status",
95 StatusFilter::Configured => "Configured",
96 StatusFilter::Unconfigured => "Unconfigured",
97 StatusFilter::Error => "Error",
98 }
99 }
100}
101
102#[function_component(SkillsPage)]
104pub fn skills_page() -> Html {
105 let store = use_store_value::<SkillsStore>();
106 let dispatch = use_dispatch::<SkillsStore>();
107
108 let search_query = use_state(String::new);
110 let source_filter = use_state(SourceFilter::default);
111 let status_filter = use_state(StatusFilter::default);
112 let sort_by = use_state(|| SkillSortBy::Name);
113 let sort_ascending = use_state(|| true);
114
115 let install_modal = use_install_skill_modal();
117
118 let import_modal = use_import_config_modal();
120
121 let api = use_memo((), |_| Rc::new(Api::new()));
123
124 {
126 let api = api.clone();
127 let dispatch = dispatch.clone();
128
129 use_effect_with((), move |_| {
130 dispatch.apply(SkillsAction::SetLoading(true));
131
132 let api = api.clone();
133 let dispatch = dispatch.clone();
134
135 spawn_local(async move {
136 match api.skills.list_all().await {
137 Ok(skills) => {
138 let store_skills: Vec<SkillSummary> =
139 skills.into_iter().map(api_to_store_skill).collect();
140 dispatch.apply(SkillsAction::SetSkills(store_skills));
141 }
142 Err(e) => {
143 dispatch.apply(SkillsAction::SetError(Some(e.to_string())));
144 }
145 }
146 });
147 });
148 }
149
150 let filtered_skills: Vec<&SkillSummary> = {
152 let query = (*search_query).to_lowercase();
153 let source_f = (*source_filter).clone();
154 let status_f = (*status_filter).clone();
155 let sort = (*sort_by).clone();
156 let ascending = *sort_ascending;
157
158 let mut skills: Vec<&SkillSummary> = store
159 .skills
160 .iter()
161 .filter(|skill| {
162 if !query.is_empty() {
164 let matches_name = skill.name.to_lowercase().contains(&query);
165 let matches_desc = skill.description.to_lowercase().contains(&query);
166 if !matches_name && !matches_desc {
167 return false;
168 }
169 }
170
171 if !source_f.matches(&skill.source) {
173 return false;
174 }
175
176 if !status_f.matches(&skill.status) {
178 return false;
179 }
180
181 true
182 })
183 .collect();
184
185 skills.sort_by(|a, b| {
187 let cmp = match sort {
188 SkillSortBy::Name => a.name.cmp(&b.name),
189 SkillSortBy::LastUsed => a.last_used.cmp(&b.last_used),
190 SkillSortBy::ExecutionCount => a.execution_count.cmp(&b.execution_count),
191 SkillSortBy::ToolsCount => a.tools_count.cmp(&b.tools_count),
192 };
193 if ascending {
194 cmp
195 } else {
196 cmp.reverse()
197 }
198 });
199
200 skills
201 };
202
203 let on_search = {
205 let search_query = search_query.clone();
206 Callback::from(move |e: InputEvent| {
207 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
208 search_query.set(input.value());
209 })
210 };
211
212 let on_source_filter = {
213 let source_filter = source_filter.clone();
214 Callback::from(move |e: Event| {
215 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
216 let filter = match select.value().as_str() {
217 "github" => SourceFilter::GitHub,
218 "local" => SourceFilter::Local,
219 "http" => SourceFilter::Http,
220 _ => SourceFilter::All,
221 };
222 source_filter.set(filter);
223 })
224 };
225
226 let on_status_filter = {
227 let status_filter = status_filter.clone();
228 Callback::from(move |e: Event| {
229 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
230 let filter = match select.value().as_str() {
231 "configured" => StatusFilter::Configured,
232 "unconfigured" => StatusFilter::Unconfigured,
233 "error" => StatusFilter::Error,
234 _ => StatusFilter::All,
235 };
236 status_filter.set(filter);
237 })
238 };
239
240 let on_sort = {
241 let sort_by = sort_by.clone();
242 let sort_ascending = sort_ascending.clone();
243 Callback::from(move |e: Event| {
244 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
245 let (new_sort, ascending) = match select.value().as_str() {
246 "name_asc" => (SkillSortBy::Name, true),
247 "name_desc" => (SkillSortBy::Name, false),
248 "last_used" => (SkillSortBy::LastUsed, false),
249 "executions" => (SkillSortBy::ExecutionCount, false),
250 "tools" => (SkillSortBy::ToolsCount, false),
251 _ => (SkillSortBy::Name, true),
252 };
253 sort_by.set(new_sort);
254 sort_ascending.set(ascending);
255 })
256 };
257
258 let total_count = store.skills.len();
259 let filtered_count = filtered_skills.len();
260 let is_loading = store.loading;
261 let error = store.error.clone();
262
263 let on_install_click = {
265 let install_modal = install_modal.clone();
266 Callback::from(move |_| {
267 install_modal.open();
268 })
269 };
270
271 let on_install_click_empty = {
272 let install_modal = install_modal.clone();
273 Callback::from(move |_| {
274 install_modal.open();
275 })
276 };
277
278 let on_import_click = {
280 let import_modal = import_modal.clone();
281 Callback::from(move |_: MouseEvent| {
282 import_modal.open();
283 })
284 };
285
286 let refresh_skills = {
288 let api = api.clone();
289 let dispatch = dispatch.clone();
290 move || {
291 let api = api.clone();
292 let dispatch = dispatch.clone();
293 dispatch.apply(SkillsAction::SetLoading(true));
294 spawn_local(async move {
295 match api.skills.list_all().await {
296 Ok(skills) => {
297 let store_skills: Vec<SkillSummary> =
298 skills.into_iter().map(api_to_store_skill).collect();
299 dispatch.apply(SkillsAction::SetSkills(store_skills));
300 }
301 Err(e) => {
302 dispatch.apply(SkillsAction::SetError(Some(e.to_string())));
303 }
304 }
305 });
306 }
307 };
308
309 let on_skill_installed = {
311 let refresh_skills = refresh_skills.clone();
312 Callback::from(move |_name: String| {
313 refresh_skills();
314 })
315 };
316
317 let on_config_imported = {
319 let refresh_skills = refresh_skills.clone();
320 Callback::from(move |_count: usize| {
321 refresh_skills();
322 })
323 };
324
325 html! {
326 <>
327 <InstallSkillModal on_installed={on_skill_installed} />
329 <ImportConfigModal on_imported={on_config_imported} />
330
331 <div class="space-y-6 animate-fade-in">
332
333 <div class="flex items-center justify-between">
335 <div>
336 <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
337 { "Skills" }
338 </h1>
339 <p class="text-gray-500 dark:text-gray-400 mt-1">
340 if is_loading {
341 { "Loading skills..." }
342 } else if filtered_count != total_count {
343 { format!("Showing {} of {} skills", filtered_count, total_count) }
344 } else {
345 { format!("{} skills installed", total_count) }
346 }
347 </p>
348 </div>
349 <div class="flex items-center gap-3">
350 <button class="btn btn-secondary" onclick={on_import_click}>
351 <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
352 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
353 </svg>
354 { "Import Config" }
355 </button>
356 <button class="btn btn-primary" onclick={on_install_click}>
357 <PlusIcon class="w-4 h-4 mr-2" />
358 { "Install Skill" }
359 </button>
360 </div>
361 </div>
362
363 if let Some(err) = error {
365 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
366 <div class="flex items-center gap-3">
367 <svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
368 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
369 </svg>
370 <p class="text-sm text-red-700 dark:text-red-300">{ err }</p>
371 </div>
372 </div>
373 }
374
375 <Card>
377 <div class="flex flex-col md:flex-row gap-4">
378 <div class="flex-1 relative">
379 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
380 <SearchIcon class="w-5 h-5 text-gray-400" />
381 </div>
382 <input
383 type="text"
384 placeholder="Search skills by name or description..."
385 class="input pl-10"
386 value={(*search_query).clone()}
387 oninput={on_search}
388 />
389 </div>
390 <div class="flex gap-2 flex-wrap">
391 <select class="input w-auto" onchange={on_source_filter}>
392 <option value="all" selected={*source_filter == SourceFilter::All}>
393 { SourceFilter::All.label() }
394 </option>
395 <option value="github" selected={*source_filter == SourceFilter::GitHub}>
396 { SourceFilter::GitHub.label() }
397 </option>
398 <option value="local" selected={*source_filter == SourceFilter::Local}>
399 { SourceFilter::Local.label() }
400 </option>
401 <option value="http" selected={*source_filter == SourceFilter::Http}>
402 { SourceFilter::Http.label() }
403 </option>
404 </select>
405 <select class="input w-auto" onchange={on_status_filter}>
406 <option value="all" selected={*status_filter == StatusFilter::All}>
407 { StatusFilter::All.label() }
408 </option>
409 <option value="configured" selected={*status_filter == StatusFilter::Configured}>
410 { StatusFilter::Configured.label() }
411 </option>
412 <option value="unconfigured" selected={*status_filter == StatusFilter::Unconfigured}>
413 { StatusFilter::Unconfigured.label() }
414 </option>
415 <option value="error" selected={*status_filter == StatusFilter::Error}>
416 { StatusFilter::Error.label() }
417 </option>
418 </select>
419 <select class="input w-auto" onchange={on_sort}>
420 <option value="name_asc">{ "Name (A-Z)" }</option>
421 <option value="name_desc">{ "Name (Z-A)" }</option>
422 <option value="last_used">{ "Last Used" }</option>
423 <option value="executions">{ "Most Executions" }</option>
424 <option value="tools">{ "Most Tools" }</option>
425 </select>
426 </div>
427 </div>
428 </Card>
429
430 if is_loading {
432 <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
433 { for (0..4).map(|_| html! { <SkillCardSkeleton /> }) }
434 </div>
435 } else if filtered_skills.is_empty() {
436 <div class="text-center py-12">
438 <SkillsIcon class="w-12 h-12 mx-auto text-gray-400" />
439 <h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">
440 if total_count == 0 {
441 { "No skills installed" }
442 } else {
443 { "No skills match your filters" }
444 }
445 </h3>
446 <p class="mt-2 text-gray-500 dark:text-gray-400">
447 if total_count == 0 {
448 { "Install your first skill to get started" }
449 } else {
450 { "Try adjusting your search or filters" }
451 }
452 </p>
453 if total_count == 0 {
454 <button class="btn btn-primary mt-4" onclick={on_install_click_empty}>
455 <PlusIcon class="w-4 h-4 mr-2" />
456 { "Install Skill" }
457 </button>
458 }
459 </div>
460 } else {
461 <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
463 { for filtered_skills.iter().map(|skill| html! { <SkillCard skill={(*skill).clone()} /> }) }
464 </div>
465 }
466 </div>
467 </>
468 }
469}
470
471#[derive(Properties, PartialEq)]
473struct SkillCardProps {
474 skill: SkillSummary,
475}
476
477#[function_component(SkillCard)]
479fn skill_card(props: &SkillCardProps) -> Html {
480 let skill = &props.skill;
481
482 let (status_badge, status_dot) = match skill.status {
483 SkillStatus::Configured => (
484 html! { <span class="badge badge-success">{ "Configured" }</span> },
485 "status-dot-success",
486 ),
487 SkillStatus::Unconfigured => (
488 html! { <span class="badge badge-warning">{ "Unconfigured" }</span> },
489 "status-dot-warning",
490 ),
491 SkillStatus::Error => (
492 html! { <span class="badge badge-error">{ "Error" }</span> },
493 "status-dot-error",
494 ),
495 SkillStatus::Loading => (
496 html! { <span class="badge badge-info">{ "Loading" }</span> },
497 "status-dot-info",
498 ),
499 };
500
501 let runtime_badge = match skill.runtime {
502 SkillRuntime::Wasm => html! { <span class="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">{ "WASM" }</span> },
503 SkillRuntime::Docker => html! { <span class="text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">{ "Docker" }</span> },
504 SkillRuntime::Native => html! { <span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded">{ "Native" }</span> },
505 };
506
507 let last_used_str = skill
509 .last_used
510 .as_ref()
511 .map(|s| {
512 if s.len() > 10 {
513 s[..10].to_string()
514 } else {
515 s.clone()
516 }
517 })
518 .unwrap_or_else(|| "Never".to_string());
519
520 html! {
521 <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow">
522 <div class="p-6">
523 <div class="flex items-start justify-between">
524 <div class="flex items-center gap-3">
525 <span class={classes!("status-dot", status_dot)} />
526 <div>
527 <Link<Route>
528 to={Route::SkillDetail { name: skill.name.clone() }}
529 classes="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400"
530 >
531 { &skill.name }
532 </Link<Route>>
533 <div class="flex items-center gap-2 mt-0.5">
534 <span class="text-xs text-gray-500 dark:text-gray-400 font-mono">{ format!("v{}", &skill.version) }</span>
535 { runtime_badge }
536 </div>
537 </div>
538 </div>
539 { status_badge }
540 </div>
541
542 <p class="mt-4 text-sm text-gray-600 dark:text-gray-300 line-clamp-2 h-10">
543 { &skill.description }
544 </p>
545
546 <div class="mt-4 flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-800">
547 <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
548 <span title="Tools">{ format!("{} tools", skill.tools_count) }</span>
549 <span>{ "•" }</span>
550 <span title="Process Count">{ format!("{} instances", skill.instances_count) }</span>
551 </div>
552
553 <Link<Route>
554 to={Route::RunSkill { skill: skill.name.clone() }}
555 classes="btn btn-sm btn-primary flex items-center gap-1.5"
556 >
557 <PlayIcon class="w-3 h-3" />
558 { "Run" }
559 </Link<Route>>
560 </div>
561 </div>
562 </div>
563 }
564}
565
566#[function_component(SkillCardSkeleton)]
568fn skill_card_skeleton() -> Html {
569 html! {
570 <div class="card p-6 animate-pulse">
571 <div class="flex items-start justify-between">
572 <div class="flex items-center gap-3">
573 <div class="w-3 h-3 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
574 <div>
575 <div class="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
576 <div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded mt-1"></div>
577 </div>
578 </div>
579 <div class="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
580 </div>
581 <div class="mt-3 space-y-2">
582 <div class="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
583 <div class="h-4 w-2/3 bg-gray-200 dark:bg-gray-700 rounded"></div>
584 </div>
585 <div class="mt-2 h-3 w-48 bg-gray-200 dark:bg-gray-700 rounded"></div>
586 <div class="mt-4 flex items-center gap-4">
587 <div class="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
588 <div class="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
589 </div>
590 </div>
591 }
592}