1use std::rc::Rc;
12use wasm_bindgen::prelude::*;
13use wasm_bindgen_futures::spawn_local;
14use web_sys::{File, FileReader, HtmlTextAreaElement};
15use yew::prelude::*;
16use yewdux::prelude::*;
17
18use crate::api::{Api, ParsedSkill};
19use crate::components::use_notifications;
20use crate::store::ui::{ModalType, UiAction, UiStore};
21
22#[derive(Clone, PartialEq)]
24enum ImportState {
25 Input,
26 Validating,
27 Preview(Vec<ParsedSkill>),
28 Importing,
29 Complete(usize),
30 Error(String),
31}
32
33#[derive(Clone, PartialEq)]
35enum ValidationStatus {
36 None,
37 Checking,
38 Valid(usize),
39 Invalid(String),
40}
41
42#[derive(Clone, PartialEq)]
44struct ExampleTemplate {
45 id: &'static str,
46 name: &'static str,
47 description: &'static str,
48 badge: &'static str,
49 badge_color: &'static str,
50 content: &'static str,
51}
52
53const EXAMPLE_TEMPLATES: &[ExampleTemplate] = &[
54 ExampleTemplate {
55 id: "quick-start",
56 name: "Quick Start",
57 description: "Working skills you can test now",
58 badge: "Try Now",
59 badge_color: "green",
60 content: r#"# Quick Start - Testable Skills
61# ==============================
62# These skills use local examples that work immediately.
63# Import this to test the UI right away!
64#
65version = "1"
66
67# ─────────────────────────────────────
68# Simple Skill (WASM) - Works Immediately!
69# ─────────────────────────────────────
70# Tools:
71# - hello(name, greeting) → Greet someone
72# - echo(message, repeat) → Echo text
73# - calculate(operation, a, b) → Math
74
75[skills.simple]
76source = "./examples/wasm-skills/simple-skill"
77description = "Simple greeting and utilities - great for testing!"
78
79[skills.simple.instances.default]
80
81# ─────────────────────────────────────
82# GitHub Skill (WASM) - Requires token
83# ─────────────────────────────────────
84# Tools:
85# - list_repos(org)
86# - get_repo(owner, repo)
87# - search_code(query)
88
89[skills.github]
90source = "./examples/wasm-skills/github-skill"
91description = "GitHub API integration"
92
93[skills.github.instances.default]
94config.token = "${GITHUB_TOKEN}"
95
96# ─────────────────────────────────────
97# Docker Management (Native)
98# ─────────────────────────────────────
99# Tools:
100# - ps() → List containers
101# - images() → List images
102# - logs(container) → View logs
103
104[skills.docker-cli]
105source = "./examples/native-skills/docker-skill"
106description = "Docker container management"
107
108[skills.docker-cli.instances.default]
109"#,
110 },
111 ExampleTemplate {
112 id: "docker-runners",
113 name: "Code Runners",
114 description: "Python, Node.js sandboxed",
115 badge: "Docker",
116 badge_color: "purple",
117 content: r#"# Code Runners (Docker Sandboxed)
118# ================================
119# Execute code in isolated containers.
120# Uses example skills from the repository.
121#
122version = "1"
123
124# ─────────────────────────────────────
125# Python Runner
126# ─────────────────────────────────────
127# From: examples/docker-runtime-skills/python-runner
128# Tools:
129# - run(code) → Execute Python code
130# - exec(command) → Run shell command
131
132[skills.python-runner]
133source = "./examples/docker-runtime-skills/python-runner"
134runtime = "docker"
135description = "Execute Python scripts in sandbox"
136
137[skills.python-runner.instances.default]
138
139# ─────────────────────────────────────
140# Node.js Runner
141# ─────────────────────────────────────
142# From: examples/docker-runtime-skills/node-runner
143# Tools:
144# - run(code) → Execute JavaScript
145# - exec(command) → Run shell command
146
147[skills.node-runner]
148source = "./examples/docker-runtime-skills/node-runner"
149runtime = "docker"
150description = "Execute Node.js scripts in sandbox"
151
152[skills.node-runner.instances.default]
153
154# ─────────────────────────────────────
155# FFmpeg (Media Processing)
156# ─────────────────────────────────────
157# From: examples/docker-runtime-skills/ffmpeg-skill
158
159[skills.ffmpeg]
160source = "./examples/docker-runtime-skills/ffmpeg-skill"
161runtime = "docker"
162description = "Video/audio processing with FFmpeg"
163
164[skills.ffmpeg.instances.default]
165
166# ─────────────────────────────────────
167# ImageMagick (Image Processing)
168# ─────────────────────────────────────
169# From: examples/docker-runtime-skills/imagemagick-skill
170
171[skills.imagemagick]
172source = "./examples/docker-runtime-skills/imagemagick-skill"
173runtime = "docker"
174description = "Image processing with ImageMagick"
175
176[skills.imagemagick.instances.default]
177"#,
178 },
179 ExampleTemplate {
180 id: "devops",
181 name: "DevOps Tools",
182 description: "Docker, Kubernetes CLIs",
183 badge: "Native",
184 badge_color: "blue",
185 content: r#"# DevOps Tools (Native CLI Wrappers)
186# ===================================
187# Native skills wrap system CLI tools with
188# structured parameters and validation.
189#
190version = "1"
191
192# ─────────────────────────────────────
193# Docker Management
194# ─────────────────────────────────────
195# From: examples/native-skills/docker-skill
196# Tools:
197# - ps(all, format) → List containers
198# - images(all) → List images
199# - run(image, name, detach, ports) → Run container
200# - exec(container, command) → Execute command
201# - logs(container, tail, follow) → View logs
202# - stop(container) → Stop container
203# - rm(container, force) → Remove container
204# - pull(image) → Pull image
205# - build(path, tag) → Build image
206
207[skills.docker]
208source = "./examples/native-skills/docker-skill"
209description = "Docker container and image management"
210
211[skills.docker.instances.default]
212# Uses system Docker socket
213
214# ─────────────────────────────────────
215# Kubernetes Management
216# ─────────────────────────────────────
217# From: examples/native-skills/kubernetes-skill
218# Tools:
219# - get(resource, name, namespace, output)
220# - describe(resource, name, namespace)
221# - logs(pod, container, tail, follow)
222# - exec(pod, command, namespace)
223# - apply(content, namespace, dry_run)
224# - delete(resource, name, namespace)
225# - scale(deployment, replicas, namespace)
226# - rollout(subcommand, deployment)
227
228[skills.kubernetes]
229source = "./examples/native-skills/kubernetes-skill"
230description = "Kubernetes cluster management"
231
232[skills.kubernetes.instances.default]
233# Uses default kubeconfig
234
235[skills.kubernetes.instances.production]
236config.context = "prod-cluster"
237"#,
238 },
239 ExampleTemplate {
240 id: "databases",
241 name: "Database Clients",
242 description: "PostgreSQL, Redis (Docker)",
243 badge: "Data",
244 badge_color: "amber",
245 content: r#"# Database Clients (Docker)
246# =========================
247# Connect to databases using containerized clients.
248# Network access enabled for connectivity.
249#
250version = "1"
251
252# ─────────────────────────────────────
253# PostgreSQL Client
254# ─────────────────────────────────────
255# From: examples/docker-runtime-skills/postgres-skill
256# Tools:
257# - query(sql) → Execute SQL query
258# - exec(command) → Run psql command
259
260[skills.postgres]
261source = "./examples/docker-runtime-skills/postgres-skill"
262runtime = "docker"
263description = "PostgreSQL database client"
264
265[skills.postgres.instances.local]
266config.host = "localhost"
267config.port = "5432"
268config.database = "postgres"
269config.user = "postgres"
270
271[skills.postgres.instances.docker]
272config.host = "host.docker.internal"
273config.port = "5432"
274config.database = "myapp"
275config.user = "postgres"
276
277# ─────────────────────────────────────
278# Redis Client
279# ─────────────────────────────────────
280# From: examples/docker-runtime-skills/redis-skill
281# Tools:
282# - command(cmd) → Execute Redis command
283# - get(key) → Get value
284# - set(key, value) → Set value
285
286[skills.redis]
287source = "./examples/docker-runtime-skills/redis-skill"
288runtime = "docker"
289description = "Redis database client"
290
291[skills.redis.instances.local]
292config.host = "localhost"
293config.port = "6379"
294"#,
295 },
296 ExampleTemplate {
297 id: "wasm-api",
298 name: "API Integrations",
299 description: "GitHub, Slack, AWS skills",
300 badge: "WASM",
301 badge_color: "indigo",
302 content: r#"# API Integration Skills (WASM)
303# =============================
304# Custom skills that integrate with external APIs.
305# Each requires appropriate API keys/tokens.
306#
307version = "1"
308
309# ─────────────────────────────────────
310# GitHub Skill
311# ─────────────────────────────────────
312# From: examples/wasm-skills/github-skill
313# Tools:
314# - list_repos(org, visibility)
315# - get_repo(owner, repo)
316# - create_issue(owner, repo, title, body)
317# - list_issues(owner, repo, state)
318# - search_code(query, language)
319
320[skills.github]
321source = "./examples/wasm-skills/github-skill"
322description = "GitHub API integration"
323
324[skills.github.instances.default]
325config.token = "${GITHUB_TOKEN}"
326
327# ─────────────────────────────────────
328# Slack Skill
329# ─────────────────────────────────────
330# From: examples/wasm-skills/slack-skill
331# Tools:
332# - post_message(channel, text)
333# - list_channels()
334# - get_user(user_id)
335
336[skills.slack]
337source = "./examples/wasm-skills/slack-skill"
338description = "Slack messaging integration"
339
340[skills.slack.instances.default]
341config.bot_token = "${SLACK_BOT_TOKEN}"
342
343# ─────────────────────────────────────
344# AWS Skill
345# ─────────────────────────────────────
346# From: examples/wasm-skills/aws-skill
347# Tools:
348# - s3_list(bucket, prefix)
349# - s3_get(bucket, key)
350# - s3_put(bucket, key, content)
351# - lambda_invoke(function, payload)
352
353[skills.aws]
354source = "./examples/wasm-skills/aws-skill"
355description = "AWS services integration"
356
357[skills.aws.instances.default]
358config.region = "us-east-1"
359config.access_key = "${AWS_ACCESS_KEY_ID}"
360config.secret_key = "${AWS_SECRET_ACCESS_KEY}"
361"#,
362 },
363 ExampleTemplate {
364 id: "complete",
365 name: "Complete Setup",
366 description: "All available example skills",
367 badge: "Full",
368 badge_color: "rose",
369 content: r#"# Complete Skill Setup
370# ====================
371# All example skills from the repository.
372# Import this to get everything at once!
373#
374version = "1"
375
376# ═══════════════════════════════════════
377# WASM SKILLS (Custom Tools)
378# ═══════════════════════════════════════
379
380[skills.simple]
381source = "./examples/wasm-skills/simple-skill"
382description = "Basic greeting and utilities"
383
384[skills.simple.instances.default]
385
386[skills.github]
387source = "./examples/wasm-skills/github-skill"
388description = "GitHub API"
389
390[skills.github.instances.default]
391config.token = "${GITHUB_TOKEN}"
392
393[skills.slack]
394source = "./examples/wasm-skills/slack-skill"
395description = "Slack messaging"
396
397[skills.slack.instances.default]
398config.bot_token = "${SLACK_BOT_TOKEN}"
399
400[skills.aws]
401source = "./examples/wasm-skills/aws-skill"
402description = "AWS services"
403
404[skills.aws.instances.default]
405config.region = "us-east-1"
406
407# ═══════════════════════════════════════
408# DOCKER SKILLS (Sandboxed Execution)
409# ═══════════════════════════════════════
410
411[skills.python-runner]
412source = "./examples/docker-runtime-skills/python-runner"
413runtime = "docker"
414description = "Python sandbox"
415
416[skills.python-runner.instances.default]
417
418[skills.node-runner]
419source = "./examples/docker-runtime-skills/node-runner"
420runtime = "docker"
421description = "Node.js sandbox"
422
423[skills.node-runner.instances.default]
424
425[skills.ffmpeg]
426source = "./examples/docker-runtime-skills/ffmpeg-skill"
427runtime = "docker"
428description = "Video/audio processing"
429
430[skills.ffmpeg.instances.default]
431
432[skills.postgres]
433source = "./examples/docker-runtime-skills/postgres-skill"
434runtime = "docker"
435description = "PostgreSQL client"
436
437[skills.postgres.instances.local]
438config.host = "localhost"
439
440[skills.redis]
441source = "./examples/docker-runtime-skills/redis-skill"
442runtime = "docker"
443description = "Redis client"
444
445[skills.redis.instances.local]
446config.host = "localhost"
447
448# ═══════════════════════════════════════
449# NATIVE SKILLS (CLI Wrappers)
450# ═══════════════════════════════════════
451
452[skills.docker-cli]
453source = "./examples/native-skills/docker-skill"
454description = "Docker management"
455
456[skills.docker-cli.instances.default]
457
458[skills.kubernetes]
459source = "./examples/native-skills/kubernetes-skill"
460description = "Kubernetes management"
461
462[skills.kubernetes.instances.default]
463"#,
464 },
465];
466
467#[derive(Properties, PartialEq)]
469pub struct ImportConfigModalProps {
470 #[prop_or_default]
471 pub on_imported: Callback<usize>,
472 #[prop_or_default]
473 pub on_close: Callback<()>,
474}
475
476#[function_component(ImportConfigModal)]
478pub fn import_config_modal(props: &ImportConfigModalProps) -> Html {
479 let (ui_store, ui_dispatch) = use_store::<UiStore>();
480 let notifications = use_notifications();
481
482 let content = use_state(String::new);
484 let import_state = use_state(|| ImportState::Input);
485 let merge_mode = use_state(|| true);
486 let is_dragging = use_state(|| false);
487 let warnings = use_state(Vec::<String>::new);
488 let validation_status = use_state(|| ValidationStatus::None);
489 let active_tab = use_state(|| "editor"); let selected_template = use_state(|| Option::<String>::None);
491 let debounce_timer = use_state(|| Option::<i32>::None);
492
493 let api = use_memo((), |_| Rc::new(Api::new()));
494
495 let is_open = ui_store.modal.open
496 && ui_store.modal.modal_type == Some(ModalType::Import);
497
498 {
500 let content = content.clone();
501 let validation_status = validation_status.clone();
502 let api = api.clone();
503 let debounce_timer = debounce_timer.clone();
504
505 use_effect_with((*content).clone(), move |content_value| {
506 if let Some(timer_id) = *debounce_timer {
507 let window = web_sys::window().unwrap();
508 window.clear_timeout_with_handle(timer_id);
509 }
510
511 let content_value = content_value.clone();
512 if content_value.trim().is_empty() {
513 validation_status.set(ValidationStatus::None);
514 return;
515 }
516
517 validation_status.set(ValidationStatus::Checking);
518
519 let validation_status = validation_status.clone();
520 let api = api.clone();
521 let debounce_timer = debounce_timer.clone();
522
523 let closure = Closure::wrap(Box::new(move || {
524 let api = api.clone();
525 let validation_status = validation_status.clone();
526 let content_value = content_value.clone();
527
528 spawn_local(async move {
529 match api.config.validate_manifest(&content_value).await {
530 Ok(response) => {
531 if response.valid {
532 validation_status.set(ValidationStatus::Valid(response.skills.len()));
533 } else {
534 let error = response.errors.first()
535 .cloned()
536 .unwrap_or_else(|| "Invalid manifest".to_string());
537 validation_status.set(ValidationStatus::Invalid(error));
538 }
539 }
540 Err(e) => {
541 validation_status.set(ValidationStatus::Invalid(e.to_string()));
542 }
543 }
544 });
545 }) as Box<dyn FnMut()>);
546
547 let window = web_sys::window().unwrap();
548 let timer_id = window.set_timeout_with_callback_and_timeout_and_arguments_0(
549 closure.as_ref().unchecked_ref(),
550 400,
551 ).unwrap();
552
553 debounce_timer.set(Some(timer_id));
554 closure.forget();
555 });
556 }
557
558 let on_close = {
560 let ui_dispatch = ui_dispatch.clone();
561 let on_close_prop = props.on_close.clone();
562 let import_state = import_state.clone();
563 let content = content.clone();
564 let validation_status = validation_status.clone();
565 let active_tab = active_tab.clone();
566 let selected_template = selected_template.clone();
567 Callback::from(move |_: MouseEvent| {
568 if !matches!(*import_state, ImportState::Validating | ImportState::Importing) {
569 ui_dispatch.apply(UiAction::CloseModal);
570 on_close_prop.emit(());
571 content.set(String::new());
572 validation_status.set(ValidationStatus::None);
573 active_tab.set("editor");
574 selected_template.set(None);
575 }
576 })
577 };
578
579 let on_backdrop_click = {
580 let on_close = on_close.clone();
581 Callback::from(move |e: MouseEvent| {
582 let target = e.target().unwrap();
583 let current_target = e.current_target().unwrap();
584 if target == current_target {
585 on_close.emit(e);
586 }
587 })
588 };
589
590 let on_content_input = {
591 let content = content.clone();
592 let import_state = import_state.clone();
593 Callback::from(move |e: InputEvent| {
594 let textarea: HtmlTextAreaElement = e.target_unchecked_into();
595 content.set(textarea.value());
596 if matches!(*import_state, ImportState::Error(_)) {
597 import_state.set(ImportState::Input);
598 }
599 })
600 };
601
602 let on_dragover = {
603 let is_dragging = is_dragging.clone();
604 Callback::from(move |e: DragEvent| {
605 e.prevent_default();
606 is_dragging.set(true);
607 })
608 };
609
610 let on_dragleave = {
611 let is_dragging = is_dragging.clone();
612 Callback::from(move |e: DragEvent| {
613 e.prevent_default();
614 is_dragging.set(false);
615 })
616 };
617
618 let on_drop = {
619 let content = content.clone();
620 let is_dragging = is_dragging.clone();
621 let active_tab = active_tab.clone();
622 Callback::from(move |e: DragEvent| {
623 e.prevent_default();
624 is_dragging.set(false);
625 active_tab.set("editor");
626
627 if let Some(data_transfer) = e.data_transfer() {
628 if let Some(files) = data_transfer.files() {
629 if files.length() > 0 {
630 if let Some(file) = files.get(0) {
631 read_file_content(file, content.clone());
632 }
633 }
634 }
635 }
636 })
637 };
638
639 let on_file_select = {
640 let content = content.clone();
641 let active_tab = active_tab.clone();
642 Callback::from(move |e: Event| {
643 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
644 if let Some(files) = input.files() {
645 if files.length() > 0 {
646 if let Some(file) = files.get(0) {
647 read_file_content(file, content.clone());
648 active_tab.set("editor");
649 }
650 }
651 }
652 })
653 };
654
655 let on_tab_change = {
656 let active_tab = active_tab.clone();
657 Callback::from(move |tab: &'static str| {
658 active_tab.set(tab);
659 })
660 };
661
662 let on_select_template = {
663 let content = content.clone();
664 let selected_template = selected_template.clone();
665 let active_tab = active_tab.clone();
666 Callback::from(move |(id, template_content): (String, String)| {
667 selected_template.set(Some(id));
668 content.set(template_content);
669 active_tab.set("editor");
670 })
671 };
672
673 let on_validate = {
674 let api = api.clone();
675 let content = content.clone();
676 let import_state = import_state.clone();
677 let warnings = warnings.clone();
678 let notifications = notifications.clone();
679
680 Callback::from(move |_| {
681 let content_value = (*content).clone();
682 if content_value.trim().is_empty() {
683 notifications.error("Empty Content", "Please enter or upload a manifest file");
684 return;
685 }
686
687 import_state.set(ImportState::Validating);
688
689 let api = api.clone();
690 let import_state = import_state.clone();
691 let warnings = warnings.clone();
692 let notifications = notifications.clone();
693
694 spawn_local(async move {
695 match api.config.validate_manifest(&content_value).await {
696 Ok(response) => {
697 if response.valid {
698 warnings.set(response.warnings);
699 import_state.set(ImportState::Preview(response.skills));
700 } else {
701 let error_msg = response.errors.join("\n");
702 import_state.set(ImportState::Error(error_msg.clone()));
703 notifications.error("Validation Failed", &error_msg);
704 }
705 }
706 Err(e) => {
707 let error_msg = e.to_string();
708 import_state.set(ImportState::Error(error_msg.clone()));
709 notifications.error("Validation Error", &error_msg);
710 }
711 }
712 });
713 })
714 };
715
716 let on_import = {
717 let api = api.clone();
718 let content = content.clone();
719 let merge_mode = merge_mode.clone();
720 let import_state = import_state.clone();
721 let notifications = notifications.clone();
722 let on_imported = props.on_imported.clone();
723 let ui_dispatch = ui_dispatch.clone();
724
725 Callback::from(move |_| {
726 let content_value = (*content).clone();
727 let merge = *merge_mode;
728
729 import_state.set(ImportState::Importing);
730
731 let api = api.clone();
732 let import_state = import_state.clone();
733 let notifications = notifications.clone();
734 let on_imported = on_imported.clone();
735 let ui_dispatch = ui_dispatch.clone();
736
737 spawn_local(async move {
738 match api.config.import_manifest(&content_value, merge, true).await {
739 Ok(response) => {
740 if response.success {
741 let count = response.installed_count;
742 import_state.set(ImportState::Complete(count));
743 notifications.success(
744 "Import Complete",
745 format!("Successfully imported {} skill(s)", count),
746 );
747 on_imported.emit(count);
748 ui_dispatch.apply(UiAction::CloseModal);
749 } else {
750 let error_msg = response.errors.join("\n");
751 import_state.set(ImportState::Error(error_msg.clone()));
752 notifications.error("Import Failed", &error_msg);
753 }
754 }
755 Err(e) => {
756 let error_msg = e.to_string();
757 import_state.set(ImportState::Error(error_msg.clone()));
758 notifications.error("Import Error", &error_msg);
759 }
760 }
761 });
762 })
763 };
764
765 let on_back = {
766 let import_state = import_state.clone();
767 Callback::from(move |_| {
768 import_state.set(ImportState::Input);
769 })
770 };
771
772 let on_toggle_merge = {
773 let merge_mode = merge_mode.clone();
774 Callback::from(move |_| {
775 merge_mode.set(!*merge_mode);
776 })
777 };
778
779 let on_clear = {
780 let content = content.clone();
781 let validation_status = validation_status.clone();
782 let import_state = import_state.clone();
783 Callback::from(move |_: MouseEvent| {
784 content.set(String::new());
785 validation_status.set(ValidationStatus::None);
786 import_state.set(ImportState::Input);
787 })
788 };
789
790 let is_loading = matches!(*import_state, ImportState::Validating | ImportState::Importing);
791 let can_validate = !(*content).trim().is_empty() && !is_loading;
792 let is_valid = matches!(*validation_status, ValidationStatus::Valid(_));
793
794 if !is_open {
795 return html! {};
796 }
797
798 html! {
799 <div
800 class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
801 onclick={on_backdrop_click}
802 >
803 <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col overflow-hidden animate-scale-in border border-gray-200 dark:border-gray-700">
804 <div class="relative px-6 py-5 border-b border-gray-100 dark:border-gray-800 bg-gradient-to-r from-gray-50 to-white dark:from-gray-800/50 dark:to-gray-900">
806 <div class="flex items-start justify-between">
807 <div class="flex items-center gap-4">
808 <div class="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg shadow-primary-500/20">
809 <svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
810 <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" />
811 </svg>
812 </div>
813 <div>
814 <h2 class="text-xl font-bold text-gray-900 dark:text-white">
815 { "Import Configuration" }
816 </h2>
817 <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
818 { "Add skills from a TOML manifest file" }
819 </p>
820 </div>
821 </div>
822 <button
823 onclick={on_close.clone()}
824 class="p-2 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
825 disabled={is_loading}
826 >
827 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
828 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
829 </svg>
830 </button>
831 </div>
832 </div>
833
834 <div class="flex-1 overflow-hidden flex">
836 {
837 match &*import_state {
838 ImportState::Input | ImportState::Validating | ImportState::Error(_) => {
839 render_editor_view(
840 &content,
841 &is_dragging,
842 &import_state,
843 &validation_status,
844 &active_tab,
845 &selected_template,
846 on_content_input.clone(),
847 on_dragover.clone(),
848 on_dragleave.clone(),
849 on_drop.clone(),
850 on_file_select.clone(),
851 on_tab_change.clone(),
852 on_select_template.clone(),
853 on_clear.clone(),
854 )
855 }
856 ImportState::Preview(skills) => {
857 render_preview_view(
858 skills,
859 &warnings,
860 &merge_mode,
861 on_toggle_merge.clone(),
862 )
863 }
864 ImportState::Importing => render_importing_view(),
865 ImportState::Complete(count) => render_complete_view(*count),
866 }
867 }
868 </div>
869
870 <div class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/30">
872 <div class="flex items-center justify-between">
873 <div class="flex items-center gap-3">
874 {
875 match &*validation_status {
876 ValidationStatus::None => html! {
877 <span class="text-sm text-gray-400 dark:text-gray-500">
878 { "Paste or upload a manifest to get started" }
879 </span>
880 },
881 ValidationStatus::Checking => html! {
882 <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
883 <div class="w-4 h-4 border-2 border-gray-300 border-t-primary-500 rounded-full animate-spin"></div>
884 <span class="text-sm">{ "Validating..." }</span>
885 </div>
886 },
887 ValidationStatus::Valid(count) => html! {
888 <div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400">
889 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
890 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
891 </svg>
892 <span class="text-sm font-medium">{ format!("{} skill{} ready", count, if *count == 1 { "" } else { "s" }) }</span>
893 </div>
894 },
895 ValidationStatus::Invalid(err) => html! {
896 <div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 max-w-md">
897 <svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
898 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
899 </svg>
900 <span class="text-sm font-medium truncate" title={err.clone()}>{ err }</span>
901 </div>
902 },
903 }
904 }
905 </div>
906
907 <div class="flex items-center gap-3">
908 {
909 match &*import_state {
910 ImportState::Input | ImportState::Validating | ImportState::Error(_) => {
911 html! {
912 <>
913 <button
914 onclick={on_close.clone()}
915 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
916 disabled={is_loading}
917 >
918 { "Cancel" }
919 </button>
920 <button
921 onclick={on_validate}
922 disabled={!can_validate}
923 class={classes!(
924 "px-5", "py-2.5", "text-sm", "font-semibold", "rounded-xl", "transition-all", "flex", "items-center", "gap-2",
925 if can_validate && is_valid {
926 "bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg shadow-green-500/25 hover:shadow-green-500/40 hover:scale-[1.02]"
927 } else if can_validate {
928 "bg-gradient-to-r from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25 hover:shadow-primary-500/40 hover:scale-[1.02]"
929 } else {
930 "bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed"
931 }
932 )}
933 >
934 if matches!(*import_state, ImportState::Validating) {
935 <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
936 { "Validating..." }
937 } else if is_valid {
938 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
939 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
940 </svg>
941 { "Continue to Import" }
942 } else {
943 { "Validate & Continue" }
944 }
945 </button>
946 </>
947 }
948 }
949 ImportState::Preview(_) => {
950 html! {
951 <>
952 <button
953 onclick={on_back}
954 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors flex items-center gap-2"
955 >
956 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
957 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
958 </svg>
959 { "Back" }
960 </button>
961 <button
962 onclick={on_import}
963 class="px-5 py-2.5 text-sm font-semibold rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25 hover:shadow-primary-500/40 hover:scale-[1.02] transition-all flex items-center gap-2"
964 >
965 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
966 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
967 </svg>
968 { "Import Skills" }
969 </button>
970 </>
971 }
972 }
973 ImportState::Importing => {
974 html! {
975 <button
976 disabled={true}
977 class="px-5 py-2.5 text-sm font-semibold rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-400 flex items-center gap-2"
978 >
979 <div class="w-4 h-4 border-2 border-gray-300 border-t-gray-500 rounded-full animate-spin"></div>
980 { "Importing..." }
981 </button>
982 }
983 }
984 ImportState::Complete(_) => {
985 html! {
986 <button
987 onclick={on_close}
988 class="px-5 py-2.5 text-sm font-semibold rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg shadow-green-500/25 hover:shadow-green-500/40 hover:scale-[1.02] transition-all"
989 >
990 { "Done" }
991 </button>
992 }
993 }
994 }
995 }
996 </div>
997 </div>
998 </div>
999 </div>
1000 </div>
1001 }
1002}
1003
1004fn render_editor_view(
1006 content: &UseStateHandle<String>,
1007 is_dragging: &UseStateHandle<bool>,
1008 import_state: &UseStateHandle<ImportState>,
1009 _validation_status: &UseStateHandle<ValidationStatus>,
1010 active_tab: &UseStateHandle<&'static str>,
1011 selected_template: &UseStateHandle<Option<String>>,
1012 on_content_input: Callback<InputEvent>,
1013 on_dragover: Callback<DragEvent>,
1014 on_dragleave: Callback<DragEvent>,
1015 on_drop: Callback<DragEvent>,
1016 on_file_select: Callback<Event>,
1017 on_tab_change: Callback<&'static str>,
1018 on_select_template: Callback<(String, String)>,
1019 on_clear: Callback<MouseEvent>,
1020) -> Html {
1021 let tab_editor = {
1022 let on_tab_change = on_tab_change.clone();
1023 Callback::from(move |_: MouseEvent| on_tab_change.emit("editor"))
1024 };
1025
1026 let tab_templates = {
1027 let on_tab_change = on_tab_change.clone();
1028 Callback::from(move |_: MouseEvent| on_tab_change.emit("templates"))
1029 };
1030
1031 html! {
1032 <div class="flex-1 flex flex-col overflow-hidden">
1033 <div class="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
1035 <div class="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
1036 <button
1037 onclick={tab_editor}
1038 class={classes!(
1039 "px-4", "py-2", "text-sm", "font-medium", "rounded-md", "transition-all",
1040 if **active_tab == "editor" {
1041 "bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm"
1042 } else {
1043 "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
1044 }
1045 )}
1046 >
1047 <span class="flex items-center gap-2">
1048 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1049 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
1050 </svg>
1051 { "Editor" }
1052 </span>
1053 </button>
1054 <button
1055 onclick={tab_templates}
1056 class={classes!(
1057 "px-4", "py-2", "text-sm", "font-medium", "rounded-md", "transition-all",
1058 if **active_tab == "templates" {
1059 "bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm"
1060 } else {
1061 "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
1062 }
1063 )}
1064 >
1065 <span class="flex items-center gap-2">
1066 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1067 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
1068 </svg>
1069 { "Templates" }
1070 </span>
1071 </button>
1072 </div>
1073
1074 <div class="flex items-center gap-2">
1075 if !(**content).is_empty() {
1076 <button
1077 onclick={on_clear}
1078 class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
1079 >
1080 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1081 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1082 </svg>
1083 { "Clear" }
1084 </button>
1085 }
1086 <label class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors cursor-pointer">
1087 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1088 <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" />
1089 </svg>
1090 { "Upload File" }
1091 <input
1092 type="file"
1093 accept=".toml,.txt"
1094 class="hidden"
1095 onchange={on_file_select}
1096 />
1097 </label>
1098 </div>
1099 </div>
1100
1101 <div class="flex-1 overflow-hidden">
1103 if **active_tab == "editor" {
1104 { render_code_editor(content, is_dragging, import_state, on_content_input, on_dragover, on_dragleave, on_drop) }
1105 } else {
1106 { render_templates_view(selected_template, on_select_template) }
1107 }
1108 </div>
1109 </div>
1110 }
1111}
1112
1113fn render_code_editor(
1115 content: &UseStateHandle<String>,
1116 is_dragging: &UseStateHandle<bool>,
1117 import_state: &UseStateHandle<ImportState>,
1118 on_content_input: Callback<InputEvent>,
1119 on_dragover: Callback<DragEvent>,
1120 on_dragleave: Callback<DragEvent>,
1121 on_drop: Callback<DragEvent>,
1122) -> Html {
1123 let line_count = if (*content).is_empty() { 20 } else { (*content).lines().count().max(20) };
1124
1125 html! {
1126 <div
1127 class="h-full flex relative"
1128 ondragover={on_dragover}
1129 ondragleave={on_dragleave}
1130 ondrop={on_drop}
1131 >
1132 if let ImportState::Error(error) = &**import_state {
1134 <div class="absolute top-0 left-0 right-0 z-10 m-4">
1135 <div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl p-4 shadow-lg">
1136 <div class="flex items-start gap-3">
1137 <div class="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/50 flex items-center justify-center flex-shrink-0">
1138 <svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1139 <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" />
1140 </svg>
1141 </div>
1142 <div class="flex-1 min-w-0">
1143 <p class="text-sm font-semibold text-red-800 dark:text-red-200">
1144 { "Validation Error" }
1145 </p>
1146 <p class="text-sm text-red-600 dark:text-red-400 mt-1 font-mono whitespace-pre-wrap">
1147 { error }
1148 </p>
1149 </div>
1150 </div>
1151 </div>
1152 </div>
1153 }
1154
1155 <div class="w-14 bg-gray-50 dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 py-4 overflow-hidden select-none flex-shrink-0">
1157 <div class="space-y-0">
1158 { for (1..=line_count).map(|n| {
1159 html! {
1160 <div class="h-6 leading-6 text-right pr-4 text-xs font-mono text-gray-400 dark:text-gray-600">
1161 { n }
1162 </div>
1163 }
1164 }) }
1165 </div>
1166 </div>
1167
1168 <div class="flex-1 relative">
1170 <textarea
1171 class="absolute inset-0 w-full h-full p-4 bg-white dark:bg-gray-900 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none resize-none leading-6"
1172 placeholder="# Paste your .skill-engine.toml content here
1173
1174version = \"1\"
1175
1176[skills.my-skill]
1177source = \"github:username/my-skill\"
1178description = \"My awesome skill\"
1179
1180[skills.my-skill.instances.default]
1181config.api_key = \"${API_KEY}\""
1182 value={(**content).clone()}
1183 oninput={on_content_input}
1184 spellcheck="false"
1185 />
1186 </div>
1187
1188 if **is_dragging {
1190 <div class="absolute inset-0 z-20 flex items-center justify-center bg-primary-500/10 dark:bg-primary-500/20 backdrop-blur-sm border-2 border-dashed border-primary-500 rounded-lg m-2">
1191 <div class="text-center">
1192 <div class="w-16 h-16 mx-auto rounded-2xl bg-primary-500/20 flex items-center justify-center mb-4">
1193 <svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1194 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
1195 </svg>
1196 </div>
1197 <p class="text-lg font-semibold text-primary-700 dark:text-primary-300">
1198 { "Drop your file here" }
1199 </p>
1200 <p class="text-sm text-primary-600/70 dark:text-primary-400/70 mt-1">
1201 { "Supports .toml and .txt files" }
1202 </p>
1203 </div>
1204 </div>
1205 }
1206 </div>
1207 }
1208}
1209
1210fn render_templates_view(
1212 selected_template: &UseStateHandle<Option<String>>,
1213 on_select_template: Callback<(String, String)>,
1214) -> Html {
1215 html! {
1216 <div class="h-full overflow-y-auto p-6 bg-gray-50/50 dark:bg-gray-950/50">
1217 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
1218 { for EXAMPLE_TEMPLATES.iter().map(|template| {
1219 let is_selected = selected_template.as_ref().map(|s| s.as_str()) == Some(template.id);
1220 let on_click = on_select_template.clone();
1221 let id = template.id.to_string();
1222 let content = template.content.to_string();
1223
1224 let badge_classes = match template.badge_color {
1225 "green" => "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
1226 "blue" => "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400",
1227 "purple" => "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400",
1228 "amber" => "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400",
1229 "indigo" => "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400",
1230 "rose" => "bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400",
1231 _ => "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400",
1232 };
1233
1234 html! {
1235 <button
1236 onclick={Callback::from(move |_| on_click.emit((id.clone(), content.clone())))}
1237 class={classes!(
1238 "group", "relative", "p-5", "rounded-xl", "border-2", "text-left", "transition-all", "hover:shadow-lg",
1239 if is_selected {
1240 "border-primary-500 bg-primary-50 dark:bg-primary-900/20 shadow-lg shadow-primary-500/10"
1241 } else {
1242 "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-gray-300 dark:hover:border-gray-600"
1243 }
1244 )}
1245 >
1246 if is_selected {
1248 <div class="absolute top-3 right-3">
1249 <div class="w-6 h-6 rounded-full bg-primary-500 flex items-center justify-center">
1250 <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1251 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
1252 </svg>
1253 </div>
1254 </div>
1255 }
1256
1257 <div class="flex items-start gap-4">
1258 <div class={classes!(
1259 "w-12", "h-12", "rounded-xl", "flex", "items-center", "justify-center", "flex-shrink-0", "transition-transform", "group-hover:scale-110",
1260 match template.badge_color {
1261 "green" => "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400",
1262 "blue" => "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400",
1263 "purple" => "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400",
1264 "amber" => "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400",
1265 "indigo" => "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400",
1266 "rose" => "bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400",
1267 _ => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
1268 }
1269 )}>
1270 { render_template_icon(template.id) }
1271 </div>
1272 <div class="flex-1 min-w-0">
1273 <div class="flex items-center gap-2 mb-1">
1274 <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
1275 { template.name }
1276 </h3>
1277 <span class={classes!("text-[10px]", "font-bold", "uppercase", "px-2", "py-0.5", "rounded-full", badge_classes)}>
1278 { template.badge }
1279 </span>
1280 </div>
1281 <p class="text-sm text-gray-500 dark:text-gray-400">
1282 { template.description }
1283 </p>
1284 </div>
1285 </div>
1286
1287 <div class="mt-4 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/50">
1289 <pre class="text-xs font-mono text-gray-600 dark:text-gray-400 overflow-hidden whitespace-pre-wrap line-clamp-3">
1290 { template.content.lines().take(4).collect::<Vec<_>>().join("\n") }
1291 </pre>
1292 </div>
1293 </button>
1294 }
1295 }) }
1296 </div>
1297 </div>
1298 }
1299}
1300
1301fn render_template_icon(id: &str) -> Html {
1303 match id {
1304 "quick-start" => html! {
1305 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1307 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
1308 </svg>
1309 },
1310 "docker-runners" => html! {
1311 <svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
1313 <path d="M13 3h2v2h-2V3zm3 0h2v2h-2V3zm-6 0h2v2H10V3zM7 3h2v2H7V3zm0 3h2v2H7V6zm3 0h2v2h-2V6zm3 0h2v2h-2V6zm3 0h2v2h-2V6zm3 0h2v2h-2V6zM4 9h2v2H4V9zm3 0h2v2H7V9zm3 0h2v2h-2V9zm3 0h2v2h-2V9zm3 0h2v2h-2V9zm3 0h2v2h-2V9zm2.5 3c-.4 0-.8.1-1.2.2-.4-1.2-1.5-2-2.8-2H3c-1.1 0-2 .9-2 2v3c0 2.2 1.8 4 4 4h12c2.8 0 5-2.2 5-5 0-1.4-.6-2.8-1.5-3.8-.5-.3-1-.4-1.5-.4z"/>
1314 </svg>
1315 },
1316 "devops" => html! {
1317 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1319 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
1320 </svg>
1321 },
1322 "databases" => html! {
1323 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1325 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
1326 </svg>
1327 },
1328 "wasm-api" => html! {
1329 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1331 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
1332 </svg>
1333 },
1334 "complete" => html! {
1335 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1337 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
1338 </svg>
1339 },
1340 _ => html! {
1341 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1342 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
1343 </svg>
1344 },
1345 }
1346}
1347
1348fn render_preview_view(
1350 skills: &[ParsedSkill],
1351 warnings: &UseStateHandle<Vec<String>>,
1352 merge_mode: &UseStateHandle<bool>,
1353 on_toggle_merge: Callback<MouseEvent>,
1354) -> Html {
1355 html! {
1356 <div class="flex-1 overflow-y-auto p-6">
1357 <div class="max-w-2xl mx-auto space-y-6">
1358 <div class="text-center py-6">
1360 <div class="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center shadow-lg shadow-green-500/30 mb-4">
1361 <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1362 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
1363 </svg>
1364 </div>
1365 <h3 class="text-xl font-bold text-gray-900 dark:text-white">
1366 { "Manifest Validated" }
1367 </h3>
1368 <p class="text-gray-500 dark:text-gray-400 mt-1">
1369 { format!("Found {} skill{} ready to import", skills.len(), if skills.len() == 1 { "" } else { "s" }) }
1370 </p>
1371 </div>
1372
1373 if !warnings.is_empty() {
1375 <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
1376 <div class="flex items-start gap-3">
1377 <div class="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center flex-shrink-0">
1378 <svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1379 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
1380 </svg>
1381 </div>
1382 <div>
1383 <p class="text-sm font-semibold text-amber-800 dark:text-amber-200">
1384 { "Warnings" }
1385 </p>
1386 <ul class="text-sm text-amber-700 dark:text-amber-300 mt-1 space-y-1">
1387 { for warnings.iter().map(|w| html! { <li class="flex items-start gap-2"><span class="text-amber-400">{ "•" }</span>{ w }</li> }) }
1388 </ul>
1389 </div>
1390 </div>
1391 </div>
1392 }
1393
1394 <div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
1396 <div>
1397 <p class="text-sm font-semibold text-gray-900 dark:text-white">
1398 { "Merge with existing" }
1399 </p>
1400 <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1401 { if **merge_mode { "Keep existing skills, add new ones" } else { "Replace all with imported" } }
1402 </p>
1403 </div>
1404 <button
1405 type="button"
1406 role="switch"
1407 aria-checked={(**merge_mode).to_string()}
1408 onclick={on_toggle_merge}
1409 class={classes!(
1410 "relative", "inline-flex", "h-7", "w-12", "flex-shrink-0",
1411 "cursor-pointer", "rounded-full", "border-2", "border-transparent",
1412 "transition-colors", "duration-200", "ease-in-out",
1413 "focus:outline-none", "focus:ring-2", "focus:ring-primary-500", "focus:ring-offset-2",
1414 if **merge_mode { "bg-primary-500" } else { "bg-gray-200 dark:bg-gray-600" }
1415 )}
1416 >
1417 <span
1418 class={classes!(
1419 "pointer-events-none", "inline-block", "h-6", "w-6",
1420 "transform", "rounded-full", "bg-white", "shadow-lg",
1421 "ring-0", "transition", "duration-200", "ease-in-out",
1422 if **merge_mode { "translate-x-5" } else { "translate-x-0" }
1423 )}
1424 />
1425 </button>
1426 </div>
1427
1428 <div class="space-y-3">
1430 { for skills.iter().map(|skill| render_skill_card(skill)) }
1431 </div>
1432 </div>
1433 </div>
1434 }
1435}
1436
1437fn render_skill_card(skill: &ParsedSkill) -> Html {
1439 let (badge_text, badge_class) = match skill.runtime.as_str() {
1440 "docker" => ("Docker", "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400"),
1441 "native" => ("Native", "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"),
1442 _ => ("WASM", "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"),
1443 };
1444
1445 html! {
1446 <div class="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
1447 <div class="flex items-start justify-between">
1448 <div class="flex items-start gap-3">
1449 <div class="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
1450 <svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1451 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
1452 </svg>
1453 </div>
1454 <div class="min-w-0">
1455 <div class="flex items-center gap-2">
1456 <h4 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
1457 { &skill.name }
1458 </h4>
1459 <span class={classes!("text-[10px]", "font-bold", "uppercase", "px-2", "py-0.5", "rounded-full", badge_class)}>
1460 { badge_text }
1461 </span>
1462 </div>
1463 if let Some(ref desc) = skill.description {
1464 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
1465 { desc }
1466 </p>
1467 }
1468 <p class="text-xs text-gray-400 dark:text-gray-500 mt-1 font-mono">
1469 { &skill.source }
1470 </p>
1471 </div>
1472 </div>
1473 <div class="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
1474 { format!("{} inst.", skill.instances.len()) }
1475 </div>
1476 </div>
1477 </div>
1478 }
1479}
1480
1481fn render_importing_view() -> Html {
1483 html! {
1484 <div class="flex-1 flex flex-col items-center justify-center p-6">
1485 <div class="relative">
1486 <div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center shadow-xl shadow-primary-500/30">
1487 <svg class="w-10 h-10 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1488 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
1489 </svg>
1490 </div>
1491 <div class="absolute inset-0 rounded-2xl border-4 border-primary-500/30 animate-ping"></div>
1492 </div>
1493 <h3 class="mt-6 text-xl font-bold text-gray-900 dark:text-white">
1494 { "Importing Skills..." }
1495 </h3>
1496 <p class="text-gray-500 dark:text-gray-400 mt-1">
1497 { "This may take a moment" }
1498 </p>
1499 </div>
1500 }
1501}
1502
1503fn render_complete_view(count: usize) -> Html {
1505 html! {
1506 <div class="flex-1 flex flex-col items-center justify-center p-6">
1507 <div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center shadow-xl shadow-green-500/30">
1508 <svg class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1509 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
1510 </svg>
1511 </div>
1512 <h3 class="mt-6 text-xl font-bold text-gray-900 dark:text-white">
1513 { "Import Complete!" }
1514 </h3>
1515 <p class="text-gray-500 dark:text-gray-400 mt-1">
1516 { format!("Successfully imported {} skill{}", count, if count == 1 { "" } else { "s" }) }
1517 </p>
1518 </div>
1519 }
1520}
1521
1522fn read_file_content(file: File, content: UseStateHandle<String>) {
1524 let reader = FileReader::new().unwrap();
1525 let reader_clone = reader.clone();
1526
1527 let onload = Closure::wrap(Box::new(move |_: web_sys::Event| {
1528 if let Ok(result) = reader_clone.result() {
1529 if let Some(text) = result.as_string() {
1530 content.set(text);
1531 }
1532 }
1533 }) as Box<dyn FnMut(_)>);
1534
1535 reader.set_onload(Some(onload.as_ref().unchecked_ref()));
1536 reader.read_as_text(&file).unwrap();
1537 onload.forget();
1538}
1539
1540#[hook]
1542pub fn use_import_config_modal() -> UseImportConfigModalHandle {
1543 let (_, dispatch) = use_store::<UiStore>();
1544 UseImportConfigModalHandle { dispatch }
1545}
1546
1547pub struct UseImportConfigModalHandle {
1549 dispatch: Dispatch<UiStore>,
1550}
1551
1552impl UseImportConfigModalHandle {
1553 pub fn open(&self) {
1554 self.dispatch.apply(UiAction::OpenModal(ModalType::Import, None));
1555 }
1556}
1557
1558impl Clone for UseImportConfigModalHandle {
1559 fn clone(&self) -> Self {
1560 Self {
1561 dispatch: self.dispatch.clone(),
1562 }
1563 }
1564}