diff --git a/src/handlers/chat.rs b/src/handlers/chat.rs index 790e792..1625a7d 100644 --- a/src/handlers/chat.rs +++ b/src/handlers/chat.rs @@ -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 { 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>) -> Html { 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, State(state): State>, - Form(form): Form, -) -> Html { +) -> Json { + // Optional: Apply rate limiting here if needed if state.is_rate_limited(limit_key("session", addr), SESSION_LIMIT_PER_WINDOW) { - return Html( - "" - .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!( - "", - 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!( - "
\ -
\ - Support chat\ - Session #{short}\ -
\ -
\ -
\ -
\ - \ - \ - \ -
\ -
\ - \ - \ -
\ -
\ - " - ); - Html(html) + Json(CreateSessionResponse { session_id }) } diff --git a/src/main.rs b/src/main.rs index 740e0f2..fcf53eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); diff --git a/src/models.rs b/src/models.rs index 5f23746..18c37da 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, } diff --git a/src/state.rs b/src/state.rs index c496d5d..517a595 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, - // broadcast channel: session_id -> sender pub notifiers: DashMap>, - // supporter broadcast for new sessions and updates pub supporter_notifier: broadcast::Sender, - // in-memory rate limits, wiped with all other state every three minutes pub rate_limits: DashMap, reset_started: Mutex, } diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..dfed89a --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,99 @@ +.page-body { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: calc(65px + 2.5rem) 2rem 4rem; + min-height: 100vh; + min-height: 100dvh; +} + +.card { + width: min(560px, 100%); + background: var(--bg-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: 3px; + padding: 3rem 3.5rem; + box-shadow: 0 8px 30px rgba(26, 29, 34, 0.06); + animation: panel-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; + opacity: 0; + transform: translateY(10px); + transition: background 0.3s ease, border-color 0.3s ease; +} + +.timer { + display: inline-flex; + margin-bottom: 1.5rem; + color: var(--danger-text); + border: 1px solid var(--danger); + border-radius: 2px; + padding: 0.3rem 0.7rem; + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.08em; + background: var(--danger-soft); +} + +.card h1 { + font-weight: 700; + font-size: 1.5rem; + letter-spacing: -0.02em; + margin-bottom: 0.6rem; + color: var(--ink); +} + +[data-theme="macchiato"] .card h1 { + text-shadow: 0 0 10px rgba(138, 173, 244, 0.25); +} + +.card > p { + color: var(--ink-muted); + line-height: 1.75; + margin-bottom: 2rem; + font-size: 0.88rem; +} + +.choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.choices a { + display: flex; + flex-direction: column; + gap: 0.4rem; + color: var(--ink); + background: transparent; + border: 1px solid var(--border); + border-radius: 3px; + padding: 1.1rem; + transition: border-color 0.2s ease, background 0.2s ease; +} +.choices a:hover { + border-color: var(--accent); + background: var(--bg-panel); + border-bottom-color: var(--accent); +} + +.role-title { + font-weight: 700; + font-size: 0.88rem; + letter-spacing: -0.01em; +} + +.role-detail { + color: var(--ink-muted); + font-size: 0.78rem; + font-weight: 300; + line-height: 1.5; +} + +@media (max-width: 640px) { + .page-body { padding: calc(55px + 1.25rem) 1rem 3rem; } + .card { padding: 2rem 1.5rem; } + .card h1 { font-size: 1.25rem; } + .choices { grid-template-columns: 1fr; } +} diff --git a/static/css/shared.css b/static/css/shared.css new file mode 100644 index 0000000..3b33bab --- /dev/null +++ b/static/css/shared.css @@ -0,0 +1,276 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root, +[data-theme="light"] { + --bg: #f4f1ec; + --bg-panel: rgba(255, 255, 255, 0.85); + --nav-bg: rgba(255, 253, 250, 0.92); + --ink: #1a1d22; + --ink-muted: #595f68; + --ink-dim: #9299a2; + --accent: #164f75; + --link: #0f527d; + --link-underline: rgba(15, 82, 125, 0.5); + --strong: #111820; + --border: rgba(26, 29, 34, 0.1); + --danger: #a03030; + --danger-soft: rgba(160, 48, 48, 0.08); + --danger-text: #a03030; + --own-bubble: #2c5a7a; + --own-bubble-ink: #f4f1ec; + --peer-bubble: rgba(26, 29, 34, 0.06); + --peer-bubble-ink: #1a1d22; + --user-bubble: #2c5a7a; + --user-bubble-ink: #f4f1ec; + --input-bg: rgba(26, 29, 34, 0.04); + --surface-hover: rgba(26, 29, 34, 0.04); + --status-waiting-bg: rgba(180, 130, 20, 0.1); + --status-waiting-ink: #8a6a10; + --status-active-bg: rgba(44, 90, 122, 0.1); + --status-active-ink: #2c5a7a; + --status-closed-bg: rgba(146, 153, 162, 0.1); + --status-closed-ink: #9299a2; +} + +[data-theme="macchiato"] { + --bg: #181926; + --bg-panel: #24273a; + --nav-bg: rgba(36, 39, 58, 0.65); + --ink: #cad3f5; + --ink-muted: #a5adcb; + --ink-dim: #8087a2; + --accent: #8aadf4; + --link: #8aadf4; + --link-underline: rgba(138, 173, 244, 0.5); + --strong: #c6d0f5; + --border: rgba(165, 173, 203, 0.15); + --danger: #ed8796; + --danger-soft: rgba(237, 135, 150, 0.16); + --danger-text: #f5c0cf; + --own-bubble: #8aadf4; + --own-bubble-ink: #181926; + --peer-bubble: rgba(138, 173, 244, 0.08); + --peer-bubble-ink: #cad3f5; + --user-bubble: #8aadf4; + --user-bubble-ink: #181926; + --input-bg: rgba(138, 173, 244, 0.04); + --surface-hover: rgba(138, 173, 244, 0.06); + --status-waiting-bg: rgba(238, 153, 160, 0.15); + --status-waiting-ink: #eed49a; + --status-active-bg: rgba(138, 173, 244, 0.12); + --status-active-ink: #8aadf4; + --status-closed-bg: rgba(165, 173, 203, 0.1); + --status-closed-ink: #8087a2; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--ink); + font-family: 'Noto Sans', sans-serif; + font-size: 0.98rem; + font-weight: 300; + line-height: 1.65; + transition: background 0.3s ease, color 0.3s ease; +} + +button, input { font: inherit; } + +a { + color: var(--link); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: all 0.2s ease; +} +a:hover { border-bottom-color: var(--link-underline); } + +strong, b { + color: var(--strong); + font-weight: 700; +} + +/* ---- Nav (shared across all pages) ---- */ +nav { + position: fixed; top: 0; left: 0; + width: 100%; z-index: 20; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 1rem; + padding: 1.25rem 2.5rem; + background: var(--nav-bg); + backdrop-filter: blur(12px) saturate(120%); + -webkit-backdrop-filter: blur(12px) saturate(120%); + border-bottom: 1px solid var(--border); + transition: background 0.3s ease; +} + +.back-link { + grid-column: 1; + justify-self: start; + font-family: 'Noto Sans', sans-serif; + font-weight: 400; + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + white-space: nowrap; + color: var(--ink-muted); + text-decoration: none; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border); + border-radius: 2px; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} +.back-link:hover { + color: var(--ink); + border-color: var(--ink-muted); + background: var(--bg-panel); +} + +.nav-brand { + grid-column: 2; + justify-self: center; + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + white-space: nowrap; + font-family: 'Noto Sans', sans-serif; + font-weight: 700; + font-size: 1.1rem; + letter-spacing: -0.02em; + color: var(--ink); + text-decoration: none; +} +.nav-brand:hover { color: var(--accent); border-bottom: none; } + +.brand-sep { + font-weight: 300; + color: var(--ink-dim); +} +.brand-sub { + font-weight: 400; + font-size: 0.78rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-muted); + align-self: center; + padding-top: 2px; +} + +[data-theme="macchiato"] .nav-brand { + color: #cad3f5; + text-shadow: 0 0 8px rgba(138, 173, 244, 0.25); +} + +/* ---- Footer (shared across all pages) ---- */ +footer { + position: relative; + z-index: 10; + padding: 0.9rem 2rem; + display: flex; + justify-content: center; + align-items: center; + gap: 1.5rem; + font-family: 'Noto Sans', sans-serif; + font-weight: 300; + font-size: 0.72rem; + letter-spacing: 0.05em; + color: var(--ink-muted); + border-top: 1px solid var(--border); + background: var(--nav-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: background 0.3s ease; +} + +.footer-icon { + color: var(--ink-muted); + text-decoration: none; + transition: all 0.25s ease; + display: flex; + align-items: center; + border-bottom: none; +} +.footer-icon:hover { + color: var(--accent); + border-bottom: none; +} + +/* ---- Theme toggle ---- */ +.theme-toggle { + position: fixed; + bottom: 4rem; + right: 1.5rem; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--border); + cursor: pointer; + padding: 0; + z-index: 30; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} +.theme-toggle:hover { + transform: scale(1.08); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); +} +[data-theme="light"] .theme-toggle { + background: linear-gradient(135deg, #24273a 0%, #8aadf4 100%); + border-color: rgba(26, 29, 34, 0.2); +} +[data-theme="macchiato"] .theme-toggle { + background: #24273a; + border-color: rgba(138, 173, 244, 0.4); + box-shadow: + 0 0 10px rgba(138, 173, 244, 0.4), + 0 0 25px rgba(138, 173, 244, 0.2), + 0 0 50px rgba(116, 150, 230, 0.15); + animation: neon-breathe-toggle 4s ease-in-out infinite; +} + +[data-theme="macchiato"] .theme-toggle:hover { + box-shadow: + 0 0 15px rgba(138, 173, 244, 0.6), + 0 0 35px rgba(138, 173, 244, 0.35), + 0 0 70px rgba(116, 150, 230, 0.25); +} + +@keyframes neon-breathe-toggle { + 0%, 100% { + box-shadow: + 0 0 10px rgba(138, 173, 244, 0.5), + 0 0 25px rgba(138, 173, 244, 0.3), + 0 0 50px rgba(116, 150, 230, 0.2); + } + 50% { + box-shadow: + 0 0 15px rgba(138, 173, 244, 0.6), + 0 0 35px rgba(138, 173, 244, 0.4), + 0 0 70px rgba(116, 150, 230, 0.25); + } +} + +/* ---- Animations ---- */ +@keyframes panel-in { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } +} + +/* ---- Responsive nav ---- */ +@media (max-width: 640px) { + nav { + gap: 0.5rem; + padding: 0.85rem 0.85rem; + } + .back-link { font-size: 0.58rem; padding: 0.25rem 0.4rem; letter-spacing: 0.08em; } + .nav-brand { font-size: 0.9rem; } + .brand-sub { font-size: 0.6rem; letter-spacing: 0.1em; } + footer { font-size: 0.65rem; padding: 0.7rem 1rem; } + .theme-toggle { width: 20px; height: 20px; bottom: 3rem; right: 0.9rem; } +} diff --git a/static/css/supporter.css b/static/css/supporter.css new file mode 100644 index 0000000..b138658 --- /dev/null +++ b/static/css/supporter.css @@ -0,0 +1,504 @@ +html, body { + width: 100%; + height: 100%; + height: var(--app-height, 100dvh); + overflow: hidden; + -webkit-text-size-adjust: 100%; +} + +body { + overflow: hidden; +} + +/* ---- Layout ---- */ +.layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + grid-template-rows: 52px minmax(0, 1fr); + width: 100%; + height: calc(var(--app-height, 100dvh) - 40px); + overflow: hidden; +} + +/* ---- Topbar ---- */ +.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1.5rem; + border-bottom: 1px solid var(--border); + background: var(--nav-bg); + backdrop-filter: blur(12px) saturate(120%); + -webkit-backdrop-filter: blur(12px) saturate(120%); + min-width: 0; + transition: background 0.3s ease; +} + +.menu-button { + display: none; + background: transparent; + color: var(--ink); + border: 1px solid var(--border); + border-radius: 2px; + padding: 0.4rem 0.65rem; + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} +.menu-button:hover { border-color: var(--ink-muted); } + +.topbar-brand { + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + white-space: nowrap; + font-weight: 700; + font-size: 1rem; + letter-spacing: -0.02em; + color: var(--ink); + text-decoration: none; +} +.topbar-brand:hover { color: var(--accent); border-bottom: none; } + +[data-theme="macchiato"] .topbar-brand { + color: #cad3f5; + text-shadow: 0 0 8px rgba(138, 173, 244, 0.25); +} + +.topbar-brand .brand-sep { + font-weight: 300; + color: var(--ink-dim); +} +.topbar-brand .brand-sub { + font-weight: 400; + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.topbar-timer { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: auto; + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.08em; + color: var(--danger-text); + background: var(--danger-soft); + padding: 0.3rem 0.7rem; + border-radius: 2px; + border: 1px solid var(--danger); + white-space: nowrap; +} + +.drawer-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 40; + backdrop-filter: blur(2px); +} + +/* ---- Sidebar ---- */ +.sidebar { + grid-column: 1; + grid-row: 2; + border-right: 1px solid var(--border); + background: var(--bg-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + transition: background 0.3s ease; +} + +.sidebar-header { + padding: 1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.75rem; + justify-content: space-between; +} + +.sidebar-title { + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--ink-dim); +} + +.close-sidebar { + display: none; + background: transparent; + border: 1px solid var(--border); + color: var(--ink-muted); + border-radius: 2px; + padding: 0.35rem 0.6rem; + cursor: pointer; + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.close-sidebar:hover { color: var(--ink); border-color: var(--ink-muted); } + +#session-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + -webkit-overflow-scrolling: touch; +} + +#session-list::-webkit-scrollbar, +#supporter-messages::-webkit-scrollbar { width: 3px; height: 3px; } +#session-list::-webkit-scrollbar-thumb, +#supporter-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.session-item { + width: 100%; + text-align: left; + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + padding: 0.7rem 0.75rem; + cursor: pointer; + color: var(--ink); + font-size: 0.85rem; + transition: background 0.15s, border-color 0.15s; + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.session-item:hover { background: var(--surface-hover); border-color: var(--border); } +.session-item.selected { background: var(--surface-hover); border-color: var(--accent); } + +.session-name { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; +} + +.session-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.session-badge { + font-size: 0.6rem; + padding: 0.12rem 0.45rem; + border-radius: 2px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.status-waiting { background: var(--status-waiting-bg); color: var(--status-waiting-ink); } +.status-active { background: var(--status-active-bg); color: var(--status-active-ink); } +.status-closed { background: var(--status-closed-bg); color: var(--status-closed-ink); } + +.session-count { font-size: 0.7rem; color: var(--ink-dim); } +.session-id { + font-size: 0.65rem; + color: var(--ink-dim); + letter-spacing: 0.04em; +} + +.no-sessions { + padding: 1.5rem 1rem; + font-size: 0.82rem; + color: var(--ink-dim); + text-align: center; + line-height: 1.6; +} + +/* ---- Main panel ---- */ +.main-panel { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: stretch; + overflow: hidden; + min-width: 0; + background: var(--bg); + padding: 1rem; +} + +.chat-shell { + display: flex; + flex-direction: column; + width: min(860px, 100%); + height: 100%; + background: var(--bg-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + box-shadow: 0 8px 30px rgba(26, 29, 34, 0.06); + transition: background 0.3s ease, border-color 0.3s ease; +} + +.main-header { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.8rem 1rem; + border-bottom: 1px solid var(--border); + min-height: 50px; + flex-shrink: 0; +} + +.active-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +#active-session-name { + font-weight: 700; + font-size: 0.88rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#active-session-meta { + font-size: 0.65rem; + color: var(--ink-dim); + white-space: nowrap; + letter-spacing: 0.04em; +} + +/* ---- Messages ---- */ +#supporter-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} + +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--ink-dim); + gap: 0.4rem; + font-size: 0.85rem; + text-align: center; + padding: 1rem; + line-height: 1.6; +} + +.msg-wrap { display: flex; } +.msg-user-wrap { justify-content: flex-start; } +.msg-supporter-wrap { justify-content: flex-end; } + +.msg-user, .msg-supporter { + max-width: 78%; + padding: 0.55rem 0.8rem; + border-radius: 12px; + font-size: 0.85rem; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.msg-user { + background: var(--peer-bubble); + color: var(--peer-bubble-ink); + border-bottom-left-radius: 3px; +} + +.msg-supporter { + background: var(--own-bubble); + color: var(--own-bubble-ink); + border-bottom-right-radius: 3px; +} + +.msg-label { + display: block; + font-size: 0.62rem; + font-weight: 500; + opacity: 0.7; + margin-bottom: 2px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.msg-text { margin: 0; } + +.msg-time { + display: block; + font-size: 0.6rem; + opacity: 0.5; + margin-top: 4px; + text-align: right; + letter-spacing: 0.04em; +} + +.msg-system-wrap { display: flex; justify-content: center; margin: 0.25rem 0; } +.msg-system-text { + font-size: 0.7rem; + color: var(--ink-dim); + background: var(--input-bg); + padding: 0.25rem 0.75rem; + border-radius: 2px; + text-align: center; + letter-spacing: 0.04em; +} + +/* ---- Typing bubble ---- */ +.typing-bubble { + display: none; + padding: 0 0.9rem 0.55rem; + justify-content: flex-start; + flex-shrink: 0; +} +.typing-bubble.visible { display: flex; } + +.typing-bubble-inner { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 48px; + padding: 0.55rem 0.75rem; + background: var(--peer-bubble); + border-radius: 12px; + border-bottom-left-radius: 3px; +} + +.typing-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ink-dim); + animation: typing-bounce 1.1s infinite ease-in-out; +} +.typing-dot:nth-child(2) { animation-delay: 0.16s; } +.typing-dot:nth-child(3) { animation-delay: 0.32s; } + +/* ---- Reply area ---- */ +#reply-area { + border-top: 1px solid var(--border); + padding: 0.6rem 0.85rem max(0.75rem, env(safe-area-inset-bottom)); + display: none; + flex-shrink: 0; +} +#reply-area.visible { display: flex; gap: 0.45rem; } + +#reply-area input[type="text"] { + flex: 1; + min-width: 0; + padding: 0.55rem 0.8rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.25; + outline: none; + transition: border-color 0.2s; +} +#reply-area input[type="text"]:focus { border-color: var(--accent); } +#reply-area input[type="text"]::placeholder { color: var(--ink-dim); } + +#reply-area button { + padding: 0.55rem 0.9rem; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 3px; + font-weight: 700; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: opacity 0.2s; + white-space: nowrap; +} +#reply-area button:hover { opacity: 0.85; } + +/* ---- Mobile ---- */ +@media (max-width: 820px) { + .layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 52px minmax(0, 1fr); + } + + .topbar { padding: 0 0.75rem; } + .menu-button { display: inline-flex; } + + .drawer-backdrop.visible { display: block; } + + .sidebar { + position: fixed; + top: 0; left: 0; bottom: 0; + height: 100%; + height: var(--app-height, 100dvh); + width: min(88vw, 320px); + border-right: 1px solid var(--border); + z-index: 50; + transform: translateX(-102%); + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 12px 0 35px rgba(0, 0, 0, 0.25); + } + .sidebar.open { transform: translateX(0); } + .close-sidebar { display: inline-flex; } + + .main-panel { + grid-column: 1; + grid-row: 2; + padding: 0.65rem; + align-items: stretch; + justify-content: stretch; + } + + .chat-shell { + width: 100%; + max-width: none; + height: 100%; + } + + .main-header { padding: 0.75rem 0.85rem; } + .msg-user, .msg-supporter { max-width: 86%; } + #reply-area.visible { gap: 0.4rem; } + #reply-area button { padding-inline: 0.75rem; } +} + +@media (max-width: 460px) { + .topbar-brand .brand-sub { display: none; } + .topbar-brand .brand-sep { display: none; } + .main-panel { padding: 0.45rem; } + .main-header { min-height: 46px; } + #supporter-messages { padding: 0.75rem; } + .theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.75rem; } +} diff --git a/static/css/user.css b/static/css/user.css new file mode 100644 index 0000000..b9f2db9 --- /dev/null +++ b/static/css/user.css @@ -0,0 +1,245 @@ +html, body { + width: 100%; + height: 100%; + height: var(--app-height, 100dvh); + overflow: hidden; + -webkit-text-size-adjust: 100%; +} + +body { + display: flex; + flex-direction: column; +} + +/* ---- Timer ---- */ +.page-timer { + display: inline-flex; + align-items: center; + justify-content: center; + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + color: var(--danger-text); + border: 1px solid var(--danger); + border-radius: 2px; + padding: 0.3rem 0.7rem; + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.08em; + background: var(--danger-soft); + z-index: 10; + white-space: nowrap; +} + +/* ---- Chat container ---- */ +.chat-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: calc(65px + 1rem) 1rem 4rem; + overflow: hidden; +} + +#chat-container { + display: flex; + flex-direction: column; + width: min(420px, 100%); + height: min(620px, calc(var(--app-height, 100dvh) - 8rem)); + background: var(--bg-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + box-shadow: 0 8px 30px rgba(26, 29, 34, 0.06); + transition: background 0.3s ease, border-color 0.3s ease; +} + +#chat-header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 0.88rem; + min-height: 52px; + flex-shrink: 0; +} + +.chat-session-id { + margin-left: auto; + font-size: 0.68rem; + color: var(--ink-dim); + font-weight: 400; + white-space: nowrap; + letter-spacing: 0.04em; +} + +/* ---- Messages ---- */ +#messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + scroll-behavior: smooth; +} + +#messages::-webkit-scrollbar { width: 3px; } +#messages::-webkit-scrollbar-track { background: transparent; } +#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.msg-wrap { display: flex; } +.msg-user-wrap { justify-content: flex-end; } +.msg-supporter-wrap { justify-content: flex-start; } + +.msg-user, .msg-supporter { + max-width: 78%; + padding: 0.55rem 0.8rem; + border-radius: 12px; + font-size: 0.85rem; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.msg-user { + background: var(--user-bubble); + color: var(--user-bubble-ink); + border-bottom-right-radius: 3px; +} + +.msg-supporter { + background: var(--peer-bubble); + color: var(--peer-bubble-ink); + border-bottom-left-radius: 3px; +} + +.msg-label { + display: block; + font-size: 0.62rem; + font-weight: 500; + opacity: 0.7; + margin-bottom: 2px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.msg-text { margin: 0; } + +.msg-time { + display: block; + font-size: 0.6rem; + opacity: 0.5; + margin-top: 4px; + text-align: right; + letter-spacing: 0.04em; +} + +.msg-system-wrap { + display: flex; + justify-content: center; + margin: 0.25rem 0; +} + +.msg-system-text { + font-size: 0.7rem; + color: var(--ink-dim); + background: var(--input-bg); + padding: 0.25rem 0.75rem; + border-radius: 2px; + text-align: center; + letter-spacing: 0.04em; +} + +/* ---- Typing bubble ---- */ +.typing-bubble { + display: none; + padding: 0 0.9rem 0.55rem; + justify-content: flex-start; + flex-shrink: 0; +} +.typing-bubble.visible { display: flex; } + +.typing-bubble-inner { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 48px; + padding: 0.55rem 0.75rem; + background: var(--peer-bubble); + border-radius: 12px; + border-bottom-left-radius: 3px; +} + +.typing-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ink-dim); + animation: typing-bounce 1.1s infinite ease-in-out; +} +.typing-dot:nth-child(2) { animation-delay: 0.16s; } +.typing-dot:nth-child(3) { animation-delay: 0.32s; } + +@keyframes typing-bounce { + 0%, 75%, 100% { transform: translateY(0); opacity: 0.45; } + 35% { transform: translateY(-4px); opacity: 1; } +} + +/* ---- Chat input ---- */ +#chat-form { + display: flex; + gap: 0.45rem; + padding: 0.65rem 0.85rem max(0.8rem, env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +#chat-form input[type="text"] { + flex: 1; + min-width: 0; + padding: 0.55rem 0.8rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.25; + outline: none; + transition: border-color 0.2s; +} +#chat-form input[type="text"]:focus { border-color: var(--accent); } +#chat-form input[type="text"]::placeholder { color: var(--ink-dim); } + +#chat-form button { + padding: 0.55rem 0.9rem; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 3px; + font-weight: 700; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: opacity 0.2s; + white-space: nowrap; +} +#chat-form button:hover { opacity: 0.85; } + +@media (max-width: 520px) { + .page-timer { top: 0.75rem; width: calc(100% - 1.5rem); text-align: center; justify-content: center; } + .chat-wrapper { padding: calc(55px + 0.75rem) 0.75rem 3rem; } + #chat-container { width: 100%; height: calc(var(--app-height, 100dvh) - 7rem); max-height: none; } + .chat-session-id { display: none; } + .msg-user, .msg-supporter { max-width: 86%; } + #messages { padding: 0.8rem; } + #chat-form { gap: 0.4rem; } + #chat-form button { padding-inline: 0.75rem; } +} diff --git a/static/js/shared.js b/static/js/shared.js new file mode 100644 index 0000000..6b67094 --- /dev/null +++ b/static/js/shared.js @@ -0,0 +1,19 @@ +(function () { + function currentSystemTheme() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light'; + } + var btn = document.getElementById('theme-toggle'); + if (btn) { + btn.addEventListener('click', function () { + var current = document.documentElement.getAttribute('data-theme') || 'light'; + var next = current === 'light' ? 'macchiato' : 'light'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('poc-theme', next); + }); + } + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { + if (!localStorage.getItem('poc-theme')) { + document.documentElement.setAttribute('data-theme', currentSystemTheme()); + } + }); +})(); diff --git a/static/js/supporter.js b/static/js/supporter.js new file mode 100644 index 0000000..8069ef5 --- /dev/null +++ b/static/js/supporter.js @@ -0,0 +1,187 @@ +var activeSessionId = null; +var supporterWs = null; +var lastTypingSessionId = null; +var supporterTypingActive = false; + +function setAppHeight() { + var height = window.visualViewport ? window.visualViewport.height : window.innerHeight; + document.documentElement.style.setProperty('--app-height', height + 'px'); +} + +setAppHeight(); +window.addEventListener('resize', setAppHeight); +if (window.visualViewport) { + window.visualViewport.addEventListener('resize', setAppHeight); + window.visualViewport.addEventListener('scroll', setAppHeight); +} + +function openSidebar() { + document.getElementById('sidebar').classList.add('open'); + document.getElementById('drawer-backdrop').classList.add('visible'); +} + +function closeSidebar() { + document.getElementById('sidebar').classList.remove('open'); + document.getElementById('drawer-backdrop').classList.remove('visible'); +} + +function selectSession(button) { + document.querySelectorAll('.session-item').forEach(function (b) { b.classList.remove('selected'); }); + button.classList.add('selected'); + setActiveSession(button.dataset.sessionId, button.dataset.sessionName); + closeSidebar(); +} + +function setActiveSession(sessionId, name) { + stopSupporterTyping(lastTypingSessionId); + activeSessionId = sessionId; + document.getElementById('active-session-name').textContent = name; + document.getElementById('active-session-name').style.color = ''; + document.getElementById('active-session-meta').textContent = 'Session #' + sessionId.slice(0, 8); + document.getElementById('reply-area').classList.add('visible'); + document.getElementById('reply-input').value = ''; + hideUserTyping(); + scrollSupporterMessages(); +} + +function resetSupporterDashboard() { + stopSupporterTyping(lastTypingSessionId); + activeSessionId = null; + document.getElementById('active-session-name').textContent = 'Select a conversation'; + document.getElementById('active-session-name').style.color = 'var(--ink-dim)'; + document.getElementById('active-session-meta').textContent = ''; + document.getElementById('reply-area').classList.remove('visible'); + hideUserTyping(); + closeSidebar(); + document.getElementById('supporter-messages').innerHTML = + '

Chat history was cleared.

New conversations will appear in the chat list.

'; + htmx.trigger('#session-list', 'refresh'); +} + +function scrollSupporterMessages() { + var el = document.getElementById('supporter-messages'); + if (el) el.scrollTop = el.scrollHeight; +} + +function showUserTyping() { + var indicator = document.getElementById('supporter-peer-typing'); + if (!indicator) return; + indicator.classList.add('visible'); + scrollSupporterMessages(); +} + +function hideUserTyping() { + var indicator = document.getElementById('supporter-peer-typing'); + if (indicator) indicator.classList.remove('visible'); +} + +function sendSupporterTypingState(state, sessionId) { + sessionId = sessionId || activeSessionId; + if (!sessionId || !supporterWs || supporterWs.readyState !== WebSocket.OPEN) return; + supporterWs.send('typing:supporter:' + state + ':' + sessionId); +} + +function startSupporterTyping() { + var input = document.getElementById('reply-input'); + if (!activeSessionId || !input || !input.value.trim()) { + stopSupporterTyping(lastTypingSessionId || activeSessionId); + return; + } + if (supporterTypingActive && lastTypingSessionId === activeSessionId) return; + supporterTypingActive = true; + lastTypingSessionId = activeSessionId; + sendSupporterTypingState('start', activeSessionId); +} + +function stopSupporterTyping(sessionId) { + sessionId = sessionId || activeSessionId; + if (!supporterTypingActive && !sessionId) return; + var targetSession = sessionId || lastTypingSessionId; + if (targetSession) sendSupporterTypingState('stop', targetSession); + supporterTypingActive = false; + lastTypingSessionId = null; +} + +function handleReplyInput() { + var input = document.getElementById('reply-input'); + if (input && input.value.trim()) startSupporterTyping(); + else stopSupporterTyping(lastTypingSessionId || activeSessionId); +} + +document.addEventListener('htmx:afterSwap', function (e) { + if (e.detail.target.id === 'supporter-messages') scrollSupporterMessages(); +}); + +document.getElementById('reply-btn').addEventListener('click', function () { + if (!activeSessionId) return; + var input = document.getElementById('reply-input'); + var content = input.value.trim(); + if (!content) return; + + stopSupporterTyping(activeSessionId); + + fetch('/supporter/send/' + activeSessionId, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'content=' + encodeURIComponent(content) + }) + .then(function (r) { return r.text(); }) + .then(function (html) { + var msgs = document.getElementById('supporter-messages'); + msgs.insertAdjacentHTML('beforeend', html); + input.value = ''; + scrollSupporterMessages(); + setAppHeight(); + }); +}); + +document.getElementById('reply-input').addEventListener('keydown', function (e) { + if (e.key === 'Enter') document.getElementById('reply-btn').click(); +}); + +document.getElementById('reply-input').addEventListener('input', handleReplyInput); +document.getElementById('reply-input').addEventListener('focus', function () { setTimeout(setAppHeight, 80); }); +document.getElementById('reply-input').addEventListener('blur', function () { setTimeout(setAppHeight, 80); }); + +function connectSupporterWS() { + var wsProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; + supporterWs = new WebSocket(wsProtocol + '://' + location.host + '/ws/supporter/dashboard'); + + supporterWs.onmessage = function (e) { + var data = e.data; + if (data === '__reset__') { + resetSupporterDashboard(); + return; + } + if (data.indexOf('typing:user:start:') === 0) { + var sessionId = data.replace('typing:user:start:', ''); + if (sessionId === activeSessionId) showUserTyping(); + return; + } + if (data.indexOf('typing:user:stop:') === 0) { + var sessionId2 = data.replace('typing:user:stop:', ''); + if (sessionId2 === activeSessionId) hideUserTyping(); + return; + } + if (data.indexOf('typing:supporter:') === 0) return; + + htmx.trigger('#session-list', 'refresh'); + + if (data === activeSessionId) { + hideUserTyping(); + fetch('/supporter/messages/' + activeSessionId) + .then(function (r) { return r.text(); }) + .then(function (html) { + document.getElementById('supporter-messages').innerHTML = html; + scrollSupporterMessages(); + }); + } + }; + + supporterWs.onopen = function () { + if (supporterTypingActive && lastTypingSessionId) sendSupporterTypingState('start', lastTypingSessionId); + }; + supporterWs.onclose = function () { setTimeout(connectSupporterWS, 2000); }; +} + +connectSupporterWS(); diff --git a/static/js/user.js b/static/js/user.js new file mode 100644 index 0000000..55f5bc9 --- /dev/null +++ b/static/js/user.js @@ -0,0 +1,113 @@ +var ws = null; +var userTypingActive = false; + +function setAppHeight() { + var height = window.visualViewport ? window.visualViewport.height : window.innerHeight; + document.documentElement.style.setProperty('--app-height', height + 'px'); +} + +setAppHeight(); +window.addEventListener('resize', setAppHeight); +if (window.visualViewport) { + window.visualViewport.addEventListener('resize', setAppHeight); + window.visualViewport.addEventListener('scroll', setAppHeight); +} + +function scrollMessages() { + var el = document.getElementById('messages'); + if (el) el.scrollTop = el.scrollHeight; +} + +function showSupporterTyping() { + var indicator = document.getElementById('peer-typing'); + if (!indicator) return; + indicator.classList.add('visible'); + scrollMessages(); +} + +function hideSupporterTyping() { + var indicator = document.getElementById('peer-typing'); + if (indicator) indicator.classList.remove('visible'); +} + +function sendUserTypingState(state) { + if (!ws || ws.readyState !== WebSocket.OPEN || !window.__sessionId) return; + ws.send('typing:user:' + state + ':' + window.__sessionId); +} + +function startUserTyping() { + if (userTypingActive) return; + userTypingActive = true; + sendUserTypingState('start'); +} + +function stopUserTyping() { + if (!userTypingActive) return; + sendUserTypingState('stop'); + userTypingActive = false; +} + +function handleUserTypingInput() { + var input = document.getElementById('chat-input'); + if (input && input.value.trim()) startUserTyping(); + else stopUserTyping(); +} + +document.addEventListener('htmx:afterSwap', function (e) { + if (e.detail.target.id === 'messages') scrollMessages(); +}); + +document.addEventListener('htmx:beforeRequest', function (e) { + if (e.detail.elt && e.detail.elt.id === 'chat-form') stopUserTyping(); +}); + +function resetChat() { + stopUserTyping(); + var oldWs = ws; + ws = null; + window.__sessionId = null; + userTypingActive = false; + if (oldWs) oldWs.close(); + location.reload(); +} + +function initChat(sessionId) { + window.__sessionId = sessionId; + document.getElementById('chat-session-id').textContent = 'Session #' + sessionId.slice(0, 8); + + var wsProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(wsProtocol + '://' + location.host + '/ws/user/' + sessionId); + + ws.onmessage = function (message) { + if (message.data === '__reset__') { + resetChat(); + return; + } + if (message.data.startsWith('typing:supporter:start:')) { + showSupporterTyping(); + return; + } + if (message.data.startsWith('typing:supporter:stop:')) { + hideSupporterTyping(); + return; + } + if (message.data.startsWith('typing:user:')) return; + hideSupporterTyping(); + htmx.trigger('#messages', 'refresh'); + }; + + ws.onopen = function () { + htmx.trigger('#messages', 'refresh'); + }; + + ws.onerror = function () { + console.warn('WebSocket error; polling will continue.'); + }; + + var chatInput = document.getElementById('chat-input'); + if (chatInput) { + chatInput.addEventListener('focus', function () { setTimeout(setAppHeight, 80); }); + chatInput.addEventListener('blur', function () { setTimeout(setAppHeight, 80); }); + } + setAppHeight(); +} diff --git a/templates/index.html b/templates/index.html index 2e0d2de..50335d1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,335 +3,37 @@ - Support Chat — FLÓ + User to Support Chat — FLÓ - + + + - -
Reset in --:-- -

Support chat

-

Chat demo with separate user and supporter views. Conversations, messages, and rate limits reset every 3 minutes. For instructions please go to the examples section on fló.fo

+

User to Support Chat

+

Rust-based real-time chat with separate user and supporter views. Conversations, messages, and rate limits reset every 20 minutes.

@@ -348,33 +50,19 @@ - + diff --git a/templates/supporter.html b/templates/supporter.html index 0889c02..8b81c45 100644 --- a/templates/supporter.html +++ b/templates/supporter.html @@ -8,629 +8,17 @@ - + + + - - @@ -638,7 +26,7 @@
- FLÓ / SUPPORT + FLÓ / USER TO SUPPORT CHAT // RUST Reset in --:--
@@ -688,215 +76,22 @@
+
+ © FLÓ.FO | ØLL RÆTTINDI TILSKILAÐ + + + + + + + + + +
+ - - + + diff --git a/templates/user.html b/templates/user.html index f0b2db6..bf8a721 100644 --- a/templates/user.html +++ b/templates/user.html @@ -8,575 +8,91 @@ - + + + - - + + + Reset in --:-- -
-

Talk to support

-

Start a conversation and a supporter can reply from the dashboard.

-
- - -
+
+
+
+ Support chat + Connecting... +
+
+
+
+ + + +
+
+ + +
+
-
+
+ © FLÓ.FO | ØLL RÆTTINDI TILSKILAÐ + + + + + + + + + +
+ +