Pushing Cuts to repo. Still bugs however decently stable.
This commit is contained in:
@@ -67,6 +67,10 @@
|
||||
<span class="nav-icon">👥</span>
|
||||
<span class="nav-text">Users</span>
|
||||
</a>
|
||||
<a href="#cuts">
|
||||
<span class="nav-icon">✂️</span>
|
||||
<span class="nav-text">Map Cuts</span>
|
||||
</a>
|
||||
<a href="#convert-data">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span class="nav-text">Convert Data</span>
|
||||
@@ -455,6 +459,133 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Map Cuts Section -->
|
||||
<section id="cuts" class="admin-section" style="display: none;">
|
||||
<h2>Map Cuts</h2>
|
||||
<p>Create and manage polygon overlays for the map. Cuts can be used to define areas like wards, neighborhoods, or custom regions.</p>
|
||||
|
||||
<div class="cuts-container">
|
||||
<!-- Map and Drawing Controls -->
|
||||
<div class="cuts-map-section">
|
||||
<div id="cuts-map" class="admin-map"></div>
|
||||
|
||||
<!-- Drawing Toolbar -->
|
||||
<div id="cut-drawing-toolbar" class="cut-drawing-toolbar">
|
||||
<div class="toolbar-content">
|
||||
<div class="vertex-count" id="vertex-count">0</div>
|
||||
|
||||
<div class="style-controls">
|
||||
<div class="color-control">
|
||||
<label>Color:</label>
|
||||
<input type="color" id="toolbar-color" value="#3388ff">
|
||||
</div>
|
||||
<div class="opacity-control">
|
||||
<label>Opacity:</label>
|
||||
<input type="range" id="toolbar-opacity" min="0" max="1" step="0.05" value="0.3">
|
||||
<span class="opacity-value" id="toolbar-opacity-display">30%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-buttons">
|
||||
<button type="button" id="finish-cut-btn" class="primary" disabled>Finish</button>
|
||||
<button type="button" id="undo-vertex-btn" class="secondary" disabled>Undo</button>
|
||||
<button type="button" id="clear-vertices-btn" class="secondary" disabled>Clear</button>
|
||||
<button type="button" id="cancel-cut-btn" class="danger">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cut Form -->
|
||||
<div class="cuts-form-section">
|
||||
<div class="cuts-management-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title" id="cut-form-title">Cut Properties</h3>
|
||||
<div class="panel-actions">
|
||||
<button id="start-drawing-btn" class="btn btn-primary btn-sm">Start Drawing</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<form id="cut-form" class="cut-form">
|
||||
<!-- Hidden fields moved to prevent duplicates -->
|
||||
<input type="hidden" id="cut-id" name="id">
|
||||
<input type="hidden" id="cut-geojson" name="geojson">
|
||||
<input type="hidden" id="cut-bounds" name="bounds">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cut-name">Name *</label>
|
||||
<input type="text" id="cut-name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cut-description">Description</label>
|
||||
<textarea id="cut-description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cut-category">Category</label>
|
||||
<select id="cut-category" name="category">
|
||||
<option value="Custom">Custom</option>
|
||||
<option value="Ward">Ward</option>
|
||||
<option value="Neighborhood">Neighborhood</option>
|
||||
<option value="District">District</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="cut-public" name="is_public" checked>
|
||||
<label for="cut-public">Make this cut visible on the public map</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="cut-official" name="is_official">
|
||||
<label for="cut-official">Mark as official cut</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" id="save-cut-btn" class="btn btn-success" disabled>Save Cut</button>
|
||||
<button type="button" id="reset-form-btn" class="btn btn-secondary">Reset</button>
|
||||
<button type="button" id="cancel-edit-btn" class="btn btn-secondary" style="display: none;">Cancel Edit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cuts List -->
|
||||
<div class="cuts-list-section">
|
||||
<div class="cuts-management-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Existing Cuts</h3>
|
||||
<div class="panel-actions">
|
||||
<button id="refresh-cuts-btn" class="btn btn-secondary btn-sm">Refresh</button>
|
||||
<button id="export-cuts-btn" class="btn btn-secondary btn-sm">Export All</button>
|
||||
<label for="import-cuts-file" class="btn btn-secondary btn-sm" style="margin: 0;">
|
||||
Import
|
||||
<input type="file" id="import-cuts-file" accept=".json" style="display: none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="cuts-filters">
|
||||
<input type="text" id="cuts-search" placeholder="Search cuts..." class="form-control">
|
||||
<select id="cuts-category-filter" class="form-control">
|
||||
<option value="">All Categories</option>
|
||||
<option value="Custom">Custom</option>
|
||||
<option value="Ward">Ward</option>
|
||||
<option value="Neighborhood">Neighborhood</option>
|
||||
<option value="District">District</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="cuts-list" class="cuts-list">
|
||||
<!-- Cuts will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Convert Data Section -->
|
||||
<section id="convert-data" class="admin-section" style="display: none;">
|
||||
<h2>Convert Data</h2>
|
||||
@@ -713,6 +844,9 @@
|
||||
<!-- Dashboard JavaScript -->
|
||||
<script src="js/dashboard.js"></script>
|
||||
|
||||
<!-- Admin Cuts JavaScript -->
|
||||
<script src="js/admin-cuts.js"></script>
|
||||
|
||||
<!-- Data Convert JavaScript -->
|
||||
<!-- Admin JavaScript -->
|
||||
<script src="js/admin.js"></script>
|
||||
|
||||
@@ -2395,3 +2395,147 @@
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cuts Section Styles */
|
||||
.cuts-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cuts-map-section {
|
||||
position: relative;
|
||||
height: 500px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#cuts-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cuts-form-section,
|
||||
.cuts-list-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cuts-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.cuts-filters input,
|
||||
.cuts-filters select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Button styles to match admin panel theme */
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: 1px solid #007bff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #0056b3;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: 1px solid #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #545b62;
|
||||
border-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: 1px solid #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background-color: #218838;
|
||||
border-color: #218838;
|
||||
}
|
||||
|
||||
/* Responsive layout for cuts */
|
||||
@media (min-width: 1200px) {
|
||||
.cuts-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cuts-form-section,
|
||||
.cuts-list-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cuts-form-section > *,
|
||||
.cuts-list-section > * {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cuts-map-section {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.cuts-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled form state */
|
||||
.cut-form.disabled {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cut-form.disabled input:not(#start-drawing-btn):not(#reset-form-btn):not(#cancel-edit-btn),
|
||||
.cut-form.disabled textarea,
|
||||
.cut-form.disabled select {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cut-form.disabled input:not(#start-drawing-btn):not(#reset-form-btn):not(#cancel-edit-btn):focus,
|
||||
.cut-form.disabled textarea:focus,
|
||||
.cut-form.disabled select:focus {
|
||||
outline: none;
|
||||
border-color: #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Override any conflicting styles */
|
||||
.leaflet-container path.leaflet-interactive {
|
||||
/* Override any conflicting styles - but allow cuts to manage their own opacity */
|
||||
.leaflet-container path.leaflet-interactive:not(.cut-polygon) {
|
||||
stroke: #ffffff !important;
|
||||
stroke-opacity: 1 !important;
|
||||
stroke-width: 2px !important;
|
||||
fill-opacity: 0.8 !important;
|
||||
}
|
||||
|
||||
/* Cut polygons - allow dynamic opacity (higher specificity to override) */
|
||||
.leaflet-container path.leaflet-interactive.cut-polygon {
|
||||
stroke-width: 2px !important;
|
||||
/* Allow JavaScript to control fill-opacity - remove !important */
|
||||
}
|
||||
|
||||
/* Marker being moved */
|
||||
.location-marker.leaflet-drag-target {
|
||||
cursor: move !important;
|
||||
|
||||
@@ -70,3 +70,269 @@
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Cut Selector Styles */
|
||||
.cut-selector-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cut-selector {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 32px 10px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cut-selector:hover {
|
||||
background-color: #7f8c8d;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.cut-selector:focus {
|
||||
background-color: #7f8c8d;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
/* Custom dropdown arrow */
|
||||
.cut-selector-container::after {
|
||||
content: '▼';
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Mobile styles for cut selector */
|
||||
@media (max-width: 768px) {
|
||||
.cut-selector {
|
||||
min-width: 120px;
|
||||
max-width: 150px;
|
||||
font-size: 13px;
|
||||
padding: 8px 28px 8px 12px;
|
||||
}
|
||||
|
||||
.cut-selector-container::after {
|
||||
right: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Multi-select cut dropdown styles - enhanced */
|
||||
.cut-checkbox-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
z-index: 1001;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 2px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove the focus-within rule that auto-shows the dropdown */
|
||||
/* This was causing the dropdown to show automatically on focus */
|
||||
|
||||
.cut-checkbox-header {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cut-checkbox-header .btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.cut-checkbox-list {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.cut-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cut-checkbox-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.cut-checkbox-item * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cut-checkbox-item input[type="checkbox"] {
|
||||
pointer-events: auto;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cut-color-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cut-checkbox-item .cut-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cut-checkbox-item .badge {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Cut legend styles */
|
||||
.cut-legend {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cut-legend.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cut-legend-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.legend-header h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Updated legend styles for multiple cuts */
|
||||
.legend-cuts-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.legend-cut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.legend-cut-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.legend-cut-item .cut-color-box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.legend-cut-item .cut-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-remove-cut {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #dc3545;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-remove-cut:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile overlay styles */
|
||||
.mobile-overlay-list {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.mobile-overlay-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.mobile-overlay-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-overlay-item input[type="checkbox"] {
|
||||
margin-right: 12px;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments for multi-select */
|
||||
@media (max-width: 768px) {
|
||||
.cut-checkbox-container {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.cut-legend {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 80px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,3 +152,97 @@
|
||||
.mobile-sidebar .btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Mobile overlay modal styles */
|
||||
.overlay-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.overlay-option {
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.overlay-option:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.overlay-option label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overlay-option input[type="radio"] {
|
||||
margin-right: 12px;
|
||||
transform: scale(1.2);
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.overlay-option.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.overlay-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.current-overlay-info {
|
||||
padding: 15px;
|
||||
background-color: var(--light-color);
|
||||
border-radius: var(--border-radius);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.current-overlay-info h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--dark-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.overlay-info-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overlay-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overlay-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.overlay-name {
|
||||
font-weight: 600;
|
||||
color: var(--dark-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.overlay-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Mobile overlay button active state */
|
||||
.mobile-sidebar #mobile-overlay-btn.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@
|
||||
@import url("modules/cache-busting.css");
|
||||
@import url("modules/apartment-popup.css");
|
||||
@import url("modules/apartment-marker.css");
|
||||
@import url("modules/temp-user.css")
|
||||
@import url("modules/temp-user.css");
|
||||
@import url("modules/cuts.css");
|
||||
|
||||
@@ -122,11 +122,17 @@
|
||||
<span class="btn-icon">➕</span>
|
||||
<span class="btn-text">Add Location Here</span>
|
||||
</button>
|
||||
|
||||
<button id="fullscreen-btn" class="btn btn-secondary">
|
||||
<span class="btn-icon">⛶</span>
|
||||
<span class="btn-text">Fullscreen</span>
|
||||
</button>
|
||||
<!-- Add cut selector with multi-select support -->
|
||||
<div class="cut-selector-container">
|
||||
<button id="cut-selector" class="cut-selector">
|
||||
Select map overlays...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mobile floating sidebar -->
|
||||
@@ -146,11 +152,24 @@
|
||||
<button id="mobile-toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle Edmonton Data">
|
||||
🏙️
|
||||
</button>
|
||||
|
||||
<!-- Add mobile overlay button -->
|
||||
<button id="mobile-overlay-btn" class="btn btn-secondary" title="Map Overlays">
|
||||
🗺️
|
||||
</button>
|
||||
|
||||
<button id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
|
||||
⛶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add cut legend -->
|
||||
<div id="cut-legend" class="cut-legend">
|
||||
<div id="cut-legend-content" class="cut-legend-content"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Crosshair for location selection -->
|
||||
<div id="crosshair" class="crosshair hidden">
|
||||
<div class="crosshair-x"></div>
|
||||
@@ -388,6 +407,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile overlay modal -->
|
||||
<div id="mobile-overlay-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Map Overlays</h2>
|
||||
<button class="modal-close" data-action="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="overlay-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="show-all">Show All</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="hide-all">Hide All</button>
|
||||
</div>
|
||||
<div id="mobile-overlay-list" class="mobile-overlay-list">
|
||||
<!-- Will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
|
||||
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();
|
||||
} else if (hash === '#convert-data') {
|
||||
showSection('convert-data');
|
||||
} else if (hash === '#cuts') {
|
||||
showSection('cuts');
|
||||
} else {
|
||||
// Default to dashboard
|
||||
showSection('dashboard');
|
||||
@@ -479,6 +481,25 @@ function showSection(sectionId) {
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Special handling for cuts section
|
||||
if (sectionId === 'cuts') {
|
||||
// Initialize admin cuts manager when section is shown
|
||||
setTimeout(() => {
|
||||
if (typeof window.adminCutsManager === 'object' && window.adminCutsManager.initialize) {
|
||||
if (!window.adminCutsManager.isInitialized) {
|
||||
console.log('Initializing admin cuts manager from showSection...');
|
||||
window.adminCutsManager.initialize().catch(error => {
|
||||
console.error('Failed to initialize cuts manager:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('Admin cuts manager already initialized');
|
||||
}
|
||||
} else {
|
||||
console.error('adminCutsManager not found in showSection');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Update map from input fields
|
||||
|
||||
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() {
|
||||
const modal = document.getElementById('add-modal');
|
||||
modal.classList.add('hidden');
|
||||
document.getElementById('location-form').reset();
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Try to find and reset the form with multiple possible IDs
|
||||
const form = document.getElementById('location-form') ||
|
||||
document.getElementById('add-location-form');
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export function openAddModal(lat, lng, performLookup = true) {
|
||||
const modal = document.getElementById('add-modal');
|
||||
const latInput = document.getElementById('location-lat');
|
||||
const lngInput = document.getElementById('location-lng');
|
||||
const geoInput = document.getElementById('geo-location');
|
||||
|
||||
if (!modal) {
|
||||
console.error('Add modal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try multiple possible field IDs for coordinates
|
||||
const latInput = document.getElementById('location-lat') ||
|
||||
document.getElementById('add-latitude') ||
|
||||
document.getElementById('latitude');
|
||||
const lngInput = document.getElementById('location-lng') ||
|
||||
document.getElementById('add-longitude') ||
|
||||
document.getElementById('longitude');
|
||||
const geoInput = document.getElementById('geo-location') ||
|
||||
document.getElementById('add-geo-location') ||
|
||||
document.getElementById('Geo-Location');
|
||||
|
||||
// Reset address confirmation state
|
||||
resetAddressConfirmation('add');
|
||||
|
||||
// Set coordinates
|
||||
latInput.value = lat.toFixed(8);
|
||||
lngInput.value = lng.toFixed(8);
|
||||
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
|
||||
// Set coordinates if input fields exist
|
||||
if (latInput && lngInput) {
|
||||
latInput.value = lat.toFixed(8);
|
||||
lngInput.value = lng.toFixed(8);
|
||||
}
|
||||
|
||||
// Clear other fields
|
||||
document.getElementById('location-form').reset();
|
||||
latInput.value = lat.toFixed(8);
|
||||
lngInput.value = lng.toFixed(8);
|
||||
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
|
||||
if (geoInput) {
|
||||
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
|
||||
}
|
||||
|
||||
// Try to find and reset the form
|
||||
const form = document.getElementById('location-form') ||
|
||||
document.getElementById('add-location-form');
|
||||
|
||||
if (form) {
|
||||
// Clear other fields but preserve coordinates
|
||||
const tempLat = lat.toFixed(8);
|
||||
const tempLng = lng.toFixed(8);
|
||||
const tempGeo = `${tempLat};${tempLng}`;
|
||||
|
||||
form.reset();
|
||||
|
||||
// Restore coordinates after reset
|
||||
if (latInput) latInput.value = tempLat;
|
||||
if (lngInput) lngInput.value = tempLng;
|
||||
if (geoInput) geoInput.value = tempGeo;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { CONFIG, loadDomainConfig } from './config.js';
|
||||
import { hideLoading, showStatus, setViewportDimensions } from './utils.js';
|
||||
import { checkAuth } from './auth.js';
|
||||
import { initializeMap } from './map-manager.js';
|
||||
import { initializeMap, getMap } from './map-manager.js';
|
||||
import { loadLocations } from './location-manager.js';
|
||||
import { setupEventListeners } from './ui-controls.js';
|
||||
import { UnifiedSearchManager } from './search-manager.js';
|
||||
import { cutManager } from './cut-manager.js';
|
||||
import { initializeCutControls } from './cut-controls.js';
|
||||
|
||||
// Application state
|
||||
let refreshInterval = null;
|
||||
@@ -36,6 +38,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Then initialize the map
|
||||
await initializeMap();
|
||||
|
||||
// Initialize cut manager after map is ready
|
||||
await cutManager.initialize(getMap());
|
||||
|
||||
// Initialize cut controls for public map
|
||||
await initializeCutControls();
|
||||
|
||||
// Only load locations after map is ready
|
||||
await loadLocations();
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ export let map = null;
|
||||
export let startLocationMarker = null;
|
||||
export let isStartLocationVisible = true;
|
||||
|
||||
// Function to get the map instance
|
||||
export function getMap() {
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function initializeMap() {
|
||||
try {
|
||||
// Get start location from PUBLIC endpoint (not admin endpoint)
|
||||
|
||||
@@ -98,7 +98,7 @@ export class MapSearch {
|
||||
*/
|
||||
selectResult(result) {
|
||||
if (!map) {
|
||||
console.error('Map not available');
|
||||
console.error('Map not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MapSearch {
|
||||
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
console.error('Invalid coordinates in result:', result);
|
||||
console.error('Invalid coordinates:', result);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,34 +121,37 @@ export class MapSearch {
|
||||
this.tempMarker = L.marker([lat, lng], {
|
||||
icon: L.divIcon({
|
||||
className: 'temp-search-marker',
|
||||
html: '📍',
|
||||
html: '<div class="marker-pin"></div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 30]
|
||||
})
|
||||
}).addTo(map);
|
||||
|
||||
// Create popup with add location option
|
||||
const popupContent = `
|
||||
<div class="search-result-popup">
|
||||
<h3>${result.formattedAddress || 'Search Result'}</h3>
|
||||
<p>${result.fullAddress || ''}</p>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-success btn-sm" onclick="mapSearchInstance.openAddLocationModal(${lat}, ${lng})">
|
||||
➕ Add Location Here
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="mapSearchInstance.clearTempMarker()">
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
// Create popup content without inline handlers
|
||||
const popupContent = document.createElement('div');
|
||||
popupContent.className = 'search-result-popup';
|
||||
popupContent.innerHTML = `
|
||||
<h3>${result.formattedAddress || 'Search Result'}</h3>
|
||||
<p>${result.fullAddress || ''}</p>
|
||||
<button class="btn btn-primary search-add-location-btn" data-lat="${lat}" data-lng="${lng}">
|
||||
Add Location Here
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Bind the popup
|
||||
this.tempMarker.bindPopup(popupContent).openPopup();
|
||||
|
||||
// Auto-clear the marker after 30 seconds
|
||||
// Add event listener after popup is opened
|
||||
setTimeout(() => {
|
||||
this.clearTempMarker();
|
||||
}, 30000);
|
||||
const addBtn = document.querySelector('.search-add-location-btn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
const btnLat = parseFloat(e.target.dataset.lat);
|
||||
const btnLng = parseFloat(e.target.dataset.lng);
|
||||
this.openAddLocationModal(btnLat, btnLng);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -492,6 +492,17 @@ export function setupEventListeners() {
|
||||
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
||||
document.getElementById('mobile-overlay-btn')?.addEventListener('click', () => {
|
||||
console.log('Mobile overlay button clicked!');
|
||||
// Call the global function to open mobile overlay modal
|
||||
if (window.openMobileOverlayModal) {
|
||||
console.log('openMobileOverlayModal function found - calling it');
|
||||
window.openMobileOverlayModal();
|
||||
} else {
|
||||
console.error('openMobileOverlayModal function not available');
|
||||
console.log('Available window functions:', Object.keys(window).filter(k => k.includes('overlay') || k.includes('Modal')));
|
||||
}
|
||||
});
|
||||
document.getElementById('mobile-toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
|
||||
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user