5 Commits

Author SHA1 Message Date
Connor Johnstone
d945c46e5a Implement real-time event refresh functionality
- Backend: Add GET /api/calendar/events/:uid endpoint for single event refresh
- Backend: Implement fetch_event_by_uid method to retrieve updated events from CalDAV
- Frontend: Add event click callback system to trigger refresh on interaction
- Frontend: Display loading state with orange pulsing animation during refresh
- Frontend: Smart event data updates without full calendar reload
- Frontend: Graceful error handling with fallback to cached data
- CSS: Add refreshing animation for visual feedback during updates

Events now automatically refresh from CalDAV server when clicked, ensuring
users always see the most current event data including changes made in
other calendar applications.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:51:30 -04:00
Connor Johnstone
1c4857ccad Add comprehensive reminder/alarm support to calendar events
- Backend: Parse VALARM components from CalDAV iCalendar data
- Backend: Add EventReminder struct with minutes_before, action, and description
- Backend: Support Display, Email, and Audio reminder types
- Backend: Parse ISO 8601 duration triggers (-PT15M, -P1D, etc.)
- Frontend: Add reminders field to CalendarEvent structure
- Frontend: Display reminders in event modal with human-readable formatting
- Frontend: Show reminder timing (15 minutes before, 1 day before) and action type
- Fix: Update Trunk.toml to properly copy CSS files to dist directory

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:37:30 -04:00
Connor Johnstone
01a21cb869 Extract CSS into separate stylesheet for better organization
- Moved all 578+ lines of CSS from index.html into styles.css
- Updated index.html to link to external stylesheet
- Improved code organization and maintainability
- Better separation of concerns between HTML structure and styling
- Enables better browser caching of stylesheets
- Follows web development best practices

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:22:28 -04:00
Connor Johnstone
b1b8e1e580 Refactor event modal into standalone component
- Created dedicated EventModal component in src/components/event_modal.rs
- Extracted modal logic and styling from calendar component for better separation
- Updated data flow to pass full CalendarEvent objects instead of strings
- Added PartialEq derive to CalendarEvent for component props
- Updated service layer to group events by CalendarEvent objects
- Enhanced event click handling to show detailed event information
- Modal displays title, description, location, start/end times, and status
- Maintained existing modal styling and user interaction patterns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:18:39 -04:00
Connor Johnstone
a3d1612dac Add interactive day selection to calendar
- Added click handlers to calendar days for user interaction
- Implemented selected day state tracking in calendar component
- Added CSS styling for selected days with green highlight
- Selected days show distinct visual feedback with borders and shadows
- Supports combination states (selected+today, selected+events)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:08:58 -04:00
15 changed files with 1347 additions and 453 deletions

View File

@@ -6,10 +6,14 @@ dist = "dist"
BACKEND_API_URL = "http://localhost:3000/api"
[watch]
watch = ["src", "Cargo.toml"]
watch = ["src", "Cargo.toml", "styles.css", "index.html"]
ignore = ["backend/"]
[serve]
address = "127.0.0.1"
port = 8080
open = false
open = false
[[copy]]
from = "styles.css"
to = "dist/"

BIN
backend/calendar.db Normal file

Binary file not shown.

View File

