Refactor admin.js to modular approach
This commit is contained in:
parent
5f019c7ed4
commit
a9161d138e
380
public/css/styles.css
Normal file
380
public/css/styles.css
Normal file
@ -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 */
|
||||||
|
}
|
413
public/css/timepicker.css
Normal file
413
public/css/timepicker.css
Normal file
@ -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;
|
||||||
|
}
|
809
public/js/admin.js
Normal file
809
public/js/admin.js
Normal file
@ -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 = '<i class="bi bi-clock"></i> 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 = '<i class="bi bi-trash"></i>';
|
||||||
|
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}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '<i class="bi bi-clipboard"></i><span style="margin-left: 8px;">Copy URL</span>';
|
||||||
|
|
||||||
|
// 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 = '<i class="bi bi-check"></i> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
172
public/js/components/calendar.js
Normal file
172
public/js/components/calendar.js
Normal file
@ -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
|
||||||
|
};
|
503
public/js/components/timepicker.js
Normal file
503
public/js/components/timepicker.js
Normal file
@ -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 `
|
||||||
|
<div class="timepicker-modal" role="dialog" tabindex="-1" id="visualTimePicker" style="display: none;">
|
||||||
|
<div id="timepickerWrapper" class="timepicker-wrapper h-100 d-flex align-items-center justify-content-center flex-column position-fixed animation fade-in" style="animation-duration: 300ms;">
|
||||||
|
<div class="d-flex align-items-center justify-content-center flex-column timepicker-container">
|
||||||
|
<div class="d-flex flex-column timepicker-elements justify-content-around">
|
||||||
|
|
||||||
|
<div id="timepickerHead" class="timepicker-head d-flex flex-row align-items-center justify-content-center" style="padding-right:0px">
|
||||||
|
<div class="timepicker-head-content d-flex w-100 justify-content-evenly">
|
||||||
|
<div class="timepicker-current-wrapper">
|
||||||
|
<span class="position-relative h-100">
|
||||||
|
<button type="button" class="timepicker-current timepicker-hour active" tabindex="0" style="pointer-events: none;">12</button>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="timepicker-dot" disabled="">:</button>
|
||||||
|
<span class="position-relative h-100">
|
||||||
|
<button type="button" class="timepicker-current timepicker-minute" tabindex="0">00</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column justify-content-center timepicker-mode-wrapper">
|
||||||
|
<button type="button" class="timepicker-hour-mode timepicker-am" tabindex="0">AM</button>
|
||||||
|
<button type="button" class="timepicker-hour-mode timepicker-pm active" tabindex="0">PM</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="timepickerClockWrapper" class="timepicker-clock-wrapper d-flex justify-content-center flex-column align-items-center">
|
||||||
|
<div class="timepicker-clock timepicker-clock-animation">
|
||||||
|
<span class="timepicker-middle-dot position-absolute"></span>
|
||||||
|
<div class="timepicker-hand-pointer position-absolute" style="transform: rotateZ(360deg);">
|
||||||
|
<div class="timepicker-circle position-absolute active"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Clock face will be dynamically generated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="timepickerFooter" class="timepicker-footer">
|
||||||
|
<div class="w-100 d-flex justify-content-between">
|
||||||
|
<button type="button" class="timepicker-button timepicker-clear" tabindex="0">Clear</button>
|
||||||
|
<button type="button" class="timepicker-button timepicker-cancel" tabindex="0">Cancel</button>
|
||||||
|
<button type="button" class="timepicker-button timepicker-submit" tabindex="0">Ok</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
187
public/js/utils/api.js
Normal file
187
public/js/utils/api.js
Normal file
@ -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
|
||||||
|
};
|
48
public/js/utils/time-utils.js
Normal file
48
public/js/utils/time-utils.js
Normal file
@ -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
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
<link rel="stylesheet" href="timepicker.css">
|
<link rel="stylesheet" href="timepicker-modal.css">
|
||||||
|
|
||||||
<div class="timepicker-modal" role="dialog" tabindex="-1" id="">
|
<div class="timepicker-modal" role="dialog" tabindex="-1" id="">
|
||||||
<div id="" class="timepicker-wrapper h-100 d-flex align-items-center justify-content-center flex-column position-fixed animation fade-in" style="animation-duration: 300ms;">
|
<div id="" class="timepicker-wrapper h-100 d-flex align-items-center justify-content-center flex-column position-fixed animation fade-in" style="animation-duration: 300ms;">
|
||||||
@ -81,4 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="timepicker.js"></script>
|
<script src="timepicker-modal.js"></script>
|
@ -6,8 +6,8 @@
|
|||||||
<title>FreeSched Admin - Manage Availability</title>
|
<title>FreeSched Admin - Manage Availability</title>
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
<link rel="stylesheet" href="/timepicker.css">
|
<link rel="stylesheet" href="/css/timepicker.css">
|
||||||
<!-- Bootstrap Icons -->
|
<!-- Bootstrap Icons -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
</head>
|
</head>
|
||||||
@ -141,6 +141,6 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
|
||||||
|
|
||||||
<script src="/admin.js"></script>
|
<script type="module" src="/js/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
Loading…
Reference in New Issue
Block a user