17 Commits

Author SHA1 Message Date
Connor Johnstone
ca1ca0c3b1 Implement comprehensive theme system with FontAwesome icons
- Add comprehensive CSS custom properties for all theme colors
- Include modal, button, input, text, and background color variables
- Enhance dark theme with complete variable overrides for proper contrast
- Replace hardcoded colors in print-preview.css with theme variables
- Add FontAwesome CDN integration and replace emoji icons
- Create minimalistic glass-effect checkbox styling with transparency
- Fix white-on-white text issue in dark theme across all modals

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 14:55:07 -04:00
Connor Johnstone
64dbf65beb Fix event positioning in print copy with dynamic base-unit recalculation
- Measure actual print copy div height after aspect-ratio scaling
- Recalculate base-unit based on measured height vs original 720px assumption
- Apply position scaling to .week-event elements in print copy only
- Parse and recalculate top/height pixel values using scale factor
- Add landscape orientation and fit-to-page CSS hints for better printing
- Preserve hour filtering with proper data attributes and CSS variables

This ensures events display correctly when print copy is scaled to fit page
while maintaining proper aspect ratio and hour range filtering.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 14:10:43 -04:00
Connor Johnstone
96585440d1 Implement hybrid print preview with CSS-based approach
- Add hidden print-preview-copy div at app level for clean print isolation
- Use @media print rules to show only print copy (960x720) in landscape
- Auto-sync print copy with preview content on modal render via use_effect
- Copy CSS variables and data attributes for proper hour filtering
- Set explicit dimensions matching print-preview-paper content area
- Force landscape orientation with @page rule for better calendar printing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 13:17:21 -04:00
Connor Johnstone
a297d38276 Implement selective print preview content printing with dynamic restoration
- Replace page body with only .print-preview-content div during printing
- Use visibilitychange and focus events to restore original content when print dialog closes
- Add 100ms delay before print dialog to show content replacement briefly
- Remove debug logging for clean production code
- Ensure print output matches preview exactly by sending only preview content to system print dialog

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 10:59:42 -04:00
Connor Johnstone
4fdaa9931d Fix print preview event positioning to respond to hour range changes
- Add print_start_hour parameter to calculate_event_position function
- Implement proper hour offset calculation for events in print mode
- Remove CSS transform hacks for event positioning
- Use dynamic pixels_per_hour for proper scaling with hour ranges
- Increase modal max-width to 1600px for better visibility
- Events now correctly reposition when start/end hours change

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 20:38:56 -04:00
Connor Johnstone
c6c7b38bef Implement dynamic base unit calculation for print preview scaling
- Add dynamic height calculation system based on selected hour range and time increment
- Replace hardcoded CSS heights with CSS variables (--print-base-unit, --print-pixels-per-hour)
- Update WeekView component with print mode support and dynamic event positioning
- Optimize week header for print: reduced to 50px height with smaller fonts
- Account for all borders and spacing in calculation (660px available content height)
- Remove debug styling (blue borders, yellow backgrounds)
- Ensure time slots, time labels, and events scale together proportionally
- Perfect fit within 720px content area regardless of hour selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:26:53 -04:00
Connor Johnstone
78db2cc00f Fix print preview paper dimensions and cleanup redundant CSS files
- Change print preview to landscape orientation (11" x 8.5")
- Fix paper div to render at exact 1056x816 pixels
- Add 48px padding (0.5 inches) directly to paper div
- Remove CSS file redundancy: deleted styles.css.backup, styles/base.css, styles/default.css
- Improve modal sizing to accommodate paper dimensions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 18:53:40 -04:00
Connor Johnstone
73d191c5ca Merge branch 'main' of git.rcjohnstone.com:connor/calendar into print-preview-feature 2025-09-11 18:01:56 -04:00
d930468748 Merge pull request 'Small bugfixes on the external calendar handling' (#19) from bugfix/external_cal_misc into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 4m3s
Reviewed-on: #19
2025-09-11 18:01:00 -04:00
Connor Johnstone
91be4436a9 Fix external calendar creation and Outlook compatibility issues
- Fix external calendar form validation by replacing node refs with controlled state inputs
- Add multiple user-agent fallback approach for better external calendar compatibility
- Enhance HTTP client configuration with proper redirect handling and timeouts
- Add detailed error logging and Outlook-specific error detection
- Improve request headers for better calendar server compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 17:58:53 -04:00
Connor Johnstone
4cbc495c48 Add print preview modal with hour range selection
Implements comprehensive print functionality for calendar views:
- Print preview modal with live preview and zoom controls
- Hour range selection for week view (start/end hours)
- Print-specific CSS to hide UI elements and optimize layout
- Event repositioning to align with visible time labels
- Support for both 30-minute and 15-minute time increments
- Mouse wheel zoom functionality in preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 14:55:18 -04:00
Connor Johnstone
927cd7d2bb Add color picker functionality to external calendars
- Enable clicking external calendar color icons to open color picker dropdown
- Implement backend API integration for updating external calendar colors
- Add conditional hover effects to prevent interference with color picker
- Use extremely high z-index (999999) to ensure dropdown appears above all elements
- Match existing CalDAV calendar color picker behavior and styling
- Support real-time color updates with immediate visual feedback
- Maintain color consistency across sidebar and calendar events

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 12:17:09 -04:00
Connor Johnstone
38b22287c7 Unify hover behavior across all sidebar selectors
All checks were successful
Build and Push Docker Image / docker (push) Successful in 31s
- Update view-selector-dropdown hover to match theme/style selectors
- Change from var(--glass-bg-light) to rgba(255, 255, 255, 0.15)
- Ensures consistent glassmorphism hover effects throughout sidebar
- Provides cohesive user experience across Week/Month, Theme, and Style dropdowns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 12:04:11 -04:00
Connor Johnstone
0de2eee626 Fix calendar management modal color picker issues
- Fix z-index issue by creating separate CSS classes for inline vs dropdown color pickers
- Unify CalDAV and external calendar color pickers to use same grid interface
- Improve color picker styling with 4x4 grid layout for 16 colors
- Enhance color option appearance with proper border centering and sizing
- Replace native HTML color input with consistent predefined color grid
- Add visual improvements: larger swatches, better hover effects, checkmark selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 11:58:35 -04:00
Connor Johnstone
aa7a15e6fa Implement tabbed calendar management modal with improved styling
- Replace separate Create Calendar and External Calendar modals with unified tabbed interface
- Redesign modal styling with less rounded corners and cleaner appearance
- Significantly increase padding throughout modal components for better spacing
- Fix CSS variable self-references (control-padding, standard-transition)
- Improve button styling with better padding (0.875rem 2rem) and colors
- Enhance form elements with generous padding (1rem) and improved focus states
- Redesign tab bar with segmented control appearance and proper active states
- Update context menus with modern glassmorphism styling and smooth animations
- Consolidate calendar management functionality into single reusable component

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 11:46:21 -04:00
Connor Johnstone
b0a8ef09a8 Major CSS cleanup and mobile detection system
CSS Improvements:
- Remove all mobile responsive CSS (@media queries) - 348+ lines removed
- Add comprehensive CSS variables for glass effects, control dimensions, transitions
- Consolidate duplicate patterns (43+ transition, 37+ border-radius, 61+ padding instances)
- Remove legacy week grid CSS section
- Reduce total CSS from 4,197 to 3,828 lines (8.8% reduction)

Sidebar Enhancements:
- Remove unused sidebar-nav div and navigation link
- Standardize all dropdown controls to consistent 40px height and styling
- Reduce calendar item padding from 0.75rem to 0.5rem for more compact display
- Unify theme-selector and style-selector styling with view-selector

Mobile Detection:
- Add MobileWarningModal component with device detection
- Show helpful popup directing mobile users to native CalDAV apps
- Add Navigator and DomTokenList web-sys features
- Desktop-focused experience with appropriate mobile guidance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 10:58:47 -04:00
Connor Johnstone
efbaea5ac1 Add current time indicator to week view
- Add real-time current time indicator that updates every 5 seconds
- Display horizontal line with dot and time label on current day only
- Position indicator accurately based on time increment mode (15/30 min)
- Use theme-aware colors with subdued gray styling for dark mode
- Include subtle shadows and proper z-indexing for visibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 10:33:07 -04:00
19 changed files with 3127 additions and 7810 deletions

View File

@@ -78,17 +78,75 @@ pub async fn fetch_external_calendar_events(
// If not fetched from cache, get from external URL
if !fetched_from_cache {
let client = Client::new();
let response = client
.get(&calendar.url)
.send()
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?;
if !response.status().is_success() {
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
// Log the URL being fetched for debugging
println!("🌍 Fetching calendar URL: {}", calendar.url);
let user_agents = vec![
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
];
let mut response = None;
let mut last_error = None;
// Try different user agents
for (i, ua) in user_agents.iter().enumerate() {
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
let client = Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.timeout(std::time::Duration::from_secs(30))
.user_agent(*ua)
.build()
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
let result = client
.get(&calendar.url)
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
.header("Accept-Charset", "utf-8")
.header("Cache-Control", "no-cache")
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
println!("📡 Response status: {}", status);
if status.is_success() {
response = Some(resp);
break;
} else if status == 400 {
// Check if this is an Outlook auth error
let error_body = resp.text().await.unwrap_or_default();
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
println!("🚫 Outlook authentication error detected, trying next approach...");
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
continue;
}
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
} else {
last_error = Some(format!("HTTP {}", status));
}
}
Err(e) => {
println!("❌ Request failed: {}", e);
last_error = Some(format!("Request error: {}", e));
}
}
}
let response = response.ok_or_else(|| {
ApiError::Internal(format!(
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
user_agents.len(),
last_error.unwrap_or("Unknown error".to_string())
))
})?;
// Response is guaranteed to be successful here since we checked in the loop
println!("✅ Successfully fetched calendar data");
ics_content = response
.text()
.await

View File

@@ -22,12 +22,16 @@ web-sys = { version = "0.3", features = [
"Document",
"Window",
"Location",
"Navigator",
"DomTokenList",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
"CssStyleDeclaration",
"MediaQueryList",
"MediaQueryListEvent",
] }
wasm-bindgen = "0.2"
js-sys = "0.3"

View File

@@ -6,7 +6,7 @@ dist = "dist"
BACKEND_API_URL = "http://localhost:3000/api"
[watch]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
ignore = ["../backend/", "../target/"]
[serve]

View File

@@ -6,8 +6,10 @@
<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="css" href="print-preview.css">
<link data-trunk rel="copy-file" href="styles/google.css">
<link data-trunk rel="icon" href="favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<script>

1215
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
use crate::components::{
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
EditAction, EventContextMenu, EventModal, EventCreationData, ExternalCalendarModal, RouteHandler,
Sidebar, Theme, ViewMode,
CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction,
EditAction, EventContextMenu, EventModal, EventCreationData,
MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode,
};
use crate::components::mobile_warning_modal::is_mobile_device;
use crate::components::sidebar::{Style};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
@@ -94,7 +95,7 @@ pub fn App() -> Html {
let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None });
let create_modal_open = use_state(|| false);
let calendar_management_modal_open = use_state(|| false);
let context_menu_open = use_state(|| false);
let context_menu_pos = use_state(|| (0i32, 0i32));
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
@@ -117,7 +118,9 @@ pub fn App() -> Html {
// External calendar state
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
let external_calendar_modal_open = use_state(|| false);
// Mobile warning state
let mobile_warning_open = use_state(|| is_mobile_device());
let refresh_interval = use_state(|| -> Option<Interval> { None });
// Calendar view state - load from localStorage if available
@@ -274,6 +277,13 @@ pub fn App() -> Html {
})
};
let on_mobile_warning_close = {
let mobile_warning_open = mobile_warning_open.clone();
Callback::from(move |_| {
mobile_warning_open.set(false);
})
};
let on_view_change = {
let current_view = current_view.clone();
Callback::from(move |new_view: ViewMode| {
@@ -557,19 +567,60 @@ pub fn App() -> Html {
let on_color_change = {
let user_info = user_info.clone();
let external_calendars = external_calendars.clone();
let color_picker_open = color_picker_open.clone();
Callback::from(move |(calendar_path, color): (String, String)| {
if let Some(mut info) = (*user_info).clone() {
for calendar in &mut info.calendars {
if calendar.path == calendar_path {
calendar.color = color.clone();
break;
}
if calendar_path.starts_with("external_") {
// Handle external calendar color change
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
let external_calendars = external_calendars.clone();
let color = color.clone();
wasm_bindgen_futures::spawn_local(async move {
// Find the external calendar to get its current details
if let Some(cal) = (*external_calendars).iter().find(|c| c.id == id_str) {
match CalendarService::update_external_calendar(
id_str,
&cal.name,
&cal.url,
&color,
cal.is_visible,
).await {
Ok(_) => {
// Update the local state
let mut updated_calendars = (*external_calendars).clone();
for calendar in &mut updated_calendars {
if calendar.id == id_str {
calendar.color = color.clone();
break;
}
}
external_calendars.set(updated_calendars);
// No need to refresh events - they will automatically pick up the new color
// from the calendar when rendered since they use the same calendar_path matching
}
Err(e) => {
web_sys::console::error_1(&format!("Failed to update external calendar color: {}", e).into());
}
}
}
});
}
user_info.set(Some(info.clone()));
} else {
// Handle CalDAV calendar color change (existing logic)
if let Some(mut info) = (*user_info).clone() {
for calendar in &mut info.calendars {
if calendar.path == calendar_path {
calendar.color = color.clone();
break;
}
}
user_info.set(Some(info.clone()));
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
}
}
}
color_picker_open.set(None);
@@ -1157,13 +1208,9 @@ pub fn App() -> Html {
<Sidebar
user_info={(*user_info).clone()}
on_logout={on_logout}
on_create_calendar={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(true)
})}
on_create_external_calendar={Callback::from({
let external_calendar_modal_open = external_calendar_modal_open.clone();
move |_| external_calendar_modal_open.set(true)
on_add_calendar={Callback::from({
let calendar_management_modal_open = calendar_management_modal_open.clone();
move |_| calendar_management_modal_open.set(true)
})}
external_calendars={(*external_calendars).clone()}
on_external_calendar_toggle={Callback::from({
@@ -1375,20 +1422,20 @@ pub fn App() -> Html {
}
}
<CreateCalendarModal
is_open={*create_modal_open}
<CalendarManagementModal
is_open={*calendar_management_modal_open}
on_close={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(false)
let calendar_management_modal_open = calendar_management_modal_open.clone();
move |_| calendar_management_modal_open.set(false)
})}
on_create={Callback::from({
on_create_calendar={Callback::from({
let auth_token = auth_token.clone();
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
let calendar_management_modal_open = calendar_management_modal_open.clone();
move |(name, description, color): (String, Option<String>, Option<String>)| {
if let Some(token) = (*auth_token).clone() {
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
let calendar_management_modal_open = calendar_management_modal_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
@@ -1407,17 +1454,41 @@ pub fn App() -> Html {
Ok(_) => {
web_sys::console::log_1(&"Calendar created successfully!".into());
refresh_calendars.emit(());
create_modal_open.set(false);
calendar_management_modal_open.set(false);
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
create_modal_open.set(false);
calendar_management_modal_open.set(false);
}
}
});
}
}
})}
on_external_success={Callback::from({
let external_calendars = external_calendars.clone();
let calendar_management_modal_open = calendar_management_modal_open.clone();
move |new_id: i32| {
// Refresh external calendars list
let external_calendars = external_calendars.clone();
let calendar_management_modal_open = calendar_management_modal_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
match CalendarService::get_external_calendars().await {
Ok(calendars) => {
external_calendars.set(calendars);
calendar_management_modal_open.set(false);
web_sys::console::log_1(&format!("External calendar {} added successfully!", new_id).into());
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to refresh external calendars: {}", err).into());
calendar_management_modal_open.set(false);
}
}
});
}
})}
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
/>
@@ -1612,58 +1683,6 @@ pub fn App() -> Html {
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
/>
<ExternalCalendarModal
is_open={*external_calendar_modal_open}
on_close={Callback::from({
let external_calendar_modal_open = external_calendar_modal_open.clone();
move |_| external_calendar_modal_open.set(false)
})}
on_success={Callback::from({
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
move |new_calendar_id: i32| {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
wasm_bindgen_futures::spawn_local(async move {
// First, refresh the calendar list to get the new calendar
match CalendarService::get_external_calendars().await {
Ok(calendars) => {
external_calendars.set(calendars.clone());
// Then immediately fetch events for the new calendar if it's visible
if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) {
if new_calendar.is_visible {
match CalendarService::fetch_external_calendar_events(new_calendar_id).await {
Ok(mut events) => {
// Set calendar_path for color matching
for event in &mut events {
event.calendar_path = Some(format!("external_{}", new_calendar_id));
}
// Add the new calendar's events to existing events
let mut all_events = (*external_calendar_events).clone();
all_events.extend(events);
external_calendar_events.set(all_events);
}
Err(e) => {
web_sys::console::log_1(
&format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(),
);
}
}
}
}
}
Err(err) => {
web_sys::console::log_1(
&format!("Failed to refresh calendars after creation: {}", err).into(),
);
}
}
});
}
})}
/>
<EventModal
event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }}
@@ -1676,7 +1695,16 @@ pub fn App() -> Html {
}
})}
/>
// Mobile warning modal
<MobileWarningModal
is_open={*mobile_warning_open}
on_close={on_mobile_warning_close}
/>
</div>
// Hidden print copy that gets shown only during printing
<div id="print-preview-copy" class="print-preview-paper" style="display: none;"></div>
</BrowserRouter>
}
}

View File

@@ -1,5 +1,5 @@
use crate::components::{
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
@@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
};
// Handle print calendar preview
let show_print_preview = use_state(|| false);
let on_print = {
let show_print_preview = show_print_preview.clone();
Callback::from(move |_: MouseEvent| {
show_print_preview.set(true);
})
};
// Handle drag-to-create event
let on_create_event = {
let show_create_modal = show_create_modal.clone();
@@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
on_print={Some(on_print)}
/>
{
@@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
}}
/>
// Print preview modal
{
if *show_print_preview {
html! {
<PrintPreviewModal
on_close={{
let show_print_preview = show_print_preview.clone();
Callback::from(move |_| {
show_print_preview.set(false);
})
}}
view_mode={props.view.clone()}
current_date={*current_date}
selected_date={*selected_date}
events={(*events).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={*time_increment}
today={today}
/>
}
} else {
html! {}
}
}
</div>
}
}

