add simple index page for user use

This commit is contained in:
creations 2025-06-01 07:56:02 -04:00
parent f9a4a30e61
commit 0a0a1b2a50
Signed by: creations
GPG key ID: 8F553AA4320FC711
8 changed files with 412 additions and 20 deletions

38
Cargo.lock generated
View file

@ -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"

View file

@ -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"

170
public/css/style.css Normal file
View file

@ -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;
}

41
public/index.html Normal file
View file

@ -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>

87
public/js/index.js Normal file
View file

@ -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();

View file

@ -1,5 +1,5 @@
use redis::Client;
use redis::aio::MultiplexedConnection;
use redis::Client;
use std::env;
pub async fn connect() -> MultiplexedConnection {

View file

@ -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(),
}
}

View file

@ -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))
}