Merge branch 'transfer-calendar' into development

This commit is contained in:
StarFleetCPTN
2025-04-19 06:25:32 -07:00
22 changed files with 1258 additions and 95 deletions
+651
View File
@@ -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) {
<div class="p-6">
<div class="w-full">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div class="flex items-center mb-4 md:mb-0">
<i class="fas fa-calendar-alt text-blue-500 mr-2"></i>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Transfer Calendar</h1>
</div>
<a href="/jobs/new" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 flex items-center">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<button id="showAll" class="flex items-center text-sm px-4 py-2 rounded-lg bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-800 dark:text-blue-100 dark:hover:bg-blue-700">
<i class="fas fa-calendar-check mr-2"></i>All Jobs
</button>
<button id="showActive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-on mr-2"></i>Active Only
</button>
<button id="showInactive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-off mr-2"></i>Inactive Only
</button>
<button id="showNext" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-step-forward mr-2"></i>Next Occurrences Only
</button>
</div>
</div>
<!-- Hidden div to store calendar event data -->
<div id="calendar-data" style="display: none;">{ generateCalendarEvents(data.Jobs) }</div>
<!-- Loading indicator -->
<div id="calendar-loading" class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 dark:border-blue-400"></div>
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading calendar...</span>
</div>
<div id="calendar" class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hidden"></div>
</div>
</div>
<!-- Info section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8 mt-6 mx-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About the Calendar View</h2>
<p class="text-gray-700 dark:text-gray-300 mb-4">
This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details.
</p>
<div class="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg mb-4">
<p class="text-blue-700 dark:text-blue-300 text-sm">
<i class="fas fa-info-circle mr-2"></i>
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.
</p>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Filter Options</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="flex items-start">
<i class="fas fa-calendar-check mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">All Jobs</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows all scheduled occurrences in the selected timeframe.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-on mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Active Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only enabled jobs that will actually run.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-off mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Inactive Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows disabled jobs that won't run unless re-enabled.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-step-forward mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Next Occurrences Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only the next upcoming occurrence of each job.</p>
</div>
</div>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Legend</h3>
<div class="flex flex-wrap gap-4">
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-blue-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Active Jobs</span>
</div>
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-gray-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Inactive Jobs</span>
</div>
</div>
</div>
<!-- Local assets are now loaded through vendor.js bundle -->
<script>
// Function to initialize the calendar
function initializeCalendar() {
// Get the event data from the hidden div
try {
// Show loading indicator
const loadingIndicator = document.getElementById('calendar-loading');
const calendarElement = document.getElementById('calendar');
// Parse the data in a non-blocking way
try {
const eventsData = JSON.parse(document.getElementById('calendar-data').textContent);
// Check if FullCalendar is available
if (typeof FullCalendar === 'undefined') {
console.error('FullCalendar is not loaded yet. Waiting...');
setTimeout(initializeCalendar, 100);
return;
}
// Initialize calendar with events data
const calendar = new FullCalendar.Calendar(calendarElement, {
initialView: 'dayGridMonth',
plugins: [
FullCalendar.dayGridPlugin,
FullCalendar.timeGridPlugin,
FullCalendar.listPlugin,
FullCalendar.interactionPlugin
],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek'
},
events: eventsData,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: true
},
eventDidMount: function(info) {
// Log the event data for debugging
console.log('Event mounted:', info.event.title, 'Enabled:', info.event.extendedProps.enabled);
// Add tooltips to events
if (info.event.extendedProps) {
// Only add tooltips to visible events to improve performance
if (info.view.type === 'dayGridMonth' &&
info.event.start >= calendar.view.activeStart &&
info.event.start <= calendar.view.activeEnd) {
// Create enhanced tooltip content with run times
let tooltipContent = `<div class="p-2">`;
tooltipContent += `<div class="font-bold mb-1">${info.event.title}</div>`;
tooltipContent += `<div class="text-sm mb-2">Schedule: ${info.event.extendedProps.schedule || 'Unknown'}</div>`;
// Show the run times if available
if (info.event.extendedProps.runTimes && info.event.extendedProps.runTimes.length > 0) {
tooltipContent += `<div class="font-bold text-xs mt-1">Run Times:</div>`;
tooltipContent += `<div class="text-xs">`;
// Show the run times in a list
info.event.extendedProps.runTimes.forEach(time => {
tooltipContent += `<div>${time}</div>`;
});
tooltipContent += `</div>`;
}
tooltipContent += `<div class="text-xs mt-2">Click to view/edit job</div>`;
tooltipContent += `</div>`;
tippy(info.el, {
content: tooltipContent,
allowHTML: true,
placement: 'top',
arrow: true,
interactive: true,
maxWidth: 300,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
});
}
}
},
eventClick: function(info) {
// Use the URL property to navigate to job details
if (info.event.url) {
window.location.href = info.event.url;
return false; // Prevents the default action
}
},
eventWillUnmount: function(info) {
// Cleanup any tooltips to prevent memory leaks
if (info.el._tippy) {
info.el._tippy.destroy();
}
},
themeSystem: 'standard',
loading: function(isLoading) {
if (!isLoading) {
// Hide loading indicator and show calendar
loadingIndicator.classList.add('hidden');
calendarElement.classList.remove('hidden');
}
}
});
calendar.render();
// Handle filter buttons with optimized filtering
document.getElementById('showAll').addEventListener('click', function() {
updateActiveButton(this);
// Show all events
calendar.getEvents().forEach(event => {
event.setProp('display', '');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showActive').addEventListener('click', function() {
updateActiveButton(this);
// Show only active events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showInactive').addEventListener('click', function() {
updateActiveButton(this);
// Show only inactive events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', !isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showNext').addEventListener('click', function() {
updateActiveButton(this);
console.log("Next Occurrences Only filter clicked");
// First reset all events to make sure none are hidden
calendar.getEvents().forEach(event => {
event.setProp('display', 'none');
});
// Get a map of all jobs
const jobIDs = new Set();
calendar.getEvents().forEach(event => {
if (event.extendedProps && event.extendedProps.jobId) {
jobIDs.add(event.extendedProps.jobId);
}
});
console.log("Found jobs:", Array.from(jobIDs));
// For each job, find the next occurrence and show it
const now = new Date();
jobIDs.forEach(jobId => {
// Get all events for this job
const jobEvents = calendar.getEvents().filter(event =>
event.extendedProps &&
event.extendedProps.jobId === jobId
);
console.log(`Job ${jobId}: Found ${jobEvents.length} events`);
// Find future events
const futureEvents = jobEvents.filter(event =>
new Date(event.start) >= now
).sort((a, b) =>
new Date(a.start) - new Date(b.start)
);
// If we have future events, show the earliest one
if (futureEvents.length > 0) {
console.log(`Job ${jobId}: Next occurrence at ${futureEvents[0].start}`);
futureEvents[0].setProp('display', '');
} else {
// If no future events, find most recent past event
const pastEvents = jobEvents.filter(event =>
new Date(event.start) < now
).sort((a, b) =>
new Date(b.start) - new Date(a.start)
);
if (pastEvents.length > 0) {
console.log(`Job ${jobId}: Most recent occurrence at ${pastEvents[0].start}`);
pastEvents[0].setProp('display', '');
}
}
});
calendar.render(); // Re-render to apply changes
});
function updateActiveButton(activeBtn) {
// Reset all buttons
document.querySelectorAll('#showAll, #showActive, #showInactive, #showNext').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
btn.classList.add('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
});
// Set active button
activeBtn.classList.remove('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
activeBtn.classList.add('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
}
// Handle theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
// Update tooltips theme
document.querySelectorAll('[data-tippy-root]').forEach(tooltip => {
tooltip.className = document.documentElement.classList.contains('dark')
? 'tippy-box dark-theme'
: 'tippy-box light-theme';
});
}, 100);
});
}
} catch (error) {
console.error("Error initializing calendar:", error);
loadingIndicator.innerHTML = '<div class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Error loading calendar data</div>';
}
} catch (error) {
console.error("Error during initial calendar setup:", error);
}
}
// Wait for vendor.js to load before initializing calendar
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initializeCalendar, 100);
});
</script>
<style>
.fc-event {
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
border-left-width: 4px;
}
/* Style consolidated events */
.fc-event-title {
font-weight: 500;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tooltip styles */
.tippy-box {
border-radius: 6px;
font-size: 13px;
}
.tippy-box[data-theme~='dark'] {
background-color: #1f2937;
color: #f3f4f6;
}
.tippy-box[data-theme~='light'] {
background-color: #ffffff;
color: #1f2937;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
.fc {
--fc-page-bg-color: #1f2937;
--fc-border-color: #374151;
--fc-neutral-bg-color: #374151;
--fc-neutral-text-color: #e5e7eb;
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
.fc-day-today {
background-color: rgba(59, 130, 246, 0.15) !important;
}
.fc-col-header-cell {
background-color: #111827;
}
.fc-scrollgrid-sync-inner {
color: #e5e7eb;
}
.fc-daygrid-day-number {
color: #e5e7eb;
}
}
</style>
}
}
+3 -3
View File
@@ -394,7 +394,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
</p>
<!-- Google Drive Authentication Badge -->
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() {
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
Authentication Required
@@ -402,7 +402,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
}
<!-- Google Drive Authentication Status Indicator -->
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() {
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated
@@ -411,7 +411,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Google Drive Authentication Button -->
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() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate
+8
View File
@@ -173,6 +173,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer History
@@ -281,6 +285,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer History
+2 -2
View File
@@ -254,7 +254,7 @@ templ SourceSelection(providers []db.StorageProvider) {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option>
<option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select>
@@ -336,7 +336,7 @@ templ DestinationSelection(providers []db.StorageProvider) {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option>
<option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select>
+1 -1
View File
@@ -161,7 +161,7 @@ templ formFields(data StorageProviderFormData) {
selected="selected"
}
>OneDrive</option>
<option value="gdrive"
<option value="drive"
if data.Provider.Type == db.ProviderTypeGoogleDrive {
selected="selected"
}
+4 -4
View File
@@ -492,11 +492,11 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
Region: { provider.Region }
</p>
}
} else if provider.Type == "gdrive" || provider.Type == "gphotos" {
} else if provider.Type == "drive" || provider.Type == "gphotos" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fab fa-google-drive w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Google
if provider.Type == "gdrive" {
if provider.Type == "drive" {
Drive
} else {
Photos
@@ -610,13 +610,13 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
</div>
<!-- Authentication Notice (More Visible) -->
if (provider.Type == "gdrive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
<div class="mt-3 flex items-center justify-between bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3 border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-500 w-5 h-5 mr-2"></i>
<span class="text-sm text-yellow-700 dark:text-yellow-300">
Authentication required for Google
if provider.Type == "gdrive" {
if provider.Type == "drive" {
Drive
} else {
Photos
+1 -1
View File
@@ -230,7 +230,7 @@ templ RcloneImportPreviewContent(ctx context.Context, preview RcloneImportPrevie
}
<!-- OAuth credentials -->
if remote.Type == "gdrive" || remote.Type == "onedrive" || remote.Type == "gphotos" {
if remote.Type == "drive" || remote.Type == "onedrive" || remote.Type == "gphotos" {
<!-- Client ID -->
if _, exists := remote.Fields["client_id"]; !exists {
<div class="flex items-center gap-2">
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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"
+10 -10
View File
@@ -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())
}
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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
}
+17
View File
@@ -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")
+4
View File
@@ -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)
@@ -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"
}
@@ -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)
@@ -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,
+250 -59
View File
@@ -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"
+8
View File
@@ -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": {
+3
View File
@@ -2,6 +2,9 @@
@tailwind components;
@tailwind utilities;
/* Import vendor CSS */
@import './vendor/fullcalendar.css';
/* Custom styles */
html, body {
margin: 0;
+255
View File
@@ -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;
}
+26
View File
@@ -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