diff --git a/client/src/api.rs b/client/src/api.rs index 4ca6589..5c23c84 100644 --- a/client/src/api.rs +++ b/client/src/api.rs @@ -1,4 +1,4 @@ -// CONFIGURATION HELPERS +// CONFIGURATION HELPERS pub fn api_base() -> &'static str { option_env!("API_URL").unwrap_or("http://localhost:3000") } diff --git a/client/src/app.rs b/client/src/app.rs index 776be65..b0cf2d2 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,7 +1,11 @@ +use crate::components::protected::ProtectedRoute; +use crate::pages::{ + admin::AdminPage, analytics::AnalyticsPage, blogs::BlogsPage, create::CreatePage, + dashboard::DashboardPage, docs::DocsPage, embed::EmbedPage, home::LandingPage, + policy::PolicyPage, view::ViewPage, +}; use leptos::*; use leptos_router::*; -use crate::components::protected::ProtectedRoute; -use crate::pages::{home::LandingPage, dashboard::DashboardPage, create::CreatePage, view::ViewPage, embed::EmbedPage, docs::DocsPage, blogs::BlogsPage, analytics::AnalyticsPage, admin::AdminPage, policy::PolicyPage}; #[component] pub fn App() -> impl IntoView { @@ -23,8 +27,8 @@ pub fn App() -> impl IntoView { } /> - - + + diff --git a/client/src/components/limit.rs b/client/src/components/limit.rs index 335c914..33462eb 100644 --- a/client/src/components/limit.rs +++ b/client/src/components/limit.rs @@ -13,18 +13,18 @@ pub fn LimitReached() -> impl IntoView {
"Please try again later or contact the owner."

- +

"Are you the owner?"

