diff --git a/components/calendar.templ b/components/calendar.templ new file mode 100644 index 0000000..408384d --- /dev/null +++ b/components/calendar.templ @@ -0,0 +1,640 @@ +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/layout.templ b/components/layout.templ index ed9f2bf..5f86cb5 100644 --- a/components/layout.templ +++ b/components/layout.templ @@ -169,6 +169,10 @@ templ LayoutWithContext(title string, ctx context.Context) { Scheduled Jobs + + + Transfer Calendar + Transfer History @@ -273,6 +277,10 @@ templ LayoutWithContext(title string, ctx context.Context) { Scheduled Jobs + + + Transfer Calendar + Transfer History 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 f98006a..0bb11f2 100644 --- a/internal/web/handlers/routes.go +++ b/internal/web/handlers/routes.go @@ -84,6 +84,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/package-lock.json b/package-lock.json index 373ebaf..30b5bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,22 @@ "": { "name": "gomft", "version": "1.0.0", + "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" } }, "node_modules/@alloc/quick-lru": { @@ -406,6 +415,54 @@ "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", @@ -887,6 +944,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", @@ -1402,6 +1465,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", @@ -1726,6 +1799,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", diff --git a/package.json b/package.json index d07a96f..e56c1ac 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,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", - "tailwindcss": "^3.4.1" + "tailwindcss": "^3.4.1", + "tippy.js": "^6.3.7" } } 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..2909070 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 +window.FullCalendar = { + Calendar, + dayGridPlugin, + timeGridPlugin, + listPlugin, + 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