skill_web/components/
result_card.rs1use 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 pub index: usize,
13 pub id: String,
15 pub skill: String,
17 pub tool: String,
19 pub content: String,
21 pub score: f32,
23 pub rerank_score: Option<f32>,
25 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>); let is_submitting = use_state(|| false);
34
35 let api = use_memo((), |_| std::rc::Rc::new(Api::new()));
37 let notifications = use_notifications();
38
39 let on_toggle = {
41 let expanded = expanded.clone();
42 Callback::from(move |_: web_sys::MouseEvent| {
43 expanded.set(!*expanded);
44 })
45 };
46
47 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, 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 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 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 <div class="flex items-start justify-between mb-3">
133 <div class="flex items-center gap-3 flex-1">
134 <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 <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 <div class="flex items-center gap-2 ml-4">
152 <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 <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 <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 <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 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 <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 <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 <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 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}