Skip to content

Commit ba7a084

Browse files
authored
Merge pull request #100 from TryCli-Studio/feat/modals-replacing-alerts
feat(modals): replace alerts with modals and improve dashboard termination UI
2 parents b23a465 + 7530968 commit ba7a084

6 files changed

Lines changed: 382 additions & 57 deletions

File tree

client/src/components/modal.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
use leptos::*;
2+
3+
#[component]
4+
pub fn Modal(
5+
show: MaybeSignal<bool>,
6+
title: MaybeSignal<String>,
7+
body: MaybeSignal<String>,
8+
button_label: MaybeSignal<String>,
9+
on_close: Callback<()>,
10+
) -> impl IntoView {
11+
view! {
12+
{move || {
13+
let on_close = on_close.clone();
14+
let title = title.clone();
15+
let body = body.clone();
16+
let button_label = button_label.clone();
17+
let show = show.clone();
18+
if show.get() {
19+
view! {
20+
<div class="modal-overlay" role="dialog" aria-modal="true">
21+
<div class="modal-card">
22+
<h3 class="modal-title">{move || title.get()}</h3>
23+
<div class="modal-body">{move || body.get()}</div>
24+
<div class="modal-actions">
25+
<button class="btn-primary" on:click=move |_| on_close.call(())>
26+
{move || button_label.get()}
27+
</button>
28+
</div>
29+
</div>
30+
</div>
31+
}.into_view()
32+
} else {
33+
view! { <></> }.into_view()
34+
}
35+
}}
36+
}
37+
}
38+
39+
#[component]
40+
pub fn EmbedModal(
41+
show: MaybeSignal<bool>,
42+
title: MaybeSignal<String>,
43+
code: MaybeSignal<String>,
44+
on_close: Callback<()>,
45+
) -> impl IntoView {
46+
let (copied, set_copied) = create_signal(false);
47+
let textarea_ref = create_node_ref::<leptos::html::Textarea>();
48+
view! {
49+
{move || {
50+
let on_close = on_close.clone();
51+
let title = title.clone();
52+
let code = code.clone();
53+
let code_for_click = code.clone();
54+
let show = show.clone();
55+
if show.get() {
56+
view! {
57+
<div class="modal-overlay" role="dialog" aria-modal="true">
58+
<div class="modal-card">
59+
<h3 class="modal-title">{move || title.get()}</h3>
60+
<p class="modal-description">
61+
"Copy and paste this embedd in your blogs and articles to demo your environment."
62+
</p>
63+
<textarea
64+
class="modal-code"
65+
readonly
66+
node_ref=textarea_ref
67+
prop:value=move || code.get()
68+
></textarea>
69+
{move || if copied.get() {
70+
view! { <div class="modal-copy-status">"Embed copied to clipboard"</div> }.into_view()
71+
} else {
72+
view! { <></> }.into_view()
73+
}}
74+
<div class="modal-actions">
75+
<button
76+
class="modal-copy-btn"
77+
aria-label="Copy embed code"
78+
on:click=move |_| {
79+
let text = code_for_click.get();
80+
let _ = window().navigator().clipboard().write_text(&text);
81+
if let Some(el) = textarea_ref.get() {
82+
el.focus().ok();
83+
el.select();
84+
}
85+
set_copied.set(true);
86+
set_timeout(move || {
87+
set_copied.set(false);
88+
}, std::time::Duration::from_millis(2000));
89+
}
90+
>
91+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
92+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
93+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
94+
</svg>
95+
</button>
96+
<button class="btn-primary" on:click=move |_| on_close.call(())>
97+
"Close"
98+
</button>
99+
</div>
100+
</div>
101+
</div>
102+
}.into_view()
103+
} else {
104+
view! { <></> }.into_view()
105+
}
106+
}}
107+
}
108+
}
109+
110+
#[component]
111+
pub fn ConfirmModal(
112+
show: MaybeSignal<bool>,
113+
title: MaybeSignal<String>,
114+
body: MaybeSignal<String>,
115+
expected_name: MaybeSignal<String>,
116+
confirm_label: MaybeSignal<String>,
117+
cancel_label: MaybeSignal<String>,
118+
on_confirm: Callback<()>,
119+
on_cancel: Callback<()>,
120+
) -> impl IntoView {
121+
let (confirm_input, set_confirm_input) = create_signal(String::new());
122+
let (confirm_error, set_confirm_error) = create_signal(None::<String>);
123+
view! {
124+
{move || {
125+
let on_confirm = on_confirm.clone();
126+
let on_cancel = on_cancel.clone();
127+
let title = title.clone();
128+
let body = body.clone();
129+
let expected_name = expected_name.clone();
130+
let confirm_label = confirm_label.clone();
131+
let cancel_label = cancel_label.clone();
132+
let show = show.clone();
133+
if show.get() {
134+
view! {
135+
<div class="modal-overlay" role="dialog" aria-modal="true">
136+
<div class="modal-card">
137+
<h3 class="modal-title">{move || title.get()}</h3>
138+
<div class="modal-body">{move || body.get()}</div>
139+
<div class="modal-body">
140+
<p class="modal-description">
141+
"Type the environment name to confirm deletion:"
142+
</p>
143+
<input
144+
class="input-slug"
145+
type="text"
146+
prop:value=confirm_input
147+
on:input=move |ev| {
148+
set_confirm_input.set(event_target_value(&ev));
149+
set_confirm_error.set(None);
150+
}
151+
/>
152+
{move || confirm_error.get().map(|err| view! {
153+
<div class="modal-copy-status" style="color:#ef4444;">{err}</div>
154+
})}
155+
</div>
156+
<div class="modal-actions">
157+
<button class="btn-secondary" on:click=move |_| on_cancel.call(())>
158+
{move || cancel_label.get()}
159+
</button>
160+
<button class="btn-danger" on:click=move |_| {
161+
if confirm_input.get().trim() == expected_name.get().trim() {
162+
on_confirm.call(());
163+
set_confirm_input.set(String::new());
164+
set_confirm_error.set(None);
165+
} else {
166+
set_confirm_error.set(Some("Name does not match.".to_string()));
167+
}
168+
}>
169+
{move || confirm_label.get()}
170+
</button>
171+
</div>
172+
</div>
173+
</div>
174+
}.into_view()
175+
} else {
176+
view! { <></> }.into_view()
177+
}
178+
}}
179+
}
180+
}

