Added calendar view to the shifts

This commit is contained in:
admin 2025-07-16 18:05:49 -06:00
parent e0562904a8
commit ff3e1e868b
5 changed files with 575 additions and 19 deletions

View File

@ -15,8 +15,8 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🎯 Configurable map start location - 🎯 Configurable map start location
- 📋 Walk Sheet generator for door-to-door canvassing - 📋 Walk Sheet generator for door-to-door canvassing
- 🔗 QR code integration for digital resources - 🔗 QR code integration for digital resources
- <20> Volunteer shift management system - <20> Volunteer shift management system with calendar and grid views
- ✋ User shift signup and cancellation - ✋ User shift signup and cancellation with color-coded calendar
- 👥 Admin shift creation and management - 👥 Admin shift creation and management
- <20>🐳 Docker containerization for easy deployment - <20>🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies) - 🆓 100% open source (no proprietary dependencies)
@ -222,12 +222,19 @@ The application includes a comprehensive volunteer shift management system acces
### User Features ### User Features
- **Dual View Options**: Toggle between grid view and calendar view for shift display
- **Calendar View**: Interactive monthly calendar showing shifts with color-coded indicators:
- Green: Shifts you've signed up for
- Blue: Available shifts you can join
- Gray: Full shifts (no spots available)
- **View Available Shifts**: See all upcoming shifts with date, time, and capacity information - **View Available Shifts**: See all upcoming shifts with date, time, and capacity information
- **Sign Up for Shifts**: One-click signup for available shifts - **Sign Up for Shifts**: One-click signup for available shifts (works in both views)
- **My Shifts Dashboard**: View all your current shift signups - **My Shifts Dashboard**: View all your current shift signups at the top of the page
- **Cancel Signups**: Cancel your shift signups when needed - **Cancel Signups**: Cancel your shift signups when needed
- **Date Filtering**: Filter shifts by specific dates - **Date Filtering**: Filter shifts by specific dates (applies to both views)
- **Real-time Updates**: Shift availability updates dynamically - **Real-time Updates**: Shift availability updates dynamically
- **Interactive Calendar**: Click on calendar shifts for detailed popup with signup options
- **Calendar Navigation**: Navigate between months to view future shifts
### Admin Features ### Admin Features

View File

