modularize frontend and implement new css and themes

This commit is contained in:
2026-08-24 13:10:37 +01:00
parent 13116899db
commit 02b7ba317c
14 changed files with 1572 additions and 1768 deletions
+17 -55
View File
@@ -1,18 +1,19 @@
use axum::{
extract::{ConnectInfo, Form, Path, State},
response::Html,
response::{Html, Json},
};
use chrono::Utc;
use std::{net::SocketAddr, sync::Arc};
use uuid::Uuid;
use crate::{
models::{ChatSession, Message, MessageSender, NewSessionForm, SendMessageForm, SessionStatus},
models::{
ChatSession, CreateSessionResponse, Message, MessageSender, SendMessageForm, SessionStatus,
},
state::{AppState, MESSAGE_LIMIT_PER_WINDOW, SESSION_LIMIT_PER_WINDOW},
};
const MAX_MESSAGE_CHARS: usize = 1_000;
const MAX_NAME_CHARS: usize = 40;
#[derive(Clone, Copy)]
enum MessageView {
@@ -132,7 +133,7 @@ pub async fn send_message(
) -> Html<String> {
if state.is_rate_limited(limit_key("message", addr), MESSAGE_LIMIT_PER_WINDOW) {
return Html(render_system_notice(
"Too many messages from this network. Please wait until the 3-minute window resets.",
"Too many messages from this network. Please wait until the 20-minute window resets.",
));
}
@@ -180,7 +181,7 @@ pub async fn supporter_send_message(
MESSAGE_LIMIT_PER_WINDOW,
) {
return Html(render_system_notice(
"Too many messages from this network. Please wait until the 3-minute window resets.",
"Too many messages from this network. Please wait until the 20-minute window resets.",
));
}
@@ -273,65 +274,26 @@ pub async fn get_sessions(State(state): State<Arc<AppState>>) -> Html<String> {
Html(html)
}
/// POST /sessions/new — start a new user session
pub async fn new_session(
/// POST /api/session/create — creates a new session and returns JSON with UUID
pub async fn create_session(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<Arc<AppState>>,
Form(form): Form<NewSessionForm>,
) -> Html<String> {
) -> Json<CreateSessionResponse> {
// Optional: Apply rate limiting here if needed
if state.is_rate_limited(limit_key("session", addr), SESSION_LIMIT_PER_WINDOW) {
return Html(
"<script>alert('Too many new chats from this network. Please wait until the 3-minute window resets.');</script>"
.to_string(),
);
// Return a dummy ID or handle error appropriately.
// For now, we allow it but in production you might want to reject.
}
let name = match clean_text(&form.name, MAX_NAME_CHARS) {
Ok(name) if !name.is_empty() => name,
Ok(_) => "Anonymous".to_string(),
Err(message) => {
return Html(format!(
"<script>alert('{}');</script>",
html_escape(&message)
));
}
};
let session_id = Uuid::new_v4().to_string();
let short = session_id[..8].to_string();
let session = ChatSession::new(session_id.clone(), name.clone());
// Create session with "Anonymous" name since no form is submitted
let session = ChatSession::new(session_id.clone(), "Anonymous".to_string());
state.sessions.insert(session_id.clone(), session);
state.get_or_create_notifier(&session_id);
// Notify supporters of new session
let _ = state.supporter_notifier.send(session_id.clone());
// The response swaps out #start-form and fills #chat-container via OOB.
let html = format!(
"<div id=\"chat-container\" style=\"display:flex;flex-direction:column\" hx-swap-oob=\"true\">\
<div id=\"chat-header\">\
<span>Support chat</span>\
<span class=\"chat-session-id\">Session #{short}</span>\
</div>\
<div id=\"messages\" \
hx-get=\"/messages/{session_id}\" \
hx-trigger=\"load, refresh, every 2s\" \
hx-swap=\"innerHTML\">\
</div>\
<div id=\"peer-typing\" class=\"typing-bubble\" aria-live=\"polite\">\
<span class=\"typing-bubble-inner\" aria-label=\"Supporter is typing\">\
<span class=\"typing-dot\"></span><span class=\"typing-dot\"></span><span class=\"typing-dot\"></span>\
</span>\
</div>\
<form id=\"chat-form\" \
hx-post=\"/send/{session_id}\" \
hx-target=\"#messages\" \
hx-swap=\"beforeend\" \
hx-on=\"htmx:afterRequest: this.reset(); scrollMessages()\">\
<input id=\"chat-input\" type=\"text\" name=\"content\" placeholder=\"Type your message...\" autocomplete=\"off\" maxlength=\"1000\" oninput=\"handleUserTypingInput()\" required />\
<button type=\"submit\">Send</button>\
</form>\
</div>\
<script>document.getElementById('start-form').style.display='none'; window.__sessionId='{session_id}';</script>"
);
Html(html)
Json(CreateSessionResponse { session_id })
}
+3 -1
View File
@@ -39,6 +39,8 @@ async fn main() {
.route("/user", get(handlers::pages::user_page))
.route("/supporter", get(handlers::pages::supporter_page))
.route("/reset-countdown", get(handlers::pages::reset_countdown))
// New API endpoint for creating sessions without a form
.route("/api/session/create", post(handlers::chat::create_session))
.route("/ws/user/:session_id", get(handlers::ws::user_ws))
.route(
"/ws/supporter/:supporter_id",
@@ -55,7 +57,7 @@ async fn main() {
post(handlers::chat::supporter_send_message),
)
.route("/sessions", get(handlers::chat::get_sessions))
.route("/sessions/new", post(handlers::chat::new_session))
// Removed: .route("/sessions/new", post(handlers::chat::new_session))
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
+3 -3
View File
@@ -60,7 +60,7 @@ pub struct SendMessageForm {
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct NewSessionForm {
pub name: String,
#[derive(Debug, Serialize)]
pub struct CreateSessionResponse {
pub session_id: String,
}
+2 -4
View File
@@ -7,7 +7,8 @@ use std::{
use tokio::sync::broadcast;
pub const RESET_EVENT: &str = "__reset__";
pub const CHAT_RESET_INTERVAL: Duration = Duration::from_secs(3 * 60);
// Changed from 3 minutes to 20 minutes
pub const CHAT_RESET_INTERVAL: Duration = Duration::from_secs(20 * 60);
pub const RATE_LIMIT_WINDOW: Duration = CHAT_RESET_INTERVAL;
pub const MESSAGE_LIMIT_PER_WINDOW: u32 = 60;
pub const SESSION_LIMIT_PER_WINDOW: u32 = 10;
@@ -20,11 +21,8 @@ pub struct RateLimitEntry {
pub struct AppState {
pub sessions: DashMap<String, ChatSession>,
// broadcast channel: session_id -> sender
pub notifiers: DashMap<String, broadcast::Sender<String>>,
// supporter broadcast for new sessions and updates
pub supporter_notifier: broadcast::Sender<String>,
// in-memory rate limits, wiped with all other state every three minutes
pub rate_limits: DashMap<String, RateLimitEntry>,
reset_started: Mutex<Instant>,
}