skill_web/components/
result_card.rs

1//! Search Result Card Component - Expandable card with feedback buttons
2
3use wasm_bindgen_futures::spawn_local;
4use yew::prelude::*;
5
6use crate::api::{Api, SubmitFeedbackRequest};
7use crate::components::use_notifications;
8
9#[derive(Properties, Clone, PartialEq)]
10pub struct ResultCardProps {
11    /// Result index (1-based)
12    pub index: usize,
13    /// Result ID (skill:tool)
14    pub id: String,
15    /// Skill name
16    pub skill: String,
17    /// Tool name
18    pub tool: String,
19    /// Content/description
20    pub content: String,
21    /// Relevance score
22    pub score: f32,
23    /// Optional rerank score
24    pub rerank_score: Option<f32>,
25    /// Search query that produced this result
26    pub query: String,
27}
28
29#[function_component(ResultCard)]
30pub fn result_card(props: &ResultCardProps) -> Html {
31    let expanded = use_state(|| false);
32    let feedback_submitted = use_state(|| None::<String>); // "positive" or "negative"
33    let is_submitting = use_state(|| false);
34
35    // API & notifications
36    let api = use_memo((), |_| std::rc::Rc::new(Api::new()));
37    let notifications = use_notifications();
38
39    // Toggle expand/collapse
40    let on_toggle = {
41        let expanded = expanded.clone();
42        Callback::from(move |_: web_sys::MouseEvent| {
43            expanded.set(!*expanded);
44        })
45    };
46
47    // Submit feedback handler
48    let submit_feedback = {
49        let props = props.clone();
50        let api = api.clone();
51        let feedback_submitted = feedback_submitted.clone();
52        let is_submitting = is_submitting.clone();
53        let notifications = notifications.clone();
54
55        move |feedback_type: String| {
56            if *is_submitting {
57                return;
58            }
59
60            is_submitting.set(true);
61
62            let api = api.clone();
63            let feedback_submitted = feedback_submitted.clone();
64            let is_submitting = is_submitting.clone();
65            let notifications = notifications.clone();
66            let props = props.clone();
67            let feedback_type_clone = feedback_type.clone();
68
69            spawn_local(async move {
70                let request = SubmitFeedbackRequest {
71                    query: props.query,
72                    result_id: props.id.clone(),
73                    score: props.score,
74                    rank: props.index - 1, // Convert to 0-based
75                    feedback_type: feedback_type.clone(),
76                    reason: None,
77                    comment: None,
78                    client_type: "http".to_string(),
79                };
80
81                match api.feedback.submit(&request).await {
82                    Ok(_) => {
83                        feedback_submitted.set(Some(feedback_type.clone()));
84                        notifications.success(
85                            "Feedback submitted",
86                            format!("Thank you for your feedback on {}", props.tool),
87                        );
88                    }
89                    Err(e) => {
90                        notifications.error("Failed to submit feedback", format!("Error: {}", e));
91                    }
92                }
93
94                is_submitting.set(false);
95            });
96        }
97    };
98
99    // Feedback button handlers
100    let on_thumbs_up = {
101        let submit_feedback = submit_feedback.clone();
102        Callback::from(move |e: web_sys::MouseEvent| {
103            e.stop_propagation();
104            submit_feedback("positive".to_string());
105        })
106    };
107
108    let on_thumbs_down = {
109        let submit_feedback = submit_feedback.clone();
110        Callback::from(move |e: web_sys::MouseEvent| {
111            e.stop_propagation();
112            submit_feedback("negative".to_string());
113        })
114    };
115
116    // Determine card classes based on feedback
117    let card_border_class = match &*feedback_submitted {
118        Some(ft) if ft == "positive" => "border-green-500 dark:border-green-400",
119        Some(ft) if ft == "negative" => "border-red-500 dark:border-red-400",
120        _ => "border-gray-200 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-400",
121    };
122
123    html! {
124        <div class={classes!(
125            "p-4", "bg-white", "dark:bg-gray-800", "rounded-lg", "border-2",
126            "transition-all", "duration-200", "cursor-pointer",
127            card_border_class
128        )}
129        onclick={on_toggle.clone()}
130        >
131            // Header
132            <div class="flex items-start justify-between mb-3">
133                <div class="flex items-center gap-3 flex-1">
134                    // Rank badge
135                    <span class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 flex items-center justify-center text-sm font-bold">
136                        { props.index }
137                    </span>
138
139                    // Title
140                    <div class="flex-1 min-w-0">
141                        <h4 class="font-semibold text-gray-900 dark:text-white truncate">
142                            { &props.skill }
143                        </h4>
144                        <p class="text-sm text-gray-600 dark:text-gray-400 truncate">
145                            { &props.tool }
146                        </p>
147                    </div>
148                </div>
149
150                // Right side: Scores and buttons
151                <div class="flex items-center gap-2 ml-4">
152                    // Score badge
153                    <span class="px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-mono whitespace-nowrap">
154                        { format!("{:.3}", props.score) }
155                    </span>
156
157                    // Feedback buttons
158                    <div class="flex items-center gap-1">
159                        <button
160                            class={classes!(
161                                "p-1.5", "rounded", "transition-colors",
162                                if feedback_submitted.as_ref() == Some(&"positive".to_string()) {
163                                    "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
164                                } else {
165                                    "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400"
166                                }
167                            )}
168                            onclick={on_thumbs_up}
169                            disabled={*is_submitting || feedback_submitted.is_some()}
170                            title="This result was helpful"
171                        >
172                            <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
173                                <path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
174                            </svg>
175                        </button>
176
177                        <button
178                            class={classes!(
179                                "p-1.5", "rounded", "transition-colors",
180                                if feedback_submitted.as_ref() == Some(&"negative".to_string()) {
181                                    "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
182                                } else {
183                                    "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400"
184                                }
185                            )}
186                            onclick={on_thumbs_down}
187                            disabled={*is_submitting || feedback_submitted.is_some()}
188                            title="This result was not helpful"
189                        >
190                            <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
191                                <path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.105-1.79l-.05-.025A4 4 0 0011.055 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
192                            </svg>
193                        </button>
194                    </div>
195
196                    // Expand icon
197                    <svg
198                        class={classes!(
199                            "w-5", "h-5", "text-gray-400", "transition-transform", "duration-200",
200                            if *expanded { "rotate-180" } else { "" }
201                        )}
202                        fill="none"
203                        viewBox="0 0 24 24"
204                        stroke="currentColor"
205                    >
206                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
207                    </svg>
208                </div>
209            </div>
210
211            // Content preview
212            <p class={classes!(
213                "text-sm", "text-gray-700", "dark:text-gray-300",
214                if !*expanded { "line-clamp-2" } else { "" }
215            )}>
216                { &props.content }
217            </p>
218
219            // Expanded details
220            if *expanded {
221                <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3"
222                     onclick={|e: web_sys::MouseEvent| e.stop_propagation()}
223                >
224                    // Metadata
225                    <div class="grid grid-cols-2 gap-4 text-sm">
226                        <div>
227                            <span class="text-gray-600 dark:text-gray-400">{ "Skill:" }</span>
228                            <span class="ml-2 font-medium text-gray-900 dark:text-white">
229                                { &props.skill }
230                            </span>
231                        </div>
232                        <div>
233                            <span class="text-gray-600 dark:text-gray-400">{ "Tool:" }</span>
234                            <span class="ml-2 font-medium text-gray-900 dark:text-white">
235                                { &props.tool }
236                            </span>
237                        </div>
238                        <div>
239                            <span class="text-gray-600 dark:text-gray-400">{ "Score:" }</span>
240                            <span class="ml-2 font-mono text-gray-900 dark:text-white">
241                                { format!("{:.6}", props.score) }
242                            </span>
243                        </div>
244                        if let Some(rerank_score) = props.rerank_score {
245                            <div>
246                                <span class="text-gray-600 dark:text-gray-400">{ "Rerank Score:" }</span>
247                                <span class="ml-2 font-mono text-gray-900 dark:text-white">
248                                    { format!("{:.6}", rerank_score) }
249                                </span>
250                            </div>
251                        }
252                    </div>
253
254                    // Full description
255                    <div class="p-3 bg-gray-50 dark:bg-gray-900 rounded text-sm">
256                        <p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
257                            { &props.content }
258                        </p>
259                    </div>
260
261                    // Action hint
262                    <div class="text-xs text-gray-500 dark:text-gray-400 italic">
263                        { "Click the card header to collapse" }
264                    </div>
265                </div>
266            }
267
268            // Feedback confirmation
269            if let Some(feedback_type) = &*feedback_submitted {
270                <div class={classes!(
271                    "mt-3", "p-2", "rounded", "text-xs", "flex", "items-center", "gap-2",
272                    if feedback_type == "positive" {
273                        "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
274                    } else {
275                        "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
276                    }
277                )}>
278                    <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
279                        <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" />
280                    </svg>
281                    { format!("Feedback recorded: {}", if feedback_type == "positive" { "Helpful" } else { "Not helpful" }) }
282                </div>
283            }
284        </div>
285    }
286}