@ -182,6 +182,196 @@
align-items: center; align-items: center;
} }
/* View toggle and calendar styles */
.shifts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.view-toggle {
display: flex;
gap: 5px;
}
.view-toggle .btn {
padding: 8px 15px;
}
.view-toggle .btn.active {
background-color: var(--primary-color);
color: white;
}
/* Calendar View Styles */
.calendar-view {
margin-bottom: 40px;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0 10px;
}
.calendar-header h3 {
margin: 0;
color: var(--dark-color);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #e0e0e0;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
overflow: hidden;
}
.calendar-day-header {
background-color: var(--primary-color);
color: white;
padding: 12px 8px;
text-align: center;
font-weight: bold;
font-size: 0.9em;
}
.calendar-day {
background-color: white;
min-height: 120px;
padding: 8px;
position: relative;
border: none;
display: flex;
flex-direction: column;
}
.calendar-day.other-month {
background-color: #f9f9f9;
color: #ccc;
}
.calendar-day.today {
background-color: #fff3cd;
}
.calendar-day-number {
font-size: 0.9em;
font-weight: bold;
margin-bottom: 5px;
color: var(--dark-color);
}
.calendar-day.other-month .calendar-day-number {
color: #ccc;
}
.calendar-shifts {
display: flex;
flex-direction: column;
gap: 2px;
flex-grow: 1;
}
.calendar-shift {
background-color: var(--primary-color);
color: white;
padding: 2px 4px;
border-radius: 3px;
font-size: 0.75em;
cursor: pointer;
transition: var(--transition);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-shift:hover {
opacity: 0.8;
transform: scale(1.02);
}
.calendar-shift.my-shift {
background-color: var(--success-color);
}
.calendar-shift.full-shift {
background-color: var(--secondary-color);
}
.calendar-shift.available-shift {
background-color: var(--primary-color);
}
/* Calendar legend */
.calendar-legend {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.legend-color.my-shift {
background-color: var(--success-color);
}
.legend-color.available-shift {
background-color: var(--primary-color);
}
.legend-color.full-shift {
background-color: var(--secondary-color);
}
/* Calendar shift popup/tooltip */
.shift-popup {
position: absolute;
background: white;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
min-width: 250px;
max-width: 300px;
}
.shift-popup h4 {
margin: 0 0 10px 0;
color: var(--dark-color);
}
.shift-popup p {
margin: 5px 0;
color: var(--secondary-color);
}
.shift-popup .shift-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
/* Mobile adjustments */ /* Mobile adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.shifts-container { .shifts-container {
@ -237,13 +427,66 @@
.filter-group { .filter-group {
flex-wrap: wrap; flex-wrap: wrap;
} }
.shifts-header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.view-toggle {
justify-content: center;
}
.calendar-day {
min-height: 80px;
padding: 4px;
}
.calendar-day-number {
font-size: 0.8em;
}
.calendar-shift {
font-size: 0.7em;
padding: 1px 2px;
}
.calendar-header {
padding: 0;
}
.calendar-header h3 {
font-size: 1.1em;
}
.calendar-legend {
gap: 15px;
}
.legend-item {
font-size: 0.8em;
}
.shift-popup {
max-width: 280px;
padding: 12px;
}
} }
/* Prevent dropdown from being cut off */ /* Extra small screens */
.shifts-grid { @media (max-width: 480px) {
overflow: visible; .calendar-day {
} min-height: 60px;
padding: 2px;
.shift-card { }
overflow: visible;
.calendar-shift {
font-size: 0.65em;
padding: 1px;
}
.calendar-day-number {
font-size: 0.75em;
}
} }

View File

@ -1,6 +1,8 @@
let currentUser = null; let currentUser = null;
let allShifts = []; let allShifts = [];
let mySignups = []; let mySignups = [];
let currentView = 'grid'; // 'grid' or 'calendar'
let currentCalendarDate = new Date(); // For calendar navigation
// Initialize when DOM is loaded // Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
@ -8,6 +10,7 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadShifts(); await loadShifts();
await loadMySignups(); await loadMySignups();
setupEventListeners(); setupEventListeners();
initializeViewToggle();
// Add clear filters button handler // Add clear filters button handler
const clearBtn = document.getElementById('clear-filters-btn'); const clearBtn = document.getElementById('clear-filters-btn');
@ -118,6 +121,11 @@ function displayShifts(shifts) {
// Add event listeners after rendering // Add event listeners after rendering
setupShiftCardListeners(); setupShiftCardListeners();
// Update calendar view if it's currently active
if (currentView === 'calendar') {
renderCalendar();
}
} }
function displayMySignups() { function displayMySignups() {
@ -157,6 +165,11 @@ function displayMySignups() {
// Add event listeners after rendering // Add event listeners after rendering
setupMySignupsListeners(); setupMySignupsListeners();
// Update calendar view if it's currently active
if (currentView === 'calendar') {
renderCalendar();
}
} }
// New function to setup listeners for shift cards // New function to setup listeners for shift cards
@ -447,13 +460,13 @@ function generateCalendarDropdown(shift) {
</button> </button>
<div class="calendar-options" style="display: none;"> <div class="calendar-options" style="display: none;">
<a href="${urls.google}" target="_blank" class="calendar-option" data-calendar-type="google"> <a href="${urls.google}" target="_blank" class="calendar-option" data-calendar-type="google">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyLjUgMkgxMy41QzE0LjMyODQgMiAxNSAyLjY3MTU3IDE1IDMuNVYxMi41QzE1IDEzLjMyODQgMTQuMzI4NCAxNCAxMy41IDE0SDIuNUMxLjY3MTU3IDE0IDEgMTMuMzI4NCAxIDEyLjVWMy41QzEgMi42NzE1NyAxLjY3MTU3IDIgMi41IDJIMy41IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iIzQyODVGNCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNMSA1SDE1IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+Cjwvc3ZnPg==" alt="Google"> Google Calendar Google Calendar
</a> </a>
<a href="${urls.outlook}" target="_blank" class="calendar-option" data-calendar-type="outlook"> <a href="${urls.outlook}" target="_blank" class="calendar-option" data-calendar-type="outlook">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDJIMlYxNEgxNFYyWiIgZmlsbD0iIzAwNzhENCIvPgo8cGF0aCBkPSJNOCA4VjE0SDE0VjhIOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04IDJWOEgxNFYySDhaIiBmaWxsPSIjMDA3OEQ0Ii8+Cjwvc3ZnPg==" alt="Outlook"> Outlook Outlook
</a> </a>
<a href="${urls.apple}" download="${urls.icsFilename}" class="calendar-option" data-calendar-type="apple"> <a href="${urls.apple}" download="${urls.icsFilename}" class="calendar-option" data-calendar-type="apple">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzIDJIMy41QzIuNjcxNTcgMiAyIDIuNjcxNTcgMiAzLjVWMTIuNUMyIDEzLjMyODQgMi42NzE1NyAxNCAzLjUgMTRIMTIuNUMxMy4zMjg0IDE0IDE0IDEzLjMyODQgMTQgMTIuNVYzQzE0IDIuNDQ3NzIgMTMuNTUyMyAyIDEzIDJaIiBmaWxsPSIjRkY1NzMzIi8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iI0ZGNTczMyIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPHBhdGggZD0iTTIgNUgxNCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHRleHQgeD0iOCIgeT0iMTEiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI2IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+MzE8L3RleHQ+Cjwvc3ZnPg==" alt="Apple"> Apple Calendar Apple Calendar
</a> </a>
</div> </div>
</div> </div>
@ -547,6 +560,268 @@ function setupMySignupsListeners() {
}); });
} }
// Calendar View Functions
function initializeViewToggle() {
const gridBtn = document.getElementById('grid-view-btn');
const calendarBtn = document.getElementById('calendar-view-btn');
const prevBtn = document.getElementById('prev-month');
const nextBtn = document.getElementById('next-month');
if (gridBtn && calendarBtn) {
gridBtn.addEventListener('click', () => switchView('grid'));
calendarBtn.addEventListener('click', () => switchView('calendar'));
// Set initial active state
gridBtn.classList.add('active');
}
if (prevBtn && nextBtn) {
prevBtn.addEventListener('click', () => navigateCalendar(-1));
nextBtn.addEventListener('click', () => navigateCalendar(1));
}
}
function switchView(view) {
const gridView = document.getElementById('shifts-grid');
const calendarView = document.getElementById('calendar-view');
const gridBtn = document.getElementById('grid-view-btn');
const calendarBtn = document.getElementById('calendar-view-btn');
currentView = view;
if (view === 'calendar') {
gridView.style.display = 'none';
calendarView.style.display = 'block';
gridBtn.classList.remove('active');
calendarBtn.classList.add('active');
renderCalendar();
} else {
gridView.style.display = 'grid';
calendarView.style.display = 'none';
gridBtn.classList.add('active');
calendarBtn.classList.remove('active');
}
}
function navigateCalendar(direction) {
currentCalendarDate.setMonth(currentCalendarDate.getMonth() + direction);
renderCalendar();
}
function renderCalendar() {
const year = currentCalendarDate.getFullYear();
const month = currentCalendarDate.getMonth();
// Update header
const monthYearHeader = document.getElementById('calendar-month-year');
if (monthYearHeader) {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
monthYearHeader.textContent = `${monthNames[month]} ${year}`;
}
// Get calendar grid
const calendarGrid = document.getElementById('calendar-grid');
if (!calendarGrid) return;
calendarGrid.innerHTML = '';
// Add day headers
const dayHeaders = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
dayHeaders.forEach(day => {
const dayHeader = document.createElement('div');
dayHeader.className = 'calendar-day-header';
dayHeader.textContent = day;
calendarGrid.appendChild(dayHeader);
});
// Get first day of month and number of days
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
// Get previous month's last days
const prevMonth = new Date(year, month, 0);
const daysInPrevMonth = prevMonth.getDate();
// Add previous month's trailing days
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
const dayNumber = daysInPrevMonth - i;
const dayElement = createCalendarDay(dayNumber, true, new Date(year, month - 1, dayNumber));
calendarGrid.appendChild(dayElement);
}
// Add current month's days
for (let day = 1; day <= daysInMonth; day++) {
const currentDate = new Date(year, month, day);
const dayElement = createCalendarDay(day, false, currentDate);
calendarGrid.appendChild(dayElement);
}
// Add next month's leading days to fill the grid
const totalCells = calendarGrid.children.length;
const remainingCells = 42 - totalCells; // 6 rows × 7 days
for (let day = 1; day <= remainingCells; day++) {
const dayElement = createCalendarDay(day, true, new Date(year, month + 1, day));
calendarGrid.appendChild(dayElement);
}
}
function createCalendarDay(dayNumber, isOtherMonth, date) {
const dayElement = document.createElement('div');
dayElement.className = 'calendar-day';
if (isOtherMonth) {
dayElement.classList.add('other-month');
}
// Check if it's today
const today = new Date();
if (date.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// Add day number
const dayNumberElement = document.createElement('div');
dayNumberElement.className = 'calendar-day-number';
dayNumberElement.textContent = dayNumber;
dayElement.appendChild(dayNumberElement);
// Add shifts for this day
const shiftsContainer = document.createElement('div');
shiftsContainer.className = 'calendar-shifts';
const dateString = date.toISOString().split('T')[0];
const dayShifts = allShifts.filter(shift => {
const shiftDate = new Date(shift.Date);
return shiftDate.toISOString().split('T')[0] === dateString;
});
dayShifts.forEach(shift => {
const shiftElement = createCalendarShift(shift);
shiftsContainer.appendChild(shiftElement);
});
dayElement.appendChild(shiftsContainer);
return dayElement;
}
function createCalendarShift(shift) {
const shiftElement = document.createElement('div');
shiftElement.className = 'calendar-shift';
// Determine shift type and color
const isSignedUp = mySignups.some(signup => signup.shift_id === shift.ID);
const isFull = shift['Current Volunteers'] >= shift['Max Volunteers'];
if (isSignedUp) {
shiftElement.classList.add('my-shift');
} else if (isFull) {
shiftElement.classList.add('full-shift');
} else {
shiftElement.classList.add('available-shift');
}
// Set shift text (time and title)
const timeText = `${shift['Start Time']} ${shift.Title}`;
shiftElement.textContent = timeText;
shiftElement.title = `${shift.Title}\n${shift['Start Time']} - ${shift['End Time']}\n${shift.Location || 'TBD'}`;
// Add click handler
shiftElement.addEventListener('click', (e) => {
e.stopPropagation();
showShiftPopup(shift, e.target);
});
return shiftElement;
}
function showShiftPopup(shift, targetElement) {
// Remove any existing popup
const existingPopup = document.querySelector('.shift-popup');
if (existingPopup) {
existingPopup.remove();
}
const popup = document.createElement('div');
popup.className = 'shift-popup';
const isSignedUp = mySignups.some(signup => signup.shift_id === shift.ID);
const isFull = shift['Current Volunteers'] >= shift['Max Volunteers'];
const shiftDate = new Date(shift.Date);
popup.innerHTML = `
<h4>${escapeHtml(shift.Title)}</h4>
<p>📅 ${shiftDate.toLocaleDateString()}</p>
<p> ${shift['Start Time']} - ${shift['End Time']}</p>
<p>📍 ${escapeHtml(shift.Location || 'TBD')}</p>
<p>👥 ${shift['Current Volunteers']}/${shift['Max Volunteers']} volunteers</p>
${shift.Description ? `<p>${escapeHtml(shift.Description)}</p>` : ''}
<div class="shift-actions">
${isSignedUp
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>`
: isFull
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
: `<button class="btn btn-primary btn-sm signup-btn" data-shift-id="${shift.ID}">Sign Up</button>`
}
</div>
`;
// Position popup
document.body.appendChild(popup);
const rect = targetElement.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
let left = rect.left + (rect.width / 2) - (popupRect.width / 2);
let top = rect.bottom + 10;
// Adjust if popup goes off screen
if (left < 10) left = 10;
if (left + popupRect.width > window.innerWidth - 10) {
left = window.innerWidth - popupRect.width - 10;
}
if (top + popupRect.height > window.innerHeight - 10) {
top = rect.top - popupRect.height - 10;
}
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
// Add event listeners for buttons in popup
const signupBtn = popup.querySelector('.signup-btn');
const cancelBtn = popup.querySelector('.cancel-signup-btn');
if (signupBtn) {
signupBtn.addEventListener('click', async () => {
await signupForShift(shift.ID);
popup.remove();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', async () => {
await cancelSignup(shift.ID);
popup.remove();
});
}
// Close popup when clicking outside
const closePopup = (e) => {
if (!popup.contains(e.target)) {
popup.remove();
document.removeEventListener('click', closePopup);
}
};
setTimeout(() => {
document.addEventListener('click', closePopup);
}, 100);
}
// Keep the document click handler to close dropdowns when clicking outside // Keep the document click handler to close dropdowns when clicking outside
document.addEventListener('click', function() { document.addEventListener('click', function() {
document.querySelectorAll('.calendar-options').forEach(opt => { document.querySelectorAll('.calendar-options').forEach(opt => {

View File

@ -31,7 +31,13 @@
</div> </div>
<div class="shifts-filters"> <div class="shifts-filters">
<h2>Available Shifts</h2> <div class="shifts-header">
<h2>Available Shifts</h2>
<div class="view-toggle">
<button class="btn btn-secondary btn-sm" id="grid-view-btn" data-view="grid">📋 List View</button>
<button class="btn btn-secondary btn-sm" id="calendar-view-btn" data-view="calendar">📅 Calendar View</button>
</div>
</div>
<div class="filter-group"> <div class="filter-group">
<label for="date-filter">Filter by Date:</label> <label for="date-filter">Filter by Date:</label>
<input type="date" id="date-filter" /> <input type="date" id="date-filter" />
@ -42,6 +48,31 @@
<div class="shifts-grid" id="shifts-grid"> <div class="shifts-grid" id="shifts-grid">
<!-- Shifts will be loaded here --> <!-- Shifts will be loaded here -->
</div> </div>
<div class="calendar-view" id="calendar-view" style="display: none;">
<div class="calendar-header">
<button class="btn btn-secondary btn-sm" id="prev-month"> Previous</button>
<h3 id="calendar-month-year"></h3>
<button class="btn btn-secondary btn-sm" id="next-month">Next </button>
</div>
<div class="calendar-grid" id="calendar-grid">
<!-- Calendar will be loaded here -->
</div>
<div class="calendar-legend">
<div class="legend-item">
<span class="legend-color my-shift"></span>
<span>My Shifts</span>
</div>
<div class="legend-item">
<span class="legend-color available-shift"></span>
<span>Available Shifts</span>
</div>
<div class="legend-item">
<span class="legend-color full-shift"></span>
<span>Full Shifts</span>
</div>
</div>
</div>
</div> </div>
<div id="status-container" class="status-container"></div> <div id="status-container" class="status-container"></div>

View File

@ -100,7 +100,7 @@ CSS styles specific to the admin panel UI.
# app/public/css/shifts.css # app/public/css/shifts.css
CSS styles for the volunteer shifts page. CSS styles for the volunteer shifts page, including grid view, calendar view, and view toggle functionality.
# app/public/css/style.css # app/public/css/style.css
@ -120,7 +120,7 @@ Login page HTML for user authentication.
# app/public/shifts.html # app/public/shifts.html
Volunteer shifts management and signup page HTML. Volunteer shifts management and signup page HTML with both grid and calendar view options.
# app/public/js/admin.js # app/public/js/admin.js
@ -152,7 +152,7 @@ Backup or legacy version of the main map JavaScript logic.
# app/public/js/shifts.js # app/public/js/shifts.js
JavaScript for volunteer shift signup, management, and UI logic. Updated to use shift titles directly from signup records. JavaScript for volunteer shift signup, management, and UI logic with both grid and calendar view functionality. Features include view toggling, calendar navigation, shift color-coding, and interactive shift popups. Updated to use shift titles directly from signup records.
# app/public/js/ui-controls.js # app/public/js/ui-controls.js