View File

@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
pub time_increment: Option<u32>,
#[prop_or_default]
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
#[prop_or_default]
pub on_print: Option<Callback<MouseEvent>>,
}
#[function_component(CalendarHeader)]
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
html! {}
}
}
{
if let Some(print_callback) = &props.on_print {
html! {
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
<i class="fas fa-print"></i>
</button>
}
} else {
html! {}
}
}
</div>
<h2 class="month-year">{title}</h2>
<div class="header-right">

View File

@@ -55,7 +55,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
{
if props.color_picker_open {
html! {
<div class="color-picker">
<div class="color-picker-dropdown">
{
props.available_colors.iter().map(|color| {
let color_str = color.clone();

View File

@@ -0,0 +1,449 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use crate::services::calendar_service::CalendarService;
#[derive(Clone, PartialEq)]
pub enum CalendarTab {
Create,
External,
}
#[derive(Properties, PartialEq)]
pub struct CalendarManagementModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
pub available_colors: Vec<String>,
}
#[function_component(CalendarManagementModal)]
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
let active_tab = use_state(|| CalendarTab::Create);
// Create Calendar state
let calendar_name = use_state(|| String::new());
let description = use_state(|| String::new());
let selected_color = use_state(|| None::<String>);
let create_error_message = use_state(|| None::<String>);
let is_creating = use_state(|| false);
// External Calendar state
let external_name = use_state(|| String::new());
let external_url = use_state(|| String::new());
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
let external_is_loading = use_state(|| false);
let external_error_message = use_state(|| None::<String>);
// Reset state when modal opens
use_effect_with(props.is_open, {
let calendar_name = calendar_name.clone();
let description = description.clone();
let selected_color = selected_color.clone();
let create_error_message = create_error_message.clone();
let is_creating = is_creating.clone();
let external_name = external_name.clone();
let external_url = external_url.clone();
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let external_selected_color = external_selected_color.clone();
let active_tab = active_tab.clone();
move |is_open| {
if *is_open {
// Reset all state when modal opens
calendar_name.set(String::new());
description.set(String::new());
selected_color.set(None);
create_error_message.set(None);
is_creating.set(false);
external_name.set(String::new());
external_url.set(String::new());
external_is_loading.set(false);
external_error_message.set(None);
external_selected_color.set(Some("#4285f4".to_string()));
active_tab.set(CalendarTab::Create);
}
}
});
let on_tab_click = {
let active_tab = active_tab.clone();
Callback::from(move |tab: CalendarTab| {
active_tab.set(tab);
})
};
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if let Some(target) = e.target() {
let element = target.dyn_into::<web_sys::Element>().unwrap();
if element.class_list().contains("modal-backdrop") {
on_close.emit(());
}
}
})
};
// Create Calendar handlers
let on_name_change = {
let calendar_name = calendar_name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
calendar_name.set(input.value());
})
};
let on_description_change = {
let description = description.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
description.set(input.value());
})
};
let on_color_select = {
let selected_color = selected_color.clone();
Callback::from(move |color: String| {
selected_color.set(Some(color));
})
};
let on_external_color_select = {
let external_selected_color = external_selected_color.clone();
Callback::from(move |color: String| {
external_selected_color.set(Some(color));
})
};
let on_create_submit = {
let calendar_name = calendar_name.clone();
let description = description.clone();
let selected_color = selected_color.clone();
let create_error_message = create_error_message.clone();
let is_creating = is_creating.clone();
let on_create_calendar = props.on_create_calendar.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = (*calendar_name).trim();
if name.is_empty() {
create_error_message.set(Some("Calendar name is required".to_string()));
return;
}
is_creating.set(true);
create_error_message.set(None);
let desc = if description.is_empty() {
None
} else {
Some((*description).clone())
};
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
})
};
// External Calendar handlers
let on_external_submit = {
let external_name = external_name.clone();
let external_url = external_url.clone();
let external_selected_color = external_selected_color.clone();
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let on_close = props.on_close.clone();
let on_external_success = props.on_external_success.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = (*external_name).trim().to_string();
let url = (*external_url).trim().to_string();
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
// Debug logging to understand the issue
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
if name.is_empty() {
external_error_message.set(Some("Calendar name is required".to_string()));
web_sys::console::log_1(&"Validation failed: empty name".into());
return;
}
if url.is_empty() {
external_error_message.set(Some("Calendar URL is required".to_string()));
web_sys::console::log_1(&"Validation failed: empty URL".into());
return;
}
// Basic URL validation
if !url.starts_with("http://") && !url.starts_with("https://") {
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
return;
}
external_is_loading.set(true);
external_error_message.set(None);
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let on_close = on_close.clone();
let on_external_success = on_external_success.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
match CalendarService::create_external_calendar(&name, &url, &color).await {
Ok(calendar) => {
external_is_loading.set(false);
on_close.emit(());
on_external_success.emit(calendar.id);
}
Err(e) => {
external_is_loading.set(false);
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
}
}
});
})
};
// External input change handlers
let on_external_name_change = {
let external_name = external_name.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
external_name.set(input.value());
}
})
};
let on_external_url_change = {
let external_url = external_url.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
external_url.set(input.value());
}
})
};
if !props.is_open {
return html! {};
}
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content calendar-management-modal">
<div class="modal-header">
<h2>{"Add Calendar"}</h2>
<button class="modal-close" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>{"×"}</button>
</div>
<div class="calendar-management-tabs">
<button
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
onclick={
let on_tab_click = on_tab_click.clone();
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
}
>
{"Create Calendar"}
</button>
<button
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
onclick={
let on_tab_click = on_tab_click.clone();
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
}
>
{"Add External"}
</button>
</div>
<div class="modal-body">
{
match *active_tab {
CalendarTab::Create => html! {
<form onsubmit={on_create_submit}>
<div class="form-group">
<label for="calendar-name">{"Calendar Name"}</label>
<input
type="text"
id="calendar-name"
value={(*calendar_name).clone()}
oninput={on_name_change}
placeholder="Enter calendar name"
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label for="calendar-description">{"Description (optional)"}</label>
<textarea
id="calendar-description"
value={(*description).clone()}
oninput={on_description_change}
placeholder="Enter calendar description"
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-picker">
{
props.available_colors.iter().map(|color| {
let is_selected = selected_color.as_ref() == Some(color);
let color_clone = color.clone();
let on_color_select = on_color_select.clone();
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={Callback::from(move |_: MouseEvent| {
on_color_select.emit(color_clone.clone());
})}
/>
}
}).collect::<Html>()
}
</div>
</div>
{
if let Some(ref error) = *create_error_message {
html! {
<div class="error-message">
{error}
</div>
}
} else {
html! {}
}
}
<div class="modal-footer">
<button
type="button"
class="cancel-button"
onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}
disabled={*is_creating}
>
{"Cancel"}
</button>
<button
type="submit"
class="create-button"
disabled={*is_creating}
>
{if *is_creating { "Creating..." } else { "Create Calendar" }}
</button>
</div>
</form>
},
CalendarTab::External => html! {
<form onsubmit={on_external_submit}>
<div class="form-group">
<label for="external-name">{"Calendar Name"}</label>
<input
type="text"
id="external-name"
value={(*external_name).clone()}
onchange={on_external_name_change}
placeholder="Enter calendar name"
disabled={*external_is_loading}
/>
</div>
<div class="form-group">
<label for="external-url">{"Calendar URL"}</label>
<input
type="url"
id="external-url"
value={(*external_url).clone()}
onchange={on_external_url_change}
placeholder="https://example.com/calendar.ics"
disabled={*external_is_loading}
/>
<small class="help-text">
{"Enter the ICS/CalDAV URL for your external calendar"}
</small>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-picker">
{
props.available_colors.iter().map(|color| {
let is_selected = external_selected_color.as_ref() == Some(color);
let color_clone = color.clone();
let on_external_color_select = on_external_color_select.clone();
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={Callback::from(move |_: MouseEvent| {
on_external_color_select.emit(color_clone.clone());
})}
/>
}
}).collect::<Html>()
}
</div>
</div>
{
if let Some(ref error) = *external_error_message {
html! {
<div class="error-message">
{error}
</div>
}
} else {
html! {}
}
}
<div class="modal-footer">
<button
type="button"
class="cancel-button"
onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}
disabled={*external_is_loading}
>
{"Cancel"}
</button>
<button
type="submit"
class="create-button"
disabled={*external_is_loading}
>
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
</button>
</div>
</form>
}
}
}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,96 @@
use yew::prelude::*;
use web_sys::window;
use wasm_bindgen::JsCast;
#[derive(Properties, PartialEq)]
pub struct MobileWarningModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
}
#[function_component(MobileWarningModal)]
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if let Some(target) = e.target() {
let element = target.dyn_into::<web_sys::Element>().unwrap();
if element.class_list().contains("modal-overlay") {
on_close.emit(());
}
}
})
};
if !props.is_open {
return html! {};
}
html! {
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
<div class="modal-content mobile-warning-modal">
<div class="modal-header">
<h2>{"Desktop Application"}</h2>
<button class="modal-close" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>{"×"}</button>
</div>
<div class="modal-body">
<div class="mobile-warning-icon">
{"💻"}
</div>
<p class="mobile-warning-title">
{"This calendar application is designed for desktop usage"}
</p>
<p class="mobile-warning-description">
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
</p>
<ul class="mobile-warning-apps">
<li>
<strong>{"iOS:"}</strong>
{" Calendar (built-in), Calendars 5, Fantastical"}
</li>
<li>
<strong>{"Android:"}</strong>
{" Google Calendar, DAVx5, CalDAV Sync"}
</li>
</ul>
<p class="mobile-warning-note">
{"These apps can sync with the same CalDAV server you're using here."}
</p>
</div>
<div class="modal-footer">
<button class="continue-anyway-button" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>
{"Continue Anyway"}
</button>
</div>
</div>
</div>
}
}
// Helper function to detect mobile devices
pub fn is_mobile_device() -> bool {
if let Some(window) = window() {
let navigator = window.navigator();
let user_agent = navigator.user_agent().unwrap_or_default();
let user_agent = user_agent.to_lowercase();
// Check for mobile device indicators
user_agent.contains("mobile")
|| user_agent.contains("android")
|| user_agent.contains("iphone")
|| user_agent.contains("ipad")
|| user_agent.contains("ipod")
|| user_agent.contains("blackberry")
|| user_agent.contains("webos")
|| user_agent.contains("opera mini")
|| user_agent.contains("windows phone")
} else {
false
}
}