- "Request More Compute" - @@ -34,4 +34,4 @@ pub fn LimitReached() -> impl IntoView {
} -} \ No newline at end of file +} diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index 67b3019..1a3efc4 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -52,7 +52,7 @@ pub fn EmbedModal( let (copied_link, set_copied_link) = create_signal(false); let (copied_vip, set_copied_vip) = create_signal(false); let (new_url, set_new_url) = create_signal(String::new()); - + let iframe_ref = create_node_ref::(); let link_ref = create_node_ref::(); let vip_ref = create_node_ref::(); @@ -81,7 +81,7 @@ pub fn EmbedModal( - + // --- SECTION 1: IFRAME ---
@@ -134,6 +134,7 @@ pub fn EmbedModal( view! { "Placeholder" }.into_view() }}
+
impl IntoView { let (user, set_user) = create_signal(None::); let (checked, set_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - // FIX: Use dynamic API URL - let url = format!("{}/api/me", api_base()); - let auth_req = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await; + create_resource( + || (), + move |_| async move { + // FIX: Use dynamic API URL + let url = format!("{}/api/me", api_base()); + let auth_req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; - match auth_req { - Ok(resp) => { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); + match auth_req { + Ok(resp) => { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } + Err(_) => {} } - Err(_) => {} - } - set_checked.set(true); - }); + set_checked.set(true); + }, + ); let children_view = children(); @@ -49,13 +52,13 @@ pub fn ProtectedRoute(children: Children) -> impl IntoView { }.into_view() } diff --git a/client/src/components/terminal.rs b/client/src/components/terminal.rs index 3cf3ae2..935dd94 100644 --- a/client/src/components/terminal.rs +++ b/client/src/components/terminal.rs @@ -1,10 +1,10 @@ +use crate::api::ws_base; use leptos::*; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; -use web_sys::{WebSocket, MessageEvent, ErrorEvent}; -use crate::api::ws_base; +use web_sys::{ErrorEvent, MessageEvent, WebSocket}; -// BINDING 1: FitAddon +// BINDING 1: FitAddon #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_name = FitAddon)] @@ -15,7 +15,7 @@ extern "C" { fn fit(this: &XtermFitAddon); } -// BINDING 2: Terminal +// BINDING 2: Terminal #[wasm_bindgen] extern "C" { type Terminal; @@ -39,12 +39,12 @@ pub fn TerminalView(container_id: String) -> impl IntoView { create_effect(move |_| { if let Some(div) = terminal_div_ref.get() { let term = Terminal::new(); - + let fit_addon = XtermFitAddon::new(); term.load_addon(&fit_addon); term.open(&div); - - fit_addon.fit(); + + fit_addon.fit(); let fit_addon_clone = fit_addon.clone().unchecked_into::(); let on_resize = Closure::::new(move || { fit_addon_clone.fit(); @@ -55,49 +55,53 @@ pub fn TerminalView(container_id: String) -> impl IntoView { window().set_onresize(None); }); term.write(&format!("Connecting to session {}...\r\n", id_for_effect)); - + let term_clone: Terminal = term.clone().unchecked_into(); let ws_url = format!("{}/ws/{}", ws_base(), id_for_effect); - + // FIX: Removed unwrap() on WebSocket::new match WebSocket::new(&ws_url) { Ok(ws) => { let ws_cleanup = ws.clone(); - + on_cleanup(move || { let _ = ws_cleanup.close(); }); - let onmessage = Closure::::new(move |e: MessageEvent| { - if let Ok(txt) = e.data().dyn_into::() { - term_clone.write(&String::from(txt)); - } - }); + let onmessage = + Closure::::new(move |e: MessageEvent| { + if let Ok(txt) = e.data().dyn_into::() { + term_clone.write(&String::from(txt)); + } + }); ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget(); let ws_clone = ws.clone(); - let on_data_callback = Closure::::new(move |data: String| { - let _ = ws_clone.send_with_str(&data); - }); + let on_data_callback = + Closure::::new(move |data: String| { + let _ = ws_clone.send_with_str(&data); + }); term.on_data(&on_data_callback); on_data_callback.forget(); let term_err = term.clone().unchecked_into::(); let onerror = Closure::::new(move |_| { - term_err.write("\r\n\x1b[31m[!] Connection Error.\x1b[0m\r\n"); + term_err.write("\r\n\x1b[31m[!] Connection Error.\x1b[0m\r\n"); }); ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); onerror.forget(); let term_close = term.clone().unchecked_into::(); let onclose = Closure::::new(move || { - term_close.write("\r\n\x1b[33m[!] Connection Closed.\x1b[0m\r\n"); + term_close.write("\r\n\x1b[33m[!] Connection Closed.\x1b[0m\r\n"); }); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); - }, + } Err(_) => { - term.write("\r\n\x1b[31m[!] Failed to initialize WebSocket connection.\x1b[0m\r\n"); + term.write( + "\r\n\x1b[31m[!] Failed to initialize WebSocket connection.\x1b[0m\r\n", + ); } } } diff --git a/client/src/lib.rs b/client/src/lib.rs index bf33e06..4600c25 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,26 +1,26 @@ use leptos::*; -pub mod types; pub mod api; pub mod app; +pub mod types; pub mod components { - pub mod terminal; - pub mod protected; pub mod limit; - pub mod navbar; pub mod modal; + pub mod navbar; + pub mod protected; + pub mod terminal; } pub mod pages { - pub mod home; - pub mod dashboard; + pub mod admin; + pub mod analytics; + pub mod blogs; pub mod create; - pub mod view; - pub mod embed; + pub mod dashboard; pub mod docs; - pub mod blogs; - pub mod analytics; - pub mod admin; + pub mod embed; + pub mod home; pub mod policy; + pub mod view; } pub use app::App; @@ -29,4 +29,4 @@ pub use app::App; pub fn main() { console_error_panic_hook::set_once(); leptos::mount_to_body(|| view! { }) -} \ No newline at end of file +} diff --git a/client/src/pages/admin.rs b/client/src/pages/admin.rs index cf3e8b2..761bbc2 100644 --- a/client/src/pages/admin.rs +++ b/client/src/pages/admin.rs @@ -1,11 +1,11 @@ -use leptos::*; -use leptos_router::use_navigate; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use serde::{Deserialize, Serialize}; use crate::api::api_base; use crate::components::navbar::Navbar; use crate::types::ProjectSummary; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::use_navigate; +use serde::{Deserialize, Serialize}; +use web_sys::RequestCredentials; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ContainerInfo { @@ -35,11 +35,14 @@ pub fn AdminPage() -> impl IntoView { // Actions are Copy, so we can use 'refresh_action' everywhere without cloning let refresh_action = create_action(move |_: &()| { // FIX: Clone navigate here so the async block owns this copy - let navigate = navigate.clone(); - + let navigate = navigate.clone(); + async move { let url = format!("{}/api/admin/stats", api_base()); - let req = Request::get(&url).credentials(RequestCredentials::Include).send().await; + let req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; match req { Ok(resp) => { @@ -54,12 +57,16 @@ pub fn AdminPage() -> impl IntoView { set_stats.set(Some(data)); } } - }, + } Err(_) => {} } - + let p_url = format!("{}/api/admin/projects", api_base()); - if let Ok(resp) = Request::get(&p_url).credentials(RequestCredentials::Include).send().await { + if let Ok(resp) = Request::get(&p_url) + .credentials(RequestCredentials::Include) + .send() + .await + { if let Ok(data) = resp.json::>().await { set_projects.set(data); } @@ -76,9 +83,17 @@ pub fn AdminPage() -> impl IntoView { let kill_container = create_action(move |id: &String| { let id = id.clone(); async move { - if !window().confirm_with_message(&format!("Kill container {}?", id)).unwrap_or(false) { return; } + if !window() + .confirm_with_message(&format!("Kill container {}?", id)) + .unwrap_or(false) + { + return; + } let url = format!("{}/api/admin/container/{}", api_base(), id); - let _ = Request::delete(&url).credentials(RequestCredentials::Include).send().await; + let _ = Request::delete(&url) + .credentials(RequestCredentials::Include) + .send() + .await; refresh_action.dispatch(()); // Trigger refresh } }); @@ -87,9 +102,20 @@ pub fn AdminPage() -> impl IntoView { let delete_project = create_action(move |slug: &String| { let slug = slug.clone(); async move { - if !window().confirm_with_message(&format!("DELETE project '{}'?\nThis deletes the DB entry AND Docker image.", slug)).unwrap_or(false) { return; } + if !window() + .confirm_with_message(&format!( + "DELETE project '{}'?\nThis deletes the DB entry AND Docker image.", + slug + )) + .unwrap_or(false) + { + return; + } let url = format!("{}/api/admin/project/{}", api_base(), slug); - let _ = Request::delete(&url).credentials(RequestCredentials::Include).send().await; + let _ = Request::delete(&url) + .credentials(RequestCredentials::Include) + .send() + .await; refresh_action.dispatch(()); // Trigger refresh } }); @@ -107,11 +133,11 @@ pub fn AdminPage() -> impl IntoView {
- "ADMIN MODE" -
} -} \ No newline at end of file +} diff --git a/client/src/pages/blogs.rs b/client/src/pages/blogs.rs index 9e05a82..2dcedab 100644 --- a/client/src/pages/blogs.rs +++ b/client/src/pages/blogs.rs @@ -1,10 +1,10 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::A; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn BlogsPage() -> impl IntoView { @@ -12,21 +12,24 @@ pub fn BlogsPage() -> impl IntoView { let (user, set_user) = create_signal(None::); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); view! {
@@ -37,8 +40,8 @@ pub fn BlogsPage() -> impl IntoView { if auth_checked.get() { if let Some(u) = user.get() { view! { - User Avatar }.into_view() } else { @@ -92,4 +95,4 @@ pub fn BlogsPage() -> impl IntoView {
} -} \ No newline at end of file +} diff --git a/client/src/pages/create.rs b/client/src/pages/create.rs index 813c9c6..b6a6e69 100644 --- a/client/src/pages/create.rs +++ b/client/src/pages/create.rs @@ -1,16 +1,16 @@ +use crate::api::api_base; +use crate::components::modal::Modal; +use crate::components::navbar::Navbar; +use crate::components::terminal::TerminalView; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::*; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use wasm_bindgen::JsValue; -use wasm_bindgen::prelude::*; -use std::rc::Rc; use std::cell::RefCell; -use crate::api::api_base; -use crate::types::User; -use crate::components::terminal::TerminalView; -use crate::components::navbar::Navbar; -use crate::components::modal::Modal; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; +use web_sys::RequestCredentials; // Simple resize divider setup fn setup_resize_divider() { @@ -20,21 +20,21 @@ fn setup_resize_divider() { .and_then(|e| e.dyn_into::().ok()) { let is_dragging = Rc::new(RefCell::new(false)); - + let on_mousedown = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = true; }) as Box) }; - + let on_mousemove = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { if !*is_dragging.borrow() { return; } - + if let Some(workspace) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.query_selector(".workspace").ok().flatten()) @@ -44,36 +44,57 @@ fn setup_resize_divider() { let workspace_left = workspace.offset_left() as f64; let relative_x = e.client_x() as f64 - workspace_left; let percentage = (relative_x / workspace_width * 100.0).max(20.0).min(80.0); - + if let Ok(panes) = workspace.query_selector_all(".pane") { if panes.length() >= 2 { - if let Some(first_pane) = panes.get(0).and_then(|e| e.dyn_into::().ok()) { + if let Some(first_pane) = panes + .get(0) + .and_then(|e| e.dyn_into::().ok()) + { first_pane.style().set_property("flex", "0 1 auto").ok(); - first_pane.style().set_property("width", &format!("{}%", percentage)).ok(); + first_pane + .style() + .set_property("width", &format!("{}%", percentage)) + .ok(); } - if let Some(second_pane) = panes.get(1).and_then(|e| e.dyn_into::().ok()) { + if let Some(second_pane) = panes + .get(1) + .and_then(|e| e.dyn_into::().ok()) + { second_pane.style().set_property("flex", "0 1 auto").ok(); - second_pane.style().set_property("width", &format!("{}%", 100.0 - percentage)).ok(); + second_pane + .style() + .set_property("width", &format!("{}%", 100.0 - percentage)) + .ok(); } } } } }) as Box) }; - + let on_mouseup = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = false; }) as Box) }; - - divider.add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()).ok(); + + divider + .add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()) + .ok(); on_mousedown.forget(); - + if let Some(document) = web_sys::window().and_then(|w| w.document()) { - document.add_event_listener_with_callback("mousemove", on_mousemove.as_ref().unchecked_ref()).ok(); - document.add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()).ok(); + document + .add_event_listener_with_callback( + "mousemove", + on_mousemove.as_ref().unchecked_ref(), + ) + .ok(); + document + .add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()) + .ok(); on_mousemove.forget(); on_mouseup.forget(); } @@ -83,13 +104,11 @@ fn setup_resize_divider() { #[component] pub fn CreatePage() -> impl IntoView { let query_params = use_query_map(); - let pre_filled_name = move || { - query_params.with(|params| { - params.get("name").cloned().unwrap_or_default() - }) - }; + let pre_filled_name = + move || query_params.with(|params| params.get("name").cloned().unwrap_or_default()); let (container_id, set_container_id) = create_signal("".to_string()); + let (spawn_error, set_spawn_error) = create_signal(None::); let (markdown, set_markdown) = create_signal(r#"# Welcome to Your TryCLI Environment This interactive workspace is your project's staging area. On the left is your live terminal, and here on the right is your editable documentation panel. @@ -138,47 +157,60 @@ After publishing, you can easily distribute your interactive terminal: let (modal_body, set_modal_body) = create_signal(String::new()); let (modal_success, set_modal_success) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - let auth_req = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await; - - match auth_req { - Ok(resp) => { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); - - let spawn_url = format!("{}/api/spawn", api_base()); - let spawn_req = Request::post(&spawn_url) - .credentials(RequestCredentials::Include) - .send() - .await; - - match spawn_req { - Ok(spawn_resp) => { - if spawn_resp.ok() { - if let Ok(id) = spawn_resp.json::().await { - set_container_id.set(id); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + let auth_req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; + + match auth_req { + Ok(resp) => { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + + let spawn_url = format!("{}/api/spawn", api_base()); + let spawn_req = Request::post(&spawn_url) + .credentials(RequestCredentials::Include) + .send() + .await; + + match spawn_req { + Ok(spawn_resp) => { + if spawn_resp.ok() { + if let Ok(id) = spawn_resp.json::().await { + set_container_id.set(id); + set_spawn_error.set(None); + } else { + set_spawn_error.set(Some( + "Failed to parse container ID".to_string(), + )); + } + } else { + let status = spawn_resp.status(); + let text = spawn_resp.text().await.unwrap_or_default(); + set_spawn_error.set(Some(format!( + "Spawn failed ({}): {}", + status, text + ))); } - } else { - let status = spawn_resp.status(); - let text = spawn_resp.text().await.unwrap_or_default(); - set_container_id.set(format!("ERROR {}: {}", status, text)); } - } - Err(e) => { - set_container_id.set(format!("NETWORK_FAIL: {}", e)); + Err(e) => { + set_spawn_error.set(Some(format!("Network error: {}", e))); + } } } } } + Err(e) => { + web_sys::console::log_1(&JsValue::from_str(&format!("Auth Error: {}", e))) + } } - Err(e) => web_sys::console::log_1(&JsValue::from_str(&format!("Auth Error: {}", e))), - } - }); + }, + ); let navigate_modal = use_navigate(); let on_publish = Rc::new(move |_: ev::MouseEvent| { // Prevent concurrent publish requests @@ -219,14 +251,15 @@ After publishing, you can easily distribute your interactive terminal: if resp.ok() { publish_success = true; set_modal_title.set("Published".to_string()); - set_modal_body.set("Your project has been published successfully.".to_string()); + set_modal_body + .set("Your project has been published successfully.".to_string()); } else { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); set_modal_title.set("Publish failed".to_string()); set_modal_body.set(format!("{}: {}", status, text)); } - }, + } Err(_) => { set_modal_title.set("Publish failed".to_string()); set_modal_body.set("Network error while publishing.".to_string()); @@ -266,20 +299,20 @@ After publishing, you can easily distribute your interactive terminal: match user.get() { Some(u) => view! {
- {u.login.clone()}
- "Logout" "Project Slug:" - {err} })} -
} -} \ No newline at end of file +} diff --git a/client/src/pages/embed.rs b/client/src/pages/embed.rs index b958ff2..6fb90b4 100644 --- a/client/src/pages/embed.rs +++ b/client/src/pages/embed.rs @@ -1,10 +1,10 @@ -use leptos::*; -use leptos_router::*; -use gloo_net::http::Request; use crate::api::api_base; -use crate::components::terminal::TerminalView; use crate::components::limit::LimitReached; -use serde::{Serialize, Deserialize}; +use crate::components::terminal::TerminalView; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] enum ProjectState { @@ -24,19 +24,21 @@ pub fn EmbedPage() -> impl IntoView { let (started, set_started) = create_signal(false); // Resource now returns ProjectState - let project_data = create_resource( +let project_data = create_resource( move || (started.get(), username(), slug()), move |(is_started, u, s)| async move { if !is_started { return ProjectState::Loading; } + // Fix: Include the 'key' parameter if present (for VIP/Secret embeds) let key = query_params.get_untracked().get("key").cloned().unwrap_or_default(); let url = if key.is_empty() { format!("{}/api/project/{}/{}", api_base(), u, s) } else { format!("{}/api/project/{}/{}?key={}", api_base(), u, s, key) }; + let req = Request::get(&url).send().await; - + match req { Ok(resp) => { if resp.status() == 403 { @@ -52,21 +54,21 @@ pub fn EmbedPage() -> impl IntoView { } else { ProjectState::NotFound } - }, - Err(_) => ProjectState::NotFound + } + Err(_) => ProjectState::NotFound, } - } + }, ); view! {
{move || if !started.get() { view! { -

"TryCli Studio Demo"

-
}.into_view() } else { view! {}.into_view() @@ -113,4 +115,4 @@ pub fn EmbedPage() -> impl IntoView { }}
} -} \ No newline at end of file +} diff --git a/client/src/pages/home.rs b/client/src/pages/home.rs index fee7da9..7141748 100644 --- a/client/src/pages/home.rs +++ b/client/src/pages/home.rs @@ -1,32 +1,35 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; +use leptos_meta::{Link, Meta, Script, Title}; use leptos_router::A; -use leptos_meta::{Title, Meta, Link, Script}; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn LandingPage() -> impl IntoView { let (user, set_user) = create_signal(None::); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); let auth_github_url = move || format!("{}/auth/github", api_base()); @@ -50,45 +53,45 @@ pub fn LandingPage() -> impl IntoView { // 4. SEO METADATA <Meta name="description" content="Turn your static documentation into interactive live demos. Zero-config Docker sandboxes for onboarding users to your CLI tools instantly." /> - + <Link rel="canonical" href="https://trycli.com" /> <Script type_="application/ld+json"> {schema_json} </Script> - + <Meta property="og:type" content="website" /> <Meta property="og:title" content="TryCLI - The Standard for Interactive Documentation" /> <Meta property="og:description" content="Instantly spin up isolated Docker containers and share your CLI projects with a single link." /> <Meta property="og:url" content="https://trycli.com" /> - + <Meta name="twitter:card" content="summary_large_image" /> <Meta name="twitter:title" content="TryCLI Studio" /> <Meta name="twitter:description" content="Host, share, and embed fully interactive CLI demos directly in the browser." /> - + // MAIN CONTENT <div class="landing-container"> - + <Navbar> <div class="nav-actions"> {move || { let (menu_open, set_menu_open) = create_signal(false); - + if auth_checked.get() { if let Some(u) = user.get() { // LOGGED IN: Show Profile + Dashboard Button + Hamburger Menu view! { <div style="display: flex; align-items: center; gap: 20px; width: 100%;"> <div style="display: flex; align-items: center; gap: 12px;"> - <img src=u.avatar_url - style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" + <img src=u.avatar_url + style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" alt="User Avatar" /> <span style="color: var(--text-main); font-weight: 500; font-size: 0.95rem;"> {u.login} </span> </div> <A href="/dashboard" class="btn-secondary btn-action btn-dashboard">"Dashboard"</A> - + // Hamburger Menu Button <button class="hamburger-menu" @@ -117,7 +120,7 @@ pub fn LandingPage() -> impl IntoView { // LOGGED OUT let url = auth_github_url(); let (menu_open, set_menu_open) = create_signal(false); - + view! { <div style="display: flex; align-items: center; gap: 20px; width: 100%;"> <a href=url class="btn-secondary btn-action btn-login" rel="external" style="display: flex; align-items: center; gap: 8px;"> @@ -126,7 +129,7 @@ pub fn LandingPage() -> impl IntoView { </svg> "Login" </a> - + <button class="hamburger-menu" class:open=move || menu_open.get() @@ -160,12 +163,12 @@ pub fn LandingPage() -> impl IntoView { <main class="hero-main"> <div class="hero-content"> <div class="badge">"Now supporting Alpine, Debian & Fish Shell"</div> - + <h1 class="hero-title"> "Interactive CLI Demos"<br /> <span class="text-gradient">"for the Modern Web"</span> </h1> - + <p class="hero-subtitle"> "The modern way to showcase CLI tools. Spin up instant, sandboxed Linux environments directly in your browser. No downloads, no configuration, just code." </p> @@ -181,13 +184,13 @@ pub fn LandingPage() -> impl IntoView { </A> }.into_view() } else { - view! { + view! { <a href=url class="btn-primary btn-hero" rel="external" style="display: flex; align-items: center; gap: 10px;"> <svg height="24" width="24" viewBox="0 0 16 16" fill="currentColor" style="color: black;"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> </svg> "Start Building Free" - </a> + </a> }.into_view() } }} @@ -196,7 +199,7 @@ pub fn LandingPage() -> impl IntoView { </A> </div> - // TERMINAL PREVIEW + // TERMINAL PREVIEW <div class="terminal-preview" role="log" aria-label="Terminal Preview Demo"> <div class="terminal-header-preview" aria-hidden="true"> <div class="dot red"></div> @@ -206,14 +209,14 @@ pub fn LandingPage() -> impl IntoView { </div> <div class="terminal-body-preview"> <div class="line"> - <span class="prompt">"➜"</span> + <span class="prompt">"➜"</span> <span class="cmd">" curl -fsSL https://trycli.com/install.sh | sh"</span> </div> <div class="line output"><span>"→ Initializing environment (Ubuntu 22.04)..."</span></div> <div class="line output"><span>"→ Installing dependencies..."</span></div> <div class="line output"><span class="success">"✔ Environment Ready! Session ID: 9f8a-2b1c"</span></div> <div class="line"> - <span class="prompt">"➜"</span> + <span class="prompt">"➜"</span> <span class="cmd">" trycli publish --public"</span> </div> <div class="line output"><span>"Snapshotting container state... Done (1.2s)"</span></div> @@ -223,18 +226,18 @@ pub fn LandingPage() -> impl IntoView { </div> </main> - // FEATURES + // FEATURES <section class="section-features" style="background: rgba(255,255,255,0.01);"> <div class="container-narrow"> <h2 class="section-title">"Frictionless Onboarding"</h2> - + <p class="section-subtitle" style="text-align: left; margin-bottom: 3rem;"> "The biggest drop-off in developer adoption happens before the first command is ever run. " "TryCLI bridges the gap between reading about a tool and actually experiencing it." </p> <div style="display: flex; flex-wrap: wrap; gap: 40px; align-items: center;"> - + // Left Column: Text explanation <div style="flex: 1 1 400px; text-align: left;"> <h3 style="font-size: 1.5rem; margin-bottom: 1rem; color: #fff; font-weight: 700;">"Stop Losing Users at 'npm install'"</h3> @@ -313,7 +316,7 @@ pub fn LandingPage() -> impl IntoView { </section> - // FINAL CTA + // FINAL CTA <section class="section-usage" style="border-bottom: none;"> <div class="container-narrow"> <div class="final-cta"> @@ -329,13 +332,13 @@ pub fn LandingPage() -> impl IntoView { if auth_checked.get() && user.get().is_some() { view! { <A href="/new" class="btn-secondary btn-lg">"Create New Project"</A> }.into_view() } else { - view! { + view! { <a href=url class="btn-primary btn-hero btn-lg" rel="external" style="display: flex; align-items: center; gap: 10px;"> <svg height="24" width="24" viewBox="0 0 16 16" fill="currentColor" style="color: black;"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> </svg> "Sign Up with GitHub" - </a> + </a> }.into_view() } }} @@ -344,7 +347,7 @@ pub fn LandingPage() -> impl IntoView { </div> </section> - // FOOTER + // FOOTER <footer class="landing-footer"> <div class="footer-container"> <div class="footer-top"> @@ -369,4 +372,4 @@ pub fn LandingPage() -> impl IntoView { </div> </> } -} \ No newline at end of file +} diff --git a/client/src/pages/policy.rs b/client/src/pages/policy.rs index af1e1d8..39a3168 100644 --- a/client/src/pages/policy.rs +++ b/client/src/pages/policy.rs @@ -1,10 +1,10 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::A; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn PolicyPage() -> impl IntoView { @@ -13,21 +13,24 @@ pub fn PolicyPage() -> impl IntoView { let (user, set_user) = create_signal(None::<User>); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::<User>().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::<User>().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); view! { <div class="docs-container"> @@ -37,8 +40,8 @@ pub fn PolicyPage() -> impl IntoView { if auth_checked.get() { if let Some(u) = user.get() { view! { - <img src=u.avatar_url - style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" + <img src=u.avatar_url + style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" alt="User Avatar" /> }.into_view() } else { @@ -120,7 +123,7 @@ pub fn PolicyPage() -> impl IntoView { <main class="docs-content"> <h1>"TryCLI Terms of Service"</h1> <p class="text-sm text-gray-500">"Last Updated: February 9, 2026"</p> - + <section id="introduction"> <h2>"1. Introduction and Acceptance of Terms"</h2> <p>"These Terms of Service (\"Terms\") constitute a binding legal agreement between you (\"Publisher,\" \"User,\" or \"You\") and TryCLI (\"Platform,\" \"We,\" or \"Us\"). By accessing, registering for, or using the TryCLI platform, including our browser-based sandbox environment, live-syncing Markdown editor, and related developer tools (collectively, the \"Services\"), you acknowledge that you have read, understood, and agree to be bound by these Terms."</p> @@ -129,7 +132,7 @@ pub fn PolicyPage() -> impl IntoView { <section id="nature-of-service"> <h2>"2. Nature of the Service: Passive Conduit"</h2> - + <h3>"2.1 Platform Status"</h3> <p>"You acknowledge and agree that TryCLI operates solely as a technological intermediary and hosting platform. We provide the infrastructure (containerized environments) for the execution of code and the display of documentation. We do not create, select, or modify the content, code, or applications (\"User Content\") uploaded or executed by Publishers."</p> @@ -160,7 +163,7 @@ pub fn PolicyPage() -> impl IntoView { <h2>"4. Proprietary Rights and License"</h2> <h3>"4.1 Your Content"</h3> <p>"You retain all ownership rights to the User Content you create, upload, or execute on TryCLI."</p> - + <h3>"4.2 License to Host"</h3> <p>"By submitting User Content to the Service, you grant TryCLI a worldwide, non-exclusive, royalty-free license to use, reproduce, modify, adapt, publish, and display such content solely for the purpose of providing the Services (e.g., running your code in a container, displaying your Markdown tutorial)."</p> </section> @@ -178,10 +181,10 @@ pub fn PolicyPage() -> impl IntoView { <section id="disclaimer"> <h2>"6. Disclaimer of Warranties"</h2> <p><strong>"THE SERVICES ARE PROVIDED ON AN \"AS IS\" AND \"AS AVAILABLE\" BASIS, WITHOUT ANY WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED."</strong></p> - + <h3>"6.1 No Warranty of Functionality"</h3> <p>"TryCLI does not warrant that the Services will be uninterrupted, secure, or error-free, or that any defects will be corrected. We do not guarantee that code which runs in our sandbox environment will function correctly in other environments or on local machines (\"Works on My Machine\" disclaimer)."</p> - + <h3>"6.2 Data Persistence"</h3> <p>"TryCLI is a sandbox environment designed for development and testing. We do not guarantee the permanent persistence of data, container states, or file systems. You are responsible for backing up your own data."</p> </section> @@ -204,4 +207,4 @@ pub fn PolicyPage() -> impl IntoView { </div> </div> } -} \ No newline at end of file +} diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 00119e4..bd07cfe 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -1,18 +1,18 @@ -use leptos::*; -use leptos_router::*; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use wasm_bindgen::prelude::*; -use std::rc::Rc; -use std::cell::RefCell; -use pulldown_cmark::{Parser, Options, html}; use crate::api::api_base; -use crate::components::terminal::TerminalView; use crate::components::limit::LimitReached; -use crate::components::navbar::Navbar; use crate::components::modal::EmbedModal; +use crate::components::navbar::Navbar; +use crate::components::terminal::TerminalView; use crate::types::User; -use serde::{Serialize, Deserialize}; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::*; +use pulldown_cmark::{html, Options, Parser}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use web_sys::RequestCredentials; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] enum ProjectState { @@ -29,7 +29,7 @@ pub fn render_markdown(text: &str) -> String { options.insert(Options::ENABLE_TABLES); let parser = Parser::new_ext(text, options); let mut html_output = String::new(); - html::push_html(&mut html_output, parser); + html::push_html(&mut html_output, parser); html_output } @@ -40,18 +40,20 @@ fn setup_resize_divider() { .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { let is_dragging = Rc::new(RefCell::new(false)); - + let on_mousedown = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = true; }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - + let on_mousemove = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { - if !*is_dragging.borrow() { return; } + if !*is_dragging.borrow() { + return; + } if let Some(workspace) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.query_selector(".workspace").ok().flatten()) @@ -61,36 +63,55 @@ fn setup_resize_divider() { let workspace_left = workspace.offset_left() as f64; let relative_x = e.client_x() as f64 - workspace_left; let percentage = (relative_x / workspace_width * 100.0).max(20.0).min(80.0); - + if let Ok(panes) = workspace.query_selector_all(".pane") { if panes.length() >= 2 { - if let Some(p1) = panes.get(0).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { + if let Some(p1) = panes + .get(0) + .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) + { p1.style().set_property("flex", "0 1 auto").ok(); - p1.style().set_property("width", &format!("{}%", percentage)).ok(); + p1.style() + .set_property("width", &format!("{}%", percentage)) + .ok(); } - if let Some(p2) = panes.get(1).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { + if let Some(p2) = panes + .get(1) + .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) + { p2.style().set_property("flex", "0 1 auto").ok(); - p2.style().set_property("width", &format!("{}%", 100.0 - percentage)).ok(); + p2.style() + .set_property("width", &format!("{}%", 100.0 - percentage)) + .ok(); } } } } }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - + let on_mouseup = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = false; }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - - divider.add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()).ok(); + + divider + .add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()) + .ok(); on_mousedown.forget(); - + if let Some(document) = web_sys::window().and_then(|w| w.document()) { - document.add_event_listener_with_callback("mousemove", on_mousemove.as_ref().unchecked_ref()).ok(); - document.add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()).ok(); + document + .add_event_listener_with_callback( + "mousemove", + on_mousemove.as_ref().unchecked_ref(), + ) + .ok(); + document + .add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()) + .ok(); on_mousemove.forget(); on_mouseup.forget(); } @@ -103,43 +124,54 @@ pub fn ViewPage() -> impl IntoView { let query_params = use_query_map(); let username = move || params.get().get("username").cloned().unwrap_or_default(); let slug = move || params.get().get("slug").cloned().unwrap_or_default(); - + let (user, set_user) = create_signal(None::<User>); let (embed_modal_open, set_embed_modal_open) = create_signal(false); let (iframe_code, set_iframe_code) = create_signal(String::new()); let (smart_link, set_smart_link) = create_signal(String::new()); let (vip_link, set_vip_link) = create_signal(String::new()); - + let (whitelist, set_whitelist) = create_signal(Vec::<String>::new()); // Auth Resource - let auth_resource = create_resource(|| (), move |_| async move { - let req = Request::get(&format!("{}/api/me", api_base())) - .credentials(RequestCredentials::Include) - .send().await; + let auth_resource = create_resource( + || (), + move |_| async move { + let req = Request::get(&format!("{}/api/me", api_base())) + .credentials(RequestCredentials::Include) + .send() + .await; - if let Ok(resp) = req { - if resp.ok() { - if let Ok(u) = resp.json::<User>().await { - set_user.set(Some(u)); - } + if let Ok(resp) = req { + if resp.ok() { + if let Ok(u) = resp.json::<User>().await { + set_user.set(Some(u)); + } + } } - } - }); + }, + ); // Project Data Resource (with VIP key and Referer security) let project_resource = create_resource( - move || (username(), slug(), auth_resource.get()), + move || (username(), slug(), auth_resource.get()), move |(u, s, _)| async move { - let key = query_params.get_untracked().get("key").cloned().unwrap_or_default(); + let key = query_params + .get_untracked() + .get("key") + .cloned() + .unwrap_or_default(); let url = if key.is_empty() { format!("{}/api/project/{}/{}", api_base(), u, s) } else { format!("{}/api/project/{}/{}?key={}", api_base(), u, s, key) }; - let req = Request::get(&url).credentials(RequestCredentials::Include).send().await; - + let req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; + match req { Ok(resp) => { if resp.status() == 403 { @@ -147,16 +179,17 @@ pub fn ViewPage() -> impl IntoView { } else if resp.status() == 429 { ProjectState::LimitReached } else if resp.ok() { - resp.json::<serde_json::Value>().await + resp.json::<serde_json::Value>() + .await .map(ProjectState::Ready) .unwrap_or(ProjectState::NotFound) } else { ProjectState::NotFound } - }, - Err(_) => ProjectState::NotFound + } + Err(_) => ProjectState::NotFound, } - } + }, ); // Whitelist Resource for Owners @@ -180,17 +213,17 @@ pub fn ViewPage() -> impl IntoView { } } } - } + }, ); let is_owner = move || { let current_user = user.get(); if let Some(ProjectState::Ready(p)) = project_resource.get() { - let project_owner_id = p.get("owner_id").and_then(|id| id.as_i64()); - match current_user { - Some(u) => Some(u.id) == project_owner_id, - None => false - } + let project_owner_id = p.get("owner_id").and_then(|id| id.as_i64()); + match current_user { + Some(u) => Some(u.id) == project_owner_id, + None => false, + } } else { false } @@ -256,18 +289,18 @@ pub fn ViewPage() -> impl IntoView { view! { <> - <EmbedModal - show=embed_modal_open.into() - title="Share Project".to_string().into() - iframe_code=iframe_code.into() + <EmbedModal + show=embed_modal_open.into() + title="Share Project".to_string().into() + iframe_code=iframe_code.into() smart_link=smart_link.into() vip_link=vip_link.into() whitelist=whitelist.into() on_add_url=Callback::new(move |url: String| add_whitelist_item.dispatch(url)) on_remove_url=Callback::new(move |url: String| remove_whitelist_item.dispatch(url)) - on_close=Callback::new(move |_| set_embed_modal_open.set(false)) + on_close=Callback::new(move |_| set_embed_modal_open.set(false)) /> - + <Navbar> <div class="controls"> {move || if is_owner() { @@ -357,7 +390,7 @@ pub fn ViewPage() -> impl IntoView { } } }); - + view! { <div style="display: flex; flex-direction: column; height: calc(100vh - 60px);"> <div class="workspace" style="flex: 1;"> @@ -389,14 +422,14 @@ pub fn ViewPage() -> impl IntoView { </div> }.into_view(), Some(ProjectState::LimitReached) => view! { <LimitReached /> }.into_view(), - Some(ProjectState::NotFound) => view! { - <div style="color: var(--text-muted); text-align: center; margin-top: 100px;">"Project not found."</div> + Some(ProjectState::NotFound) => view! { + <div style="color: var(--text-muted); text-align: center; margin-top: 100px;">"Project not found."</div> }.into_view(), - _ => view! { + _ => view! { <div style="padding: 50px; text-align: center;"> <div class="spinner" style="margin: 0 auto;"></div> <p style="margin-top: 1rem; color: var(--text-muted);">"PREPARING ENVIRONMENT..."</p> - </div> + </div> }.into_view() }} </> diff --git a/client/src/types.rs b/client/src/types.rs index c5b74dd..dc8325c 100644 --- a/client/src/types.rs +++ b/client/src/types.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct ProjectSummary { pub slug: String, pub image_tag: String, @@ -54,4 +54,4 @@ pub struct User { pub id: i64, pub login: String, pub avatar_url: String, -} \ No newline at end of file +}