10 Commits

Author SHA1 Message Date
Connor Johnstone
970b0a07da Fix compiler warnings
- Remove unused import in auth handler
- Remove unused PreferencesService export
- Fix unused mutable variable in preferences
- Remove unused display_name method from Style enum
- Add dead_code attributes for future preferences service

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:38:13 -04:00
Connor Johnstone
e2e5813b54 Fix week view time grid display and late night event rendering
- Remove unnecessary boundary slot from week view time grid
- Adjust container heights to display full 11 PM - midnight time slot
- Fix timezone issue preventing events at 8 PM or later from rendering
- Update date matching logic to handle UTC-4 timezone offset correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:36:26 -04:00
Connor Johnstone
73567c185c Implement comprehensive style system with Google Calendar theme
This commit adds a complete style system alongside the existing theme system, allowing users to switch between different UI styles while maintaining theme color variations.

**Core Features:**
- Style enum (Default, Google Calendar) separate from Theme enum
- Hot-swappable stylesheets with dynamic loading
- Style preference persistence (localStorage + database)
- Style picker UI in sidebar below theme picker

**Frontend Implementation:**
- Add Style enum to sidebar.rs with value/display methods
- Implement dynamic stylesheet loading in app.rs
- Add style picker dropdown with proper styling
- Handle style state management and persistence
- Add web-sys features for HtmlLinkElement support

**Backend Integration:**
- Add calendar_style column to user_preferences table
- Update all database operations (insert/update/select)
- Extend API models for style preference
- Add migration for existing users

**Google Calendar Style:**
- Clean Material Design-inspired interface
- White sidebar with proper contrast
- Enhanced calendar grid with subtle shadows
- Improved event styling with hover effects
- Google Sans typography throughout
- Professional color scheme and spacing

**Technical Details:**
- Trunk asset management for stylesheet copying
- High CSS specificity to override theme styles
- Modular CSS architecture for easy extensibility
- Comprehensive text contrast fixes
- Enhanced calendar cells and navigation

Users can now choose between the original gradient design (Default) and a clean Google Calendar-inspired interface (Google Calendar), with full preference persistence across sessions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 20:08:05 -04:00
0587762bbb Merge pull request 'Fixed some auth stuff and deployment stuff' (#7) from bugfix/sqlite-auth-docker-deployment into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #7
2025-09-01 19:29:17 -04:00
Connor Johnstone
cd6e9c3619 Update README with Docker deployment instructions and auth features
- Added comprehensive Docker Compose deployment section as recommended method
- Documented automatic database migrations and persistent storage
- Updated architecture section to mention SQLite auth system
- Added new User Experience section highlighting session management
- Reorganized development setup with local database migration instructions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:28:24 -04:00
Connor Johnstone
d8c3997f24 Fix Docker database directory and permissions
- Create /db directory in container
- Ensure database directory exists and has proper permissions in startup script
- Remove problematic conditional COPY command that was causing build failures

This fixes the 'unable to open database file' error by ensuring the
database directory exists before migrations run.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:21:38 -04:00
Connor Johnstone
e44d49e190 Add database migrations to Docker entrypoint
- Install sqlx-cli in backend builder stage
- Copy migrations and sqlx binary to runtime image
- Run database migrations automatically on container startup
- Add error handling to prevent startup failure if migrations already applied

This ensures the database schema is always up to date when deploying
with Docker, eliminating manual migration steps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:13:04 -04:00
4d2aad404b Merge pull request 'Improved auth system' (#6) from feature/sqlite-auth-system into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #6
2025-09-01 19:03:30 -04:00
Connor Johnstone
0453763c98 Make remember checkboxes more subtle and checked by default
- Remember checkboxes now default to checked for better user experience
- Reduced visual prominence with smaller size, lighter colors, and lower opacity
- Users get convenience by default while still being able to opt out

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:02:29 -04:00
Connor Johnstone
03c0011445 Implement lightweight auth system with SQLite
Added SQLite database for session management and user preferences storage,
allowing users to have consistent settings across different sessions and devices.

Backend changes:
- Added SQLite database with users, sessions, and preferences tables
- Implemented session-based authentication alongside JWT tokens
- Created preference storage/retrieval API endpoints
- Database migrations for schema setup
- Session validation and cleanup functionality

Frontend changes:
- Added "Remember server" and "Remember username" checkboxes to login
- Created preferences service for syncing settings with backend
- Updated auth flow to handle session tokens and preferences
- Store remembered values in LocalStorage (not database) for convenience

Key features:
- User preferences persist across sessions and devices
- CalDAV passwords never stored, only passed through
- Sessions expire after 24 hours
- Remember checkboxes only affect local browser storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:55:09 -04:00
32 changed files with 8966 additions and 69 deletions

View File

@@ -38,4 +38,4 @@ calendar.db
**/tests/
# Migrations (not needed for builds)
migrations/
migrations/

6
.gitignore vendored
View File

@@ -22,3 +22,9 @@ dist/
CLAUDE.md
data/
# SQLite database
*.db
*.db-shm
*.db-wal
calendar.db

View File

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

View File

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

View File

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

View 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)
);

View 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);

View 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
);

View File

@@ -0,0 +1,2 @@
-- Add calendar style preference to user preferences
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';

View File

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

View File

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

View File

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

View 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"
})),
))
}

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
"HtmlSelectElement",
"HtmlInputElement",
"HtmlTextAreaElement",
"HtmlLinkElement",
"HtmlHeadElement",
"Event",
"MouseEvent",
"InputEvent",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
pub mod calendar_service;
pub mod preferences;
pub use calendar_service::CalendarService;

View 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(())
}
}

View File

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

File diff suppressed because it is too large Load Diff

51
frontend/styles/base.css Normal file
View 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

File diff suppressed because it is too large Load Diff

645
frontend/styles/google.css Normal file
View 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;
}