diff --git a/components/calendar.templ b/components/calendar.templ new file mode 100644 index 0000000..ee7cbf5 --- /dev/null +++ b/components/calendar.templ @@ -0,0 +1,651 @@ +package components + +import ( + "context" + "fmt" + "github.com/starfleetcptn/gomft/internal/db" + "time" + "encoding/json" + "strings" + "strconv" + "sort" +) + +// JobCalendarData contains the data for the calendar view +type JobCalendarData struct { + Jobs []db.Job +} + +// generateCalendarEvents converts jobs to calendar events in JSON format +func generateCalendarEvents(jobs []db.Job) string { + type CalendarEvent struct { + ID string `json:"id"` + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end,omitempty"` + AllDay bool `json:"allDay,omitempty"` + URL string `json:"url,omitempty"` + ClassName string `json:"className,omitempty"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + JobID uint `json:"jobId"` + JobName string `json:"jobName"` + RunTimes []string `json:"runTimes,omitempty"` // Store additional run times for this day + RunCount int `json:"runCount,omitempty"` // Count of runs on this day + Schedule string `json:"schedule,omitempty"` // Store the schedule for tooltip + } + + var events []CalendarEvent + + // Set the range for future occurrences - 2 months seems to be a good balance + now := time.Now() + twoMonthsLater := now.AddDate(0, 2, 0) + + // Map to track events by job ID and date to consolidate multiple occurrences + eventsByJobAndDay := make(map[string][]time.Time) + + // Store job information for easy access + jobInfo := make(map[uint]struct { + Name string + Enabled bool + Schedule string + }) + + // First, gather all runs and group them by job ID and day + for _, job := range jobs { + // Skip jobs with no next run time + if job.NextRun == nil { + continue + } + + // Store job information + jobName := job.Name + if jobName == "" { + jobName = job.Config.Name + } + + jobInfo[job.ID] = struct { + Name string + Enabled bool + Schedule string + }{ + Name: jobName, + Enabled: job.GetEnabled(), + Schedule: job.Schedule, + } + + // Create the first occurrence based on NextRun + nextRun := *job.NextRun + + // Skip past events that are more than a day old + oneDayAgo := now.AddDate(0, 0, -1) + if nextRun.Before(oneDayAgo) { + // For past events, if we have LastRun, use that instead + if job.LastRun != nil { + nextRun = *job.LastRun + // Still skip if it's too old + if nextRun.Before(oneDayAgo) { + continue + } + } else { + continue + } + } + + // Add initial run to the map + dateKey := fmt.Sprintf("%d-%s", job.ID, nextRun.Format("2006-01-02")) + eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], nextRun) + + // Try to determine future occurrences based on the cron schedule + var interval time.Duration + schedule := strings.ToLower(job.Schedule) + + // Determine interval based on schedule + switch { + case strings.Contains(schedule, "every minute") || strings.Contains(schedule, "* * * * *"): + interval = 1 * time.Minute + case strings.Contains(schedule, "every 5 minutes") || strings.Contains(schedule, "*/5 * * * *"): + interval = 5 * time.Minute + case strings.Contains(schedule, "every 10 minutes") || strings.Contains(schedule, "*/10 * * * *"): + interval = 10 * time.Minute + case strings.Contains(schedule, "every 15 minutes") || strings.Contains(schedule, "*/15 * * * *"): + interval = 15 * time.Minute + case strings.Contains(schedule, "every 30 minutes") || strings.Contains(schedule, "*/30 * * * *"): + interval = 30 * time.Minute + case strings.Contains(schedule, "hourly") || strings.Contains(schedule, "0 * * * *"): + interval = 1 * time.Hour + case strings.Contains(schedule, "every 2 hours") || strings.Contains(schedule, "0 */2 * * *"): + interval = 2 * time.Hour + case strings.Contains(schedule, "every 3 hours") || strings.Contains(schedule, "0 */3 * * *"): + interval = 3 * time.Hour + case strings.Contains(schedule, "every 4 hours") || strings.Contains(schedule, "0 */4 * * *"): + interval = 4 * time.Hour + case strings.Contains(schedule, "every 6 hours") || strings.Contains(schedule, "0 */6 * * *"): + interval = 6 * time.Hour + case strings.Contains(schedule, "every 12 hours") || strings.Contains(schedule, "0 */12 * * *"): + interval = 12 * time.Hour + case strings.Contains(schedule, "daily") || strings.Contains(schedule, "0 0 * * *"): + interval = 24 * time.Hour + case strings.Contains(schedule, "weekly") || strings.Contains(schedule, "0 0 * * 0"): + interval = 7 * 24 * time.Hour + case strings.Contains(schedule, "monthly") || strings.Contains(schedule, "0 0 1 * *"): + // Approximate as 30 days + interval = 30 * 24 * time.Hour + default: + // For other schedules, try a simple cron expression check + if strings.Contains(schedule, "* * * * *") { + // Every minute + interval = 1 * time.Minute + } else if strings.Contains(schedule, "*/") { + // Likely a recurring job with specific interval + interval = 1 * time.Hour // Default to hourly as a safe guess + } else { + continue + } + } + + // Generate future occurrences + // Limit the number of runs we'll capture per day + maxRunsPerDay := 20 + + // Generate future occurrences up to our limits + currentTime := nextRun.Add(interval) + + for currentTime.Before(twoMonthsLater) { + dateKey := fmt.Sprintf("%d-%s", job.ID, currentTime.Format("2006-01-02")) + + // Check if we already have too many runs for this day + if len(eventsByJobAndDay[dateKey]) < maxRunsPerDay { + eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], currentTime) + } + + currentTime = currentTime.Add(interval) + } + } + + // Now convert the map to calendar events, consolidating runs on the same day + for dateKey, runTimes := range eventsByJobAndDay { + // Parse job ID from date key + parts := strings.Split(dateKey, "-") + jobIDStr := parts[0] + + jobID, _ := strconv.ParseUint(jobIDStr, 10, 32) + + // Get the job info + job, exists := jobInfo[uint(jobID)] + if !exists { + continue + } + + // Sort run times chronologically + sort.Slice(runTimes, func(i, j int) bool { + return runTimes[i].Before(runTimes[j]) + }) + + // Use the first run time as the event time + firstRunTime := runTimes[0] + + // Format run times for display in tooltip + formattedTimes := make([]string, 0, len(runTimes)) + for i, rt := range runTimes { + // Limit to showing max 10 times in tooltip + if i >= 10 { + formattedTimes = append(formattedTimes, fmt.Sprintf("... and %d more", len(runTimes)-10)) + break + } + formattedTimes = append(formattedTimes, rt.Format("15:04:05")) + } + + // Set class based on job enabled status + className := "" + if job.Enabled { + className = "bg-blue-200 border-blue-600 text-blue-800 dark:bg-blue-800 dark:border-blue-500 dark:text-blue-100" + } else { + className = "bg-gray-200 border-gray-400 text-gray-700 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300" + } + + // Create a single event for this job on this day + title := job.Name + if len(runTimes) > 1 { + title = fmt.Sprintf("%s (%d runs)", job.Name, len(runTimes)) + } + + // Create event + events = append(events, CalendarEvent{ + ID: fmt.Sprintf("job-%d-%s", jobID, firstRunTime.Format("20060102")), + Title: title, + Start: firstRunTime.Format(time.RFC3339), + AllDay: false, + URL: fmt.Sprintf("/jobs/%d", jobID), + ClassName: className, + Description: fmt.Sprintf("Schedule: %s", job.Schedule), + Enabled: job.Enabled, + JobID: uint(jobID), + JobName: job.Name, + RunTimes: formattedTimes, + RunCount: len(runTimes), + Schedule: job.Schedule, + }) + } + + // Debug info + fmt.Printf("Generated %d consolidated calendar events\n", len(events)) + + eventsJSON, err := json.Marshal(events) + if err != nil { + return "[]" // Return empty array if marshaling fails + } + + return string(eventsJSON) +} + +// JobCalendar displays scheduled jobs in a calendar view +templ JobCalendar(ctx context.Context, data JobCalendarData) { + @LayoutWithContext("Transfer Calendar", ctx) { +
+
+
+
+ +

