commit
9ae6d88c21
24 changed files with 7589 additions and 0 deletions
43
.forgejo/workflows/pipeline.yaml
Normal file
43
.forgejo/workflows/pipeline.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: host
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
- uses: actions/checkout@v6
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
- name: Cache Cargo target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target/
|
||||
key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-target-
|
||||
- run: ~/.cargo/bin/cargo build -r
|
||||
- run: |
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
|
||||
systemctl --user stop calendar
|
||||
mkdir -p ~/calendar
|
||||
cp target/release/calendar ~/calendar/calendar
|
||||
chmod +x ~/calendar/calendar
|
||||
mkdir -p ~/calendar/static
|
||||
rm -rf ~/calendar/static/*
|
||||
cp -r static/* ~/calendar/static/
|
||||
systemctl --user start calendar
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
.env
|
||||
4703
Cargo.lock
generated
Normal file
4703
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "calendar"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
dotenvy = "0.15.7"
|
||||
sea-orm = { version = "1.1.19", features = ["runtime-tokio-rustls", "sqlx-postgres"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tower-http = { version = "0.6.8", features = ["full"] }
|
||||
uuid = { version = "1.12.1", features = ["v4"] }
|
||||
urlencoding = "2.1.3"
|
||||
async-trait = "0.1.86"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = "0.3.22"
|
||||
migration = { path = "migration" }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
openidconnect = { version = "4.0.0", features = ["reqwest"] }
|
||||
jsonwebtoken = "9.3.1"
|
||||
anyhow = "1.0.95"
|
||||
reqwest = { version = "0.12.12", features = ["json", "rustls-tls"] }
|
||||
serde_json = "1.0.138"
|
||||
11
migration/Cargo.toml
Normal file
11
migration/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
sea-orm-migration = { version = "1.1.19" }
|
||||
16
migration/src/lib.rs
Normal file
16
migration/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20220101_000001_create_event_table;
|
||||
mod m20250215_213300_create_user_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20220101_000001_create_event_table::Migration),
|
||||
Box::new(m20250215_213300_create_user_table::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
49
migration/src/m20220101_000001_create_event_table.rs
Normal file
49
migration/src/m20220101_000001_create_event_table.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Event {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
From,
|
||||
To,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20220101_000001_create_event_table"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Event::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Event::Id)
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Event::Name).string().not_null())
|
||||
.col(ColumnDef::new(Event::From).timestamp().not_null())
|
||||
.col(ColumnDef::new(Event::To).timestamp().not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Event::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
87
migration/src/m20250215_213300_create_user_table.rs
Normal file
87
migration/src/m20250215_213300_create_user_table.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(User::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(User::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(User::Sub).string().not_null().unique_key())
|
||||
.col(ColumnDef::new(User::Email).string().not_null())
|
||||
.col(ColumnDef::new(User::Name).string().not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Event::Table)
|
||||
.add_column(ColumnDef::new(Event::UserId).integer())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-event-user_id")
|
||||
.from(Event::Table, Event::UserId)
|
||||
.to(User::Table, User::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk-event-user_id")
|
||||
.table(Event::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Event::Table)
|
||||
.drop_column(Event::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum User {
|
||||
Table,
|
||||
Id,
|
||||
Sub,
|
||||
Email,
|
||||
Name,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Event {
|
||||
Table,
|
||||
UserId,
|
||||
}
|
||||
275
src/auth.rs
Normal file
275
src/auth.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use crate::config::Config;
|
||||
use crate::entities::user;
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{header, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, FromRef)]
|
||||
pub struct AppState {
|
||||
pub db: DatabaseConnection,
|
||||
pub oidc: OidcClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Jwk {
|
||||
pub kid: String,
|
||||
pub n: String,
|
||||
pub e: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct Jwks {
|
||||
keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JwksCache {
|
||||
keys: Arc<RwLock<Jwks>>,
|
||||
jwks_url: String,
|
||||
}
|
||||
|
||||
impl JwksCache {
|
||||
pub async fn new(jwks_url: String) -> Result<Self> {
|
||||
let cache = Self {
|
||||
keys: Arc::new(RwLock::new(Jwks { keys: vec![] })),
|
||||
jwks_url,
|
||||
};
|
||||
cache.refresh().await?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<()> {
|
||||
let jwks: Jwks = reqwest::get(&self.jwks_url)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to fetch JWKS")?;
|
||||
|
||||
let mut keys = self.keys.write().await;
|
||||
*keys = jwks;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_key(&self, kid: &str) -> Option<Jwk> {
|
||||
let keys = self.keys.read().await;
|
||||
keys.keys.iter().find(|k| k.kid == kid).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OidcClient {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub issuer: String,
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub redirect_uri: String,
|
||||
pub jwks_cache: JwksCache,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DiscoveryConfig {
|
||||
authorization_endpoint: String,
|
||||
token_endpoint: String,
|
||||
jwks_uri: String,
|
||||
}
|
||||
|
||||
impl OidcClient {
|
||||
pub async fn new(config: &Config) -> Result<Self> {
|
||||
let issuer = config.oidc_issuer_url.trim_end_matches('/');
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
|
||||
// Manual discovery using reqwest
|
||||
let discovery: DiscoveryConfig = reqwest::get(&discovery_url)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to fetch OIDC discovery document")?;
|
||||
|
||||
let jwks_cache = JwksCache::new(discovery.jwks_uri).await?;
|
||||
|
||||
Ok(Self {
|
||||
client_id: config.oidc_client_id.clone(),
|
||||
client_secret: config.oidc_client_secret.clone(),
|
||||
issuer: config.oidc_issuer_url.clone(),
|
||||
auth_url: discovery.authorization_endpoint,
|
||||
token_url: discovery.token_endpoint,
|
||||
redirect_uri: config.oidc_redirect_uri.clone(),
|
||||
jwks_cache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn auth_url(&self) -> (String, String, String) {
|
||||
let state = uuid::Uuid::new_v4().to_string();
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let url = format!(
|
||||
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid%20profile%20email%20offline_access&state={}&nonce={}",
|
||||
self.auth_url,
|
||||
urlencoding::encode(&self.client_id),
|
||||
urlencoding::encode(&self.redirect_uri),
|
||||
state,
|
||||
nonce
|
||||
);
|
||||
|
||||
(url, state, nonce)
|
||||
}
|
||||
|
||||
pub async fn exchange_code(&self, code: String) -> Result<TokenResponse> {
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(&self.token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &code),
|
||||
("client_id", &self.client_id),
|
||||
("client_secret", &self.client_secret),
|
||||
("redirect_uri", &self.redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
|
||||
let id_token = res["id_token"]
|
||||
.as_str()
|
||||
.context("No ID token in response")?
|
||||
.to_string();
|
||||
let refresh_token = res["refresh_token"].as_str().map(|s| s.to_string());
|
||||
|
||||
Ok(TokenResponse {
|
||||
id_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&self, refresh_token: String) -> Result<TokenResponse> {
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(&self.token_url)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", &refresh_token),
|
||||
("client_id", &self.client_id),
|
||||
("client_secret", &self.client_secret),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
|
||||
let id_token = res["id_token"]
|
||||
.as_str()
|
||||
.context("No ID token in refresh response")?
|
||||
.to_string();
|
||||
let refresh_token = res["refresh_token"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or(Some(refresh_token)); // Keep old one if new one not provided
|
||||
|
||||
Ok(TokenResponse {
|
||||
id_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn validate_token(&self, token: &str) -> Result<Claims> {
|
||||
let header = decode_header(token).context("Failed to decode token header")?;
|
||||
let kid = header.kid.context("No kid in token header")?;
|
||||
|
||||
let jwk = self
|
||||
.jwks_cache
|
||||
.get_key(&kid)
|
||||
.await
|
||||
.context("Key not found in JWKS")?;
|
||||
|
||||
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)
|
||||
.context("Failed to create decoding key")?;
|
||||
|
||||
let mut validation = Validation::new(header.alg);
|
||||
validation.validate_exp = true;
|
||||
validation.required_spec_claims.insert("sub".to_string());
|
||||
validation.validate_aud = false;
|
||||
|
||||
let token_data = decode::<Claims>(token, &decoding_key, &validation)
|
||||
.context("Failed to validate token")?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub id_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CurrentUser {
|
||||
pub id: i32,
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for CurrentUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let state = AppState::from_ref(state);
|
||||
let db = state.db;
|
||||
let oidc = state.oidc;
|
||||
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok());
|
||||
|
||||
if let Some(auth_header) = auth_header {
|
||||
if auth_header.starts_with("Bearer ") {
|
||||
let token = &auth_header[7..];
|
||||
if let Ok(claims) = oidc.validate_token(token).await {
|
||||
let user = user::Entity::find()
|
||||
.filter(user::Column::Sub.eq(&claims.sub))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
if let Some(user) = user {
|
||||
return Ok(CurrentUser {
|
||||
id: user.id,
|
||||
sub: user.sub,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response())
|
||||
}
|
||||
}
|
||||
39
src/config.rs
Normal file
39
src/config.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use dotenvy::dotenv;
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub port: u16,
|
||||
pub oidc_client_id: String,
|
||||
pub oidc_client_secret: String,
|
||||
pub oidc_issuer_url: String,
|
||||
pub oidc_redirect_uri: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenv().ok();
|
||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse::<u16>()
|
||||
.expect("PORT must be a valid u16");
|
||||
|
||||
let oidc_client_id = env::var("OIDC_CLIENT_ID").expect("OIDC_CLIENT_ID must be set");
|
||||
let oidc_client_secret =
|
||||
env::var("OIDC_CLIENT_SECRET").expect("OIDC_CLIENT_SECRET must be set");
|
||||
let oidc_issuer_url = env::var("OIDC_ISSUER_URL").expect("OIDC_ISSUER_URL must be set");
|
||||
let oidc_redirect_uri =
|
||||
env::var("OIDC_REDIRECT_URI").expect("OIDC_REDIRECT_URI must be set");
|
||||
|
||||
Config {
|
||||
database_url,
|
||||
port,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
oidc_issuer_url,
|
||||
oidc_redirect_uri,
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/db.rs
Normal file
7
src/db.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use sea_orm::{Database, DatabaseConnection, DbErr};
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn connect(config: &Config) -> Result<DatabaseConnection, DbErr> {
|
||||
let db = Database::connect(&config.database_url).await?;
|
||||
Ok(db)
|
||||
}
|
||||
31
src/entities/event.rs
Normal file
31
src/entities/event.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "event")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: Option<i32>,
|
||||
pub name: String,
|
||||
pub from: DateTime,
|
||||
pub to: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
2
src/entities/mod.rs
Normal file
2
src/entities/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod event;
|
||||
pub mod user;
|
||||
27
src/entities/user.rs
Normal file
27
src/entities/user.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::event::Entity")]
|
||||
Event,
|
||||
}
|
||||
|
||||
impl Related<super::event::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Event.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
119
src/handlers/auth.rs
Normal file
119
src/handlers/auth.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use crate::auth::AppState;
|
||||
use crate::entities::user;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthCallback {
|
||||
pub code: String,
|
||||
#[allow(dead_code)]
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<AppState>) -> Response {
|
||||
let oidc = state.oidc;
|
||||
let (auth_url, _csrf_token, _nonce) = oidc.auth_url();
|
||||
Redirect::to(&auth_url).into_response()
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<AuthCallback>,
|
||||
) -> Response {
|
||||
let oidc = state.oidc;
|
||||
let db = state.db;
|
||||
|
||||
match oidc.exchange_code(params.code).await {
|
||||
Ok(tokens) => match oidc.validate_token(&tokens.id_token).await {
|
||||
Ok(claims) => {
|
||||
let sub = claims.sub;
|
||||
let email = claims.email.unwrap_or_default();
|
||||
let name = claims.name.unwrap_or_default();
|
||||
|
||||
let existing_user = user::Entity::find()
|
||||
.filter(user::Column::Sub.eq(&sub))
|
||||
.one(&db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if existing_user.is_none() {
|
||||
let new_user = user::ActiveModel {
|
||||
sub: Set(sub),
|
||||
email: Set(email),
|
||||
name: Set(name),
|
||||
..Default::default()
|
||||
};
|
||||
new_user.insert(&db).await.unwrap();
|
||||
};
|
||||
|
||||
let mut redirect_url = format!("/#access_token={}", tokens.id_token);
|
||||
if let Some(refresh_token) = tokens.refresh_token {
|
||||
redirect_url.push_str(&format!("&refresh_token={}", refresh_token));
|
||||
}
|
||||
|
||||
Redirect::to(&redirect_url).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Token validation error: {:?}", e);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid token",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC callback error: {:?}", e);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Authentication failed",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RefreshRequest>,
|
||||
) -> Response {
|
||||
let oidc = state.oidc;
|
||||
|
||||
match oidc.refresh_token(payload.refresh_token).await {
|
||||
Ok(tokens) => Json(tokens).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Token refresh error: {:?}", e);
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "Refresh failed").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub async fn logout() -> Response {
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
crate::auth::CurrentUser {
|
||||
id,
|
||||
sub,
|
||||
email,
|
||||
name,
|
||||
}: crate::auth::CurrentUser,
|
||||
) -> Json<crate::auth::CurrentUser> {
|
||||
Json(crate::auth::CurrentUser {
|
||||
id,
|
||||
sub,
|
||||
email,
|
||||
name,
|
||||
})
|
||||
}
|
||||
148
src/handlers/event.rs
Normal file
148
src/handlers/event.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use crate::auth::{AppState, CurrentUser};
|
||||
use crate::entities::{event, event::Entity as Event};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use sea_orm::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct CreateEventRequest {
|
||||
pub name: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListEventsQuery {
|
||||
pub upcoming: Option<bool>,
|
||||
}
|
||||
|
||||
fn parse_datetime(dt_str: &str) -> Result<chrono::NaiveDateTime, StatusCode> {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") {
|
||||
return Ok(dt);
|
||||
}
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Ok(dt);
|
||||
}
|
||||
dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub async fn create_event(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { id, .. }: CurrentUser,
|
||||
Json(payload): Json<CreateEventRequest>,
|
||||
) -> Result<Json<event::Model>, StatusCode> {
|
||||
let db = state.db;
|
||||
let from = parse_datetime(&payload.from)?;
|
||||
let to = parse_datetime(&payload.to)?;
|
||||
|
||||
let new_event = event::ActiveModel {
|
||||
name: Set(payload.name),
|
||||
from: Set(from),
|
||||
to: Set(to),
|
||||
user_id: Set(Some(id)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = new_event.insert(&db).await.map_err(|e| {
|
||||
tracing::error!("Failed to create event: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!("Created event {} for user {}", result.id, id);
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { id, .. }: CurrentUser,
|
||||
Query(query): Query<ListEventsQuery>,
|
||||
) -> Result<Json<Vec<event::Model>>, StatusCode> {
|
||||
let db = state.db;
|
||||
let mut find = Event::find().filter(event::Column::UserId.eq(id));
|
||||
|
||||
if let Some(true) = query.upcoming {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
find = find.filter(event::Column::From.gte(now));
|
||||
}
|
||||
|
||||
let events = find
|
||||
.all(&db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(events))
|
||||
}
|
||||
|
||||
pub async fn get_event(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { id, .. }: CurrentUser,
|
||||
Path(event_id): Path<i32>,
|
||||
) -> Result<Json<event::Model>, StatusCode> {
|
||||
let db = state.db;
|
||||
let event = Event::find_by_id(event_id)
|
||||
.filter(event::Column::UserId.eq(id))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(event))
|
||||
}
|
||||
|
||||
pub async fn update_event(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { id, .. }: CurrentUser,
|
||||
Path(event_id): Path<i32>,
|
||||
Json(payload): Json<CreateEventRequest>,
|
||||
) -> Result<Json<event::Model>, StatusCode> {
|
||||
let db = state.db;
|
||||
let from = parse_datetime(&payload.from)?;
|
||||
let to = parse_datetime(&payload.to)?;
|
||||
|
||||
let event = Event::find_by_id(event_id)
|
||||
.filter(event::Column::UserId.eq(id))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut event: event::ActiveModel = event.into();
|
||||
event.name = Set(payload.name);
|
||||
event.from = Set(from);
|
||||
event.to = Set(to);
|
||||
|
||||
let result = event.update(&db).await.map_err(|e| {
|
||||
tracing::error!("Failed to update event {}: {:?}", event_id, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!("Updated event {} for user {}", event_id, id);
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn delete_event(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { id, .. }: CurrentUser,
|
||||
Path(event_id): Path<i32>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let db = state.db;
|
||||
let event = Event::find_by_id(event_id)
|
||||
.filter(event::Column::UserId.eq(id))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
event
|
||||
.delete(&db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
2
src/handlers/mod.rs
Normal file
2
src/handlers/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod auth;
|
||||
pub mod event;
|
||||
58
src/main.rs
Normal file
58
src/main.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod entities;
|
||||
mod handlers;
|
||||
mod routes;
|
||||
|
||||
use crate::auth::{AppState, OidcClient};
|
||||
use crate::config::Config;
|
||||
use crate::routes::create_router;
|
||||
use migration::MigratorTrait;
|
||||
use std::net::SocketAddr;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env();
|
||||
|
||||
// Connect to database
|
||||
let db = db::connect(&config)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
tracing::info!("Connected to database");
|
||||
|
||||
// Run migrations
|
||||
migration::Migrator::up(&db, None)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
tracing::info!("Migrations completed");
|
||||
|
||||
// Initialize OIDC client
|
||||
let oidc = OidcClient::new(&config)
|
||||
.await
|
||||
.expect("Failed to initialize OIDC client");
|
||||
|
||||
// Create AppState
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
oidc,
|
||||
};
|
||||
|
||||
// Create router
|
||||
let app = create_router(state)
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.fallback_service(tower_http::services::ServeDir::new("static"));
|
||||
|
||||
// Start server
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
|
||||
tracing::info!("listening on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
29
src/routes.rs
Normal file
29
src/routes.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use crate::auth::AppState;
|
||||
use crate::handlers::{auth, event};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
// Explicitly set the router state type to AppState
|
||||
Router::<AppState>::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/auth/login", get(auth::login))
|
||||
.route("/auth/callback", get(auth::callback))
|
||||
.route("/auth/logout", get(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/auth/refresh", post(auth::refresh))
|
||||
.route("/events", post(event::create_event).get(event::list_events))
|
||||
.route(
|
||||
"/events/{id}",
|
||||
get(event::get_event)
|
||||
.put(event::update_event)
|
||||
.delete(event::delete_event),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
463
static/app.js
Normal file
463
static/app.js
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
const eventList = document.getElementById('event-list');
|
||||
const eventModal = document.getElementById('event-modal');
|
||||
const eventForm = document.getElementById('event-form');
|
||||
const addEventBtn = document.getElementById('add-event-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const confirmModal = document.getElementById('confirm-modal');
|
||||
const confirmCancelBtn = document.getElementById('confirm-cancel-btn');
|
||||
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||
|
||||
let eventToDelete = null;
|
||||
|
||||
const API_URL = '/events';
|
||||
const AUTH_URL = '/auth';
|
||||
let currentUser = null;
|
||||
|
||||
// Helper for authenticated requests
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('jwt_token');
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
const refreshRes = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (refreshRes.ok) {
|
||||
const tokens = await refreshRes.json();
|
||||
localStorage.setItem('jwt_token', tokens.id_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
|
||||
// Retry original request
|
||||
return authenticatedFetch(url, options);
|
||||
}
|
||||
}
|
||||
// If refresh fails or no refresh token, logout
|
||||
logout();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Helper to format date in European format (DD.MM.YYYY)
|
||||
function formatDateEuropean(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Helpers for custom date inputs
|
||||
function getDateValues(idPrefix) {
|
||||
const d = document.getElementById(`${idPrefix}-day`).value.padStart(2, '0');
|
||||
const m = document.getElementById(`${idPrefix}-month`).value.padStart(2, '0');
|
||||
const y = document.getElementById(`${idPrefix}-year`).value;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function setDateValues(idPrefix, isoDate) {
|
||||
if (!isoDate) return;
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
document.getElementById(`${idPrefix}-day`).value = d;
|
||||
document.getElementById(`${idPrefix}-month`).value = m;
|
||||
document.getElementById(`${idPrefix}-year`).value = y;
|
||||
document.getElementById(`${idPrefix}-picker`).value = isoDate;
|
||||
}
|
||||
|
||||
function initDateInputs() {
|
||||
['event-from', 'event-to'].forEach(prefix => {
|
||||
const day = document.getElementById(`${prefix}-day`);
|
||||
const month = document.getElementById(`${prefix}-month`);
|
||||
const year = document.getElementById(`${prefix}-year`);
|
||||
const picker = document.getElementById(`${prefix}-picker`);
|
||||
|
||||
if (!day || !month || !year || !picker) return;
|
||||
|
||||
// Sync triple inputs to picker on change
|
||||
const syncToPicker = () => {
|
||||
const d = day.value.padStart(2, '0');
|
||||
const m = month.value.padStart(2, '0');
|
||||
const y = year.value;
|
||||
if (d && m && y.length === 4) {
|
||||
picker.value = `${y}-${m}-${d}`;
|
||||
}
|
||||
};
|
||||
|
||||
day.addEventListener('input', () => {
|
||||
if (day.value.length >= 2) month.focus();
|
||||
syncToPicker();
|
||||
});
|
||||
month.addEventListener('input', () => {
|
||||
if (month.value.length >= 2) year.focus();
|
||||
syncToPicker();
|
||||
});
|
||||
year.addEventListener('input', syncToPicker);
|
||||
|
||||
// Sync picker to triple inputs on change
|
||||
picker.addEventListener('change', () => {
|
||||
if (picker.value) {
|
||||
const [y, m, d] = picker.value.split('-');
|
||||
day.value = d;
|
||||
month.value = m;
|
||||
year.value = y;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all events
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}?upcoming=true`);
|
||||
if (response.ok) {
|
||||
const events = await response.json();
|
||||
renderEvents(events);
|
||||
} else {
|
||||
eventList.innerHTML = '<p class="text-muted">Please login to see your events.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Scrolling Time Spinner
|
||||
function initTimeSelectors() {
|
||||
const fromHours = document.getElementById('event-from-hours');
|
||||
const fromMinutes = document.getElementById('event-from-minutes');
|
||||
const toHours = document.getElementById('event-to-hours');
|
||||
const toMinutes = document.getElementById('event-to-minutes');
|
||||
|
||||
if (!fromHours || !fromMinutes || !toHours || !toMinutes) return;
|
||||
|
||||
populateSpinner(fromHours, 24);
|
||||
populateSpinner(toHours, 24);
|
||||
populateSpinner(fromMinutes, 60, 5);
|
||||
populateSpinner(toMinutes, 60, 5);
|
||||
|
||||
// Initial styles and scroll syncing
|
||||
[fromHours, fromMinutes, toHours, toMinutes].forEach(col => {
|
||||
col.addEventListener('scroll', () => updateSpinnerStyles(col));
|
||||
// Small delay to ensure initial scroll-snap works
|
||||
setTimeout(() => updateSpinnerStyles(col), 100);
|
||||
});
|
||||
}
|
||||
|
||||
function populateSpinner(container, total, step = 1) {
|
||||
container.innerHTML = '';
|
||||
// Add spacer items at top and bottom for 5-item view (2 spacers each)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spinner-item spacer';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
|
||||
for (let i = 0; i < total; i += step) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'spinner-item';
|
||||
const val = String(i).padStart(2, '0');
|
||||
item.textContent = val;
|
||||
item.dataset.value = val;
|
||||
item.onclick = () => {
|
||||
container.scrollTo({
|
||||
top: item.offsetTop - container.offsetTop - (container.clientHeight / 2 - item.clientHeight / 2),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spinner-item spacer';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpinnerStyles(container) {
|
||||
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
||||
const containerCenter = container.scrollTop + container.clientHeight / 2;
|
||||
|
||||
let closestItem = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
items.forEach(item => {
|
||||
const itemCenter = item.offsetTop - container.offsetTop + item.clientHeight / 2;
|
||||
const distance = Math.abs(containerCenter - itemCenter);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestItem = item;
|
||||
}
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
if (closestItem) {
|
||||
closestItem.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function getSpinnerTime(idPrefix) {
|
||||
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
||||
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
||||
|
||||
const hour = hourCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
||||
const minute = minCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
||||
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function setSpinnerTime(idPrefix, timeStr) {
|
||||
if (!timeStr) return;
|
||||
const [h, m] = timeStr.split(':');
|
||||
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
||||
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
||||
|
||||
scrollSpinnerToValue(hourCol, h);
|
||||
scrollSpinnerToValue(minCol, m.slice(0, 2));
|
||||
}
|
||||
|
||||
function scrollSpinnerToValue(container, value) {
|
||||
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
||||
const targetItem = Array.from(items).find(item => item.dataset.value === value);
|
||||
if (targetItem) {
|
||||
// We scroll so the target item is centered
|
||||
container.scrollTop = targetItem.offsetTop - container.offsetTop - (container.clientHeight / 2 - targetItem.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Render events in the grid
|
||||
function renderEvents(events) {
|
||||
eventList.innerHTML = '';
|
||||
events.forEach(event => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'event-card';
|
||||
card.innerHTML = `
|
||||
<h3>${event.name}</h3>
|
||||
<div class="event-time">
|
||||
<p>From: ${formatDateEuropean(event.from)}</p>
|
||||
<p>To: ${formatDateEuropean(event.to)}</p>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<button class="btn-secondary btn-sm" onclick="editEvent(${event.id})">Edit</button>
|
||||
<button class="btn-danger btn-sm" onclick="deleteEvent(${event.id})">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
eventList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Open modal
|
||||
function openModal(title = 'Add Event') {
|
||||
modalTitle.textContent = title;
|
||||
eventModal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
eventModal.style.display = 'none';
|
||||
eventForm.reset();
|
||||
document.getElementById('event-id').value = '';
|
||||
}
|
||||
|
||||
// Handle form submit
|
||||
eventForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('event-id').value;
|
||||
const fromDate = getDateValues('event-from');
|
||||
const fromTime = getSpinnerTime('event-from');
|
||||
const toDate = getDateValues('event-to');
|
||||
const toTime = getSpinnerTime('event-to');
|
||||
|
||||
const from = `${fromDate}T${fromTime}`;
|
||||
const to = `${toDate}T${toTime}`;
|
||||
|
||||
if (new Date(from) >= new Date(to)) {
|
||||
alert('Start time must be before end time');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('event-name').value,
|
||||
from: from,
|
||||
to: to
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (id) {
|
||||
// Update
|
||||
response = await authenticatedFetch(`${API_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
response = await authenticatedFetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
fetchEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Edit event
|
||||
async function editEvent(id) {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/${id}`);
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch event for edit');
|
||||
return;
|
||||
}
|
||||
const event = await response.json();
|
||||
|
||||
document.getElementById('event-id').value = event.id;
|
||||
document.getElementById('event-name').value = event.name;
|
||||
|
||||
// Split datetime strings for input date/time
|
||||
if (event.from) {
|
||||
const [fromDate, fromTime] = event.from.includes('T') ? event.from.split('T') : event.from.split(' ');
|
||||
setDateValues('event-from', fromDate);
|
||||
setSpinnerTime('event-from', fromTime);
|
||||
}
|
||||
if (event.to) {
|
||||
const [toDate, toTime] = event.to.includes('T') ? event.to.split('T') : event.to.split(' ');
|
||||
setDateValues('event-to', toDate);
|
||||
setSpinnerTime('event-to', toTime);
|
||||
}
|
||||
|
||||
openModal('Edit Event');
|
||||
} catch (error) {
|
||||
console.error('Error fetching event for edit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete event
|
||||
async function deleteEvent(id) {
|
||||
eventToDelete = id;
|
||||
confirmModal.style.display = 'flex';
|
||||
}
|
||||
|
||||
confirmCancelBtn.onclick = () => {
|
||||
confirmModal.style.display = 'none';
|
||||
eventToDelete = null;
|
||||
};
|
||||
|
||||
confirmDeleteBtn.onclick = async () => {
|
||||
if (!eventToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/${eventToDelete}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
confirmModal.style.display = 'none';
|
||||
eventToDelete = null;
|
||||
fetchEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
async function checkAuth() {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1));
|
||||
const accessToken = params.get('access_token') || params.get('token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken) {
|
||||
localStorage.setItem('jwt_token', accessToken);
|
||||
}
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
window.location.hash = '';
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('jwt_token');
|
||||
if (!token) {
|
||||
updateAuthUI();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`${AUTH_URL}/me`);
|
||||
if (response.ok) {
|
||||
currentUser = await response.json();
|
||||
} else {
|
||||
localStorage.removeItem('jwt_token');
|
||||
currentUser = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
}
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const authStatus = document.getElementById('auth-status');
|
||||
const loginGate = document.getElementById('login-gate');
|
||||
const appContent = document.getElementById('app-content');
|
||||
|
||||
if (currentUser) {
|
||||
authStatus.innerHTML = `
|
||||
<div class="user-info">
|
||||
<div class="user-details">
|
||||
<div class="user-name">${currentUser.name}</div>
|
||||
</div>
|
||||
<button onclick="logout()" class="btn-secondary btn-sm">Logout</button>
|
||||
</div>
|
||||
`;
|
||||
loginGate.style.display = 'none';
|
||||
appContent.style.display = 'block';
|
||||
fetchEvents();
|
||||
} else {
|
||||
loginGate.style.display = 'flex';
|
||||
appContent.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
window.location.href = `${AUTH_URL}/login`;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('jwt_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
addEventBtn.addEventListener('click', () => openModal());
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
initTimeSelectors();
|
||||
initDateInputs();
|
||||
});
|
||||
149
static/calendar.html
Normal file
149
static/calendar.html
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Event Calendar - Month View</title>
|
||||
<link rel="stylesheet" href="style.css?v=3">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Login Gate -->
|
||||
<div id="login-gate" class="calendar-container">
|
||||
<h1>Welcome to Event Calendar</h1>
|
||||
<p>Keep track of your schedule with ease. Sign in to start managing your events.</p>
|
||||
<button onclick="login()" class="btn-primary">Login with Authentik</button>
|
||||
</div>
|
||||
|
||||
<!-- App Content (Hidden until auth) -->
|
||||
<div id="app-content" class="calendar-container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>Calendar View</h1>
|
||||
</div>
|
||||
<nav class="view-switch">
|
||||
<a href="/">List View</a>
|
||||
<a href="/calendar.html" class="active">Calendar View</a>
|
||||
</nav>
|
||||
<div id="auth-status">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="calendar-controls">
|
||||
<div class="btn-group">
|
||||
<button id="prev-month" class="btn-secondary"><</button>
|
||||
<button id="next-month" class="btn-secondary">></button>
|
||||
</div>
|
||||
<h1 id="current-month-year">Month Year</h1>
|
||||
</div>
|
||||
<button id="add-event-btn" class="btn-primary">Add Event</button>
|
||||
</header>
|
||||
|
||||
<main class="calendar-view-container">
|
||||
<div class="month-calendar-grid" id="calendar-grid">
|
||||
<!-- Grid will be generated here -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal for adding/editing events -->
|
||||
<div id="event-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 id="modal-title">Add Event</h2>
|
||||
<form id="event-form">
|
||||
<input type="hidden" id="event-id">
|
||||
<div class="form-group">
|
||||
<label for="event-name">Name</label>
|
||||
<input type="text" id="event-name" required placeholder="Enter event name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From</label>
|
||||
<div class="datetime-input-group">
|
||||
<div class="date-input-container">
|
||||
<input type="number" id="event-from-day" placeholder="DD" min="1" max="31">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-from-month" placeholder="MM" min="1" max="12">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-from-year" placeholder="YYYY" min="2000" max="2100">
|
||||
<input type="date" id="event-from-picker" class="hidden-picker">
|
||||
<button type="button" class="calendar-btn"
|
||||
onclick="document.getElementById('event-from-picker').showPicker()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
|
||||
fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="time-spinner" id="event-from-spinner">
|
||||
<div class="spinner-highlight"></div>
|
||||
<div class="spinner-column hours" id="event-from-hours"></div>
|
||||
<div class="spinner-separator">:</div>
|
||||
<div class="spinner-column minutes" id="event-from-minutes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>To</label>
|
||||
<div class="datetime-input-group">
|
||||
<div class="date-input-container">
|
||||
<input type="number" id="event-to-day" placeholder="DD" min="1" max="31">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-to-month" placeholder="MM" min="1" max="12">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-to-year" placeholder="YYYY" min="2000" max="2100">
|
||||
<input type="date" id="event-to-picker" class="hidden-picker">
|
||||
<button type="button" class="calendar-btn"
|
||||
onclick="document.getElementById('event-to-picker').showPicker()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
|
||||
fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="time-spinner" id="event-to-spinner">
|
||||
<div class="spinner-highlight"></div>
|
||||
<div class="spinner-column hours" id="event-to-hours"></div>
|
||||
<div class="spinner-separator">:</div>
|
||||
<div class="spinner-column minutes" id="event-to-minutes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="delete-btn" class="btn-danger"
|
||||
style="display: none; margin-right: auto;">Delete</button>
|
||||
<button type="button" id="cancel-btn" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save Event</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="modal">
|
||||
<div class="modal-content confirm-modal-content">
|
||||
<h3>Are you sure?</h3>
|
||||
<p>Do you really want to delete this event? This action cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel-btn" class="btn-secondary">Cancel</button>
|
||||
<button id="confirm-delete-btn" class="btn-primary"
|
||||
style="background-color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="calendar.js?v=3"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
532
static/calendar.js
Normal file
532
static/calendar.js
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
const calendarGrid = document.getElementById('calendar-grid');
|
||||
const currentMonthYearHeader = document.getElementById('current-month-year');
|
||||
const prevMonthBtn = document.getElementById('prev-month');
|
||||
const nextMonthBtn = document.getElementById('next-month');
|
||||
|
||||
const eventModal = document.getElementById('event-modal');
|
||||
const eventForm = document.getElementById('event-form');
|
||||
const addEventBtn = document.getElementById('add-event-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const confirmModal = document.getElementById('confirm-modal');
|
||||
const confirmCancelBtn = document.getElementById('confirm-cancel-btn');
|
||||
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||
|
||||
let currentDate = new Date();
|
||||
let events = [];
|
||||
|
||||
const API_URL = '/events';
|
||||
const AUTH_URL = '/auth';
|
||||
let currentUser = null;
|
||||
|
||||
// Helper for authenticated requests
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('jwt_token');
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
const refreshRes = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (refreshRes.ok) {
|
||||
const tokens = await refreshRes.json();
|
||||
localStorage.setItem('jwt_token', tokens.id_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
|
||||
// Retry original request
|
||||
return authenticatedFetch(url, options);
|
||||
}
|
||||
}
|
||||
// If refresh fails or no refresh token, logout
|
||||
logout();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Helper to format date in European format (DD.MM.YYYY)
|
||||
function formatDateEuropean(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Helpers for custom date inputs
|
||||
function getDateValues(idPrefix) {
|
||||
const d = document.getElementById(`${idPrefix}-day`).value.padStart(2, '0');
|
||||
const m = document.getElementById(`${idPrefix}-month`).value.padStart(2, '0');
|
||||
const y = document.getElementById(`${idPrefix}-year`).value;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function setDateValues(idPrefix, isoDate) {
|
||||
if (!isoDate) return;
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
document.getElementById(`${idPrefix}-day`).value = d;
|
||||
document.getElementById(`${idPrefix}-month`).value = m;
|
||||
document.getElementById(`${idPrefix}-year`).value = y;
|
||||
document.getElementById(`${idPrefix}-picker`).value = isoDate;
|
||||
}
|
||||
|
||||
function initDateInputs() {
|
||||
['event-from', 'event-to'].forEach(prefix => {
|
||||
const day = document.getElementById(`${prefix}-day`);
|
||||
const month = document.getElementById(`${prefix}-month`);
|
||||
const year = document.getElementById(`${prefix}-year`);
|
||||
const picker = document.getElementById(`${prefix}-picker`);
|
||||
|
||||
if (!day || !month || !year || !picker) return;
|
||||
|
||||
// Sync triple inputs to picker on change
|
||||
const syncToPicker = () => {
|
||||
const d = day.value.padStart(2, '0');
|
||||
const m = month.value.padStart(2, '0');
|
||||
const y = year.value;
|
||||
if (d && m && y.length === 4) {
|
||||
picker.value = `${y}-${m}-${d}`;
|
||||
}
|
||||
};
|
||||
|
||||
day.addEventListener('input', () => {
|
||||
if (day.value.length >= 2) month.focus();
|
||||
syncToPicker();
|
||||
});
|
||||
month.addEventListener('input', () => {
|
||||
if (month.value.length >= 2) year.focus();
|
||||
syncToPicker();
|
||||
});
|
||||
year.addEventListener('input', syncToPicker);
|
||||
|
||||
// Sync picker to triple inputs on change
|
||||
picker.addEventListener('change', () => {
|
||||
if (picker.value) {
|
||||
const [y, m, d] = picker.value.split('-');
|
||||
day.value = d;
|
||||
month.value = m;
|
||||
year.value = y;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch events and render calendar
|
||||
async function initCalendar() {
|
||||
await fetchEvents();
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const response = await authenticatedFetch(API_URL);
|
||||
if (response.ok) {
|
||||
events = await response.json();
|
||||
} else {
|
||||
events = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Scrolling Time Spinner
|
||||
function initTimeSelectors() {
|
||||
const fromHours = document.getElementById('event-from-hours');
|
||||
const fromMinutes = document.getElementById('event-from-minutes');
|
||||
const toHours = document.getElementById('event-to-hours');
|
||||
const toMinutes = document.getElementById('event-to-minutes');
|
||||
|
||||
if (!fromHours || !fromMinutes || !toHours || !toMinutes) return;
|
||||
|
||||
populateSpinner(fromHours, 24);
|
||||
populateSpinner(toHours, 24);
|
||||
populateSpinner(fromMinutes, 60, 5);
|
||||
populateSpinner(toMinutes, 60, 5);
|
||||
|
||||
// Initial styles and scroll syncing
|
||||
[fromHours, fromMinutes, toHours, toMinutes].forEach(col => {
|
||||
col.addEventListener('scroll', () => updateSpinnerStyles(col));
|
||||
// Small delay to ensure initial scroll-snap works
|
||||
setTimeout(() => updateSpinnerStyles(col), 100);
|
||||
});
|
||||
}
|
||||
|
||||
function populateSpinner(container, total, step = 1) {
|
||||
container.innerHTML = '';
|
||||
// Add spacer items at top and bottom for 5-item view (2 spacers each)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spinner-item spacer';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
|
||||
for (let i = 0; i < total; i += step) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'spinner-item';
|
||||
const val = String(i).padStart(2, '0');
|
||||
item.textContent = val;
|
||||
item.dataset.value = val;
|
||||
item.onclick = () => {
|
||||
container.scrollTo({
|
||||
top: item.offsetTop - container.offsetTop - (container.clientHeight / 2 - item.clientHeight / 2),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spinner-item spacer';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpinnerStyles(container) {
|
||||
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
||||
const containerCenter = container.scrollTop + container.clientHeight / 2;
|
||||
|
||||
let closestItem = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
items.forEach(item => {
|
||||
const itemCenter = item.offsetTop - container.offsetTop + item.clientHeight / 2;
|
||||
const distance = Math.abs(containerCenter - itemCenter);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestItem = item;
|
||||
}
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
if (closestItem) {
|
||||
closestItem.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function getSpinnerTime(idPrefix) {
|
||||
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
||||
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
||||
|
||||
const hour = hourCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
||||
const minute = minCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
||||
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function setSpinnerTime(idPrefix, timeStr) {
|
||||
if (!timeStr) return;
|
||||
const [h, m] = timeStr.split(':');
|
||||
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
||||
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
||||
|
||||
scrollSpinnerToValue(hourCol, h);
|
||||
scrollSpinnerToValue(minCol, m.slice(0, 2));
|
||||
}
|
||||
|
||||
function scrollSpinnerToValue(container, value) {
|
||||
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
||||
const targetItem = Array.from(items).find(item => item.dataset.value === value);
|
||||
if (targetItem) {
|
||||
// We scroll so the target item is centered
|
||||
container.scrollTop = targetItem.offsetTop - container.offsetTop - (container.clientHeight / 2 - targetItem.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
calendarGrid.innerHTML = '';
|
||||
|
||||
// Day names header
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
days.forEach(day => {
|
||||
const dayHeader = document.createElement('div');
|
||||
dayHeader.className = 'calendar-day-header';
|
||||
dayHeader.textContent = day;
|
||||
calendarGrid.appendChild(dayHeader);
|
||||
});
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
currentMonthYearHeader.textContent = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' }).format(currentDate);
|
||||
|
||||
// Get first day of month and adjust for Monday start (0=Sun, 1=Mon, ..., 6=Sat)
|
||||
// We want Mon=0, Tue=1, ..., Sun=6
|
||||
let firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
let startingDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
|
||||
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
// Previous month days to fill the gap
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = startingDay - 1; i >= 0; i--) {
|
||||
const daySquare = document.createElement('div');
|
||||
daySquare.className = 'calendar-day empty';
|
||||
daySquare.textContent = prevMonthLastDay - i;
|
||||
calendarGrid.appendChild(daySquare);
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const daySquare = document.createElement('div');
|
||||
daySquare.className = 'calendar-day';
|
||||
daySquare.innerHTML = `<span class="day-number">${i}</span>`;
|
||||
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
||||
const dayEvents = events.filter(e => e.from.startsWith(dateStr));
|
||||
|
||||
dayEvents.forEach(event => {
|
||||
const eventEl = document.createElement('div');
|
||||
eventEl.className = 'calendar-event';
|
||||
const startTime = event.from.split('T')[1].substring(0, 5);
|
||||
eventEl.textContent = `${startTime} - ${event.name}`;
|
||||
eventEl.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
editEvent(event.id);
|
||||
};
|
||||
daySquare.appendChild(eventEl);
|
||||
});
|
||||
|
||||
daySquare.onclick = () => {
|
||||
openModal('Add Event', `${dateStr}T12:00`, `${dateStr}T13:00`);
|
||||
};
|
||||
|
||||
calendarGrid.appendChild(daySquare);
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(title = 'Add Event', from = null, to = null, id = null) {
|
||||
modalTitle.textContent = title;
|
||||
eventModal.style.display = 'flex';
|
||||
|
||||
if (from && to) {
|
||||
const [fromDate, fromTime] = from.includes('T') ? from.split('T') : from.split(' ');
|
||||
const [toDate, toTime] = to.includes('T') ? to.split('T') : to.split(' ');
|
||||
|
||||
setDateValues('event-from', fromDate);
|
||||
setDateValues('event-to', toDate);
|
||||
|
||||
// Round time to nearest 5 minutes for spinner
|
||||
const roundTime = (time) => {
|
||||
const [h, m] = time.split(':');
|
||||
const roundedM = Math.round(parseInt(m) / 5) * 5;
|
||||
if (roundedM === 60) return `${String(parseInt(h) + 1).padStart(2, '0')}:00`;
|
||||
return `${h}:${String(roundedM).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
setSpinnerTime('event-from', roundTime(fromTime));
|
||||
setSpinnerTime('event-to', roundTime(toTime));
|
||||
}
|
||||
|
||||
if (id) {
|
||||
document.getElementById('event-id').value = id;
|
||||
deleteBtn.style.display = 'block';
|
||||
} else {
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
eventModal.style.display = 'none';
|
||||
eventForm.reset();
|
||||
document.getElementById('event-id').value = '';
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1));
|
||||
const accessToken = params.get('access_token') || params.get('token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken) {
|
||||
localStorage.setItem('jwt_token', accessToken);
|
||||
}
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
window.location.hash = '';
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('jwt_token');
|
||||
if (!token) {
|
||||
updateAuthUI();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`${AUTH_URL}/me`);
|
||||
if (response.ok) {
|
||||
currentUser = await response.json();
|
||||
} else {
|
||||
localStorage.removeItem('jwt_token');
|
||||
currentUser = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
}
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const authStatus = document.getElementById('auth-status');
|
||||
const loginGate = document.getElementById('login-gate');
|
||||
const appContent = document.getElementById('app-content');
|
||||
|
||||
if (currentUser) {
|
||||
authStatus.innerHTML = `
|
||||
<div class="user-info">
|
||||
<div class="user-details">
|
||||
<div class="user-name">${currentUser.name}</div>
|
||||
</div>
|
||||
<button onclick="logout()" class="btn-secondary btn-sm">Logout</button>
|
||||
</div>
|
||||
`;
|
||||
loginGate.style.display = 'none';
|
||||
appContent.style.display = 'block';
|
||||
initCalendar();
|
||||
} else {
|
||||
loginGate.style.display = 'flex';
|
||||
appContent.style.display = 'none';
|
||||
events = [];
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
window.location.href = `${AUTH_URL}/login`;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('jwt_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
initTimeSelectors();
|
||||
initDateInputs();
|
||||
});
|
||||
|
||||
eventForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('event-id').value;
|
||||
const fromDate = getDateValues('event-from');
|
||||
const fromTime = getSpinnerTime('event-from');
|
||||
const toDate = getDateValues('event-to');
|
||||
const toTime = getSpinnerTime('event-to');
|
||||
|
||||
const from = `${fromDate}T${fromTime}`;
|
||||
const to = `${toDate}T${toTime}`;
|
||||
|
||||
if (new Date(from) >= new Date(to)) {
|
||||
alert('Start time must be before end time');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('event-name').value,
|
||||
from: from,
|
||||
to: to
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (id) {
|
||||
response = await authenticatedFetch(`${API_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await authenticatedFetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
initCalendar();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
async function editEvent(id) {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/${id}`);
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch event for edit');
|
||||
return;
|
||||
}
|
||||
const event = await response.json();
|
||||
|
||||
document.getElementById('event-id').value = event.id;
|
||||
document.getElementById('event-name').value = event.name;
|
||||
|
||||
openModal('Edit Event', event.from, event.to, id);
|
||||
} catch (error) {
|
||||
console.error('Error fetching event for edit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
prevMonthBtn.onclick = () => {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
renderCalendar();
|
||||
};
|
||||
|
||||
nextMonthBtn.onclick = () => {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
renderCalendar();
|
||||
};
|
||||
|
||||
addEventBtn.addEventListener('click', () => openModal());
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
|
||||
deleteBtn.onclick = () => {
|
||||
confirmModal.style.display = 'flex';
|
||||
};
|
||||
|
||||
confirmCancelBtn.onclick = () => {
|
||||
confirmModal.style.display = 'none';
|
||||
};
|
||||
|
||||
confirmDeleteBtn.onclick = async () => {
|
||||
const id = document.getElementById('event-id').value;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
confirmModal.style.display = 'none';
|
||||
closeModal();
|
||||
initCalendar();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
}
|
||||
};
|
||||
139
static/index.html
Normal file
139
static/index.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Event Calendar</title>
|
||||
<link rel="stylesheet" href="style.css?v=3">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Login Gate -->
|
||||
<div id="login-gate" class="container">
|
||||
<h1>Welcome to Event Calendar</h1>
|
||||
<p>Keep track of your schedule with ease. Sign in to start managing your events.</p>
|
||||
<button onclick="login()" class="btn-primary">Login with Authentik</button>
|
||||
</div>
|
||||
|
||||
<!-- App Content (Hidden until auth) -->
|
||||
<div id="app-content" class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>Calendar Events</h1>
|
||||
</div>
|
||||
<nav class="view-switch">
|
||||
<a href="/" class="active">List View</a>
|
||||
<a href="/calendar.html">Calendar View</a>
|
||||
</nav>
|
||||
<div id="auth-status">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class="page-header">
|
||||
<h1>My Events</h1>
|
||||
<button id="add-event-btn" class="btn-primary">Add Event</button>
|
||||
</header>
|
||||
|
||||
<main id="event-list" class="event-grid">
|
||||
<!-- Events will be loaded here -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal for adding/editing events -->
|
||||
<div id="event-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 id="modal-title">Add Event</h2>
|
||||
<form id="event-form">
|
||||
<input type="hidden" id="event-id">
|
||||
<div class="form-group">
|
||||
<label for="event-name">Name</label>
|
||||
<input type="text" id="event-name" required placeholder="Enter event name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From</label>
|
||||
<div class="datetime-input-group">
|
||||
<div class="date-input-container">
|
||||
<input type="number" id="event-from-day" placeholder="DD" min="1" max="31">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-from-month" placeholder="MM" min="1" max="12">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-from-year" placeholder="YYYY" min="2000" max="2100">
|
||||
<input type="date" id="event-from-picker" class="hidden-picker">
|
||||
<button type="button" class="calendar-btn"
|
||||
onclick="document.getElementById('event-from-picker').showPicker()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
|
||||
fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="time-spinner" id="event-from-spinner">
|
||||
<div class="spinner-highlight"></div>
|
||||
<div class="spinner-column hours" id="event-from-hours"></div>
|
||||
<div class="spinner-separator">:</div>
|
||||
<div class="spinner-column minutes" id="event-from-minutes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>To</label>
|
||||
<div class="datetime-input-group">
|
||||
<div class="date-input-container">
|
||||
<input type="number" id="event-to-day" placeholder="DD" min="1" max="31">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-to-month" placeholder="MM" min="1" max="12">
|
||||
<span>.</span>
|
||||
<input type="number" id="event-to-year" placeholder="YYYY" min="2000" max="2100">
|
||||
<input type="date" id="event-to-picker" class="hidden-picker">
|
||||
<button type="button" class="calendar-btn"
|
||||
onclick="document.getElementById('event-to-picker').showPicker()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
|
||||
fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="time-spinner" id="event-to-spinner">
|
||||
<div class="spinner-highlight"></div>
|
||||
<div class="spinner-column hours" id="event-to-hours"></div>
|
||||
<div class="spinner-separator">:</div>
|
||||
<div class="spinner-column minutes" id="event-to-minutes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="cancel-btn" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save Event</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="modal">
|
||||
<div class="modal-content confirm-modal-content">
|
||||
<h3>Are you sure?</h3>
|
||||
<p>Do you really want to delete this event? This action cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel-btn" class="btn-secondary">Cancel</button>
|
||||
<button id="confirm-delete-btn" class="btn-primary"
|
||||
style="background-color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=3"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
634
static/style.css
Normal file
634
static/style.css
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
:root {
|
||||
--bg-color: #0f172a;
|
||||
--card-bg: rgba(30, 41, 59, 0.7);
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background-image: radial-gradient(circle at top left, #1e293b 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
.container,
|
||||
.calendar-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.view-switch a {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-switch a.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#auth-status {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.event-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
transform: scale(1.02);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.event-card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.calendar-event:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid var(--border);
|
||||
padding: 2.5rem;
|
||||
border-radius: 1rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-modal-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content p {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.datetime-input-group input[type="time"],
|
||||
.datetime-input-group select {
|
||||
flex: 1;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-main);
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.datetime-input-group select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='rgba(148, 163, 184, 0.5)'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.datetime-input-group select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
background-color: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
/* Time Spinner Styles */
|
||||
.time-spinner-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.time-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 150px;
|
||||
/* 30px * 5 items */
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner-column {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
scrollbar-width: none;
|
||||
/* Hide scrollbar for Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Hide scrollbar for IE/Edge */
|
||||
}
|
||||
|
||||
.spinner-column::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* Hide scrollbar for Chrome/Safari */
|
||||
}
|
||||
|
||||
.spinner-item {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
scroll-snap-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s, font-size 0.2s, font-weight 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spinner-item.active {
|
||||
color: var(--text-main);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.spinner-separator {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.spinner-highlight {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-top: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.3);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Login Gate */
|
||||
#login-gate {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
text-align: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
#login-gate h1 {
|
||||
font-size: 3.5rem;
|
||||
background: linear-gradient(to bottom right, #fff, #94a3b8);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#login-gate p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 500px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Month Calendar Styles */
|
||||
.calendar-view-container {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.calendar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.month-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
background-color: rgba(30, 41, 59, 0.9);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
background-color: var(--bg-color);
|
||||
min-height: 120px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.calendar-day:hover:not(.other-month) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.calendar-day.other-month {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.calendar-day.empty {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Custom Date Input Styling */
|
||||
.date-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-input-container input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-input-container input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#event-from-day,
|
||||
#event-from-month,
|
||||
#event-to-day,
|
||||
#event-to-month {
|
||||
width: 2ch;
|
||||
}
|
||||
|
||||
#event-from-year,
|
||||
#event-to-year {
|
||||
width: 4ch;
|
||||
}
|
||||
|
||||
.date-input-container span {
|
||||
color: var(--text-muted);
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.date-input-container input::-webkit-outer-spin-button,
|
||||
.date-input-container input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.date-input-container input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.date-input-container:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.calendar-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.day-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.calendar-event-tag {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-event-tag:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
color: var(--text-main);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
nav a:hover:not(.active) {
|
||||
color: var(--text-main);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue