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