Transfer Calendar

+
+ + + New Job + +
+ +
+
+ + + + +
+
+ + + + + +
+
+ Loading calendar... +
+ + +
+
+ + +
+

About the Calendar View

+

+ This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details. +

+ +
+

+ + Jobs with multiple runs on the same day are consolidated into a single event. Hover over any event to see all scheduled run times for that day. +

+
+ +

Filter Options

+
+
+ +
+

All Jobs

+

Shows all scheduled occurrences in the selected timeframe.

+
+
+
+ +
+

Active Only

+

Shows only enabled jobs that will actually run.

+
+
+
+ +
+

Inactive Only

+

Shows disabled jobs that won't run unless re-enabled.

+
+
+
+ +
+

Next Occurrences Only

+

Shows only the next upcoming occurrence of each job.

+
+
+
+ +

Legend

+
+
+
+ Active Jobs +
+
+
+ Inactive Jobs +
+
+
+ + + + + + + } +} \ No newline at end of file diff --git a/components/configs.templ b/components/configs.templ index fa91209..8a37f0a 100644 --- a/components/configs.templ +++ b/components/configs.templ @@ -394,7 +394,7 @@ templ Configs(ctx context.Context, data ConfigsData) {

- if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { + if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { Authentication Required @@ -402,7 +402,7 @@ templ Configs(ctx context.Context, data ConfigsData) { } - if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() { + if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() { Authenticated @@ -411,7 +411,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
- if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { + if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { Authenticate diff --git a/components/layout.templ b/components/layout.templ index 8fbeab1..3c7dc5e 100644 --- a/components/layout.templ +++ b/components/layout.templ @@ -173,6 +173,10 @@ templ LayoutWithContext(title string, ctx context.Context) { Scheduled Jobs + + + Transfer Calendar + Transfer History @@ -281,6 +285,10 @@ templ LayoutWithContext(title string, ctx context.Context) { Scheduled Jobs + + + Transfer Calendar + Transfer History diff --git a/components/providers/common/common.templ b/components/providers/common/common.templ index 5462ae7..9c75656 100644 --- a/components/providers/common/common.templ +++ b/components/providers/common/common.templ @@ -254,7 +254,7 @@ templ SourceSelection(providers []db.StorageProvider) { - + @@ -336,7 +336,7 @@ templ DestinationSelection(providers []db.StorageProvider) { - + diff --git a/components/storage_provider_form.templ b/components/storage_provider_form.templ index 8502243..3f367d0 100644 --- a/components/storage_provider_form.templ +++ b/components/storage_provider_form.templ @@ -161,7 +161,7 @@ templ formFields(data StorageProviderFormData) { selected="selected" } >OneDrive -
- if (provider.Type == "gdrive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) { + if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
Authentication required for Google - if provider.Type == "gdrive" { + if provider.Type == "drive" { Drive } else { Photos diff --git a/components/storage_providers_import.templ b/components/storage_providers_import.templ index db031f8..3ca67da 100644 --- a/components/storage_providers_import.templ +++ b/components/storage_providers_import.templ @@ -230,7 +230,7 @@ templ RcloneImportPreviewContent(ctx context.Context, preview RcloneImportPrevie } - if remote.Type == "gdrive" || remote.Type == "onedrive" || remote.Type == "gphotos" { + if remote.Type == "drive" || remote.Type == "onedrive" || remote.Type == "gphotos" { if _, exists := remote.Fields["client_id"]; !exists {
diff --git a/internal/db/migrate_provider_data.go b/internal/db/migrate_provider_data.go index efdfcfa..0afcd12 100644 --- a/internal/db/migrate_provider_data.go +++ b/internal/db/migrate_provider_data.go @@ -248,7 +248,7 @@ func extractSourceProviderConfig(config *TransferConfig) *ProviderConfig { } // Special handling for OAuth authentication status - if config.SourceType == "gdrive" || config.SourceType == "gphotos" { + if config.SourceType == "drive" || config.SourceType == "gphotos" { authenticated := config.GetGoogleAuthenticated() sourceConfig.Authenticated = &authenticated } @@ -296,7 +296,7 @@ func extractDestinationProviderConfig(config *TransferConfig) *ProviderConfig { } // Special handling for OAuth authentication status - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + if config.DestinationType == "drive" || config.DestinationType == "gphotos" { authenticated := config.GetGoogleAuthenticated() destConfig.Authenticated = &authenticated } diff --git a/internal/db/storage_provider.go b/internal/db/storage_provider.go index 1697c8c..f974713 100644 --- a/internal/db/storage_provider.go +++ b/internal/db/storage_provider.go @@ -13,7 +13,7 @@ const ( ProviderTypeSFTP StorageProviderType = "sftp" ProviderTypeS3 StorageProviderType = "s3" ProviderTypeOneDrive StorageProviderType = "onedrive" - ProviderTypeGoogleDrive StorageProviderType = "gdrive" + ProviderTypeGoogleDrive StorageProviderType = "drive" ProviderTypeGooglePhoto StorageProviderType = "gphotos" ProviderTypeFTP StorageProviderType = "ftp" ProviderTypeSMB StorageProviderType = "smb" diff --git a/internal/db/transfer_config_store.go b/internal/db/transfer_config_store.go index 444ae43..9ba5002 100644 --- a/internal/db/transfer_config_store.go +++ b/internal/db/transfer_config_store.go @@ -465,7 +465,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { return fmt.Errorf("failed to write source config (local): %v", err) } - case "gdrive": + case "drive": // For Google Drive, we need client ID and secret clientID := getStringValue(sourceCredentials, "client_id", config.SourceClientID) @@ -1018,7 +1018,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { if _, err := f.WriteString(content); err != nil { return fmt.Errorf("failed to write destination config (local): %v", err) } - case "gdrive": + case "drive": // For Google Drive, we need client ID and secret clientID := getStringValue(destCredentials, "client_id", config.DestClientID) @@ -1244,7 +1244,7 @@ func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error { // Check if we're using a provider reference and update the provider instead if config.IsUsingDestinationProviderReference() && config.DestinationProvider != nil && - (config.DestinationProvider.Type == "gdrive" || config.DestinationProvider.Type == "gphotos") { + (config.DestinationProvider.Type == "drive" || config.DestinationProvider.Type == "gphotos") { // Update the provider with the token provider := config.DestinationProvider provider.RefreshToken = token // Set the clear token temporarily @@ -1257,7 +1257,7 @@ func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error { // Continue with creating the rclone config file since this is still needed for transfers } else if config.IsUsingSourceProviderReference() && config.SourceProvider != nil && - (config.SourceProvider.Type == "gdrive" || config.SourceProvider.Type == "gphotos") { + (config.SourceProvider.Type == "drive" || config.SourceProvider.Type == "gphotos") { // Update the provider with the token provider := config.SourceProvider provider.RefreshToken = token // Set the clear token temporarily @@ -1338,7 +1338,7 @@ func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string var startYear int // Determine if source or destination needs token update - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + if config.DestinationType == "drive" || config.DestinationType == "gphotos" { configType = config.DestinationType section = "dest" clientID = config.DestClientID @@ -1346,7 +1346,7 @@ func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string readOnly = config.DestReadOnly startYear = config.DestStartYear includeArchived = config.DestIncludeArchived - } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { + } else if config.SourceType == "drive" || config.SourceType == "gphotos" { configType = config.SourceType section = "source" clientID = config.SourceClientID @@ -1367,7 +1367,7 @@ func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string var sectionContent string sectionHeader := fmt.Sprintf("[%s_%d]", section, config.ID) - if configType == "gdrive" { + if configType == "drive" { sectionContent = sectionHeader + "\ntype = drive\n" if clientID != "" { sectionContent += fmt.Sprintf("client_id = %s\n", clientID) @@ -1432,9 +1432,9 @@ func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string // Update the authentication status in DB authenticated := true - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + if config.DestinationType == "drive" || config.DestinationType == "gphotos" { config.SetGoogleAuthenticated(authenticated) - } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { + } else if config.SourceType == "drive" || config.SourceType == "gphotos" { config.SetGoogleAuthenticated(authenticated) } // Persist the change (assuming UpdateTransferConfig saves the whole object) @@ -1624,7 +1624,7 @@ func (db *DB) ConvertToProviderReferences(config *TransferConfig) error { } // For Google Drive/Photos, carry over authentication status - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + if config.DestinationType == "drive" || config.DestinationType == "gphotos" { provider.SetAuthenticated(config.GetGoogleAuthenticated()) } diff --git a/internal/rclone_service/rclone_service.go b/internal/rclone_service/rclone_service.go index 516dc0b..018a510 100644 --- a/internal/rclone_service/rclone_service.go +++ b/internal/rclone_service/rclone_service.go @@ -219,7 +219,7 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta if pass != "" { createArgs = append(createArgs, "pass", pass) } - case "gdrive": + case "drive": createArgs = append(createArgs, "scope", "drive") if clientID != "" { createArgs = append(createArgs, "client_id", clientID) diff --git a/internal/web/handlers/gdrive_handlers.go b/internal/web/handlers/gdrive_handlers.go index f231825..efc9a6f 100644 --- a/internal/web/handlers/gdrive_handlers.go +++ b/internal/web/handlers/gdrive_handlers.go @@ -37,7 +37,7 @@ func (h *Handlers) HandleGDriveAuth(c *gin.Context) { } // Ensure it's a Google Drive or Google Photos configuration - if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" { + if config.SourceType != "drive" && config.DestinationType != "drive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" { RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos") return } @@ -132,7 +132,7 @@ func (h *Handlers) HandleGDriveAuth(c *gin.Context) { } // Create a config file with redirect URI-based auth - configType := "gdrive" + configType := "drive" if config.DestinationType == "gphotos" { configType = "gphotos" } @@ -362,7 +362,7 @@ func (h *Handlers) HandleGDriveTokenProcess(c *gin.Context) { } // Ensure it's a Google Drive or Google Photos configuration - if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" { + if config.SourceType != "drive" && config.DestinationType != "drive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" { RenderErrorPage(c, "Not a Google Drive configuration", "") return } diff --git a/internal/web/handlers/job_handlers.go b/internal/web/handlers/job_handlers.go index 718d306..2c0dca4 100644 --- a/internal/web/handlers/job_handlers.go +++ b/internal/web/handlers/job_handlers.go @@ -42,6 +42,23 @@ func (h *Handlers) HandleJobs(c *gin.Context) { components.Jobs(c, data).Render(c, c.Writer) } +// HandleCalendarView handles the GET /calendar route +func (h *Handlers) HandleCalendarView(c *gin.Context) { + userID := c.GetUint("userID") + + // Get all jobs with Next Run time for this user + var jobs []db.Job + h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs) + + // Prepare data for the calendar view + data := components.JobCalendarData{ + Jobs: jobs, + } + + // Render the calendar view + components.JobCalendar(c, data).Render(c, c.Writer) +} + // HandleJobRunDetails handles the GET /job/:id route func (h *Handlers) HandleJobRunDetails(c *gin.Context) { userID := c.GetUint("userID") diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go index 3c7b641..22e2b57 100644 --- a/internal/web/handlers/routes.go +++ b/internal/web/handlers/routes.go @@ -109,6 +109,10 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) { authorized.POST("/jobs/:id/run", h.HandleRunJob) authorized.GET("/history", h.HandleHistory) authorized.GET("/job-runs/:id", h.HandleJobRunDetails) + + // Calendar view route + authorized.GET("/calendar", h.HandleCalendarView) + authorized.GET("/profile", h.HandleProfile) authorized.POST("/profile/theme", h.HandleUpdateTheme) authorized.POST("/logout", h.HandleLogout) diff --git a/internal/web/handlers/storage_provider_gdrive_handlers.go b/internal/web/handlers/storage_provider_gdrive_handlers.go index c4a48b2..cd1b336 100644 --- a/internal/web/handlers/storage_provider_gdrive_handlers.go +++ b/internal/web/handlers/storage_provider_gdrive_handlers.go @@ -39,7 +39,7 @@ func (h *Handlers) HandleStorageProviderGDriveAuth(c *gin.Context) { } // Ensure it's a Google Drive or Google Photos provider - if provider.Type != "gdrive" && provider.Type != "gphotos" { + if provider.Type != "drive" && provider.Type != "gphotos" { RenderErrorPage(c, "Not a Google provider", "The selected provider is not set up for Google Drive or Google Photos") return } @@ -113,7 +113,7 @@ func (h *Handlers) HandleStorageProviderGDriveAuth(c *gin.Context) { } // Create a config file with redirect URI-based auth - configType := "gdrive" + configType := "drive" if provider.Type == "gphotos" { configType = "gphotos" } @@ -331,7 +331,7 @@ func (h *Handlers) HandleStorageProviderGDriveTokenProcess(c *gin.Context) { } // Ensure it's a Google Drive or Google Photos provider - if provider.Type != "gdrive" && provider.Type != "gphotos" { + if provider.Type != "drive" && provider.Type != "gphotos" { RenderErrorPage(c, "Not a Google provider", "") return } @@ -378,15 +378,15 @@ func (h *Handlers) HandleStorageProviderGDriveHeadlessAuth(c *gin.Context) { } // Ensure it's a Google Drive or Google Photos provider - if provider.Type != "gdrive" && provider.Type != "gphotos" { + if provider.Type != "drive" && provider.Type != "gphotos" { RenderErrorPage(c, "Not a Google provider", "The selected provider is not set up for Google Drive or Google Photos") return } // Determine which Google service we're authenticating with var serviceType string - if provider.Type == "gdrive" { - serviceType = "gdrive" + if provider.Type == "drive" { + serviceType = "drive" } else { serviceType = "gphotos" } diff --git a/internal/web/handlers/storage_provider_handlers.go b/internal/web/handlers/storage_provider_handlers.go index 555d5b0..364724d 100644 --- a/internal/web/handlers/storage_provider_handlers.go +++ b/internal/web/handlers/storage_provider_handlers.go @@ -898,7 +898,7 @@ func storageProviderFromRcloneSection(name string, section map[string]string, us } // Optionally: you can check for known types and set generic if not recognized knownTypes := map[string]bool{ - "sftp": true, "s3": true, "onedrive": true, "gdrive": true, "gphotos": true, "ftp": true, "smb": true, "hetzner": true, "local": true, "webdav": true, "nextcloud": true, "b2": true, "wasabi": true, "minio": true, + "sftp": true, "s3": true, "onedrive": true, "drive": true, "gphotos": true, "ftp": true, "smb": true, "hetzner": true, "local": true, "webdav": true, "nextcloud": true, "b2": true, "wasabi": true, "minio": true, } if !knownTypes[providerType] { providerType = string(db.ProviderTypeGeneric) diff --git a/internal/web/handlers/storage_provider_handlers_test.go b/internal/web/handlers/storage_provider_handlers_test.go index f83bf91..9d80a5b 100644 --- a/internal/web/handlers/storage_provider_handlers_test.go +++ b/internal/web/handlers/storage_provider_handlers_test.go @@ -426,7 +426,7 @@ func TestInputValidation(t *testing.T) { "ftp": true, "smb": true, "onedrive": true, - "gdrive": true, + "drive": true, "gphotos": true, "hetzner": true, "local": true, diff --git a/package-lock.json b/package-lock.json index 7faf9c3..a683a7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,19 @@ "hasInstallScript": true, "dependencies": { "@fortawesome/fontawesome-free": "^6.4.0", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/list": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@popperjs/core": "^2.11.8", "alpinejs": "^3.13.5", "esbuild": "^0.20.1", "flowbite": "^2.2.1", + "fullcalendar": "^5.11.3", "htmx.org": "^1.9.10", - "tailwindcss": "^3.4.1" + "tailwindcss": "^3.4.1", + "tippy.js": "^6.3.7" }, "devDependencies": { "@playwright/test": "^1.46.0", @@ -403,15 +411,62 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.0.tgz", - "integrity": "sha512-0NyytTlPJwB/BF5LtRV8rrABDbe3TdTXqNB3PdZ+UUUZAEIrdOJdmABqKjt4AXwIoJNaRVVZEXxpNrqvE1GAYQ==", - "hasInstallScript": true, + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.17", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.17.tgz", + "integrity": "sha512-0W7lnIrv18ruJ5zeWBeNZXO8qCWlzxDdp9COFEsZnyNjiEhUVnrW/dPbjRKYpL0edGG0/Lhs0ghp1z/5ekt8ZA==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.17", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.17.tgz", + "integrity": "sha512-K7m+pd7oVJ9fW4h7CLDdDGJbc9szJ1xDU1DZ2ag+7oOo1aCNLv44CehzkkknM6r8EYlOOhgaelxQpKAI4glj7A==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.17" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.17", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.17.tgz", + "integrity": "sha512-AudvQvgmJP2FU89wpSulUUjeWv24SuyCx8FzH2WIPVaYg+vDGGYarI7K6PcM3TH7B/CyaBjm5Rqw9lXgnwt5YA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.17" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.17", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz", + "integrity": "sha512-fkyK49F9IxwlGUBVhJGsFpd/LTi/vRVERLIAe1HmBaGkjwpxnynm8TMLb9mZip97wvDk3CmZWduMe6PxscAlow==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.17" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.17", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.17.tgz", + "integrity": "sha512-K4PlA3L3lclLOs3IX8cvddeiJI9ZVMD7RA9IqaWwbvac771971foc9tFze9YY+Pqesf6S+vhS2dWtEVlERaGlQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.17" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.17" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -548,6 +603,64 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, "node_modules/@vue/reactivity": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", @@ -564,9 +677,9 @@ "license": "MIT" }, "node_modules/alpinejs": { - "version": "3.13.5", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.5.tgz", - "integrity": "sha512-1d2XeNGN+Zn7j4mUAKXtAgdc4/rLeadyTMWeJGXF5DzwawPBxwTiBhFFm6w/Ei8eJxUZeyNWWSD9zknfdz1kEw==", + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz", + "integrity": "sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==", "license": "MIT", "dependencies": { "@vue/reactivity": "~3.1.1" @@ -615,6 +728,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -758,6 +883,15 @@ "node": ">=4" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -890,15 +1024,26 @@ } }, "node_modules/flowbite": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.2.1.tgz", - "integrity": "sha512-iiZyBTtriEDRHrqXZgpKHaxl4B2J8HZUP8Yn1RXozUDKszWHDVj4GxQqMMB9AJHRWOgXV/4E/LJZ/zqQgBUhWA==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz", + "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==", "license": "MIT", "dependencies": { "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^1.3.0", "mini-svg-data-uri": "^1.4.3" } }, + "node_modules/flowbite-datepicker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz", + "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "flowbite": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -916,9 +1061,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -929,6 +1074,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fullcalendar": { + "version": "5.11.3", + "resolved": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-5.11.3.tgz", + "integrity": "sha512-SgqiMEA+lWLyEd2jEwtIxdfx41j2CZr4KK00D2Gepj1MnGOjaEi13athnU6xvqMQXXjgJNj+vmlUP69QiuGncQ==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -983,10 +1134,10 @@ } }, "node_modules/htmx.org": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.10.tgz", - "integrity": "sha512-UgchasltTCrTuU2DQLom3ohHrBvwr7OqpwyAVJ9VxtNBng4XKkVsqrv0Qr3srqvM9ZNI3f1MmvVQQqK7KW/bTA==", - "license": "BSD 2-Clause" + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz", + "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==", + "license": "0BSD" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -1045,6 +1196,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1085,12 +1242,15 @@ } }, "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { @@ -1127,6 +1287,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -1260,12 +1432,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -1281,9 +1453,9 @@ } }, "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "license": "MIT", "engines": { "node": ">= 6" @@ -1435,18 +1607,6 @@ } } }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/postcss-nested": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", @@ -1491,6 +1651,16 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1532,6 +1702,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -1758,33 +1940,33 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", @@ -1815,6 +1997,15 @@ "node": ">=0.8" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1946,9 +2137,9 @@ } }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 8415176..f684337 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,18 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^6.4.0", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/list": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@popperjs/core": "^2.11.8", "alpinejs": "^3.13.5", "esbuild": "^0.20.1", "flowbite": "^2.2.1", + "fullcalendar": "^5.11.3", "htmx.org": "^1.9.10", + "tippy.js": "^6.3.7", "tailwindcss": "^3.4.1" }, "devDependencies": { diff --git a/static/css/app.css b/static/css/app.css index 6bf16b7..89efe7d 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2,6 +2,9 @@ @tailwind components; @tailwind utilities; +/* Import vendor CSS */ +@import './vendor/fullcalendar.css'; + /* Custom styles */ html, body { margin: 0; diff --git a/static/css/vendor/fullcalendar.css b/static/css/vendor/fullcalendar.css new file mode 100644 index 0000000..befd45c --- /dev/null +++ b/static/css/vendor/fullcalendar.css @@ -0,0 +1,255 @@ +/* FullCalendar Basic Styles */ + +.fc { + display: flex; + flex-direction: column; + font-size: 1em; + max-width: 100%; +} + +.fc-view-harness { + flex-grow: 1; + position: relative; +} + +.fc-scrollgrid { + border-collapse: collapse; + width: 100%; +} + +.fc-scrollgrid, .fc-scrollgrid table { + table-layout: fixed; +} + +.fc-scrollgrid, .fc-scrollgrid table { + border-style: solid; + border-color: #ddd; + border-width: 1px; +} + +.fc-theme-standard td, .fc-theme-standard th { + border: 1px solid #ddd; +} + +.fc-header-toolbar { + padding: 1em; + display: flex; + justify-content: space-between; + align-items: center; +} + +.fc-toolbar-title { + font-size: 1.5em; + font-weight: bold; +} + +.fc-button-group { + display: inline-flex; +} + +.fc-button { + background-color: #f3f4f6; + border: 1px solid #d1d5db; + color: #374151; + padding: 0.5em 0.75em; + cursor: pointer; + font-size: 0.9em; + margin: 0; + border-radius: 0.25em; +} + +.fc-button:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.fc-button:not(:first-child):not(:last-child) { + border-radius: 0; + border-left: none; +} + +.fc-button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; +} + +.fc-button:hover { + background-color: #e5e7eb; + z-index: 1; +} + +.fc-button-primary { + background-color: #3b82f6; + border-color: #2563eb; + color: #ffffff; +} + +.fc-button-primary:hover { + background-color: #2563eb; +} + +.fc-button-active { + background-color: #1d4ed8; + border-color: #1e40af; + color: #ffffff; + z-index: 2; +} + +.fc-col-header-cell { + padding: 0.5em; + background-color: #f9fafb; + font-weight: bold; +} + +.fc-col-header-cell-cushion { + display: block; + padding: 0.25em 0; + text-decoration: none; + color: #374151; +} + +.fc-scrollgrid-sync-inner { + text-align: center; +} + +.fc-daygrid-day-top { + padding: 0.5em; + text-align: right; +} + +.fc-daygrid-day-number { + font-size: 0.9em; + color: #374151; + text-decoration: none; + font-weight: 500; +} + +.fc-daygrid-day-events { + min-height: 2em; + position: relative; + padding: 0 0.5em; +} + +.fc-daygrid-event { + margin-bottom: 1px; + font-size: 0.85em; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fc-h-event { + display: block; + border: 1px solid #3b82f6; + background-color: #3b82f6; + color: #fff; + margin-top: 1px; + margin-bottom: 1px; + padding: 2px 4px; + border-radius: 3px; +} + +.fc-h-event .fc-event-main { + color: #fff; +} + +.fc-day-today { + background-color: rgba(59, 130, 246, 0.1); +} + +.fc-theme-standard .fc-list { + border: 1px solid #ddd; +} + +.fc-list-day { + background-color: #f9fafb; +} + +.fc-list-day-cushion { + padding: 0.75em 1em; +} + +.fc-list-event { + cursor: pointer; +} + +.fc-list-event:hover td { + background-color: #f3f4f6; +} + +.fc-list-event-time { + white-space: nowrap; + width: 1px; + text-align: right; +} + +.fc-list-event-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Dark mode */ +.dark .fc { + color: #e5e7eb; +} + +.dark .fc-scrollgrid, .dark .fc-scrollgrid table { + border-color: #4b5563; +} + +.dark .fc-theme-standard td, .dark .fc-theme-standard th { + border-color: #4b5563; +} + +.dark .fc-button { + background-color: #374151; + border-color: #4b5563; + color: #e5e7eb; +} + +.dark .fc-button:hover { + background-color: #4b5563; +} + +.dark .fc-button-primary { + background-color: #3b82f6; + border-color: #2563eb; + color: #ffffff; +} + +.dark .fc-button-primary:hover { + background-color: #2563eb; +} + +.dark .fc-button-active { + background-color: #1d4ed8; + border-color: #1e40af; + color: #ffffff; +} + +.dark .fc-col-header-cell { + background-color: #1f2937; +} + +.dark .fc-col-header-cell-cushion { + color: #e5e7eb; +} + +.dark .fc-day-today { + background-color: rgba(59, 130, 246, 0.15) !important; +} + +.dark .fc-daygrid-day-number { + color: #e5e7eb; +} + +.dark .fc-list-day { + background-color: #1f2937; +} + +.dark .fc-list-event:hover td { + background-color: #374151; +} \ No newline at end of file diff --git a/static/js/vendor.js b/static/js/vendor.js index f3fc856..b2a7780 100644 --- a/static/js/vendor.js +++ b/static/js/vendor.js @@ -9,6 +9,32 @@ window.Alpine = Alpine; import 'flowbite'; import 'flowbite/dist/flowbite.css'; +// Import FullCalendar +import { Calendar } from '@fullcalendar/core'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import interactionPlugin from '@fullcalendar/interaction'; + +// Make FullCalendar available globally in the format expected by the calendar template +window.FullCalendar = { + Calendar: Calendar, // This makes FullCalendar.Calendar a constructor + dayGridPlugin: dayGridPlugin, + timeGridPlugin: timeGridPlugin, + listPlugin: listPlugin, + interactionPlugin: interactionPlugin +}; + +// Import Popper.js +import * as Popper from '@popperjs/core'; +window.Popper = Popper; + +// Import Tippy.js +import tippy from 'tippy.js'; +import 'tippy.js/dist/tippy.css'; +import 'tippy.js/themes/light.css'; +window.tippy = tippy; + // Initialize Flowbite components document.addEventListener('DOMContentLoaded', () => { // Initialize Alpine.js