View File

@@ -1,5 +1,6 @@
pub mod calendar;
pub mod calendar_context_menu;
pub mod calendar_management_modal;
pub mod calendar_header;
pub mod calendar_list_item;
pub mod context_menu;
@@ -10,7 +11,9 @@ pub mod event_form;
pub mod event_modal;
pub mod external_calendar_modal;
pub mod login;
pub mod mobile_warning_modal;
pub mod month_view;
pub mod print_preview_modal;
pub mod recurring_edit_modal;
pub mod route_handler;
pub mod sidebar;
@@ -18,18 +21,19 @@ pub mod week_view;
pub use calendar::Calendar;
pub use calendar_context_menu::CalendarContextMenu;
pub use calendar_management_modal::CalendarManagementModal;
pub use calendar_header::CalendarHeader;
pub use calendar_list_item::CalendarListItem;
pub use context_menu::ContextMenu;
pub use create_calendar_modal::CreateCalendarModal;
pub use create_event_modal::CreateEventModal;
// Re-export event form types for backwards compatibility
pub use event_form::EventCreationData;
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
pub use event_modal::EventModal;
pub use external_calendar_modal::ExternalCalendarModal;
pub use login::Login;
pub use mobile_warning_modal::MobileWarningModal;
pub use month_view::MonthView;
pub use print_preview_modal::PrintPreviewModal;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
pub use route_handler::RouteHandler;
pub use sidebar::{Sidebar, Theme, ViewMode};

