Compare commits
No commits in common. "0dd56c0c7580e93aada6cf64721ed4d9ded81639" and "9e5b3193f76b5df10c227cd05bc46066acac4e63" have entirely different histories.
0dd56c0c75
...
9e5b3193f7
@ -1,10 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
The following are instructions for developing this project. The project is called Map and is a canvasing application for political campaigns.
|
|
||||||
|
|
||||||
It uses nocodb as a backend database.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Do not use inline event handlers. Use properly attached event listeners instead.
|
|
||||||
- Always update the README.md, or instruct the user to update the README, when developing new features.
|
|
||||||
102
map/README.md
102
map/README.md
@ -15,10 +15,7 @@ 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
|
- 🐳 Docker containerization for easy deployment
|
||||||
- ✋ User shift signup and cancellation
|
|
||||||
- 👥 Admin shift creation and management
|
|
||||||
- <20>🐳 Docker containerization for easy deployment
|
|
||||||
- 🆓 100% open source (no proprietary dependencies)
|
- 🆓 100% open source (no proprietary dependencies)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@ -43,15 +40,13 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
Edit the `.env` file with your NocoDB API and API Url:
|
Edit the `.env` file with your NocoDB API and API Url:
|
||||||
```env
|
```env
|
||||||
# NocoDB API Configuration
|
# NocoDB API Configuration
|
||||||
NOCODB_API_URL=https://db.cmlite.org/api/v1
|
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
|
||||||
NOCODB_API_TOKEN=your-api-token-here
|
NOCODB_API_TOKEN=your-api-token-here
|
||||||
|
|
||||||
# These will be populated after running build-nocodb.sh
|
# These will be populated after running build-nocodb.sh
|
||||||
NOCODB_VIEW_URL=
|
NOCODB_VIEW_URL=
|
||||||
NOCODB_LOGIN_SHEET=
|
NOCODB_LOGIN_SHEET=
|
||||||
NOCODB_SETTINGS_SHEET=
|
NOCODB_SETTINGS_SHEET=
|
||||||
NOCODB_SHIFTS_SHEET=
|
|
||||||
NOCODB_SHIFT_SIGNUPS_SHEET=
|
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3000
|
||||||
@ -62,13 +57,6 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
DEFAULT_LAT=53.5461
|
DEFAULT_LAT=53.5461
|
||||||
DEFAULT_LNG=-113.4938
|
DEFAULT_LNG=-113.4938
|
||||||
DEFAULT_ZOOM=11
|
DEFAULT_ZOOM=11
|
||||||
|
|
||||||
# Cloudflare Settings
|
|
||||||
TRUST_PROXY=true
|
|
||||||
COOKIE_DOMAIN=.cmlite.org
|
|
||||||
|
|
||||||
# Allowed Origins
|
|
||||||
ALLOWED_ORIGINS=https://map.cmlite.org,http://localhost:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Auto-Create Database Structure**
|
3. **Auto-Create Database Structure**
|
||||||
@ -79,30 +67,26 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
./build-nocodb.sh
|
./build-nocodb.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates five tables:
|
This creates three tables:
|
||||||
- **Locations** - Main map data with geo-location, contact info, support levels
|
- **Locations** - Main map data with geo-location, contact info, support levels
|
||||||
- **Login** - User authentication (email, name, admin flag)
|
- **Login** - User authentication (email, name, admin flag)
|
||||||
- **Settings** - Admin configuration and QR codes
|
- **Settings** - Admin configuration and QR codes
|
||||||
- **Shifts** - Shift scheduling and management
|
|
||||||
- **Shift Signups** - User shift registrations
|
|
||||||
|
|
||||||
4. **Get Table URLs**
|
4. **Get Table URLs**
|
||||||
|
|
||||||
After the script completes:
|
After the script completes:
|
||||||
1. Login to your NocoDB instance at https://db.cmlite.org
|
1. Login to your NocoDB instance
|
||||||
2. Navigate to your project ("Map Viewer Project - TIMESTAMP")
|
2. Navigate to your project ("Map Viewer Project")
|
||||||
3. Copy the view URLs for each table from your browser address bar
|
3. Copy the view URLs for each table from your browser address bar
|
||||||
4. URLs should look like: `https://db.cmlite.org/dashboard/#/nc/project-id/table-id`
|
4. URLs should look like: `https://your-nocodb.com/dashboard/#/nc/project-id/table-id`
|
||||||
|
|
||||||
5. **Update Environment with URLs**
|
5. **Update Environment with URLs**
|
||||||
|
|
||||||
Edit your `.env` file and add the table URLs:
|
Edit your `.env` file and add the table URLs:
|
||||||
```env
|
```env
|
||||||
NOCODB_VIEW_URL=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/m6g7bkzv7s1w2ur
|
NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id
|
||||||
NOCODB_LOGIN_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mizyc64e4r7ppzh
|
NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id
|
||||||
NOCODB_SETTINGS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mix06f2mlep7gqb
|
NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id
|
||||||
NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u
|
|
||||||
NOCODB_SHIFT_SIGNUPS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mi8jg1tn26mu8fj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Build and Deploy**
|
6. **Build and Deploy**
|
||||||
@ -121,8 +105,6 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
- Check container status: `docker-compose ps`
|
- Check container status: `docker-compose ps`
|
||||||
- View logs: `docker-compose logs -f map-viewer`
|
- View logs: `docker-compose logs -f map-viewer`
|
||||||
- Access the application at: http://localhost:3000
|
- Access the application at: http://localhost:3000
|
||||||
- Access shift management at: http://localhost:3000/shifts.html
|
|
||||||
- Access admin panel at: http://localhost:3000/admin.html (admin users only)
|
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
@ -165,18 +147,6 @@ The build script automatically creates the following table structure:
|
|||||||
- `qr_code_2_image` (Attachment): QR code 2 image
|
- `qr_code_2_image` (Attachment): QR code 2 image
|
||||||
- `qr_code_3_image` (Attachment): QR code 3 image
|
- `qr_code_3_image` (Attachment): QR code 3 image
|
||||||
|
|
||||||
### Shifts Table
|
|
||||||
- Standard NocoDB fields for shift scheduling and management
|
|
||||||
- Contains shift dates, times, locations, capacity limits
|
|
||||||
- Status tracking (Active, Cancelled, Full)
|
|
||||||
- Created automatically by build script with basic structure
|
|
||||||
|
|
||||||
### Shift Signups Table
|
|
||||||
- Links users to shifts they've signed up for
|
|
||||||
- Tracks signup timestamps and user information
|
|
||||||
- Handles cancellations and waitlist management
|
|
||||||
- Created automatically by build script with basic structure
|
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Public Endpoints
|
### Public Endpoints
|
||||||
@ -189,20 +159,6 @@ The build script automatically creates the following table structure:
|
|||||||
- `GET /api/config/start-location` - Get map start location
|
- `GET /api/config/start-location` - Get map start location
|
||||||
- `GET /health` - Health check
|
- `GET /health` - Health check
|
||||||
|
|
||||||
### Shifts Endpoints (requires authentication)
|
|
||||||
|
|
||||||
- `GET /api/shifts` - Get all available shifts
|
|
||||||
- `GET /api/shifts/my-signups` - Get current user's shift signups
|
|
||||||
- `POST /api/shifts/:shiftId/signup` - Sign up for a shift
|
|
||||||
- `POST /api/shifts/:shiftId/cancel` - Cancel shift signup
|
|
||||||
|
|
||||||
### Shifts Admin Endpoints (requires admin privileges)
|
|
||||||
|
|
||||||
- `GET /api/shifts/admin` - Get all shifts including cancelled ones
|
|
||||||
- `POST /api/shifts/admin` - Create new shift
|
|
||||||
- `PUT /api/shifts/admin/:id` - Update existing shift
|
|
||||||
- `DELETE /api/shifts/admin/:id` - Delete shift
|
|
||||||
|
|
||||||
### Authentication Endpoints
|
### Authentication Endpoints
|
||||||
|
|
||||||
- `POST /api/auth/login` - User login
|
- `POST /api/auth/login` - User login
|
||||||
@ -216,35 +172,6 @@ The build script automatically creates the following table structure:
|
|||||||
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
|
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
|
||||||
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
|
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
|
||||||
|
|
||||||
## Shifts Management
|
|
||||||
|
|
||||||
The application includes a comprehensive volunteer shift management system accessible at `/shifts.html`.
|
|
||||||
|
|
||||||
### User Features
|
|
||||||
|
|
||||||
- **View Available Shifts**: See all upcoming shifts with date, time, and capacity information
|
|
||||||
- **Sign Up for Shifts**: One-click signup for available shifts
|
|
||||||
- **My Shifts Dashboard**: View all your current shift signups
|
|
||||||
- **Cancel Signups**: Cancel your shift signups when needed
|
|
||||||
- **Date Filtering**: Filter shifts by specific dates
|
|
||||||
- **Real-time Updates**: Shift availability updates dynamically
|
|
||||||
|
|
||||||
### Admin Features
|
|
||||||
|
|
||||||
Administrators have additional capabilities for managing shifts:
|
|
||||||
|
|
||||||
- **Create New Shifts**: Add new volunteer shifts with date, time, location, and capacity
|
|
||||||
- **Edit Existing Shifts**: Modify shift details, times, or capacity
|
|
||||||
- **Cancel Shifts**: Mark shifts as cancelled (they remain in system but hidden from users)
|
|
||||||
- **View All Signups**: See who has signed up for each shift
|
|
||||||
- **Manage Capacity**: Set maximum number of volunteers per shift
|
|
||||||
|
|
||||||
### Shift Status System
|
|
||||||
|
|
||||||
- **Active**: Available for signups
|
|
||||||
- **Full**: Capacity reached, no more signups accepted
|
|
||||||
- **Cancelled**: Hidden from public view but retained in database
|
|
||||||
|
|
||||||
## Admin Panel
|
## Admin Panel
|
||||||
|
|
||||||
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
|
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
|
||||||
@ -289,20 +216,13 @@ All configuration is done via environment variables:
|
|||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `NOCODB_API_URL` | NocoDB API base URL | Required |
|
| `NOCODB_API_URL` | NocoDB API base URL | Required |
|
||||||
| `NOCODB_API_TOKEN` | API authentication token | Required |
|
| `NOCODB_API_TOKEN` | API authentication token | Required |
|
||||||
| `NOCODB_VIEW_URL` | Full NocoDB view URL for locations table | Required |
|
| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required |
|
||||||
| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required |
|
| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required |
|
||||||
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Required |
|
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional |
|
||||||
| `NOCODB_SHIFTS_SHEET` | Shifts table URL for shift management | Required |
|
|
||||||
| `NOCODB_SHIFT_SIGNUPS_SHEET` | Shift signups table URL for user registrations | Required |
|
|
||||||
| `PORT` | Server port | 3000 |
|
| `PORT` | Server port | 3000 |
|
||||||
| `NODE_ENV` | Environment mode | production |
|
|
||||||
| `SESSION_SECRET` | Session encryption secret | Required |
|
|
||||||
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
||||||
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
||||||
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
|
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
|
||||||
| `TRUST_PROXY` | Trust proxy headers (for Cloudflare) | true |
|
|
||||||
| `COOKIE_DOMAIN` | Cookie domain setting | .cmlite.org |
|
|
||||||
| `ALLOWED_ORIGINS` | CORS allowed origins | Multiple URLs |
|
|
||||||
|
|
||||||
## Maintenance Commands
|
## Maintenance Commands
|
||||||
|
|
||||||
|
|||||||
@ -16,40 +16,18 @@ class ShiftsController {
|
|||||||
|
|
||||||
logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId);
|
logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId);
|
||||||
|
|
||||||
|
// Load all shifts without filter - we'll filter in JavaScript
|
||||||
const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, {
|
const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, {
|
||||||
sort: 'Date,Start Time'
|
sort: 'Date,Start Time'
|
||||||
});
|
});
|
||||||
|
|
||||||
let shifts = (response.list || []).filter(shift =>
|
logger.info('Loaded shifts:', response);
|
||||||
|
|
||||||
|
// Filter out cancelled shifts manually
|
||||||
|
const shifts = (response.list || []).filter(shift =>
|
||||||
shift.Status !== 'Cancelled'
|
shift.Status !== 'Cancelled'
|
||||||
);
|
);
|
||||||
|
|
||||||
// If signups sheet is configured, calculate current volunteer counts
|
|
||||||
if (config.nocodb.shiftSignupsSheetId) {
|
|
||||||
try {
|
|
||||||
const signupsResponse = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
|
|
||||||
const allSignups = signupsResponse.list || [];
|
|
||||||
|
|
||||||
// Update each shift with calculated volunteer count
|
|
||||||
shifts = shifts.map(shift => {
|
|
||||||
const confirmedSignups = allSignups.filter(signup =>
|
|
||||||
signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed'
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentVolunteers = confirmedSignups.length;
|
|
||||||
const maxVolunteers = shift['Max Volunteers'] || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...shift,
|
|
||||||
'Current Volunteers': currentVolunteers,
|
|
||||||
'Status': currentVolunteers >= maxVolunteers ? 'Full' : 'Open'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (signupError) {
|
|
||||||
logger.warn('Could not load signups for volunteer count calculation:', signupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
shifts: shifts
|
shifts: shifts
|
||||||
@ -157,24 +135,15 @@ class ShiftsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate current volunteers dynamically
|
if (shift['Current Volunteers'] >= shift['Max Volunteers']) {
|
||||||
let currentVolunteers = 0;
|
|
||||||
if (config.nocodb.shiftSignupsSheetId) {
|
|
||||||
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
|
|
||||||
const confirmedSignups = (allSignups.list || []).filter(signup =>
|
|
||||||
signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed'
|
|
||||||
);
|
|
||||||
currentVolunteers = confirmedSignups.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentVolunteers >= shift['Max Volunteers']) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Shift is full'
|
error: 'Shift is full'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already signed up - we already have allSignups from above
|
// Check if already signed up - get all signups and filter
|
||||||
|
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
|
||||||
const existingSignup = (allSignups.list || []).find(signup => {
|
const existingSignup = (allSignups.list || []).find(signup => {
|
||||||
return signup['Shift ID'] === parseInt(shiftId) &&
|
return signup['Shift ID'] === parseInt(shiftId) &&
|
||||||
signup['User Email'] === userEmail &&
|
signup['User Email'] === userEmail &&
|
||||||
@ -199,10 +168,10 @@ class ShiftsController {
|
|||||||
|
|
||||||
logger.info('Created signup:', signup);
|
logger.info('Created signup:', signup);
|
||||||
|
|
||||||
// Update shift volunteer count with calculated value
|
// Update shift volunteer count
|
||||||
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
|
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
|
||||||
'Current Volunteers': currentVolunteers + 1,
|
'Current Volunteers': (shift['Current Volunteers'] || 0) + 1,
|
||||||
'Status': currentVolunteers + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open'
|
'Status': shift['Current Volunteers'] + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open'
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@ -254,14 +223,10 @@ class ShiftsController {
|
|||||||
'Status': 'Cancelled'
|
'Status': 'Cancelled'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate current volunteers dynamically after cancellation
|
// Update shift volunteer count
|
||||||
const allSignupsAfter = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
|
|
||||||
const confirmedSignupsAfter = (allSignupsAfter.list || []).filter(s =>
|
|
||||||
s['Shift ID'] === parseInt(shiftId) && s.Status === 'Confirmed'
|
|
||||||
);
|
|
||||||
const newCount = confirmedSignupsAfter.length;
|
|
||||||
|
|
||||||
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
|
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
|
||||||
|
const newCount = Math.max(0, (shift['Current Volunteers'] || 0) - 1);
|
||||||
|
|
||||||
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
|
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
|
||||||
'Current Volunteers': newCount,
|
'Current Volunteers': newCount,
|
||||||
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'
|
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'
|
||||||
|
|||||||
@ -109,10 +109,6 @@
|
|||||||
|
|
||||||
.shift-actions {
|
.shift-actions {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-shifts {
|
.no-shifts {
|
||||||
@ -122,67 +118,14 @@
|
|||||||
grid-column: 1 / -1; /* Span all columns */
|
grid-column: 1 / -1; /* Span all columns */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar dropdown styles */
|
/* Tablet: 2 columns */
|
||||||
.calendar-dropdown {
|
@media (max-width: 1024px) and (min-width: 769px) {
|
||||||
position: relative;
|
.shifts-grid {
|
||||||
display: inline-block;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-toggle {
|
/* Mobile: 1 column */
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-options {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1000;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--dark-color);
|
|
||||||
transition: var(--transition);
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-option:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-option:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-option img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure dropdowns appear above other elements */
|
|
||||||
.shift-card {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update signup actions for My Shifts section */
|
|
||||||
.signup-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile adjustments */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.shifts-container {
|
.shifts-container {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@ -199,30 +142,8 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signup-actions {
|
.shift-card {
|
||||||
width: 100%;
|
padding: 15px;
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shift-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shift-actions .btn,
|
|
||||||
.calendar-dropdown {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-toggle {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-options {
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-signups {
|
.my-signups {
|
||||||
@ -237,13 +158,4 @@
|
|||||||
.filter-group {
|
.filter-group {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent dropdown from being cut off */
|
|
||||||
.shifts-grid {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shift-card {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
@ -105,8 +105,7 @@ function displayShifts(shifts) {
|
|||||||
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
|
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
|
||||||
<div class="shift-actions">
|
<div class="shift-actions">
|
||||||
${isSignedUp
|
${isSignedUp
|
||||||
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>
|
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>`
|
||||||
${generateCalendarDropdown(shift)}`
|
|
||||||
: isFull
|
: isFull
|
||||||
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
|
? '<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>`
|
: `<button class="btn btn-primary btn-sm signup-btn" data-shift-id="${shift.ID}">Sign Up</button>`
|
||||||
@ -142,10 +141,7 @@ function displayMySignups() {
|
|||||||
<h4>${escapeHtml(signup.shift.Title)}</h4>
|
<h4>${escapeHtml(signup.shift.Title)}</h4>
|
||||||
<p>📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
|
<p>📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="signup-actions">
|
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
|
||||||
${generateCalendarDropdown(signup.shift)}
|
|
||||||
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -163,40 +159,15 @@ function setupShiftCardListeners() {
|
|||||||
const newGrid = grid.cloneNode(true);
|
const newGrid = grid.cloneNode(true);
|
||||||
grid.parentNode.replaceChild(newGrid, grid);
|
grid.parentNode.replaceChild(newGrid, grid);
|
||||||
|
|
||||||
// Add click listener for all buttons
|
// Add click listener for signup buttons
|
||||||
newGrid.addEventListener('click', async (e) => {
|
newGrid.addEventListener('click', async (e) => {
|
||||||
// Handle signup buttons
|
|
||||||
if (e.target.classList.contains('signup-btn')) {
|
if (e.target.classList.contains('signup-btn')) {
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await signupForShift(shiftId);
|
await signupForShift(shiftId);
|
||||||
}
|
} else if (e.target.classList.contains('cancel-signup-btn')) {
|
||||||
// Handle cancel buttons
|
|
||||||
else if (e.target.classList.contains('cancel-signup-btn')) {
|
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await cancelSignup(shiftId);
|
await cancelSignup(shiftId);
|
||||||
}
|
}
|
||||||
// Handle calendar toggle buttons
|
|
||||||
else if (e.target.classList.contains('calendar-toggle')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
const isOpen = options.style.display !== 'none';
|
|
||||||
|
|
||||||
// Close all other dropdowns
|
|
||||||
document.querySelectorAll('.calendar-options').forEach(opt => {
|
|
||||||
opt.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle this dropdown
|
|
||||||
options.style.display = isOpen ? 'none' : 'block';
|
|
||||||
}
|
|
||||||
// Handle calendar option clicks
|
|
||||||
else if (e.target.closest('.calendar-option')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
options.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,35 +180,12 @@ function setupMySignupsListeners() {
|
|||||||
const newList = list.cloneNode(true);
|
const newList = list.cloneNode(true);
|
||||||
list.parentNode.replaceChild(newList, list);
|
list.parentNode.replaceChild(newList, list);
|
||||||
|
|
||||||
// Add click listener for all interactions
|
// Add click listener for cancel buttons
|
||||||
newList.addEventListener('click', async (e) => {
|
newList.addEventListener('click', async (e) => {
|
||||||
// Handle cancel buttons
|
|
||||||
if (e.target.classList.contains('cancel-signup-btn')) {
|
if (e.target.classList.contains('cancel-signup-btn')) {
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await cancelSignup(shiftId);
|
await cancelSignup(shiftId);
|
||||||
}
|
}
|
||||||
// Handle calendar toggle buttons
|
|
||||||
else if (e.target.classList.contains('calendar-toggle')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
const isOpen = options.style.display !== 'none';
|
|
||||||
|
|
||||||
// Close all other dropdowns
|
|
||||||
document.querySelectorAll('.calendar-options').forEach(opt => {
|
|
||||||
opt.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle this dropdown
|
|
||||||
options.style.display = isOpen ? 'none' : 'block';
|
|
||||||
}
|
|
||||||
// Handle calendar option clicks
|
|
||||||
else if (e.target.closest('.calendar-option')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
options.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,209 +290,4 @@ function escapeHtml(text) {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = String(text);
|
div.textContent = String(text);
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add these calendar URL generation functions after the existing functions
|
|
||||||
function generateCalendarUrls(shift) {
|
|
||||||
const shiftDate = new Date(shift.Date);
|
|
||||||
|
|
||||||
// Parse start and end times
|
|
||||||
const [startHour, startMinute] = shift['Start Time'].split(':').map(n => parseInt(n));
|
|
||||||
const [endHour, endMinute] = shift['End Time'].split(':').map(n => parseInt(n));
|
|
||||||
|
|
||||||
// Create start and end datetime objects
|
|
||||||
const startDate = new Date(shiftDate);
|
|
||||||
startDate.setHours(startHour, startMinute, 0, 0);
|
|
||||||
|
|
||||||
const endDate = new Date(shiftDate);
|
|
||||||
endDate.setHours(endHour, endMinute, 0, 0);
|
|
||||||
|
|
||||||
// Format dates for different calendar formats
|
|
||||||
const formatGoogleDate = (date) => {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}${month}${day}T${hours}${minutes}00`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatISODate = (date) => {
|
|
||||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event details
|
|
||||||
const title = shift.Title;
|
|
||||||
const description = shift.Description || 'Volunteer shift';
|
|
||||||
const location = shift.Location || '';
|
|
||||||
|
|
||||||
// Google Calendar URL
|
|
||||||
const googleStartStr = formatGoogleDate(startDate);
|
|
||||||
const googleEndStr = formatGoogleDate(endDate);
|
|
||||||
const googleParams = new URLSearchParams({
|
|
||||||
action: 'TEMPLATE',
|
|
||||||
text: title,
|
|
||||||
dates: `${googleStartStr}/${googleEndStr}`,
|
|
||||||
details: description,
|
|
||||||
location: location
|
|
||||||
});
|
|
||||||
const googleUrl = `https://calendar.google.com/calendar/render?${googleParams.toString()}`;
|
|
||||||
|
|
||||||
// Outlook Web Calendar URL
|
|
||||||
const outlookStartStr = startDate.toISOString();
|
|
||||||
const outlookEndStr = endDate.toISOString();
|
|
||||||
const outlookParams = new URLSearchParams({
|
|
||||||
path: '/calendar/action/compose',
|
|
||||||
rru: 'addevent',
|
|
||||||
subject: title,
|
|
||||||
startdt: outlookStartStr,
|
|
||||||
enddt: outlookEndStr,
|
|
||||||
body: description,
|
|
||||||
location: location
|
|
||||||
});
|
|
||||||
const outlookUrl = `https://outlook.live.com/calendar/0/deeplink/compose?${outlookParams.toString()}`;
|
|
||||||
|
|
||||||
// Apple Calendar (.ics file) - we'll generate this dynamically
|
|
||||||
const icsContent = [
|
|
||||||
'BEGIN:VCALENDAR',
|
|
||||||
'VERSION:2.0',
|
|
||||||
'PRODID:-//BNKops//Volunteer Shifts//EN',
|
|
||||||
'BEGIN:VEVENT',
|
|
||||||
`UID:${shift.ID}-${Date.now()}@bnkops.com`,
|
|
||||||
`DTSTART:${formatISODate(startDate)}`,
|
|
||||||
`DTEND:${formatISODate(endDate)}`,
|
|
||||||
`SUMMARY:${title}`,
|
|
||||||
`DESCRIPTION:${description.replace(/\n/g, '\\n')}`,
|
|
||||||
`LOCATION:${location}`,
|
|
||||||
'STATUS:CONFIRMED',
|
|
||||||
'END:VEVENT',
|
|
||||||
'END:VCALENDAR'
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
// Create a data URL for the .ics file
|
|
||||||
const icsDataUrl = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(icsContent);
|
|
||||||
|
|
||||||
return {
|
|
||||||
google: googleUrl,
|
|
||||||
outlook: outlookUrl,
|
|
||||||
apple: icsDataUrl,
|
|
||||||
icsFilename: `${title.replace(/[^a-z0-9]/gi, '_')}_${shift.ID}.ics`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update calendar dropdown HTML generator (remove onclick handlers)
|
|
||||||
function generateCalendarDropdown(shift) {
|
|
||||||
const urls = generateCalendarUrls(shift);
|
|
||||||
return `
|
|
||||||
<div class="calendar-dropdown">
|
|
||||||
<button class="btn btn-secondary btn-sm calendar-toggle" data-shift-id="${shift.ID}">
|
|
||||||
📅 Add to Calendar ▼
|
|
||||||
</button>
|
|
||||||
<div class="calendar-options" style="display: none;">
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update setupShiftCardListeners to handle calendar dropdowns
|
|
||||||
function setupShiftCardListeners() {
|
|
||||||
const grid = document.getElementById('shifts-grid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
// Remove any existing listeners by cloning
|
|
||||||
const newGrid = grid.cloneNode(true);
|
|
||||||
grid.parentNode.replaceChild(newGrid, grid);
|
|
||||||
|
|
||||||
// Add click listener for all buttons
|
|
||||||
newGrid.addEventListener('click', async (e) => {
|
|
||||||
// Handle signup buttons
|
|
||||||
if (e.target.classList.contains('signup-btn')) {
|
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
|
||||||
await signupForShift(shiftId);
|
|
||||||
}
|
|
||||||
// Handle cancel buttons
|
|
||||||
else if (e.target.classList.contains('cancel-signup-btn')) {
|
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
|
||||||
await cancelSignup(shiftId);
|
|
||||||
}
|
|
||||||
// Handle calendar toggle buttons
|
|
||||||
else if (e.target.classList.contains('calendar-toggle')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
const isOpen = options.style.display !== 'none';
|
|
||||||
|
|
||||||
// Close all other dropdowns
|
|
||||||
document.querySelectorAll('.calendar-options').forEach(opt => {
|
|
||||||
opt.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle this dropdown
|
|
||||||
options.style.display = isOpen ? 'none' : 'block';
|
|
||||||
}
|
|
||||||
// Handle calendar option clicks
|
|
||||||
else if (e.target.closest('.calendar-option')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
options.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update setupMySignupsListeners similarly
|
|
||||||
function setupMySignupsListeners() {
|
|
||||||
const list = document.getElementById('my-signups-list');
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
// Remove any existing listeners by cloning
|
|
||||||
const newList = list.cloneNode(true);
|
|
||||||
list.parentNode.replaceChild(newList, list);
|
|
||||||
|
|
||||||
// Add click listener for all interactions
|
|
||||||
newList.addEventListener('click', async (e) => {
|
|
||||||
// Handle cancel buttons
|
|
||||||
if (e.target.classList.contains('cancel-signup-btn')) {
|
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
|
||||||
await cancelSignup(shiftId);
|
|
||||||
}
|
|
||||||
// Handle calendar toggle buttons
|
|
||||||
else if (e.target.classList.contains('calendar-toggle')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
const isOpen = options.style.display !== 'none';
|
|
||||||
|
|
||||||
// Close all other dropdowns
|
|
||||||
document.querySelectorAll('.calendar-options').forEach(opt => {
|
|
||||||
opt.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle this dropdown
|
|
||||||
options.style.display = isOpen ? 'none' : 'block';
|
|
||||||
}
|
|
||||||
// Handle calendar option clicks
|
|
||||||
else if (e.target.closest('.calendar-option')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const dropdown = e.target.closest('.calendar-dropdown');
|
|
||||||
const options = dropdown.querySelector('.calendar-options');
|
|
||||||
options.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the document click handler to close dropdowns when clicking outside
|
|
||||||
document.addEventListener('click', function() {
|
|
||||||
document.querySelectorAll('.calendar-options').forEach(opt => {
|
|
||||||
opt.style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user