mirror of
https://github.com/dralkh/spaceforge.git
synced 2026-07-22 06:45:03 +00:00
feat: add calender event tooltips on hover instead of notifications**
- full calender system with events - Implemented tooltip system for calendar event tabs - Added smart positioning with viewport awareness - Included fade-in animations and responsive design - Enhanced user experience with contextual information display - Added proper cleanup and memory management
This commit is contained in:
parent
10579cfc9d
commit
a4aff67faa
17 changed files with 4985 additions and 13978 deletions
24
README.md
24
README.md
|
|
@ -26,6 +26,15 @@ https://github.com/user-attachments/assets/10bb251e-360c-41ae-a128-e90f3f3e2576
|
|||
* **Comprehensive Review Management:**
|
||||
* A dedicated sidebar view displays upcoming reviews, estimated completion times, and allows for easy navigation with configurable hotkey support.
|
||||
* Calendar view for visualizing your review schedule with standardized UTC date calculations.
|
||||
* **📅 Calendar Events Organization:**
|
||||
* **Full Event Management**: Create, edit, and delete calendar events with comprehensive CRUD operations
|
||||
* **Event Categories**: Color-coded categories (Work, Personal, Study, Meeting, Health, Social, Other) for visual organization
|
||||
* **Recurring Events**: Support for daily, weekly, monthly, and yearly recurrence patterns with end dates
|
||||
* **Visual Event Display**: Events shown as colored tabs in calendar cells with hover interactions
|
||||
* **Upcoming Events List**: Organized list of upcoming events below calendar grid with day-based grouping
|
||||
* **Quick Event Creation**: Hover plus button on calendar days for fast event creation with pre-filled dates
|
||||
* **Event Details Modal**: Comprehensive event creation/editing interface with all event properties
|
||||
* **Real-time Updates**: Calendar refreshes instantly after event operations with proper state management
|
||||
* **Flexible Note Addition:**
|
||||
* Add individual notes or entire folders to your review schedule via the right-click context menu.
|
||||
* Use Obsidian commands to add the current note or its folder.
|
||||
|
|
@ -82,6 +91,15 @@ npm install && npm run build && node install.js --d /home/user/Documents/vault/.
|
|||
2. Start, pause, reset, or skip Pomodoro sessions (Work, Short Break, Long Break).
|
||||
3. The timer and current session type are displayed in the sidebar.
|
||||
|
||||
#### Managing Calendar Events
|
||||
1. **Create Events**: Click the plus button in the calendar header or hover over calendar days to reveal the quick-add button
|
||||
2. **View Events**: Events appear as colored tabs in calendar cells and in the upcoming events list below the calendar
|
||||
3. **Edit Events**: Double-click event tabs in calendar cells to open the edit modal
|
||||
4. **Delete Events**: Use the delete button in the edit modal (no confirmation required for quick workflow)
|
||||
5. **Event Categories**: Choose from predefined categories with automatic color coding or set custom colors
|
||||
6. **Recurring Events**: Set recurrence patterns (daily, weekly, monthly, yearly) with optional end dates
|
||||
7. **Event Details**: Add title, description, date, time, location, and category for comprehensive event management
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Scheduling Algorithms: Optimize Your Learning
|
||||
|
|
@ -98,6 +116,7 @@ The Spaceforge settings tab allows you to customize:
|
|||
* **Scheduling Algorithm:** Select and configure FSRS or SM-2 parameters with standardized UTC date calculations.
|
||||
* **MCQ Generation:** Enable/disable MCQs, select API provider, enter API keys and models, configure scoring behavior.
|
||||
* **Pomodoro Timer:** Enable/disable, set durations for work/break sessions, sound notifications.
|
||||
* **Calendar Events:** Enable/disable calendar events, set default event category, configure event display options.
|
||||
* **Data Management:** Set a custom path for plugin data, import/export data, manage data integrity.
|
||||
|
||||
## Commands & Shortcuts
|
||||
|
|
@ -109,6 +128,7 @@ Spaceforge adds several commands to the Obsidian command palette:
|
|||
* `Spaceforge: Add Current Note to Review Schedule`
|
||||
* `Spaceforge: Add Current Note's Folder to Review Schedule`
|
||||
* `Spaceforge: Open Review Sidebar`
|
||||
* `Spaceforge: Create Calendar Event`
|
||||
|
||||
You can assign custom keyboard shortcuts to these commands via Obsidian's hotkey settings. Spaceforge now includes enhanced navigation command execution with configurable hotkey support for improved workflow efficiency.
|
||||
|
||||
|
|
@ -141,6 +161,10 @@ This plugin is licensed under the MIT License.
|
|||
* **Customizable Sidebar Sections:** Allow users to show/hide or reorder sections within the Spaceforge sidebar for a personalized study environment.
|
||||
* **More Informative Note Items:** Display more details on note items (e.g., icons for MCQ availability, leech status, FSRS/SM-2 type; hover for more stats) for quick insights.
|
||||
* **Calendar View Enhancements:** More interactive calendar with direct review options, visual density indicators, and hover details for better schedule management.
|
||||
* **Event Reminders:** Notifications and alerts for upcoming calendar events with customizable timing.
|
||||
* **Calendar Synchronization:** Integration with external calendar services (Google Calendar, Outlook, etc.).
|
||||
* **Advanced Event Filtering:** Filter events by category, date range, or search terms for better organization.
|
||||
* **Event Templates**: Pre-defined event templates for common activities (meetings, study sessions, etc.).
|
||||
* **Pomodoro UI Refinements:** Visual timer, session history, and more sound customization options for an improved focus timer experience.
|
||||
|
||||
**IV. Integrations & Data Management:**
|
||||
|
|
|
|||
13
main.ts
13
main.ts
|
|
@ -24,6 +24,7 @@ import { ReviewHistoryService } from './services/review-history-service';
|
|||
import { ReviewSessionService } from './services/review-session-service';
|
||||
import { MCQService } from './services/mcq-service';
|
||||
import { PomodoroService } from './services/pomodoro-service';
|
||||
import { CalendarEventService } from './services/calendar-event-service';
|
||||
|
||||
/**
|
||||
* Spaceforge: Spaced Repetition Plugin for Obsidian
|
||||
|
|
@ -37,6 +38,7 @@ export default class SpaceforgePlugin extends Plugin {
|
|||
reviewSessionService: ReviewSessionService;
|
||||
mcqService: MCQService;
|
||||
pomodoroService: PomodoroService;
|
||||
calendarEventService: CalendarEventService;
|
||||
|
||||
private readonly stylesheetPath: string = "styles.css";
|
||||
private readonly stylesheetId: string = "spaceforge-styles";
|
||||
|
|
@ -85,6 +87,11 @@ export default class SpaceforgePlugin extends Plugin {
|
|||
|
||||
// Initialize PomodoroService after settings are loaded
|
||||
this.pomodoroService = new PomodoroService(this);
|
||||
|
||||
// Initialize CalendarEventService after settings are loaded
|
||||
this.calendarEventService = new CalendarEventService();
|
||||
const eventsArray = Object.values(this.pluginState.calendarEvents);
|
||||
this.calendarEventService.initialize(eventsArray);
|
||||
|
||||
|
||||
this.registerView(
|
||||
|
|
@ -373,6 +380,11 @@ export default class SpaceforgePlugin extends Plugin {
|
|||
this.pluginState = { ...this.pluginState, ...(rawLoadedData as Partial<PluginStateData>) };
|
||||
}
|
||||
|
||||
// Ensure calendarEvents exists for legacy data
|
||||
if (!this.pluginState.calendarEvents) {
|
||||
this.pluginState.calendarEvents = {};
|
||||
}
|
||||
|
||||
// Ensure pomodoro state is initialized if not present in loaded data
|
||||
this.pluginState.pomodoroCurrentMode = this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode;
|
||||
this.pluginState.pomodoroTimeLeftInSeconds = this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds;
|
||||
|
|
@ -471,6 +483,7 @@ export default class SpaceforgePlugin extends Plugin {
|
|||
pomodoroUserOverrideHours: typeof this.pluginState.pomodoroUserOverrideHours === 'number' ? this.pluginState.pomodoroUserOverrideHours : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserOverrideHours,
|
||||
pomodoroUserOverrideMinutes: typeof this.pluginState.pomodoroUserOverrideMinutes === 'number' ? this.pluginState.pomodoroUserOverrideMinutes : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserOverrideMinutes,
|
||||
pomodoroUserAddToEstimation: typeof this.pluginState.pomodoroUserAddToEstimation === 'boolean' ? this.pluginState.pomodoroUserAddToEstimation : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserAddToEstimation,
|
||||
calendarEvents: this.calendarEventService ? Object.fromEntries(this.calendarEventService.getAllEvents().map(event => [event.id, event])) : {},
|
||||
version: this.manifest.version,
|
||||
};
|
||||
this.pluginState = currentPluginState; // Update the live pluginState
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "spaceforge",
|
||||
"name": "Spaceforge",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "A spaced repetition FSRS & SM-2 Algorithm powered by AI plugin for efficient knowledge review with pomodoro timer",
|
||||
"author": "dralkh",
|
||||
|
|
|
|||
124
models/calendar-event.ts
Normal file
124
models/calendar-event.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Event categories with predefined colors
|
||||
*/
|
||||
export enum EventCategory {
|
||||
Work = 'work',
|
||||
Personal = 'personal',
|
||||
Study = 'study',
|
||||
Meeting = 'meeting',
|
||||
Health = 'health',
|
||||
Social = 'social',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Event recurrence patterns
|
||||
*/
|
||||
export enum EventRecurrence {
|
||||
None = 'none',
|
||||
Daily = 'daily',
|
||||
Weekly = 'weekly',
|
||||
Monthly = 'monthly',
|
||||
Yearly = 'yearly'
|
||||
}
|
||||
|
||||
/**
|
||||
* Default colors for event categories
|
||||
*/
|
||||
export const EVENT_CATEGORY_COLORS: Record<EventCategory, string> = {
|
||||
[EventCategory.Work]: '#4A90E2',
|
||||
[EventCategory.Personal]: '#7B68EE',
|
||||
[EventCategory.Study]: '#50C878',
|
||||
[EventCategory.Meeting]: '#FF6B6B',
|
||||
[EventCategory.Health]: '#FF9F40',
|
||||
[EventCategory.Social]: '#FF69B4',
|
||||
[EventCategory.Other]: '#95A5A6'
|
||||
};
|
||||
|
||||
/**
|
||||
* Calendar event interface
|
||||
*/
|
||||
export interface CalendarEvent {
|
||||
/**
|
||||
* Unique identifier for the event
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Event title
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Event description (optional)
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Event date (timestamp)
|
||||
*/
|
||||
date: number;
|
||||
|
||||
/**
|
||||
* Event time in HH:MM format (optional)
|
||||
*/
|
||||
time?: string;
|
||||
|
||||
/**
|
||||
* Event category
|
||||
*/
|
||||
category: EventCategory;
|
||||
|
||||
/**
|
||||
* Custom color (overrides category color if set)
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Event recurrence pattern
|
||||
*/
|
||||
recurrence: EventRecurrence;
|
||||
|
||||
/**
|
||||
* End date for recurring events (optional)
|
||||
*/
|
||||
recurrenceEndDate?: number;
|
||||
|
||||
/**
|
||||
* Whether the event is all-day
|
||||
*/
|
||||
isAllDay: boolean;
|
||||
|
||||
/**
|
||||
* Event location (optional)
|
||||
*/
|
||||
location?: string;
|
||||
|
||||
/**
|
||||
* Creation timestamp
|
||||
*/
|
||||
createdAt: number;
|
||||
|
||||
/**
|
||||
* Last modification timestamp
|
||||
*/
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for grouped events by date
|
||||
*/
|
||||
export interface DateEvents {
|
||||
timestamp: number;
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for upcoming event display
|
||||
*/
|
||||
export interface UpcomingEvent {
|
||||
event: CalendarEvent;
|
||||
daysUntil: number;
|
||||
isToday: boolean;
|
||||
isTomorrow: boolean;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { SpaceforgeSettings, DEFAULT_SETTINGS } from './settings';
|
|||
import { ReviewSchedule, ReviewHistoryItem } from './review-schedule';
|
||||
import { ReviewSessionStore } from './review-session';
|
||||
import { MCQSet, MCQSession } from './mcq';
|
||||
import { CalendarEvent } from './calendar-event';
|
||||
|
||||
/**
|
||||
* Interface for the plugin's operational state (excluding settings).
|
||||
|
|
@ -34,6 +35,9 @@ export interface PluginStateData {
|
|||
pomodoroUserOverrideHours: number; // User-specified hours for override
|
||||
pomodoroUserOverrideMinutes: number; // User-specified minutes for override
|
||||
pomodoroUserAddToEstimation: boolean; // Whether to add user time to estimation or replace it
|
||||
|
||||
// Calendar Events
|
||||
calendarEvents: Record<string, CalendarEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,6 +70,9 @@ export const DEFAULT_PLUGIN_STATE_DATA: PluginStateData = {
|
|||
pomodoroUserOverrideHours: 0,
|
||||
pomodoroUserOverrideMinutes: 0,
|
||||
pomodoroUserAddToEstimation: false,
|
||||
|
||||
// Calendar Events Defaults
|
||||
calendarEvents: {},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -310,6 +310,31 @@ export interface SpaceforgeSettings {
|
|||
key: string | null;
|
||||
};
|
||||
navigationCommandDelay: number; // in milliseconds
|
||||
|
||||
// --- Calendar Events Settings ---
|
||||
/**
|
||||
* Enable calendar events feature
|
||||
* Default: true
|
||||
*/
|
||||
enableCalendarEvents: boolean;
|
||||
|
||||
/**
|
||||
* Show upcoming events below calendar
|
||||
* Default: true
|
||||
*/
|
||||
showUpcomingEvents: boolean;
|
||||
|
||||
/**
|
||||
* Number of days to show in upcoming events list
|
||||
* Default: 7
|
||||
*/
|
||||
upcomingEventsDays: number;
|
||||
|
||||
/**
|
||||
* Default event category
|
||||
* Default: 'personal'
|
||||
*/
|
||||
defaultEventCategory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -403,4 +428,10 @@ export const DEFAULT_SETTINGS: SpaceforgeSettings = {
|
|||
key: null,
|
||||
},
|
||||
navigationCommandDelay: 500,
|
||||
|
||||
// Calendar Events Defaults
|
||||
enableCalendarEvents: true,
|
||||
showUpcomingEvents: true,
|
||||
upcomingEventsDays: 7,
|
||||
defaultEventCategory: 'personal',
|
||||
};
|
||||
|
|
|
|||
10530
releases/1.0.2/main.js
10530
releases/1.0.2/main.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"id": "spaceforge",
|
||||
"name": "Spaceforge",
|
||||
"version": "1.0.2",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "A spaced repetition FSRS & SM-2 Algorithm powered by AI plugin for efficient knowledge review with pomodoro timer",
|
||||
"author": "dralkh",
|
||||
"authorUrl": "https://github.com/dralkh",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
350
services/calendar-event-service.ts
Normal file
350
services/calendar-event-service.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { CalendarEvent, DateEvents, UpcomingEvent, EventCategory, EventRecurrence } from '../models/calendar-event';
|
||||
import { DateUtils } from '../utils/dates';
|
||||
|
||||
/**
|
||||
* Service for managing calendar events
|
||||
*/
|
||||
export class CalendarEventService {
|
||||
/**
|
||||
* Storage for calendar events
|
||||
*/
|
||||
private events: Map<string, CalendarEvent> = new Map();
|
||||
|
||||
/**
|
||||
* Initialize the service with existing events
|
||||
*
|
||||
* @param events Array of existing events
|
||||
*/
|
||||
initialize(events: CalendarEvent[] = []): void {
|
||||
this.events.clear();
|
||||
events.forEach(event => {
|
||||
this.events.set(event.id, event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events
|
||||
*
|
||||
* @returns Array of all events
|
||||
*/
|
||||
getAllEvents(): CalendarEvent[] {
|
||||
return Array.from(this.events.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID
|
||||
*
|
||||
* @param id Event ID
|
||||
* @returns Event or null if not found
|
||||
*/
|
||||
getEventById(id: string): CalendarEvent | null {
|
||||
return this.events.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
*
|
||||
* @param eventData Event data (without id, createdAt, updatedAt)
|
||||
* @returns Created event
|
||||
*/
|
||||
createEvent(eventData: Omit<CalendarEvent, 'id' | 'createdAt' | 'updatedAt'>): CalendarEvent {
|
||||
const now = Date.now();
|
||||
const event: CalendarEvent = {
|
||||
...eventData,
|
||||
id: this.generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
this.events.set(event.id, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
*
|
||||
* @param id Event ID
|
||||
* @param updates Partial event data to update
|
||||
* @returns Updated event or null if not found
|
||||
*/
|
||||
updateEvent(id: string, updates: Partial<CalendarEvent>): CalendarEvent | null {
|
||||
const existingEvent = this.events.get(id);
|
||||
if (!existingEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedEvent: CalendarEvent = {
|
||||
...existingEvent,
|
||||
...updates,
|
||||
id, // Ensure ID doesn't change
|
||||
createdAt: existingEvent.createdAt, // Preserve creation time
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
this.events.set(id, updatedEvent);
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*
|
||||
* @param id Event ID
|
||||
* @returns True if deleted, false if not found
|
||||
*/
|
||||
deleteEvent(id: string): boolean {
|
||||
return this.events.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific date range
|
||||
*
|
||||
* @param startDate Start date timestamp
|
||||
* @param endDate End date timestamp
|
||||
* @returns Array of events in the date range
|
||||
*/
|
||||
getEventsInRange(startDate: number, endDate: number): CalendarEvent[] {
|
||||
return this.getAllEvents().filter(event => {
|
||||
const eventDate = DateUtils.startOfDay(new Date(event.date));
|
||||
const start = DateUtils.startOfDay(new Date(startDate));
|
||||
const end = DateUtils.startOfDay(new Date(endDate));
|
||||
|
||||
return eventDate >= start && eventDate <= end;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific date
|
||||
*
|
||||
* @param date Date timestamp
|
||||
* @returns Array of events for the date
|
||||
*/
|
||||
getEventsForDate(date: number): CalendarEvent[] {
|
||||
const targetDate = DateUtils.startOfDay(new Date(date));
|
||||
return this.getAllEvents().filter(event => {
|
||||
const eventDate = DateUtils.startOfDay(new Date(event.date));
|
||||
return eventDate === targetDate;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events grouped by date for a date range
|
||||
*
|
||||
* @param startDate Start date timestamp
|
||||
* @param endDate End date timestamp
|
||||
* @returns Map of date keys to DateEvents
|
||||
*/
|
||||
getEventsGroupedByDate(startDate: number, endDate: number): Map<string, DateEvents> {
|
||||
const eventsByDate = new Map<string, DateEvents>();
|
||||
const eventsInRange = this.getEventsInRange(startDate, endDate);
|
||||
|
||||
eventsInRange.forEach(event => {
|
||||
const eventDateStart = DateUtils.startOfDay(new Date(event.date));
|
||||
const dateKey = eventDateStart.toString();
|
||||
|
||||
if (!eventsByDate.has(dateKey)) {
|
||||
eventsByDate.set(dateKey, {
|
||||
timestamp: eventDateStart,
|
||||
events: []
|
||||
});
|
||||
}
|
||||
|
||||
const dateEvents = eventsByDate.get(dateKey);
|
||||
if (dateEvents) {
|
||||
dateEvents.events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
return eventsByDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming events
|
||||
*
|
||||
* @param daysAhead Number of days ahead to look
|
||||
* @param fromDate Optional start date (defaults to today)
|
||||
* @returns Array of upcoming events
|
||||
*/
|
||||
getUpcomingEvents(daysAhead: number = 7, fromDate: Date = new Date()): UpcomingEvent[] {
|
||||
const today = DateUtils.startOfDay(fromDate);
|
||||
const endDate = DateUtils.addDays(today, daysAhead);
|
||||
const eventsInRange = this.getEventsInRange(today, endDate);
|
||||
|
||||
return eventsInRange
|
||||
.map(event => {
|
||||
const eventDate = DateUtils.startOfDay(new Date(event.date));
|
||||
const daysUntil = Math.floor((eventDate - today) / (24 * 60 * 60 * 1000));
|
||||
|
||||
return {
|
||||
event,
|
||||
daysUntil,
|
||||
isToday: daysUntil === 0,
|
||||
isTomorrow: daysUntil === 1
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by days until, then by time
|
||||
if (a.daysUntil !== b.daysUntil) {
|
||||
return a.daysUntil - b.daysUntil;
|
||||
}
|
||||
|
||||
// If same day, sort by time (all-day events first)
|
||||
if (a.event.isAllDay && !b.event.isAllDay) return -1;
|
||||
if (!a.event.isAllDay && b.event.isAllDay) return 1;
|
||||
|
||||
// If both have times, sort by time
|
||||
if (a.event.time && b.event.time) {
|
||||
return a.event.time.localeCompare(b.event.time);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recurring event instances
|
||||
*
|
||||
* @param event Recurring event
|
||||
* @param startDate Start date for generating instances
|
||||
* @param endDate End date for generating instances
|
||||
* @returns Array of event instances
|
||||
*/
|
||||
generateRecurringInstances(event: CalendarEvent, startDate: number, endDate: number): CalendarEvent[] {
|
||||
if (event.recurrence === EventRecurrence.None) {
|
||||
return [event];
|
||||
}
|
||||
|
||||
const instances: CalendarEvent[] = [];
|
||||
const start = DateUtils.startOfDay(new Date(startDate));
|
||||
const end = DateUtils.startOfDay(new Date(endDate));
|
||||
const eventDate = DateUtils.startOfDay(new Date(event.date));
|
||||
const recurrenceEnd = event.recurrenceEndDate ? DateUtils.startOfDay(new Date(event.recurrenceEndDate)) : end;
|
||||
|
||||
let currentDate = new Date(eventDate);
|
||||
|
||||
while (currentDate.getTime() <= Math.min(end, recurrenceEnd)) {
|
||||
if (currentDate.getTime() >= start) {
|
||||
const instance: CalendarEvent = {
|
||||
...event,
|
||||
date: currentDate.getTime(),
|
||||
id: `${event.id}-${currentDate.getTime()}` // Unique ID for instance
|
||||
};
|
||||
instances.push(instance);
|
||||
}
|
||||
|
||||
// Move to next occurrence
|
||||
switch (event.recurrence) {
|
||||
case EventRecurrence.Daily:
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
break;
|
||||
case EventRecurrence.Weekly:
|
||||
currentDate.setDate(currentDate.getDate() + 7);
|
||||
break;
|
||||
case EventRecurrence.Monthly:
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
break;
|
||||
case EventRecurrence.Yearly:
|
||||
currentDate.setFullYear(currentDate.getFullYear() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by category
|
||||
*
|
||||
* @param category Event category
|
||||
* @returns Array of events in the category
|
||||
*/
|
||||
getEventsByCategory(category: EventCategory): CalendarEvent[] {
|
||||
return this.getAllEvents().filter(event => event.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search events by title or description
|
||||
*
|
||||
* @param query Search query
|
||||
* @returns Array of matching events
|
||||
*/
|
||||
searchEvents(query: string): CalendarEvent[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return this.getAllEvents().filter(event =>
|
||||
event.title.toLowerCase().includes(lowerQuery) ||
|
||||
(event.description && event.description.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export events to JSON
|
||||
*
|
||||
* @returns JSON string of all events
|
||||
*/
|
||||
exportEvents(): string {
|
||||
return JSON.stringify(this.getAllEvents(), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import events from JSON
|
||||
*
|
||||
* @param json JSON string of events
|
||||
* @returns Number of imported events
|
||||
*/
|
||||
importEvents(json: string): number {
|
||||
try {
|
||||
const events: CalendarEvent[] = JSON.parse(json);
|
||||
let importedCount = 0;
|
||||
|
||||
events.forEach(event => {
|
||||
if (event.id && event.title && event.date) {
|
||||
this.events.set(event.id, event);
|
||||
importedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return importedCount;
|
||||
} catch (error) {
|
||||
console.error('Failed to import events:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for new events
|
||||
*
|
||||
* @returns Unique ID string
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event color (custom color or category color)
|
||||
*
|
||||
* @param event Event
|
||||
* @returns Color hex code
|
||||
*/
|
||||
getEventColor(event: CalendarEvent): string {
|
||||
return event.color || this.getCategoryColor(event.category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category color
|
||||
*
|
||||
* @param category Event category
|
||||
* @returns Color hex code
|
||||
*/
|
||||
getCategoryColor(category: EventCategory): string {
|
||||
const colors = {
|
||||
[EventCategory.Work]: '#4A90E2',
|
||||
[EventCategory.Personal]: '#7B68EE',
|
||||
[EventCategory.Study]: '#50C878',
|
||||
[EventCategory.Meeting]: '#FF6B6B',
|
||||
[EventCategory.Health]: '#FF9F40',
|
||||
[EventCategory.Social]: '#FF69B4',
|
||||
[EventCategory.Other]: '#95A5A6'
|
||||
};
|
||||
|
||||
return colors[category] || colors[EventCategory.Other];
|
||||
}
|
||||
}
|
||||
893
styles.css
893
styles.css
|
|
@ -12,6 +12,8 @@
|
|||
--sf-danger-light: rgba(244, 67, 54, 0.15);
|
||||
--sf-info: #2196f3;
|
||||
--sf-info-light: rgba(33, 150, 243, 0.15);
|
||||
--sf-overdue-accent: #ff8c42;
|
||||
--sf-overdue-bg: rgba(255, 140, 66, 0.08);
|
||||
--sf-text: var(--text-normal, #333);
|
||||
--sf-text-muted: var(--text-muted, #888);
|
||||
--sf-text-on-primary: var(--text-on-accent, white);
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
--sf-warning-light: rgba(255, 152, 0, 0.2);
|
||||
--sf-danger-light: rgba(244, 67, 54, 0.2);
|
||||
--sf-info-light: rgba(33, 150, 243, 0.2);
|
||||
--sf-overdue-bg: rgba(255, 140, 66, 0.12);
|
||||
--sf-bg-primary: var(--background-primary-alt);
|
||||
--sf-bg-secondary: var(--background-secondary-alt);
|
||||
}
|
||||
|
|
@ -945,7 +948,7 @@ button.disabled:hover {
|
|||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.review-date-section-overdue {
|
||||
border-left: 3px solid var(--text-error);
|
||||
border-left: 3px solid var(--sf-overdue-accent);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
.review-date-header {
|
||||
|
|
@ -976,7 +979,7 @@ button.disabled:hover {
|
|||
min-width: 0;
|
||||
}
|
||||
.review-date-section-overdue .review-date-header h3 {
|
||||
color: var(--text-error);
|
||||
color: var(--sf-overdue-accent);
|
||||
}
|
||||
.review-date-postpone-all {
|
||||
padding: 4px 10px;
|
||||
|
|
@ -1030,8 +1033,8 @@ button.disabled:hover {
|
|||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.review-note-item.overdue-note {
|
||||
background-color: var(--background-modifier-error-hover);
|
||||
border-left: 3px solid var(--text-error);
|
||||
background-color: var(--sf-overdue-bg);
|
||||
border-left: 3px solid var(--sf-overdue-accent);
|
||||
}
|
||||
.review-note-title {
|
||||
font-weight: 500;
|
||||
|
|
@ -1913,6 +1916,888 @@ button.disabled:hover {
|
|||
padding: 1px 3px;
|
||||
}
|
||||
}
|
||||
.calendar-events-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
.calendar-event-tab {
|
||||
font-size: 9px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.calendar-event-tab:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
z-index: 10;
|
||||
}
|
||||
.calendar-events-more {
|
||||
font-size: 8px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--text-muted);
|
||||
color: var(--background-primary);
|
||||
font-weight: 600;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.calendar-day.has-events {
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
}
|
||||
.upcoming-events-wrapper {
|
||||
margin-top: var(--sf-space-md);
|
||||
padding-top: var(--sf-space-md);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.upcoming-events-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sf-space-sm);
|
||||
padding-bottom: var(--sf-space-sm);
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
.upcoming-events-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--text-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sf-space-xs);
|
||||
}
|
||||
.upcoming-events-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.upcoming-events-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--sf-space-xs);
|
||||
}
|
||||
.upcoming-events-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.upcoming-events-container::-webkit-scrollbar-track {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.upcoming-events-container::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.upcoming-events-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.upcoming-events-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sf-space-lg) var(--sf-space-md);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.upcoming-events-empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: var(--sf-space-sm);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.upcoming-events-empty-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--sf-space-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.upcoming-events-empty-subtext {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.upcoming-events-day-section {
|
||||
margin-bottom: var(--sf-space-md);
|
||||
border-radius: var(--radius-m);
|
||||
background-color: var(--background-secondary);
|
||||
overflow: hidden;
|
||||
transition: var(--sf-transition);
|
||||
}
|
||||
.upcoming-events-day-section:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
.upcoming-events-day-section.today {
|
||||
background-color: rgba(var(--interactive-accent-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--interactive-accent-rgb), 0.2);
|
||||
}
|
||||
.upcoming-events-day-section.tomorrow {
|
||||
background-color: rgba(var(--interactive-accent-rgb), 0.05);
|
||||
}
|
||||
.upcoming-events-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
background-color: var(--background-modifier-border);
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
.upcoming-events-day-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.upcoming-events-day-section.today .upcoming-events-day-title {
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
.upcoming-events-day-events {
|
||||
padding: var(--sf-space-xs);
|
||||
}
|
||||
.upcoming-events-event-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
margin: var(--sf-space-xs) 0;
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
transition: var(--sf-transition);
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.upcoming-events-event-item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-color: var(--background-modifier-border);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.upcoming-events-event-color {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
min-height: 32px;
|
||||
border-radius: 2px;
|
||||
margin-right: var(--sf-space-sm);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-events-event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.upcoming-events-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.upcoming-events-event-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upcoming-events-event-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.upcoming-events-event-details {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-events-event-location {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-events-event-description {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-events-event-category {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--background-modifier-border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
}
|
||||
.event-modal-datetime-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sf-space-md);
|
||||
margin-bottom: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-category-color-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sf-space-md);
|
||||
margin-bottom: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-buttons {
|
||||
display: flex;
|
||||
gap: var(--sf-space-sm);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sf-space-lg);
|
||||
padding-top: var(--sf-space-md);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.event-modal-buttons button {
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
transition: var(--sf-transition);
|
||||
font-weight: 500;
|
||||
}
|
||||
.event-modal-buttons button.mod-cta {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.event-modal-buttons button.mod-cta:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
.event-modal-buttons button:not(.mod-cta) {
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.event-modal-buttons button:not(.mod-cta):hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
.event-modal-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.event-modal-datetime-row,
|
||||
.event-modal-category-color-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.upcoming-events-container {
|
||||
max-height: 200px;
|
||||
}
|
||||
.calendar-event-tab {
|
||||
font-size: 8px;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
.upcoming-events-event-item {
|
||||
padding: var(--sf-space-xs) var(--sf-space-sm);
|
||||
}
|
||||
.upcoming-events-event-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
.upcoming-events-event-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
}
|
||||
.calendar-day-add-event {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 20;
|
||||
box-shadow: var(--sf-shadow-sm);
|
||||
}
|
||||
.calendar-day:hover .calendar-day-add-event {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.calendar-day-add-event:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--sf-shadow-md);
|
||||
}
|
||||
.calendar-day.empty:hover .calendar-day-add-event {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.calendar-day.has-reviews .calendar-day-add-event {
|
||||
top: 28px;
|
||||
}
|
||||
.calendar-day.has-events .calendar-day-add-event {
|
||||
top: 28px;
|
||||
}
|
||||
.calendar-day.has-reviews.has-events .calendar-day-add-event {
|
||||
top: 44px;
|
||||
}
|
||||
.event-modal {
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.event-modal .modal-content {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.event-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sf-space-md);
|
||||
padding: var(--sf-space-lg) var(--sf-space-lg) var(--sf-space-md) var(--sf-space-lg);
|
||||
background: linear-gradient(135deg, var(--interactive-accent) 0%, var(--interactive-accent-hover) 100%);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.event-modal-header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
}
|
||||
.event-modal-header-text {
|
||||
flex: 1;
|
||||
}
|
||||
.event-modal-title {
|
||||
margin: 0 0 var(--sf-space-xs) 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.event-modal-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.event-modal-form {
|
||||
padding: var(--sf-space-lg);
|
||||
}
|
||||
.event-modal-section {
|
||||
margin-bottom: var(--sf-space-lg);
|
||||
}
|
||||
.event-modal-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
margin-bottom: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-label.required::after {
|
||||
content: " *";
|
||||
color: var(--text-error);
|
||||
}
|
||||
.event-modal-input,
|
||||
.event-modal-textarea,
|
||||
.event-modal-select {
|
||||
width: 100%;
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.event-modal-input:focus,
|
||||
.event-modal-textarea:focus,
|
||||
.event-modal-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 3px rgba(var(--interactive-accent-rgb), 0.1);
|
||||
}
|
||||
.event-modal-input:hover,
|
||||
.event-modal-textarea:hover,
|
||||
.event-modal-select:hover {
|
||||
border-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
.event-modal-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.event-modal-datetime-section {
|
||||
background-color: var(--background-secondary);
|
||||
padding: var(--sf-space-md);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.event-modal-datetime-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sf-space-md);
|
||||
margin-bottom: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-input-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.event-modal-input-icon {
|
||||
position: absolute;
|
||||
left: var(--sf-space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.event-modal-input-group .event-modal-input {
|
||||
padding-left: 36px;
|
||||
}
|
||||
.event-modal-all-day-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--interactive-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.event-modal-checkbox-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.event-modal-category-color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-category-container,
|
||||
.event-modal-color-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-color-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.event-modal-color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-color-input {
|
||||
width: 50px;
|
||||
height: 36px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
.event-modal-color-preview {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.event-modal-color-preview:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.event-modal-recurrence-container {
|
||||
margin-bottom: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-recurrence-end-container {
|
||||
background-color: var(--background-secondary);
|
||||
padding: var(--sf-space-md);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
margin-top: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sf-space-sm);
|
||||
justify-content: flex-end;
|
||||
padding: var(--sf-space-lg);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
background-color: var(--background-secondary);
|
||||
margin: 0 -var(--sf-space-lg) -var(--sf-space-lg);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
.event-modal-btn {
|
||||
padding: var(--sf-space-sm) var(--sf-space-lg);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
.event-modal-btn-primary {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.event-modal-btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--interactive-accent-rgb), 0.3);
|
||||
}
|
||||
.event-modal-btn-secondary {
|
||||
background-color: var(--background-modifier-border);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.event-modal-btn-secondary:hover {
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.event-modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.event-modal {
|
||||
max-width: 95vw;
|
||||
margin: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-datetime-grid,
|
||||
.event-modal-category-color-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-header {
|
||||
padding: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-form {
|
||||
padding: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-actions {
|
||||
padding: var(--sf-space-md);
|
||||
flex-direction: column;
|
||||
}
|
||||
.event-modal-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.event-modal {
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.event-modal-input:focus,
|
||||
.event-modal-textarea:focus,
|
||||
.event-modal-select:focus {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.event-modal-input.error,
|
||||
.event-modal-textarea.error,
|
||||
.event-modal-select.error {
|
||||
border-color: var(--text-error);
|
||||
box-shadow: 0 0 0 3px rgba(var(--text-error-rgb), 0.1);
|
||||
}
|
||||
.event-modal-btn.loading {
|
||||
position: relative;
|
||||
color: transparent;
|
||||
}
|
||||
.event-modal-btn.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -8px;
|
||||
margin-top: -8px;
|
||||
border: 2px solid var(--text-on-accent);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.event-modal {
|
||||
max-width: 500px;
|
||||
max-height: 85vh;
|
||||
}
|
||||
.event-modal .modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.event-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sf-space-sm);
|
||||
padding: var(--sf-space-md) var(--sf-space-lg);
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.event-modal-header-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
.event-modal-header-text {
|
||||
flex: 1;
|
||||
}
|
||||
.event-modal-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.event-modal-form {
|
||||
padding: var(--sf-space-md);
|
||||
}
|
||||
.event-modal-row {
|
||||
display: flex;
|
||||
gap: var(--sf-space-sm);
|
||||
margin-bottom: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-row.event-modal-full-width {
|
||||
display: block;
|
||||
}
|
||||
.event-modal-field-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.event-modal-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
margin: 0;
|
||||
}
|
||||
.event-modal-label.required::after {
|
||||
content: " *";
|
||||
color: var(--text-error);
|
||||
}
|
||||
.event-modal-input,
|
||||
.event-modal-textarea,
|
||||
.event-modal-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
transition: border-color 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.event-modal-input:focus,
|
||||
.event-modal-textarea:focus,
|
||||
.event-modal-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.event-modal-textarea {
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.event-modal-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--interactive-accent);
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.event-modal-checkbox-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.event-modal-color-picker-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.event-modal-color-input {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 1px;
|
||||
}
|
||||
.event-modal-recurrence-end-row {
|
||||
display: none;
|
||||
}
|
||||
.event-modal-recurrence-end-row.visible {
|
||||
display: flex;
|
||||
}
|
||||
.event-modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sf-space-sm);
|
||||
justify-content: flex-end;
|
||||
padding: var(--sf-space-md) var(--sf-space-lg);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
background-color: var(--background-secondary);
|
||||
margin: 0 -var(--sf-space-md) -var(--sf-space-md);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.event-modal-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
}
|
||||
.event-modal-btn-primary {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.event-modal-btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
.event-modal-btn-secondary {
|
||||
background-color: var(--background-modifier-border);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.event-modal-btn-secondary:hover {
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
.event-modal-btn-danger {
|
||||
background-color: var(--color-red, #dc3545);
|
||||
color: white;
|
||||
}
|
||||
.event-modal-btn-danger:hover {
|
||||
background-color: var(--color-red-hover, #c82333);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.event-modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.event-modal {
|
||||
max-width: 95vw;
|
||||
margin: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-row {
|
||||
flex-direction: column;
|
||||
gap: var(--sf-space-xs);
|
||||
}
|
||||
.event-modal-header {
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
}
|
||||
.event-modal-form {
|
||||
padding: var(--sf-space-sm);
|
||||
}
|
||||
.event-modal-actions {
|
||||
padding: var(--sf-space-sm) var(--sf-space-md);
|
||||
flex-direction: row;
|
||||
}
|
||||
.event-modal-btn {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
.calendar-event-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
max-width: 280px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.calendar-event-tooltip .tooltip-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.calendar-event-tooltip .tooltip-date,
|
||||
.calendar-event-tooltip .tooltip-time,
|
||||
.calendar-event-tooltip .tooltip-description,
|
||||
.calendar-event-tooltip .tooltip-location,
|
||||
.calendar-event-tooltip .tooltip-category {
|
||||
margin: 3px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.calendar-event-tooltip .tooltip-description {
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.event-modal-datetime-section,
|
||||
.event-modal-datetime-grid,
|
||||
.event-modal-input-group,
|
||||
.event-modal-input-icon,
|
||||
.event-modal-all-day-container,
|
||||
.event-modal-category-color-grid,
|
||||
.event-modal-category-container,
|
||||
.event-modal-color-container,
|
||||
.event-modal-color-label,
|
||||
.event-modal-recurrence-container,
|
||||
.event-modal-recurrence-end-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* styles/mcq.css */
|
||||
.mcq-header-container {
|
||||
|
|
|
|||
1067
styles/calendar.css
1067
styles/calendar.css
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,11 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import SpaceforgePlugin from "../main";
|
||||
import { ReviewSchedule } from "../models/review-schedule";
|
||||
import { CalendarEvent } from "../models/calendar-event";
|
||||
import { DateUtils } from "../utils/dates";
|
||||
import { EstimationUtils } from "../utils/estimation";
|
||||
import { UpcomingEvents } from "./upcoming-events";
|
||||
import { EventModal } from "./event-modal";
|
||||
|
||||
/**
|
||||
* Interface for grouped reviews by date
|
||||
|
|
@ -13,6 +16,14 @@ interface DateReviews {
|
|||
totalTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for grouped events by date
|
||||
*/
|
||||
interface DateEvents {
|
||||
timestamp: number;
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar view component for review schedule
|
||||
*/
|
||||
|
|
@ -37,10 +48,21 @@ export class CalendarView {
|
|||
*/
|
||||
reviewsByDate: Map<string, DateReviews> = new Map();
|
||||
|
||||
/**
|
||||
* Events grouped by date
|
||||
*/
|
||||
eventsByDate: Map<string, DateEvents> = new Map();
|
||||
|
||||
// Persistent UI elements
|
||||
private calendarHeaderEl: HTMLElement | null = null;
|
||||
private monthTitleEl: HTMLElement | null = null;
|
||||
private calendarGridEl: HTMLElement | null = null;
|
||||
private upcomingEventsEl: HTMLElement | null = null;
|
||||
private upcomingEventsComponent: UpcomingEvents | null = null;
|
||||
|
||||
// Tooltip elements
|
||||
private tooltipEl: HTMLElement | null = null;
|
||||
private tooltipTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initialize calendar view
|
||||
|
|
@ -63,10 +85,16 @@ export class CalendarView {
|
|||
this.updateCalendarHeader(); // Updates month title
|
||||
|
||||
await this.loadReviewsData(); // Preloads review data for the current view
|
||||
await this.loadEventsData(); // Preloads event data for the current view
|
||||
|
||||
if (this.calendarGridEl) {
|
||||
this.renderCalendarGridContent(this.calendarGridEl); // Renders/updates the grid
|
||||
}
|
||||
|
||||
// Render upcoming events
|
||||
if (this.upcomingEventsComponent) {
|
||||
await this.upcomingEventsComponent.render();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCalendarBaseStructure(): void {
|
||||
|
|
@ -103,12 +131,25 @@ export class CalendarView {
|
|||
this.currentDate = new Date();
|
||||
this.render();
|
||||
});
|
||||
|
||||
const addEventBtn = this.calendarHeaderEl.createDiv("calendar-add-event-btn");
|
||||
setIcon(addEventBtn, "plus");
|
||||
addEventBtn.addEventListener("click", () => {
|
||||
this.openCreateEventModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.calendarGridEl || !calendarContainer.contains(this.calendarGridEl)) {
|
||||
this.calendarGridEl?.remove();
|
||||
this.calendarGridEl = calendarContainer.createDiv("calendar-grid");
|
||||
}
|
||||
|
||||
// Create or update upcoming events container
|
||||
if (!this.upcomingEventsEl || !calendarContainer.contains(this.upcomingEventsEl)) {
|
||||
this.upcomingEventsEl?.remove();
|
||||
this.upcomingEventsEl = calendarContainer.createDiv("upcoming-events-wrapper");
|
||||
this.upcomingEventsComponent = new UpcomingEvents(this.upcomingEventsEl, this.plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -151,6 +192,43 @@ export class CalendarView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and organize event data by date
|
||||
*/
|
||||
async loadEventsData(): Promise<void> {
|
||||
this.eventsByDate = new Map();
|
||||
|
||||
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.calendarEventService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = this.getCalendarData();
|
||||
const startDate = new Date(year, month, 1);
|
||||
const endDate = new Date(year, month + 1, 0); // Last day of month
|
||||
|
||||
const eventsInRange = this.plugin.calendarEventService.getEventsInRange(
|
||||
startDate.getTime(),
|
||||
endDate.getTime()
|
||||
);
|
||||
|
||||
for (const event of eventsInRange) {
|
||||
const eventDayStart = DateUtils.startOfUTCDay(new Date(event.date));
|
||||
const dateKey = eventDayStart.toString();
|
||||
|
||||
if (!this.eventsByDate.has(dateKey)) {
|
||||
this.eventsByDate.set(dateKey, {
|
||||
timestamp: eventDayStart,
|
||||
events: []
|
||||
});
|
||||
}
|
||||
|
||||
const dateEvents = this.eventsByDate.get(dateKey);
|
||||
if (dateEvents) {
|
||||
dateEvents.events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render or update the calendar grid content
|
||||
|
|
@ -207,6 +285,9 @@ export class CalendarView {
|
|||
}
|
||||
|
||||
const dateReviews = this.reviewsByDate.get(dateKey);
|
||||
const dateEvents = this.eventsByDate.get(dateKey);
|
||||
|
||||
// Render reviews
|
||||
if (dateReviews && dateReviews.notes.length > 0) {
|
||||
dayCell.addClass("has-reviews");
|
||||
|
||||
|
|
@ -237,6 +318,59 @@ export class CalendarView {
|
|||
else if (dateReviews.notes.length > 5) dayCell.addClass("medium-load");
|
||||
else dayCell.addClass("light-load");
|
||||
}
|
||||
|
||||
// Render events as small tabs
|
||||
if (dateEvents && dateEvents.events.length > 0) {
|
||||
dayCell.addClass("has-events");
|
||||
|
||||
const eventsContainer = dayCell.createDiv("calendar-events-container");
|
||||
|
||||
// Show up to 3 event tabs
|
||||
const eventsToShow = dateEvents.events.slice(0, 3);
|
||||
eventsToShow.forEach(event => {
|
||||
const eventTab = eventsContainer.createDiv("calendar-event-tab");
|
||||
eventTab.setText(event.title.substring(0, 8) + (event.title.length > 8 ? "..." : ""));
|
||||
|
||||
// Set color based on event category or custom color
|
||||
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
|
||||
eventTab.style.backgroundColor = eventColor;
|
||||
|
||||
// Add hover handler for tooltip
|
||||
eventTab.addEventListener("mouseenter", (e) => {
|
||||
this.showEventTooltip(eventTab, event);
|
||||
});
|
||||
|
||||
eventTab.addEventListener("mouseleave", (e) => {
|
||||
this.hideEventTooltip();
|
||||
});
|
||||
|
||||
// Add click handler for event
|
||||
eventTab.addEventListener("click", (e) => {
|
||||
e.stopPropagation(); // Prevent day cell click
|
||||
this.showEventDetails(event);
|
||||
});
|
||||
|
||||
// Add double-click handler for editing
|
||||
eventTab.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation(); // Prevent day cell click
|
||||
this.openEditEventModal(event);
|
||||
});
|
||||
});
|
||||
|
||||
// Show "more" indicator if there are more events
|
||||
if (dateEvents.events.length > 3) {
|
||||
const moreIndicator = eventsContainer.createDiv("calendar-events-more");
|
||||
moreIndicator.setText(`+${dateEvents.events.length - 3}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add hover plus button for creating events
|
||||
const addEventBtn = dayCell.createDiv("calendar-day-add-event");
|
||||
setIcon(addEventBtn, "plus");
|
||||
addEventBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation(); // Prevent day cell click
|
||||
this.openCreateEventModalForDate(currentDateObj);
|
||||
});
|
||||
dayOfMonth++;
|
||||
} else {
|
||||
dayCell.addClass("empty");
|
||||
|
|
@ -278,4 +412,233 @@ export class CalendarView {
|
|||
today.getDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show event details modal
|
||||
*
|
||||
* @param event Event to show details for
|
||||
*/
|
||||
showEventDetails(event: CalendarEvent): void {
|
||||
// For now, just show a notice with event details
|
||||
// TODO: Create a proper modal for event details
|
||||
const eventDate = new Date(event.date);
|
||||
const dateStr = eventDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
let details = `📅 ${event.title}\n📆 ${dateStr}`;
|
||||
|
||||
if (event.time) {
|
||||
details += `\n🕐 ${event.time}`;
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
details += `\n📝 ${event.description}`;
|
||||
}
|
||||
|
||||
if (event.location) {
|
||||
details += `\n📍 ${event.location}`;
|
||||
}
|
||||
|
||||
details += `\n🏷️ ${event.category}`;
|
||||
|
||||
new Notice(details, 8000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open create event modal
|
||||
*/
|
||||
openCreateEventModal(): void {
|
||||
const modal = new EventModal(
|
||||
this.plugin.app,
|
||||
this.plugin,
|
||||
null,
|
||||
async (savedEvent) => {
|
||||
// Refresh the calendar view after saving
|
||||
await this.render();
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open create event modal for a specific date
|
||||
*
|
||||
* @param date Date to pre-fill in the modal
|
||||
*/
|
||||
openCreateEventModalForDate(date: Date): void {
|
||||
// Create a default event with the selected date
|
||||
const defaultEvent = {
|
||||
title: "",
|
||||
date: date.getTime(),
|
||||
isAllDay: true,
|
||||
category: this.plugin.settings.defaultEventCategory as any || 'personal',
|
||||
recurrence: 'none' as any
|
||||
};
|
||||
|
||||
const modal = new EventModal(
|
||||
this.plugin.app,
|
||||
this.plugin,
|
||||
null,
|
||||
async (savedEvent) => {
|
||||
// Refresh the calendar view after saving
|
||||
await this.render();
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
|
||||
// Pre-fill the date in the modal after it opens
|
||||
setTimeout(() => {
|
||||
const dateInput = modal.contentEl.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
if (dateInput) {
|
||||
dateInput.value = date.toISOString().split('T')[0];
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open edit event modal
|
||||
*
|
||||
* @param event Event to edit
|
||||
*/
|
||||
openEditEventModal(event: CalendarEvent): void {
|
||||
const modal = new EventModal(
|
||||
this.plugin.app,
|
||||
this.plugin,
|
||||
event,
|
||||
async (savedEvent) => {
|
||||
// Refresh the calendar view after saving
|
||||
await this.render();
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when the calendar view is destroyed
|
||||
*/
|
||||
destroy(): void {
|
||||
this.hideEventTooltip();
|
||||
|
||||
// Clear other references
|
||||
this.calendarHeaderEl = null;
|
||||
this.monthTitleEl = null;
|
||||
this.calendarGridEl = null;
|
||||
this.upcomingEventsEl = null;
|
||||
this.upcomingEventsComponent = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show event tooltip
|
||||
*
|
||||
* @param targetEl Element to show tooltip for
|
||||
* @param event Event to show details for
|
||||
*/
|
||||
showEventTooltip(targetEl: HTMLElement, event: CalendarEvent): void {
|
||||
// Clear any existing timeout
|
||||
if (this.tooltipTimeout) {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
}
|
||||
|
||||
// Small delay before showing tooltip to avoid flickering
|
||||
this.tooltipTimeout = setTimeout(() => {
|
||||
this.createEventTooltip(targetEl, event);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide event tooltip
|
||||
*/
|
||||
hideEventTooltip(): void {
|
||||
// Clear any pending tooltip
|
||||
if (this.tooltipTimeout) {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
this.tooltipTimeout = null;
|
||||
}
|
||||
|
||||
// Remove existing tooltip
|
||||
if (this.tooltipEl) {
|
||||
this.tooltipEl.remove();
|
||||
this.tooltipEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show the tooltip element
|
||||
*
|
||||
* @param targetEl Element to position tooltip relative to
|
||||
* @param event Event to show details for
|
||||
*/
|
||||
private createEventTooltip(targetEl: HTMLElement, event: CalendarEvent): void {
|
||||
// Remove existing tooltip if any
|
||||
this.hideEventTooltip();
|
||||
|
||||
// Create tooltip element
|
||||
this.tooltipEl = document.createElement("div");
|
||||
this.tooltipEl.className = "calendar-event-tooltip";
|
||||
|
||||
// Format event details
|
||||
const eventDate = new Date(event.date);
|
||||
const dateStr = eventDate.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
let tooltipContent = `<div class="tooltip-title">${event.title}</div>`;
|
||||
tooltipContent += `<div class="tooltip-date">📅 ${dateStr}</div>`;
|
||||
|
||||
if (event.time) {
|
||||
tooltipContent += `<div class="tooltip-time">🕐 ${event.time}</div>`;
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
tooltipContent += `<div class="tooltip-description">📝 ${event.description}</div>`;
|
||||
}
|
||||
|
||||
if (event.location) {
|
||||
tooltipContent += `<div class="tooltip-location">📍 ${event.location}</div>`;
|
||||
}
|
||||
|
||||
tooltipContent += `<div class="tooltip-category">🏷️ ${event.category}</div>`;
|
||||
|
||||
this.tooltipEl.innerHTML = tooltipContent;
|
||||
|
||||
// Position tooltip
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const tooltipRect = this.tooltipEl.getBoundingClientRect();
|
||||
|
||||
// Add to DOM temporarily to get dimensions
|
||||
document.body.appendChild(this.tooltipEl);
|
||||
|
||||
// Calculate position
|
||||
let left = rect.left + (rect.width / 2) - (this.tooltipEl.offsetWidth / 2);
|
||||
let top = rect.bottom + 8;
|
||||
|
||||
// Adjust if tooltip goes off screen
|
||||
if (left < 8) left = 8;
|
||||
if (left + this.tooltipEl.offsetWidth > window.innerWidth - 8) {
|
||||
left = window.innerWidth - this.tooltipEl.offsetWidth - 8;
|
||||
}
|
||||
if (top + this.tooltipEl.offsetHeight > window.innerHeight - 8) {
|
||||
top = rect.top - this.tooltipEl.offsetHeight - 8;
|
||||
}
|
||||
|
||||
this.tooltipEl.style.left = `${left}px`;
|
||||
this.tooltipEl.style.top = `${top}px`;
|
||||
|
||||
// Add fade-in animation
|
||||
this.tooltipEl.style.opacity = '0';
|
||||
this.tooltipEl.style.transform = 'translateY(-4px)';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (this.tooltipEl) {
|
||||
this.tooltipEl.style.opacity = '1';
|
||||
this.tooltipEl.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
436
ui/event-modal.ts
Normal file
436
ui/event-modal.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { App, Modal, Setting, Notice, setIcon } from "obsidian";
|
||||
import SpaceforgePlugin from "../main";
|
||||
import { CalendarEvent, EventCategory, EventRecurrence, EVENT_CATEGORY_COLORS } from "../models/calendar-event";
|
||||
|
||||
/**
|
||||
* Modal for creating or editing calendar events
|
||||
*/
|
||||
export class EventModal extends Modal {
|
||||
plugin: SpaceforgePlugin;
|
||||
event: CalendarEvent | null;
|
||||
onSave: (event: CalendarEvent) => void;
|
||||
|
||||
// Form elements
|
||||
titleInput: HTMLInputElement;
|
||||
descriptionInput: HTMLTextAreaElement;
|
||||
dateInput: HTMLInputElement;
|
||||
timeInput: HTMLInputElement;
|
||||
locationInput: HTMLInputElement;
|
||||
categorySelect: HTMLSelectElement;
|
||||
colorInput: HTMLInputElement;
|
||||
isAllDayToggle: HTMLInputElement;
|
||||
recurrenceSelect: HTMLSelectElement;
|
||||
recurrenceEndDateInput: HTMLInputElement;
|
||||
|
||||
constructor(app: App, plugin: SpaceforgePlugin, event: CalendarEvent | null = null, onSave: (event: CalendarEvent) => void) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.event = event;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass("event-modal");
|
||||
|
||||
// Compact header
|
||||
const header = contentEl.createDiv("event-modal-header");
|
||||
const headerIcon = header.createDiv("event-modal-header-icon");
|
||||
setIcon(headerIcon, this.event ? "edit" : "calendar-plus");
|
||||
|
||||
const headerText = header.createDiv("event-modal-header-text");
|
||||
headerText.createEl("h2", {
|
||||
text: this.event ? "Edit Event" : "New Event",
|
||||
cls: "event-modal-title"
|
||||
});
|
||||
|
||||
// Compact form container
|
||||
const formContainer = contentEl.createDiv("event-modal-form");
|
||||
|
||||
// Title row
|
||||
const titleRow = formContainer.createDiv("event-modal-row");
|
||||
const titleGroup = titleRow.createDiv("event-modal-field-group");
|
||||
titleGroup.createEl("label", { text: "Title", cls: "event-modal-label required" });
|
||||
this.titleInput = titleGroup.createEl("input", {
|
||||
type: "text",
|
||||
cls: "event-modal-input",
|
||||
placeholder: "Event title..."
|
||||
});
|
||||
if (this.event?.title) {
|
||||
this.titleInput.value = this.event.title;
|
||||
}
|
||||
this.titleInput.addEventListener("input", () => this.validateForm());
|
||||
|
||||
// Date & Time row
|
||||
const dateTimeRow = formContainer.createDiv("event-modal-row");
|
||||
const dateGroup = dateTimeRow.createDiv("event-modal-field-group");
|
||||
dateGroup.createEl("label", { text: "Date", cls: "event-modal-label" });
|
||||
this.dateInput = dateGroup.createEl("input", {
|
||||
type: "date",
|
||||
cls: "event-modal-input"
|
||||
});
|
||||
if (this.event?.date) {
|
||||
const date = new Date(this.event.date);
|
||||
this.dateInput.value = date.toISOString().split('T')[0];
|
||||
} else {
|
||||
this.dateInput.value = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
this.dateInput.addEventListener("change", () => this.validateForm());
|
||||
|
||||
const timeGroup = dateTimeRow.createDiv("event-modal-field-group");
|
||||
timeGroup.createEl("label", { text: "Time", cls: "event-modal-label" });
|
||||
this.timeInput = timeGroup.createEl("input", {
|
||||
type: "time",
|
||||
cls: "event-modal-input"
|
||||
});
|
||||
if (this.event?.time) {
|
||||
this.timeInput.value = this.event.time;
|
||||
}
|
||||
this.timeInput.addEventListener("change", () => this.updateAllDayToggle());
|
||||
|
||||
// All-day toggle row
|
||||
const allDayRow = formContainer.createDiv("event-modal-row");
|
||||
const allDayGroup = allDayRow.createDiv("event-modal-field-group");
|
||||
this.isAllDayToggle = allDayGroup.createEl("input", {
|
||||
type: "checkbox",
|
||||
cls: "event-modal-checkbox"
|
||||
}) as HTMLInputElement;
|
||||
this.isAllDayToggle.checked = this.event?.isAllDay ?? false;
|
||||
this.isAllDayToggle.addEventListener("change", () => this.toggleTimeInput());
|
||||
|
||||
const allDayLabel = allDayGroup.createEl("label", {
|
||||
text: "All-day event",
|
||||
cls: "event-modal-checkbox-label"
|
||||
});
|
||||
|
||||
// Location row
|
||||
const locationRow = formContainer.createDiv("event-modal-row");
|
||||
const locationGroup = locationRow.createDiv("event-modal-field-group");
|
||||
locationGroup.createEl("label", { text: "Location", cls: "event-modal-label" });
|
||||
this.locationInput = locationGroup.createEl("input", {
|
||||
type: "text",
|
||||
cls: "event-modal-input",
|
||||
placeholder: "Location (optional)..."
|
||||
});
|
||||
if (this.event?.location) {
|
||||
this.locationInput.value = this.event.location;
|
||||
}
|
||||
|
||||
// Category & Color row
|
||||
const categoryColorRow = formContainer.createDiv("event-modal-row");
|
||||
const categoryGroup = categoryColorRow.createDiv("event-modal-field-group");
|
||||
categoryGroup.createEl("label", { text: "Category", cls: "event-modal-label" });
|
||||
this.categorySelect = categoryGroup.createEl("select", {
|
||||
cls: "event-modal-select"
|
||||
});
|
||||
|
||||
Object.values(EventCategory).forEach(category => {
|
||||
const option = this.categorySelect.createEl("option", {
|
||||
value: category,
|
||||
text: category.charAt(0).toUpperCase() + category.slice(1)
|
||||
});
|
||||
this.categorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (this.event?.category) {
|
||||
this.categorySelect.value = this.event.category;
|
||||
} else {
|
||||
this.categorySelect.value = this.plugin.settings.defaultEventCategory || 'personal';
|
||||
}
|
||||
|
||||
this.categorySelect.addEventListener("change", () => this.updateColorFromCategory());
|
||||
|
||||
const colorGroup = categoryColorRow.createDiv("event-modal-field-group");
|
||||
colorGroup.createEl("label", { text: "Color", cls: "event-modal-label" });
|
||||
const colorPickerContainer = colorGroup.createDiv("event-modal-color-picker-compact");
|
||||
this.colorInput = colorPickerContainer.createEl("input", {
|
||||
type: "color",
|
||||
cls: "event-modal-color-input"
|
||||
}) as HTMLInputElement;
|
||||
|
||||
if (this.event?.color) {
|
||||
this.colorInput.value = this.event.color;
|
||||
} else {
|
||||
this.updateColorFromCategory();
|
||||
}
|
||||
|
||||
// Description row (full width)
|
||||
const descriptionRow = formContainer.createDiv("event-modal-row event-modal-full-width");
|
||||
const descriptionGroup = descriptionRow.createDiv("event-modal-field-group");
|
||||
descriptionGroup.createEl("label", { text: "Description", cls: "event-modal-label" });
|
||||
this.descriptionInput = descriptionGroup.createEl("textarea", {
|
||||
cls: "event-modal-textarea",
|
||||
placeholder: "Event details (optional)..."
|
||||
});
|
||||
this.descriptionInput.rows = 2;
|
||||
if (this.event?.description) {
|
||||
this.descriptionInput.value = this.event.description;
|
||||
}
|
||||
|
||||
// Recurrence row
|
||||
const recurrenceRow = formContainer.createDiv("event-modal-row");
|
||||
const recurrenceGroup = recurrenceRow.createDiv("event-modal-field-group");
|
||||
recurrenceGroup.createEl("label", { text: "Recurrence", cls: "event-modal-label" });
|
||||
this.recurrenceSelect = recurrenceGroup.createEl("select", {
|
||||
cls: "event-modal-select"
|
||||
});
|
||||
|
||||
Object.values(EventRecurrence).forEach(recurrence => {
|
||||
const option = this.recurrenceSelect.createEl("option", {
|
||||
value: recurrence,
|
||||
text: this.getRecurrenceDisplayText(recurrence)
|
||||
});
|
||||
this.recurrenceSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (this.event?.recurrence) {
|
||||
this.recurrenceSelect.value = this.event.recurrence;
|
||||
} else {
|
||||
this.recurrenceSelect.value = EventRecurrence.None;
|
||||
}
|
||||
|
||||
this.recurrenceSelect.addEventListener("change", () => this.toggleRecurrenceEndDate());
|
||||
|
||||
// Recurrence end date row (conditional)
|
||||
const recurrenceEndRow = formContainer.createDiv("event-modal-row event-modal-recurrence-end-row");
|
||||
const recurrenceEndGroup = recurrenceEndRow.createDiv("event-modal-field-group");
|
||||
recurrenceEndGroup.createEl("label", { text: "End Date", cls: "event-modal-label" });
|
||||
this.recurrenceEndDateInput = recurrenceEndGroup.createEl("input", {
|
||||
type: "date",
|
||||
cls: "event-modal-input"
|
||||
});
|
||||
if (this.event?.recurrenceEndDate) {
|
||||
const date = new Date(this.event.recurrenceEndDate);
|
||||
this.recurrenceEndDateInput.value = date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Enhanced buttons section
|
||||
const buttonContainer = contentEl.createDiv("event-modal-actions");
|
||||
|
||||
const cancelButton = buttonContainer.createEl("button", {
|
||||
text: "Cancel",
|
||||
cls: "event-modal-btn event-modal-btn-secondary"
|
||||
});
|
||||
cancelButton.addEventListener("click", () => this.close());
|
||||
|
||||
// Add delete button only for existing events
|
||||
if (this.event) {
|
||||
const deleteButton = buttonContainer.createEl("button", {
|
||||
text: "Delete",
|
||||
cls: "event-modal-btn event-modal-btn-danger"
|
||||
});
|
||||
deleteButton.addEventListener("click", () => this.deleteEvent());
|
||||
}
|
||||
|
||||
const saveButton = buttonContainer.createEl("button", {
|
||||
text: this.event ? "Update Event" : "Create Event",
|
||||
cls: "event-modal-btn event-modal-btn-primary"
|
||||
});
|
||||
saveButton.addEventListener("click", () => this.saveEvent());
|
||||
|
||||
// Initial setup
|
||||
this.validateForm();
|
||||
this.updateAllDayToggle();
|
||||
this.toggleTimeInput();
|
||||
this.toggleRecurrenceEndDate();
|
||||
this.updateColorPreview();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form and enable/disable save button
|
||||
*/
|
||||
private validateForm(): void {
|
||||
const saveButton = this.contentEl.querySelector(".event-modal-btn-primary") as HTMLButtonElement;
|
||||
const isValid = this.titleInput.value.trim() !== "" && this.dateInput.value !== "";
|
||||
|
||||
if (saveButton) {
|
||||
saveButton.disabled = !isValid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update color based on selected category
|
||||
*/
|
||||
private updateColorFromCategory(): void {
|
||||
const category = this.categorySelect.value as EventCategory;
|
||||
const categoryColor = EVENT_CATEGORY_COLORS[category];
|
||||
this.colorInput.value = categoryColor;
|
||||
this.updateColorPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update color preview
|
||||
*/
|
||||
private updateColorPreview(): void {
|
||||
const colorPreview = this.contentEl.querySelector(".event-modal-color-preview") as HTMLElement;
|
||||
if (colorPreview) {
|
||||
colorPreview.style.backgroundColor = this.colorInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle time input based on all-day setting
|
||||
*/
|
||||
private toggleTimeInput(): void {
|
||||
const isAllDay = this.isAllDayToggle.checked;
|
||||
const timeContainer = this.timeInput.closest(".event-modal-input-group") as HTMLElement;
|
||||
|
||||
if (timeContainer) {
|
||||
timeContainer.style.display = isAllDay ? "none" : "block";
|
||||
}
|
||||
|
||||
if (isAllDay) {
|
||||
this.timeInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all-day toggle based on time input
|
||||
*/
|
||||
private updateAllDayToggle(): void {
|
||||
const hasTime = this.timeInput.value !== "";
|
||||
if (hasTime && this.isAllDayToggle.checked) {
|
||||
this.isAllDayToggle.checked = false;
|
||||
this.toggleTimeInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle recurrence end date based on recurrence setting
|
||||
*/
|
||||
private toggleRecurrenceEndDate(): void {
|
||||
const recurrence = this.recurrenceSelect.value as EventRecurrence;
|
||||
const endDateContainer = this.recurrenceEndDateInput.closest(".event-modal-recurrence-end-container") as HTMLElement;
|
||||
|
||||
if (endDateContainer) {
|
||||
endDateContainer.style.display = recurrence === EventRecurrence.None ? "none" : "block";
|
||||
}
|
||||
|
||||
if (recurrence === EventRecurrence.None) {
|
||||
this.recurrenceEndDateInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for recurrence option
|
||||
*/
|
||||
private getRecurrenceDisplayText(recurrence: EventRecurrence): string {
|
||||
switch (recurrence) {
|
||||
case EventRecurrence.None: return "None";
|
||||
case EventRecurrence.Daily: return "Daily";
|
||||
case EventRecurrence.Weekly: return "Weekly";
|
||||
case EventRecurrence.Monthly: return "Monthly";
|
||||
case EventRecurrence.Yearly: return "Yearly";
|
||||
default: return recurrence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the event
|
||||
*/
|
||||
private async deleteEvent(): Promise<void> {
|
||||
if (!this.event) return;
|
||||
|
||||
try {
|
||||
const deleted = this.plugin.calendarEventService?.deleteEvent(this.event.id);
|
||||
|
||||
if (deleted) {
|
||||
new Notice("Event deleted successfully");
|
||||
await this.plugin.savePluginData();
|
||||
|
||||
// Trigger calendar refresh through the onSave callback
|
||||
this.onSave(this.event);
|
||||
|
||||
this.close();
|
||||
} else {
|
||||
new Notice("Failed to delete event");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
new Notice("Error deleting event");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the event
|
||||
*/
|
||||
private async saveEvent(): Promise<void> {
|
||||
try {
|
||||
const title = this.titleInput.value.trim();
|
||||
const description = this.descriptionInput.value.trim();
|
||||
const date = new Date(this.dateInput.value);
|
||||
const time = this.timeInput.value;
|
||||
const location = this.locationInput.value.trim();
|
||||
const category = this.categorySelect.value as EventCategory;
|
||||
const color = this.colorInput.value;
|
||||
const isAllDay = this.isAllDayToggle.checked;
|
||||
const recurrence = this.recurrenceSelect.value as EventRecurrence;
|
||||
const recurrenceEndDateText = this.recurrenceEndDateInput.value;
|
||||
const recurrenceEndDate = recurrenceEndDateText ? new Date(recurrenceEndDateText).getTime() : undefined;
|
||||
|
||||
if (!title || !this.dateInput.value) {
|
||||
new Notice("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
let eventData: Omit<CalendarEvent, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
if (this.event) {
|
||||
// Update existing event
|
||||
eventData = {
|
||||
...this.event,
|
||||
title,
|
||||
description: description || undefined,
|
||||
date: date.getTime(),
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
category,
|
||||
color: color !== EVENT_CATEGORY_COLORS[category] ? color : undefined,
|
||||
isAllDay,
|
||||
recurrence,
|
||||
recurrenceEndDate
|
||||
};
|
||||
|
||||
const updatedEvent = this.plugin.calendarEventService?.updateEvent(this.event.id, eventData);
|
||||
if (updatedEvent) {
|
||||
this.onSave(updatedEvent);
|
||||
new Notice("Event updated successfully");
|
||||
}
|
||||
} else {
|
||||
// Create new event
|
||||
eventData = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
date: date.getTime(),
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
category,
|
||||
color: color !== EVENT_CATEGORY_COLORS[category] ? color : undefined,
|
||||
isAllDay,
|
||||
recurrence,
|
||||
recurrenceEndDate
|
||||
};
|
||||
|
||||
const newEvent = this.plugin.calendarEventService?.createEvent(eventData);
|
||||
if (newEvent) {
|
||||
this.onSave(newEvent);
|
||||
new Notice("Event created successfully");
|
||||
}
|
||||
}
|
||||
|
||||
// Save plugin data
|
||||
await this.plugin.savePluginData();
|
||||
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Error saving event:", error);
|
||||
new Notice("Error saving event");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +106,7 @@ export class SpaceforgeSettingTab extends PluginSettingTab {
|
|||
pomodoroUserOverrideHours: this.plugin.pluginState.pomodoroUserOverrideHours ?? 0,
|
||||
pomodoroUserOverrideMinutes: this.plugin.pluginState.pomodoroUserOverrideMinutes ?? 0,
|
||||
pomodoroUserAddToEstimation: this.plugin.pluginState.pomodoroUserAddToEstimation ?? false,
|
||||
calendarEvents: this.plugin.pluginState.calendarEvents ?? {},
|
||||
};
|
||||
|
||||
const dataToExport: SpaceforgePluginData = {
|
||||
|
|
|
|||
283
ui/upcoming-events.ts
Normal file
283
ui/upcoming-events.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import SpaceforgePlugin from "../main";
|
||||
import { UpcomingEvent } from "../models/calendar-event";
|
||||
import { DateUtils } from "../utils/dates";
|
||||
|
||||
/**
|
||||
* Upcoming events component for calendar view
|
||||
*/
|
||||
export class UpcomingEvents {
|
||||
/**
|
||||
* Reference to the main plugin
|
||||
*/
|
||||
plugin: SpaceforgePlugin;
|
||||
|
||||
/**
|
||||
* Container element for upcoming events
|
||||
*/
|
||||
containerEl: HTMLElement;
|
||||
|
||||
/**
|
||||
* Upcoming events data
|
||||
*/
|
||||
upcomingEvents: UpcomingEvent[] = [];
|
||||
|
||||
/**
|
||||
* Initialize upcoming events component
|
||||
*
|
||||
* @param containerEl Container element
|
||||
* @param plugin Reference to the main plugin
|
||||
*/
|
||||
constructor(containerEl: HTMLElement, plugin: SpaceforgePlugin) {
|
||||
this.containerEl = containerEl;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render upcoming events list
|
||||
*/
|
||||
async render(): Promise<void> {
|
||||
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.settings.showUpcomingEvents) {
|
||||
this.containerEl.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.plugin.calendarEventService) {
|
||||
this.containerEl.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load upcoming events data
|
||||
await this.loadUpcomingEvents();
|
||||
|
||||
// Clear container
|
||||
this.containerEl.empty();
|
||||
|
||||
if (this.upcomingEvents.length === 0) {
|
||||
this.renderEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderEventsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load upcoming events data
|
||||
*/
|
||||
async loadUpcomingEvents(): Promise<void> {
|
||||
this.upcomingEvents = this.plugin.calendarEventService.getUpcomingEvents(
|
||||
this.plugin.settings.upcomingEventsDays || 7
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state when no upcoming events
|
||||
*/
|
||||
private renderEmptyState(): void {
|
||||
const emptyState = this.containerEl.createDiv("upcoming-events-empty");
|
||||
|
||||
const emptyIcon = emptyState.createDiv("upcoming-events-empty-icon");
|
||||
setIcon(emptyIcon, "calendar");
|
||||
|
||||
const emptyText = emptyState.createDiv("upcoming-events-empty-text");
|
||||
emptyText.setText("No upcoming events");
|
||||
|
||||
const emptySubtext = emptyState.createDiv("upcoming-events-empty-subtext");
|
||||
emptySubtext.setText("Events will appear here once you create them");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the events list
|
||||
*/
|
||||
private renderEventsList(): void {
|
||||
// Create header
|
||||
const header = this.containerEl.createDiv("upcoming-events-header");
|
||||
|
||||
const headerTitle = header.createDiv("upcoming-events-title");
|
||||
headerTitle.setText("Upcoming Events");
|
||||
|
||||
const headerSubtitle = header.createDiv("upcoming-events-subtitle");
|
||||
const daysText = this.plugin.settings.upcomingEventsDays || 7;
|
||||
headerSubtitle.setText(`Next ${daysText} days`);
|
||||
|
||||
// Create events container
|
||||
const eventsContainer = this.containerEl.createDiv("upcoming-events-container");
|
||||
|
||||
// Group events by day
|
||||
const eventsByDay = this.groupEventsByDay();
|
||||
|
||||
// Render each day's events
|
||||
eventsByDay.forEach((dayEvents, dayKey) => {
|
||||
this.renderDaySection(eventsContainer, dayKey, dayEvents);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group upcoming events by day
|
||||
*/
|
||||
private groupEventsByDay(): Map<string, UpcomingEvent[]> {
|
||||
const eventsByDay = new Map<string, UpcomingEvent[]>();
|
||||
|
||||
this.upcomingEvents.forEach(upcomingEvent => {
|
||||
let dayKey: string;
|
||||
|
||||
if (upcomingEvent.isToday) {
|
||||
dayKey = "Today";
|
||||
} else if (upcomingEvent.isTomorrow) {
|
||||
dayKey = "Tomorrow";
|
||||
} else {
|
||||
const eventDate = new Date(upcomingEvent.event.date);
|
||||
dayKey = eventDate.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
if (!eventsByDay.has(dayKey)) {
|
||||
eventsByDay.set(dayKey, []);
|
||||
}
|
||||
|
||||
eventsByDay.get(dayKey)!.push(upcomingEvent);
|
||||
});
|
||||
|
||||
return eventsByDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a day section with its events
|
||||
*/
|
||||
private renderDaySection(container: HTMLElement, dayKey: string, dayEvents: UpcomingEvent[]): void {
|
||||
const daySection = container.createDiv("upcoming-events-day-section");
|
||||
|
||||
// Day header
|
||||
const dayHeader = daySection.createDiv("upcoming-events-day-header");
|
||||
|
||||
const dayTitle = dayHeader.createDiv("upcoming-events-day-title");
|
||||
dayTitle.setText(dayKey);
|
||||
|
||||
if (dayKey === "Today") {
|
||||
daySection.addClass("today");
|
||||
} else if (dayKey === "Tomorrow") {
|
||||
daySection.addClass("tomorrow");
|
||||
}
|
||||
|
||||
// Events for this day
|
||||
const dayEventsContainer = daySection.createDiv("upcoming-events-day-events");
|
||||
|
||||
dayEvents.forEach(upcomingEvent => {
|
||||
this.renderEventItem(dayEventsContainer, upcomingEvent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single event item
|
||||
*/
|
||||
private renderEventItem(container: HTMLElement, upcomingEvent: UpcomingEvent): void {
|
||||
const event = upcomingEvent.event;
|
||||
const eventItem = container.createDiv("upcoming-events-event-item");
|
||||
|
||||
// Event color indicator
|
||||
const colorIndicator = eventItem.createDiv("upcoming-events-event-color");
|
||||
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
|
||||
colorIndicator.style.backgroundColor = eventColor;
|
||||
|
||||
// Event content
|
||||
const eventContent = eventItem.createDiv("upcoming-events-event-content");
|
||||
|
||||
// Event title and time
|
||||
const eventHeader = eventContent.createDiv("upcoming-events-event-header");
|
||||
|
||||
const eventTitle = eventHeader.createDiv("upcoming-events-event-title");
|
||||
eventTitle.setText(event.title);
|
||||
|
||||
if (event.time) {
|
||||
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
|
||||
eventTime.setText(event.time);
|
||||
} else if (event.isAllDay) {
|
||||
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
|
||||
eventTime.setText("All day");
|
||||
}
|
||||
|
||||
// Event details
|
||||
if (event.description || event.location) {
|
||||
const eventDetails = eventContent.createDiv("upcoming-events-event-details");
|
||||
|
||||
if (event.location) {
|
||||
const eventLocation = eventDetails.createDiv("upcoming-events-event-location");
|
||||
setIcon(eventLocation, "map-pin");
|
||||
eventLocation.appendText(" " + event.location);
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
const eventDescription = eventDetails.createDiv("upcoming-events-event-description");
|
||||
eventDescription.setText(event.description.substring(0, 100) + (event.description.length > 100 ? "..." : ""));
|
||||
}
|
||||
}
|
||||
|
||||
// Event category
|
||||
const eventCategory = eventContent.createDiv("upcoming-events-event-category");
|
||||
eventCategory.setText(event.category);
|
||||
|
||||
// Click handler to show event details
|
||||
eventItem.addEventListener("click", () => {
|
||||
this.showEventDetails(event);
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
eventItem.addEventListener("mouseenter", () => {
|
||||
eventItem.addClass("hover");
|
||||
});
|
||||
|
||||
eventItem.addEventListener("mouseleave", () => {
|
||||
eventItem.removeClass("hover");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show event details modal
|
||||
*
|
||||
* @param event Event to show details for
|
||||
*/
|
||||
private showEventDetails(event: any): void {
|
||||
const eventDate = new Date(event.date);
|
||||
const dateStr = eventDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
let details = `📅 ${event.title}\n📆 ${dateStr}`;
|
||||
|
||||
if (event.time) {
|
||||
details += `\n🕐 ${event.time}`;
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
details += `\n📝 ${event.description}`;
|
||||
}
|
||||
|
||||
if (event.location) {
|
||||
details += `\n📍 ${event.location}`;
|
||||
}
|
||||
|
||||
details += `\n🏷️ ${event.category}`;
|
||||
|
||||
new Notice(details, 8000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the upcoming events display
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
await this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the component
|
||||
*/
|
||||
destroy(): void {
|
||||
this.containerEl.empty();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue