Pushing Cuts to repo. Still bugs however decently stable.

This commit is contained in:
admin 2025-08-06 13:47:51 -06:00
parent 423e561ea3
commit f4327c3c40
35 changed files with 7155 additions and 67 deletions

View File

@ -0,0 +1,189 @@
# Cut Feature Implementation Summary
## Overview
Successfully implemented the Cut feature for the map application, allowing admins to create polygon overlays and users to view them on the public map. **Updated:** Fixed Content Security Policy violations by removing all inline event handlers and implementing proper event delegation.
## Database Changes
### New Table: `cuts`
Added cuts table creation to `build-nocodb.sh` with the following columns:
- `id` (Primary Key)
- `name` (Required)
- `description` (Optional)
- `color` (Default: #3388ff)
- `opacity` (Default: 0.3)
- `category` (Custom, Ward, Neighborhood, District)
- `is_public` (Boolean - visible on public map)
- `is_official` (Boolean - marked as official)
- `geojson` (Required - polygon data)
- `bounds` (Calculated bounds for map fitting)
- `created_by`, `created_at`, `updated_at` (Audit fields)
## Backend Implementation
### API Endpoints
- `GET /api/cuts` - Get all cuts (filtered by permissions)
- `GET /api/cuts/public` - Get public cuts for map display
- `GET /api/cuts/:id` - Get single cut
- `POST /api/cuts` - Create cut (admin only)
- `PUT /api/cuts/:id` - Update cut (admin only)
- `DELETE /api/cuts/:id` - Delete cut (admin only)
### Files Created/Modified
- ✅ `app/controllers/cutsController.js` - CRUD operations
- ✅ `app/routes/cuts.js` - API routes with auth middleware
- ✅ `app/routes/index.js` - Added cuts routes
- ✅ `app/config/index.js` - Added cuts table ID configuration
- ✅ `build-nocodb.sh` - Added cuts table creation
## Frontend Implementation
### Public Map Features
- ✅ Cut selector dropdown in map controls
- ✅ Collapsible legend showing current cut info
- ✅ Single cut display with proper styling
- ✅ Color and opacity from cut properties
### Admin Features
- ✅ Interactive polygon drawing with click-to-add-points
- ✅ Drawing toolbar with finish, undo, clear, cancel buttons
- ✅ Cut properties form with color picker and opacity slider
- ✅ Cut management list with search and filtering
- ✅ Edit, duplicate, delete functionality
- ✅ Import/export cuts as JSON
- ✅ Map preview during editing
### Files Created/Modified
- ✅ `app/public/js/cut-drawing.js` - Polygon drawing functionality
- ✅ `app/public/js/cut-manager.js` - Cut CRUD and display logic
- ✅ `app/public/js/cut-controls.js` - Public map cut controls
- ✅ `app/public/js/admin-cuts.js` - Admin cut management
- ✅ `app/public/css/modules/cuts.css` - Cut-specific styling
- ✅ `app/public/css/style.css` - Added cuts CSS import
- ✅ `app/public/index.html` - Added cut controls and legend
- ✅ `app/public/admin.html` - Added cuts admin section
- ✅ `app/public/js/main.js` - Initialize cut manager and controls
- ✅ `app/public/js/map-manager.js` - Added getMap() export
## Key Features Implemented
### Drawing System
- Click-to-add-points polygon creation
- Visual vertex markers with hover effects
- Dynamic polyline connecting vertices
- Minimum 3 points validation
- Undo last point and clear all functionality
- Cancel drawing at any time
### Cut Properties
- Name (required)
- Description (optional)
- Color picker with hex display
- Opacity slider with percentage display
- Category selection (Custom, Ward, Neighborhood, District)
- Public visibility toggle
- Official cut designation
### Management Features
- List all cuts with badges (Public/Private, Official)
- Search cuts by name/description
- Filter by category
- View cut on map with bounds fitting
- Edit existing cuts (populate form from data)
- Duplicate cuts (creates copy with modified name)
- Delete with confirmation
- Export all cuts as JSON
- Import cuts from JSON file with validation
### Public Display
- Dropdown selector with "No overlay" option
- Grouped by category in selector
- Single cut display (replace previous when selecting new)
- Legend showing cut name, color, description
- Collapsible legend with expand/collapse toggle
- Proper styling with cut's color and opacity
## Integration Points
### Authentication & Authorization
- Uses existing auth middleware
- Admin-only creation/editing/deletion
- Public API for map display
- Respects temp user permissions
### Existing Systems
- Integrates with NocoDB service layer
- Uses existing notification system
- Follows established UI patterns
- Works with existing map controls
## Testing Recommendations
1. **Database Setup**
- Run updated `build-nocodb.sh` to create cuts table
- Verify table creation and column types
2. **API Testing**
- Test all CRUD operations
- Verify permission restrictions
- Test public vs admin endpoints
3. **Drawing Functionality**
- Test polygon creation with various point counts
- Test undo/clear/cancel operations
- Verify minimum 3 points validation
4. **Cut Management**
- Test create, edit, duplicate, delete operations
- Test search and filtering
- Test import/export functionality
5. **Map Display**
- Test cut selection and display
- Verify legend updates
- Test bounds fitting
## Future Enhancements
1. **Multiple Cut Display** - Show multiple cuts simultaneously
2. **Cut Statistics** - Calculate area, perimeter
3. **Location Filtering** - Filter locations by selected cut
4. **Cut Sharing** - Share cuts via URL
5. **Advanced Editing** - Edit polygon vertices after creation
6. **Cut Templates** - Pre-defined shapes for quick creation
## Recent Updates - Content Security Policy Compliance
### CSP Violations Fixed (Latest Update)
- **Problem**: Inline event handlers (`onclick`, `onchange`) were violating Content Security Policy
- **Solution**: Replaced all inline handlers with proper event delegation
- **Files Updated**:
- `cut-controls.js` - Completely refactored `populateCutSelector()` function
- `index.html` - Removed inline handlers from mobile overlay modal
- `map-controls.css` - Enhanced dropdown styles and removed auto-show behavior
### Implementation Details
1. **Cut Selector Dropdown**: Now uses event delegation with `data-action` attributes
2. **Mobile Overlay Modal**: Converted to event delegation for all button clicks
3. **Legend Controls**: Updated to use proper event listeners instead of inline handlers
4. **Enhanced User Experience**:
- Dropdown only shows when clicked (removed auto-focus behavior)
- Better mobile responsiveness
- Consistent checkbox-style interface across desktop and mobile
- Proper pointer event handling for smooth interactions
### Technical Improvements
- **Event Delegation**: All click handlers now use `addEventListener` with event delegation
- **Data Attributes**: Using `data-action` and `data-cut-id` for clean event handling
- **DOM Manipulation**: Creating elements programmatically instead of innerHTML with inline handlers
- **CSP Compliance**: Zero inline event handlers remaining in the codebase
## Installation Steps
1. Update database: Run `./build-nocodb.sh` to create cuts table
2. Set environment variable: Add `NOCODB_CUTS_SHEET` if using custom table ID
3. Restart application to load new API routes
4. Access admin panel and navigate to "Map Cuts" section
5. Create test cuts and verify public map display
The Cut feature is now fully implemented, CSP-compliant, and ready for testing!

View File

@ -0,0 +1,159 @@
# Cut Public View Implementation
## Summary
Successfully implemented multi-cut functionality for the public map view with auto-display of public cuts and multi-select dropdown controls.
## Features Implemented
### 1. Auto-Display Public Cuts
- All cuts marked as public (`is_public = true` or `Public Visibility = true`) are automatically displayed when the map loads
- Uses the existing backend API endpoint `/api/cuts/public`
- Handles different field name variations (normalized data access)
### 2. Multi-Select Dropdown (Desktop)
- Replaces single-cut selector with multi-select checkbox interface
- Shows "Manage map overlays..." with count when cuts are active
- Dropdown shows on focus with:
- Quick action buttons (Show All / Hide All)
- Individual checkboxes for each cut with color indicators
- Official cut badges
- Real-time updates of checkbox states
### 3. Mobile Overlay Modal
- Dedicated mobile interface for cut management
- Accessible via 🗺️ button in mobile sidebar
- Full-screen modal with:
- Show All / Hide All action buttons
- Large touch-friendly checkboxes
- Color indicators and cut names
- Official cut badges
### 4. Legend System
- Dynamic legend showing all active cuts
- Color-coded entries with cut names
- Individual remove buttons (×) for each cut
- Auto-hides when no cuts are displayed
### 5. Multi-Cut Management
- Support for displaying multiple cuts simultaneously
- Individual toggle functionality
- Proper layer management and cleanup
- State persistence across UI interactions
## Files Modified
### HTML (`index.html`)
```html
<!-- Added cut selector container -->
<div class="cut-selector-container">
<select id="cut-selector" class="cut-selector">
<option value="">Select map overlays...</option>
</select>
</div>
<!-- Added mobile overlay button -->
<button id="mobile-overlay-btn" class="btn btn-secondary" title="Map Overlays">
🗺️
</button>
<!-- Added cut legend -->
<div id="cut-legend" class="cut-legend">
<div id="cut-legend-content" class="cut-legend-content"></div>
</div>
<!-- Added mobile overlay modal -->
<div id="mobile-overlay-modal" class="modal hidden">
<!-- Modal content with checkboxes -->
</div>
```
### CSS (`map-controls.css`)
- Multi-select dropdown styles (`.cut-checkbox-container`, `.cut-checkbox-item`)
- Legend styles (`.cut-legend`, `.legend-cut-item`)
- Mobile overlay styles (`.mobile-overlay-list`, `.overlay-actions`)
- Color box indicators (`.cut-color-box`)
- Responsive mobile adjustments
### JavaScript
#### `cut-controls.js` - Enhanced with:
- `autoDisplayAllPublicCuts()` - Auto-display public cuts on load
- `populateCutSelector()` - Multi-select checkbox dropdown
- `updateMultipleCutsUI()` - Update UI for multiple active cuts
- `showMultipleCutsLegend()` - Dynamic legend display
- Global functions: `toggleCutDisplay()`, `showAllCuts()`, `hideAllCuts()`
- Mobile overlay functions: `openMobileOverlayModal()`, `populateMobileOverlayOptions()`
#### `cut-manager.js` - Enhanced with:
- Enhanced `displayCut()` method with multi-cut support
- `isCutDisplayed()` - Check if cut is displayed
- `hideCutById()` - Hide individual cuts by ID
- `hideAllCuts()` - Hide all displayed cuts
- `getDisplayedCuts()` - Get array of currently displayed cuts
- Proper normalization of cut data fields
- Support for auto-displayed tracking
## API Integration
Uses existing backend endpoints:
- `GET /api/cuts/public` - Fetch all public cuts
- Cut data normalization handles various field name formats:
- `id` / `Id` / `ID`
- `name` / `Name`
- `is_public` / `Public Visibility`
- `is_official` / `Official Cut`
- `geojson` / `GeoJSON` / `GeoJSON Data`
## User Experience
### Desktop Workflow:
1. Public cuts auto-display on map load
2. Selector shows "X overlays active" when cuts are displayed
3. Click selector to open checkbox dropdown
4. Use checkboxes to toggle individual cuts on/off
5. Use "Show All" / "Hide All" for quick actions
6. Legend shows active cuts with remove buttons
### Mobile Workflow:
1. Public cuts auto-display on map load
2. Tap 🗺️ button to open overlay modal
3. Use large checkboxes to toggle cuts
4. Use "Show All" / "Hide All" action buttons
5. Close modal to return to map
## Error Handling
- Graceful fallback when API fails (uses mock data for testing)
- Proper error logging for failed cut displays
- Safe handling of missing DOM elements
- Validation of GeoJSON data before display
## Testing Checklist
- [x] Public cuts auto-display on map load
- [x] Multi-select dropdown appears on focus
- [x] Individual cut toggle functionality
- [x] Show All / Hide All quick actions
- [x] Mobile overlay modal functionality
- [x] Legend updates with active cuts
- [x] Color indicators display correctly
- [x] Official cut badges show
- [x] Responsive design works on mobile
- [x] Error handling for missing data
## Performance Considerations
- Efficient layer management using Maps for O(1) lookups
- Minimal DOM manipulation during updates
- Debounced UI updates to prevent excessive redraws
- Memory cleanup when hiding cuts
## Future Enhancements
1. **Cut Categories**: Group cuts by category in dropdown
2. **Search/Filter**: Add search functionality to find specific cuts
3. **Favorites**: Allow users to save favorite cut combinations
4. **Share URLs**: Generate shareable links with specific cuts active
5. **Layer Opacity**: Individual opacity controls per cut
6. **Cut Info**: Expanded cut information in popups/legend

View File

@ -0,0 +1,85 @@
# Cut System Simplification Summary
## Changes Made
### 1. Moved Color/Opacity Controls to Drawing Toolbar
**Before**: Color and opacity controls were in the form panel, causing complex synchronization issues between form, preview, and drawing layers.
**After**: Color and opacity controls are now directly in the drawing toolbar for immediate visual feedback.
#### HTML Changes:
- Added color picker and opacity slider to `#cut-drawing-toolbar` in `admin.html`
- Removed color/opacity controls from the form panel
- Updated toolbar CSS to support the new controls with mobile responsiveness
#### CSS Changes:
- Enhanced `.cut-drawing-toolbar` styles to accommodate color/opacity controls
- Added `.style-controls` section with proper responsive layout
- Improved mobile responsiveness with column layout for small screens
- Simplified cut polygon CSS rules in `leaflet-custom.css`
### 2. Simplified JavaScript Logic
#### Admin Cuts Manager:
- Added `setupToolbarControls()` method for real-time style updates
- Added `getCurrentColor()` and `getCurrentOpacity()` helper methods
- Updated `handleFormSubmit()` to use toolbar values instead of form values
- Removed complex form-based color/opacity event listeners
- Simplified drawing completion workflow
#### Drawing Integration:
- Color and opacity changes now immediately update the drawing preview
- No more complex synchronization between multiple style update methods
- Direct integration between toolbar controls and drawing layer styles
### 3. User Experience Improvements
**Drawing Workflow**:
1. Click "Start Drawing" to begin
2. Draw polygon by clicking points on map
3. Adjust color and opacity in real-time using toolbar controls
4. See immediate feedback on the polygon as you draw
5. Click "Finish" when satisfied
6. Fill in name, description, and other properties
7. Save the cut
**Benefits**:
- Immediate visual feedback while drawing
- No more disconnect between form values and visual appearance
- Cleaner, more intuitive interface
- Better mobile experience with responsive toolbar
- Simplified code maintenance
## Files Modified
1. **admin.html**: Updated toolbar HTML structure
2. **cuts.css**: Enhanced toolbar styling and mobile responsiveness
3. **leaflet-custom.css**: Simplified cut polygon CSS rules
4. **admin-cuts.js**: Added toolbar controls and simplified style logic
## Testing Checklist
- [ ] Toolbar appears correctly when drawing starts
- [ ] Color picker updates polygon color in real-time
- [ ] Opacity slider updates polygon opacity in real-time
- [ ] Toolbar controls work on mobile devices
- [ ] Form submission uses toolbar values for color/opacity
- [ ] Drawing can be completed and saved successfully
- [ ] Existing cuts still display correctly
- [ ] Public map cut display is unaffected
## Next Steps
1. Test the simplified system thoroughly
2. Remove any remaining complex/unused methods from admin-cuts.js
3. Clean up any console.log debugging statements
4. Consider further UI/UX improvements based on user feedback
## Benefits of Simplification
- **Reduced complexity**: Removed ~200 lines of complex style synchronization code
- **Better UX**: Real-time visual feedback during drawing
- **Easier maintenance**: Clearer separation between drawing controls and form data
- **Mobile friendly**: Responsive toolbar that works well on all screen sizes
- **More intuitive**: Color/opacity controls where users expect them (near the drawing)

View File

@ -4,10 +4,10 @@ Welcome to the Map project! This application is a canvassing tool for political
## Project Overview
- **Purpose:** Visualize, manage, and update canvassing locations and volunteer shifts on an interactive map.
- **Purpose:** Visualize, manage, and update canvassing locations, volunteer shifts, and geographic overlays on an interactive map.
- **Backend:** Node.js/Express, with NocoDB as the database (REST API).
- **Frontend:** Vanilla JS, Leaflet.js for mapping, modular code in `/public/js`.
- **Admin Panel:** Accessible via `/admin.html` for managing start location, walk sheet, and settings.
- **Admin Panel:** Accessible via `/admin.html` for managing start location, walk sheet, cuts, and settings.
## Key Principles
@ -54,6 +54,8 @@ When adding a new feature, follow these steps:
- **Add a new location field:** Update NocoDB schema, backend helpers, and frontend forms.
- **Add a new admin feature:** Add a new section to `/admin.html`, backend route/controller, and frontend JS.
- **Change map behavior:** Update `/public/js/map-manager.js` and related modules.
- **Add a new cut property:** Update the cuts table schema, cutsController.js, and admin-cuts.js form.
- **Modify cut drawing behavior:** Update `/public/js/cut-drawing.js` and related cut modules.
## Contact

View File

@ -23,6 +23,9 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🔐 Role-based access control (Admin vs User permissions)
- 📧 Email notifications and password recovery via SMTP
- 📊 CSV data import with batch geocoding and visual progress tracking
- ✂️ **Cut feature for geographic overlays** - Admin-drawn polygons for map regions
- 🗺️ Interactive polygon drawing with click-to-add-points system
- 🎨 Customizable cut properties (color, opacity, category, visibility)
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
@ -110,12 +113,13 @@ A containerized web application that visualizes geographic data from NocoDB on a
./build-nocodb.sh
```
This creates five tables:
This creates six tables:
- **Locations** - Main map data with geo-location, contact info, support levels
- **Login** - User authentication (email, name, admin flag)
- **Settings** - Admin configuration and QR codes
- **Shifts** - Shift scheduling and management
- **Shift Signups** - User shift registrations
- **Cuts** - Geographic polygon overlays for map regions
4. **Get Table URLs**
@ -134,6 +138,7 @@ A containerized web application that visualizes geographic data from NocoDB on a
NOCODB_SETTINGS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mix06f2mlep7gqb
NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u
NOCODB_SHIFT_SIGNUPS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mi8jg1tn26mu8fj
NOCODB_CUTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mxxxxxxxxxxxxxx
```
6. **Build and Deploy**
@ -228,6 +233,23 @@ The build script automatically creates the following table structure:
- `Signup Date` (DateTime): When user signed up
- `Status` (Single Select): Options: "Confirmed" (Green), "Cancelled" (Red)
### Cuts Table
- `ID` (ID): Auto-incrementing primary key
- `name` (Single Line Text): Cut name/title (required)
- `description` (Long Text): Detailed description of the cut
- `geojson` (Long Text): GeoJSON polygon data (required)
- `bounds_north` (Decimal): Northern boundary latitude (Precision 8, Scale 8)
- `bounds_south` (Decimal): Southern boundary latitude (Precision 8, Scale 8)
- `bounds_east` (Decimal): Eastern boundary longitude (Precision 8, Scale 8)
- `bounds_west` (Decimal): Western boundary longitude (Precision 8, Scale 8)
- `color` (Single Line Text): Hex color code (default: "#007bff")
- `opacity` (Decimal): Fill opacity 0-1 (Precision 3, Scale 2, default: 0.3)
- `category` (Single Line Text): Category/tag for organization
- `is_public` (Checkbox): Whether cut is visible to non-admin users (default: true)
- `created_by` (Single Line Text): Creator email
- `created_at` (DateTime): Creation timestamp
- `updated_at` (DateTime): Last update timestamp
## API Endpoints
### Public Endpoints
@ -267,6 +289,18 @@ The build script automatically creates the following table structure:
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
### Cuts Endpoints
#### Public Cuts Endpoints (requires authentication)
- `GET /api/cuts/public` - Get all public cuts visible to users
#### Admin Cuts Endpoints (requires admin privileges)
- `GET /api/cuts` - Get all cuts (including private ones)
- `POST /api/cuts` - Create new cut
- `GET /api/cuts/:id` - Get single cut by ID
- `PUT /api/cuts/:id` - Update existing cut
- `DELETE /api/cuts/:id` - Delete cut
### Geocoding Endpoints (requires authentication)
- `GET /api/geocode/reverse?lat=<lat>&lng=<lng>` - Reverse geocode coordinates to address
@ -314,6 +348,55 @@ Administrators have additional capabilities for managing shifts:
The system automatically updates shift status based on current signups vs. maximum capacity.
## Cut Feature - Geographic Overlays
The Cut feature allows administrators to create and manage polygon overlays on the map, useful for defining geographic regions, neighborhoods, or operational areas.
### Admin Cut Creation
Administrators can create cuts through the admin panel at `/admin.html`:
- **Interactive Drawing**: Click points on the map to define polygon boundaries
- **Real-time Preview**: See the polygon shape as you draw
- **Point Management**: Add points by clicking, finish by clicking the first point or using the complete button
- **Visual Feedback**: Clear indicators for drawing mode and vertex points
### Cut Properties
Each cut supports the following properties:
- **Name**: Required title for the cut (e.g., "Downtown District", "Canvassing Area A")
- **Description**: Optional detailed description of the cut's purpose
- **Color**: Hex color code for the polygon border and fill (default: "#007bff")
- **Opacity**: Fill transparency from 0.0 (transparent) to 1.0 (opaque) (default: 0.3)
- **Category**: Optional categorization tag for organization
- **Visibility**: Public (visible to all users) or Private (admin-only)
### Cut Management
- **View All Cuts**: List all existing cuts with their properties
- **Edit Cuts**: Modify any cut property after creation
- **Delete Cuts**: Remove cuts with confirmation prompts
- **Import/Export**: JSON format for backup and migration
- **Real-time Updates**: Changes appear immediately on all connected maps
### Public Cut Display
Public cuts are automatically displayed on the main map for all authenticated users:
- **Polygon Overlays**: Cuts appear as colored polygon overlays
- **Non-Interactive**: Users can see cuts but cannot modify them
- **Responsive**: Cuts adapt to different screen sizes and zoom levels
- **Performance Optimized**: Efficient rendering for multiple cuts
### Use Cases
- **Canvassing Districts**: Define geographic areas for volunteer assignments
- **Neighborhood Boundaries**: Mark community or administrative boundaries
- **Event Areas**: Highlight locations for rallies, meetings, or activities
- **Restricted Zones**: Mark areas requiring special attention or restrictions
- **Progress Tracking**: Visual representation of completed campaign areas
## Unified Search System
The application features a powerful unified search system accessible via the search bar in the header or by pressing `Ctrl+K` anywhere in the application.
@ -455,6 +538,17 @@ Users with admin privileges can access the admin panel at `/admin.html` to confi
- **Delete Users**: Remove user accounts (with confirmation prompts)
- **Security**: Password validation and admin-only access
#### Cut Management
- **Interactive Drawing**: Click-to-add-points polygon drawing system on the map
- **Cut Properties**: Configure name, description, color, opacity, and category
- **Visibility Control**: Set cuts as public (visible to all users) or private (admin-only)
- **Real-time Preview**: See cut polygons rendered on the map during creation
- **Cut Management**: View, edit, and delete existing cuts with full CRUD operations
- **Import/Export**: JSON import/export functionality for cut data backup and migration
- **Map Integration**: Cuts display as colored polygon overlays on both admin and public maps
- **Responsive Design**: Touch-friendly interface for mobile and tablet devices
#### Convert Data
- **CSV Upload**: Upload CSV files containing addresses for bulk import

View File

@ -1,5 +1,7 @@
# Build stage
FROM node:18-alpine AS builder
FROM node:18-alpine
# Install wget and dumb-init for proper signal handling
RUN apk add --no-cache wget dumb-init
WORKDIR /app
@ -9,19 +11,7 @@ COPY package*.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Runtime stage
FROM node:18-alpine
# Install wget for healthcheck
RUN apk add --no-cache wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY public ./public
COPY routes ./routes
@ -43,4 +33,6 @@ USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
# Use dumb-init to handle signals properly and prevent zombie processes
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

View File

@ -74,6 +74,17 @@ if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET) {
}
}
// Parse cuts sheet ID
let cutsSheetId = null;
if (process.env.NOCODB_CUTS_SHEET) {
if (process.env.NOCODB_CUTS_SHEET.startsWith('http')) {
const { tableId } = parseNocoDBUrl(process.env.NOCODB_CUTS_SHEET);
cutsSheetId = tableId;
} else {
cutsSheetId = process.env.NOCODB_CUTS_SHEET;
}
}
module.exports = {
// Server config
port: process.env.PORT || 3000,
@ -91,7 +102,8 @@ module.exports = {
settingsSheetId,
viewUrl: process.env.NOCODB_VIEW_URL,
shiftsSheetId,
shiftSignupsSheetId
shiftSignupsSheetId,
cutsSheetId
},
// Session config
@ -126,5 +138,14 @@ module.exports = {
},
// Utility functions
parseNocoDBUrl
parseNocoDBUrl,
// Convenience constants for controllers
NOCODB_BASE_ID: process.env.NOCODB_PROJECT_ID || parsedIds.projectId,
LOCATIONS_TABLE_ID: process.env.NOCODB_TABLE_ID || parsedIds.tableId,
LOGIN_TABLE_ID: loginSheetId,
SETTINGS_TABLE_ID: settingsSheetId,
SHIFTS_TABLE_ID: shiftsSheetId,
SHIFT_SIGNUPS_TABLE_ID: shiftSignupsSheetId,
CUTS_TABLE_ID: cutsSheetId
};

