From 0a0a1b2a500b204ef10584eb63f49f1e660b11d7 Mon Sep 17 00:00:00 2001
From: creations <creations@creations.works>
Date: Sun, 1 Jun 2025 07:56:02 -0400
Subject: [PATCH] add simple index page for user use

---
 Cargo.lock             |  38 +++++++++
 Cargo.toml             |   3 +-
 public/css/style.css   | 170 +++++++++++++++++++++++++++++++++++++++++
 public/index.html      |  41 ++++++++++
 public/js/index.js     |  87 +++++++++++++++++++++
 src/db/redis_helper.rs |   2 +-
 src/routes/auth.rs     |  77 ++++++++++++++-----
 src/routes/mod.rs      |  14 +++-
 8 files changed, 412 insertions(+), 20 deletions(-)
 create mode 100644 public/css/style.css
 create mode 100644 public/index.html
 create mode 100644 public/js/index.js

diff --git a/Cargo.lock b/Cargo.lock
index 8a192ec..8cb5347 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -743,6 +743,12 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -1070,6 +1076,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.8"
@@ -2175,6 +2191,7 @@ dependencies = [
  "tower-http",
  "tracing",
  "tracing-subscriber",
+ "urlencoding",
  "uuid",
 ]
 
@@ -2303,11 +2320,20 @@ dependencies = [
  "futures-util",
  "http",
  "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
  "iri-string",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
  "pin-project-lite",
+ "tokio",
+ "tokio-util",
  "tower",
  "tower-layer",
  "tower-service",
+ "tracing",
 ]
 
 [[package]]
@@ -2392,6 +2418,12 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
 
+[[package]]
+name = "unicase"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.18"
@@ -2436,6 +2468,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"
diff --git a/Cargo.toml b/Cargo.toml
index fb99e20..b1b5e97 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,8 @@ tracing-subscriber = "0.3"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1.0"
 reqwest = { version = "0.12", features = ["json", "gzip"] }
-tower-http = { version = "0.6.4", features = ["cors"] }
+tower-http = { version = "0.6.4", features = ["cors", "fs"] }
 headers = "0.4.0"
 chrono-tz = "0.10.3"
 tower = "0.5.2"
