1use flams_backend_types::search::{QueryFilter, SearchResult, SearchResultKind};
2use flams_utils::{impossible, vecmap::VecMap};
3use flams_web_utils::components::error_with_toaster;
4use ftml_components::components::content::FtmlViewable;
5use ftml_dom::utils::css::inject_css;
6use ftml_uris::{
7 DocumentElementUri, DocumentUri, IsNarrativeUri, SymbolUri,
8 components::{DocumentUriComponents, UriComponents},
9};
10use leptos::prelude::*;
11
12#[derive(Debug, Clone)]
13pub(crate) enum SearchState {
14 None,
15 Loading,
16 Results(Vec<(f32, SearchResult)>),
17 SymResults(Vec<(SymbolUri, Vec<(f32, SearchResult)>)>),
18}
19
20#[derive(Copy, Clone, PartialEq, Eq)]
21pub(crate) enum Filter {
22 Doc,
23 Def,
24 Par,
25 Ex,
26 Ass,
27}
28impl Filter {
29 const ALL: [Self; 5] = [Self::Doc, Self::Def, Self::Par, Self::Ex, Self::Ass];
30 fn from_value(s: &str) -> Self {
31 match s {
32 "doc" => Self::Doc,
33 "def" => Self::Def,
34 "par" => Self::Par,
35 "ex" => Self::Ex,
36 "ass" => Self::Ass,
37 _ => impossible!(),
38 }
39 }
40 const fn value_str(self) -> &'static str {
41 match self {
42 Self::Doc => "doc",
43 Self::Def => "def",
44 Self::Par => "par",
45 Self::Ex => "ex",
46 Self::Ass => "ass",
47 }
48 }
49 const fn tag_str(self) -> &'static str {
50 match self {
51 Self::Doc => "Documents",
52 Self::Def => "Definitions",
53 Self::Par => "Paragraphs",
54 Self::Ex => "Examples",
55 Self::Ass => "Assertions",
56 }
57 }
58 const fn long_str(self) -> &'static str {
59 match self {
60 Self::Doc => "Full Documents",
61 Self::Def => "Definitions",
62 Self::Par => "Other Paragraphs",
63 Self::Ex => "(Counter-)examples",
64 Self::Ass => "Assertions (Theorems, Lemmata, etc.)",
65 }
66 }
67}
68
69#[component]
70pub fn SearchTop() -> AnyView {
71 use flams_web_utils::components::ClientOnly;
72 use thaw::{
73 Divider, Flex, FlexAlign, Icon, Input, InputPrefix, Layout, LayoutHeader, Radio,
74 RadioGroup, Tag, TagPicker, TagPickerControl, TagPickerGroup, TagPickerInput,
75 TagPickerOption, ToasterInjection,
76 }; let query = RwSignal::new(String::new());
78 let search_kind = RwSignal::new(vec![
79 Filter::Def.value_str().to_string(),
80 Filter::Par.value_str().to_string(),
81 ]);
82 let query_opts = Memo::new(move |_| {
83 search_kind.with(|v| {
84 let mut ret = QueryFilter::default();
85 ret.allow_documents = false;
86 ret.allow_paragraphs = false;
87 ret.allow_definitions = false;
88 ret.allow_examples = false;
89 ret.allow_assertions = false;
90 ret.allow_problems = false;
91 for s in v {
92 match Filter::from_value(s.as_str()) {
93 Filter::Doc => ret.allow_documents = true,
94 Filter::Def => ret.allow_definitions = true,
95 Filter::Par => ret.allow_paragraphs = true,
96 Filter::Ex => ret.allow_examples = true,
97 Filter::Ass => ret.allow_assertions = true,
98 }
99 }
100 ret
101 })
102 });
103 let results = RwSignal::new(SearchState::None);
104 let toaster = ToasterInjection::expect_context();
105 let action = Action::new(move |&()| {
106 results.set(SearchState::Loading);
107 let s = query.get_untracked();
108 let opts = query_opts.get_untracked();
109 async move {
110 match super::search_query(s, opts, 20).await {
111 Ok(r) => results.set(SearchState::Results(r)),
112 Err(e) => {
113 results.set(SearchState::None);
114 error_with_toaster(e, toaster);
115 }
116 }
117 }
118 });
119 let sym_action = Action::new(move |&()| {
120 results.set(SearchState::Loading);
121 let s = query.get_untracked();
122 async move {
123 match super::search_symbols(s, 20).await {
124 Ok(r) => results.set(SearchState::SymResults(r)),
125 Err(e) => {
126 results.set(SearchState::None);
127 error_with_toaster(e, toaster);
128 }
129 }
130 }
131 });
132 let radio_value = RwSignal::new("X".to_string());
133 Effect::new(move || {
134 if query.with(|q| q.is_empty()) {
135 return;
136 };
137 if radio_value.with(|s| s == "S") {
138 sym_action.dispatch(());
139 } else {
140 let _ = query_opts.get(); action.dispatch(());
142 }
143 });
144 inject_css(
145 "flams-search-picker",
146 ".flams-search-picker{} .flams-search-picker-disabled { display:none; }",
147 );
148 let cls = Memo::new(move |_| match radio_value.get().as_str() {
149 "X" => "flams-search-picker".to_string(),
150 "S" => "flams-search-picker-disabled".to_string(),
151 _ => impossible!(),
152 });
153 view! {
154 <Layout>
155 <LayoutHeader><Flex>
156 <Input value=query placeholder="search...">
157 <InputPrefix slot>
158 <Icon icon=icondata_ai::AiSearchOutlined/>
159 </InputPrefix>
160 </Input>
161 <RadioGroup value=radio_value>
162 <Radio value="S" label="Symbols"/>
163 <Radio value="X" label="Documents/Paragraphs"/>
164 </RadioGroup>
165 <ClientOnly>
166 <TagPicker selected_options=search_kind class=cls>
167 <TagPickerControl slot>
168 <TagPickerGroup>
169 {move ||
170 search_kind.get().into_iter().map(|option| view!{
171 <Tag value=option.clone() attr:style="background-color:var(--colorBrandBackground2)">
172 {Filter::from_value(option.as_str()).tag_str()}
173 </Tag>
174 }).collect_view()
175 }
176 </TagPickerGroup>
177 <TagPickerInput />
178 </TagPickerControl>
179 {
180 move ||
181 search_kind.with(|opts| {
182 Filter::ALL.iter().filter_map(|option| {
183 if opts.iter().any(|o| o == option.value_str()) {
184 return None
185 } else {
186 Some(view! {
187 <TagPickerOption value=option.value_str().to_string() text=option.long_str() />
188 })
189 }
190 }).collect_view()
191 })
192 }
193 </TagPicker>
194 </ClientOnly>
195 </Flex></LayoutHeader>
196 <Layout>
197 <Divider/>
198 <div style="width:fit-content;padding:10px;"><Flex vertical=true align=FlexAlign::Start>{move || do_results(results)}</Flex></div>
199 </Layout>
200 </Layout>
201 }.into_any()
202}
203
204fn do_results(results: RwSignal<SearchState>) -> AnyView {
205 results.with(|r| match r {
206 SearchState::None => ().into_any(),
207 SearchState::Results(v) if v.is_empty() => "(No results)".into_any(),
208 SearchState::Loading => view!(<flams_web_utils::components::Spinner/>).into_any(),
209 SearchState::SymResults(v) => v
210 .iter()
211 .map(|(sym, res)| do_sym_result(sym, res.clone()))
212 .collect_view().into_any(),
213 SearchState::Results(v) => v
214 .iter()
215 .map(|(score, res)| do_result(*score, res))
216 .collect_view().into_any(),
217 })
218}
219
220fn do_sym_result(sym: &SymbolUri, res: Vec<(f32, SearchResult)>) -> AnyView {
221 use flams_router_content::components::Fragment;
222 use flams_web_utils::components::ClientOnly;
223 use thaw::{Body1, Card, CardHeader, CardPreview, Scrollbar};
224
225 let name = sym.as_view::<flams_router_content::backend::FtmlBackend>(); view! {
227 <Card>
228 <CardHeader>
229 <Body1><b>{name}</b></Body1>
230 </CardHeader>
231 <CardPreview>
232 <div style="padding:0 5px;max-width:100%">
233 <div style="width:100%;color:black;background-color:white;">
234 <Scrollbar style="max-height: 100px;width:100%;max-width:100%;">{
235 res.into_iter().map(|(_,r)| {
236 let SearchResult::Paragraph { uri, .. } = r else { impossible!()};
237 view!{
238 <Fragment uri=UriComponents::Full(uri.into()) position=ftml_components::SidebarPosition::None/>
241 }
243 }).collect_view()
244 }
245 </Scrollbar>
246 </div>
247 </div>
248 </CardPreview>
249 </Card>
250 }.into_any()
251}
252
253fn do_result(score: f32, res: &SearchResult) -> AnyView {
254 use leptos::either::Either::*;
255 match res {
256 SearchResult::Document(d) => do_doc(score, d.clone()),
257 SearchResult::Paragraph {
258 uri, fors, kind, ..
259 } => do_para(score, uri.clone(), *kind, fors.clone()),
260 }
261}
262
263fn do_doc(score: f32, uri: DocumentUri) -> AnyView {
264 use flams_router_content::components::DocumentInner;
265 use thaw::{Body1, Card, CardHeader, CardHeaderAction, CardPreview, Scrollbar};
266
267 let name = uri.as_view::<flams_router_content::backend::FtmlBackend>(); view! {
269 <Card>
270 <CardHeader>
271 <Body1>
272 <b>"Document "{name}</b>
273 </Body1>
274 <CardHeaderAction slot>
278 <span>"Score: "{score}</span>
279 </CardHeaderAction>
280 </CardHeader>
281 <CardPreview>
282 <div style="padding:0 5px;max-width:100%">
283 <div style="width:100%;color:black;background-color:white;">
284 <Scrollbar style="max-height: 100px;;width:100%;max-width:100%;"><DocumentInner doc=DocumentUriComponents::Full(uri) /></Scrollbar>
285 </div>
286 </div>
287 </CardPreview>
288 </Card>
292 }.into_any()
293}
294
295fn do_para(
296 score: f32,
297 uri: DocumentElementUri,
298 kind: SearchResultKind,
299 fors: Vec<SymbolUri>,
300) -> AnyView {
301 use flams_router_content::components::Fragment;
302 use flams_web_utils::components::{Popover, PopoverTrigger};
303 use thaw::{
304 Body1, Caption1, Card, CardHeader, CardHeaderAction, CardHeaderDescription, CardPreview,
305 Scrollbar,
306 };
307 let uristr = uri.to_string();
308 let namestr = uri.name().to_string();
309 let name = view! {
310 <div style="display:inline-block;"><Popover>
311 <PopoverTrigger slot>{view!(<span class="ftml-comp">{namestr}</span>).into_any()}</PopoverTrigger>
312 <div style="font-size:small;">{uristr}</div>
313 </Popover></div>
314 };
315
316 let desc = ftml_components::components::content::CommaSep(
317 "For",
318 fors.into_iter()
319 .map(|s| s.as_view::<flams_router_content::backend::FtmlBackend>()),
320 )
321 .into_view();
322 view! {
323 <Card>
324 <CardHeader>
325 <Body1>
326 <b>{kind.as_str()}" "{name}</b>
327 </Body1>
328 <CardHeaderDescription slot>
329 <Caption1>{desc}</Caption1>
330 </CardHeaderDescription>
331 <CardHeaderAction slot>
332 <span>"Score: "{score}</span>
333 </CardHeaderAction>
334 </CardHeader>
335 <CardPreview>
336 <div style="padding:0 5px;max-width:100%">
337 <div style="width:100%;color:black;background-color:white;">
338 <Scrollbar style="max-height: 100px;width:100%;max-width:100%;"><Fragment uri=UriComponents::Full(uri.into()) position=ftml_components::SidebarPosition::None /></Scrollbar>
339 </div>
340 </div>
341 </CardPreview>
342 </Card>
346 }.into_any()
347}