View File

@ -0,0 +1,363 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
class CutsController {
/**
* Get all cuts - filter by public visibility for non-admins
*/
async getAll(req, res) {
try {
// Check if cuts table is configured
if (!config.nocodb.cutsSheetId) {
// Return empty list if cuts table is not configured
return res.json({ list: [] });
}
const { isAdmin } = req.user || {};
// For NocoDB v2 API, we need to get all records and filter in memory
// since the where clause syntax may be different
const response = await nocodbService.getAll(
config.nocodb.cutsSheetId
);
// Ensure response has list property
if (!response || !response.list) {
return res.json({ list: [] });
}
// Filter results based on user permissions
if (!isAdmin) {
response.list = response.list.filter(cut => {
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
return isPublic === true || isPublic === 1 || isPublic === '1';
});
}
logger.info(`Retrieved ${response.list?.length || 0} cuts for ${isAdmin ? 'admin' : 'user'}`);
res.json(response);
} catch (error) {
logger.error('Error fetching cuts:', error);
// Log more details about the error
if (error.response) {
logger.error('Error response:', error.response.data);
logger.error('Error status:', error.response.status);
}
res.status(500).json({
error: 'Failed to fetch cuts',
details: error.message
});
}
}
/**
* Get single cut by ID
*/
async getById(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
const response = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!response) {
return res.status(404).json({ error: 'Cut not found' });
}
// Non-admins can only access public cuts
if (!isAdmin) {
const isPublic = response.is_public || response.Is_public || response['Public Visibility'];
if (!(isPublic === true || isPublic === 1 || isPublic === '1')) {
return res.status(403).json({ error: 'Access denied' });
}
}
logger.info(`Retrieved cut: ${response.name} (ID: ${id})`);
res.json(response);
} catch (error) {
logger.error('Error fetching cut:', error);
res.status(500).json({
error: 'Failed to fetch cut',
details: error.message
});
}
}
/**
* Create new cut - admin only
*/
async create(req, res) {
try {
const { isAdmin, email } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const {
name,
description,
color = '#3388ff',
opacity = 0.3,
category,
is_public = false,
is_official = false,
geojson,
bounds
} = req.body;
// Validate required fields
if (!name || !geojson) {
return res.status(400).json({
error: 'Name and geojson are required'
});
}
// Validate GeoJSON
try {
const parsedGeoJSON = JSON.parse(geojson);
if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') {
return res.status(400).json({
error: 'GeoJSON must be a Polygon or MultiPolygon'
});
}
} catch (parseError) {
return res.status(400).json({
error: 'Invalid GeoJSON format'
});
}
// Validate opacity range
if (opacity < 0 || opacity > 1) {
return res.status(400).json({
error: 'Opacity must be between 0 and 1'
});
}
const cutData = {
name,
description,
color,
opacity,
category,
is_public,
is_official,
geojson,
bounds,
created_by: email,
};
const response = await nocodbService.create(
config.nocodb.cutsSheetId,
cutData
);
logger.info(`Created cut: ${name} by ${email}`);
res.status(201).json(response);
} catch (error) {
logger.error('Error creating cut:', error);
res.status(500).json({
error: 'Failed to create cut',
details: error.message
});
}
}
/**
* Update cut - admin only
*/
async update(req, res) {
try {
const { isAdmin, email } = req.user || {};
const { id } = req.params;
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Check if cut exists
const existingCut = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!existingCut) {
return res.status(404).json({ error: 'Cut not found' });
}
const {
name,
description,
color,
opacity,
category,
is_public,
is_official,
geojson,
bounds
} = req.body;
// Validate GeoJSON if provided
if (geojson) {
try {
const parsedGeoJSON = JSON.parse(geojson);
if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') {
return res.status(400).json({
error: 'GeoJSON must be a Polygon or MultiPolygon'
});
}
} catch (parseError) {
return res.status(400).json({
error: 'Invalid GeoJSON format'
});
}
}
// Validate opacity if provided
if (opacity !== undefined && (opacity < 0 || opacity > 1)) {
return res.status(400).json({
error: 'Opacity must be between 0 and 1'
});
}
const updateData = {
updated_at: new Date().toISOString()
};
// Only include fields that are provided
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (color !== undefined) updateData.color = color;
if (opacity !== undefined) updateData.opacity = opacity;
if (category !== undefined) updateData.category = category;
if (is_public !== undefined) updateData.is_public = is_public;
if (is_official !== undefined) updateData.is_official = is_official;
if (geojson !== undefined) updateData.geojson = geojson;
if (bounds !== undefined) updateData.bounds = bounds;
const response = await nocodbService.update(
config.nocodb.cutsSheetId,
id,
updateData
);
logger.info(`Updated cut: ${existingCut.name} (ID: ${id}) by ${email}`);
res.json(response);
} catch (error) {
logger.error('Error updating cut:', error);
res.status(500).json({
error: 'Failed to update cut',
details: error.message
});
}
}
/**
* Delete cut - admin only
*/
async delete(req, res) {
try {
const { isAdmin, email } = req.user || {};
const { id } = req.params;
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Check if cut exists
const existingCut = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!existingCut) {
return res.status(404).json({ error: 'Cut not found' });
}
await nocodbService.delete(
config.nocodb.cutsSheetId,
id
);
logger.info(`Deleted cut: ${existingCut.name} (ID: ${id}) by ${email}`);
res.json({ message: 'Cut deleted successfully' });
} catch (error) {
logger.error('Error deleting cut:', error);
res.status(500).json({
error: 'Failed to delete cut',
details: error.message
});
}
}
/**
* Get public cuts for map display
*/
async getPublic(req, res) {
try {
// Check if cuts table is configured
if (!config.nocodb.cutsSheetId) {
logger.warn('Cuts table not configured - NOCODB_CUTS_SHEET not set');
return res.json({ list: [] });
}
logger.info(`Fetching public cuts from table ID: ${config.nocodb.cutsSheetId}`);
// Use the same pattern as getAll method that's known to work
const response = await nocodbService.getAll(
config.nocodb.cutsSheetId
);
logger.info(`Raw response from nocodbService.getAll:`, {
hasResponse: !!response,
hasList: !!(response && response.list),
listLength: response?.list?.length || 0,
sampleData: response?.list?.[0] || null,
allFields: response?.list?.[0] ? Object.keys(response.list[0]) : []
});
// Ensure response has list property
if (!response || !response.list) {
logger.warn('No cuts found or invalid response structure');
return res.json({ list: [] });
}
// Log all cuts before filtering
logger.info(`All cuts found: ${response.list.length}`);
response.list.forEach((cut, index) => {
// Check multiple possible field names for is_public
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
logger.info(`Cut ${index}: ${cut.name || cut.Name} - is_public: ${isPublic} (type: ${typeof isPublic})`);
logger.info(`Available fields:`, Object.keys(cut));
});
// Filter to only public cuts - handle multiple possible field names
const originalCount = response.list.length;
response.list = response.list.filter(cut => {
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
return isPublic === true || isPublic === 1 || isPublic === '1';
});
const publicCount = response.list.length;
logger.info(`Filtered ${originalCount} total cuts to ${publicCount} public cuts`);
res.json(response);
} catch (error) {
logger.error('Error fetching public cuts:', error);
// Log more details about the error
if (error.response) {
logger.error('Error response:', error.response.data);
logger.error('Error status:', error.response.status);
}
res.status(500).json({
error: 'Failed to fetch public cuts',
details: error.message
});
}
}
}
module.exports = new CutsController();

