Pushing Cuts to repo. Still bugs however decently stable.
This commit is contained in:
parent
423e561ea3
commit
f4327c3c40
189
map/CUT_IMPLEMENTATION_SUMMARY.md
Normal file
189
map/CUT_IMPLEMENTATION_SUMMARY.md
Normal 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!
|
||||||
159
map/CUT_PUBLIC_IMPLEMENTATION.md
Normal file
159
map/CUT_PUBLIC_IMPLEMENTATION.md
Normal 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
|
||||||
85
map/CUT_SIMPLIFICATION_SUMMARY.md
Normal file
85
map/CUT_SIMPLIFICATION_SUMMARY.md
Normal 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)
|
||||||
@ -4,10 +4,10 @@ Welcome to the Map project! This application is a canvassing tool for political
|
|||||||
|
|
||||||
## Project Overview
|
## 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).
|
- **Backend:** Node.js/Express, with NocoDB as the database (REST API).
|
||||||
- **Frontend:** Vanilla JS, Leaflet.js for mapping, modular code in `/public/js`.
|
- **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
|
## 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 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.
|
- **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.
|
- **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
|
## Contact
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,9 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
- 🔐 Role-based access control (Admin vs User permissions)
|
- 🔐 Role-based access control (Admin vs User permissions)
|
||||||
- 📧 Email notifications and password recovery via SMTP
|
- 📧 Email notifications and password recovery via SMTP
|
||||||
- 📊 CSV data import with batch geocoding and visual progress tracking
|
- 📊 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
|
- 🐳 Docker containerization for easy deployment
|
||||||
- 🆓 100% open source (no proprietary dependencies)
|
- 🆓 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
|
./build-nocodb.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates five tables:
|
This creates six tables:
|
||||||
- **Locations** - Main map data with geo-location, contact info, support levels
|
- **Locations** - Main map data with geo-location, contact info, support levels
|
||||||
- **Login** - User authentication (email, name, admin flag)
|
- **Login** - User authentication (email, name, admin flag)
|
||||||
- **Settings** - Admin configuration and QR codes
|
- **Settings** - Admin configuration and QR codes
|
||||||
- **Shifts** - Shift scheduling and management
|
- **Shifts** - Shift scheduling and management
|
||||||
- **Shift Signups** - User shift registrations
|
- **Shift Signups** - User shift registrations
|
||||||
|
- **Cuts** - Geographic polygon overlays for map regions
|
||||||
|
|
||||||
4. **Get Table URLs**
|
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_SETTINGS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mix06f2mlep7gqb
|
||||||
NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u
|
NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u
|
||||||
NOCODB_SHIFT_SIGNUPS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mi8jg1tn26mu8fj
|
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**
|
6. **Build and Deploy**
|
||||||
@ -228,6 +233,23 @@ The build script automatically creates the following table structure:
|
|||||||
- `Signup Date` (DateTime): When user signed up
|
- `Signup Date` (DateTime): When user signed up
|
||||||
- `Status` (Single Select): Options: "Confirmed" (Green), "Cancelled" (Red)
|
- `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
|
## API Endpoints
|
||||||
|
|
||||||
### Public 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
|
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
|
||||||
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
|
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
|
||||||
|
|
||||||
|
### 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)
|
### Geocoding Endpoints (requires authentication)
|
||||||
|
|
||||||
- `GET /api/geocode/reverse?lat=<lat>&lng=<lng>` - Reverse geocode coordinates to address
|
- `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.
|
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
|
## 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.
|
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)
|
- **Delete Users**: Remove user accounts (with confirmation prompts)
|
||||||
- **Security**: Password validation and admin-only access
|
- **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
|
#### Convert Data
|
||||||
|
|
||||||
- **CSV Upload**: Upload CSV files containing addresses for bulk import
|
- **CSV Upload**: Upload CSV files containing addresses for bulk import
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Build stage
|
FROM node:18-alpine
|
||||||
FROM node:18-alpine AS builder
|
|
||||||
|
# Install wget and dumb-init for proper signal handling
|
||||||
|
RUN apk add --no-cache wget dumb-init
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -9,19 +11,7 @@ COPY package*.json ./
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
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 application files
|
||||||
COPY package*.json ./
|
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY routes ./routes
|
COPY routes ./routes
|
||||||
@ -43,4 +33,6 @@ USER nodejs
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Use dumb-init to handle signals properly and prevent zombie processes
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@ -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 = {
|
module.exports = {
|
||||||
// Server config
|
// Server config
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
@ -91,7 +102,8 @@ module.exports = {
|
|||||||
settingsSheetId,
|
settingsSheetId,
|
||||||
viewUrl: process.env.NOCODB_VIEW_URL,
|
viewUrl: process.env.NOCODB_VIEW_URL,
|
||||||
shiftsSheetId,
|
shiftsSheetId,
|
||||||
shiftSignupsSheetId
|
shiftSignupsSheetId,
|
||||||
|
cutsSheetId
|
||||||
},
|
},
|
||||||
|
|
||||||
// Session config
|
// Session config
|
||||||
@ -126,5 +138,14 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Utility functions
|
// 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
|
||||||
};
|
};
|
||||||
363
map/app/controllers/cutsController.js
Normal file
363
map/app/controllers/cutsController.js
Normal 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();
|
||||||
@ -54,6 +54,14 @@ const requireAuth = async (req, res, next) => {
|
|||||||
return; // Response already sent by checkTempUserExpiration
|
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();
|
next();
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Unauthorized access attempt', {
|
logger.warn('Unauthorized access attempt', {
|
||||||
@ -85,6 +93,14 @@ const requireAdmin = async (req, res, next) => {
|
|||||||
return; // Response already sent by checkTempUserExpiration
|
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();
|
next();
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Unauthorized admin access attempt', {
|
logger.warn('Unauthorized admin access attempt', {
|
||||||
@ -116,6 +132,14 @@ const requireNonTemp = async (req, res, next) => {
|
|||||||
return; // Response already sent by checkTempUserExpiration
|
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();
|
next();
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Temp user access denied', {
|
logger.warn('Temp user access denied', {
|
||||||
|
|||||||
@ -67,6 +67,10 @@
|
|||||||
<span class="nav-icon">👥</span>
|
<span class="nav-icon">👥</span>
|
||||||
<span class="nav-text">Users</span>
|
<span class="nav-text">Users</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#cuts">
|
||||||
|
<span class="nav-icon">✂️</span>
|
||||||
|
<span class="nav-text">Map Cuts</span>
|
||||||
|
</a>
|
||||||
<a href="#convert-data">
|
<a href="#convert-data">
|
||||||
<span class="nav-icon">📊</span>
|
<span class="nav-icon">📊</span>
|
||||||
<span class="nav-text">Convert Data</span>
|
<span class="nav-text">Convert Data</span>
|
||||||
@ -455,6 +459,133 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Convert Data Section -->
|
||||||
<section id="convert-data" class="admin-section" style="display: none;">
|
<section id="convert-data" class="admin-section" style="display: none;">
|
||||||
<h2>Convert Data</h2>
|
<h2>Convert Data</h2>
|
||||||
@ -713,6 +844,9 @@
|
|||||||
<!-- Dashboard JavaScript -->
|
<!-- Dashboard JavaScript -->
|
||||||
<script src="js/dashboard.js"></script>
|
<script src="js/dashboard.js"></script>
|
||||||
|
|
||||||
|
<!-- Admin Cuts JavaScript -->
|
||||||
|
<script src="js/admin-cuts.js"></script>
|
||||||
|
|
||||||
<!-- Data Convert JavaScript -->
|
<!-- Data Convert JavaScript -->
|
||||||
<!-- Admin JavaScript -->
|
<!-- Admin JavaScript -->
|
||||||
<script src="js/admin.js"></script>
|
<script src="js/admin.js"></script>
|
||||||
|
|||||||
@ -2395,3 +2395,147 @@
|
|||||||
padding: 16px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
996
map/app/public/css/modules/cuts.css
Normal file
996
map/app/public/css/modules/cuts.css
Normal 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;
|
||||||
|
}
|
||||||
@ -96,14 +96,20 @@ path.leaflet-interactive {
|
|||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override any conflicting styles */
|
/* Override any conflicting styles - but allow cuts to manage their own opacity */
|
||||||
.leaflet-container path.leaflet-interactive {
|
.leaflet-container path.leaflet-interactive:not(.cut-polygon) {
|
||||||
stroke: #ffffff !important;
|
stroke: #ffffff !important;
|
||||||
stroke-opacity: 1 !important;
|
stroke-opacity: 1 !important;
|
||||||
stroke-width: 2px !important;
|
stroke-width: 2px !important;
|
||||||
fill-opacity: 0.8 !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 */
|
/* Marker being moved */
|
||||||
.location-marker.leaflet-drag-target {
|
.location-marker.leaflet-drag-target {
|
||||||
cursor: move !important;
|
cursor: move !important;
|
||||||
|
|||||||
@ -70,3 +70,269 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -152,3 +152,97 @@
|
|||||||
.mobile-sidebar .btn:active {
|
.mobile-sidebar .btn:active {
|
||||||
transform: scale(0.95);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -16,4 +16,5 @@
|
|||||||
@import url("modules/cache-busting.css");
|
@import url("modules/cache-busting.css");
|
||||||
@import url("modules/apartment-popup.css");
|
@import url("modules/apartment-popup.css");
|
||||||
@import url("modules/apartment-marker.css");
|
@import url("modules/apartment-marker.css");
|
||||||
@import url("modules/temp-user.css")
|
@import url("modules/temp-user.css");
|
||||||
|
@import url("modules/cuts.css");
|
||||||
|
|||||||
@ -122,11 +122,17 @@
|
|||||||
<span class="btn-icon">➕</span>
|
<span class="btn-icon">➕</span>
|
||||||
<span class="btn-text">Add Location Here</span>
|
<span class="btn-text">Add Location Here</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button id="fullscreen-btn" class="btn btn-secondary">
|
<button id="fullscreen-btn" class="btn btn-secondary">
|
||||||
<span class="btn-icon">⛶</span>
|
<span class="btn-icon">⛶</span>
|
||||||
<span class="btn-text">Fullscreen</span>
|
<span class="btn-text">Fullscreen</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile floating sidebar -->
|
<!-- Mobile floating sidebar -->
|
||||||
@ -146,11 +152,24 @@
|
|||||||
<button id="mobile-toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle Edmonton Data">
|
<button id="mobile-toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle Edmonton Data">
|
||||||
🏙️
|
🏙️
|
||||||
</button>
|
</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 id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
|
||||||
⛶
|
⛶
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Crosshair for location selection -->
|
||||||
<div id="crosshair" class="crosshair hidden">
|
<div id="crosshair" class="crosshair hidden">
|
||||||
<div class="crosshair-x"></div>
|
<div class="crosshair-x"></div>
|
||||||
@ -388,6 +407,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Loading Overlay -->
|
||||||
<div id="loading" class="loading-overlay">
|
<div id="loading" class="loading-overlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|||||||
1990
map/app/public/js/admin-cuts.js
Normal file
1990
map/app/public/js/admin-cuts.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -55,6 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
checkAndLoadWalkSheetConfig();
|
checkAndLoadWalkSheetConfig();
|
||||||
} else if (hash === '#convert-data') {
|
} else if (hash === '#convert-data') {
|
||||||
showSection('convert-data');
|
showSection('convert-data');
|
||||||
|
} else if (hash === '#cuts') {
|
||||||
|
showSection('cuts');
|
||||||
} else {
|
} else {
|
||||||
// Default to dashboard
|
// Default to dashboard
|
||||||
showSection('dashboard');
|
showSection('dashboard');
|
||||||
@ -479,6 +481,25 @@ function showSection(sectionId) {
|
|||||||
}
|
}
|
||||||
}, 100);
|
}, 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
|
// Update map from input fields
|
||||||
|
|||||||
1289
map/app/public/js/cut-controls.js
Normal file
1289
map/app/public/js/cut-controls.js
Normal file
File diff suppressed because it is too large
Load Diff
336
map/app/public/js/cut-drawing.js
Normal file
336
map/app/public/js/cut-drawing.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
502
map/app/public/js/cut-manager.js
Normal file
502
map/app/public/js/cut-manager.js
Normal 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();
|
||||||
@ -545,29 +545,67 @@ export async function handleDeleteLocation() {
|
|||||||
|
|
||||||
export function closeAddModal() {
|
export function closeAddModal() {
|
||||||
const modal = document.getElementById('add-modal');
|
const modal = document.getElementById('add-modal');
|
||||||
modal.classList.add('hidden');
|
if (modal) {
|
||||||
document.getElementById('location-form').reset();
|
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) {
|
export function openAddModal(lat, lng, performLookup = true) {
|
||||||
const modal = document.getElementById('add-modal');
|
const modal = document.getElementById('add-modal');
|
||||||
const latInput = document.getElementById('location-lat');
|
|
||||||
const lngInput = document.getElementById('location-lng');
|
if (!modal) {
|
||||||
const geoInput = document.getElementById('geo-location');
|
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
|
// Reset address confirmation state
|
||||||
resetAddressConfirmation('add');
|
resetAddressConfirmation('add');
|
||||||
|
|
||||||
// Set coordinates
|
// Set coordinates if input fields exist
|
||||||
latInput.value = lat.toFixed(8);
|
if (latInput && lngInput) {
|
||||||
lngInput.value = lng.toFixed(8);
|
latInput.value = lat.toFixed(8);
|
||||||
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
|
lngInput.value = lng.toFixed(8);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear other fields
|
if (geoInput) {
|
||||||
document.getElementById('location-form').reset();
|
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
|
||||||
latInput.value = lat.toFixed(8);
|
}
|
||||||
lngInput.value = lng.toFixed(8);
|
|
||||||
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
|
// Show modal
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
import { CONFIG, loadDomainConfig } from './config.js';
|
import { CONFIG, loadDomainConfig } from './config.js';
|
||||||
import { hideLoading, showStatus, setViewportDimensions } from './utils.js';
|
import { hideLoading, showStatus, setViewportDimensions } from './utils.js';
|
||||||
import { checkAuth } from './auth.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 { loadLocations } from './location-manager.js';
|
||||||
import { setupEventListeners } from './ui-controls.js';
|
import { setupEventListeners } from './ui-controls.js';
|
||||||
import { UnifiedSearchManager } from './search-manager.js';
|
import { UnifiedSearchManager } from './search-manager.js';
|
||||||
|
import { cutManager } from './cut-manager.js';
|
||||||
|
import { initializeCutControls } from './cut-controls.js';
|
||||||
|
|
||||||
// Application state
|
// Application state
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
@ -36,6 +38,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Then initialize the map
|
// Then initialize the map
|
||||||
await initializeMap();
|
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
|
// Only load locations after map is ready
|
||||||
await loadLocations();
|
await loadLocations();
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,11 @@ export let map = null;
|
|||||||
export let startLocationMarker = null;
|
export let startLocationMarker = null;
|
||||||
export let isStartLocationVisible = true;
|
export let isStartLocationVisible = true;
|
||||||
|
|
||||||
|
// Function to get the map instance
|
||||||
|
export function getMap() {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeMap() {
|
export async function initializeMap() {
|
||||||
try {
|
try {
|
||||||
// Get start location from PUBLIC endpoint (not admin endpoint)
|
// Get start location from PUBLIC endpoint (not admin endpoint)
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export class MapSearch {
|
|||||||
*/
|
*/
|
||||||
selectResult(result) {
|
selectResult(result) {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
console.error('Map not available');
|
console.error('Map not initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ export class MapSearch {
|
|||||||
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
|
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lng)) {
|
if (isNaN(lat) || isNaN(lng)) {
|
||||||
console.error('Invalid coordinates in result:', result);
|
console.error('Invalid coordinates:', result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,34 +121,37 @@ export class MapSearch {
|
|||||||
this.tempMarker = L.marker([lat, lng], {
|
this.tempMarker = L.marker([lat, lng], {
|
||||||
icon: L.divIcon({
|
icon: L.divIcon({
|
||||||
className: 'temp-search-marker',
|
className: 'temp-search-marker',
|
||||||
html: '📍',
|
html: '<div class="marker-pin"></div>',
|
||||||
iconSize: [30, 30],
|
iconSize: [30, 30],
|
||||||
iconAnchor: [15, 30]
|
iconAnchor: [15, 30]
|
||||||
})
|
})
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// Create popup with add location option
|
// Create popup content without inline handlers
|
||||||
const popupContent = `
|
const popupContent = document.createElement('div');
|
||||||
<div class="search-result-popup">
|
popupContent.className = 'search-result-popup';
|
||||||
<h3>${result.formattedAddress || 'Search Result'}</h3>
|
popupContent.innerHTML = `
|
||||||
<p>${result.fullAddress || ''}</p>
|
<h3>${result.formattedAddress || 'Search Result'}</h3>
|
||||||
<div class="popup-actions">
|
<p>${result.fullAddress || ''}</p>
|
||||||
<button class="btn btn-success btn-sm" onclick="mapSearchInstance.openAddLocationModal(${lat}, ${lng})">
|
<button class="btn btn-primary search-add-location-btn" data-lat="${lat}" data-lng="${lng}">
|
||||||
➕ Add Location Here
|
Add Location Here
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="mapSearchInstance.clearTempMarker()">
|
|
||||||
✕ Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Bind the popup
|
||||||
this.tempMarker.bindPopup(popupContent).openPopup();
|
this.tempMarker.bindPopup(popupContent).openPopup();
|
||||||
|
|
||||||
// Auto-clear the marker after 30 seconds
|
// Add event listener after popup is opened
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.clearTempMarker();
|
const addBtn = document.querySelector('.search-add-location-btn');
|
||||||
}, 30000);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -492,6 +492,17 @@ export function setupEventListeners() {
|
|||||||
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||||
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||||
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
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-toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
|
||||||
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||||
|
|
||||||
|
|||||||
30
map/app/routes/cuts.js
Normal file
30
map/app/routes/cuts.js
Normal 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;
|
||||||
@ -13,6 +13,7 @@ const debugRoutes = require('./debug');
|
|||||||
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
|
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
|
||||||
const shiftsRoutes = require('./shifts');
|
const shiftsRoutes = require('./shifts');
|
||||||
const externalDataRoutes = require('./external');
|
const externalDataRoutes = require('./external');
|
||||||
|
const cutsRoutes = require('./cuts');
|
||||||
|
|
||||||
module.exports = (app) => {
|
module.exports = (app) => {
|
||||||
// Health check (no auth)
|
// Health check (no auth)
|
||||||
@ -44,9 +45,12 @@ module.exports = (app) => {
|
|||||||
// QR code routes (authenticated)
|
// QR code routes (authenticated)
|
||||||
app.use('/api/qr', requireAuth, qrRoutes);
|
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)
|
// Test QR page (no auth for testing)
|
||||||
app.get('/test-qr', (req, res) => {
|
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
|
// Protected routes
|
||||||
@ -56,6 +60,9 @@ module.exports = (app) => {
|
|||||||
app.use('/api/shifts', requireNonTemp, shiftsRoutes);
|
app.use('/api/shifts', requireNonTemp, shiftsRoutes);
|
||||||
app.use('/api/external', externalDataRoutes);
|
app.use('/api/external', externalDataRoutes);
|
||||||
|
|
||||||
|
// Cuts routes (add after other protected routes)
|
||||||
|
app.use('/api/cuts', requireAuth, cutsRoutes);
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
app.get('/admin.html', requireAdmin, (req, res) => {
|
app.get('/admin.html', requireAdmin, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
|
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
|
||||||
@ -66,6 +73,28 @@ module.exports = (app) => {
|
|||||||
// Debug routes (admin only)
|
// Debug routes (admin only)
|
||||||
app.use('/api/debug', requireAdmin, debugRoutes);
|
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)
|
// Config check endpoint (authenticated)
|
||||||
app.get('/api/config-check', requireAuth, (req, res) => {
|
app.get('/api/config-check', requireAuth, (req, res) => {
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
@ -77,10 +106,12 @@ module.exports = (app) => {
|
|||||||
hasTableId: !!config.nocodb.tableId,
|
hasTableId: !!config.nocodb.tableId,
|
||||||
hasLoginSheet: !!config.nocodb.loginSheetId,
|
hasLoginSheet: !!config.nocodb.loginSheetId,
|
||||||
hasSettingsSheet: !!config.nocodb.settingsSheetId,
|
hasSettingsSheet: !!config.nocodb.settingsSheetId,
|
||||||
|
hasCutsSheet: !!config.nocodb.cutsSheetId,
|
||||||
projectId: config.nocodb.projectId,
|
projectId: config.nocodb.projectId,
|
||||||
tableId: config.nocodb.tableId,
|
tableId: config.nocodb.tableId,
|
||||||
loginSheet: config.nocodb.loginSheetId,
|
loginSheet: config.nocodb.loginSheetId,
|
||||||
settingsSheet: config.nocodb.settingsSheetId,
|
settingsSheet: config.nocodb.settingsSheetId,
|
||||||
|
cutsSheet: config.nocodb.cutsSheetId,
|
||||||
nodeEnv: config.nodeEnv
|
nodeEnv: config.nodeEnv
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 express = require('express');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
@ -8,7 +14,7 @@ const fetch = require('node-fetch');
|
|||||||
|
|
||||||
// Debug: Check if server.js is being loaded multiple times
|
// Debug: Check if server.js is being loaded multiple times
|
||||||
const serverInstanceId = Math.random().toString(36).substr(2, 9);
|
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
|
// Import configuration and utilities
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
@ -180,6 +186,7 @@ const server = app.listen(config.port, () => {
|
|||||||
║ Project ID: ${config.nocodb.projectId} ║
|
║ Project ID: ${config.nocodb.projectId} ║
|
||||||
║ Table ID: ${config.nocodb.tableId} ║
|
║ Table ID: ${config.nocodb.tableId} ║
|
||||||
║ Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} ║
|
║ Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} ║
|
||||||
|
║ PID: ${process.pid} ║
|
||||||
║ Time: ${new Date().toISOString()} ║
|
║ Time: ${new Date().toISOString()} ║
|
||||||
╚════════════════════════════════════════╝
|
╚════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
|
|||||||
@ -4,10 +4,13 @@
|
|||||||
# This script automatically creates the necessary base and tables for the BNKops Map Viewer application using NocoDB.
|
# 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
|
# 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
|
# 1. locations - Main table with GeoData, proper field types per README.md
|
||||||
# 2. login - Simple authentication table with Email, Name, Admin fields
|
# 2. login - Simple authentication table with Email, Name, Admin fields
|
||||||
# 3. settings - Configuration table with text fields only (no QR image storage)
|
# 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
|
# 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"
|
create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to create the shifts table
|
# Function to create the shifts table
|
||||||
create_shifts_table() {
|
create_shifts_table() {
|
||||||
local base_id=$1
|
local base_id=$1
|
||||||
@ -713,6 +717,115 @@ create_shift_signups_table() {
|
|||||||
create_table "$base_id" "shift_signups" "$table_data" "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
|
# Function to create default admin user
|
||||||
create_default_admin() {
|
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"
|
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 execution
|
||||||
main() {
|
main() {
|
||||||
print_status "Starting NocoDB Auto-Setup..."
|
print_status "Starting NocoDB Auto-Setup..."
|
||||||
@ -802,6 +985,9 @@ main() {
|
|||||||
# Create shift signups table
|
# Create shift signups table
|
||||||
SHIFT_SIGNUPS_TABLE_ID=$(create_shift_signups_table "$BASE_ID")
|
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
|
# Wait a moment for tables to be fully created
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
@ -814,6 +1000,9 @@ main() {
|
|||||||
# Create default settings row (includes both start location and walk sheet config)
|
# Create default settings row (includes both start location and walk sheet config)
|
||||||
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
|
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_status "================================"
|
||||||
print_success "NocoDB Auto-Setup completed successfully!"
|
print_success "NocoDB Auto-Setup completed successfully!"
|
||||||
print_status "================================"
|
print_status "================================"
|
||||||
@ -829,6 +1018,7 @@ main() {
|
|||||||
print_status " - NOCODB_SETTINGS_SHEET (for settings table)"
|
print_status " - NOCODB_SETTINGS_SHEET (for settings table)"
|
||||||
print_status " - NOCODB_SHIFTS_SHEET (for shifts table)"
|
print_status " - NOCODB_SHIFTS_SHEET (for shifts table)"
|
||||||
print_status " - NOCODB_SHIFT_SIGNUPS_SHEET (for shift signups 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 "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 "5. IMPORTANT: Change the default password after first login!"
|
||||||
print_status "6. Start adding your location data!"
|
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 "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 "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 "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
|
# Check if script is being run directly
|
||||||
|
|||||||
0
map/debug-mobile-overlay.html
Normal file
0
map/debug-mobile-overlay.html
Normal file
0
map/debug-mobile-overlay.js
Normal file
0
map/debug-mobile-overlay.js
Normal 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).
|
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
|
# app/middleware/auth.js
|
||||||
|
|
||||||
Express middleware for authentication and admin access control.
|
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.).
|
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
|
# app/public/css/modules/cache-busting.css
|
||||||
|
|
||||||
Styles for the cache busting update notification that prompts users to refresh the page.
|
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.
|
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
|
# app/routes/admin.js
|
||||||
|
|
||||||
Express router for admin-only endpoints (start location, walk sheet config).
|
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).
|
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
|
# 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.
|
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.
|
||||||
|
|||||||
0
map/test-mobile-overlay.js
Normal file
0
map/test-mobile-overlay.js
Normal file
Loading…
x
Reference in New Issue
Block a user