skill_web/pages/
skills.rs

1//! Skills browser page with search and filtering
2
3use 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
20/// Convert API skill summary to store skill summary
21fn 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/// Source filter options
41#[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/// Status filter options
73#[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/// Skills browser page component
103#[function_component(SkillsPage)]
104pub fn skills_page() -> Html {
105    let store = use_store_value::<SkillsStore>();
106    let dispatch = use_dispatch::<SkillsStore>();
107
108    // Local filter states
109    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    // Install skill modal
116    let install_modal = use_install_skill_modal();
117
118    // Import config modal
119    let import_modal = use_import_config_modal();
120
121    // Create API client
122    let api = use_memo((), |_| Rc::new(Api::new()));
123
124    // Load data on mount
125    {
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    // Filter and sort skills
151    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                // Search filter
163                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                // Source filter
172                if !source_f.matches(&skill.source) {
173                    return false;
174                }
175
176                // Status filter
177                if !status_f.matches(&skill.status) {
178                    return false;
179                }
180
181                true
182            })
183            .collect();
184
185        // Sort
186        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    // Event handlers
204    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    // Install skill button handlers
264    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    // Import config button handler
279    let on_import_click = {
280        let import_modal = import_modal.clone();
281        Callback::from(move |_: MouseEvent| {
282            import_modal.open();
283        })
284    };
285
286    // Refresh skills list (shared helper)
287    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    // Refresh skills list after installation
310    let on_skill_installed = {
311        let refresh_skills = refresh_skills.clone();
312        Callback::from(move |_name: String| {
313            refresh_skills();
314        })
315    };
316
317    // Refresh skills list after import
318    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            // Modals
328            <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            // Page header
334            <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            // Error alert
364            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            // Search and filters
376            <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            // Loading state
431            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                // Empty state
437                <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                // Skills grid
462                <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/// Skill card props
472#[derive(Properties, PartialEq)]
473struct SkillCardProps {
474    skill: SkillSummary,
475}
476
477/// Skill card component
478#[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    // Format last used
508    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/// Skeleton loader for skill cards
567#[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}