Compare commits
10 Commits
79f287ed61
...
bugfix/wee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
970b0a07da | ||
|
|
e2e5813b54 | ||
|
|
73567c185c | ||
| 0587762bbb | |||
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
| 4d2aad404b | |||
|
|
0453763c98 | ||
|
|
03c0011445 |
@@ -38,4 +38,4 @@ calendar.db
|
||||
**/tests/
|
||||
|
||||
# Migrations (not needed for builds)
|
||||
migrations/
|
||||
migrations/
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,3 +22,9 @@ dist/
|
||||
CLAUDE.md
|
||||
|
||||
data/
|
||||
|
||||
# SQLite database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
calendar.db
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -47,6 +47,9 @@ FROM rust:alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
|
||||
# Install sqlx-cli for migrations
|
||||
RUN cargo install sqlx-cli --no-default-features --features sqlite
|
||||
|
||||
# Copy shared models
|
||||
COPY calendar-models ./calendar-models
|
||||
|
||||
@@ -76,19 +79,29 @@ RUN cargo build --release --bin backend
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||
|
||||
# Copy frontend files to temporary location
|
||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||
|
||||
# Copy backend binary (built in workspace root)
|
||||
# Copy backend binary and sqlx-cli
|
||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||
|
||||
# Create startup script to copy frontend files to shared volume
|
||||
RUN mkdir -p /srv/www
|
||||
# Copy migrations for database setup
|
||||
COPY --from=backend-builder /app/backend/migrations /migrations
|
||||
|
||||
# Create startup script to copy frontend files, run migrations, and start backend
|
||||
RUN mkdir -p /srv/www /db
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
|
||||
48
README.md
48
README.md
@@ -29,6 +29,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
||||
- **Timezone Aware**: Proper local time display with UTC storage
|
||||
|
||||
### User Experience
|
||||
- **Persistent Preferences**: Settings sync across devices and sessions
|
||||
- **Remember Me**: Optional server/username remembering for convenience
|
||||
- **Session Management**: Secure session tokens with automatic expiry
|
||||
- **Cross-Device Sync**: User preferences stored in database, not just browser
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend (Yew WebAssembly)
|
||||
@@ -40,7 +46,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
### Backend (Axum)
|
||||
- **Framework**: Axum async web framework with CORS support
|
||||
- **Authentication**: JWT token management and validation
|
||||
- **Authentication**: SQLite-backed session management with JWT tokens
|
||||
- **Database**: SQLite for user preferences and session storage
|
||||
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
||||
- **API Design**: RESTful endpoints following calendar operation patterns
|
||||
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
||||
@@ -54,12 +61,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
The easiest way to run the calendar is using Docker Compose:
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd calendar
|
||||
```
|
||||
|
||||
2. **Start the application**:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
3. **Access the application** at `http://localhost`
|
||||
|
||||
The Docker setup includes:
|
||||
- **Automatic database migrations** on startup
|
||||
- **Persistent data storage** in `./data/db/` volume
|
||||
- **Frontend served via Caddy** on port 80
|
||||
- **Backend API** accessible on port 3000
|
||||
|
||||
### Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
- Rust (latest stable version)
|
||||
- Trunk (`cargo install trunk`)
|
||||
|
||||
### Development Setup
|
||||
#### Local Development
|
||||
|
||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||
```bash
|
||||
@@ -73,6 +104,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
3. **Access the application** at `http://localhost:8080`
|
||||
|
||||
#### Database Setup
|
||||
|
||||
For local development, run the database migrations:
|
||||
```bash
|
||||
# Install sqlx-cli if not already installed
|
||||
cargo install sqlx-cli --features sqlite
|
||||
|
||||
# Run migrations
|
||||
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
|
||||
@@ -34,6 +34,10 @@ base64 = "0.21"
|
||||
thiserror = "1.0"
|
||||
lazy_static = "1.4"
|
||||
|
||||
# Database dependencies
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||
tokio-rusqlite = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
8
backend/migrations/001_create_users_table.sql
Normal file
8
backend/migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
server_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(username, server_url)
|
||||
);
|
||||
16
backend/migrations/002_create_sessions_table.sql
Normal file
16
backend/migrations/002_create_sessions_table.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_accessed TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for faster token lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
|
||||
-- Index for cleanup of expired sessions
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Create user preferences table
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
calendar_selected_date TEXT,
|
||||
calendar_time_increment INTEGER,
|
||||
calendar_view_mode TEXT,
|
||||
calendar_theme TEXT,
|
||||
calendar_colors TEXT, -- JSON string for calendar color mappings
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
2
backend/migrations/004_add_style_preference.sql
Normal file
2
backend/migrations/004_add_style_preference.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add calendar style preference to user preferences
|
||||
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';
|
||||
@@ -1,10 +1,12 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest};
|
||||
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
@@ -17,11 +19,12 @@ pub struct Claims {
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
jwt_secret: String,
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(jwt_secret: String) -> Self {
|
||||
Self { jwt_secret }
|
||||
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||
Self { jwt_secret, db }
|
||||
}
|
||||
|
||||
/// Authenticate user directly against CalDAV server
|
||||
@@ -49,13 +52,48 @@ impl AuthService {
|
||||
"✅ Authentication successful! Found {} calendars",
|
||||
calendars.len()
|
||||
);
|
||||
// Authentication successful, generate JWT token
|
||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
|
||||
// Find or create user in database
|
||||
let user_repo = UserRepository::new(&self.db);
|
||||
let user = user_repo
|
||||
.find_or_create(&request.username, &request.server_url)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||
|
||||
// Generate JWT token
|
||||
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
// Generate session token
|
||||
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||
|
||||
// Create session in database
|
||||
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
session_repo
|
||||
.create(&session)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||
|
||||
// Get or create user preferences
|
||||
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||
let preferences = prefs_repo
|
||||
.get_or_create(&user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
Ok(AuthResponse {
|
||||
token,
|
||||
token: jwt_token,
|
||||
session_token,
|
||||
username: request.username,
|
||||
server_url: request.server_url,
|
||||
preferences: UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_style: preferences.calendar_style,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
},
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -143,4 +181,33 @@ impl AuthService {
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Validate session token
|
||||
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
|
||||
let session = session_repo
|
||||
.find_by_token(session_token)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
|
||||
|
||||
if session.is_expired() {
|
||||
return Err(ApiError::Unauthorized("Session expired".to_string()));
|
||||
}
|
||||
|
||||
Ok(session.user_id)
|
||||
}
|
||||
|
||||
/// Logout user by deleting session
|
||||
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
|
||||
session_repo
|
||||
.delete(session_token)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
309
backend/src/db.rs
Normal file
309
backend/src/db.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||
use sqlx::{FromRow, Result};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Database connection pool wrapper
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Create a new database connection pool
|
||||
pub async fn new(database_url: &str) -> Result<Self> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(Self {
|
||||
pool: Arc::new(pool),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to the connection pool
|
||||
pub fn pool(&self) -> &SqlitePool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
/// User model representing a CalDAV user
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: String, // UUID as string for SQLite
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with generated UUID
|
||||
pub fn new(username: String, server_url: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username,
|
||||
server_url,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session model for user sessions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Session {
|
||||
pub id: String, // UUID as string
|
||||
pub user_id: String, // Foreign key to User
|
||||
pub token: String, // Session token
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session for a user
|
||||
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id,
|
||||
token,
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(expires_in_hours),
|
||||
last_accessed: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the session has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// User preferences model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct UserPreferences {
|
||||
pub user_id: String,
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_style: Option<String>,
|
||||
pub calendar_colors: Option<String>, // JSON string
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserPreferences {
|
||||
/// Create default preferences for a new user
|
||||
pub fn default_for_user(user_id: String) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
calendar_selected_date: None,
|
||||
calendar_time_increment: Some(15),
|
||||
calendar_view_mode: Some("month".to_string()),
|
||||
calendar_theme: Some("light".to_string()),
|
||||
calendar_style: Some("default".to_string()),
|
||||
calendar_colors: None,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for User operations
|
||||
pub struct UserRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> UserRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Find or create a user by username and server URL
|
||||
pub async fn find_or_create(
|
||||
&self,
|
||||
username: &str,
|
||||
server_url: &str,
|
||||
) -> Result<User> {
|
||||
// Try to find existing user
|
||||
let existing = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE username = ? AND server_url = ?",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(server_url)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(user) = existing {
|
||||
Ok(user)
|
||||
} else {
|
||||
// Create new user
|
||||
let user = User::new(username.to_string(), server_url.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.server_url)
|
||||
.bind(&user.created_at)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a user by ID
|
||||
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for Session operations
|
||||
pub struct SessionRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> SessionRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Create a new session
|
||||
pub async fn create(&self, session: &Session) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&session.id)
|
||||
.bind(&session.user_id)
|
||||
.bind(&session.token)
|
||||
.bind(&session.created_at)
|
||||
.bind(&session.expires_at)
|
||||
.bind(&session.last_accessed)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a session by token and update last_accessed
|
||||
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
|
||||
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(ref s) = session {
|
||||
if !s.is_expired() {
|
||||
// Update last_accessed time
|
||||
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
|
||||
.bind(Utc::now())
|
||||
.bind(&s.id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Delete a session (logout)
|
||||
pub async fn delete(&self, token: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up expired sessions
|
||||
pub async fn cleanup_expired(&self) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||
.bind(Utc::now())
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for UserPreferences operations
|
||||
pub struct PreferencesRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> PreferencesRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Get user preferences, creating defaults if not exist
|
||||
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
|
||||
let existing = sqlx::query_as::<_, UserPreferences>(
|
||||
"SELECT * FROM user_preferences WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(prefs) = existing {
|
||||
Ok(prefs)
|
||||
} else {
|
||||
// Create default preferences
|
||||
let prefs = UserPreferences::default_for_user(user_id.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_preferences
|
||||
(user_id, calendar_selected_date, calendar_time_increment,
|
||||
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&prefs.user_id)
|
||||
.bind(&prefs.calendar_selected_date)
|
||||
.bind(&prefs.calendar_time_increment)
|
||||
.bind(&prefs.calendar_view_mode)
|
||||
.bind(&prefs.calendar_theme)
|
||||
.bind(&prefs.calendar_style)
|
||||
.bind(&prefs.calendar_colors)
|
||||
.bind(&prefs.updated_at)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE user_preferences
|
||||
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
||||
calendar_colors = ?, updated_at = ?
|
||||
WHERE user_id = ?",
|
||||
)
|
||||
.bind(&prefs.calendar_selected_date)
|
||||
.bind(&prefs.calendar_time_increment)
|
||||
.bind(&prefs.calendar_view_mode)
|
||||
.bind(&prefs.calendar_theme)
|
||||
.bind(&prefs.calendar_style)
|
||||
.bind(&prefs.calendar_colors)
|
||||
.bind(Utc::now())
|
||||
.bind(&prefs.user_id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
mod auth;
|
||||
mod calendar;
|
||||
mod events;
|
||||
mod preferences;
|
||||
mod series;
|
||||
|
||||
pub use auth::{get_user_info, login, verify_token};
|
||||
pub use calendar::{create_calendar, delete_calendar};
|
||||
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||
pub use preferences::{get_preferences, logout, update_preferences};
|
||||
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||
|
||||
@@ -2,7 +2,6 @@ use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::{
|
||||
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||
AppState,
|
||||
@@ -46,41 +45,12 @@ pub async fn login(
|
||||
println!(" Username: {}", request.username);
|
||||
println!(" Password length: {}", request.password.len());
|
||||
|
||||
// Basic validation
|
||||
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Username, password, and server URL are required".to_string(),
|
||||
));
|
||||
}
|
||||
// Use the auth service login method which now handles database, sessions, and preferences
|
||||
let response = state.auth_service.login(request).await?;
|
||||
|
||||
println!("✅ Input validation passed");
|
||||
println!("✅ Login successful with session management");
|
||||
|
||||
// Create a token using the auth service
|
||||
println!("📝 Created CalDAV config");
|
||||
|
||||
// First verify the credentials are valid by attempting to discover calendars
|
||||
let config = CalDAVConfig::new(
|
||||
request.server_url.clone(),
|
||||
request.username.clone(),
|
||||
request.password.clone(),
|
||||
);
|
||||
let client = CalDAVClient::new(config);
|
||||
client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
||||
|
||||
let token = state
|
||||
.auth_service
|
||||
.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
token,
|
||||
username: request.username,
|
||||
server_url: request.server_url,
|
||||
}))
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn verify_token(
|
||||
|
||||
128
backend/src/handlers/preferences.rs
Normal file
128
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::PreferencesRepository,
|
||||
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
|
||||
AppState,
|
||||
};
|
||||
|
||||
/// Get user preferences
|
||||
pub async fn get_preferences(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Validate session and get user ID
|
||||
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||
|
||||
// Get preferences from database
|
||||
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||
let preferences = prefs_repo
|
||||
.get_or_create(&user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
Ok(Json(UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_style: preferences.calendar_style,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
pub async fn update_preferences(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdatePreferencesRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Validate session and get user ID
|
||||
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||
|
||||
// Update preferences in database
|
||||
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||
|
||||
let mut preferences = prefs_repo
|
||||
.get_or_create(&user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
// Update only provided fields
|
||||
if request.calendar_selected_date.is_some() {
|
||||
preferences.calendar_selected_date = request.calendar_selected_date;
|
||||
}
|
||||
if request.calendar_time_increment.is_some() {
|
||||
preferences.calendar_time_increment = request.calendar_time_increment;
|
||||
}
|
||||
if request.calendar_view_mode.is_some() {
|
||||
preferences.calendar_view_mode = request.calendar_view_mode;
|
||||
}
|
||||
if request.calendar_theme.is_some() {
|
||||
preferences.calendar_theme = request.calendar_theme;
|
||||
}
|
||||
if request.calendar_style.is_some() {
|
||||
preferences.calendar_style = request.calendar_style;
|
||||
}
|
||||
if request.calendar_colors.is_some() {
|
||||
preferences.calendar_colors = request.calendar_colors;
|
||||
}
|
||||
|
||||
prefs_repo
|
||||
.update(&preferences)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_style: preferences.calendar_style,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Logout user
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Delete session
|
||||
state.auth_service.logout(session_token).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Logged out successfully"
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -9,27 +9,37 @@ use tower_http::cors::{Any, CorsLayer};
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
|
||||
use auth::AuthService;
|
||||
use db::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
println!("🚀 Starting Calendar Backend Server");
|
||||
|
||||
// Initialize database
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||
|
||||
let db = Database::new(&database_url).await?;
|
||||
println!("✅ Database initialized");
|
||||
|
||||
// Create auth service
|
||||
let jwt_secret = std::env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||
|
||||
let auth_service = AuthService::new(jwt_secret);
|
||||
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||
|
||||
let app_state = AppState { auth_service };
|
||||
let app_state = AppState { auth_service, db };
|
||||
|
||||
// Build our application with routes
|
||||
let app = Router::new()
|
||||
@@ -58,6 +68,10 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"/api/calendar/events/series/delete",
|
||||
post(handlers::delete_event_series),
|
||||
)
|
||||
// User preferences endpoints
|
||||
.route("/api/preferences", get(handlers::get_preferences))
|
||||
.route("/api/preferences", post(handlers::update_preferences))
|
||||
.route("/api/auth/logout", post(handlers::logout))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
@@ -16,8 +16,30 @@ pub struct CalDAVLoginRequest {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferencesResponse {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_style: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdatePreferencesRequest {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_style: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
BIN
calendar.db
BIN
calendar.db
Binary file not shown.
@@ -1,17 +1,16 @@
|
||||
services:
|
||||
calendar-backend:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data/site_dist:/srv/www
|
||||
- ./data/db:/db
|
||||
|
||||
calendar-frontend:
|
||||
image: caddy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_API_URL=http://localhost:3000/api
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlSelectElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlTextAreaElement",
|
||||
"HtmlLinkElement",
|
||||
"HtmlHeadElement",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"InputEvent",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base data-trunk-public-url />
|
||||
<link data-trunk rel="css" href="styles.css">
|
||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
|
||||
@@ -3,10 +3,12 @@ use crate::components::{
|
||||
EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType,
|
||||
ReminderType, RouteHandler, Sidebar, Theme, ViewMode,
|
||||
};
|
||||
use crate::components::sidebar::{Style};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
use chrono::NaiveDate;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
@@ -96,6 +98,16 @@ pub fn App() -> Html {
|
||||
}
|
||||
});
|
||||
|
||||
// Style state - load from localStorage if available
|
||||
let current_style = use_state(|| {
|
||||
// Try to load saved style from localStorage
|
||||
if let Ok(saved_style) = LocalStorage::get::<String>("calendar_style") {
|
||||
Style::from_value(&saved_style)
|
||||
} else {
|
||||
Style::Default // Default style
|
||||
}
|
||||
});
|
||||
|
||||
let available_colors = use_state(|| get_theme_event_colors());
|
||||
|
||||
let on_login = {
|
||||
@@ -152,6 +164,42 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_style_change = {
|
||||
let current_style = current_style.clone();
|
||||
Callback::from(move |new_style: Style| {
|
||||
// Save style to localStorage
|
||||
let _ = LocalStorage::set("calendar_style", new_style.value());
|
||||
|
||||
// Hot-swap stylesheet
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
// Remove existing style link if it exists
|
||||
if let Some(existing_link) = document.get_element_by_id("dynamic-style") {
|
||||
existing_link.remove();
|
||||
}
|
||||
|
||||
// Create and append new stylesheet link only if style has a path
|
||||
if let Some(stylesheet_path) = new_style.stylesheet_path() {
|
||||
if let Ok(link) = document.create_element("link") {
|
||||
let link = link.dyn_into::<web_sys::HtmlLinkElement>().unwrap();
|
||||
link.set_id("dynamic-style");
|
||||
link.set_rel("stylesheet");
|
||||
link.set_href(stylesheet_path);
|
||||
|
||||
if let Some(head) = document.head() {
|
||||
let _ = head.append_child(&link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If stylesheet_path is None (Default style), just removing the dynamic link is enough
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
current_style.set(new_style);
|
||||
})
|
||||
};
|
||||
|
||||
// Apply initial theme on mount
|
||||
{
|
||||
let current_theme = current_theme.clone();
|
||||
@@ -165,6 +213,32 @@ pub fn App() -> Html {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply initial style on mount
|
||||
{
|
||||
let current_style = current_style.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let style = (*current_style).clone();
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
// Create and append stylesheet link for initial style only if it has a path
|
||||
if let Some(stylesheet_path) = style.stylesheet_path() {
|
||||
if let Ok(link) = document.create_element("link") {
|
||||
let link = link.dyn_into::<web_sys::HtmlLinkElement>().unwrap();
|
||||
link.set_id("dynamic-style");
|
||||
link.set_rel("stylesheet");
|
||||
link.set_href(stylesheet_path);
|
||||
|
||||
if let Some(head) = document.head() {
|
||||
let _ = head.append_child(&link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If initial style is Default (None), no additional stylesheet needed
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user info when token is available
|
||||
{
|
||||
let user_info = user_info.clone();
|
||||
@@ -718,6 +792,8 @@ pub fn App() -> Html {
|
||||
on_view_change={on_view_change}
|
||||
current_theme={(*current_theme).clone()}
|
||||
on_theme_change={on_theme_change}
|
||||
current_style={(*current_style).clone()}
|
||||
on_style_change={on_style_change}
|
||||
/>
|
||||
<main class="app-main">
|
||||
<RouteHandler
|
||||
|
||||
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferencesResponse {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -9,11 +9,20 @@ pub struct LoginProps {
|
||||
|
||||
#[function_component]
|
||||
pub fn Login(props: &LoginProps) -> Html {
|
||||
let server_url = use_state(String::new);
|
||||
let username = use_state(String::new);
|
||||
// Load remembered values from LocalStorage on mount
|
||||
let server_url = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||
});
|
||||
let username = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||
});
|
||||
let password = use_state(String::new);
|
||||
let error_message = use_state(|| Option::<String>::None);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
// Remember checkboxes state - default to checked
|
||||
let remember_server = use_state(|| true);
|
||||
let remember_username = use_state(|| true);
|
||||
|
||||
let server_url_ref = use_node_ref();
|
||||
let username_ref = use_node_ref();
|
||||
@@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
password.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_remember_server_change = {
|
||||
let remember_server = remember_server.clone();
|
||||
let server_url = server_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
let checked = target.checked();
|
||||
remember_server.set(checked);
|
||||
|
||||
if checked {
|
||||
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
|
||||
} else {
|
||||
let _ = LocalStorage::delete("remembered_server_url");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_remember_username_change = {
|
||||
let remember_username = remember_username.clone();
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
let checked = target.checked();
|
||||
remember_username.set(checked);
|
||||
|
||||
if checked {
|
||||
let _ = LocalStorage::set("remembered_username", (*username).clone());
|
||||
} else {
|
||||
let _ = LocalStorage::delete("remembered_username");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let server_url = server_url.clone();
|
||||
@@ -73,7 +114,7 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||
Ok((token, credentials)) => {
|
||||
Ok((token, session_token, credentials, preferences)) => {
|
||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||
// Store token and credentials in local storage
|
||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||
@@ -82,11 +123,22 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||
error_message
|
||||
.set(Some("Failed to store session token".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
|
||||
error_message.set(Some("Failed to store credentials".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store preferences from database
|
||||
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
on_login.emit(token);
|
||||
@@ -117,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_server"
|
||||
checked={*remember_server}
|
||||
onchange={on_remember_server_change}
|
||||
/>
|
||||
<label for="remember_server">{"Remember server"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -130,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_username"
|
||||
checked={*remember_username}
|
||||
onchange={on_remember_username_change}
|
||||
/>
|
||||
<label for="remember_username">{"Remember username"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -177,7 +247,7 @@ async fn perform_login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(String, String), String> {
|
||||
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||
use serde_json;
|
||||
|
||||
@@ -201,7 +271,17 @@ async fn perform_login(
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
Ok((response.token, credentials.to_string()))
|
||||
|
||||
// Extract preferences as JSON
|
||||
let preferences = serde_json::json!({
|
||||
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||
"calendar_theme": response.preferences.calendar_theme,
|
||||
"calendar_colors": response.preferences.calendar_colors,
|
||||
});
|
||||
|
||||
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||
|
||||
@@ -32,6 +32,12 @@ pub enum Theme {
|
||||
Mint,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Style {
|
||||
Default,
|
||||
Google,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -60,6 +66,30 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
Style::Default => "default",
|
||||
Style::Google => "google",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Self {
|
||||
match value {
|
||||
"google" => Style::Google,
|
||||
_ => Style::Default,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn stylesheet_path(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
||||
Style::Google => Some("google.css"), // Trunk copies to root level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
@@ -80,6 +110,8 @@ pub struct SidebarProps {
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
pub on_theme_change: Callback<Theme>,
|
||||
pub current_style: Style,
|
||||
pub on_style_change: Callback<Style>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
@@ -111,6 +143,18 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_style_change = {
|
||||
let on_style_change = props.on_style_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
let value = select.value();
|
||||
let new_style = Style::from_value(&value);
|
||||
on_style_change.emit(new_style);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -175,6 +219,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</div>
|
||||
|
||||
<div class="theme-selector">
|
||||
<label>{"Theme:"}</label>
|
||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||
@@ -187,6 +232,14 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="style-selector">
|
||||
<label>{"Style:"}</label>
|
||||
<select class="style-selector-dropdown" onchange={on_style_change}>
|
||||
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
||||
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -585,11 +585,6 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
// Final boundary slot to complete the 24-hour visual grid - make it interactive like other slots
|
||||
<div class="time-slot boundary-slot">
|
||||
<div class="time-slot-half"></div>
|
||||
<div class="time-slot-half"></div>
|
||||
</div>
|
||||
|
||||
// Events positioned absolutely based on their actual times
|
||||
<div class="events-container">
|
||||
@@ -1029,8 +1024,13 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
|
||||
// Only position events that are on this specific date
|
||||
if event_date != date {
|
||||
// Position events based on when they appear in local time, not their original date
|
||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
||||
// but should still display on Sunday's column since that's when the user sees it
|
||||
let should_display_here = event_date == date ||
|
||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
||||
|
||||
if !should_display_here {
|
||||
return (0.0, 0.0, false); // Event not on this date
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod calendar_service;
|
||||
pub mod preferences;
|
||||
|
||||
pub use calendar_service::CalendarService;
|
||||
|
||||
180
frontend/src/services/preferences.rs
Normal file
180
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferences {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct UpdatePreferencesRequest {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct PreferencesService {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl PreferencesService {
|
||||
pub fn new() -> Self {
|
||||
let base_url = option_env!("BACKEND_API_URL")
|
||||
.unwrap_or("http://localhost:3000/api")
|
||||
.to_string();
|
||||
|
||||
Self { base_url }
|
||||
}
|
||||
|
||||
/// Load preferences from LocalStorage (cached from login)
|
||||
pub fn load_cached() -> Option<UserPreferences> {
|
||||
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
|
||||
serde_json::from_str(&prefs_json).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single preference field and sync with backend
|
||||
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
// Get session token
|
||||
let session_token = LocalStorage::get::<String>("session_token")
|
||||
.map_err(|_| "No session token found".to_string())?;
|
||||
|
||||
// Load current preferences
|
||||
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
|
||||
calendar_selected_date: None,
|
||||
calendar_time_increment: None,
|
||||
calendar_view_mode: None,
|
||||
calendar_theme: None,
|
||||
calendar_colors: None,
|
||||
});
|
||||
|
||||
// Update the specific field
|
||||
match field {
|
||||
"calendar_selected_date" => {
|
||||
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_time_increment" => {
|
||||
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
|
||||
}
|
||||
"calendar_view_mode" => {
|
||||
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_theme" => {
|
||||
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_colors" => {
|
||||
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
_ => return Err(format!("Unknown preference field: {}", field)),
|
||||
}
|
||||
|
||||
// Save to LocalStorage cache
|
||||
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
// Sync with backend
|
||||
let request = UpdatePreferencesRequest {
|
||||
calendar_selected_date: preferences.calendar_selected_date.clone(),
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
||||
calendar_theme: preferences.calendar_theme.clone(),
|
||||
calendar_colors: preferences.calendar_colors.clone(),
|
||||
};
|
||||
|
||||
self.sync_preferences(&session_token, &request).await
|
||||
}
|
||||
|
||||
/// Sync all preferences with backend
|
||||
async fn sync_preferences(
|
||||
&self,
|
||||
session_token: &str,
|
||||
request: &UpdatePreferencesRequest,
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let json_body = serde_json::to_string(request)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
|
||||
|
||||
let url = format!("{}/preferences", self.base_url);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Content-Type", "application/json")
|
||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("X-Session-Token", session_token)
|
||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
if resp.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Failed to update preferences: {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate preferences from LocalStorage to backend (on first login after update)
|
||||
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
|
||||
let session_token = LocalStorage::get::<String>("session_token")
|
||||
.map_err(|_| "No session token found".to_string())?;
|
||||
|
||||
let request = UpdatePreferencesRequest {
|
||||
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
|
||||
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
|
||||
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
||||
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
||||
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
||||
};
|
||||
|
||||
// Only migrate if we have some preferences to migrate
|
||||
if request.calendar_selected_date.is_some()
|
||||
|| request.calendar_time_increment.is_some()
|
||||
|| request.calendar_view_mode.is_some()
|
||||
|| request.calendar_theme.is_some()
|
||||
|| request.calendar_colors.is_some()
|
||||
{
|
||||
self.sync_preferences(&session_token, &request).await?;
|
||||
|
||||
// Clear old LocalStorage entries after successful migration
|
||||
let _ = LocalStorage::delete("calendar_selected_date");
|
||||
let _ = LocalStorage::delete("calendar_time_increment");
|
||||
let _ = LocalStorage::delete("calendar_view_mode");
|
||||
let _ = LocalStorage::delete("calendar_theme");
|
||||
let _ = LocalStorage::delete("calendar_colors");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,120 @@
|
||||
/* Base Styles - Always Loaded */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* CSS Variables for Style System */
|
||||
--border-radius-small: 4px;
|
||||
--border-radius-medium: 8px;
|
||||
--border-radius-large: 12px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 8px 25px rgba(0,0,0,0.15);
|
||||
--border-light: 1px solid #e9ecef;
|
||||
--border-medium: 1px solid #dee2e6;
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Base Layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Basic Form Elements */
|
||||
input, select, textarea, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Theme Data Attributes for Color Schemes */
|
||||
[data-theme="default"] {
|
||||
--primary-color: #667eea;
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
[data-theme="ocean"] {
|
||||
--primary-color: #006994;
|
||||
--primary-gradient: linear-gradient(135deg, #006994 0%, #0891b2 100%);
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
--primary-color: #065f46;
|
||||
--primary-gradient: linear-gradient(135deg, #065f46 0%, #047857 100%);
|
||||
}
|
||||
|
||||
[data-theme="sunset"] {
|
||||
--primary-color: #ea580c;
|
||||
--primary-gradient: linear-gradient(135deg, #ea580c 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
[data-theme="purple"] {
|
||||
--primary-color: #7c3aed;
|
||||
--primary-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #374151;
|
||||
--primary-gradient: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||
}
|
||||
|
||||
[data-theme="rose"] {
|
||||
--primary-color: #e11d48;
|
||||
--primary-gradient: linear-gradient(135deg, #e11d48 0%, #f43f5e 100%);
|
||||
}
|
||||
|
||||
[data-theme="mint"] {
|
||||
--primary-color: #10b981;
|
||||
--primary-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
@@ -289,6 +400,30 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.remember-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.remember-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.remember-checkbox label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-button, .register-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
@@ -550,12 +685,13 @@ body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Allow flex item to shrink below content size */
|
||||
}
|
||||
|
||||
.time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
min-height: 100%;
|
||||
min-height: 1530px;
|
||||
}
|
||||
|
||||
/* Time Labels */
|
||||
@@ -565,6 +701,7 @@ body {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
min-height: 1440px; /* Match the time slots height */
|
||||
}
|
||||
|
||||
.time-label {
|
||||
@@ -590,12 +727,13 @@ body {
|
||||
.week-days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */
|
||||
}
|
||||
|
||||
.week-day-column {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
|
||||
min-height: 1440px; /* 24 time slots × 60px = 1440px total */
|
||||
}
|
||||
|
||||
.week-day-column:last-child {
|
||||
@@ -2990,6 +3128,50 @@ body {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Style Selector Styles */
|
||||
.style-selector {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.style-selector label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.style-selector-dropdown {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.style-selector-dropdown:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.style-selector-dropdown:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.style-selector-dropdown option {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Theme Definitions */
|
||||
:root {
|
||||
/* Default Theme */
|
||||
|
||||
3501
frontend/styles.css.backup
Normal file
3501
frontend/styles.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/styles/base.css
Normal file
51
frontend/styles/base.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Base Styles - Always Loaded */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Base Layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Basic Form Elements */
|
||||
input, select, textarea, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
}
|
||||
3501
frontend/styles/default.css
Normal file
3501
frontend/styles/default.css
Normal file
File diff suppressed because it is too large
Load Diff
645
frontend/styles/google.css
Normal file
645
frontend/styles/google.css
Normal file
@@ -0,0 +1,645 @@
|
||||
/* Google Calendar-inspired styles */
|
||||
|
||||
/* Override CSS Variables for Google Calendar Style */
|
||||
:root {
|
||||
/* Google-style spacing */
|
||||
--spacing-xs: 2px;
|
||||
--spacing-sm: 4px;
|
||||
--spacing-md: 8px;
|
||||
--spacing-lg: 12px;
|
||||
--spacing-xl: 16px;
|
||||
|
||||
/* Google-style borders and radius */
|
||||
--border-radius-small: 2px;
|
||||
--border-radius-medium: 4px;
|
||||
--border-radius-large: 8px;
|
||||
--border-light: 1px solid #dadce0;
|
||||
--border-medium: 1px solid #dadce0;
|
||||
|
||||
/* Google-style shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
|
||||
--shadow-md: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15);
|
||||
--shadow-lg: 0 4px 6px 0 rgba(60,64,67,.3), 0 8px 25px 5px rgba(60,64,67,.15);
|
||||
}
|
||||
|
||||
/* Google-style sidebar - override all theme variants */
|
||||
body .app-sidebar,
|
||||
[data-theme] .app-sidebar,
|
||||
[data-theme="default"] .app-sidebar,
|
||||
[data-theme="ocean"] .app-sidebar,
|
||||
[data-theme="forest"] .app-sidebar,
|
||||
[data-theme="sunset"] .app-sidebar,
|
||||
[data-theme="purple"] .app-sidebar,
|
||||
[data-theme="dark"] .app-sidebar,
|
||||
[data-theme="rose"] .app-sidebar,
|
||||
[data-theme="mint"] .app-sidebar {
|
||||
background: #ffffff !important;
|
||||
border-right: 1px solid #dadce0 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
box-shadow: 2px 0 8px rgba(60,64,67,.1) !important;
|
||||
}
|
||||
|
||||
body .sidebar-header,
|
||||
[data-theme] .sidebar-header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
}
|
||||
|
||||
body .sidebar-header h1,
|
||||
[data-theme] .sidebar-header h1 {
|
||||
font-size: 20px !important;
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
body .user-info,
|
||||
[data-theme] .user-info {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .user-info .username,
|
||||
[data-theme] .user-info .username {
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .user-info .server-url,
|
||||
[data-theme] .user-info .server-url {
|
||||
color: #5f6368 !important;
|
||||
}
|
||||
|
||||
/* Google-style buttons */
|
||||
.create-calendar-button {
|
||||
background: #1a73e8 !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
|
||||
transition: box-shadow 0.2s ease !important;
|
||||
}
|
||||
|
||||
.create-calendar-button:hover {
|
||||
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: transparent !important;
|
||||
color: #1a73e8 !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.2s ease !important;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Google-style navigation and sidebar text */
|
||||
body .sidebar-nav .nav-link,
|
||||
[data-theme] .sidebar-nav .nav-link {
|
||||
color: #3c4043 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
body .sidebar-nav .nav-link:hover,
|
||||
[data-theme] .sidebar-nav .nav-link:hover {
|
||||
color: #1a73e8 !important;
|
||||
background: #f1f3f4 !important;
|
||||
}
|
||||
|
||||
/* Calendar list styling */
|
||||
body .calendar-list h3,
|
||||
[data-theme] .calendar-list h3 {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .calendar-list .calendar-name,
|
||||
[data-theme] .calendar-list .calendar-name {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .no-calendars,
|
||||
[data-theme] .no-calendars {
|
||||
color: #5f6368 !important;
|
||||
}
|
||||
|
||||
/* Form labels and text */
|
||||
body .sidebar-footer label,
|
||||
[data-theme] .sidebar-footer label,
|
||||
body .view-selector label,
|
||||
[data-theme] .view-selector label,
|
||||
body .theme-selector label,
|
||||
[data-theme] .theme-selector label,
|
||||
body .style-selector label,
|
||||
[data-theme] .style-selector label {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Google-style selectors */
|
||||
body .view-selector-dropdown,
|
||||
body .theme-selector-dropdown,
|
||||
body .style-selector-dropdown,
|
||||
[data-theme] .view-selector-dropdown,
|
||||
[data-theme] .theme-selector-dropdown,
|
||||
[data-theme] .style-selector-dropdown {
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px !important;
|
||||
font-size: 14px !important;
|
||||
color: #3c4043 !important;
|
||||
background: white !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.view-selector-dropdown:focus,
|
||||
.theme-selector-dropdown:focus,
|
||||
.style-selector-dropdown:focus {
|
||||
outline: none;
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||
}
|
||||
|
||||
/* Google-style calendar list items */
|
||||
.calendar-list h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-list ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-item {
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-item:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-name {
|
||||
color: #3c4043;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Google-style main content area */
|
||||
body .app-main,
|
||||
[data-theme] .app-main {
|
||||
background: #ffffff !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Calendar header elements */
|
||||
body .calendar-header,
|
||||
[data-theme] .calendar-header {
|
||||
background: #f8f9fa !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .calendar-header h2,
|
||||
body .calendar-header h3,
|
||||
body .month-header,
|
||||
body .week-header,
|
||||
[data-theme] .calendar-header h2,
|
||||
[data-theme] .calendar-header h3,
|
||||
[data-theme] .month-header,
|
||||
[data-theme] .week-header {
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Month name and title - aggressive override */
|
||||
body h1,
|
||||
body h2,
|
||||
body h3,
|
||||
body .month-title,
|
||||
body .calendar-title,
|
||||
body .current-month,
|
||||
body .month-year,
|
||||
body .header-title,
|
||||
[data-theme] h1,
|
||||
[data-theme] h2,
|
||||
[data-theme] h3,
|
||||
[data-theme] .month-title,
|
||||
[data-theme] .calendar-title,
|
||||
[data-theme] .current-month,
|
||||
[data-theme] .month-year,
|
||||
[data-theme] .header-title {
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Navigation arrows and buttons - aggressive override */
|
||||
body button,
|
||||
body .nav-button,
|
||||
body .calendar-nav-button,
|
||||
body .prev-button,
|
||||
body .next-button,
|
||||
body .arrow-button,
|
||||
body .navigation-arrow,
|
||||
body [class*="arrow"],
|
||||
body [class*="nav"],
|
||||
body [class*="button"],
|
||||
[data-theme] button,
|
||||
[data-theme] .nav-button,
|
||||
[data-theme] .calendar-nav-button,
|
||||
[data-theme] .prev-button,
|
||||
[data-theme] .next-button,
|
||||
[data-theme] .arrow-button,
|
||||
[data-theme] .navigation-arrow,
|
||||
[data-theme] [class*="arrow"],
|
||||
[data-theme] [class*="nav"],
|
||||
[data-theme] [class*="button"] {
|
||||
color: #3c4043 !important;
|
||||
background: #f8f9fa !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
body button:hover,
|
||||
body .nav-button:hover,
|
||||
body .calendar-nav-button:hover,
|
||||
body .prev-button:hover,
|
||||
body .next-button:hover,
|
||||
body .arrow-button:hover,
|
||||
[data-theme] button:hover,
|
||||
[data-theme] .nav-button:hover,
|
||||
[data-theme] .calendar-nav-button:hover,
|
||||
[data-theme] .prev-button:hover,
|
||||
[data-theme] .next-button:hover,
|
||||
[data-theme] .arrow-button:hover {
|
||||
background: #e8f0fe !important;
|
||||
color: #1a73e8 !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
/* Calendar controls and date display */
|
||||
body .calendar-controls,
|
||||
body .current-date,
|
||||
body .date-display,
|
||||
[data-theme] .calendar-controls,
|
||||
[data-theme] .current-date,
|
||||
[data-theme] .date-display {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Ultimate nuclear approach - override EVERYTHING */
|
||||
html body .app-main,
|
||||
html body .app-main *,
|
||||
html body .main-content,
|
||||
html body .main-content *,
|
||||
html body .calendar-container,
|
||||
html body .calendar-container *,
|
||||
html [data-theme] .app-main,
|
||||
html [data-theme] .app-main *,
|
||||
html [data-theme] .main-content,
|
||||
html [data-theme] .main-content *,
|
||||
html [data-theme] .calendar-container,
|
||||
html [data-theme] .calendar-container *,
|
||||
html [data-theme="default"] .app-main *,
|
||||
html [data-theme="ocean"] .app-main *,
|
||||
html [data-theme="forest"] .app-main *,
|
||||
html [data-theme="sunset"] .app-main *,
|
||||
html [data-theme="purple"] .app-main *,
|
||||
html [data-theme="dark"] .app-main *,
|
||||
html [data-theme="rose"] .app-main *,
|
||||
html [data-theme="mint"] .app-main * {
|
||||
color: #3c4043 !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Force all text elements */
|
||||
html body .app-main h1,
|
||||
html body .app-main h2,
|
||||
html body .app-main h3,
|
||||
html body .app-main h4,
|
||||
html body .app-main h5,
|
||||
html body .app-main h6,
|
||||
html body .app-main p,
|
||||
html body .app-main span,
|
||||
html body .app-main div,
|
||||
html body .app-main button,
|
||||
html [data-theme] .app-main h1,
|
||||
html [data-theme] .app-main h2,
|
||||
html [data-theme] .app-main h3,
|
||||
html [data-theme] .app-main h4,
|
||||
html [data-theme] .app-main h5,
|
||||
html [data-theme] .app-main h6,
|
||||
html [data-theme] .app-main p,
|
||||
html [data-theme] .app-main span,
|
||||
html [data-theme] .app-main div,
|
||||
html [data-theme] .app-main button {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Exception for buttons - make them stand out */
|
||||
body .app-main button,
|
||||
body .main-content button,
|
||||
[data-theme] .app-main button,
|
||||
[data-theme] .main-content button {
|
||||
color: #3c4043 !important;
|
||||
background: #f8f9fa !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
}
|
||||
|
||||
/* Google-style calendar grid - more aggressive styling */
|
||||
html body .calendar-grid,
|
||||
html [data-theme] .calendar-grid,
|
||||
body .calendar-container,
|
||||
[data-theme] .calendar-container {
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
background: white !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
html body .calendar-header,
|
||||
html [data-theme] .calendar-header {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
html body .month-header,
|
||||
html body .week-header,
|
||||
html [data-theme] .month-header,
|
||||
html [data-theme] .week-header {
|
||||
font-size: 22px !important;
|
||||
font-weight: 400 !important;
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Google-style calendar cells - complete overhaul */
|
||||
html body .calendar-day,
|
||||
html [data-theme] .calendar-day,
|
||||
body .day-cell,
|
||||
[data-theme] .day-cell {
|
||||
border: 1px solid #e8eaed !important;
|
||||
background: white !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
padding: 8px !important;
|
||||
min-height: 120px !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
html body .calendar-day:hover,
|
||||
html [data-theme] .calendar-day:hover,
|
||||
body .day-cell:hover,
|
||||
[data-theme] .day-cell:hover {
|
||||
background: #f8f9fa !important;
|
||||
box-shadow: inset 0 0 0 1px #dadce0 !important;
|
||||
}
|
||||
|
||||
html body .calendar-day.today,
|
||||
html [data-theme] .calendar-day.today,
|
||||
body .day-cell.today,
|
||||
[data-theme] .day-cell.today {
|
||||
background: #e8f0fe !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
html body .calendar-day.other-month,
|
||||
html [data-theme] .calendar-day.other-month,
|
||||
body .day-cell.other-month,
|
||||
[data-theme] .day-cell.other-month {
|
||||
background: #fafafa !important;
|
||||
color: #9aa0a6 !important;
|
||||
}
|
||||
|
||||
html body .day-number,
|
||||
html [data-theme] .day-number,
|
||||
body .date-number,
|
||||
[data-theme] .date-number {
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
margin-bottom: 4px !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Day headers (Mon, Tue, Wed, etc.) */
|
||||
html body .day-header,
|
||||
html [data-theme] .day-header,
|
||||
body .weekday-header,
|
||||
[data-theme] .weekday-header {
|
||||
background: #f8f9fa !important;
|
||||
color: #5f6368 !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.8px !important;
|
||||
padding: 8px !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Google Calendar-style events - complete redesign */
|
||||
html body .app-main .event,
|
||||
html [data-theme] .app-main .event,
|
||||
html body .calendar-container .event,
|
||||
html [data-theme] .calendar-container .event,
|
||||
body .event,
|
||||
[data-theme] .event {
|
||||
border-radius: 4px !important;
|
||||
padding: 2px 8px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 400 !important;
|
||||
margin: 1px 0 2px 0 !important;
|
||||
cursor: pointer !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease !important;
|
||||
display: block !important;
|
||||
text-overflow: ellipsis !important;
|
||||
overflow: hidden !important;
|
||||
white-space: nowrap !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
html body .app-main .event *,
|
||||
html [data-theme] .app-main .event *,
|
||||
html body .calendar-container .event *,
|
||||
html [data-theme] .calendar-container .event *,
|
||||
body .event *,
|
||||
[data-theme] .event * {
|
||||
color: white !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
html body .app-main .event:hover,
|
||||
html [data-theme] .app-main .event:hover,
|
||||
body .event:hover,
|
||||
[data-theme] .event:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 8px rgba(60,64,67,.4) !important;
|
||||
}
|
||||
|
||||
/* All-day events styling */
|
||||
html body .event.all-day,
|
||||
html [data-theme] .event.all-day {
|
||||
border-radius: 12px !important;
|
||||
padding: 4px 12px !important;
|
||||
font-weight: 500 !important;
|
||||
margin: 2px 0 !important;
|
||||
}
|
||||
|
||||
/* Event time display */
|
||||
html body .event-time,
|
||||
html [data-theme] .event-time {
|
||||
opacity: 0.9 !important;
|
||||
font-size: 10px !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
/* Week view events */
|
||||
html body .week-view .event,
|
||||
html [data-theme] .week-view .event {
|
||||
border-left: 3px solid rgba(255,255,255,0.8) !important;
|
||||
border-radius: 0 4px 4px 0 !important;
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
|
||||
/* Calendar table structure */
|
||||
html body .calendar-table,
|
||||
html [data-theme] .calendar-table,
|
||||
body table,
|
||||
[data-theme] table {
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
width: 100% !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
html body .calendar-table td,
|
||||
html [data-theme] .calendar-table td,
|
||||
body table td,
|
||||
[data-theme] table td {
|
||||
vertical-align: top !important;
|
||||
border: 1px solid #e8eaed !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Month/Week view toggle */
|
||||
html body .view-toggle,
|
||||
html [data-theme] .view-toggle {
|
||||
display: flex !important;
|
||||
gap: 4px !important;
|
||||
background: #f1f3f4 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
html body .view-toggle button,
|
||||
html [data-theme] .view-toggle button {
|
||||
padding: 6px 12px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: #5f6368 !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
}
|
||||
|
||||
html body .view-toggle button.active,
|
||||
html [data-theme] .view-toggle button.active {
|
||||
background: white !important;
|
||||
color: #1a73e8 !important;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||
}
|
||||
|
||||
/* Today button */
|
||||
html body .today-button,
|
||||
html [data-theme] .today-button {
|
||||
background: white !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
color: #1a73e8 !important;
|
||||
padding: 8px 16px !important;
|
||||
border-radius: 4px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
}
|
||||
|
||||
html body .today-button:hover,
|
||||
html [data-theme] .today-button:hover {
|
||||
background: #f8f9fa !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
/* Google-style modals */
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
font-family: 'Google Sans', sans-serif;
|
||||
}
|
||||
|
||||
/* Google-style form inputs */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
textarea,
|
||||
select {
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #3c4043;
|
||||
background: white;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||
}
|
||||
|
||||
/* Google-style labels */
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
Reference in New Issue
Block a user