@@ -53,6 +53,9 @@ pub struct CalendarEvent {
/// All-day event flag
pub all_day: bool,
/// Reminders/alarms for this event
pub reminders: Vec<EventReminder>,
/// ETag from CalDAV server for conflict detection
pub etag: Option<String>,
@@ -88,6 +91,27 @@ impl Default for EventClass {
}
}
/// Event reminder/alarm information
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EventReminder {
/// How long before the event to trigger the reminder (in minutes)
pub minutes_before: i32,
/// Type of reminder action
pub action: ReminderAction,
/// Optional description for the reminder
pub description: Option<String>,
}
/// Reminder action types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ReminderAction {
Display,
Email,
Audio,
}
/// CalDAV client for fetching and parsing calendar events
pub struct CalDAVClient {
config: crate::config::CalDAVConfig,
@@ -168,6 +192,17 @@ impl CalDAVClient {
Ok(events)
}
/// Fetch a single calendar event by UID from the CalDAV server
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
// First fetch all events and find the one with matching UID
let events = self.fetch_events(calendar_path).await?;
// Find event with matching UID
let event = events.into_iter().find(|e| e.uid == uid);
Ok(event)
}
/// Extract calendar data sections from CalDAV XML response
fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> {
let mut sections = Vec::new();
@@ -244,8 +279,8 @@ impl CalDAVClient {
let mut properties: HashMap<String, String> = HashMap::new();
// Extract all properties from the event
for property in event.properties {
properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default());
for property in &event.properties {
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
}
// Required UID field
@@ -325,11 +360,82 @@ impl CalDAVClient {
last_modified,
recurrence_rule: properties.get("RRULE").cloned(),
all_day,
reminders: self.parse_alarms(&event)?,
etag: None, // Set by caller
href: None, // Set by caller
})
}
/// Parse VALARM components from an iCal event
fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<EventReminder>, CalDAVError> {
let mut reminders = Vec::new();
for alarm in &event.alarms {
if let Ok(reminder) = self.parse_single_alarm(alarm) {
reminders.push(reminder);
}
}
Ok(reminders)
}
/// Parse a single VALARM component into an EventReminder
fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, CalDAVError> {
let mut properties: HashMap<String, String> = HashMap::new();
// Extract all properties from the alarm
for property in &alarm.properties {
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
}
// Parse ACTION (required)
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
Some(ref action_str) if action_str == "DISPLAY" => ReminderAction::Display,
Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email,
Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio,
_ => ReminderAction::Display, // Default
};
// Parse TRIGGER (required)
let minutes_before = if let Some(trigger) = properties.get("TRIGGER") {
self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes
} else {
15 // Default 15 minutes
};
// Get description
let description = properties.get("DESCRIPTION").cloned();
Ok(EventReminder {
minutes_before,
action,
description,
})
}
/// Parse a TRIGGER duration string into minutes before event
fn parse_trigger_duration(&self, trigger: &str) -> Option<i32> {
// Basic parsing of ISO 8601 duration or relative time
// Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before)
if trigger.starts_with("-PT") && trigger.ends_with("M") {
// Parse "-PT15M" format (minutes)
let minutes_str = &trigger[3..trigger.len()-1];
minutes_str.parse::<i32>().ok()
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
// Parse "-PT1H" format (hours)
let hours_str = &trigger[3..trigger.len()-1];
hours_str.parse::<i32>().ok().map(|h| h * 60)
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
// Parse "-P1D" format (days)
let days_str = &trigger[2..trigger.len()-1];
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
} else {
// Try to parse as raw minutes
trigger.parse::<i32>().ok().map(|m| m.abs())
}
}
/// Discover available calendar collections on the server
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
// First, try to discover user calendars if we have a calendar path in config

View File

@@ -0,0 +1,61 @@
use crate::calendar::CalDAVClient;
use crate::config::CalDAVConfig;
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
let config = CalDAVConfig::from_env()?;
let client = CalDAVClient::new(config);
println!("=== DEBUG: CalDAV Fetch ===");
// Discover calendars
let calendars = client.discover_calendars().await?;
println!("Found {} calendars: {:?}", calendars.len(), calendars);
if let Some(calendar_path) = calendars.first() {
println!("Fetching events from: {}", calendar_path);
// Make the raw REPORT request
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>"#;
let url = format!("{}{}", client.config.server_url.trim_end_matches('/'), calendar_path);
println!("Request URL: {}", url);
let response = client.http_client
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
.header("Authorization", format!("Basic {}", client.config.get_basic_auth()))
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
.body(report_body)
.send()
.await?;
println!("Response status: {}", response.status());
let body = response.text().await?;
println!("Response body length: {}", body.len());
println!("First 500 chars of response: {}", &body[..std::cmp::min(500, body.len())]);
// Try to parse it
let events = client.parse_calendar_response(&body)?;
println!("Parsed {} events", events.len());
for (i, event) in events.iter().enumerate() {
println!("Event {}: {}", i+1, event.summary.as_deref().unwrap_or("No title"));
println!(" Start: {}", event.start);
println!(" UID: {}", event.uid);
}
}
Ok(())
}

