diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..4476b18 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,380 @@ +/* Custom styles to modernize and align with Calendly-inspired design */ +:root { + --primary-color: #007bff; /* Blue from Calendly */ + --secondary-color: #6c757d; /* Gray for muted elements */ + --light-bg: #f8f9fa; /* Light background for modern look */ + --shadow-color: rgba(0, 0, 0, 0.1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Arial', sans-serif; /* Matches Calendly font */ +} + +.container-fluid { + max-width: 1000px; + margin: 20px auto; + background-color: #fff; + border-radius: 6px; + box-shadow: 0 2px 4px var(--shadow-color); + overflow: hidden; +} + +.sidebar { + width: 30%; + padding: 20px; + border-right: 1px solid #e1e8ed; + text-align: center; + background-color: #fff; +} + +.profile-pic { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 50%; + margin-bottom: 10px; + border: 2px solid #fff; + box-shadow: 0 2px 4px var(--shadow-color); +} + +.sidebar h2 { + font-size: 1.25rem; + color: #333; + margin-bottom: 5px; + font-weight: 600; +} + +.sidebar h3 { + font-size: 0.9rem; + color: #666; + margin-bottom: 5px; + font-weight: 400; +} + +.duration { + font-size: 0.875rem; + color: #666; +} + +.main-content { + width: 70%; + padding: 20px; + position: relative; +} + +.calendar-header h1 { + font-size: 1.5rem; + color: #333; + font-weight: 700; + margin-bottom: 10px; +} + +.month-nav { + display: flex; + justify-content: space-between; + font-size: 1rem; + color: #333; + padding: 0.5rem 0; + border-bottom: 1px solid #e1e8ed; +} + +.month-nav button { + color: var(--secondary-color); + transition: color 0.3s ease; +} + +.month-nav button:hover { + color: var(--primary-color); +} + +.days-of-week, .dates { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 5px; + text-align: center; +} + +.days-of-week span { + font-weight: bold; + color: #666; + padding: 5px; +} + +.dates .date-item { + padding: 10px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.2s ease; +} + +.dates .date-item.disabled { + cursor: not-allowed; + color: var(--secondary-color); + opacity: 0.5; +} + +.dates .date-item:hover { + background-color: #f0f8ff; + transform: scale(1.05); +} + +.dates .date-item.available { + background-color: #e9f2ff; + border: 2px solid var(--primary-color); + font-weight: 600; +} + +.dates .date-item.selected { + background-color: #0d6efd !important; + color: #fff !important; + border-radius: 6px; + font-weight: 600; + border: none !important; +} + +.dates .date-item.current { + background-color: #e9f2ff; + border: 2px solid var(--primary-color); + font-weight: 600; +} + +.time-slot { + font-size: 1rem; + padding: 0.75rem 1.5rem; + transition: background-color 0.3s ease, border-color 0.3s ease, transform 0.2s ease; +} + +.time-slot.available { + background-color: #e9f2ff; + border-color: var(--primary-color); + color: var(--primary-color); +} + +.time-slots .text-muted { + color: #6c757d; /* Muted text color, matching Calendly */ + font-size: rem; /* Slightly larger font, e.g., 20px instead of 16px */ + font-weight: bold; /* Bold text */ + padding: 2.2rem 0; + text-align: center; +} + +.time-slot.available:hover, .time-slot:hover { + background-color: var(--primary-color); + color: #fff; + transform: scale(1.05); +} + +.timezone { + font-size: 0.875rem; + color: #666; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +#timezone { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.powered-by { + font-size: 0.75rem; + color: #666; + opacity: 0.8; + transition: opacity 0.3s ease; + z-index: 10; + background-color: transparent; + padding: 5px; + margin-left: auto; /* Push to the right */ + pointer-events: auto; +} + +.powered-by.text-end { + text-align: right; +} + +.powered-by a { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.powered-by a.github-link { + display: inline-flex; + align-items: center; + background-color: #e9e9e9; + color: #333; + padding: 8px 12px; + border-radius: 20px; + font-size: 14px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + white-space: nowrap; +} + +.octocat-icon { + margin-right: 8px; + display: flex; + align-items: center; +} + +.octocat-icon svg { + fill: #333; +} + +.powered-by:hover { + opacity: 1; +} + +/* Admin-specific powered-by styling */ +.admin-powered-by { + position: absolute; + bottom: 20px; + right: 20px; + margin-top: 0; +} + +/* Index-specific powered-by styling */ +.index-powered-by { + text-align: right; + margin-top: 0; + margin-left: auto; + display: inline-flex; + justify-content: flex-end; + align-items: center; + flex-shrink: 0; +} + +/* Add clearance for elements with flex classes */ +.d-flex.flex-column.gap-2.mt-3 { + margin-bottom: 50px; /* Increased margin to ensure these elements don't overlap with the powered-by button */ +} + +/* Admin-specific styles */ +#admin .calendar { + margin-bottom: 20px; +} + +#admin .form-control, #admin .form-select { + border-radius: 6px; +} + +#admin .btn-primary, #admin .btn-danger { + border-radius: 4px; +} + +#availabilityList .list-group-item { + border-radius: 4px; + margin-bottom: 5px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .container-fluid { + margin: 10px; + border-radius: 6px; + } + + .sidebar, .main-content { + width: 100%; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid #e1e8ed; + } + + #admin .row { + flex-direction: column; + } + #admin .col-md-6 { + width: 100%; + } + + .powered-by { + position: relative; + bottom: auto; + right: auto; + text-align: center; + margin-top: 30px; + } + + .admin-powered-by { + position: relative; + bottom: auto; + right: auto; + text-align: right; + margin-top: 20px; + } + + .index-powered-by { + position: relative; + text-align: right; + margin-left: auto; + display: inline-flex; + justify-content: flex-end; + } +} + +/* Sidebar buttons styling */ +.sidebar .btn { + padding: 12px 20px; + font-size: 1.1rem; + font-weight: 500; + border: none; + transition: all 0.3s ease; +} + +.sidebar .btn-success { + background-color: #2e8b57; /* Money green color */ + border-color: #2e8b57; +} + +.sidebar .btn-success:hover { + background-color: #267349; + border-color: #267349; +} + +.sidebar .btn-warning { + background-color: #ffc107; + border-color: #ffc107; + color: #212529; +} + +.sidebar .btn-warning:hover { + background-color: #e0a800; + border-color: #e0a800; +} + +.sidebar .btn-danger { + background-color: #dc3545; + border-color: #dc3545; +} + +.sidebar .btn-danger:hover { + background-color: #c82333; + border-color: #c82333; +} + +/* Dev Tools button styling */ +.sidebar .btn-info { + background-color: #00bfff; /* Neon blue color */ + border-color: #00bfff; + color: white; + box-shadow: 0 0 10px rgba(0, 191, 255, 0.5); /* Neon glow effect */ +} + +.sidebar .btn-info:hover { + background-color: #0099cc; + border-color: #0099cc; + box-shadow: 0 0 15px rgba(0, 191, 255, 0.7); /* Enhanced glow on hover */ +} + +.time-slots { + margin-bottom: 50px; /* Increased margin to ensure time slots don't overlap with the powered-by button */ +} \ No newline at end of file diff --git a/public/css/timepicker.css b/public/css/timepicker.css new file mode 100644 index 0000000..1e9af76 --- /dev/null +++ b/public/css/timepicker.css @@ -0,0 +1,413 @@ +/** + * Timepicker styling + */ +/* Core timepicker styling */ +.timepicker-wrapper { + --fs-timepicker-wrapper-bg: rgba(0, 0, 0, 0.4); + --fs-timepicker-elements-min-width: 310px; + --fs-timepicker-elements-min-height: 325px; + --fs-timepicker-elements-background: #fff; + --fs-timepicker-elements-border-top-right-radius: 0.6rem; + --fs-timepicker-elements-border-top-left-radius: 0.6rem; + --fs-timepicker-head-bg: #3b71ca; + --fs-timepicker-head-height: 100px; + --fs-timepicker-head-border-top-right-radius: 0.5rem; + --fs-timepicker-head-border-top-left-radius: 0.5rem; + --fs-timepicker-head-padding-y: 10px; + --fs-timepicker-head-padding-right: 24px; + --fs-timepicker-head-padding-left: 50px; + --fs-timepicker-button-font-size: 0.8rem; + --fs-timepicker-button-min-width: 64px; + --fs-timepicker-button-font-weight: 500; + --fs-timepicker-button-line-height: 40px; + --fs-timepicker-button-border-radius: 10px; + --fs-timepicker-button-color: #4f4f4f; + --fs-timepicker-button-hover-bg: rgba(0, 0, 0, 0.08); + --fs-timepicker-button-focus-bg: rgba(0, 0, 0, 0.08); + --fs-timepicker-button-padding-x: 10px; + --fs-timepicker-button-height: 40px; + --fs-timepicker-button-margin-bottom: 10px; + --fs-timepicker-current-font-size: 3.75rem; + --fs-timepicker-current-font-weight: 300; + --fs-timepicker-current-line-height: 1.2; + --fs-timepicker-current-color: #fff; + --fs-timepicker-current-opacity: 0.54; + --fs-timepicker-clock-wrapper-min-width: 310px; + --fs-timepicker-clock-wrapper-max-width: 325px; + --fs-timepicker-clock-wrapper-min-height: 305px; + --fs-timepicker-clock-wrapper-text-color: #4f4f4f; + --fs-timepicker-mode-wrapper-font-size: 18px; + --fs-timepicker-mode-wrapper-color: rgba(255, 255, 255, 0.54); + --fs-timepicker-clock-width: 260px; + --fs-timepicker-clock-height: 260px; + --fs-timepicker-clock-face-bg: #f0f0f0; + --fs-timepicker-time-tips-inner-active-color: #fff; + --fs-timepicker-time-tips-inner-active-bg: #3b71ca; + --fs-timepicker-time-tips-inner-active-font-weight: 400; + --fs-timepicker-dot-font-weight: 300; + --fs-timepicker-dot-line-height: 1.2; + --fs-timepicker-dot-color: #fff; + --fs-timepicker-dot-font-size: 3.75rem; + --fs-timepicker-dot-opacity: 0.54; + --fs-timepicker-item-middle-dot-width: 6px; + --fs-timepicker-item-middle-dot-height: 6px; + --fs-timepicker-item-middle-dot-border-radius: 50%; + --fs-timepicker-item-middle-dot-bg: #3b71ca; + --fs-timepicker-hand-pointer-bg: #3b71ca; + --fs-timepicker-hand-pointer-bottom: 50%; + --fs-timepicker-hand-pointer-height: 40%; + --fs-timepicker-hand-pointer-left: calc(50% - 1px); + --fs-timepicker-hand-pointer-width: 2px; + --fs-timepicker-circle-top: -21px; + --fs-timepicker-circle-left: -15px; + --fs-timepicker-circle-width: 4px; + --fs-timepicker-circle-border-width: 14px; + --fs-timepicker-circle-border-color: #3b71ca; + --fs-timepicker-circle-height: 4px; + --fs-timepicker-circle-active-background-color: #fff; + --fs-timepicker-hour-mode-color: #fff; + --fs-timepicker-hour-mode-opacity: 0.54; + --fs-timepicker-hour-mode-hover-bg: rgba(0, 0, 0, 0.15); + --fs-timepicker-hour-mode-active-color: #fff; + --fs-timepicker-footer-border-bottom-left-radius: 0.5rem; + --fs-timepicker-footer-border-bottom-right-radius: 0.5rem; + --fs-timepicker-footer-height: 56px; + --fs-timepicker-footer-padding-x: 12px; + --fs-timepicker-footer-bg: #fff; + --fs-timepicker-clock-animation: show-up-clock 350ms linear; + --fs-timepicker-zindex: 1065; + + touch-action: none; + z-index: var(--fs-timepicker-zindex); + opacity: 1; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: var(--fs-timepicker-wrapper-bg); +} + +/* Animation */ +.animation { + animation-fill-mode: both; +} + +.fade-in { + animation-name: fadeIn; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes show-up-clock { + 0% { + opacity: 0; + transform: scale(0.7); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Timepicker components */ +.timepicker-modal { + margin: 0; + font-family: "Roboto", sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.6; + color: #4f4f4f; + text-align: left; + background-color: #fff; + z-index: var(--fs-timepicker-zindex); + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +/* Ensure all elements inside the timepicker use border-box */ +.timepicker-modal *, +.timepicker-modal *::before, +.timepicker-modal *::after { + box-sizing: border-box; +} + +.timepicker-container { + max-height: calc(100% - 64px); + overflow-y: auto; + box-shadow: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04); +} + +.timepicker-elements { + min-width: var(--fs-timepicker-elements-min-width); + min-height: var(--fs-timepicker-elements-min-height); + background: var(--fs-timepicker-elements-background); + border-top-right-radius: var(--fs-timepicker-elements-border-top-right-radius); + border-top-left-radius: var(--fs-timepicker-elements-border-top-left-radius); +} + +.timepicker-head { + background-color: var(--fs-timepicker-head-bg); + height: var(--fs-timepicker-head-height); + border-top-right-radius: var(--fs-timepicker-head-border-top-right-radius); + border-top-left-radius: var(--fs-timepicker-head-border-top-left-radius); + padding: var(--fs-timepicker-head-padding-y) var(--fs-timepicker-head-padding-right) var(--fs-timepicker-head-padding-y) var(--fs-timepicker-head-padding-left); +} + +.timepicker-current { + font-size: var(--fs-timepicker-current-font-size); + font-weight: var(--fs-timepicker-current-font-weight); + line-height: var(--fs-timepicker-current-line-height); + color: var(--fs-timepicker-current-color); + opacity: var(--fs-timepicker-current-opacity); + border: none; + background: transparent; + padding: 0; + position: relative; + vertical-align: unset; +} + +.timepicker-current.active { + opacity: 1; +} + +.timepicker-dot { + font-size: var(--fs-timepicker-dot-font-size); + font-weight: var(--fs-timepicker-dot-font-weight); + line-height: var(--fs-timepicker-dot-line-height); + color: var(--fs-timepicker-dot-color); + opacity: var(--fs-timepicker-dot-opacity); + border: none; + background: transparent; + padding: 0; +} + +.timepicker-mode-wrapper { + font-size: var(--fs-timepicker-mode-wrapper-font-size); + color: var(--fs-timepicker-mode-wrapper-color); +} + +.timepicker-hour-mode { + padding: 0; + background-color: transparent; + border: none; + color: var(--fs-timepicker-hour-mode-color); + opacity: var(--fs-timepicker-hour-mode-opacity); + cursor: pointer; +} + +/* These focus styles are overridden later in the file with !important */ +.timepicker-hour-mode:hover, +.timepicker-hour-mode:focus, +.timepicker-hour:hover, +.timepicker-hour:focus, +.timepicker-minute:hover, +.timepicker-minute:focus { + background-color: var(--fs-timepicker-hour-mode-hover-bg); + outline: none; + cursor: pointer; +} + +.timepicker-hour-mode.active, +.timepicker-hour.active, +.timepicker-minute.active { + color: #fff; + opacity: 1; + outline: none; +} + +.timepicker-clock-wrapper { + min-width: var(--fs-timepicker-clock-wrapper-min-width); + max-width: var(--fs-timepicker-clock-wrapper-max-width); + min-height: var(--fs-timepicker-clock-wrapper-min-height); + overflow-x: hidden; + height: 100%; + color: var(--fs-timepicker-clock-wrapper-text-color); +} + +.timepicker-clock { + position: relative; + border-radius: 100%; + width: var(--fs-timepicker-clock-width); + height: var(--fs-timepicker-clock-height); + cursor: default; + margin: 0 auto; + background-color: var(--fs-timepicker-clock-face-bg); + user-select: none; +} + +.timepicker-clock-animation { + animation: var(--fs-timepicker-clock-animation); +} + +.timepicker-middle-dot { + top: 50%; + left: 50%; + width: var(--fs-timepicker-item-middle-dot-width); + height: var(--fs-timepicker-item-middle-dot-height); + transform: translate(-50%, -50%); + border-radius: var(--fs-timepicker-item-middle-dot-border-radius); + background-color: var(--fs-timepicker-item-middle-dot-bg); +} + +.timepicker-hand-pointer { + background-color: var(--fs-timepicker-hand-pointer-bg); + bottom: var(--fs-timepicker-hand-pointer-bottom); + height: var(--fs-timepicker-hand-pointer-height); + left: var(--fs-timepicker-hand-pointer-left); + transform-origin: center bottom 0; + width: var(--fs-timepicker-hand-pointer-width); +} + +.timepicker-circle { + top: var(--fs-timepicker-circle-top); + left: var(--fs-timepicker-circle-left); + width: var(--fs-timepicker-circle-width); + border: var(--fs-timepicker-circle-border-width) solid var(--fs-timepicker-circle-border-color); + height: var(--fs-timepicker-circle-height); + box-sizing: content-box; + border-radius: 100%; + background-color: transparent; +} + +.timepicker-circle.active { + background-color: var(--fs-timepicker-circle-active-background-color); +} + +.timepicker-time-tips-minutes, +.timepicker-time-tips-hours { + position: absolute; + border-radius: 100%; + width: 32px; + height: 32px; + text-align: center; + cursor: pointer; + font-size: 1.1rem; + display: flex; + justify-content: center; + align-items: center; + font-weight: 300; + user-select: none; +} + +.timepicker-time-tips-minutes.active, +.timepicker-time-tips-hours.active { + color: var(--fs-timepicker-time-tips-inner-active-color); + background-color: var(--fs-timepicker-time-tips-inner-active-bg); + font-weight: var(--fs-timepicker-time-tips-inner-active-font-weight); +} + +.timepicker-footer { + border-bottom-left-radius: var(--fs-timepicker-footer-border-bottom-left-radius); + border-bottom-right-radius: var(--fs-timepicker-footer-border-bottom-right-radius); + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: var(--fs-timepicker-footer-height); + padding-left: var(--fs-timepicker-footer-padding-x); + padding-right: var(--fs-timepicker-footer-padding-x); + background-color: var(--fs-timepicker-footer-bg); +} + +.timepicker-button { + font-size: var(--fs-timepicker-button-font-size); + min-width: var(--fs-timepicker-button-min-width); + box-sizing: border-box; + font-weight: var(--fs-timepicker-button-font-weight); + line-height: var(--fs-timepicker-button-line-height); + border-radius: var(--fs-timepicker-button-border-radius); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fs-timepicker-button-color); + border: none; + background-color: transparent; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + outline: none; + padding: 0 var(--fs-timepicker-button-padding-x); + height: var(--fs-timepicker-button-height); + margin-bottom: var(--fs-timepicker-button-margin-bottom); + cursor: pointer; +} + +.timepicker-button:hover { + background-color: var(--fs-timepicker-button-hover-bg); +} + +.timepicker-button:focus { + outline: none; + background-color: var(--fs-timepicker-button-focus-bg); +} + +/* Add missing button styling */ +button { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +/** Remove focus from buttons after clicked **/ +/* Prevent selection/focus styling on all buttons in the timepicker */ +.timepicker-modal button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +/* Override focus styles completely */ +.timepicker-modal button:focus { + outline: none !important; + box-shadow: none !important; + -webkit-box-shadow: none !important; +} + +/* Prevent text selection on all elements */ +.timepicker-modal * { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Add this to the body when the timepicker is active */ +body.timepicker-active { + -webkit-tap-highlight-color: transparent; +} + +/* Add !important to ensure these styles take precedence */ +.timepicker-current:focus, +.timepicker-dot:focus, +.timepicker-hour-mode:focus, +.timepicker-button:focus { + outline: none !important; + background-color: transparent !important; +} + +/* Only apply background color on hover, not on focus */ +.timepicker-hour-mode:hover, +.timepicker-hour:hover, +.timepicker-minute:hover, +.timepicker-button:hover { + background-color: var(--fs-timepicker-hour-mode-hover-bg); +} + +/* Ensure active states are properly styled */ +.timepicker-hour-mode.active, +.timepicker-hour.active, +.timepicker-minute.active { + color: #fff !important; + opacity: 1 !important; +} \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..64d4bcc --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,809 @@ +// js/admin.js + +// Import components and utilities +import { + renderAdminCalendar, + selectAdminDate, + currentAdminMonth, + currentAdminYear, + selectedAdminDate +} from './components/calendar.js'; + +import { + initializeVisualTimePicker, + showTimepicker +} from './components/timepicker.js'; + +import { + fetchAvailability, + loadUids, + createUid, + deleteUid, + addSingleTime, + removeTime, + flushAvailability, + resetDatabase +} from './utils/api.js'; + +import { + timeToMinutes, + formatTimeSlot, + formatDate +} from './utils/time-utils.js'; + +// Check if a date has availability +async function checkAvailabilityForDate(date) { + const availability = await fetchAvailability(); + const dateStr = formatDate(date); + return !!availability[dateStr]; +} + +// Update time slots for a selected date +async function updateTimeSlots(dateStr) { + const timeSlots = document.getElementById('timeSlots'); + if (!timeSlots) return; + + const selectedUid = document.getElementById('uidSelect').value; + if (!selectedUid) return; + + try { + // Fetch availability data + const availability = await fetchAvailability(); + + // Get available times for the selected date + const availableTimes = availability[dateStr] || []; + + // Clear existing time slots + timeSlots.innerHTML = ''; + + // Add custom time input button + const customTimeBtn = document.createElement('button'); + customTimeBtn.type = 'button'; + customTimeBtn.id = 'customTimeInput'; + customTimeBtn.classList.add('btn', 'btn-success', 'btn-lg', 'w-100', 'mb-4'); + customTimeBtn.style.backgroundColor = '#2e8b57'; // Money green color + customTimeBtn.style.borderColor = '#2e8b57'; + customTimeBtn.style.fontSize = '1.25rem'; + customTimeBtn.style.padding = '0.75rem 1.25rem'; + customTimeBtn.innerHTML = ' Add Time Slot'; + timeSlots.appendChild(customTimeBtn); + + // Add divider + const divider = document.createElement('hr'); + timeSlots.appendChild(divider); + + // Initialize visual time picker + const timepickerController = initializeVisualTimePicker(dateStr, availableTimes); + + // Sort available times + availableTimes.sort((a, b) => timeToMinutes(a) - timeToMinutes(b)); + + // Display existing time slots + if (availableTimes.length === 0) { + const noTimesMessage = document.createElement('p'); + noTimesMessage.classList.add('text-muted', 'text-center'); + noTimesMessage.textContent = 'No time slots available for this date.'; + timeSlots.appendChild(noTimesMessage); + } else { + // Create container for time slot list + const timeSlotsContainer = document.createElement('div'); + timeSlotsContainer.classList.add('d-flex', 'flex-column', 'gap-2', 'mt-3'); + + // Display all slots in chronological order + availableTimes.forEach(time => { + // Create a container div for the time slot + const timeSlotContainer = document.createElement('div'); + timeSlotContainer.classList.add('position-relative', 'w-100', 'd-flex', 'align-items-center', 'mb-2'); + + // Create the time slot pill + const timeSlotItem = document.createElement('div'); + timeSlotItem.classList.add('btn', 'btn-light', 'rounded-pill', 'px-4', 'py-3', 'flex-grow-1'); + timeSlotItem.style.backgroundColor = '#e9f2ff'; + timeSlotItem.style.color = '#0d6efd'; + timeSlotItem.style.border = '1px solid #0d6efd'; + timeSlotItem.style.textAlign = 'center'; + timeSlotItem.style.fontSize = '1.1rem'; + timeSlotItem.style.fontWeight = '500'; + timeSlotItem.style.transition = 'all 0.2s ease'; + + // Add time text directly to the pill + timeSlotItem.textContent = time; + + // Add delete button/icon as a separate element + const deleteIcon = document.createElement('button'); + deleteIcon.innerHTML = ''; + deleteIcon.classList.add('btn', 'btn-sm', 'text-danger', 'ms-3'); + deleteIcon.style.backgroundColor = 'transparent'; + deleteIcon.style.border = 'none'; + deleteIcon.style.fontSize = '1.2rem'; + deleteIcon.style.padding = '8px'; + deleteIcon.style.cursor = 'pointer'; + deleteIcon.style.transition = 'all 0.2s ease'; + deleteIcon.style.opacity = '0.8'; + deleteIcon.style.borderRadius = '50%'; + deleteIcon.style.display = 'flex'; + deleteIcon.style.alignItems = 'center'; + deleteIcon.style.justifyContent = 'center'; + + // Add hover effects + timeSlotItem.addEventListener('mouseenter', () => { + timeSlotItem.style.backgroundColor = '#0d6efd'; + timeSlotItem.style.color = 'white'; + timeSlotItem.style.transform = 'translateY(-2px)'; + timeSlotItem.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)'; + }); + + timeSlotItem.addEventListener('mouseleave', () => { + timeSlotItem.style.backgroundColor = '#e9f2ff'; + timeSlotItem.style.color = '#0d6efd'; + timeSlotItem.style.transform = 'translateY(0)'; + timeSlotItem.style.boxShadow = 'none'; + }); + + // Add hover effects for delete icon + deleteIcon.addEventListener('mouseenter', () => { + deleteIcon.style.opacity = '1'; + deleteIcon.style.transform = 'scale(1.2)'; + deleteIcon.style.backgroundColor = 'rgba(220, 53, 69, 0.1)'; + }); + + deleteIcon.addEventListener('mouseleave', () => { + deleteIcon.style.opacity = '0.8'; + deleteIcon.style.transform = 'scale(1)'; + deleteIcon.style.backgroundColor = 'transparent'; + }); + + deleteIcon.addEventListener('click', (e) => { + e.stopPropagation(); + removeTime(dateStr, time) + .then(() => { + // Refresh the time slots + updateTimeSlots(dateStr); + // Refresh the calendar to show availability indicator + renderAdminCalendar(currentAdminMonth, currentAdminYear); + }) + .catch(error => { + console.error('Error removing time:', error); + showAlert('danger', 'Failed to remove time'); + }); + }); + + timeSlotContainer.appendChild(timeSlotItem); + timeSlotContainer.appendChild(deleteIcon); + timeSlotsContainer.appendChild(timeSlotContainer); + }); + + timeSlots.appendChild(timeSlotsContainer); + } + + // Initialize the timepicker when the custom time button is clicked + customTimeBtn.addEventListener('click', (e) => { + // Set up the onSubmit handler with the current dateStr + window.activeTimepickerController = { + dateStr: dateStr, // Store the date string for reference + onSubmit: (time) => { + console.log('Submitting time:', time, 'for date:', dateStr); + addSingleTime(dateStr, time) + .then(() => { + console.log('Time added successfully'); + // Refresh the time slots + updateTimeSlots(dateStr); + // Refresh the calendar to show availability indicator + renderAdminCalendar(currentAdminMonth, currentAdminYear); + }) + .catch(error => { + console.error('Error adding time:', error); + showAlert('danger', 'Failed to add time'); + }); + } + }; + + // Show the timepicker with the controller + showTimepicker(timepickerController); + + // Stop event propagation to prevent the click outside handler from firing + e.stopPropagation(); + }); + } catch (error) { + console.error('Error updating time slots:', error); + } +} + +// Expose updateTimeSlots globally so it can be called from the timepicker component +window.updateTimeSlots = updateTimeSlots; + +// Also expose addSingleTime globally for the timepicker component +window.addSingleTime = addSingleTime; + +// Show an alert message +function showAlert(type, message) { + // Create alert element + const alert = document.createElement('div'); + alert.className = `alert alert-${type} alert-dismissible fade show`; + alert.role = 'alert'; + alert.innerHTML = ` + ${message} + + `; + + // Add to page + const mainContent = document.querySelector('.main-content'); + if (mainContent) { + mainContent.prepend(alert); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + const bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }, 3000); + } +} + +// Initialize the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', async () => { + // Add custom CSS for the current date to make it more distinguishable + const style = document.createElement('style'); + style.textContent = ` + .date-item.current { + border: 2px solid #ffc107 !important; /* Yellow border */ + background-color: rgba(255, 193, 7, 0.1) !important; /* Light yellow background */ + font-weight: bold !important; + color: #212529 !important; /* Ensure text is visible */ + } + + /* Ensure current date styling takes precedence but doesn't interfere with selection */ + .date-item.current.selected { + border: 2px solid #0d6efd !important; /* Blue border for selected */ + background-color: #0d6efd !important; /* Dark blue background when selected */ + color: white !important; /* White text for better contrast */ + font-weight: bold !important; + } + + /* Prevent hover effects on empty calendar cells */ + .date-item.empty-cell { + cursor: default !important; + pointer-events: none !important; + } + + .date-item.empty-cell:hover { + background-color: transparent !important; + transform: none !important; + } + + /* Time picker styling */ + #visualTimePicker { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + border-radius: 8px; + overflow: hidden; + } + + #visualTimePicker .card-header { + background-color: #0d6efd; + } + + #visualTimePicker .display-4 { + font-size: 3rem; + font-weight: 300; + color: white; + } + + .time-picker-clock { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + } + + .hour-number, .minute-number { + border-radius: 50%; + transition: all 0.2s ease; + } + + .hour-number:hover, .minute-number:hover { + background-color: rgba(13, 110, 253, 0.1); + } + + /* Time slot pills styling */ + .time-slot { + transition: all 0.2s ease; + } + + .time-slot:hover { + transform: translateY(-2px); + } + + /* Make all UID buttons consistent */ + #createUid, #deleteUid, #flushDatabase, #copyUrlBtn { + border-radius: 30px; + padding: 0.75rem 1.5rem; + font-size: 1.25rem; + font-weight: 500; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + transition: all 0.3s ease; + } + + #createUid { + background-color: #2e8b57; /* Money green color */ + border-color: #2e8b57; + } + + #createUid:hover { + background-color: #267349; + border-color: #267349; + } + + /* URL container styling */ + .url-container { + display: flex; + align-items: center; + gap: 10px; + } + + .url-display { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + } + `; + document.head.appendChild(style); + + // Create and add the Copy URL button + const urlContainer = document.querySelector('.form-control-lg.bg-light.p-3'); + if (urlContainer) { + // Create a wrapper div for the URL and button + const wrapper = document.createElement('div'); + wrapper.classList.add('url-container', 'mt-2'); + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.gap = '10px'; + wrapper.style.flexWrap = 'nowrap'; // Prevent wrapping + + // Move the existing elements to the wrapper + const uidPlaceholder = document.getElementById('uidPlaceholder'); + const uidUrl = document.getElementById('uidUrl'); + + // Create a div to contain the URL display + const urlDisplay = document.createElement('div'); + urlDisplay.classList.add('url-display'); + urlDisplay.style.overflow = 'hidden'; + urlDisplay.style.textOverflow = 'ellipsis'; + urlDisplay.style.whiteSpace = 'nowrap'; // Keep URL on one line + urlDisplay.style.minWidth = '0'; // Allow flex item to shrink below content size + urlDisplay.style.flexGrow = '1'; // Take available space + urlDisplay.style.flexShrink = '1'; // Allow shrinking + + // Move the existing elements to the URL display div + if (uidPlaceholder) urlDisplay.appendChild(uidPlaceholder); + if (uidUrl) urlDisplay.appendChild(uidUrl); + + // Add the URL display to the wrapper + wrapper.appendChild(urlDisplay); + + // Create the Copy URL button + const copyUrlBtn = document.createElement('button'); + copyUrlBtn.id = 'copyUrlBtn'; + copyUrlBtn.classList.add('btn', 'btn-primary', 'rounded-pill', 'd-none'); + copyUrlBtn.style.height = '40px'; + copyUrlBtn.style.padding = '0 15px'; + copyUrlBtn.style.fontSize = '0.9rem'; + copyUrlBtn.style.whiteSpace = 'nowrap'; + copyUrlBtn.style.width = 'auto'; // Only as wide as content + copyUrlBtn.style.minWidth = 'fit-content'; // Ensure it fits content + copyUrlBtn.style.flexShrink = '0'; // Prevent button from shrinking + + copyUrlBtn.innerHTML = 'Copy URL'; + + // Add click event to copy URL to clipboard + copyUrlBtn.addEventListener('click', () => { + const url = document.getElementById('uidUrl').href; + if (url && url !== '#') { + navigator.clipboard.writeText(url) + .then(() => { + // Change button text temporarily to indicate success + const originalText = copyUrlBtn.innerHTML; + copyUrlBtn.innerHTML = ' Copied!'; + copyUrlBtn.classList.remove('btn-primary'); + copyUrlBtn.classList.add('btn-success'); + + // Reset button after 2 seconds + setTimeout(() => { + copyUrlBtn.innerHTML = originalText; + copyUrlBtn.classList.remove('btn-success'); + copyUrlBtn.classList.add('btn-primary'); + }, 2000); + }) + .catch(err => { + console.error('Failed to copy URL: ', err); + alert('Failed to copy URL to clipboard'); + }); + } + }); + + // Add the button to the wrapper + wrapper.appendChild(copyUrlBtn); + + // Clear the container and add the wrapper + urlContainer.innerHTML = ''; + urlContainer.appendChild(document.createElement('small')).classList.add('d-block', 'text-muted', 'mb-1'); + urlContainer.querySelector('small').textContent = 'Public Access URL:'; + urlContainer.appendChild(wrapper); + + // Show the button if a UID is already selected + const selectedUid = document.getElementById('uidSelect').value; + if (selectedUid) { + copyUrlBtn.classList.remove('d-none'); + } + } + + // Close time picker when clicking outside + document.addEventListener('click', (e) => { + const timepicker = document.getElementById('visualTimePicker'); + const timepickerContainer = document.querySelector('.timepicker-container'); + const customTimeBtn = document.getElementById('customTimeInput'); + + // Only close if we're not in the middle of a drag operation + if (timepicker && + timepicker.style.display === 'block' && + e.target !== customTimeBtn && + (!timepickerContainer || !timepickerContainer.contains(e.target)) && + !window.timepickerDragging) { // Check the global dragging flag + + // Close the timepicker + timepicker.style.display = 'none'; + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; + + // Call the onClose callback if provided + if (window.activeTimepickerController && window.activeTimepickerController.onClose) { + window.activeTimepickerController.onClose(); + } + } + }); + + // Listen for date selection events from the calendar component + document.addEventListener('dateSelected', (event) => { + if (event.detail && event.detail.dateStr) { + updateTimeSlots(event.detail.dateStr); + } + }); + + // Handle UID selection + const uidSelect = document.getElementById('uidSelect'); + const deleteUidBtn = document.getElementById('deleteUid'); + const flushDatabaseBtn = document.getElementById('flushDatabase'); + const uidUrl = document.getElementById('uidUrl'); + const uidPlaceholder = document.getElementById('uidPlaceholder'); + + uidSelect.addEventListener('change', async () => { + const selectedUid = uidSelect.value; + deleteUidBtn.disabled = !selectedUid; + flushDatabaseBtn.disabled = !selectedUid; + + // Show or hide calendar and availability sections based on UID selection + const calendarSection = document.getElementById('calendarSection'); + const calendarDatesSection = document.getElementById('calendarDatesSection'); + const availabilitySection = document.getElementById('availabilitySection'); + + if (selectedUid) { + calendarSection.classList.remove('d-none'); + calendarDatesSection.classList.remove('d-none'); + + // Show URL link and hide placeholder + uidPlaceholder.classList.add('d-none'); + uidUrl.classList.remove('d-none'); + uidUrl.href = `${window.location.origin}/${selectedUid}`; + uidUrl.textContent = `${window.location.origin}/${selectedUid}`; + + // Show copy button when URL is displayed + if (document.getElementById('copyUrlBtn')) { + document.getElementById('copyUrlBtn').classList.remove('d-none'); + } + + // Render the calendar + await renderAdminCalendar(currentAdminMonth, currentAdminYear); + + // Automatically select today's date if it's in the current month/year + const today = new Date(); + if (today.getMonth() === currentAdminMonth && today.getFullYear() === currentAdminYear) { + // Get the first day of the month to pass to selectAdminDate + const firstDay = new Date(currentAdminYear, currentAdminMonth, 1).getDay(); + + // Select today's date + await selectAdminDate(today, firstDay); + } + } else { + calendarSection.classList.add('d-none'); + calendarDatesSection.classList.add('d-none'); + availabilitySection.classList.add('d-none'); + + // Hide URL link and show placeholder + uidPlaceholder.classList.remove('d-none'); + uidUrl.classList.add('d-none'); + + // Hide copy button when no URL is displayed + if (document.getElementById('copyUrlBtn')) { + document.getElementById('copyUrlBtn').classList.add('d-none'); + } + + // Clear current selections + const selectedDateElem = document.getElementById('selectedDateDisplay'); + const timeSlotsElem = document.getElementById('timeSlots'); + + // Clear UI elements + if (selectedDateElem) selectedDateElem.textContent = 'Please select a UID to view the calendar'; + if (timeSlotsElem) timeSlotsElem.innerHTML = ''; + } + }); + + // Handle create UID button + const createUidBtn = document.getElementById('createUid'); + const createUidModal = new bootstrap.Modal(document.getElementById('createUidModal')); + + // Add event listener for modal shown event to focus the input field + document.getElementById('createUidModal').addEventListener('shown.bs.modal', function () { + const modalUidInput = document.getElementById('modalUidInput'); + if (modalUidInput) { + modalUidInput.focus(); + } + }); + + createUidBtn.addEventListener('click', () => { + // Clear input field + const uidInput = document.getElementById('modalUidInput'); + if (uidInput) { + uidInput.value = ''; + } + + // Show modal + createUidModal.show(); + }); + + // Add event listener for Enter key on the input field + const modalUidInput = document.getElementById('modalUidInput'); + if (modalUidInput) { + modalUidInput.addEventListener('keyup', (event) => { + if (event.key === 'Enter') { + document.getElementById('confirmCreateUid').click(); + } + }); + } + + // Handle confirm create UID button + const confirmCreateUidBtn = document.getElementById('confirmCreateUid'); + confirmCreateUidBtn.addEventListener('click', async () => { + const uidInput = document.getElementById('modalUidInput'); + if (uidInput && uidInput.value) { + const uid = uidInput.value.trim().toLowerCase(); + + // Validate UID format + if (!/^[a-z0-9-]+$/.test(uid)) { + showAlert('danger', 'UID can only contain lowercase letters, numbers, and hyphens'); + return; + } + + try { + await createUid(uid); + + // Reload UIDs and select the new one + const uids = await loadUids(); + + // Clear existing options except the placeholder + while (uidSelect.options.length > 1) { + uidSelect.remove(1); + } + + // Add UIDs to select + uids.forEach(uid => { + const option = document.createElement('option'); + option.value = uid.uid; + option.textContent = uid.uid; + uidSelect.appendChild(option); + }); + + // Select the new UID + uidSelect.value = uid; + uidSelect.dispatchEvent(new Event('change')); + + // Hide modal + createUidModal.hide(); + + // Show success message + showAlert('success', `UID "${uid}" created successfully`); + } catch (error) { + console.error('Error creating UID:', error); + showAlert('danger', 'Failed to create UID'); + } + } + }); + + // Handle delete UID button + deleteUidBtn.addEventListener('click', async () => { + const selectedUid = uidSelect.value; + if (!selectedUid) return; + + if (confirm(`Are you sure you want to delete UID "${selectedUid}" and all associated availability?`)) { + try { + await deleteUid(selectedUid); + + // Reload UIDs and reset selection + const uids = await loadUids(); + + // Clear existing options except the placeholder + while (uidSelect.options.length > 1) { + uidSelect.remove(1); + } + + // Add UIDs to select + uids.forEach(uid => { + const option = document.createElement('option'); + option.value = uid.uid; + option.textContent = uid.uid; + uidSelect.appendChild(option); + }); + + // Reset selection + uidSelect.value = ''; + uidSelect.dispatchEvent(new Event('change')); + + // Show success message + showAlert('success', `UID "${selectedUid}" deleted successfully`); + } catch (error) { + console.error('Error deleting UID:', error); + showAlert('danger', 'Failed to delete UID'); + } + } + }); + + // Handle flush database button + flushDatabaseBtn.addEventListener('click', async () => { + const selectedUid = uidSelect.value; + if (!selectedUid) { + showAlert('warning', 'Please select a UID first'); + return; + } + + if (confirm(`Are you sure you want to delete ALL availability entries for UID: ${selectedUid}?`)) { + try { + await flushAvailability(selectedUid); + + // Refresh data + await fetchAvailability(); // Refresh availability data + + // Reset UI + const selectedDateElem = document.getElementById('selectedDateDisplay'); + const timeSlotsElem = document.getElementById('timeSlots'); + + // Clear UI elements + if (selectedDateElem) selectedDateElem.textContent = ''; + if (timeSlotsElem) timeSlotsElem.innerHTML = ''; + + // Re-render the calendar + await renderAdminCalendar(currentAdminMonth, currentAdminYear); + + // Show success message + showAlert('success', `All availability for UID "${selectedUid}" deleted successfully`); + } catch (error) { + console.error('Error flushing availability:', error); + showAlert('danger', 'Failed to delete availability'); + } + } + }); + + // Handle dev tools button + const devToolsBtn = document.getElementById('devToolsBtn'); + const devToolsModal = new bootstrap.Modal(document.getElementById('devToolsModal')); + + devToolsBtn.addEventListener('click', () => { + devToolsModal.show(); + }); + + // Handle reset database button + const resetDatabaseBtn = document.getElementById('resetDatabase'); + + resetDatabaseBtn.addEventListener('click', async () => { + if (confirm('WARNING: This will permanently delete ALL UIDs and availability data. This action cannot be undone. Are you sure?')) { + try { + await resetDatabase(); + + // Reload UIDs and reset selection + const uids = await loadUids(); + + // Clear existing options except the placeholder + while (uidSelect.options.length > 1) { + uidSelect.remove(1); + } + + // Add UIDs to select + uids.forEach(uid => { + const option = document.createElement('option'); + option.value = uid.uid; + option.textContent = uid.uid; + uidSelect.appendChild(option); + }); + + // Reset selection + uidSelect.value = ''; + uidSelect.dispatchEvent(new Event('change')); + + // Hide modal + devToolsModal.hide(); + + // Show success message + showAlert('success', 'Database reset successfully'); + } catch (error) { + console.error('Error resetting database:', error); + showAlert('danger', 'Failed to reset database'); + } + } + }); + + // Handle month navigation + const prevMonthBtn = document.getElementById('adminPrevMonth'); + const nextMonthBtn = document.getElementById('adminNextMonth'); + + prevMonthBtn.addEventListener('click', () => { + // Update current month and year + if (currentAdminMonth === 0) { + currentAdminMonth = 11; + currentAdminYear--; + } else { + currentAdminMonth--; + } + + // Render calendar with new month + renderAdminCalendar(currentAdminMonth, currentAdminYear); + }); + + nextMonthBtn.addEventListener('click', () => { + // Update current month and year + if (currentAdminMonth === 11) { + currentAdminMonth = 0; + currentAdminYear++; + } else { + currentAdminMonth++; + } + + // Render calendar with new month + renderAdminCalendar(currentAdminMonth, currentAdminYear); + }); + + // Load UIDs on page load + try { + const uids = await loadUids(); + + // Clear existing options except the placeholder + while (uidSelect.options.length > 1) { + uidSelect.remove(1); + } + + // Add UIDs to select + uids.forEach(uid => { + const option = document.createElement('option'); + option.value = uid.uid; + option.textContent = uid.uid; + uidSelect.appendChild(option); + }); + + // Enable/disable buttons based on UID count + const hasUids = uids.length > 0; + flushDatabaseBtn.disabled = !hasUids; + } catch (error) { + console.error('Error loading UIDs:', error); + } + + // Initial render with current date + if (uidSelect.value) { + renderAdminCalendar(currentAdminMonth, currentAdminYear); + + // Check if current date has availability and select it if it does + const today = new Date(); + if (today.getMonth() === currentAdminMonth && today.getFullYear() === currentAdminYear) { + const hasAvailability = await checkAvailabilityForDate(today); + if (hasAvailability) { + const firstDay = new Date(currentAdminYear, currentAdminMonth, 1).getDay(); + selectAdminDate(today, firstDay); + } + } + } +}); diff --git a/public/js/components/calendar.js b/public/js/components/calendar.js new file mode 100644 index 0000000..a4da748 --- /dev/null +++ b/public/js/components/calendar.js @@ -0,0 +1,172 @@ +// js/components/calendar.js + +// Import time utilities +import { timeToMinutes, formatDate } from '../utils/time-utils.js'; +import { fetchAvailability } from '../utils/api.js'; + +// Global variables for calendar +let currentAdminDate = new Date(); +let currentAdminMonth = currentAdminDate.getMonth(); +let currentAdminYear = currentAdminDate.getFullYear(); +let selectedAdminDate = null; +const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +// Render the admin calendar with available dates highlighted +async function renderAdminCalendar(month, year) { + const adminCalendarDates = document.getElementById('adminCalendarDates'); + const adminMonthYear = document.getElementById('adminMonthYear'); + if (!adminCalendarDates || !adminMonthYear) { + console.error('Required calendar elements not found'); + return; + } + + // Clear existing calendar + adminCalendarDates.innerHTML = ''; + adminMonthYear.textContent = `${months[month]} ${year}`; + + // Get current UID and availability + const selectedUid = document.getElementById('uidSelect').value; + if (!selectedUid) { + // If no UID selected, just show empty calendar + return; + } + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const today = new Date(); + const currentMinutes = today.getHours() * 60 + today.getMinutes(); + const availability = await fetchAvailability(); + + // Start of today (midnight) + const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + // Create blank cells for days before the first day of the month + for (let i = 0; i < firstDay; i++) { + const blank = document.createElement('div'); + blank.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center', 'text-muted', 'empty-cell'); + blank.style.cursor = 'default'; // Remove pointer cursor + blank.style.pointerEvents = 'none'; // Disable hover and click events + adminCalendarDates.appendChild(blank); + } + + // Clear any existing selected date + const existingSelected = adminCalendarDates.querySelector('.selected'); + if (existingSelected) { + existingSelected.classList.remove('selected'); + } + + // Populate the calendar with days + let todayElement = null; + for (let i = 1; i <= daysInMonth; i++) { + const day = document.createElement('div'); + day.classList.add('date-item', 'p-2', 'rounded-pill', 'text-center'); + day.textContent = i; + const date = new Date(year, month, i); + const dateStr = formatDate(date); + + // Check if date is in the past + const isPastDate = date < startOfToday; + const isToday = date.toDateString() === today.toDateString(); + + if (isToday) { + day.classList.add('current'); + todayElement = day; + } + + // Check if the date has available time slots + let hasAvailableTimes = false; + if (availability[dateStr]) { + let times = []; + if (typeof availability[dateStr] === 'string') { + times = availability[dateStr].split(',').map(t => t.trim()); + } else if (Array.isArray(availability[dateStr])) { + times = availability[dateStr].map(t => t.trim()); + } + + // For today, filter out past times + if (isToday) { + times = times.filter(time => timeToMinutes(time) > currentMinutes); + } + + // Only mark as available if there are valid times + hasAvailableTimes = times.length > 0; + } + + // Only add 'available' class if the date is not in the past AND has available times + if (hasAvailableTimes && !isPastDate) { + day.classList.add('available'); + } + + if (isPastDate) { + day.classList.add('disabled'); + } else { + day.addEventListener('click', () => selectAdminDate(date, firstDay)); + } + adminCalendarDates.appendChild(day); + } + + // If a date was previously selected, re-select it + if (selectedAdminDate && + selectedAdminDate.getMonth() === month && + selectedAdminDate.getFullYear() === year) { + selectAdminDate(selectedAdminDate, firstDay); + } +} + +// Handle admin date selection +async function selectAdminDate(date, firstDay) { + // Check if the date is in the past + const today = new Date(); + const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + if (date < startOfToday) { + console.warn('Attempted to select a past date'); + return; + } + + // Update selected date + selectedAdminDate = date; + + // Get the date string for the selected date + const dateStr = formatDate(date); + + // Trigger the updateTimeSlots function in admin.js + const event = new CustomEvent('dateSelected', { + detail: { dateStr: dateStr } + }); + document.dispatchEvent(event); + + // Update UI to show selected date + const dateItems = document.querySelectorAll('#adminCalendarDates .date-item'); + dateItems.forEach(item => item.classList.remove('selected')); // Ensure only one date is selected + + // Find the exact date item for the clicked date + const selectedDay = Array.from(dateItems).find(item => { + const dayText = item.textContent; + if (!dayText || isNaN(parseInt(dayText))) return false; + const itemDate = new Date(currentAdminYear, currentAdminMonth, parseInt(dayText)); + return itemDate.toDateString() === date.toDateString(); + }); + + if (selectedDay) selectedDay.classList.add('selected'); + + const selectedDateDisplay = document.getElementById('selectedDateDisplay'); + if (selectedDateDisplay) { + selectedDateDisplay.textContent = ''; + } + + // Show the availability section + const availabilitySection = document.getElementById('availabilitySection'); + if (availabilitySection) { + availabilitySection.classList.remove('d-none'); + } +} + +// Export functions for use in other files +export { + renderAdminCalendar, + selectAdminDate, + currentAdminMonth, + currentAdminYear, + selectedAdminDate, + months +}; \ No newline at end of file diff --git a/public/js/components/timepicker.js b/public/js/components/timepicker.js new file mode 100644 index 0000000..e27f918 --- /dev/null +++ b/public/js/components/timepicker.js @@ -0,0 +1,503 @@ +// js/components/timepicker.js + +// Initialize the visual time picker +function initializeVisualTimePicker(dateStr, availableTimes) { + // Remove any existing time picker + const existingTimePicker = document.getElementById('visualTimePicker'); + if (existingTimePicker) { + existingTimePicker.remove(); + } + + // Create the timepicker modal from the template - moved to a separate function for clarity + document.body.insertAdjacentHTML('beforeend', createTimepickerTemplate()); + + // Store the dateStr for later use + const currentWrapper = document.querySelector('.timepicker-current-wrapper'); + if (currentWrapper) { + currentWrapper.setAttribute('data-date', dateStr); + } + + // Initialize timepicker functionality and store the reference to the controller + const controller = initializeTimepickerFunctionality(dateStr, availableTimes); + + // Store a reference to the controller in the global scope for access from event handlers + window.timepickerController = controller; + + return controller; +} + +// Helper function to show the timepicker +function showTimepicker(timepickerController) { + const timepicker = document.getElementById('visualTimePicker'); + if (!timepicker) return; + + // First make the timepicker visible but with opacity 0 + timepicker.style.display = 'block'; + timepicker.style.opacity = '0'; + + // Force a reflow to ensure the clock dimensions are calculated + setTimeout(() => { + // Get the controller from the DOM element if not provided + if (!timepickerController || !timepickerController.switchToHoursView) { + // Find the controller in the global scope + const clockElements = document.querySelector('.timepicker-clock'); + if (clockElements) { + // Manually render the clock face if needed + const hourTips = clockElements.querySelectorAll('.timepicker-time-tips-hours'); + if (hourTips.length === 0) { + // Re-initialize the timepicker if no hour tips are found + const dateStr = document.querySelector('.timepicker-current-wrapper') ? + document.querySelector('.timepicker-current-wrapper').getAttribute('data-date') : ''; + const availableTimes = []; + initializeTimepickerFunctionality(dateStr, availableTimes); + } + } + } else { + timepickerController.switchToHoursView(); + } + + // Make the timepicker visible + timepicker.style.opacity = '1'; + document.body.style.overflow = 'hidden'; + document.body.style.paddingRight = '0px'; + }, 10); +} + +// Create the timepicker HTML template +function createTimepickerTemplate() { + return ` +
`; +} + +// Initialize the timepicker functionality +function initializeTimepickerFunctionality(dateStr, availableTimes) { + // DOM elements - group related elements together + const timeElements = { + hour: document.querySelector('.timepicker-hour'), + minute: document.querySelector('.timepicker-minute'), + am: document.querySelector('.timepicker-am'), + pm: document.querySelector('.timepicker-pm'), + dot: document.querySelector('.timepicker-dot') + }; + + const clockElements = { + wrapper: document.querySelector('.timepicker-clock-wrapper'), + clock: document.querySelector('.timepicker-clock'), + hand: document.querySelector('.timepicker-hand-pointer') + }; + + const actionButtons = { + clear: document.querySelector('.timepicker-clear'), + cancel: document.querySelector('.timepicker-cancel'), + submit: document.querySelector('.timepicker-submit') + }; + + // State variables + const state = { + currentView: 'hours', + selectedHour: 12, + selectedMinute: 0, + isDragging: false, + isPM: timeElements.pm.classList.contains('active') + }; + + // Create a global variable to track if we're dragging from inside the timepicker + window.timepickerDragging = false; + + // Initialize + setupEventListeners(); + renderClockFace(state.currentView); + + function setupEventListeners() { + // Header hour/minute buttons + timeElements.hour.addEventListener('click', (e) => { + switchView('hours'); + e.stopPropagation(); + }); + timeElements.minute.addEventListener('click', (e) => { + switchView('minutes'); + e.stopPropagation(); + }); + + // Clock face events + clockElements.clock.addEventListener('mousedown', handleClockMouseDown); + document.addEventListener('mousemove', handleClockMouseMove); + document.addEventListener('mouseup', handleClockMouseUp); + + // Prevent text selection on the clock + clockElements.clock.style.userSelect = 'none'; + clockElements.clock.style.webkitUserSelect = 'none'; + clockElements.clock.style.msUserSelect = 'none'; + + // AM/PM buttons + timeElements.am.addEventListener('click', (e) => { + setAmPm('AM'); + e.stopPropagation(); + }); + timeElements.pm.addEventListener('click', (e) => { + setAmPm('PM'); + e.stopPropagation(); + }); + + // Action buttons + actionButtons.clear.addEventListener('click', (e) => { + clearTime(); + e.stopPropagation(); + }); + actionButtons.cancel.addEventListener('click', (e) => { + closeTimepicker(); + e.stopPropagation(); + }); + actionButtons.submit.addEventListener('click', (e) => { + submitTime(); + e.stopPropagation(); + }); + } + + function switchView(view) { + state.currentView = view; + + // Update active state in header + if (view === 'hours') { + timeElements.hour.classList.add('active'); + timeElements.minute.classList.remove('active'); + timeElements.hour.style.pointerEvents = 'none'; + timeElements.minute.style.pointerEvents = 'auto'; + } else { + timeElements.hour.classList.remove('active'); + timeElements.minute.classList.add('active'); + timeElements.hour.style.pointerEvents = 'auto'; + timeElements.minute.style.pointerEvents = 'none'; + } + + renderClockFace(view); + } + + function renderClockFace(view) { + // Clear existing time tips + const existingTips = clockElements.clock.querySelectorAll('.timepicker-time-tips-hours, .timepicker-time-tips-minutes'); + existingTips.forEach(tip => tip.remove()); + + if (view === 'hours') { + renderHoursFace(); + updateHandPosition(state.selectedHour, 'hours'); + } else { + renderMinutesFace(); + updateHandPosition(state.selectedMinute, 'minutes'); + } + } + + function renderHoursFace() { + const clockRadius = clockElements.clock.offsetWidth / 2; + const tipRadius = clockRadius * 0.85; // Position closer to the edge (85% of radius) + + for (let hour = 1; hour <= 12; hour++) { + const tip = createClockTip(hour, clockRadius, tipRadius, 'hours'); + + if (hour === state.selectedHour) { + tip.classList.add('active'); + } + + clockElements.clock.appendChild(tip); + } + } + + function renderMinutesFace() { + const clockRadius = clockElements.clock.offsetWidth / 2; + const tipRadius = clockRadius * 0.85; // Position closer to the edge (85% of radius) + + for (let minute = 0; minute < 60; minute += 5) { + const tip = createClockTip(minute, clockRadius, tipRadius, 'minutes'); + + if (minute === state.selectedMinute) { + tip.classList.add('active'); + } + + clockElements.clock.appendChild(tip); + } + } + + // Helper function to create clock face tips (numbers) + function createClockTip(value, clockRadius, tipRadius, type) { + // Calculate angle and position + let angle, displayValue; + + if (type === 'hours') { + angle = ((value * 30) - 90) * (Math.PI / 180); + displayValue = value; + } else { + angle = ((value * 6) - 90) * (Math.PI / 180); + displayValue = value.toString().padStart(2, '0'); + } + + // Calculate position + const left = clockRadius + tipRadius * Math.cos(angle); + const top = clockRadius + tipRadius * Math.sin(angle); + + // Create the tip element + const tip = document.createElement('span'); + tip.className = `timepicker-time-tips-${type}`; + tip.style.left = `${left}px`; + tip.style.top = `${top}px`; + tip.style.position = 'absolute'; + tip.style.transform = 'translate(-50%, -50%)'; + tip.style.userSelect = 'none'; + tip.style.webkitUserSelect = 'none'; + tip.style.msUserSelect = 'none'; + + const tipElement = document.createElement('span'); + tipElement.className = 'timepicker-tips-element'; + tipElement.textContent = displayValue; + + tip.appendChild(tipElement); + return tip; + } + + function updateHandPosition(value, view) { + let angle; + + if (view === 'hours') { + // For hours, convert 12 to 0 for calculation purposes + const hour = value === 12 ? 0 : value; + angle = (hour / 12) * 360; + } else { + angle = (value / 60) * 360; + } + + clockElements.hand.style.transform = `rotateZ(${angle}deg)`; + + // Update the active circle on the hand + const circle = clockElements.hand.querySelector('.timepicker-circle'); + if (circle) { + circle.classList.add('active'); + } + } + + function handleClockMouseDown(event) { + state.isDragging = true; + // Set the global flag to indicate we're dragging from inside the timepicker + window.timepickerDragging = true; + updateTimeFromClockPosition(event); + + // Stop event propagation to prevent the click outside handler from firing + event.stopPropagation(); + } + + function handleClockMouseMove(event) { + if (state.isDragging) { + updateTimeFromClockPosition(event); + } + } + + function handleClockMouseUp() { + if (state.isDragging) { + state.isDragging = false; + + // Reset the global dragging flag after a short delay + // This allows the click event to process first + setTimeout(() => { + window.timepickerDragging = false; + }, 10); + + // If we just finished selecting an hour, switch to minutes + if (state.currentView === 'hours') { + switchView('minutes'); + } + } + } + + function updateTimeFromClockPosition(event) { + const clockRect = clockElements.clock.getBoundingClientRect(); + const centerX = clockRect.left + clockRect.width / 2; + const centerY = clockRect.top + clockRect.height / 2; + + // Calculate angle from center to mouse position + const x = event.clientX - centerX; + const y = event.clientY - centerY; + + // Calculate angle in degrees, starting from 12 o'clock position + let angle = Math.atan2(y, x) * (180 / Math.PI) + 90; + + // Normalize angle to 0-360 + if (angle < 0) { + angle += 360; + } + + if (state.currentView === 'hours') { + updateHourFromAngle(angle); + } else { + updateMinuteFromAngle(angle); + } + } + + function updateHourFromAngle(angle) { + // Convert angle to hour (1-12) + let hour = Math.round(angle / 30); + + // Handle edge cases for full circle + if (hour === 0 || hour > 12) hour = 12; + + state.selectedHour = hour; + timeElements.hour.textContent = hour; + updateHandPosition(hour, 'hours'); + + // Update active class on hour tips + const hourTips = clockElements.clock.querySelectorAll('.timepicker-time-tips-hours'); + hourTips.forEach(tip => { + const hourValue = parseInt(tip.querySelector('.timepicker-tips-element').textContent); + if (hourValue === state.selectedHour) { + tip.classList.add('active'); + } else { + tip.classList.remove('active'); + } + }); + } + + function updateMinuteFromAngle(angle) { + // Convert angle to minute (0-55, step 5) + let minute = Math.floor(angle / 6); + + // Round to nearest 5 + minute = Math.round(minute / 5) * 5; + + // Handle edge case for full circle + if (minute >= 60) minute = 0; + + state.selectedMinute = minute; + timeElements.minute.textContent = minute.toString().padStart(2, '0'); + updateHandPosition(minute, 'minutes'); + + // Update active class on minute tips + const minuteTips = clockElements.clock.querySelectorAll('.timepicker-time-tips-minutes'); + minuteTips.forEach(tip => { + const minuteValue = parseInt(tip.querySelector('.timepicker-tips-element').textContent); + if (minuteValue === state.selectedMinute) { + tip.classList.add('active'); + } else { + tip.classList.remove('active'); + } + }); + } + + function setAmPm(period) { + if (period === 'AM') { + timeElements.am.classList.add('active'); + timeElements.pm.classList.remove('active'); + state.isPM = false; + } else { + timeElements.am.classList.remove('active'); + timeElements.pm.classList.add('active'); + state.isPM = true; + } + } + + function clearTime() { + state.selectedHour = 12; + state.selectedMinute = 0; + timeElements.hour.textContent = '12'; + timeElements.minute.textContent = '00'; + setAmPm('PM'); + switchView('hours'); + } + + function closeTimepicker() { + const timepicker = document.getElementById('visualTimePicker'); + if (timepicker) { + timepicker.style.display = 'none'; + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; + } + + // Call the onClose callback if provided through the controller + if (window.activeTimepickerController && window.activeTimepickerController.onClose) { + window.activeTimepickerController.onClose(); + } + } + + function submitTime() { + const formattedHour = state.selectedHour; + const formattedMinute = state.selectedMinute.toString().padStart(2, '0'); + const period = state.isPM ? 'pm' : 'am'; + + const timeValue = `${formattedHour}:${formattedMinute}${period}`; + console.log('Submitting time value:', timeValue); + + // Get the date string from the data attribute + const currentWrapper = document.querySelector('.timepicker-current-wrapper'); + const dateStr = currentWrapper ? currentWrapper.getAttribute('data-date') : ''; + + // Check if time already exists in available times + if (availableTimes && availableTimes.includes(timeValue)) { + // Silently handle duplicate time slots - no error message + console.log('Time slot already exists, not adding duplicate'); + } else { + // Call the onSubmit callback if provided through the controller + if (window.activeTimepickerController && window.activeTimepickerController.onSubmit) { + console.log('Calling onSubmit with time:', timeValue); + window.activeTimepickerController.onSubmit(timeValue); + } else { + console.error('No active timepicker controller found'); + } + } + + closeTimepicker(); + } + + // Return controller object with public methods + return { + renderClockFace, + closeTimepicker, + switchToHoursView: () => switchView('hours') + }; +} + +// Export functions for use in other files +export { + initializeVisualTimePicker, + showTimepicker, + createTimepickerTemplate +}; \ No newline at end of file diff --git a/public/js/utils/api.js b/public/js/utils/api.js new file mode 100644 index 0000000..32d1a69 --- /dev/null +++ b/public/js/utils/api.js @@ -0,0 +1,187 @@ +// js/utils/api.js + +// API utility for making requests +const api = { + async request(endpoint, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + const config = { + ...options, + headers + }; + + try { + const response = await fetch(`/${endpoint}`, config); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API request error:', error); + throw error; + } + }, + + get(endpoint) { + return this.request(endpoint); + }, + + post(endpoint, data) { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + put(endpoint, data) { + return this.request(endpoint, { + method: 'PUT', + body: JSON.stringify(data) + }); + }, + + delete(endpoint) { + return this.request(endpoint, { + method: 'DELETE' + }); + } +}; + +// Fetch availability data from the API +async function fetchAvailability() { + try { + const selectedUid = document.getElementById('uidSelect').value; + if (!selectedUid) { + return {}; // Return empty object if no UID is selected + } + + return await api.get(`api/availability/${selectedUid}`); + } catch (error) { + console.error('Error fetching availability:', error); + return {}; // Return empty object on error for graceful degradation + } +} + +// Load UIDs from the API +async function loadUids() { + try { + return await api.get('api/uids'); + } catch (error) { + console.error('Error loading UIDs:', error); + return []; // Return empty array on error for graceful degradation + } +} + +// Create a new UID +async function createUid(uid) { + try { + return await api.post('api/uids', { uid }); + } catch (error) { + console.error('Error creating UID:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Delete a UID +async function deleteUid(uid) { + try { + await api.delete(`api/uids/${uid}`); + return true; + } catch (error) { + console.error('Error deleting UID:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Add a single time slot to a date +async function addSingleTime(dateStr, time) { + try { + const selectedUid = document.getElementById('uidSelect').value; + if (!selectedUid) { + throw new Error('No UID selected'); + } + + // First, get current availability for this date + const availability = await fetchAvailability(); + const currentTimes = availability[dateStr] || []; + + // Add the new time if it doesn't already exist + if (!currentTimes.includes(time)) { + currentTimes.push(time); + } + + // Update the availability with the new times array + return await api.post(`api/availability/${selectedUid}`, { + date: dateStr, + times: currentTimes + }); + } catch (error) { + console.error('Error adding time:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Remove a time slot from a date +async function removeTime(dateStr, time) { + try { + const selectedUid = document.getElementById('uidSelect').value; + if (!selectedUid) { + throw new Error('No UID selected'); + } + + // First, get current availability for this date + const availability = await fetchAvailability(); + const currentTimes = availability[dateStr] || []; + + // Remove the specified time + const updatedTimes = currentTimes.filter(t => t !== time); + + // Update the availability with the filtered times array + return await api.post(`api/availability/${selectedUid}`, { + date: dateStr, + times: updatedTimes + }); + } catch (error) { + console.error('Error removing time:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Flush (delete) all availability entries for a specific UID +async function flushAvailability(uid) { + try { + return await api.delete(`api/availability/${uid}/flush`); + } catch (error) { + console.error('Error flushing availability:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Reset the database (dev tool) +async function resetDatabase() { + try { + await api.post('api/reset'); + return true; + } catch (error) { + console.error('Error resetting database:', error); + throw error; // Rethrow for caller to handle (this is a user-initiated action) + } +} + +// Export functions and API utility for use in other files +export { + api, + fetchAvailability, + loadUids, + createUid, + deleteUid, + addSingleTime, + removeTime, + flushAvailability, + resetDatabase +}; \ No newline at end of file diff --git a/public/js/utils/time-utils.js b/public/js/utils/time-utils.js new file mode 100644 index 0000000..6ce7db3 --- /dev/null +++ b/public/js/utils/time-utils.js @@ -0,0 +1,48 @@ +// js/utils/time-utils.js + +// Helper function to convert time string to minutes since midnight +function timeToMinutes(timeStr) { + const match = timeStr.match(/([0-9]+)(?::([0-9]+))?(am|pm)/i); + if (!match) return 0; + + let hours = parseInt(match[1]); + const minutes = parseInt(match[2] || '0'); + const meridian = match[3].toLowerCase(); + + if (meridian === 'pm' && hours !== 12) { + hours += 12; + } else if (meridian === 'am' && hours === 12) { + hours = 0; + } + + return hours * 60 + minutes; +} + +// Helper function to ensure consistent time format +function formatTimeSlot(hour) { + const meridian = hour >= 12 ? 'pm' : 'am'; + const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); + return `${displayHour}:00${meridian}`; +} + +// Format date as YYYY-MM-DD +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// Parse date string to Date object +function parseDate(dateStr) { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day); +} + +// Export functions for use in other files +export { + timeToMinutes, + formatTimeSlot, + formatDate, + parseDate +}; \ No newline at end of file diff --git a/timepicker-modal/timepicker-modal.html b/timepicker-modal/timepicker-modal.html index 818af67..93e0841 100644 --- a/timepicker-modal/timepicker-modal.html +++ b/timepicker-modal/timepicker-modal.html @@ -1,4 +1,4 @@ - +