+urlencoding = "2.1.3"
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..b116f25
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,170 @@
+:root {
+    --bg: #0d0d0d;
+    --fg: #f5f5f5;
+    --accent: #4ea3ff;
+    --card: #1c1c1c;
+    --border: #333;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    background: var(--bg);
+    color: var(--fg);
+    font-family: system-ui, sans-serif;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    min-height: 100vh;
+    padding: 2rem;
+}
+
+main {
+    width: 100%;
+    max-width: 420px;
+    background: var(--card);
+    padding: 2rem;
+    border-radius: 1rem;
+    box-shadow: 0 0 10px #0008;
+    border: 1px solid var(--border);
+}
+
+h1,
+h2 {
+    text-align: center;
+    margin-bottom: 1rem;
+    color: var(--accent);
+}
+
+.profile {
+    text-align: center;
+    margin-bottom: 1.5rem;
+}
+
+#avatar {
+    width: 80px;
+    height: 80px;
+    border-radius: 50%;
+    border: 2px solid var(--border);
+    margin-bottom: 0.5rem;
+}
+
+label {
+    display: block;
+    margin-bottom: 0.5rem;
+    font-weight: 500;
+}
+
+select,
+button {
+    width: 100%;
+    padding: 0.6rem;
+    margin-top: 0.25rem;
+    border: 1px solid var(--border);
+    border-radius: 0.5rem;
+    font-size: 1rem;
+    background: var(--bg);
+    color: var(--fg);
+}
+
+select:focus,
+button:hover {
+    border-color: var(--accent);
+    outline: none;
+}
+
+#set-timezone {
+    background: var(--accent);
+    color: white;
+    margin-top: 1rem;
+    cursor: pointer;
+    transition: background 0.2s ease;
+}
+
+#set-timezone:hover {
+    background: #2c8de6;
+}
+
+a#login-btn {
+    display: block;
+    text-align: center;
+    padding: 0.75rem;
+    margin-top: 1rem;
+    background: var(--accent);
+    color: white;
+    font-weight: bold;
+    border-radius: 0.5rem;
+    text-decoration: none;
+    transition: background 0.2s ease;
+}
+
+a#login-btn:hover {
+    background: #2c8de6;
+}
+
+#status-msg {
+    margin-top: 1rem;
+    text-align: center;
+    font-size: 0.9rem;
+    color: var(--accent);
+}
+
+button.danger {
+    background: #ff4e4e;
+    color: white;
+}
+
+button.danger:hover {
+    background: #e63838;
+}
+
+.ts-wrapper.single .ts-control {
+    background-color: var(--card);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 0.5rem;
+    padding: 0.6rem;
+    font-size: 1rem;
+    box-shadow: none;
+}
+
+.ts-wrapper.single.focus .ts-control {
+    background-color: var(--card);
+    border-color: var(--accent);
+    box-shadow: 0 0 0 2px rgba(78, 163, 255, 0.25);
+}
+
+.ts-dropdown {
+    background-color: var(--card);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 0.5rem;
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
+}
+
+.ts-dropdown .option {
+    padding: 0.5rem 0.75rem;
+    cursor: pointer;
+    transition: background 0.2s;
+}
+
+.ts-dropdown .option:hover {
+    background-color: rgba(255, 255, 255, 0.05);
+}
+
+.ts-dropdown .option.active {
+    background-color: var(--accent);
+    color: #fff;
+}
+
+.ts-wrapper .ts-control input {
+    color: var(--fg);
+}
+
+
+.hidden {
+    display: none;
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5354c51
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Timezone DB</title>
+
+    <link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
+    <script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js" defer></script>
+
+    <link rel="stylesheet" href="/public/css/style.css" />
+    <script src="/public/js/index.js" defer></script>
+</head>
+
+<body>
+    <main>
+        <section id="login-section" class="hidden">
+            <h1>Timezone DB</h1>
+            <a href="/auth/discord?redirect=/" id="login-btn">Log in with Discord</a>
+        </section>
+
+        <section id="timezone-section" class="hidden">
+            <div class="profile">
+                <img id="avatar" class="hidden" width="80" height="80" alt="Avatar" />
+                <h2 id="auth-status">Logged in</h2>
+            </div>
+
+            <label for="timezone-select">Select your timezone:</label>
+            <select id="timezone-select">
+                <option value="">Select timezone</option>
+            </select>
+            <button id="set-timezone">Save</button>
+            <button id="delete-timezone" class="danger hidden">Delete Timezone</button>
+
+            <p id="status-msg"></p>
+        </section>
+    </main>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/public/js/index.js b/public/js/index.js
new file mode 100644
index 0000000..a342ffb
--- /dev/null
+++ b/public/js/index.js
@@ -0,0 +1,87 @@
+const loginSection = document.getElementById("login-section");
+const timezoneSection = document.getElementById("timezone-section");
+const avatarEl = document.getElementById("avatar");
+const authStatusEl = document.getElementById("auth-status");
+const timezoneSelect = document.getElementById("timezone-select");
+const setBtn = document.getElementById("set-timezone");
+const statusMsg = document.getElementById("status-msg");
+
+const timezones = Intl.supportedValuesOf("timeZone");
+timezones.forEach(tz => {
+	const opt = document.createElement("option");
+	opt.value = tz;
+	opt.textContent = tz;
+	timezoneSelect.appendChild(opt);
+});
+
+const ts = new TomSelect("#timezone-select", {
+	create: false,
+	sorted: true,
+	searchField: ["text"],
+	maxOptions: 1000
+});
+
+async function fetchUserInfo() {
+	try {
+		const res = await fetch("/me", { credentials: "include" });
+		if (!res.ok) throw new Error();
+
+		const json = await res.json();
+		const user = json.user;
+		const tz = json.timezone;
+
+		authStatusEl.textContent = user.username;
+
+		if (user.avatar) {
+			avatarEl.src = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
+			avatarEl.classList.remove("hidden");
+		}
+
+		loginSection.classList.add("hidden");
+		timezoneSection.classList.remove("hidden");
+
+		const deleteBtn = document.getElementById("delete-timezone");
+
+		if (tz) {
+			ts.setValue(tz);
+			deleteBtn.classList.remove("hidden");
+		} else {
+			ts.clear();
+			deleteBtn.classList.add("hidden");
+		}
+
+		deleteBtn.addEventListener("click", async () => {
+			try {
+				const res = await fetch("/delete", { credentials: "include" });
+				if (!res.ok) throw new Error();
+
+				ts.clear();
+				statusMsg.textContent = "Timezone deleted.";
+				deleteBtn.classList.add("hidden");
+			} catch {
+				statusMsg.textContent = "Failed to delete timezone.";
+			}
+		});
+
+	} catch {
+		loginSection.classList.remove("hidden");
+		timezoneSection.classList.add("hidden");
+	}
+}
+
+setBtn.addEventListener("click", async () => {
+	const timezone = ts.getValue();
+	if (!timezone) return;
+
+	try {
+		const res = await fetch(`/set?timezone=${encodeURIComponent(timezone)}`, {
+			credentials: "include",
+		});
+		if (!res.ok) throw new Error();
+		statusMsg.textContent = "Timezone updated!";
+	} catch {
+		statusMsg.textContent = "Failed to update timezone.";
+	}
+});
+
+fetchUserInfo();
diff --git a/src/db/redis_helper.rs b/src/db/redis_helper.rs
index 6fb8049..1e7d19e 100644
--- a/src/db/redis_helper.rs
+++ b/src/db/redis_helper.rs
@@ -1,5 +1,5 @@
-use redis::Client;
 use redis::aio::MultiplexedConnection;
+use redis::Client;
 use std::env;
 
 pub async fn connect() -> MultiplexedConnection {
diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index c1bc5c1..1319790 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -2,20 +2,22 @@ use crate::db::AppState;
 
 use crate::types::JsonMessage;
 use axum::{
-    Json,
     extract::{Query, State},
     http::{HeaderMap, StatusCode},
     response::IntoResponse,
+    Json,
 };
 use headers::{Cookie, HeaderMapExt};
 use redis::AsyncCommands;
 use serde::{Deserialize, Serialize};
-use std::env;
+use sqlx::Row;
+use std::{collections::HashMap, env};
 use uuid::Uuid;
 
 #[derive(Deserialize)]
 pub struct CallbackQuery {
     code: String,
+    state: Option<String>,
 }
 
 #[derive(Deserialize, Serialize)]
@@ -77,14 +79,18 @@ pub async fn get_user_from_session(
     Ok(user)
 }
 
-pub async fn start_oauth(State(_): State<AppState>) -> impl IntoResponse {
+pub async fn start_oauth(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
     let client_id = env::var("CLIENT_ID").unwrap_or_default();
     let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
 
-    let url = format!(
-        "https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
-        client_id, redirect_uri
-    );
+    let mut url = format!(
+		"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
+		client_id, redirect_uri
+	);
+
+    if let Some(redirect) = params.get("redirect") {
+        url.push_str(&format!("&state={}", urlencoding::encode(redirect)));
+    }
 
     (StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response()
 }
@@ -177,6 +183,13 @@ pub async fn handle_callback(
         )
         .await;
 
+    let redirect_target = match &query.state {
+        Some(s) => urlencoding::decode(s)
+            .map(|s| s.into_owned())
+            .unwrap_or("/".to_string()),
+        None => "/".to_string(),
+    };
+
     let mut headers = HeaderMap::new();
     headers.insert(
         "Set-Cookie",
@@ -187,21 +200,51 @@ pub async fn handle_callback(
         .parse()
         .unwrap(),
     );
+    headers.insert(
+        axum::http::header::LOCATION,
+        redirect_target.parse().unwrap(),
+    );
 
-    (
-        StatusCode::OK,
-        headers,
-        Json(AuthResponse {
-            user,
-            session: session_id,
-        }),
-    )
-        .into_response()
+    (StatusCode::FOUND, headers).into_response()
 }
 
 pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
     match get_user_from_session(&headers, &state).await {
-        Ok(user) => (StatusCode::OK, Json(user)).into_response(),
+        Ok(user) => {
+            let result = sqlx::query("SELECT timezone FROM timezones WHERE user_id = $1")
+                .bind(&user.id)
+                .fetch_optional(&state.db)
+                .await;
+
+            match result {
+                Ok(Some(row)) => {
+                    let timezone: String = row.get("timezone");
+                    (
+                        StatusCode::OK,
+                        Json(serde_json::json!({
+                            "user": user,
+                            "timezone": timezone
+                        })),
+                    )
+                        .into_response()
+                }
+                Ok(None) => (
+                    StatusCode::OK,
+                    Json(serde_json::json!({
+                        "user": user,
+                        "timezone": null
+                    })),
+                )
+                    .into_response(),
+                Err(_) => (
+                    StatusCode::INTERNAL_SERVER_ERROR,
+                    Json(JsonMessage {
+                        message: "Failed to fetch timezone".into(),
+                    }),
+                )
+                    .into_response(),
+            }
+        }
         Err(err) => err.into_response(),
     }
 }
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index 8da7fbd..e90d36f 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -1,10 +1,12 @@
 use crate::db::AppState;
 use axum::{
     http::{HeaderValue, StatusCode},
-    response::Response,
+    response::{Html, Response},
     routing::{get, options},
     Router,
 };
+use std::fs;
+use tower_http::services::ServeDir;
 
 pub mod auth;
 mod timezone;
@@ -33,8 +35,16 @@ async fn preflight_handler() -> Response {
     res
 }
 
+async fn index_page() -> Html<String> {
+    Html(
+        fs::read_to_string("public/index.html")
+            .unwrap_or_else(|_| "<h1>404 Not Found</h1>".to_string()),
+    )
+}
+
 pub fn all() -> Router<AppState> {
     Router::new()
+        .route("/", get(index_page))
         .route("/get", get(timezone::get_timezone))
         .route("/set", get(timezone::set_timezone))
         .route("/set", options(preflight_handler))
@@ -43,4 +53,6 @@ pub fn all() -> Router<AppState> {
         .route("/auth/discord", get(auth::start_oauth))
         .route("/auth/discord/callback", get(auth::handle_callback))
         .route("/me", get(auth::me))
+        .nest_service("/public", ServeDir::new("public"))
+        .fallback(get(index_page))
 }
-- 
GitLab