View File

@@ -1,5 +1,5 @@
use axum::{
extract::{State, Query},
extract::{State, Query, Path},
http::HeaderMap,
response::Json,
};
@@ -73,6 +73,52 @@ pub async fn get_calendar_events(
Ok(Json(filtered_events))
}
pub async fn refresh_event(
State(_state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
// Verify authentication (extract token from Authorization header)
let _token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if auth_str.starts_with("Bearer ") {
auth_str.strip_prefix("Bearer ").unwrap().to_string()
} else {
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
};
// TODO: Validate JWT token here
// Load CalDAV configuration
let config = CalDAVConfig::from_env()
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(None)); // No calendars found
}
// Fetch the specific event by UID from the first calendar
let calendar_path = &calendar_paths[0];
let event = client.fetch_event_by_uid(calendar_path, &uid)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?;
Ok(Json(event))
}
pub async fn register(
State(state): State<Arc<AppState>>,
Json(request): Json<RegisterRequest>,

View File

@@ -53,6 +53,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/auth/login", post(handlers::login))
.route("/api/auth/verify", get(handlers::verify_token))
.route("/api/calendar/events", get(handlers::get_calendar_events))
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
.layer(
CorsLayer::new()
.allow_origin(Any)

View File

@@ -4,436 +4,7 @@
<meta charset="utf-8" />
<title>Calendar App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
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: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.app-header h1 {
margin: 0;
font-size: 1.8rem;
}
.app-header nav {
display: flex;
gap: 1rem;
align-items: center;
}
.app-header nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.app-header nav a:hover {
background-color: rgba(255,255,255,0.2);
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Authentication Forms */
.login-container, .register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.login-form, .register-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.login-form h2, .register-form h2 {
text-align: center;
margin-bottom: 2rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.login-button, .register-button {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.login-button:hover, .register-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.login-button:disabled, .register-button:disabled {
background: #ccc;
transform: none;
cursor: not-allowed;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #f5c6cb;
}
.auth-links {
text-align: center;
margin-top: 2rem;
color: #666;
}
.auth-links a {
color: #667eea;
text-decoration: none;
}
.auth-links a:hover {
text-decoration: underline;
}
.logout-button {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background: rgba(255,255,255,0.3);
}
/* Calendar View */
.calendar-view {
height: calc(100vh - 140px); /* Full height minus header and padding */
display: flex;
flex-direction: column;
}
.calendar-loading, .calendar-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.calendar-loading p {
font-size: 1.2rem;
color: #666;
}
.calendar-error p {
font-size: 1.2rem;
color: #d32f2f;
}
/* Calendar Component */
.calendar {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.month-year {
font-size: 1.8rem;
font-weight: 600;
margin: 0;
}
.nav-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 1.5rem;
font-weight: bold;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.nav-button:hover {
background: rgba(255,255,255,0.3);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
flex: 1;
background: white;
}
.weekday-header {
background: #f8f9fa;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #666;
border-bottom: 1px solid #e9ecef;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-day {
border: 1px solid #f0f0f0;
padding: 0.75rem;
min-height: 100px;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.calendar-day:hover {
background-color: #f8f9ff;
}
.calendar-day.current-month {
background: white;
}
.calendar-day.prev-month,
.calendar-day.next-month {
background: #fafafa;
color: #ccc;
}
.calendar-day.today {
background: #e3f2fd;
border: 2px solid #2196f3;
}
.calendar-day.has-events {
background: #fff3e0;
}
.calendar-day.today.has-events {
background: #e1f5fe;
}
.day-number {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.calendar-day.today .day-number {
color: #1976d2;
}
.event-indicators {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-box {
background: #2196f3;
color: white;
padding: 2px 4px;
border-radius: 3px;
font-size: 0.7rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.2s;
}
.event-box:hover {
background: #1976d2;
}
.event-dot {
background: #ff9800;
height: 6px;
border-radius: 3px;
margin-bottom: 1px;
}
.more-events {
font-size: 0.7rem;
color: #666;
margin-top: 2px;
font-weight: 500;
}
/* Responsive Design */
@media (max-width: 768px) {
.calendar-header {
padding: 1rem;
}
.month-year {
font-size: 1.4rem;
}
.nav-button {
width: 35px;
height: 35px;
font-size: 1.2rem;
}
.weekday-header {
padding: 0.5rem;
font-size: 0.8rem;
}
.calendar-day {
min-height: 70px;
padding: 0.5rem;
}
.day-number {
font-size: 1rem;
}
.app-main {
padding: 1rem;
}
.calendar-view {
height: calc(100vh - 120px);
}
}
@media (max-width: 480px) {
.calendar-day {
min-height: 60px;
padding: 0.25rem;
}
.weekday-header {
padding: 0.5rem 0.25rem;
}
.day-number {
font-size: 0.9rem;
}
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.app-header nav {
margin-top: 1rem;
}
.app-main {
padding: 1rem;
}
.login-form, .register-form {
padding: 1.5rem;
}
}
</style>
<link rel="stylesheet" href="styles.css">
</head>
<body></body>
</html>

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use crate::components::{Login, Register, Calendar};
use crate::services::CalendarService;
use crate::services::{CalendarService, CalendarEvent};
use std::collections::HashMap;
use chrono::{Local, NaiveDate, Datelike};
@@ -107,9 +107,10 @@ pub fn App() -> Html {
#[function_component]
fn CalendarView() -> Html {
let events = use_state(|| HashMap::<NaiveDate, Vec<String>>::new());
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let refreshing_event = use_state(|| None::<String>);
// Get current auth token
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
@@ -118,6 +119,57 @@ fn CalendarView() -> Html {
let current_year = today.year();
let current_month = today.month();
// Event refresh callback
let on_event_click = {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let auth_token = auth_token.clone();
Callback::from(move |event: CalendarEvent| {
if let Some(token) = auth_token.clone() {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let uid = event.uid.clone();
refreshing_event.set(Some(uid.clone()));
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
match calendar_service.refresh_event(&token, &uid).await {
Ok(Some(refreshed_event)) => {
// Update the event in the existing events map
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
for existing_event in day_events.iter_mut() {
if existing_event.uid == uid {
*existing_event = refreshed_event.clone();
break;
}
}
}
events.set(updated_events);
}
Ok(None) => {
// Event was deleted, remove it from the map
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {
// Log error but don't show it to user - keep using cached event
// Silently fall back to cached event data
}
}
refreshing_event.set(None);
});
}
})
};
// Fetch events when component mounts
{
let events = events.clone();
@@ -165,15 +217,16 @@ fn CalendarView() -> Html {
</div>
}
} else if let Some(err) = (*error).clone() {
let dummy_callback = Callback::from(|_: CalendarEvent| {});
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
<Calendar events={HashMap::new()} />
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} />
</div>
}
} else {
html! {
<Calendar events={(*events).clone()} />
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} />
}
}
}

View File

@@ -1,17 +1,24 @@
use yew::prelude::*;
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
use std::collections::HashMap;
use crate::services::calendar_service::CalendarEvent;
use crate::components::EventModal;
#[derive(Properties, PartialEq)]
pub struct CalendarProps {
#[prop_or_default]
pub events: HashMap<NaiveDate, Vec<String>>,
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
pub on_event_click: Callback<CalendarEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
}
#[function_component]
pub fn Calendar(props: &CalendarProps) -> Html {
let today = Local::now().date_naive();
let current_month = use_state(|| today);
let selected_day = use_state(|| today);
let selected_event = use_state(|| None::<CalendarEvent>);
let first_day_of_month = current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(*current_month);
@@ -71,18 +78,27 @@ pub fn Calendar(props: &CalendarProps) -> Html {
(1..=days_in_month).map(|day| {
let date = current_month.with_day(day).unwrap();
let is_today = date == today;
let is_selected = date == *selected_day;
let events = props.events.get(&date).cloned().unwrap_or_default();
let mut classes = vec!["calendar-day", "current-month"];
if is_today {
classes.push("today");
}
if is_selected {
classes.push("selected");
}
if !events.is_empty() {
classes.push("has-events");
}
let selected_day_clone = selected_day.clone();
let on_click = Callback::from(move |_| {
selected_day_clone.set(date);
});
html! {
<div class={classes!(classes)}>
<div class={classes!(classes)} onclick={on_click}>
<div class="day-number">{day}</div>
{
if !events.is_empty() {
@@ -90,13 +106,29 @@ pub fn Calendar(props: &CalendarProps) -> Html {
<div class="event-indicators">
{
events.iter().take(2).map(|event| {
let event_clone = event.clone();
let selected_event_clone = selected_event.clone();
let on_event_click = props.on_event_click.clone();
let event_click = Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent day selection
on_event_click.emit(event_clone.clone());
selected_event_clone.set(Some(event_clone.clone()));
});
let title = event.get_title();
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
html! {
<div class="event-box" title={event.clone()}>
<div class={class_name}
title={title.clone()}
onclick={event_click}>
{
if event.len() > 15 {
format!("{}...", &event[..12])
if is_refreshing {
"🔄 Refreshing...".to_string()
} else if title.len() > 15 {
format!("{}...", &title[..12])
} else {
event.clone()
title
}
}
</div>
@@ -123,6 +155,17 @@ pub fn Calendar(props: &CalendarProps) -> Html {
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
</div>
// Event details modal
<EventModal
event={(*selected_event).clone()}
on_close={{
let selected_event_clone = selected_event.clone();
Callback::from(move |_| {
selected_event_clone.set(None);
})
}}
/>
</div>
}
}
@@ -193,4 +236,5 @@ fn get_month_name(month: u32) -> &'static str {
12 => "December",
_ => "Invalid"
}
}
}

View File

@@ -0,0 +1,289 @@
use yew::prelude::*;
use chrono::{DateTime, Utc};
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
#[derive(Properties, PartialEq)]
pub struct EventModalProps {
pub event: Option<CalendarEvent>,
pub on_close: Callback<()>,
}
#[function_component]
pub fn EventModal(props: &EventModalProps) -> Html {
let close_modal = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if e.target() == e.current_target() {
on_close.emit(());
}
})
};
if let Some(ref event) = props.event {
html! {
<div class="modal-backdrop" onclick={backdrop_click}>
<div class="modal-content">
<div class="modal-header">
<h3>{"Event Details"}</h3>
<button class="modal-close" onclick={close_modal}>{"×"}</button>
</div>
<div class="modal-body">
<div class="event-detail">
<strong>{"Title:"}</strong>
<span>{event.get_title()}</span>
</div>
{
if let Some(ref description) = event.description {
html! {
<div class="event-detail">
<strong>{"Description:"}</strong>
<span>{description}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"Start:"}</strong>
<span>{format_datetime(&event.start, event.all_day)}</span>
</div>
{
if let Some(ref end) = event.end {
html! {
<div class="event-detail">
<strong>{"End:"}</strong>
<span>{format_datetime(end, event.all_day)}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"All Day:"}</strong>
<span>{if event.all_day { "Yes" } else { "No" }}</span>
</div>
{
if let Some(ref location) = event.location {
html! {
<div class="event-detail">
<strong>{"Location:"}</strong>
<span>{location}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"Status:"}</strong>
<span>{event.get_status_display()}</span>
</div>
<div class="event-detail">
<strong>{"Privacy:"}</strong>
<span>{event.get_class_display()}</span>
</div>
<div class="event-detail">
<strong>{"Priority:"}</strong>
<span>{event.get_priority_display()}</span>
</div>
{
if let Some(ref organizer) = event.organizer {
html! {
<div class="event-detail">
<strong>{"Organizer:"}</strong>
<span>{organizer}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.attendees.is_empty() {
html! {
<div class="event-detail">
<strong>{"Attendees:"}</strong>
<span>{event.attendees.join(", ")}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.categories.is_empty() {
html! {
<div class="event-detail">
<strong>{"Categories:"}</strong>
<span>{event.categories.join(", ")}</span>
</div>
}
} else {
html! {}
}
}
{
if let Some(ref recurrence) = event.recurrence_rule {
html! {
<div class="event-detail">
<strong>{"Repeats:"}</strong>
<span>{format_recurrence_rule(recurrence)}</span>
</div>
}
} else {
html! {
<div class="event-detail">
<strong>{"Repeats:"}</strong>
<span>{"No"}</span>
</div>
}
}
}
{
if !event.reminders.is_empty() {
html! {
<div class="event-detail">
<strong>{"Reminders:"}</strong>
<span>{format_reminders(&event.reminders)}</span>
</div>
}
} else {
html! {
<div class="event-detail">
<strong>{"Reminders:"}</strong>
<span>{"None"}</span>
</div>
}
}
}
{
if let Some(ref created) = event.created {
html! {
<div class="event-detail">
<strong>{"Created:"}</strong>
<span>{format_datetime(created, false)}</span>
</div>
}
} else {
html! {}
}
}
{
if let Some(ref modified) = event.last_modified {
html! {
<div class="event-detail">
<strong>{"Last Modified:"}</strong>
<span>{format_datetime(modified, false)}</span>
</div>
}
} else {
html! {}
}
}
</div>
</div>
</div>
}
} else {
html! {}
}
}
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
if all_day {
dt.format("%B %d, %Y").to_string()
} else {
dt.format("%B %d, %Y at %I:%M %p").to_string()
}
}
fn format_recurrence_rule(rrule: &str) -> String {
// Basic parsing of RRULE to display user-friendly text
if rrule.contains("FREQ=DAILY") {
"Daily".to_string()
} else if rrule.contains("FREQ=WEEKLY") {
"Weekly".to_string()
} else if rrule.contains("FREQ=MONTHLY") {
"Monthly".to_string()
} else if rrule.contains("FREQ=YEARLY") {
"Yearly".to_string()
} else {
// Show the raw rule if we can't parse it
format!("Custom ({})", rrule)
}
}
fn format_reminders(reminders: &[EventReminder]) -> String {
if reminders.is_empty() {
return "None".to_string();
}
let formatted_reminders: Vec<String> = reminders
.iter()
.map(|reminder| {
let time_text = if reminder.minutes_before == 0 {
"At event time".to_string()
} else if reminder.minutes_before < 60 {
format!("{} minutes before", reminder.minutes_before)
} else if reminder.minutes_before == 60 {
"1 hour before".to_string()
} else if reminder.minutes_before % 60 == 0 {
format!("{} hours before", reminder.minutes_before / 60)
} else if reminder.minutes_before < 1440 {
let hours = reminder.minutes_before / 60;
let minutes = reminder.minutes_before % 60;
format!("{}h {}m before", hours, minutes)
} else if reminder.minutes_before == 1440 {
"1 day before".to_string()
} else if reminder.minutes_before % 1440 == 0 {
format!("{} days before", reminder.minutes_before / 1440)
} else {
let days = reminder.minutes_before / 1440;
let remaining_minutes = reminder.minutes_before % 1440;
let hours = remaining_minutes / 60;
let minutes = remaining_minutes % 60;
if hours > 0 {
format!("{}d {}h before", days, hours)
} else if minutes > 0 {
format!("{}d {}m before", days, minutes)
} else {
format!("{} days before", days)
}
};
let action_text = match reminder.action {
ReminderAction::Display => "notification",
ReminderAction::Email => "email",
ReminderAction::Audio => "sound",
};
format!("{} ({})", time_text, action_text)
})
.collect();
formatted_reminders.join(", ")
}

View File

@@ -1,7 +1,9 @@
pub mod login;
pub mod register;
pub mod calendar;
pub mod event_modal;
pub use login::Login;
pub use register::Register;
pub use calendar::Calendar;
pub use calendar::Calendar;
pub use event_modal::EventModal;

View File

@@ -5,7 +5,27 @@ use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EventReminder {
pub minutes_before: i32,
pub action: ReminderAction,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ReminderAction {
Display,
Email,
Audio,
}
impl Default for ReminderAction {
fn default() -> Self {
ReminderAction::Display
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarEvent {
pub uid: String,
pub summary: Option<String>,
@@ -13,8 +33,45 @@ pub struct CalendarEvent {
pub start: DateTime<Utc>,
pub end: Option<DateTime<Utc>>,
pub location: Option<String>,
pub status: String,
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
pub organizer: Option<String>,
pub attendees: Vec<String>,
pub categories: Vec<String>,
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub recurrence_rule: Option<String>,
pub all_day: bool,
pub reminders: Vec<EventReminder>,
pub etag: Option<String>,
pub href: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
impl CalendarEvent {
@@ -31,6 +88,38 @@ impl CalendarEvent {
pub fn get_title(&self) -> String {
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
}
/// Get display string for status
pub fn get_status_display(&self) -> &'static str {
match self.status {
EventStatus::Tentative => "Tentative",
EventStatus::Confirmed => "Confirmed",
EventStatus::Cancelled => "Cancelled",
}
}
/// Get display string for class
pub fn get_class_display(&self) -> &'static str {
match self.class {
EventClass::Public => "Public",
EventClass::Private => "Private",
EventClass::Confidential => "Confidential",
}
}
/// Get display string for priority
pub fn get_priority_display(&self) -> String {
match self.priority {
None => "Not set".to_string(),
Some(0) => "Undefined".to_string(),
Some(1) => "High".to_string(),
Some(p) if p <= 4 => "High".to_string(),
Some(5) => "Medium".to_string(),
Some(p) if p <= 8 => "Low".to_string(),
Some(9) => "Low".to_string(),
Some(p) => format!("Priority {}", p),
}
}
}
pub struct CalendarService {
@@ -91,18 +180,56 @@ impl CalendarService {
}
/// Convert events to a HashMap grouped by date for calendar display
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<String>> {
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
let mut grouped = HashMap::new();
for event in events {
let date = event.get_date();
let title = event.get_title();
grouped.entry(date)
.or_insert_with(Vec::new)
.push(title);
.push(event);
}
grouped
}
/// Refresh a single event by UID from the CalDAV server
pub async fn refresh_event(&self, token: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
let url = format!("{}/calendar/events/{}", self.base_url, uid);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request.headers().set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization 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))?;
let text = JsFuture::from(resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text_string = text.as_string()
.ok_or("Response text is not a string")?;
if resp.ok() {
let event: Option<CalendarEvent> = serde_json::from_str(&text_string)
.map_err(|e| format!("JSON parsing failed: {}", e))?;
Ok(event)
} else {
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
}
}
}

View File

@@ -1,3 +1,3 @@
pub mod calendar_service;
pub use calendar_service::CalendarService;
pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction};

589
styles.css Normal file
View File

@@ -0,0 +1,589 @@
* {
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: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.app-header h1 {
margin: 0;
font-size: 1.8rem;
}
.app-header nav {
display: flex;
gap: 1rem;
align-items: center;
}
.app-header nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.app-header nav a:hover {
background-color: rgba(255,255,255,0.2);
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Authentication Forms */
.login-container, .register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.login-form, .register-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.login-form h2, .register-form h2 {
text-align: center;
margin-bottom: 2rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.login-button, .register-button {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.login-button:hover, .register-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.login-button:disabled, .register-button:disabled {
background: #ccc;
transform: none;
cursor: not-allowed;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #f5c6cb;
}
.auth-links {
text-align: center;
margin-top: 2rem;
color: #666;
}
.auth-links a {
color: #667eea;
text-decoration: none;
}
.auth-links a:hover {
text-decoration: underline;
}
.logout-button {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background: rgba(255,255,255,0.3);
}
/* Calendar View */
.calendar-view {
height: calc(100vh - 140px); /* Full height minus header and padding */
display: flex;
flex-direction: column;
}
.calendar-loading, .calendar-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.calendar-loading p {
font-size: 1.2rem;
color: #666;
}
.calendar-error p {
font-size: 1.2rem;
color: #d32f2f;
}
/* Calendar Component */
.calendar {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.month-year {
font-size: 1.8rem;
font-weight: 600;
margin: 0;
}
.nav-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 1.5rem;
font-weight: bold;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.nav-button:hover {
background: rgba(255,255,255,0.3);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
flex: 1;
background: white;
}
.weekday-header {
background: #f8f9fa;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #666;
border-bottom: 1px solid #e9ecef;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-day {
border: 1px solid #f0f0f0;
padding: 0.75rem;
min-height: 100px;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.calendar-day:hover {
background-color: #f8f9ff;
}
.calendar-day.current-month {
background: white;
}
.calendar-day.prev-month,
.calendar-day.next-month {
background: #fafafa;
color: #ccc;
}
.calendar-day.today {
background: #e3f2fd;
border: 2px solid #2196f3;
}
.calendar-day.has-events {
background: #fff3e0;
}
.calendar-day.today.has-events {
background: #e1f5fe;
}
.calendar-day.selected {
background: #e8f5e8;
border: 2px solid #4caf50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
}
.calendar-day.selected.has-events {
background: #f1f8e9;
}
.calendar-day.selected.today {
background: #e0f2f1;
border: 2px solid #4caf50;
}
.calendar-day.selected .day-number {
color: #2e7d32;
font-weight: 700;
}
.day-number {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.calendar-day.today .day-number {
color: #1976d2;
}
.event-indicators {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-box {
background: #2196f3;
color: white;
padding: 2px 4px;
border-radius: 3px;
font-size: 0.7rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.2s;
}
.event-box:hover {
background: #1976d2;
}
.event-box.refreshing {
background: #ff9800;
animation: pulse 1.5s ease-in-out infinite alternate;
}
@keyframes pulse {
0% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.event-dot {
background: #ff9800;
height: 6px;
border-radius: 3px;
margin-bottom: 1px;
}
.more-events {
font-size: 0.7rem;
color: #666;
margin-top: 2px;
font-weight: 500;
}
/* Event Modal Styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-content {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
animation: modalAppear 0.2s ease-out;
}
@keyframes modalAppear {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid #e9ecef;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 1.4rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.8rem;
color: #999;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
line-height: 1;
}
.modal-close:hover {
background: #f8f9fa;
color: #666;
transform: scale(1.1);
}
.modal-body {
padding: 1.5rem 2rem 2rem;
}
.event-detail {
display: grid;
grid-template-columns: 100px 1fr;
gap: 1rem;
margin-bottom: 1rem;
align-items: start;
}
.event-detail strong {
color: #555;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.event-detail span {
color: #333;
font-size: 1rem;
line-height: 1.5;
word-break: break-word;
}
/* Responsive Design */
@media (max-width: 768px) {
.calendar-header {
padding: 1rem;
}
.month-year {
font-size: 1.4rem;
}
.nav-button {
width: 35px;
height: 35px;
font-size: 1.2rem;
}
.weekday-header {
padding: 0.5rem;
font-size: 0.8rem;
}
.calendar-day {
min-height: 70px;
padding: 0.5rem;
}
.day-number {
font-size: 1rem;
}
.app-main {
padding: 1rem;
}
.calendar-view {
height: calc(100vh - 120px);
}
}
@media (max-width: 480px) {
.calendar-day {
min-height: 60px;
padding: 0.25rem;
}
.weekday-header {
padding: 0.5rem 0.25rem;
}
.day-number {
font-size: 0.9rem;
}
}
/* Mobile adjustments for modal */
@media (max-width: 768px) {
.modal-content {
margin: 1rem;
width: calc(100% - 2rem);
}
.modal-header, .modal-body {
padding: 1rem 1.5rem;
}
.event-detail {
grid-template-columns: 80px 1fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.event-detail strong {
font-size: 0.8rem;
}
.event-detail span {
font-size: 0.9rem;
}
.app-header {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.app-header nav {
margin-top: 1rem;
}
.app-main {
padding: 1rem;
}
.login-form, .register-form {
padding: 1.5rem;
}
}

1
test_backend_url.js Normal file
View File

@@ -0,0 +1 @@
console.log("Backend URL test");