client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod components {
88
pub mod protected;
99
pub mod limit;
1010
pub mod navbar;
11+
pub mod modal;
1112
}
1213
pub mod pages {
1314
pub mod home;

client/src/pages/create.rs

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::api::api_base;
1010
use crate::types::User;
1111
use crate::components::terminal::TerminalView;
1212
use crate::components::navbar::Navbar;
13+
use crate::components::modal::Modal;
1314

1415
// Simple resize divider setup
1516
fn setup_resize_divider() {
@@ -99,7 +100,6 @@ This interactive workspace is your project's staging area. On the left is your l
99100
100101
### 1. Select Your Stack
101102
Use the environment settings to choose your preferred **Linux Distribution** and **Shell** (e.g., Bash, Zsh) to ensure your project runs in its native environment.
102-
103103
### 2. Root Access
104104
You are authenticated as the **root user** in this container. You can execute all commands directly; there is no need to use `sudo` for installations or system configurations.
105105
@@ -115,15 +115,13 @@ Use the terminal to prepare your demo:
115115
116116
This Markdown panel is **fully editable**. You should use this space to write down the specific steps, descriptions, and commands that viewers need to follow to experience a demo of your project.
117117
118-
> **Tip:** Provide clear, copyable command snippets. Since viewers will follow your lead, ensure your documentation matches the environment setup on the left.
119118
120119
---
121120
122121
## Publish & Embed
123122
124123
Once your environment is configured and your guide is written, you can make your project live via the **Publish** action in your dashboard.
125124
126-
### Sharing Your Work
127125
After publishing, you can easily distribute your interactive terminal:
128126
* **Direct Sharing:** Share the unique project URL with your community.
129127
* **Embed Anywhere:** Copy the **Embed Code** from the project settings and paste it into any blog (e.g., Hashnode, Dev.to) or documentation site. Your viewers will be able to interact with your CLI directly within your post.
@@ -135,6 +133,10 @@ After publishing, you can easily distribute your interactive terminal:
135133
let (slug_error, set_slug_error) = create_signal(None::<String>);
136134
let (user, set_user) = create_signal(None::<User>);
137135
let (is_publishing, set_is_publishing) = create_signal(false);
136+
let (modal_open, set_modal_open) = create_signal(false);
137+
let (modal_title, set_modal_title) = create_signal(String::new());
138+
let (modal_body, set_modal_body) = create_signal(String::new());
139+
let (modal_success, set_modal_success) = create_signal(false);
138140

139141
create_resource(|| (), move |_| async move {
140142
let url = format!("{}/api/me", api_base());
@@ -177,37 +179,35 @@ After publishing, you can easily distribute your interactive terminal:
177179
Err(e) => web_sys::console::log_1(&JsValue::from_str(&format!("Auth Error: {}", e))),
178180
}
179181
});
180-
let navigate = use_navigate();
182+
let navigate_modal = use_navigate();
181183
let on_publish = Rc::new(move |_: ev::MouseEvent| {
182-
let navigate = navigate.clone();
183184
// Prevent concurrent publish requests
184185
if is_publishing.get() {
185186
return;
186187
}
187188
set_is_publishing.set(true);
188-
189+
189190
spawn_local(async move {
190-
let navigate = navigate.clone();
191191
let mut publish_success = false;
192192
let body_data = serde_json::json!({
193193
"container_id": container_id.get_untracked(),
194194
"slug": slug.get_untracked(),
195195
"markdown": markdown.get_untracked()
196196
});
197197

198-
// FIX: Safe serialization instead of unwrap()
199198
let body_str = match serde_json::to_string(&body_data) {
200199
Ok(s) => s,
201200
Err(_) => {
202201
set_is_publishing.set(false);
203-
let _ = window().alert_with_message("Failed to serialize request");
202+
set_modal_title.set("Publish failed".to_string());
203+
set_modal_body.set("Failed to serialize request.".to_string());
204+
set_modal_success.set(false);
205+
set_modal_open.set(true);
204206
return;
205207
}
206208
};
207209

208210
let url = format!("{}/api/publish", api_base());
209-
210-
// FIX: Safe Request building
211211
let req = Request::post(&url)
212212
.header("Content-Type", "application/json")
213213
.credentials(RequestCredentials::Include)
@@ -218,30 +218,47 @@ After publishing, you can easily distribute your interactive terminal:
218218
Ok(resp) => {
219219
if resp.ok() {
220220
publish_success = true;
221-
let _ = window().alert_with_message("Published!");
221+
set_modal_title.set("Published".to_string());
222+
set_modal_body.set("Your project has been published successfully.".to_string());
222223
} else {
223224
let status = resp.status();
224225
let text = resp.text().await.unwrap_or_default();
225-
let _ = window().alert_with_message(&format!("Publish Failed ({}: {})", status, text));
226+
set_modal_title.set("Publish failed".to_string());
227+
set_modal_body.set(format!("{}: {}", status, text));
226228
}
227229
},
228230
Err(_) => {
229-
let _ = window().alert_with_message("Publish Failed: Network Error");
231+
set_modal_title.set("Publish failed".to_string());
232+
set_modal_body.set("Network error while publishing.".to_string());
230233
}
231234
}
232235
} else {
233-
let _ = window().alert_with_message("Failed to build request");
236+
set_modal_title.set("Publish failed".to_string());
237+
set_modal_body.set("Failed to build request.".to_string());
234238
}
235-
236-
// Re-enable button after request completes
237-
set_is_publishing.set(false);
238239

239-
if publish_success {
240-
navigate("/dashboard", Default::default());
241-
}
240+
set_is_publishing.set(false);
241+
set_modal_success.set(publish_success);
242+
set_modal_open.set(true);
242243
});
243244
});
245+
let on_modal_close = {
246+
let navigate = navigate_modal.clone();
247+
Callback::new(move |_| {
248+
set_modal_open.set(false);
249+
if modal_success.get() {
250+
navigate("/dashboard", Default::default());
251+
}
252+
})
253+
};
244254
view! {
255+
<Modal
256+
show=modal_open.into()
257+
title=modal_title.into()
258+
body=modal_body.into()
259+
button_label="Close".to_string().into()
260+
on_close=on_modal_close
261+
/>
245262
<Navbar>
246263
<div class="controls">
247264
{move || {

0 commit comments

Comments
 (0)