View File

@@ -0,0 +1,362 @@
use crate::components::{ViewMode, WeekView, MonthView};
use crate::models::ical::VEvent;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::NaiveDate;
use std::collections::HashMap;
use wasm_bindgen::{closure::Closure, JsCast};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct PrintPreviewModalProps {
pub on_close: Callback<()>,
pub view_mode: ViewMode,
pub current_date: NaiveDate,
pub selected_date: NaiveDate,
pub events: HashMap<NaiveDate, Vec<VEvent>>,
pub user_info: Option<UserInfo>,
pub external_calendars: Vec<ExternalCalendar>,
pub time_increment: u32,
pub today: NaiveDate,
}
#[function_component]
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
let start_hour = use_state(|| 6u32);
let end_hour = use_state(|| 22u32);
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
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(());
}
})
};
let on_start_hour_change = {
let start_hour = start_hour.clone();
let end_hour = end_hour.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
if let Some(select) = target {
if let Ok(hour) = select.value().parse::<u32>() {
if hour < *end_hour {
start_hour.set(hour);
}
}
}
})
};
let on_end_hour_change = {
let start_hour = start_hour.clone();
let end_hour = end_hour.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
if let Some(select) = target {
if let Ok(hour) = select.value().parse::<u32>() {
if hour > *start_hour && hour <= 24 {
end_hour.set(hour);
}
}
}
})
};
let format_hour = |hour: u32| -> String {
if hour == 0 {
"12 AM".to_string()
} else if hour < 12 {
format!("{} AM", hour)
} else if hour == 12 {
"12 PM".to_string()
} else {
format!("{} PM", hour - 12)
}
};
// Calculate dynamic base unit for print preview
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
let visible_hours = (end_hour - start_hour) as f64;
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
let header_height = 50.0; // Fixed week header height in print preview
let header_border = 2.0; // Week header bottom border (2px solid)
let container_spacing = 8.0; // Additional container spacing/margins
let total_overhead = header_height + header_border + container_spacing;
let available_height = 720.0 - total_overhead; // Available for time content
let base_unit = available_height / (visible_hours * slots_per_hour);
let pixels_per_hour = base_unit * slots_per_hour;
(base_unit, pixels_per_hour, available_height)
};
// Calculate print dimensions for the current hour range
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
// Effect to update print copy whenever modal renders or content changes
{
let start_hour = *start_hour;
let end_hour = *end_hour;
let time_increment = props.time_increment;
let original_base_unit = base_unit;
use_effect(move || {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Set CSS variables on document root
if let Some(document_element) = document.document_element() {
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
let style = html_element.style();
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
}
}
// Copy content from print-preview-content to the hidden print-preview-copy div
let copy_content = move || {
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
// Clone the preview content
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
// Clear the print copy div and add the cloned content
print_copy.set_inner_html("");
let _ = print_copy.append_child(&content_clone);
// Get the actual rendered height of the print copy div and recalculate base-unit
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
// Temporarily make visible to measure height, then hide again
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
let _ = print_copy_html.style().set_property("display", "block");
let _ = print_copy_html.style().set_property("visibility", "hidden");
let _ = print_copy_html.style().set_property("position", "absolute");
let _ = print_copy_html.style().set_property("top", "-9999px");
// Now measure the height
let actual_height = print_copy_html.client_height() as f64;
// Restore original display
let _ = print_copy_html.style().set_property("display", &original_display);
let _ = print_copy_html.style().remove_property("visibility");
let _ = print_copy_html.style().remove_property("position");
let _ = print_copy_html.style().remove_property("top");
// Recalculate base-unit and pixels-per-hour based on actual height
let visible_hours = (end_hour - start_hour) as f64;
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
let header_height = 50.0;
let header_border = 2.0;
let container_spacing = 8.0;
let total_overhead = header_height + header_border + container_spacing;
let available_height = actual_height - total_overhead;
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
// Set CSS variables with recalculated values
let style = print_copy_html.style();
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
// Copy data attributes
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
// Recalculate event positions using the new base-unit
let events = print_copy.query_selector_all(".week-event").unwrap();
let scale_factor = actual_base_unit / original_base_unit;
for i in 0..events.length() {
if let Some(event_element) = events.get(i) {
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
let event_style = event_html.style();
// Get current positioning values and recalculate
if let Ok(current_top) = event_style.get_property_value("top") {
if current_top.ends_with("px") {
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
let new_top = top_px * scale_factor;
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
}
}
}
if let Ok(current_height) = event_style.get_property_value("height") {
if current_height.ends_with("px") {
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
let new_height = height_px * scale_factor;
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
}
}
}
}
}
}
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
}
}
}
}
};
// Copy content immediately
copy_content();
// Also set up a small delay to catch any async rendering
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
copy_callback.as_ref().unchecked_ref(),
100
);
copy_callback.forget();
}
}
|| ()
});
}
let on_print = {
Callback::from(move |_: MouseEvent| {
if let Some(window) = web_sys::window() {
// Print copy is already updated by the use_effect, just trigger print
let _ = window.print();
}
})
};
html! {
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
<div class="modal-content print-preview-modal">
<div class="modal-header">
<h3>{"Print Preview"}</h3>
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
</div>
<div class="modal-body print-preview-body">
<div class="print-preview-controls">
{
if props.view_mode == ViewMode::Week {
html! {
<>
<div class="control-group">
<label for="start-hour">{"Start Hour:"}</label>
<select id="start-hour" onchange={on_start_hour_change}>
{
(0..24).map(|hour| {
html! {
<option value={hour.to_string()} selected={hour == *start_hour}>
{format_hour(hour)}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="control-group">
<label for="end-hour">{"End Hour:"}</label>
<select id="end-hour" onchange={on_end_hour_change}>
{
(1..=24).map(|hour| {
html! {
<option value={hour.to_string()} selected={hour == *end_hour}>
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="hour-range-info">
{format!("Will print from {} to {}",
format_hour(*start_hour),
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
)}
</div>
</>
}
} else {
html! {
<div class="month-info">
{"Will print entire month view"}
</div>
}
}
}
<div class="zoom-display-info">
<label>{"Zoom: "}</label>
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
<span class="zoom-hint">{"(scroll to zoom)"}</span>
</div>
<div class="preview-actions">
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
</div>
</div>
<div class="print-preview-display" onwheel={{
let zoom_level = zoom_level.clone();
Callback::from(move |e: WheelEvent| {
e.prevent_default(); // Prevent page scroll
let delta_y = e.delta_y();
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
zoom_level.set(new_zoom);
})
}}>
<div class="print-preview-paper"
data-start-hour={start_hour.to_string()}
data-end-hour={end_hour.to_string()}
style={format!(
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
)}>
<div class="print-preview-content">
{
match props.view_mode {
ViewMode::Week => html! {
<WeekView
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
current_date={props.current_date}
today={props.today}
events={props.events.clone()}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={props.time_increment}
print_mode={true}
print_pixels_per_hour={Some(pixels_per_hour)}
print_start_hour={Some(*start_hour)}
/>
},
ViewMode::Month => html! {
<MonthView
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
current_month={props.current_date}
selected_date={Some(props.selected_date)}
today={props.today}
events={props.events.clone()}
on_day_select={None::<Callback<NaiveDate>>}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
/>
},
}
}
</div>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@@ -100,8 +100,7 @@ impl Default for ViewMode {
pub struct SidebarProps {
pub user_info: Option<UserInfo>,
pub on_logout: Callback<()>,
pub on_create_calendar: Callback<()>,
pub on_create_external_calendar: Callback<()>,
pub on_add_calendar: Callback<()>,
pub external_calendars: Vec<ExternalCalendar>,
pub on_external_calendar_toggle: Callback<i32>,
pub on_external_calendar_delete: Callback<i32>,
@@ -204,9 +203,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
}
}
</div>
<nav class="sidebar-nav">
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
</nav>
{
if let Some(ref info) = props.user_info {
if !info.calendars.is_empty() {
@@ -260,7 +256,11 @@ pub fn sidebar(props: &SidebarProps) -> Html {
html! {
<li class="external-calendar-item" style="position: relative;">
<div
class="external-calendar-info"
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
"external-calendar-info color-picker-active"
} else {
"external-calendar-info"
}}
oncontextmenu={{
let on_context_menu = on_external_calendar_context_menu.clone();
let cal_id = cal.id;
@@ -277,7 +277,48 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<span
class="external-calendar-color"
style={format!("background-color: {}", cal.color)}
/>
onclick={{
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
let external_id = format!("external_{}", cal.id);
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_color_picker_toggle.emit(external_id.clone());
})
}}
>
{
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
html! {
<div class="color-picker-dropdown">
{
props.available_colors.iter().map(|color| {
let color_str = color.clone();
let external_id = format!("external_{}", cal.id);
let on_color_change = props.on_color_change.clone();
let on_color_select = Callback::from(move |_: MouseEvent| {
on_color_change.emit((external_id.clone(), color_str.clone()));
});
let is_selected = cal.color == *color;
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={on_color_select}
/>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="external-calendar-name">{&cal.name}</span>
<div class="external-calendar-actions">
{
@@ -309,9 +350,9 @@ pub fn sidebar(props: &SidebarProps) -> Html {
>
{
if props.refreshing_calendar_id == Some(cal.id) {
"" // Loading spinner
html! { <i class="fas fa-spinner fa-spin"></i> }
} else {
"🔄" // Normal refresh icon
html! { <i class="fas fa-sync-alt"></i> }
}
}
</button>
@@ -360,12 +401,8 @@ pub fn sidebar(props: &SidebarProps) -> Html {
</div>
<div class="sidebar-footer">
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
{"+ Create Calendar"}
</button>
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
{"+ Add External Calendar"}
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
{"+ Add Calendar"}
</button>
<div class="view-selector">

View File

@@ -42,6 +42,12 @@ pub struct WeekViewProps {
pub context_menus_open: bool,
#[prop_or_default]
pub time_increment: u32,
#[prop_or_default]
pub print_mode: bool,
#[prop_or_default]
pub print_pixels_per_hour: Option<f64>,
#[prop_or_default]
pub print_start_hour: Option<u32>,
}
#[derive(Clone, PartialEq)]
@@ -81,6 +87,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
// Current time state for time indicator
let current_time = use_state(|| Local::now());
// Update current time every 5 seconds
{
let current_time = current_time.clone();
use_effect_with((), move |_| {
let interval = gloo_timers::callback::Interval::new(5_000, move || {
current_time.set(Local::now());
});
// Return the interval to keep it alive
move || drop(interval)
});
}
// Helper function to calculate current time indicator position
let calculate_current_time_position = |time_increment: u32| -> f64 {
let now = current_time.time();
let hour = now.hour() as f64;
let minute = now.minute() as f64;
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
(hour + minute / 60.0) * pixels_per_hour
};
// Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String {
if let Some(calendar_path) = &event.calendar_path {
@@ -413,13 +444,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Time labels
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
time_labels.iter().map(|time| {
time_labels.iter().enumerate().map(|(hour, time)| {
let is_quarter_mode = props.time_increment == 15;
html! {
<div class={classes!(
"time-label",
if is_quarter_mode { Some("quarter-mode") } else { None }
)}>
)} data-hour={hour.to_string()}>
{time}
</div>
}
@@ -676,10 +707,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
>
// Time slot backgrounds - 24 hour slots to represent full day
{
(0..24).map(|_hour| {
(0..24).map(|hour| {
let slots_per_hour = 60 / props.time_increment;
html! {
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
{
(0..slots_per_hour).map(|_slot| {
let slot_class = if props.time_increment == 15 {
@@ -701,7 +732,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="events-container">
{
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
// Skip all-day events (they're rendered in the header)
if is_all_day {
@@ -730,6 +761,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let event_for_drag = event.clone();
let date_for_drag = *date;
let time_increment = props.time_increment;
let print_pixels_per_hour = props.print_pixels_per_hour;
let print_start_hour = props.print_start_hour;
Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
@@ -743,7 +776,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
// Get event's current position in day column coordinates
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
let event_start_pixels = event_start_pixels as f64;
// Convert click position to day column coordinates
@@ -1029,7 +1062,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
};
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
@@ -1059,7 +1092,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let new_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
@@ -1089,6 +1122,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
html! {}
}
}
// Current time indicator - only show on today
{
if *date == props.today {
let current_time_position = calculate_current_time_position(props.time_increment);
let current_time_str = current_time.time().format("%I:%M %p").to_string();
html! {
<div class="current-time-indicator-container">
<div
class="current-time-indicator"
style={format!("top: {}px;", current_time_position)}
>
<div class="current-time-dot"></div>
<div class="current-time-line"></div>
<div class="current-time-label">{current_time_str}</div>
</div>
</div>
}
} else {
html! {}
}
}
</div>
}
}).collect::<Html>()
@@ -1170,7 +1226,7 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) {
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
// Convert UTC times to local time for display
let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive();
@@ -1190,11 +1246,23 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
}
// Calculate start position in pixels from midnight
// Calculate start position in pixels
let start_hour = local_start.hour() as f32;
let start_minute = local_start.minute() as f32;
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
print_pph as f32 // Use the dynamic print mode calculation
} else {
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
};
// In print mode, offset by the start hour to show relative position within visible range
let hour_offset = if let Some(print_start) = print_start_hour {
print_start as f32
} else {
0.0 // No offset for normal view (starts at midnight)
};
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
// Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend {
@@ -1203,19 +1271,19 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
// Handle events that span multiple days by capping at midnight
if end_date > date {
// Event continues past midnight, cap at 24:00
let max_pixels = 24.0 * pixels_per_hour;
max_pixels - start_pixels
// Event continues past midnight, cap at end of visible range
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
(max_pixels - start_pixels).max(20.0)
} else {
let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() as f32;
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
}
} else {
pixels_per_hour // Default 1 hour if no end time
};
(start_pixels, duration_pixels, false) // is_all_day = false
}
@@ -1256,7 +1324,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
return None;
}
let (_, _, _) = calculate_event_position(event, date, time_increment);
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive();
if event_date == date ||

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
/* 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;
}

File diff suppressed because it is too large Load Diff