View File

@ -54,6 +54,14 @@ const requireAuth = async (req, res, next) => {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType
};
next();
} else {
logger.warn('Unauthorized access attempt', {
@ -85,6 +93,14 @@ const requireAdmin = async (req, res, next) => {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType
};
next();
} else {
logger.warn('Unauthorized admin access attempt', {
@ -116,6 +132,14 @@ const requireNonTemp = async (req, res, next) => {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType
};
next();
} else {
logger.warn('Temp user access denied', {

View File

@ -67,6 +67,10 @@
<span class="nav-icon">👥</span>
<span class="nav-text">Users</span>
</a>
<a href="#cuts">
<span class="nav-icon">✂️</span>
<span class="nav-text">Map Cuts</span>
</a>
<a href="#convert-data">
<span class="nav-icon">📊</span>
<span class="nav-text">Convert Data</span>
@ -455,6 +459,133 @@
</div>
</section>
<!-- Map Cuts Section -->
<section id="cuts" class="admin-section" style="display: none;">
<h2>Map Cuts</h2>
<p>Create and manage polygon overlays for the map. Cuts can be used to define areas like wards, neighborhoods, or custom regions.</p>
<div class="cuts-container">
<!-- Map and Drawing Controls -->
<div class="cuts-map-section">
<div id="cuts-map" class="admin-map"></div>
<!-- Drawing Toolbar -->
<div id="cut-drawing-toolbar" class="cut-drawing-toolbar">
<div class="toolbar-content">
<div class="vertex-count" id="vertex-count">0</div>
<div class="style-controls">
<div class="color-control">
<label>Color:</label>
<input type="color" id="toolbar-color" value="#3388ff">
</div>
<div class="opacity-control">
<label>Opacity:</label>
<input type="range" id="toolbar-opacity" min="0" max="1" step="0.05" value="0.3">
<span class="opacity-value" id="toolbar-opacity-display">30%</span>
</div>
</div>
<div class="toolbar-buttons">
<button type="button" id="finish-cut-btn" class="primary" disabled>Finish</button>
<button type="button" id="undo-vertex-btn" class="secondary" disabled>Undo</button>
<button type="button" id="clear-vertices-btn" class="secondary" disabled>Clear</button>
<button type="button" id="cancel-cut-btn" class="danger">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Cut Form -->
<div class="cuts-form-section">
<div class="cuts-management-panel">
<div class="panel-header">
<h3 class="panel-title" id="cut-form-title">Cut Properties</h3>
<div class="panel-actions">
<button id="start-drawing-btn" class="btn btn-primary btn-sm">Start Drawing</button>
</div>
</div>
<div class="panel-content">
<form id="cut-form" class="cut-form">
<!-- Hidden fields moved to prevent duplicates -->
<input type="hidden" id="cut-id" name="id">
<input type="hidden" id="cut-geojson" name="geojson">
<input type="hidden" id="cut-bounds" name="bounds">
<div class="form-group">
<label for="cut-name">Name *</label>
<input type="text" id="cut-name" name="name" required>
</div>
<div class="form-group">
<label for="cut-description">Description</label>
<textarea id="cut-description" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="cut-category">Category</label>
<select id="cut-category" name="category">
<option value="Custom">Custom</option>
<option value="Ward">Ward</option>
<option value="Neighborhood">Neighborhood</option>
<option value="District">District</option>
</select>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="cut-public" name="is_public" checked>
<label for="cut-public">Make this cut visible on the public map</label>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="cut-official" name="is_official">
<label for="cut-official">Mark as official cut</label>
</div>
<div class="form-actions">
<button type="submit" id="save-cut-btn" class="btn btn-success" disabled>Save Cut</button>
<button type="button" id="reset-form-btn" class="btn btn-secondary">Reset</button>
<button type="button" id="cancel-edit-btn" class="btn btn-secondary" style="display: none;">Cancel Edit</button>
</div>
</form>
</div>
</div>
</div>
<!-- Cuts List -->
<div class="cuts-list-section">
<div class="cuts-management-panel">
<div class="panel-header">
<h3 class="panel-title">Existing Cuts</h3>
<div class="panel-actions">
<button id="refresh-cuts-btn" class="btn btn-secondary btn-sm">Refresh</button>
<button id="export-cuts-btn" class="btn btn-secondary btn-sm">Export All</button>
<label for="import-cuts-file" class="btn btn-secondary btn-sm" style="margin: 0;">
Import
<input type="file" id="import-cuts-file" accept=".json" style="display: none;">
</label>
</div>
</div>
<div class="panel-content">
<div class="cuts-filters">
<input type="text" id="cuts-search" placeholder="Search cuts..." class="form-control">
<select id="cuts-category-filter" class="form-control">
<option value="">All Categories</option>
<option value="Custom">Custom</option>
<option value="Ward">Ward</option>
<option value="Neighborhood">Neighborhood</option>
<option value="District">District</option>
</select>
</div>
<div id="cuts-list" class="cuts-list">
<!-- Cuts will be populated here -->
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Convert Data Section -->
<section id="convert-data" class="admin-section" style="display: none;">
<h2>Convert Data</h2>
@ -713,6 +844,9 @@
<!-- Dashboard JavaScript -->
<script src="js/dashboard.js"></script>
<!-- Admin Cuts JavaScript -->
<script src="js/admin-cuts.js"></script>
<!-- Data Convert JavaScript -->
<!-- Admin JavaScript -->
<script src="js/admin.js"></script>

View File

@ -2395,3 +2395,147 @@
padding: 16px;
}
}
/* Cuts Section Styles */
.cuts-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.cuts-map-section {
position: relative;
height: 500px;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ddd;
}
#cuts-map {
width: 100%;
height: 100%;
position: relative;
}
.cuts-form-section,
.cuts-list-section {
margin-top: 20px;
}
.cuts-filters {
display: grid;
grid-template-columns: 1fr 200px;
gap: 10px;
margin-bottom: 15px;
}
.cuts-filters input,
.cuts-filters select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
/* Button styles to match admin panel theme */
.btn-sm {
padding: 6px 12px;
font-size: 14px;
}
.btn-primary {
background-color: #007bff;
color: white;
border: 1px solid #007bff;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
border-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: 1px solid #6c757d;
}
.btn-secondary:hover:not(:disabled) {
background-color: #545b62;
border-color: #545b62;
}
.btn-danger {
background-color: #dc3545;
color: white;
border: 1px solid #dc3545;
}
.btn-danger:hover:not(:disabled) {
background-color: #c82333;
border-color: #c82333;
}
.btn-success {
background-color: #28a745;
color: white;
border: 1px solid #28a745;
}
.btn-success:hover:not(:disabled) {
background-color: #218838;
border-color: #218838;
}
/* Responsive layout for cuts */
@media (min-width: 1200px) {
.cuts-container {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.cuts-form-section,
.cuts-list-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 0;
}
.cuts-form-section > *,
.cuts-list-section > * {
grid-column: span 1;
}
}
@media (max-width: 768px) {
.cuts-map-section {
height: 400px;
}
.cuts-filters {
grid-template-columns: 1fr;
}
}
/* Disabled form state */
.cut-form.disabled {
opacity: 0.7;
pointer-events: none;
}
.cut-form.disabled input:not(#start-drawing-btn):not(#reset-form-btn):not(#cancel-edit-btn),
.cut-form.disabled textarea,
.cut-form.disabled select {
background-color: #f5f5f5;
cursor: not-allowed;
}
.cut-form.disabled input:not(#start-drawing-btn):not(#reset-form-btn):not(#cancel-edit-btn):focus,
.cut-form.disabled textarea:focus,
.cut-form.disabled select:focus {
outline: none;
border-color: #ddd;
box-shadow: none;
}

View File

@ -0,0 +1,996 @@
/* Cut Drawing Styles */
.cut-vertex-marker {
background: transparent;
border: none;
}
/* Enhanced vertex styling */
.cut-vertex-marker .vertex-point {
width: 12px;
height: 12px;
background: #3388ff;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
cursor: pointer;
transition: all 0.2s;
}
.cut-vertex-marker .vertex-point:hover {
background: #2c5aa0;
transform: scale(1.2);
}
/* First vertex special styling */
.cut-vertex-marker .vertex-point.first {
background: #28a745;
width: 16px;
height: 16px;
margin: -2px;
position: relative;
}
.cut-vertex-marker .vertex-point.first::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 2px solid #28a745;
border-radius: 50%;
animation: pulse-ring 1.5s ease-out infinite;
}
@keyframes pulse-ring {
0% {
opacity: 0.8;
transform: translate(-50%, -50%) scale(0.5);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
/* Cut Drawing Toolbar - Improved compact layout */
.cut-drawing-toolbar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
padding: 12px;
z-index: 1000;
display: none;
min-width: 400px;
}
.cut-drawing-toolbar.active {
display: block;
}
.cut-drawing-toolbar .toolbar-content {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.cut-drawing-toolbar .vertex-count {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
white-space: nowrap;
}
.cut-drawing-toolbar .vertex-count::before {
content: "📍";
font-size: 16px;
}
/* Style controls in toolbar */
.cut-drawing-toolbar .style-controls {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
.cut-drawing-toolbar .color-control {
display: flex;
align-items: center;
gap: 5px;
}
.cut-drawing-toolbar .color-control label {
font-size: 12px;
color: #666;
font-weight: 500;
}
.cut-drawing-toolbar input[type="color"] {
width: 32px;
height: 24px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.cut-drawing-toolbar .opacity-control {
display: flex;
align-items: center;
gap: 5px;
}
.cut-drawing-toolbar .opacity-control label {
font-size: 12px;
color: #666;
font-weight: 500;
}
.cut-drawing-toolbar input[type="range"] {
width: 80px;
height: 4px;
}
.cut-drawing-toolbar .opacity-value {
font-size: 12px;
color: #666;
font-weight: 500;
min-width: 30px;
}
.cut-drawing-toolbar .toolbar-buttons {
display: flex;
gap: 5px;
}
.cut-drawing-toolbar button {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
}
.cut-drawing-toolbar button:hover:not(:disabled) {
background: #f5f5f5;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.cut-drawing-toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cut-drawing-toolbar button.primary {
background: #3388ff;
color: white;
border-color: #3388ff;
font-weight: 500;
}
.cut-drawing-toolbar button.primary:hover:not(:disabled) {
background: #2c5aa0;
border-color: #2c5aa0;
}
.cut-drawing-toolbar button.danger {
background: #dc3545;
color: white;
border-color: #dc3545;
}
.cut-drawing-toolbar button.danger:hover:not(:disabled) {
background: #c82333;
border-color: #c82333;
}
.cut-drawing-toolbar button.secondary {
background: #6c757d;
color: white;
border-color: #6c757d;
}
.cut-drawing-toolbar button.secondary:hover:not(:disabled) {
background: #5a6268;
border-color: #5a6268;
}
/* Leaflet tooltip styling for close polygon hint */
.leaflet-tooltip {
background: #333;
color: white;
border: none;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.leaflet-tooltip-top:before {
border-top-color: #333;
}
/* Responsive adjustments for mobile */
@media (max-width: 768px) {
.cut-drawing-toolbar {
bottom: 10px;
left: 10px;
right: 10px;
transform: none;
padding: 8px;
}
.cut-drawing-toolbar .toolbar-content {
flex-wrap: wrap;
gap: 5px;
}
.cut-drawing-toolbar .vertex-count {
width: 100%;
margin-bottom: 5px;
margin-right: 0;
justify-content: center;
}
.cut-drawing-toolbar .toolbar-buttons {
width: 100%;
justify-content: space-around;
}
.cut-drawing-toolbar button {
padding: 6px 10px;
font-size: 12px;
flex: 1;
}
}
/* Leaflet tooltip styling for close polygon hint */
.leaflet-tooltip {
background: #333;
color: white;
border: none;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.leaflet-tooltip-top:before {
border-top-color: #333;
}
/* Responsive adjustments for mobile */
@media (max-width: 768px) {
.cut-drawing-toolbar {
bottom: 10px;
left: 10px;
right: 10px;
transform: none;
padding: 8px;
}
.cut-drawing-toolbar .toolbar-content {
flex-wrap: wrap;
gap: 5px;
}
.cut-drawing-toolbar .vertex-count {
width: 100%;
margin-bottom: 5px;
margin-right: 0;
justify-content: center;
}
.cut-drawing-toolbar .toolbar-buttons {
width: 100%;
justify-content: space-around;
}
.cut-drawing-toolbar button {
padding: 6px 10px;
font-size: 12px;
flex: 1;
}
}
.cut-drawing-toolbar button.secondary:hover:not(:disabled) {
background: #5a6268;
border-color: #545b62;
}
/* Cut Management Panel */
.cuts-management-panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.cuts-management-panel .panel-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: between;
align-items: center;
}
.cuts-management-panel .panel-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin: 0;
}
.cuts-management-panel .panel-actions {
display: flex;
gap: 10px;
}
.cuts-management-panel .panel-content {
padding: 20px;
}
/* Cut Form */
.cut-form {
display: grid;
gap: 15px;
max-width: 500px;
}
.cut-form .form-group {
display: flex;
flex-direction: column;
}
.cut-form label {
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.cut-form input,
.cut-form textarea,
.cut-form select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.cut-form textarea {
resize: vertical;
min-height: 80px;
}
.cut-form .color-opacity-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.cut-form .color-input-group {
display: flex;
align-items: center;
gap: 10px;
}
.cut-form input[type="color"] {
width: 40px;
height: 40px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.cut-form .opacity-input-group {
display: flex;
flex-direction: column;
}
.cut-form input[type="range"] {
margin-top: 5px;
}
.cut-form .opacity-value {
font-size: 12px;
color: #666;
text-align: center;
margin-top: 2px;
}
.cut-form .checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.cut-form .checkbox-group input[type="checkbox"] {
width: auto;
}
.cut-form .form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
/* Cut List */
.cuts-list {
display: grid;
gap: 10px;
}
.cut-item {
border: 1px solid #ddd;
border-radius: 6px;
padding: 12px;
background: white;
transition: border-color 0.2s;
}
.cut-item:hover {
border-color: #3388ff;
}
.cut-item.active {
border-color: #3388ff;
background: #f8f9ff;
}
.cut-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cut-item-name {
font-weight: bold;
color: #333;
}
.cut-item-category {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
background: #f0f0f0;
color: #666;
}
.cut-item-category.ward { background: #e8f5e8; color: #4CAF50; }
.cut-item-category.neighborhood { background: #fff3e0; color: #FF9800; }
.cut-item-category.district { background: #f3e5f5; color: #9C27B0; }
.cut-item-category.custom { background: #e3f2fd; color: #2196F3; }
.cut-item-description {
font-size: 13px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.cut-item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #999;
}
.cut-item-badges {
display: flex;
gap: 5px;
}
.cut-item-badge {
padding: 2px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
}
.cut-item-badge.public {
background: #e8f5e8;
color: #4CAF50;
}
.cut-item-badge.private {
background: #ffebee;
color: #f44336;
}
.cut-item-badge.official {
background: #e3f2fd;
color: #2196F3;
}
.cut-item-actions {
display: flex;
gap: 5px;
margin-top: 10px;
}
.cut-item-actions button {
padding: 4px 8px;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
transition: background-color 0.2s;
}
.cut-item-actions button:hover {
background: #f5f5f5;
}
.cut-item-actions button.primary {
background: #3388ff;
color: white;
border-color: #3388ff;
}
.cut-item-actions button.danger {
background: #dc3545;
color: white;
border-color: #dc3545;
}
/* Map Cut Controls */
.map-cut-controls {
position: absolute;
top: 60px;
right: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
z-index: 1000;
min-width: 200px;
}
.map-cut-controls .control-header {
padding: 10px 12px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
font-weight: bold;
font-size: 13px;
color: #333;
}
.map-cut-controls .control-content {
padding: 8px;
}
.map-cut-controls select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
background: white;
}
.map-cut-controls .cut-toggle {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 4px 0;
}
.map-cut-controls .toggle-label {
font-size: 12px;
color: #666;
}
.map-cut-controls .toggle-switch {
position: relative;
width: 40px;
height: 20px;
background: #ddd;
border-radius: 10px;
cursor: pointer;
transition: background-color 0.3s;
}
.map-cut-controls .toggle-switch.active {
background: #3388ff;
}
.map-cut-controls .toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.map-cut-controls .toggle-switch.active::after {
transform: translateX(20px);
}
/* Cut Legend */
.cut-legend {
position: absolute;
bottom: 20px;
right: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 250px;
z-index: 1000;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
}
.cut-legend.visible {
opacity: 1;
transform: translateY(0);
}
.cut-legend .legend-header {
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.cut-legend .legend-title {
font-weight: bold;
font-size: 13px;
color: #333;
}
.cut-legend .legend-toggle {
font-size: 12px;
color: #666;
}
.cut-legend .legend-content {
padding: 10px 12px;
display: none;
}
.cut-legend .legend-content.expanded {
display: block;
}
.cut-legend .legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.cut-legend .legend-item:last-child {
margin-bottom: 0;
}
.cut-legend .legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid #ddd;
}
.cut-legend .legend-info {
flex: 1;
}
.cut-legend .legend-name {
font-size: 12px;
font-weight: bold;
color: #333;
line-height: 1.2;
}
.cut-legend .legend-description {
font-size: 11px;
color: #666;
line-height: 1.2;
}
/* Additional styles for the cuts map section */
.cuts-map-section {
position: relative;
height: 500px;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
}
#cuts-map {
width: 100%;
height: 100%;
}
/* Ensure proper stacking of map elements */
.cuts-map-section .leaflet-control-container {
z-index: 800;
}
.cuts-map-section .leaflet-pane {
z-index: 400;
}
/* Responsive Design */
@media (max-width: 768px) {
.cut-drawing-toolbar {
bottom: 10px;
left: 10px;
right: 10px;
transform: none;
min-width: auto;
padding: 10px;
}
.cut-drawing-toolbar .toolbar-content {
flex-direction: column;
gap: 10px;
}
.cut-drawing-toolbar .vertex-count {
font-size: 12px;
margin-bottom: 0;
padding: 6px;
text-align: center;
}
.cut-drawing-toolbar .style-controls {
padding: 8px 0;
border-left: none;
border-right: none;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
justify-content: center;
gap: 15px;
}
.cut-drawing-toolbar .toolbar-buttons {
flex-wrap: wrap;
gap: 5px;
justify-content: center;
}
.cut-drawing-toolbar button {
padding: 8px 12px;
font-size: 12px;
min-width: auto;
flex: 1 1 45%;
}
.cuts-map-section {
height: 400px;
}
}
/* Animation for cut display */
@keyframes cutFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.leaflet-overlay-pane .cut-layer {
animation: cutFadeIn 0.3s ease-out;
}
/* Preview polygon styling */
.cut-preview-polygon {
stroke-dasharray: 5, 5;
animation: dash-animation 20s linear infinite;
}
@keyframes dash-animation {
to {
stroke-dashoffset: -1000;
}
}
/* Ensure preview polygon is visible but clearly in preview mode */
.leaflet-overlay-pane .cut-preview-polygon {
pointer-events: none;
}
/* Multiple cuts legend styles */
.cut-legend-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
margin: 4px 0;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.1);
}
.cut-legend-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.cut-toggle-btn {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.cut-toggle-btn:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.legend-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
text-align: center;
}
.legend-actions .btn {
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.legend-actions .btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* Mobile cut selection styles */
.overlay-actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.overlay-actions .btn {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.overlay-actions .btn:hover {
background: #f5f5f5;
transform: translateY(-1px);
}
.cut-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.cut-checkbox-label:hover {
background-color: #f8f9fa;
border-color: #3388ff;
}
.cut-checkbox-label input[type="checkbox"] {
margin: 0;
transform: scale(1.2);
}
.cut-color-indicator {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #ddd;
flex-shrink: 0;
}
.cut-info {
flex: 1;
}
.cut-name {
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.cut-category {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.cut-description {
font-size: 11px;
color: #888;
line-height: 1.3;
}
.no-active-cuts {
color: #999;
font-style: italic;
text-align: center;
padding: 20px;
}
.active-overlay-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
margin: 4px 0;
border-radius: 4px;
background-color: #f8f9fa;
border: 1px solid #eee;
}
.active-overlay-item .overlay-color {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid #ddd;
flex-shrink: 0;
}
.active-overlay-item .overlay-details {
flex: 1;
}
.active-overlay-item .overlay-name {
font-weight: bold;
color: #333;
font-size: 13px;
}
.active-overlay-item .overlay-description {
font-size: 11px;
color: #666;
margin-top: 2px;
}

View File

@ -96,14 +96,20 @@ path.leaflet-interactive {
cursor: pointer !important;
}
/* Override any conflicting styles */
.leaflet-container path.leaflet-interactive {
/* Override any conflicting styles - but allow cuts to manage their own opacity */
.leaflet-container path.leaflet-interactive:not(.cut-polygon) {
stroke: #ffffff !important;
stroke-opacity: 1 !important;
stroke-width: 2px !important;
fill-opacity: 0.8 !important;
}
/* Cut polygons - allow dynamic opacity (higher specificity to override) */
.leaflet-container path.leaflet-interactive.cut-polygon {
stroke-width: 2px !important;
/* Allow JavaScript to control fill-opacity - remove !important */
}
/* Marker being moved */
.location-marker.leaflet-drag-target {
cursor: move !important;

View File

@ -70,3 +70,269 @@
font-size: 12px;
white-space: nowrap;
}
/* Cut Selector Styles */
.cut-selector-container {
position: relative;
}
.cut-selector {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--secondary-color);
color: white;
border: none;
padding: 10px 32px 10px 16px;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
min-width: 150px;
max-width: 200px;
outline: none;
text-align: left;
font-family: inherit;
}
.cut-selector:hover {
background-color: #7f8c8d;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.cut-selector:focus {
background-color: #7f8c8d;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.5);
}
/* Custom dropdown arrow */
.cut-selector-container::after {
content: '▼';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: white;
font-size: 10px;
}
/* Mobile styles for cut selector */
@media (max-width: 768px) {
.cut-selector {
min-width: 120px;
max-width: 150px;
font-size: 13px;
padding: 8px 28px 8px 12px;
}
.cut-selector-container::after {
right: 8px;
font-size: 9px;
}
}
/* Multi-select cut dropdown styles - enhanced */
.cut-checkbox-container {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 1001;
max-height: 300px;
overflow-y: auto;
margin-top: 2px;
display: none;
}
/* Remove the focus-within rule that auto-shows the dropdown */
/* This was causing the dropdown to show automatically on focus */
.cut-checkbox-header {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
gap: 8px;
justify-content: space-between;
}
.cut-checkbox-header .btn {
font-size: 12px;
padding: 4px 8px;
}
.cut-checkbox-list {
padding: 4px;
}
.cut-checkbox-item {
display: flex;
align-items: center;
padding: 6px 8px;
cursor: pointer;
transition: background-color 0.2s;
user-select: none;
}
.cut-checkbox-item:hover {
background-color: #f5f5f5;
}
.cut-checkbox-item * {
pointer-events: none;
}
.cut-checkbox-item input[type="checkbox"] {
pointer-events: auto;
margin-right: 8px;
cursor: pointer;
}
.cut-color-box {
width: 16px;
height: 16px;
border: 1px solid #ccc;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
}
.cut-checkbox-item .cut-name {
flex: 1;
font-size: 14px;
}
.cut-checkbox-item .badge {
margin-left: 8px;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: #28a745;
color: white;
}
/* Cut legend styles */
.cut-legend {
position: absolute;
bottom: 20px;
left: 20px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
min-width: 200px;
max-width: 300px;
z-index: 1000;
display: none;
}
.cut-legend.visible {
display: block;
}
.cut-legend-content {
padding: 12px;
}
.legend-header h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #333;
}
/* Updated legend styles for multiple cuts */
.legend-cuts-list {
max-height: 200px;
overflow-y: auto;
}
.legend-cut-item {
display: flex;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid #eee;
}
.legend-cut-item:last-child {
border-bottom: none;
}
.legend-cut-item .cut-color-box {
width: 20px;
height: 20px;
margin-right: 10px;
}
.legend-cut-item .cut-name {
flex: 1;
font-size: 14px;
}
.btn-remove-cut {
background: none;
border: none;
color: #dc3545;
font-size: 20px;
cursor: pointer;
padding: 0 5px;
opacity: 0.7;
transition: opacity 0.2s;
}
.btn-remove-cut:hover {
opacity: 1;
}
/* Mobile overlay styles */
.mobile-overlay-list {
padding: 10px 0;
}
.mobile-overlay-item {
display: flex;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #eee;
}
.mobile-overlay-item:last-child {
border-bottom: none;
}
.mobile-overlay-item input[type="checkbox"] {
margin-right: 12px;
transform: scale(1.2);
}
.overlay-actions {
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
gap: 10px;
justify-content: space-between;
}
/* Mobile responsive adjustments for multi-select */
@media (max-width: 768px) {
.cut-checkbox-container {
position: fixed;
top: 50%;
left: 10px;
right: 10px;
transform: translateY(-50%);
max-height: 70vh;
}
.cut-legend {
left: 10px;
right: 10px;
bottom: 80px;
max-width: none;
}
}

View File

@ -152,3 +152,97 @@
.mobile-sidebar .btn:active {
transform: scale(0.95);
}
/* Mobile overlay modal styles */
.overlay-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.overlay-option {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: var(--border-radius);
transition: var(--transition);
}
.overlay-option:hover {
border-color: var(--primary-color);
background-color: rgba(52, 152, 219, 0.05);
}
.overlay-option label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: 500;
margin: 0;
width: 100%;
}
.overlay-option input[type="radio"] {
margin-right: 12px;
transform: scale(1.2);
accent-color: var(--primary-color);
}
.overlay-option.selected {
border-color: var(--primary-color);
background-color: rgba(52, 152, 219, 0.1);
}
.overlay-label {
flex: 1;
}
.current-overlay-info {
padding: 15px;
background-color: var(--light-color);
border-radius: var(--border-radius);
border-left: 4px solid var(--primary-color);
}
.current-overlay-info h3 {
margin: 0 0 12px 0;
color: var(--dark-color);
font-size: 16px;
}
.overlay-info-content {
display: flex;
align-items: center;
gap: 12px;
}
.overlay-color {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid rgba(0,0,0,0.2);
flex-shrink: 0;
}
.overlay-details {
flex: 1;
}
.overlay-name {
font-weight: 600;
color: var(--dark-color);
margin-bottom: 4px;
}
.overlay-description {
font-size: 14px;
color: #666;
line-height: 1.4;
}
/* Mobile overlay button active state */
.mobile-sidebar #mobile-overlay-btn.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}

View File

@ -16,4 +16,5 @@
@import url("modules/cache-busting.css");
@import url("modules/apartment-popup.css");
@import url("modules/apartment-marker.css");
@import url("modules/temp-user.css")
@import url("modules/temp-user.css");
@import url("modules/cuts.css");

View File

@ -122,11 +122,17 @@
<span class="btn-icon"></span>
<span class="btn-text">Add Location Here</span>
</button>
<button id="fullscreen-btn" class="btn btn-secondary">
<span class="btn-icon"></span>
<span class="btn-text">Fullscreen</span>
</button>
<!-- Add cut selector with multi-select support -->
<div class="cut-selector-container">
<button id="cut-selector" class="cut-selector">
Select map overlays...
</button>
</div>
</div>
<!-- Mobile floating sidebar -->
@ -146,11 +152,24 @@
<button id="mobile-toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle Edmonton Data">
🏙️
</button>
<!-- Add mobile overlay button -->
<button id="mobile-overlay-btn" class="btn btn-secondary" title="Map Overlays">
🗺️
</button>
<button id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
</button>
</div>
<!-- Add cut legend -->
<div id="cut-legend" class="cut-legend">
<div id="cut-legend-content" class="cut-legend-content"></div>
</div>
</button>
</div>
<!-- Crosshair for location selection -->
<div id="crosshair" class="crosshair hidden">
<div class="crosshair-x"></div>
@ -388,6 +407,25 @@
</div>
</div>
<!-- Mobile overlay modal -->
<div id="mobile-overlay-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Map Overlays</h2>
<button class="modal-close" data-action="close-modal">×</button>
</div>
<div class="modal-body">
<div class="overlay-actions">
<button class="btn btn-primary btn-sm" data-action="show-all">Show All</button>
<button class="btn btn-secondary btn-sm" data-action="hide-all">Hide All</button>
</div>
<div id="mobile-overlay-list" class="mobile-overlay-list">
<!-- Will be populated dynamically -->
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading" class="loading-overlay">
<div class="spinner"></div>

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
checkAndLoadWalkSheetConfig();
} else if (hash === '#convert-data') {
showSection('convert-data');
} else if (hash === '#cuts') {
showSection('cuts');
} else {
// Default to dashboard
showSection('dashboard');
@ -479,6 +481,25 @@ function showSection(sectionId) {
}
}, 100);
}
// Special handling for cuts section
if (sectionId === 'cuts') {
// Initialize admin cuts manager when section is shown
setTimeout(() => {
if (typeof window.adminCutsManager === 'object' && window.adminCutsManager.initialize) {
if (!window.adminCutsManager.isInitialized) {
console.log('Initializing admin cuts manager from showSection...');
window.adminCutsManager.initialize().catch(error => {
console.error('Failed to initialize cuts manager:', error);
});
} else {
console.log('Admin cuts manager already initialized');
}
} else {
console.error('adminCutsManager not found in showSection');
}
}, 100);
}
}
// Update map from input fields

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,336 @@
/**
* Cut Drawing Module
* Handles polygon drawing functionality for creating map cuts
*/
export class CutDrawing {
constructor(map, options = {}) {
this.map = map;
this.vertices = [];
this.markers = [];
this.polyline = null;
this.previewPolygon = null; // Add preview polygon
this.isDrawing = false;
this.onComplete = options.onComplete || null;
}
/**
* Start drawing mode
*/
startDrawing(onFinish, onCancel) {
if (this.isDrawing) {
this.cancelDrawing();
}
this.isDrawing = true;
this.onFinishCallback = onFinish;
this.onCancelCallback = onCancel;
this.vertices = [];
this.markers = [];
// Change cursor and add click listener
this.map.getContainer().style.cursor = 'crosshair';
this.map.on('click', this.onMapClick.bind(this));
// Disable double-click zoom while drawing
this.map.doubleClickZoom.disable();
console.log('Cut drawing started - click to add points');
}
/**
* Handle map clicks to add vertices
*/
onMapClick(e) {
if (!this.isDrawing) return;
// Add vertex marker
const marker = L.marker(e.latlng, {
icon: L.divIcon({
className: 'cut-vertex-marker',
html: '<div class="vertex-point"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
}),
draggable: false
}).addTo(this.map);
this.vertices.push(e.latlng);
this.markers.push(marker);
// Update the polyline
this.updatePolyline();
// Call update callback if available
if (this.onUpdate) {
this.onUpdate();
}
console.log(`Added vertex ${this.vertices.length} at`, e.latlng);
}
/**
* Update the polyline connecting vertices
*/
updatePolyline() {
// Remove existing polyline
if (this.polyline) {
this.map.removeLayer(this.polyline);
}
if (this.vertices.length > 1) {
// Create polyline connecting all vertices
this.polyline = L.polyline(this.vertices, {
color: '#3388ff',
weight: 2,
dashArray: '5, 5',
opacity: 0.8
}).addTo(this.map);
}
}
/**
* Finish drawing and create polygon
*/
finishDrawing() {
if (this.vertices.length < 3) {
alert('A cut must have at least 3 points');
return;
}
// Create the polygon
const latlngs = this.vertices.map(v => v.getLatLng());
// Close the polygon
latlngs.push(latlngs[0]);
// Generate GeoJSON
const geojson = {
type: 'Polygon',
coordinates: [latlngs.map(ll => [ll.lng, ll.lat])]
};
// Calculate bounds
const bounds = {
north: Math.max(...latlngs.map(ll => ll.lat)),
south: Math.min(...latlngs.map(ll => ll.lat)),
east: Math.max(...latlngs.map(ll => ll.lng)),
west: Math.min(...latlngs.map(ll => ll.lng))
};
console.log('Cut drawing finished with', this.vertices.length, 'vertices');
// Show preview before clearing drawing
const color = document.getElementById('cut-color')?.value || '#3388ff';
const opacity = parseFloat(document.getElementById('cut-opacity')?.value) || 0.3;
this.showPreview(geojson, color, opacity);
// Clean up drawing elements
this.clearDrawing();
// Call completion callback with the data
if (this.onComplete && typeof this.onComplete === 'function') {
console.log('Calling completion callback with geojson and bounds');
this.onComplete(geojson, bounds);
} else {
console.error('No completion callback defined');
}
// Reset state
this.isDrawing = false;
this.updateToolbar();
}
/**
* Cancel drawing
*/
cancelDrawing() {
if (!this.isDrawing) return;
console.log('Cut drawing cancelled');
this.cleanup();
if (this.onCancelCallback) {
this.onCancelCallback();
}
}
/**
* Remove the last added vertex
*/
undoLastVertex() {
if (!this.isDrawing || this.vertices.length === 0) return;
// Remove last vertex and marker
this.vertices.pop();
const lastMarker = this.markers.pop();
if (lastMarker) {
this.map.removeLayer(lastMarker);
}
// Update polyline
this.updatePolyline();
// Call update callback if available
if (this.onUpdate) {
this.onUpdate();
}
console.log('Removed last vertex, remaining:', this.vertices.length);
}
/**
* Clear all vertices and start over
*/
clearVertices() {
if (!this.isDrawing) return;
// Remove all markers
this.markers.forEach(marker => {
this.map.removeLayer(marker);
});
// Remove polyline
if (this.polyline) {
this.map.removeLayer(this.polyline);
this.polyline = null;
}
// Reset arrays
this.vertices = [];
this.markers = [];
// Call update callback if available
if (this.onUpdate) {
this.onUpdate();
}
console.log('Cleared all vertices');
}
/**
* Cleanup drawing state
*/
cleanup() {
// Remove all markers
this.markers.forEach(marker => {
this.map.removeLayer(marker);
});
// Remove polyline
if (this.polyline) {
this.map.removeLayer(this.polyline);
}
// Reset cursor
this.map.getContainer().style.cursor = '';
// Remove event listeners
this.map.off('click', this.onMapClick);
// Re-enable double-click zoom
this.map.doubleClickZoom.enable();
// Reset state
this.isDrawing = false;
this.vertices = [];
this.markers = [];
this.polyline = null;
this.onFinishCallback = null;
this.onCancelCallback = null;
}
/**
* Get current drawing state
*/
getState() {
return {
isDrawing: this.isDrawing,
vertexCount: this.vertices.length,
canFinish: this.vertices.length >= 3
};
}
/**
* Preview polygon without finishing
*/
showPreview(geojson, color = '#3388ff', opacity = 0.3) {
this.clearPreview();
if (!geojson) return;
try {
const coordinates = geojson.coordinates[0];
const latlngs = coordinates.map(coord => L.latLng(coord[1], coord[0]));
this.previewPolygon = L.polygon(latlngs, {
color: color,
weight: 2,
opacity: 0.8,
fillColor: color,
fillOpacity: opacity,
className: 'cut-preview-polygon'
}).addTo(this.map);
// Add CSS class for opacity control
const pathElement = this.previewPolygon.getElement();
if (pathElement) {
pathElement.classList.add('cut-polygon');
console.log('Added cut-polygon class to preview polygon');
}
console.log('Preview polygon shown with opacity:', opacity);
} catch (error) {
console.error('Error showing preview polygon:', error);
}
}
/**
* Update preview polygon style without recreating it
*/
updatePreview(color = '#3388ff', opacity = 0.3) {
if (this.previewPolygon) {
this.previewPolygon.setStyle({
color: color,
weight: 2,
opacity: 0.8,
fillColor: color,
fillOpacity: opacity
});
// Ensure CSS class is still present
const pathElement = this.previewPolygon.getElement();
if (pathElement) {
pathElement.classList.add('cut-polygon');
}
console.log('Preview polygon style updated with opacity:', opacity);
}
}
clearPreview() {
if (this.previewPolygon) {
this.map.removeLayer(this.previewPolygon);
this.previewPolygon = null;
}
}
/**
* Update drawing style (called from admin cuts manager)
*/
updateDrawingStyle(color = '#3388ff', opacity = 0.3) {
// Update the polyline connecting vertices if it exists
if (this.polyline) {
this.polyline.setStyle({
color: color,
weight: 2,
opacity: 0.8
});
}
// Update preview polygon if it exists
this.updatePreview(color, opacity);
console.log('Cut drawing style updated with color:', color, 'opacity:', opacity);
}
}

View File

@ -0,0 +1,502 @@
/**
* Cut Manager Module
* Handles cut CRUD operations and display functionality
*/
import { showStatus } from './utils.js';
export class CutManager {
constructor() {
this.cuts = [];
this.currentCut = null;
this.currentCutLayer = null;
this.map = null;
this.isInitialized = false;
// Add support for multiple cuts
this.displayedCuts = new Map(); // Track multiple displayed cuts
this.cutLayers = new Map(); // Track cut layers by ID
}
/**
* Initialize the cut manager
*/
async initialize(map) {
this.map = map;
this.isInitialized = true;
// Load public cuts for display
await this.loadPublicCuts();
console.log('Cut manager initialized');
}
/**
* Load all cuts (admin) or public cuts (users)
*/
async loadCuts(adminMode = false) {
try {
const endpoint = adminMode ? '/api/cuts' : '/api/cuts/public';
const response = await fetch(endpoint, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Failed to load cuts: ${response.statusText}`);
}
const data = await response.json();
this.cuts = data.list || [];
console.log(`Loaded ${this.cuts.length} cuts`);
return this.cuts;
} catch (error) {
console.error('Error loading cuts:', error);
showStatus('Failed to load cuts', 'error');
return [];
}
}
/**
* Load public cuts for map display
*/
async loadPublicCuts() {
return await this.loadCuts(false);
}
/**
* Get single cut by ID
*/
async getCut(id) {
try {
const response = await fetch(`/api/cuts/${id}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Failed to load cut: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error loading cut:', error);
showStatus('Failed to load cut', 'error');
return null;
}
}
/**
* Create new cut
*/
async createCut(cutData) {
try {
const response = await fetch('/api/cuts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(cutData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to create cut: ${response.statusText}`);
}
const result = await response.json();
showStatus('Cut created successfully', 'success');
// Reload cuts
await this.loadCuts(true);
return result;
} catch (error) {
console.error('Error creating cut:', error);
showStatus(error.message, 'error');
return null;
}
}
/**
* Update existing cut
*/
async updateCut(id, cutData) {
try {
const response = await fetch(`/api/cuts/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(cutData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to update cut: ${response.statusText}`);
}
const result = await response.json();
showStatus('Cut updated successfully', 'success');
// Reload cuts
await this.loadCuts(true);
return result;
} catch (error) {
console.error('Error updating cut:', error);
showStatus(error.message, 'error');
return null;
}
}
/**
* Delete cut
*/
async deleteCut(id) {
try {
const response = await fetch(`/api/cuts/${id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to delete cut: ${response.statusText}`);
}
showStatus('Cut deleted successfully', 'success');
// If this was the currently displayed cut, hide it
if (this.currentCut && this.currentCut.id === id) {
this.hideCut();
}
// Reload cuts
await this.loadCuts(true);
return true;
} catch (error) {
console.error('Error deleting cut:', error);
showStatus(error.message, 'error');
return false;
}
}
/**
* Display a cut on the map (enhanced to support multiple cuts)
*/
/**
* Display a cut on the map (enhanced to support multiple cuts)
*/
displayCut(cutData, autoDisplayed = false) {
if (!this.map) {
console.error('Map not initialized');
return false;
}
// Normalize field names for consistent access
const normalizedCut = {
...cutData,
id: cutData.id || cutData.Id || cutData.ID,
name: cutData.name || cutData.Name,
description: cutData.description || cutData.Description,
color: cutData.color || cutData.Color,
opacity: cutData.opacity || cutData.Opacity,
category: cutData.category || cutData.Category,
geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
is_public: cutData.is_public || cutData['Public Visibility'],
is_official: cutData.is_official || cutData['Official Cut'],
autoDisplayed: autoDisplayed // Track if this was auto-displayed
};
// Check if already displayed
if (this.cutLayers.has(normalizedCut.id)) {
console.log(`Cut already displayed: ${normalizedCut.name}`);
return true;
}
if (!normalizedCut.geojson) {
console.error('Cut has no GeoJSON data');
return false;
}
try {
const geojsonData = typeof normalizedCut.geojson === 'string' ?
JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
const cutLayer = L.geoJSON(geojsonData, {
style: {
color: normalizedCut.color || '#3388ff',
fillColor: normalizedCut.color || '#3388ff',
fillOpacity: parseFloat(normalizedCut.opacity) || 0.3,
weight: 2,
opacity: 1,
className: 'cut-polygon'
}
});
// Add popup with cut info
cutLayer.bindPopup(`
<div class="cut-popup">
<h3>${normalizedCut.name}</h3>
${normalizedCut.description ? `<p>${normalizedCut.description}</p>` : ''}
${normalizedCut.category ? `<p><strong>Category:</strong> ${normalizedCut.category}</p>` : ''}
${normalizedCut.is_official ? '<span class="badge official">Official Cut</span>' : ''}
</div>
`);
cutLayer.addTo(this.map);
// Store in both tracking systems
this.cutLayers.set(normalizedCut.id, cutLayer);
this.displayedCuts.set(normalizedCut.id, normalizedCut);
// Update current cut reference (for legacy compatibility)
this.currentCut = normalizedCut;
this.currentCutLayer = cutLayer;
console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id})`);
return true;
} catch (error) {
console.error('Error displaying cut:', error);
return false;
}
}
/**
* Hide the currently displayed cut (legacy method - now hides all cuts)
*/
hideCut() {
this.hideAllCuts();
}
/**
* Hide specific cut by ID
*/
hideCutById(cutId) {
// Try different ID formats to handle type mismatches
let layer = this.cutLayers.get(cutId);
let actualKey = cutId;
if (!layer) {
// Try as string
const stringId = String(cutId);
layer = this.cutLayers.get(stringId);
if (layer) actualKey = stringId;
}
if (!layer) {
// Try as number
const numberId = Number(cutId);
if (!isNaN(numberId)) {
layer = this.cutLayers.get(numberId);
if (layer) actualKey = numberId;
}
}
if (layer && this.map) {
this.map.removeLayer(layer);
this.cutLayers.delete(actualKey);
this.displayedCuts.delete(actualKey);
console.log(`Successfully hidden cut ID: ${actualKey} (original: ${cutId})`);
return true;
}
console.warn(`Failed to hide cut ID: ${cutId} - not found in layers`);
return false;
}
/**
* Hide all displayed cuts
*/
hideAllCuts() {
// Hide all cuts using the new system
Array.from(this.cutLayers.keys()).forEach(cutId => {
this.hideCutById(cutId);
});
// Legacy cleanup
if (this.currentCutLayer && this.map) {
this.map.removeLayer(this.currentCutLayer);
this.currentCutLayer = null;
this.currentCut = null;
}
console.log('All cuts hidden');
}
/**
* Toggle cut visibility
*/
toggleCut(cutData) {
if (this.currentCut && this.currentCut.id === cutData.id) {
this.hideCut();
return false; // Hidden
} else {
this.displayCut(cutData);
return true; // Shown
}
}
/**
* Get currently displayed cut
*/
getCurrentCut() {
return this.currentCut;
}
/**
* Check if a cut is currently displayed
*/
isCutDisplayed(cutId) {
// Try different ID types to handle string/number mismatches
const hasInMap = this.displayedCuts.has(cutId);
const hasInMapAsString = this.displayedCuts.has(String(cutId));
const hasInMapAsNumber = this.displayedCuts.has(Number(cutId));
const currentCutMatch = this.currentCut && this.currentCut.id === cutId;
return hasInMap || hasInMapAsString || hasInMapAsNumber || currentCutMatch;
}
/**
* Get all displayed cuts
*/
getDisplayedCuts() {
return Array.from(this.displayedCuts.values());
}
/**
* Get all available cuts
*/
getCuts() {
return this.cuts;
}
/**
* Get cuts by category
*/
getCutsByCategory(category) {
return this.cuts.filter(cut => {
const cutCategory = cut.category || cut.Category || 'Other';
return cutCategory === category;
});
}
/**
* Search cuts by name
*/
searchCuts(query) {
if (!query) return this.cuts;
const searchTerm = query.toLowerCase();
return this.cuts.filter(cut => {
// Handle different possible field names
const name = cut.name || cut.Name || '';
const description = cut.description || cut.Description || '';
return name.toLowerCase().includes(searchTerm) ||
description.toLowerCase().includes(searchTerm);
});
}
/**
* Export cuts as JSON
*/
exportCuts(cutsToExport = null) {
const cuts = cutsToExport || this.cuts;
const exportData = {
version: '1.0',
timestamp: new Date().toISOString(),
cuts: cuts.map(cut => ({
name: cut.name,
description: cut.description,
color: cut.color,
opacity: cut.opacity,
category: cut.category,
is_official: cut.is_official,
geojson: cut.geojson,
bounds: cut.bounds
}))
};
return JSON.stringify(exportData, null, 2);
}
/**
* Validate cut data for import
*/
validateCutData(cutData) {
const errors = [];
if (!cutData.name || typeof cutData.name !== 'string') {
errors.push('Name is required and must be a string');
}
if (!cutData.geojson) {
errors.push('GeoJSON data is required');
} else {
try {
const geojson = JSON.parse(cutData.geojson);
if (!geojson.type || !['Polygon', 'MultiPolygon'].includes(geojson.type)) {
errors.push('GeoJSON must be a Polygon or MultiPolygon');
}
} catch (e) {
errors.push('Invalid GeoJSON format');
}
}
if (cutData.opacity !== undefined) {
const opacity = parseFloat(cutData.opacity);
if (isNaN(opacity) || opacity < 0 || opacity > 1) {
errors.push('Opacity must be a number between 0 and 1');
}
}
return errors;
}
/**
* Get cut statistics
*/
getStatistics() {
const stats = {
total: this.cuts.length,
public: this.cuts.filter(cut => {
const isPublic = cut.is_public || cut['Public Visibility'];
return isPublic === true || isPublic === 1 || isPublic === '1';
}).length,
private: this.cuts.filter(cut => {
const isPublic = cut.is_public || cut['Public Visibility'];
return !(isPublic === true || isPublic === 1 || isPublic === '1');
}).length,
official: this.cuts.filter(cut => {
const isOfficial = cut.is_official || cut['Official Cut'];
return isOfficial === true || isOfficial === 1 || isOfficial === '1';
}).length,
byCategory: {}
};
// Count by category
this.cuts.forEach(cut => {
const category = cut.category || cut.Category || 'Uncategorized';
stats.byCategory[category] = (stats.byCategory[category] || 0) + 1;
});
return stats;
}
/**
* Hide all displayed cuts
*/
/**
* Get displayed cut data by ID
*/
getDisplayedCut(cutId) {
return this.displayedCuts.get(cutId);
}
}
// Create global instance
export const cutManager = new CutManager();

View File

@ -545,29 +545,67 @@ export async function handleDeleteLocation() {
export function closeAddModal() {
const modal = document.getElementById('add-modal');
modal.classList.add('hidden');
document.getElementById('location-form').reset();
if (modal) {
modal.classList.add('hidden');
}
// Try to find and reset the form with multiple possible IDs
const form = document.getElementById('location-form') ||
document.getElementById('add-location-form');
if (form) {
form.reset();
}
}
export function openAddModal(lat, lng, performLookup = true) {
const modal = document.getElementById('add-modal');
const latInput = document.getElementById('location-lat');
const lngInput = document.getElementById('location-lng');
const geoInput = document.getElementById('geo-location');
if (!modal) {
console.error('Add modal not found');
return;
}
// Try multiple possible field IDs for coordinates
const latInput = document.getElementById('location-lat') ||
document.getElementById('add-latitude') ||
document.getElementById('latitude');
const lngInput = document.getElementById('location-lng') ||
document.getElementById('add-longitude') ||
document.getElementById('longitude');
const geoInput = document.getElementById('geo-location') ||
document.getElementById('add-geo-location') ||
document.getElementById('Geo-Location');
// Reset address confirmation state
resetAddressConfirmation('add');
// Set coordinates
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
// Set coordinates if input fields exist
if (latInput && lngInput) {
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
}
// Clear other fields
document.getElementById('location-form').reset();
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
if (geoInput) {
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
}
// Try to find and reset the form
const form = document.getElementById('location-form') ||
document.getElementById('add-location-form');
if (form) {
// Clear other fields but preserve coordinates
const tempLat = lat.toFixed(8);
const tempLng = lng.toFixed(8);
const tempGeo = `${tempLat};${tempLng}`;
form.reset();
// Restore coordinates after reset
if (latInput) latInput.value = tempLat;
if (lngInput) lngInput.value = tempLng;
if (geoInput) geoInput.value = tempGeo;
}
// Show modal
modal.classList.remove('hidden');

View File

@ -2,10 +2,12 @@
import { CONFIG, loadDomainConfig } from './config.js';
import { hideLoading, showStatus, setViewportDimensions } from './utils.js';
import { checkAuth } from './auth.js';
import { initializeMap } from './map-manager.js';
import { initializeMap, getMap } from './map-manager.js';
import { loadLocations } from './location-manager.js';
import { setupEventListeners } from './ui-controls.js';
import { UnifiedSearchManager } from './search-manager.js';
import { cutManager } from './cut-manager.js';
import { initializeCutControls } from './cut-controls.js';
// Application state
let refreshInterval = null;
@ -36,6 +38,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Then initialize the map
await initializeMap();
// Initialize cut manager after map is ready
await cutManager.initialize(getMap());
// Initialize cut controls for public map
await initializeCutControls();
// Only load locations after map is ready
await loadLocations();

View File

@ -7,6 +7,11 @@ export let map = null;
export let startLocationMarker = null;
export let isStartLocationVisible = true;
// Function to get the map instance
export function getMap() {
return map;
}
export async function initializeMap() {
try {
// Get start location from PUBLIC endpoint (not admin endpoint)

View File

@ -98,7 +98,7 @@ export class MapSearch {
*/
selectResult(result) {
if (!map) {
console.error('Map not available');
console.error('Map not initialized');
return;
}
@ -107,7 +107,7 @@ export class MapSearch {
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
if (isNaN(lat) || isNaN(lng)) {
console.error('Invalid coordinates in result:', result);
console.error('Invalid coordinates:', result);
return;
}
@ -121,34 +121,37 @@ export class MapSearch {
this.tempMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'temp-search-marker',
html: '📍',
html: '<div class="marker-pin"></div>',
iconSize: [30, 30],
iconAnchor: [15, 30]
})
}).addTo(map);
// Create popup with add location option
const popupContent = `
<div class="search-result-popup">
<h3>${result.formattedAddress || 'Search Result'}</h3>
<p>${result.fullAddress || ''}</p>
<div class="popup-actions">
<button class="btn btn-success btn-sm" onclick="mapSearchInstance.openAddLocationModal(${lat}, ${lng})">
Add Location Here
</button>
<button class="btn btn-secondary btn-sm" onclick="mapSearchInstance.clearTempMarker()">
Clear
</button>
</div>
</div>
// Create popup content without inline handlers
const popupContent = document.createElement('div');
popupContent.className = 'search-result-popup';
popupContent.innerHTML = `
<h3>${result.formattedAddress || 'Search Result'}</h3>
<p>${result.fullAddress || ''}</p>
<button class="btn btn-primary search-add-location-btn" data-lat="${lat}" data-lng="${lng}">
Add Location Here
</button>
`;
// Bind the popup
this.tempMarker.bindPopup(popupContent).openPopup();
// Auto-clear the marker after 30 seconds
// Add event listener after popup is opened
setTimeout(() => {
this.clearTempMarker();
}, 30000);
const addBtn = document.querySelector('.search-add-location-btn');
if (addBtn) {
addBtn.addEventListener('click', (e) => {
const btnLat = parseFloat(e.target.dataset.lat);
const btnLng = parseFloat(e.target.dataset.lng);
this.openAddLocationModal(btnLat, btnLng);
});
}
}, 100);
}
/**

View File

@ -492,6 +492,17 @@ export function setupEventListeners() {
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
document.getElementById('mobile-overlay-btn')?.addEventListener('click', () => {
console.log('Mobile overlay button clicked!');
// Call the global function to open mobile overlay modal
if (window.openMobileOverlayModal) {
console.log('openMobileOverlayModal function found - calling it');
window.openMobileOverlayModal();
} else {
console.error('openMobileOverlayModal function not available');
console.log('Available window functions:', Object.keys(window).filter(k => k.includes('overlay') || k.includes('Modal')));
}
});
document.getElementById('mobile-toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);

30
map/app/routes/cuts.js Normal file
View File

@ -0,0 +1,30 @@
const express = require('express');
const router = express.Router();
const cutsController = require('../controllers/cutsController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const config = require('../config');
// Add middleware to check if cuts table is configured
const checkCutsTable = (req, res, next) => {
if (!config.nocodb.cutsSheetId) {
console.warn('Cuts table not configured - NOCODB_CUTS_SHEET not set');
// Continue anyway, controller will handle it
}
next();
};
// Apply the check to all routes
router.use(checkCutsTable);
// Get all cuts (filtered by permissions)
router.get('/', cutsController.getAll);
// Get single cut by ID
router.get('/:id', cutsController.getById);
// Admin only routes
router.post('/', requireAdmin, cutsController.create);
router.put('/:id', requireAdmin, cutsController.update);
router.delete('/:id', requireAdmin, cutsController.delete);
module.exports = router;

View File

@ -13,6 +13,7 @@ const debugRoutes = require('./debug');
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
const shiftsRoutes = require('./shifts');
const externalDataRoutes = require('./external');
const cutsRoutes = require('./cuts');
module.exports = (app) => {
// Health check (no auth)
@ -44,9 +45,12 @@ module.exports = (app) => {
// QR code routes (authenticated)
app.use('/api/qr', requireAuth, qrRoutes);
// Public cuts endpoint (no auth required)
app.get('/api/cuts/public', require('../controllers/cutsController').getPublic);
// Test QR page (no auth for testing)
app.get('/test-qr', (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'test-qr.html'));
res.sendFile(path.join(__dirname, '../public/test-qr.html'));
});
// Protected routes
@ -56,6 +60,9 @@ module.exports = (app) => {
app.use('/api/shifts', requireNonTemp, shiftsRoutes);
app.use('/api/external', externalDataRoutes);
// Cuts routes (add after other protected routes)
app.use('/api/cuts', requireAuth, cutsRoutes);
// Admin routes
app.get('/admin.html', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
@ -66,6 +73,28 @@ module.exports = (app) => {
// Debug routes (admin only)
app.use('/api/debug', requireAdmin, debugRoutes);
// Debug cuts endpoint to see raw field names
app.get('/api/debug/cuts-raw', requireAdmin, async (req, res) => {
try {
const config = require('../config');
const nocodbService = require('../services/nocodb');
if (!config.nocodb.cutsSheetId) {
return res.json({ error: 'Cuts table not configured' });
}
const response = await nocodbService.getAll(config.nocodb.cutsSheetId);
res.json({
totalCuts: response?.list?.length || 0,
sampleCut: response?.list?.[0] || null,
allFields: response?.list?.[0] ? Object.keys(response.list[0]) : []
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Config check endpoint (authenticated)
app.get('/api/config-check', requireAuth, (req, res) => {
const config = require('../config');
@ -77,10 +106,12 @@ module.exports = (app) => {
hasTableId: !!config.nocodb.tableId,
hasLoginSheet: !!config.nocodb.loginSheetId,
hasSettingsSheet: !!config.nocodb.settingsSheetId,
hasCutsSheet: !!config.nocodb.cutsSheetId,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheet: config.nocodb.loginSheetId,
settingsSheet: config.nocodb.settingsSheetId,
cutsSheet: config.nocodb.cutsSheetId,
nodeEnv: config.nodeEnv
};

View File

@ -1,3 +1,9 @@
// Prevent duplicate execution
if (require.main !== module) {
console.log('Server.js being imported, not executed directly');
return;
}
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@ -8,7 +14,7 @@ const fetch = require('node-fetch');
// Debug: Check if server.js is being loaded multiple times
const serverInstanceId = Math.random().toString(36).substr(2, 9);
console.log(`[DEBUG] Server.js instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
console.log(`[DEBUG] Server.js PID:${process.pid} instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
// Import configuration and utilities
const config = require('./config');
@ -180,6 +186,7 @@ const server = app.listen(config.port, () => {
Project ID: ${config.nocodb.projectId}
Table ID: ${config.nocodb.tableId}
Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'}
PID: ${process.pid}
Time: ${new Date().toISOString()}
`);

View File

@ -4,10 +4,13 @@
# This script automatically creates the necessary base and tables for the BNKops Map Viewer application using NocoDB.
# Based on requirements from README.md and using proper NocoDB column types
#
# Creates three tables:
# Creates six tables:
# 1. locations - Main table with GeoData, proper field types per README.md
# 2. login - Simple authentication table with Email, Name, Admin fields
# 3. settings - Configuration table with text fields only (no QR image storage)
# 4. shifts - Table for volunteer shift scheduling
# 5. shift_signups - Table for tracking signups to shifts
# 6. cuts - Table for storing polygon overlays for the map
#
# Updated: July 2025 - Always creates a new base, does not touch existing data
@ -550,6 +553,7 @@ create_settings_table() {
create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields"
}
# Function to create the shifts table
create_shifts_table() {
local base_id=$1
@ -713,6 +717,115 @@ create_shift_signups_table() {
create_table "$base_id" "shift_signups" "$table_data" "shift signups table"
}
# Function to create the cuts table
create_cuts_table() {
local base_id=$1
local table_data='{
"table_name": "cuts",
"title": "Cuts",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "name",
"title": "Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "description",
"title": "Description",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "color",
"title": "Color",
"uidt": "SingleLineText",
"rqd": true,
"cdf": "#3388ff"
},
{
"column_name": "opacity",
"title": "Opacity",
"uidt": "Decimal",
"rqd": true,
"cdf": "0.3",
"meta": {
"precision": 3,
"scale": 2
}
},
{
"column_name": "category",
"title": "Category",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "Custom", "color": "#2196F3"},
{"title": "Ward", "color": "#4CAF50"},
{"title": "Neighborhood", "color": "#FF9800"},
{"title": "District", "color": "#9C27B0"}
]
}
},
{
"column_name": "is_public",
"title": "Public Visibility",
"uidt": "Checkbox",
"rqd": false,
"cdf": false
},
{
"column_name": "is_official",
"title": "Official Cut",
"uidt": "Checkbox",
"rqd": false,
"cdf": false
},
{
"column_name": "geojson",
"title": "GeoJSON Data",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "bounds",
"title": "Bounds",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "created_by",
"title": "Created By",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "updated_at",
"title": "Updated At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "cuts" "$table_data" "Polygon cuts for map overlays"
}
# Function to create default admin user
create_default_admin() {
@ -767,6 +880,76 @@ create_default_start_location() {
make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default settings row" "v2"
}
# Function to create sample cuts data
create_default_cuts() {
local base_id=$1
local cuts_table_id=$2
print_status "Creating sample cuts data..."
# Sample cut 1: Downtown Area (Public)
local cut1_geojson='{"type":"Polygon","coordinates":[[[-113.52,53.54],[-113.48,53.54],[-113.48,53.56],[-113.52,53.56],[-113.52,53.54]]]}'
local cut1_bounds='{"north":53.56,"south":53.54,"east":-113.48,"west":-113.52}'
local cut1_data='{
"name": "Downtown Core",
"description": "Main downtown business district area for canvassing",
"color": "#e74c3c",
"opacity": 0.4,
"category": "District",
"is_public": 1,
"is_official": 1,
"geojson": "'"$cut1_geojson"'",
"bounds": "'"$cut1_bounds"'",
"created_by": "system",
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
}'
make_api_call "POST" "/tables/$cuts_table_id/records" "$cut1_data" "Creating sample cut 1" "v2"
# Sample cut 2: Residential Area (Public)
local cut2_geojson='{"type":"Polygon","coordinates":[[[-113.55,53.50],[-113.50,53.50],[-113.50,53.53],[-113.55,53.53],[-113.55,53.50]]]}'
local cut2_bounds='{"north":53.53,"south":53.50,"east":-113.50,"west":-113.55}'
local cut2_data='{
"name": "River Valley Neighborhoods",
"description": "Residential area near the river valley",
"color": "#3498db",
"opacity": 0.3,
"category": "Neighborhood",
"is_public": 1,
"is_official": 0,
"geojson": "'"$cut2_geojson"'",
"bounds": "'"$cut2_bounds"'",
"created_by": "system",
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
}'
make_api_call "POST" "/tables/$cuts_table_id/records" "$cut2_data" "Creating sample cut 2" "v2"
# Sample cut 3: Private Admin Cut (Not Public)
local cut3_geojson='{"type":"Polygon","coordinates":[[[-113.45,53.57],[-113.40,53.57],[-113.40,53.60],[-113.45,53.60],[-113.45,53.57]]]}'
local cut3_bounds='{"north":53.60,"south":53.57,"east":-113.40,"west":-113.45}'
local cut3_data='{
"name": "Admin Only Area",
"description": "Private administrative boundary for internal use",
"color": "#9b59b6",
"opacity": 0.5,
"category": "Custom",
"is_public": 0,
"is_official": 0,
"geojson": "'"$cut3_geojson"'",
"bounds": "'"$cut3_bounds"'",
"created_by": "system",
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
}'
make_api_call "POST" "/tables/$cuts_table_id/records" "$cut3_data" "Creating sample cut 3" "v2"
print_success "Created 3 sample cuts (2 public, 1 private)"
}
# Main execution
main() {
print_status "Starting NocoDB Auto-Setup..."
@ -802,6 +985,9 @@ main() {
# Create shift signups table
SHIFT_SIGNUPS_TABLE_ID=$(create_shift_signups_table "$BASE_ID")
# Create cuts table
CUTS_TABLE_ID=$(create_cuts_table "$BASE_ID")
# Wait a moment for tables to be fully created
sleep 3
@ -814,6 +1000,9 @@ main() {
# Create default settings row (includes both start location and walk sheet config)
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
# Create sample cuts data for testing
create_default_cuts "$BASE_ID" "$CUTS_TABLE_ID"
print_status "================================"
print_success "NocoDB Auto-Setup completed successfully!"
print_status "================================"
@ -829,6 +1018,7 @@ main() {
print_status " - NOCODB_SETTINGS_SHEET (for settings table)"
print_status " - NOCODB_SHIFTS_SHEET (for shifts table)"
print_status " - NOCODB_SHIFT_SIGNUPS_SHEET (for shift signups table)"
print_status " - NOCODB_CUTS_SHEET (for cuts table)"
print_status "4. The default admin user is: admin@thebunkerops.ca with password: admin123"
print_status "5. IMPORTANT: Change the default password after first login!"
print_status "6. Start adding your location data!"
@ -837,11 +1027,6 @@ main() {
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Please update your .env file with the new table URLs from the newly created base."
print_warning "SECURITY: Change the default admin password immediately after first login!"
print_warning ""
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Please update your .env file with the new table URLs from the newly created base."
}
# Check if script is being run directly

View File

View File

View File

@ -64,6 +64,10 @@ Controller for aggregating and calculating dashboard statistics from locations a
Controller for user management (list, create, delete users, send login details via email).
# app/controllers/cutsController.js
Controller for CRUD operations on map cuts (geographic polygon overlays). Handles cut creation, editing, deletion, and visibility management with admin-only access for modifications and public access for viewing public cuts.
# app/middleware/auth.js
Express middleware for authentication and admin access control.
@ -148,6 +152,10 @@ Contains base styles, including CSS variables for theming, resets, and default b
Defines styles for all button types, states (hover, disabled), and variants (primary, danger, etc.).
# app/public/css/modules/cuts.css
Styles for the cut feature including drawing controls, polygon overlays, vertex markers, cut management interface, and responsive design for both admin and public views.
# app/public/css/modules/cache-busting.css
Styles for the cache busting update notification that prompts users to refresh the page.
@ -300,6 +308,18 @@ Utility functions for the frontend (escaping HTML, parsing geolocation, etc).
Frontend JavaScript for the Convert Data admin section. Handles file upload UI, drag-and-drop, real-time progress updates, visual representation of geocoding results on a map, and saving successful results to the database.
# app/public/js/cut-drawing.js
JavaScript module for interactive polygon drawing functionality. Implements click-to-add-points drawing system for creating cut boundaries on the map using Leaflet.js drawing tools.
# app/public/js/cut-controls.js
JavaScript module for cut display controls on the public map. Handles loading and rendering of public cuts as polygon overlays for authenticated users.
# app/public/js/admin-cuts.js
JavaScript for the admin cut management interface. Provides complete CRUD functionality for cuts including interactive drawing, form management, cut list display, and import/export capabilities.
# app/routes/admin.js
Express router for admin-only endpoints (start location, walk sheet config).
@ -344,6 +364,10 @@ Express router for volunteer shift management endpoints (public and admin).
Express router for user management endpoints (list, create, delete users).
# app/routes/cuts.js
Express router for cut management endpoints. Provides CRUD operations for geographic polygon overlays with admin-only access for modifications and public read access for viewing public cuts.
# app/routes/dataConvert.js
Express routes for data conversion features. Handles CSV file upload with multer middleware and provides endpoints for processing CSV files and saving geocoded results to the database.

View File