Skip to content

Commit 7530968

Browse files
authored
Merge branch 'main' into feat/modals-replacing-alerts
2 parents c453cbb + b23a465 commit 7530968

23 files changed

Lines changed: 475 additions & 40 deletions

client/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
<link data-trunk rel="css" href="styles/07-landing.css" />
7575
<link data-trunk rel="css" href="styles/08-footer-docs.css" />
7676
<link data-trunk rel="css" href="styles/09-utilities.css" />
77+
<link data-trunk rel="css" href="styles/10-analytics.css" />
7778

7879
<link rel="preconnect" href="https://fonts.googleapis.com">
7980
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

client/src/app.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use leptos::*;
22
use leptos_router::*;
33
use crate::components::protected::ProtectedRoute;
4-
use crate::pages::{home::LandingPage, dashboard::DashboardPage, create::CreatePage, view::ViewPage, embed::EmbedPage, docs::DocsPage, blogs::BlogsPage};
4+
use crate::pages::{home::LandingPage, dashboard::DashboardPage, create::CreatePage, view::ViewPage, embed::EmbedPage, docs::DocsPage, blogs::BlogsPage, analytics::AnalyticsPage};
55

66
#[component]
77
pub fn App() -> impl IntoView {
@@ -16,6 +16,12 @@ pub fn App() -> impl IntoView {
1616
<DashboardPage />
1717
</ProtectedRoute>
1818
} />
19+
20+
<Route path="/analytics" view=move || view! {
21+
<ProtectedRoute>
22+
<AnalyticsPage />
23+
</ProtectedRoute>
24+
} />
1925

2026

2127
<Route path="/new" view=move || view! {

client/src/components/limit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub fn LimitReached() -> impl IntoView {
1919
"Are you the owner?"
2020
</p>
2121
<a href="mailto:tryclistudio@gmail.com"
22-
class="btn-primary"
22+
class="btn-secondary btn-action"
2323
style="width: 100%; max-width: 300px; text-decoration: none;">
2424
"Request More Compute"
2525
</a>

client/src/components/protected.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub fn ProtectedRoute(children: Children) -> impl IntoView {
5050
<h2 style="color: var(--text-main);">"Authentication Required"</h2>
5151
<p style="color: var(--text-muted);">"Please log in to access this page"</p>
5252
<a href=format!("{}/auth/github", api_base())
53-
class="btn-primary"
53+
class="btn-secondary btn-action"
5454
rel="external"
5555
style="text-decoration: none;">
5656
"Login with GitHub"

client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod pages {
1818
pub mod embed;
1919
pub mod docs;
2020
pub mod blogs;
21+
pub mod analytics;
2122
}
2223

2324
pub use app::App;

client/src/pages/analytics.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use leptos::*;
2+
use leptos_router::A;
3+
use gloo_net::http::Request;
4+
use web_sys::RequestCredentials;
5+
use crate::components::navbar::Navbar;
6+
use crate::api::api_base;
7+
use crate::types::{User, AnalyticsDashboardData};
8+
9+
#[component]
10+
pub fn AnalyticsPage() -> impl IntoView {
11+
let (_user, set_user) = create_signal(None::<User>);
12+
let (data, set_data) = create_signal(None::<AnalyticsDashboardData>);
13+
14+
// Auth & Data Fetch
15+
create_resource(|| (), move |_| async move {
16+
// 1. Check Auth
17+
let auth_url = format!("{}/api/me", api_base());
18+
let auth_resp = Request::get(&auth_url)
19+
.credentials(RequestCredentials::Include)
20+
.send()
21+
.await;
22+
23+
if let Ok(resp) = auth_resp {
24+
if resp.ok() {
25+
if let Ok(u) = resp.json::<User>().await {
26+
set_user.set(Some(u));
27+
28+
// 2. Fetch Analytics
29+
let analytics_url = format!("{}/api/analytics", api_base());
30+
if let Ok(data_resp) = Request::get(&analytics_url)
31+
.credentials(RequestCredentials::Include)
32+
.send()
33+
.await
34+
{
35+
if let Ok(d) = data_resp.json::<AnalyticsDashboardData>().await {
36+
set_data.set(Some(d));
37+
}
38+
}
39+
}
40+
}
41+
}
42+
});
43+
44+
let format_uptime = |seconds: u64| {
45+
if seconds < 60 {
46+
format!("{}s", seconds)
47+
} else if seconds < 3600 {
48+
format!("{}m {}s", seconds / 60, seconds % 60)
49+
} else {
50+
format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
51+
}
52+
};
53+
54+
view! {
55+
<div style="min-height: 100vh; background: var(--bg-dark);">
56+
<Navbar>
57+
<div class="navbar-actions">
58+
<A href="/dashboard" class="btn-nav">"← Back to Dashboard"</A>
59+
</div>
60+
</Navbar>
61+
62+
<div class="dashboard-section">
63+
<div class="section-header">
64+
<h2>"Project Analytics"</h2>
65+
{move || {
66+
let d = data.get();
67+
view! {
68+
<span style="color: var(--text-muted); font-family: var(--font-mono);">
69+
{move || match d.clone() {
70+
Some(_val) => format!("Last Updated: Just now"),
71+
None => "Loading...".to_string()
72+
}}
73+
</span>
74+
}
75+
}}
76+
</div>
77+
78+
{move || match data.get() {
79+
Some(stats) => view! {
80+
// 1. TOP STATS
81+
<div class="stats-grid">
82+
<div class="stat-card">
83+
<span class="stat-label">"Total Lifetime Views"</span>
84+
<span class="stat-value">{stats.total_lifetime_views}</span>
85+
<span class="stat-sub">"Across all projects"</span>
86+
</div>
87+
<div class="stat-card">
88+
<span class="stat-label">"Active Viewers Now"</span>
89+
<span class="stat-value">{stats.active_sessions.len()}</span>
90+
<span class="stat-sub">"Live Containers"</span>
91+
</div>
92+
<div class="stat-card">
93+
<span class="stat-label">"Total Projects"</span>
94+
<span class="stat-value">{stats.project_breakdown.len()}</span>
95+
<span class="stat-sub">"Published Images"</span>
96+
</div>
97+
</div>
98+
99+
// 2. LIVE SESSIONS
100+
<h3 style="margin-bottom: 20px; color: var(--text-main);">"Live Sessions"</h3>
101+
<div class="data-table-container">
102+
<table class="data-table">
103+
<thead>
104+
<tr>
105+
<th>"Project"</th>
106+
<th>"Status"</th>
107+
<th>"Uptime"</th>
108+
<th>"Container ID"</th>
109+
</tr>
110+
</thead>
111+
<tbody>
112+
{if stats.active_sessions.is_empty() {
113+
view! {
114+
<tr>
115+
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 32px;">
116+
"No active viewers right now."
117+
</td>
118+
</tr>
119+
}.into_view()
120+
} else {
121+
stats.active_sessions.into_iter().map(|session| {
122+
view! {
123+
<tr>
124+
<td style="font-weight: 600;">{session.slug}</td>
125+
<td>
126+
<span class="status-dot active"></span> "Running"
127+
</td>
128+
<td>
129+
<span class="uptime-badge">{format_uptime(session.uptime_seconds)}</span>
130+
</td>
131+
<td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 0.85rem;">
132+
{session.container_name}
133+
</td>
134+
</tr>
135+
}
136+
}).collect_view()
137+
}}
138+
</tbody>
139+
</table>
140+
</div>
141+
142+
// 3. PROJECT PERFORMANCE
143+
<h3 style="margin-bottom: 20px; color: var(--text-main);">"Performance by Project"</h3>
144+
<div class="data-table-container">
145+
<table class="data-table">
146+
<thead>
147+
<tr>
148+
<th>"Project Name"</th>
149+
<th>"Total Views"</th>
150+
<th style="width: 40%;">"Engagement"</th>
151+
</tr>
152+
</thead>
153+
<tbody>
154+
{stats.project_breakdown.into_iter().map(|proj| {
155+
// Calculate percentage for bar width
156+
let percent = if stats.total_lifetime_views > 0 {
157+
(proj.view_count as f64 / stats.total_lifetime_views as f64) * 100.0
158+
} else {
159+
0.0
160+
};
161+
162+
view! {
163+
<tr>
164+
<td style="font-weight: 600;">{proj.slug}</td>
165+
<td style="font-family: var(--font-mono);">{proj.view_count}</td>
166+
<td>
167+
<div style="display: flex; align-items: center; gap: 12px;">
168+
<div class="progress-bar-bg">
169+
<div class="progress-bar-fill" style=format!("width: {}%", percent)></div>
170+
</div>
171+
<span style="font-size: 0.8rem; color: var(--text-muted); width: 40px; text-align: right;">
172+
{format!("{:.1}%", percent)}
173+
</span>
174+
</div>
175+
</td>
176+
</tr>
177+
}
178+
}).collect_view()}
179+
</tbody>
180+
</table>
181+
</div>
182+
183+
}.into_view(),
184+
None => view! {
185+
<div style="display: flex; justify-content: center; padding: 100px;">
186+
<div class="spinner"></div>
187+
</div>
188+
}.into_view()
189+
}}
190+
</div>
191+
</div>
192+
}
193+
}

client/src/pages/create.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ After publishing, you can easily distribute your interactive terminal:
273273
</span>
274274
</div>
275275
<a href=format!("{}/auth/logout", api_base())
276-
class="btn-primary btn-logout"
276+
class="btn-secondary btn-action btn-logout"
277277
rel="external"
278278
style="margin-right: 12px; text-decoration: none; font-size: 0.8rem;">
279279
"Logout"
@@ -294,14 +294,14 @@ After publishing, you can easily distribute your interactive terminal:
294294
{move || slug_error.get().map(|err| view! {
295295
<span style="color: #ef4444; font-size: 0.75rem; margin-left: 8px;">{err}</span>
296296
})}
297-
<button class="btn-primary btn-success" on:click=move |ev| (on_publish)(ev)
297+
<button class="btn-secondary btn-action btn-success" on:click=move |ev| (on_publish)(ev)
298298
prop:disabled=move || container_id.get().is_empty() || slug_error.get().is_some() || is_publishing.get()
299299
style=move || if is_publishing.get() { "opacity: 0.6; cursor: not-allowed;" } else { "" }>
300300
{move || if is_publishing.get() { "Publishing..." } else { "Publish" }}
301301
</button>
302302
}.into_view(),
303303
None => view! {
304-
<a href=format!("{}/auth/github", api_base()) class="btn-primary" style="text-decoration: none;">
304+
<a href=format!("{}/auth/github", api_base()) class="btn-secondary btn-action" style="text-decoration: none;">
305305
"Login with GitHub"
306306
</a>
307307
}.into_view()

client/src/pages/dashboard.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,14 @@ pub fn DashboardPage() -> impl IntoView {
145145
<span style="color: var(--text-main); font-weight: 500;">{u.login.clone()}</span>
146146
</div>
147147
<a href=format!("{}/auth/logout", api_base())
148-
class="btn-primary btn-logout"
148+
class="btn-secondary btn-action btn-logout"
149149
rel="external"
150150
style="text-decoration: none; font-size: 0.9rem;">
151151
"Logout"
152152
</a>
153153
}.into_view(),
154154
None => view! {
155-
<a href=format!("{}/auth/github", api_base()) class="btn-primary" rel="external" style="text-decoration: none;"> "Login with GitHub"
155+
<a href=format!("{}/auth/github", api_base()) class="btn-secondary btn-action" rel="external" style="text-decoration: none;"> "Login with GitHub"
156156
</a>
157157
}.into_view()
158158
}}
@@ -184,11 +184,19 @@ pub fn DashboardPage() -> impl IntoView {
184184
</div>
185185

186186
<div class="dashboard-section">
187-
<div class="section-header">
187+
<div class="section-header">
188188
<h2>"Active Deployments"</h2>
189-
<A href="/new" class="btn-primary">
190-
"+ Initialize Environment"
191-
</A>
189+
190+
191+
<div style="display: flex; gap: 12px; align-items: center;">
192+
<A href="/analytics" class="btn-secondary btn-action">
193+
"Analytics"
194+
</A>
195+
196+
<A href="/new" class="btn-secondary btn-action">
197+
"+ Initialize Environment"
198+
</A>
199+
</div>
192200
</div>
193201

194202
<DashboardProjectList
@@ -324,7 +332,7 @@ fn DashboardSearch(
324332
view! {
325333
<div style="padding: 16px; color: var(--text-muted);">
326334
<p style="margin: 0 0 8px 0; font-size: 0.9rem;">"No existing environment found."</p>
327-
<button class="btn-primary"
335+
<button class="btn-secondary btn-action"
328336
style=move || {
329337
let base = "font-size: 0.9rem; padding: 8px 12px; width: 100%; text-align: left;";
330338
if active_index.get() == 0 {
@@ -467,7 +475,7 @@ fn DashboardProjectList(
467475
Some(err) => view! {
468476
<div class="error-state">
469477
<p class="error-message">{err}</p>
470-
<button class="btn-primary" on:click=move |_| {
478+
<button class="btn-secondary btn-action" on:click=move |_| {
471479
set_error.set(None);
472480
set_loading.set(true);
473481
}>
@@ -481,7 +489,7 @@ fn DashboardProjectList(
481489
view! {
482490
<div class="empty-state">
483491
<p class="empty-message">"No active environments. Initialize a new sandbox to start building."</p>
484-
<A href="/new" class="btn-primary">
492+
<A href="/new" class="btn-secondary btn-action">
485493
"Initialize Environment"
486494
</A>
487495
</div>

client/src/pages/embed.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub fn EmbedPage() -> impl IntoView {
5757
style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0,0,0,0.8); z-index: 10;">
5858
<div style="text-align: center; color: white;">
5959
<h3 style="margin-bottom: 1rem; font-family: var(--font-sans);">"TryCli Studio Demo"</h3>
60-
<button class="btn-primary"
60+
<button class="btn-secondary btn-action"
6161
style="padding: 12px 24px; font-size: 1.1rem;"
6262
on:click=move |_| set_started.set(true)>
6363
"▶ Start Terminal"

0 commit comments

Comments
 (0)