diff --git a/.gitignore b/.gitignore
index f36c968..a334fbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,6 +110,7 @@ temp/
*.njsproj
*.sln
*.sw?
+.cursor
# OS generated files
.DS_Store
diff --git a/bun.lock b/bun.lock
index b6b5fd8..e2e980a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,45 +4,48 @@
"": {
"name": "bars-site",
"dependencies": {
- "@geoman-io/leaflet-geoman-free": "latest",
- "dompurify": "latest",
- "framer-motion": "latest",
- "geolib": "latest",
- "leaflet": "latest",
- "leaflet-polylinedecorator": "latest",
- "lightningcss": "latest",
- "lucide-react": "latest",
- "maplibre-gl": "latest",
- "marked": "latest",
- "posthog-js": "latest",
- "prop-types": "latest",
- "react": "latest",
- "react-confetti": "latest",
- "react-dom": "latest",
- "react-leaflet": "latest",
- "react-map-gl": "latest",
- "react-router-dom": "latest",
+ "@geoman-io/leaflet-geoman-free": "^2.19.0",
+ "dompurify": "^3.3.1",
+ "framer-motion": "^12.23.26",
+ "geolib": "^3.3.4",
+ "leaflet": "^1.9.4",
+ "leaflet-polylinedecorator": "^1.6.0",
+ "lightningcss": "^1.30.2",
+ "lucide-react": "^0.562.0",
+ "maplibre-gl": "^5.15.0",
+ "marked": "^17.0.1",
+ "posthog-js": "^1.309.1",
+ "prop-types": "^15.8.1",
+ "react": "^19.2.3",
+ "react-confetti": "^6.4.0",
+ "react-dom": "^19.2.3",
+ "react-leaflet": "^5.0.0",
+ "react-map-gl": "^8.1.0",
+ "react-router-dom": "^7.11.0",
},
"devDependencies": {
- "@eslint/js": "latest",
- "@tailwindcss/postcss": "latest",
- "@tailwindcss/typography": "latest",
- "@vitejs/plugin-react": "latest",
- "autoprefixer": "latest",
- "eslint": "latest",
- "eslint-plugin-react": "latest",
- "eslint-plugin-react-hooks": "latest",
- "eslint-plugin-react-refresh": "latest",
- "globals": "latest",
- "prettier": "latest",
- "tailwindcss": "latest",
- "vite": "latest",
+ "@eslint/js": "^9.39.2",
+ "@tailwindcss/postcss": "^4.1.18",
+ "@tailwindcss/typography": "^0.5.19",
+ "@vitejs/plugin-react": "^5.1.2",
+ "autoprefixer": "^10.4.23",
+ "eslint": "^9.39.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.26",
+ "globals": "^16.5.0",
+ "prettier": "^3.7.4",
+ "react-grab": "^0.1.29",
+ "tailwindcss": "^4.1.18",
+ "vite": "^7.3.0",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+ "@antfu/ni": ["@antfu/ni@0.23.2", "", { "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nu": "bin/nu.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs" } }, "sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ=="],
+
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
@@ -191,8 +194,12 @@
"@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.2.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/vector-tile": "^2.0.4", "@types/geojson-vt": "3.2.5", "@types/supercluster": "^7.1.3", "geojson-vt": "^4.0.2", "pbf": "^4.0.1", "supercluster": "^8.0.1" } }, "sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA=="],
+ "@medv/finder": ["@medv/finder@4.0.2", "", {}, "sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ=="],
+
"@posthog/core": ["@posthog/core@1.8.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew=="],
+ "@react-grab/cli": ["@react-grab/cli@0.1.29", "", { "dependencies": { "@antfu/ni": "^0.23.0", "commander": "^14.0.0", "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "ora": "^8.2.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "smol-toml": "^1.6.0" }, "bin": { "react-grab": "dist/cli.js" } }, "sha512-idgTec/ganPsag5BvbR3srF42UNNoUwHY+p94YORk5sCBl9ZaGJOXcDO93F8M4wruzTqIZgf1MUeY0+sTo0fVA=="],
+
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
@@ -315,6 +322,10 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
+ "@types/react-reconciler": ["@types/react-reconciler@0.28.9", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg=="],
+
"@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
@@ -331,6 +342,8 @@
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -365,6 +378,8 @@
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
+ "bippy": ["bippy@0.5.32", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": ">=17.0.1" } }, "sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw=="],
+
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -385,10 +400,16 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+ "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
+
+ "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
+
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -401,6 +422,8 @@
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@@ -427,6 +450,10 @@
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
+ "element-source": ["element-source@0.0.3", "", { "dependencies": { "bippy": "^0.5.32" } }, "sha512-o3VMv2BIfY/axhIBKlE9HrR5rNqnhjHN2PEAKxG65O0VCSfONoMi9QMQjY12XVVvMuTzr1cAg/4xLMkvh+/Wlg=="],
+
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
@@ -513,6 +540,8 @@
"geolib": ["geolib@3.3.4", "", {}, "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ=="],
+ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
+
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -587,6 +616,8 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
+
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
@@ -607,6 +638,8 @@
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
+ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
@@ -639,12 +672,16 @@
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
+
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"kdbush": ["kdbush@4.0.2", "", {}, "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
+
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"leaflet-polylinedecorator": ["leaflet-polylinedecorator@1.6.0", "", { "dependencies": { "leaflet-rotatedmarker": "^0.2.0" } }, "sha512-kn3krmZRetgvN0wjhgYL8kvyLS0tUogAl0vtHuXQnwlYNjbl7aLQpkoFUo8UB8gVZoB0dhI4Tb55VdTJAcYzzQ=="],
@@ -683,6 +720,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+ "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
+
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -697,6 +736,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
+
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -729,8 +770,12 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
+ "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
+
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
+
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -773,6 +818,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
+ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
+
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
@@ -789,6 +836,8 @@
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
+ "react-grab": ["react-grab@0.1.29", "", { "dependencies": { "@medv/finder": "^4.0.2", "@react-grab/cli": "0.1.29", "bippy": "^0.5.32", "element-source": "^0.0.3", "solid-js": "^1.9.10" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-s6CDIwjA0pzhCJqgFabM3GZ6m9vsmStOHopl7c8Xyx702QZ5ZXxOMRTpd2/7Gg+GC/1XwFmyLHwQiOCbUYIIYQ=="],
+
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
@@ -811,6 +860,8 @@
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
+ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
+
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
"rollup": ["rollup@4.45.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw=="],
@@ -827,6 +878,10 @@
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
+
+ "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
+
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
@@ -849,6 +904,14 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+
+ "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
+
+ "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
+
"sort-asc": ["sort-asc@0.2.0", "", {}, "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA=="],
"sort-desc": ["sort-desc@0.2.0", "", {}, "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w=="],
@@ -861,8 +924,12 @@
"split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="],
+ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
+
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
"string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
@@ -873,6 +940,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="],
@@ -971,6 +1040,8 @@
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
+ "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
@@ -995,6 +1066,12 @@
"eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
+ "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
+ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
+
+ "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
"rbush/quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="],
"split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
diff --git a/index.html b/index.html
index 4e0baaf..8ee7fcd 100644
--- a/index.html
+++ b/index.html
@@ -1,6 +1,11 @@
+
diff --git a/package.json b/package.json
index d389783..b4c8054 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"prettier": "^3.7.4",
+ "react-grab": "^0.1.29",
"tailwindcss": "^4.1.18",
"vite": "^7.3.0"
}
diff --git a/src/components/divisions/AirportPointEditor.jsx b/src/components/divisions/AirportPointEditor.jsx
index a53af15..b34db0c 100644
--- a/src/components/divisions/AirportPointEditor.jsx
+++ b/src/components/divisions/AirportPointEditor.jsx
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
-import { MapContainer, TileLayer, useMap, Rectangle, Polyline } from 'react-leaflet';
+import { MapContainer, TileLayer, useMap, Rectangle, Polyline, CircleMarker } from 'react-leaflet';
import L from 'leaflet';
import { getVatsimToken } from '../../utils/cookieUtils';
import {
@@ -18,6 +18,9 @@ import {
CircleFadingPlus,
Loader,
MapPinPlus,
+ ImageUp,
+ Lock,
+ LockOpen,
} from 'lucide-react';
import { Dropdown } from '../shared/Dropdown';
import { Layout } from '../layout/Layout';
@@ -1226,6 +1229,699 @@ const emptyFormState = {
ihp: false,
};
+const MIN_OVERLAY_SIZE_PX = 24;
+const PICK_DRAG_THRESHOLD_PX = 6;
+const ROTATION_HANDLE_OFFSET_PX = 28;
+const OVERLAY_ROTATION_TRANSFORM_REGEX =
+ /\stranslate\([^)]*\)\srotate\([^)]*\)\stranslate\([^)]*\)\s*$/;
+const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
+
+const normalizeAngle = (value) => {
+ if (!Number.isFinite(value)) return 0;
+ let next = value % 360;
+ if (next > 180) next -= 360;
+ if (next <= -180) next += 360;
+ return next;
+};
+
+const rotatePointOffset = (point, angleDeg) => {
+ const radians = (angleDeg * Math.PI) / 180;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ return L.point(point.x * cos - point.y * sin, point.x * sin + point.y * cos);
+};
+
+const getOverlayMetricsFromBounds = (map, overlayBounds) => {
+ const swPoint = map.latLngToLayerPoint(overlayBounds.getSouthWest());
+ const nePoint = map.latLngToLayerPoint(overlayBounds.getNorthEast());
+ const center = L.point((swPoint.x + nePoint.x) / 2, (swPoint.y + nePoint.y) / 2);
+
+ return {
+ center,
+ width: Math.max(Math.abs(nePoint.x - swPoint.x), MIN_OVERLAY_SIZE_PX),
+ height: Math.max(Math.abs(swPoint.y - nePoint.y), MIN_OVERLAY_SIZE_PX),
+ };
+};
+
+const buildBoundsFromMetrics = (map, center, width, height) => {
+ const swPoint = L.point(center.x - width / 2, center.y + height / 2);
+ const nePoint = L.point(center.x + width / 2, center.y - height / 2);
+ return L.latLngBounds(map.layerPointToLatLng(swPoint), map.layerPointToLatLng(nePoint));
+};
+
+const getUnitOverlayPoint = (normalizedPoint, aspectRatio = 1) =>
+ L.point(
+ ((normalizedPoint?.x ?? 0.5) - 0.5) * (aspectRatio > 0 ? aspectRatio : 1),
+ (normalizedPoint?.y ?? 0.5) - 0.5
+ );
+
+const getDisplayedOverlayLayerPoint = ({ map, bounds, rotation = 0, normalizedPoint }) => {
+ if (!map || !bounds || !normalizedPoint) return null;
+ const { center, width, height } = getOverlayMetricsFromBounds(map, bounds);
+ const localOffset = L.point(
+ ((normalizedPoint.x ?? 0.5) - 0.5) * width,
+ ((normalizedPoint.y ?? 0.5) - 0.5) * height
+ );
+ const rotatedOffset = rotatePointOffset(localOffset, rotation);
+ return L.point(center.x + rotatedOffset.x, center.y + rotatedOffset.y);
+};
+
+const solveBestFitOverlayTransform = (sourcePoints, targetPoints) => {
+ if (
+ !Array.isArray(sourcePoints) ||
+ !Array.isArray(targetPoints) ||
+ sourcePoints.length !== targetPoints.length ||
+ sourcePoints.length < 2
+ ) {
+ return null;
+ }
+
+ const count = sourcePoints.length;
+ const sourceCentroid = sourcePoints.reduce(
+ (sum, point) => L.point(sum.x + point.x, sum.y + point.y),
+ L.point(0, 0)
+ );
+ sourceCentroid.x /= count;
+ sourceCentroid.y /= count;
+
+ const targetCentroid = targetPoints.reduce(
+ (sum, point) => L.point(sum.x + point.x, sum.y + point.y),
+ L.point(0, 0)
+ );
+ targetCentroid.x /= count;
+ targetCentroid.y /= count;
+
+ let dot = 0;
+ let cross = 0;
+ let sourceVariance = 0;
+
+ for (let index = 0; index < count; index += 1) {
+ const sourceDelta = L.point(
+ sourcePoints[index].x - sourceCentroid.x,
+ sourcePoints[index].y - sourceCentroid.y
+ );
+ const targetDelta = L.point(
+ targetPoints[index].x - targetCentroid.x,
+ targetPoints[index].y - targetCentroid.y
+ );
+
+ dot += sourceDelta.x * targetDelta.x + sourceDelta.y * targetDelta.y;
+ cross += sourceDelta.x * targetDelta.y - sourceDelta.y * targetDelta.x;
+ sourceVariance += sourceDelta.x * sourceDelta.x + sourceDelta.y * sourceDelta.y;
+ }
+
+ if (sourceVariance < 0.000001) return null;
+
+ const heightScale = Math.hypot(dot, cross) / sourceVariance;
+ const rotation = normalizeAngle((Math.atan2(cross, dot) * 180) / Math.PI);
+ const rotatedSourceCentroid = rotatePointOffset(
+ L.point(sourceCentroid.x * heightScale, sourceCentroid.y * heightScale),
+ rotation
+ );
+ const center = L.point(
+ targetCentroid.x - rotatedSourceCentroid.x,
+ targetCentroid.y - rotatedSourceCentroid.y
+ );
+
+ return { center, heightScale, rotation };
+};
+
+// ---------------------------------------------------------------------------
+// ImageOverlayTool
+// Renders a draggable, resizable, rotatable, lockable image overlay inside the MapContainer.
+// ---------------------------------------------------------------------------
+const ImageOverlayTool = ({
+ imageUrl,
+ bounds,
+ onBoundsChange,
+ locked,
+ opacity,
+ rotation = 0,
+ onRotationChange,
+ aspectRatio = 1,
+ autoAlignExpectingOverlayPoint = false,
+ onAutoAlignOverlayPoint,
+}) => {
+ const map = useMap();
+ const overlayRef = useRef(null);
+ const dragMarkerRef = useRef(null);
+ const cornerMarkersRef = useRef([]);
+ const rotationMarkerRef = useRef(null);
+ const rotationRef = useRef(normalizeAngle(rotation));
+ const aspectRatioRef = useRef(aspectRatio > 0 ? aspectRatio : 1);
+ const shiftPressedRef = useRef(false);
+ const syncPresentationRef = useRef(() => {});
+ const autoAlignGestureRef = useRef({
+ active: false,
+ moved: false,
+ suppressClick: false,
+ startX: 0,
+ startY: 0,
+ });
+
+ const makeHandleIcon = (
+ cursor = 'move',
+ { size = 14, fill = '#3b82f6', outline = '#1e40af', radius = '50%' } = {}
+ ) =>
+ L.divIcon({
+ className: '',
+ html: `
`,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ });
+
+ const makeCornerIcon = () =>
+ L.divIcon({
+ className: '',
+ html: `
`,
+ iconSize: [10, 10],
+ iconAnchor: [5, 5],
+ });
+
+ const makeRotationIcon = () =>
+ makeHandleIcon('grab', {
+ size: 12,
+ fill: '#10b981',
+ outline: '#047857',
+ });
+
+ useEffect(() => {
+ rotationRef.current = normalizeAngle(rotation);
+ syncPresentationRef.current();
+ }, [rotation]);
+
+ useEffect(() => {
+ aspectRatioRef.current = aspectRatio > 0 ? aspectRatio : 1;
+ }, [aspectRatio]);
+
+ useEffect(() => {
+ const onKeyDown = (e) => {
+ if (e.key === 'Shift') shiftPressedRef.current = true;
+ };
+ const onKeyUp = (e) => {
+ if (e.key === 'Shift') shiftPressedRef.current = false;
+ };
+ const resetShiftState = () => {
+ shiftPressedRef.current = false;
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ window.addEventListener('blur', resetShiftState);
+
+ return () => {
+ window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('keyup', onKeyUp);
+ window.removeEventListener('blur', resetShiftState);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!map || !imageUrl || !bounds) return;
+
+ // Create the image overlay
+ const overlay = L.imageOverlay(imageUrl, bounds, {
+ opacity,
+ interactive: false,
+ className: 'bars-image-overlay',
+ }).addTo(map);
+ overlayRef.current = overlay;
+
+ const getDisplayGeometry = (overlayBounds = overlay.getBounds()) => {
+ const { center, width, height } = getOverlayMetricsFromBounds(map, overlayBounds);
+ const cornerOffsets = [
+ L.point(-width / 2, height / 2), // SW
+ L.point(width / 2, height / 2), // SE
+ L.point(width / 2, -height / 2), // NE
+ L.point(-width / 2, -height / 2), // NW
+ ];
+ const corners = cornerOffsets.map((offset) => {
+ const rotatedOffset = rotatePointOffset(offset, rotationRef.current);
+ return L.point(center.x + rotatedOffset.x, center.y + rotatedOffset.y);
+ });
+ const rotationOffset = rotatePointOffset(
+ L.point(0, -height / 2 - ROTATION_HANDLE_OFFSET_PX),
+ rotationRef.current
+ );
+
+ return {
+ center,
+ width,
+ height,
+ corners,
+ rotationHandle: L.point(center.x + rotationOffset.x, center.y + rotationOffset.y),
+ };
+ };
+
+ const applyOverlayRotation = () => {
+ const element = overlay.getElement();
+ if (!element) return;
+ const overlayBounds = overlay.getBounds();
+ const { width, height } = getOverlayMetricsFromBounds(map, overlayBounds);
+ const baseTransform = (element.style.transform || '')
+ .replace(OVERLAY_ROTATION_TRANSFORM_REGEX, '')
+ .trim();
+ const pivotX = width / 2;
+ const pivotY = height / 2;
+ const rotationTransform =
+ rotationRef.current === 0
+ ? ''
+ : ` translate(${pivotX}px, ${pivotY}px) rotate(${rotationRef.current}deg) translate(${-pivotX}px, ${-pivotY}px)`;
+
+ // Keep Leaflet's top-left transform origin intact so animated zoom can scale from the
+ // correct anchor, then add our center-based rotation as an extra transform segment.
+ element.style.transformOrigin = '0 0';
+ element.style.width = `${width}px`;
+ element.style.height = `${height}px`;
+ element.style.transform = `${baseTransform}${rotationTransform}`.trim();
+ };
+
+ const syncPresentation = () => {
+ applyOverlayRotation();
+
+ if (locked) return;
+
+ const geometry = getDisplayGeometry();
+ dragMarkerRef.current?.setLatLng(map.layerPointToLatLng(geometry.center));
+ cornerMarkersRef.current.forEach((marker, index) => {
+ marker.setLatLng(map.layerPointToLatLng(geometry.corners[index]));
+ });
+ rotationMarkerRef.current?.setLatLng(map.layerPointToLatLng(geometry.rotationHandle));
+ };
+ syncPresentationRef.current = syncPresentation;
+ map.on('zoomanim zoomend viewreset resize', syncPresentation);
+
+ if (!locked) {
+ // Center drag handle
+ const geometry = getDisplayGeometry();
+ const dragMarker = L.marker(map.layerPointToLatLng(geometry.center), {
+ icon: makeHandleIcon('move'),
+ draggable: true,
+ zIndexOffset: 1000,
+ }).addTo(map);
+ dragMarkerRef.current = dragMarker;
+
+ dragMarker.on('drag', () => {
+ const { width, height } = getOverlayMetricsFromBounds(map, overlay.getBounds());
+ const centerPoint = map.latLngToLayerPoint(dragMarker.getLatLng());
+ const newBounds = buildBoundsFromMetrics(map, centerPoint, width, height);
+ overlay.setBounds(newBounds);
+ syncPresentation();
+ });
+
+ dragMarker.on('dragend', () => {
+ onBoundsChange(overlay.getBounds());
+ });
+
+ // Corner resize handles
+ const cornerDirections = [
+ { x: -1, y: 1 }, // SW
+ { x: 1, y: 1 }, // SE
+ { x: 1, y: -1 }, // NE
+ { x: -1, y: -1 }, // NW
+ ];
+ const cornerMarkers = geometry.corners.map((corner, idx) => {
+ const m = L.marker(map.layerPointToLatLng(corner), {
+ icon: makeCornerIcon(),
+ draggable: true,
+ zIndexOffset: 1001,
+ }).addTo(map);
+
+ m.on('drag', () => {
+ const geometry = getDisplayGeometry();
+ const anchorPoint = geometry.corners[(idx + 2) % 4];
+ const pointerPoint = map.latLngToLayerPoint(m.getLatLng());
+ const localVector = rotatePointOffset(
+ L.point(pointerPoint.x - anchorPoint.x, pointerPoint.y - anchorPoint.y),
+ -rotationRef.current
+ );
+ const direction = cornerDirections[idx];
+
+ let nextWidth = Math.max(
+ direction.x > 0 ? localVector.x : -localVector.x,
+ MIN_OVERLAY_SIZE_PX
+ );
+ let nextHeight = Math.max(
+ direction.y > 0 ? localVector.y : -localVector.y,
+ MIN_OVERLAY_SIZE_PX
+ );
+
+ if (!shiftPressedRef.current) {
+ const lockedAspectRatio = aspectRatioRef.current || 1;
+ if (nextWidth / nextHeight > lockedAspectRatio) {
+ nextHeight = nextWidth / lockedAspectRatio;
+ } else {
+ nextWidth = nextHeight * lockedAspectRatio;
+ }
+ }
+
+ const resizedOffset = rotatePointOffset(
+ L.point(direction.x * nextWidth, direction.y * nextHeight),
+ rotationRef.current
+ );
+ const draggedCornerPoint = L.point(
+ anchorPoint.x + resizedOffset.x,
+ anchorPoint.y + resizedOffset.y
+ );
+ const nextCenter = L.point(
+ (anchorPoint.x + draggedCornerPoint.x) / 2,
+ (anchorPoint.y + draggedCornerPoint.y) / 2
+ );
+ const newBounds = buildBoundsFromMetrics(map, nextCenter, nextWidth, nextHeight);
+
+ overlay.setBounds(newBounds);
+ syncPresentation();
+ });
+
+ m.on('dragend', () => {
+ syncPresentation();
+ onBoundsChange(overlay.getBounds());
+ });
+
+ return m;
+ });
+ cornerMarkersRef.current = cornerMarkers;
+
+ const rotationMarker = L.marker(map.layerPointToLatLng(geometry.rotationHandle), {
+ icon: makeRotationIcon(),
+ draggable: true,
+ zIndexOffset: 1002,
+ }).addTo(map);
+ rotationMarkerRef.current = rotationMarker;
+
+ rotationMarker.on('drag', () => {
+ const { center } = getOverlayMetricsFromBounds(map, overlay.getBounds());
+ const pointerPoint = map.latLngToLayerPoint(rotationMarker.getLatLng());
+ const dx = pointerPoint.x - center.x;
+ const dy = pointerPoint.y - center.y;
+ if (dx === 0 && dy === 0) return;
+
+ const nextRotation = normalizeAngle((Math.atan2(dy, dx) * 180) / Math.PI + 90);
+ rotationRef.current = nextRotation;
+ onRotationChange?.(nextRotation);
+ syncPresentation();
+ });
+
+ rotationMarker.on('dragend', () => {
+ syncPresentation();
+ });
+ }
+
+ overlay.once('load', syncPresentation);
+ syncPresentation();
+
+ return () => {
+ map.off('zoomanim zoomend viewreset resize', syncPresentation);
+ overlay.remove();
+ dragMarkerRef.current?.remove();
+ dragMarkerRef.current = null;
+ cornerMarkersRef.current.forEach((m) => m.remove());
+ cornerMarkersRef.current = [];
+ rotationMarkerRef.current?.remove();
+ rotationMarkerRef.current = null;
+ syncPresentationRef.current = () => {};
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [map, imageUrl, bounds, locked]);
+
+ // Update opacity without remounting
+ useEffect(() => {
+ overlayRef.current?.setOpacity(opacity);
+ }, [opacity]);
+
+ useEffect(() => {
+ syncPresentationRef.current();
+ }, [locked]);
+
+ useEffect(() => {
+ const overlay = overlayRef.current;
+ if (!map || !overlay) return undefined;
+
+ const element = overlay.getElement();
+ if (!element) return undefined;
+
+ const shouldCaptureOverlayPoint = autoAlignExpectingOverlayPoint;
+ const gesture = autoAlignGestureRef.current;
+ const handlePointerDown = (event) => {
+ gesture.active = true;
+ gesture.moved = false;
+ gesture.suppressClick = false;
+ gesture.startX = event.clientX;
+ gesture.startY = event.clientY;
+ };
+ const handlePointerMove = (event) => {
+ if (!gesture.active || gesture.moved) return;
+ if (
+ Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) >
+ PICK_DRAG_THRESHOLD_PX
+ ) {
+ gesture.moved = true;
+ }
+ };
+ const finishPointerGesture = () => {
+ if (gesture.active && gesture.moved) {
+ gesture.suppressClick = true;
+ }
+ gesture.active = false;
+ };
+ const handleOverlayClick = (event) => {
+ if (!shouldCaptureOverlayPoint) return;
+ if (gesture.suppressClick || gesture.moved) {
+ gesture.suppressClick = false;
+ gesture.moved = false;
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const containerRect = map.getContainer().getBoundingClientRect();
+ const containerPoint = L.point(
+ event.clientX - containerRect.left,
+ event.clientY - containerRect.top
+ );
+ const layerPoint = map.containerPointToLayerPoint(containerPoint);
+ const { center, width, height } = getOverlayMetricsFromBounds(map, overlay.getBounds());
+ const localOffset = rotatePointOffset(
+ L.point(layerPoint.x - center.x, layerPoint.y - center.y),
+ -rotationRef.current
+ );
+
+ onAutoAlignOverlayPoint?.({
+ x: clamp(localOffset.x / width + 0.5, 0, 1),
+ y: clamp(localOffset.y / height + 0.5, 0, 1),
+ });
+ };
+
+ element.style.pointerEvents = shouldCaptureOverlayPoint ? 'auto' : 'none';
+ element.style.cursor = shouldCaptureOverlayPoint ? 'crosshair' : '';
+ element.addEventListener('pointerdown', handlePointerDown, true);
+ window.addEventListener('pointermove', handlePointerMove, true);
+ window.addEventListener('pointerup', finishPointerGesture, true);
+ window.addEventListener('pointercancel', finishPointerGesture, true);
+ element.addEventListener('click', handleOverlayClick);
+
+ return () => {
+ element.removeEventListener('pointerdown', handlePointerDown, true);
+ window.removeEventListener('pointermove', handlePointerMove, true);
+ window.removeEventListener('pointerup', finishPointerGesture, true);
+ window.removeEventListener('pointercancel', finishPointerGesture, true);
+ element.removeEventListener('click', handleOverlayClick);
+ element.style.pointerEvents = 'none';
+ element.style.cursor = '';
+ gesture.active = false;
+ gesture.moved = false;
+ gesture.suppressClick = false;
+ };
+ }, [autoAlignExpectingOverlayPoint, map, onAutoAlignOverlayPoint, rotation, bounds]);
+
+ return null;
+};
+
+ImageOverlayTool.propTypes = {
+ imageUrl: PropTypes.string,
+ bounds: PropTypes.object,
+ onBoundsChange: PropTypes.func.isRequired,
+ locked: PropTypes.bool,
+ opacity: PropTypes.number,
+ rotation: PropTypes.number,
+ onRotationChange: PropTypes.func,
+ aspectRatio: PropTypes.number,
+ autoAlignExpectingOverlayPoint: PropTypes.bool,
+ onAutoAlignOverlayPoint: PropTypes.func,
+};
+
+const OverlayAutoAlignController = ({ expectingMapPoint, onMapPointAdd }) => {
+ const map = useMap();
+ const pointerGestureRef = useRef({
+ active: false,
+ moved: false,
+ suppressClick: false,
+ startX: 0,
+ startY: 0,
+ });
+
+ useEffect(() => {
+ const container = map.getContainer();
+ const previousCursor = container.style.cursor;
+
+ container.style.cursor = expectingMapPoint ? 'crosshair' : previousCursor;
+
+ return () => {
+ container.style.cursor = previousCursor;
+ };
+ }, [expectingMapPoint, map]);
+
+ useEffect(() => {
+ if (!expectingMapPoint) return undefined;
+
+ const container = map.getContainer();
+ const gesture = pointerGestureRef.current;
+ const handlePointerDown = (event) => {
+ gesture.active = true;
+ gesture.moved = false;
+ gesture.suppressClick = false;
+ gesture.startX = event.clientX;
+ gesture.startY = event.clientY;
+ };
+ const handlePointerMove = (event) => {
+ if (!gesture.active || gesture.moved) return;
+ if (
+ Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) >
+ PICK_DRAG_THRESHOLD_PX
+ ) {
+ gesture.moved = true;
+ }
+ };
+ const finishPointerGesture = () => {
+ if (gesture.active && gesture.moved) {
+ gesture.suppressClick = true;
+ }
+ gesture.active = false;
+ };
+ const handleMapClick = (event) => {
+ if (gesture.suppressClick || gesture.moved) {
+ gesture.suppressClick = false;
+ gesture.moved = false;
+ return;
+ }
+ if (event.originalEvent) {
+ L.DomEvent.stop(event.originalEvent);
+ }
+ gesture.suppressClick = false;
+ gesture.moved = false;
+ onMapPointAdd?.(event.latlng);
+ };
+
+ container.addEventListener('pointerdown', handlePointerDown, true);
+ window.addEventListener('pointermove', handlePointerMove, true);
+ window.addEventListener('pointerup', finishPointerGesture, true);
+ window.addEventListener('pointercancel', finishPointerGesture, true);
+ map.on('click', handleMapClick);
+ return () => {
+ container.removeEventListener('pointerdown', handlePointerDown, true);
+ window.removeEventListener('pointermove', handlePointerMove, true);
+ window.removeEventListener('pointerup', finishPointerGesture, true);
+ window.removeEventListener('pointercancel', finishPointerGesture, true);
+ map.off('click', handleMapClick);
+ gesture.active = false;
+ gesture.moved = false;
+ gesture.suppressClick = false;
+ };
+ }, [expectingMapPoint, map, onMapPointAdd]);
+
+ return null;
+};
+
+OverlayAutoAlignController.propTypes = {
+ expectingMapPoint: PropTypes.bool,
+ onMapPointAdd: PropTypes.func,
+};
+
+const OverlayAutoAlignPreview = ({ bounds, rotation = 0, matches }) => {
+ const map = useMap();
+
+ const overlayLatLngs = useMemo(() => {
+ if (!map || !bounds || matches.length === 0) return [];
+
+ return matches
+ .map((match) =>
+ getDisplayedOverlayLayerPoint({
+ map,
+ bounds,
+ rotation,
+ normalizedPoint: match.overlayPoint,
+ })
+ )
+ .filter(Boolean)
+ .map((layerPoint, index) => ({
+ latlng: map.layerPointToLatLng(layerPoint),
+ complete: Boolean(matches[index]?.mapPoint),
+ }));
+ }, [bounds, map, matches, rotation]);
+
+ const mapLatLngs = useMemo(
+ () =>
+ matches
+ .filter((match) => match.mapPoint)
+ .map((match) => ({
+ latlng: match.mapPoint,
+ complete: true,
+ })),
+ [matches]
+ );
+
+ if (overlayLatLngs.length === 0 && mapLatLngs.length === 0) return null;
+
+ return (
+ <>
+ {overlayLatLngs.map(({ latlng, complete }, index) => (
+
+ ))}
+ {mapLatLngs.map(({ latlng }, index) => (
+
+ ))}
+ >
+ );
+};
+
+OverlayAutoAlignPreview.propTypes = {
+ bounds: PropTypes.object,
+ rotation: PropTypes.number,
+ matches: PropTypes.arrayOf(
+ PropTypes.shape({
+ overlayPoint: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ }),
+ mapPoint: PropTypes.shape({
+ lat: PropTypes.number.isRequired,
+ lng: PropTypes.number.isRequired,
+ }),
+ })
+ ),
+};
+
const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = 'dynamic' }) => {
const navigate = useNavigate();
const [remotePoints, setRemotePoints] = useState(null); // null = not loaded
@@ -1359,6 +2055,19 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
const editUndoStackRef = useRef({});
const lastEditCoordsRef = useRef({});
const drawingCoordsRef = useRef([]);
+ // Image overlay reference layer state
+ const [refImageUrl, setRefImageUrl] = useState(null);
+ const [refImageBounds, setRefImageBounds] = useState(null);
+ const [refImageLocked, setRefImageLocked] = useState(false);
+ const [refImageOpacity, setRefImageOpacity] = useState(0.5);
+ const [refImageRotation, setRefImageRotation] = useState(0);
+ const [refImageAspectRatio, setRefImageAspectRatio] = useState(1);
+ const [refImageAutoAlignActive, setRefImageAutoAlignActive] = useState(false);
+ const [refImageAutoAlignMatches, setRefImageAutoAlignMatches] = useState([]);
+ const [refImageOpacityInput, setRefImageOpacityInput] = useState('50');
+ const [refImageRotationInput, setRefImageRotationInput] = useState('0');
+ const refImageInputRef = useRef(null);
+ const refImageObjectUrlRef = useRef(null);
const [manualCoordsMode, setManualCoordsMode] = useState(false);
const [manualCoords, setManualCoords] = useState([{ value: '' }, { value: '' }]);
const [manualCoordsErrors, setManualCoordsErrors] = useState([]);
@@ -1367,6 +2076,7 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
useEffect(() => {
drawingCoordsRef.current = drawingCoords;
}, [drawingCoords]);
+
useEffect(() => {
if (uploadState.status === 'success') {
const t = setTimeout(() => {
@@ -1376,6 +2086,170 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
}
}, [uploadState.status]);
+ useEffect(() => {
+ setRefImageOpacityInput(String(Math.round(refImageOpacity * 100)));
+ }, [refImageOpacity]);
+
+ useEffect(() => {
+ setRefImageRotationInput(String(Math.round(refImageRotation)));
+ }, [refImageRotation]);
+
+ const commitRefImageOpacityInput = useCallback(
+ (rawValue) => {
+ const parsedPercent = parseFloat(rawValue);
+ if (!Number.isFinite(parsedPercent)) {
+ setRefImageOpacityInput(String(Math.round(refImageOpacity * 100)));
+ return;
+ }
+
+ const nextOpacity = clamp(parsedPercent / 100, 0.05, 1);
+ setRefImageOpacity(nextOpacity);
+ setRefImageOpacityInput(String(Math.round(nextOpacity * 100)));
+ },
+ [refImageOpacity]
+ );
+
+ const commitRefImageRotationInput = useCallback(
+ (rawValue) => {
+ const parsedDegrees = parseFloat(rawValue);
+ if (!Number.isFinite(parsedDegrees)) {
+ setRefImageRotationInput(String(Math.round(refImageRotation)));
+ return;
+ }
+
+ const nextRotation = clamp(parsedDegrees, -180, 180);
+ setRefImageRotation(nextRotation);
+ setRefImageRotationInput(String(Math.round(nextRotation)));
+ },
+ [refImageRotation]
+ );
+
+ const resetRefImageAutoAlignMatches = useCallback(() => {
+ setRefImageAutoAlignMatches([]);
+ }, []);
+
+ const cancelRefImageAutoAlign = useCallback(() => {
+ setRefImageAutoAlignActive(false);
+ resetRefImageAutoAlignMatches();
+ }, [resetRefImageAutoAlignMatches]);
+
+ const startRefImageAutoAlign = useCallback(() => {
+ setRefImageAutoAlignActive(true);
+ resetRefImageAutoAlignMatches();
+ }, [resetRefImageAutoAlignMatches]);
+
+ const refImageLatestAutoAlignMatch =
+ refImageAutoAlignMatches[refImageAutoAlignMatches.length - 1] || null;
+ const refImageAutoAlignExpectingOverlayPoint =
+ refImageAutoAlignActive &&
+ (!refImageLatestAutoAlignMatch || Boolean(refImageLatestAutoAlignMatch.mapPoint));
+ const refImageAutoAlignExpectingMapPoint =
+ refImageAutoAlignActive &&
+ Boolean(refImageLatestAutoAlignMatch?.overlayPoint) &&
+ !refImageLatestAutoAlignMatch?.mapPoint;
+ const refImageCompletedAutoAlignMatches = useMemo(
+ () =>
+ refImageAutoAlignMatches.filter(
+ (match) => Boolean(match.overlayPoint) && Boolean(match.mapPoint)
+ ),
+ [refImageAutoAlignMatches]
+ );
+
+ const handleRefImageAutoAlignOverlayPoint = useCallback(
+ (normalizedPoint) => {
+ if (!refImageAutoAlignActive) return;
+ setRefImageAutoAlignMatches((prev) => {
+ const lastMatch = prev[prev.length - 1];
+ if (lastMatch && !lastMatch.mapPoint) return prev;
+ return [...prev, { overlayPoint: normalizedPoint, mapPoint: null }];
+ });
+ },
+ [refImageAutoAlignActive]
+ );
+
+ const handleRefImageAutoAlignMapPoint = useCallback(
+ (latlng) => {
+ if (!refImageAutoAlignActive) return;
+ setRefImageAutoAlignMatches((prev) => {
+ const lastIndex = prev.length - 1;
+ const lastMatch = prev[lastIndex];
+ if (!lastMatch || !lastMatch.overlayPoint || lastMatch.mapPoint) return prev;
+
+ const next = [...prev];
+ next[lastIndex] = { ...lastMatch, mapPoint: latlng };
+ return next;
+ });
+ },
+ [refImageAutoAlignActive]
+ );
+
+ const handleRefImageAutoAlignUndo = useCallback(() => {
+ setRefImageAutoAlignMatches((prev) => {
+ if (prev.length === 0) return prev;
+
+ const lastMatch = prev[prev.length - 1];
+ if (!lastMatch.mapPoint) return prev.slice(0, -1);
+
+ const next = [...prev];
+ next[next.length - 1] = { ...lastMatch, mapPoint: null };
+ return next;
+ });
+ }, []);
+
+ const handleRefImageAutoAlignApply = useCallback(() => {
+ const map = mapInstanceRef.current;
+ const aspectRatio = refImageAspectRatio > 0 ? refImageAspectRatio : 1;
+ if (!map) return;
+
+ if (refImageCompletedAutoAlignMatches.length < 2) {
+ setToastConfig({
+ title: 'Need More Point Matches',
+ description:
+ 'Add at least two completed overlay-to-basemap point matches before applying alignment.',
+ variant: 'destructive',
+ });
+ setShowToast(true);
+ return;
+ }
+
+ const sourcePoints = refImageCompletedAutoAlignMatches.map((match) =>
+ getUnitOverlayPoint(match.overlayPoint, aspectRatio)
+ );
+ const targetPoints = refImageCompletedAutoAlignMatches.map((match) =>
+ map.latLngToLayerPoint(match.mapPoint)
+ );
+ const transform = solveBestFitOverlayTransform(sourcePoints, targetPoints);
+
+ if (!transform || !Number.isFinite(transform.heightScale)) {
+ setToastConfig({
+ title: 'Alignment Failed',
+ description:
+ 'The selected points could not produce a stable alignment. Try spreading the matches further apart.',
+ variant: 'destructive',
+ });
+ setShowToast(true);
+ return;
+ }
+
+ const nextHeight = Math.max(
+ transform.heightScale,
+ MIN_OVERLAY_SIZE_PX,
+ MIN_OVERLAY_SIZE_PX / Math.max(aspectRatio, 1)
+ );
+ const nextWidth = nextHeight * aspectRatio;
+
+ setRefImageBounds(buildBoundsFromMetrics(map, transform.center, nextWidth, nextHeight));
+ setRefImageRotation(transform.rotation);
+ setToastConfig({
+ title: 'Overlay Aligned',
+ description: `Applied best-fit alignment from ${refImageCompletedAutoAlignMatches.length} point matches.`,
+ variant: 'default',
+ });
+ setShowToast(true);
+ setRefImageAutoAlignActive(false);
+ resetRefImageAutoAlignMatches();
+ }, [refImageAspectRatio, refImageCompletedAutoAlignMatches, resetRefImageAutoAlignMatches]);
+
// No global snapshots; undo only in drawing or when editing a selected geometry
const performUndo = useCallback(() => {
@@ -1485,6 +2359,115 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [performUndo, creatingNew, selectedId, manualPlacedId]);
+ const handleRefImageUpload = useCallback(
+ (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ e.target.value = '';
+
+ // 1. Validate MIME type
+ const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ setToastConfig({
+ title: 'Invalid Format',
+ description: 'Unsupported file type. Please upload a JPEG, PNG, GIF, or WebP image.',
+ variant: 'destructive',
+ });
+ setShowToast(true);
+ return;
+ }
+
+ // 2. Enforce a 20 MB file size cap to prevent browser freeze on huge files
+ const MAX_BYTES = 20 * 1024 * 1024;
+ if (file.size > MAX_BYTES) {
+ setToastConfig({
+ title: 'File Too Large',
+ description: 'Image exceeds the 20 MB limit.',
+ variant: 'destructive',
+ });
+ setShowToast(true);
+ return;
+ }
+
+ // 3. Verify the file is actually a decodable image before committing state
+ const candidateUrl = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ const nextAspectRatio =
+ img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1;
+ // Revoke previous object URL
+ if (refImageObjectUrlRef.current) {
+ URL.revokeObjectURL(refImageObjectUrlRef.current);
+ }
+ refImageObjectUrlRef.current = candidateUrl;
+ setRefImageAutoAlignActive(false);
+ resetRefImageAutoAlignMatches();
+ // Place the image centred on current map view while preserving its natural aspect ratio.
+ const map = mapInstanceRef.current;
+ if (map) {
+ const center = map.getCenter();
+ const centerPoint = map.latLngToContainerPoint(center);
+ const mapSize = map.getSize();
+ const maxWidthPx = mapSize.x * 0.3;
+ const maxHeightPx = mapSize.y * 0.3;
+ let widthPx = maxWidthPx;
+ let heightPx = widthPx / nextAspectRatio;
+ if (heightPx > maxHeightPx) {
+ heightPx = maxHeightPx;
+ widthPx = heightPx * nextAspectRatio;
+ }
+
+ const southWest = map.containerPointToLatLng(
+ L.point(centerPoint.x - widthPx / 2, centerPoint.y + heightPx / 2)
+ );
+ const northEast = map.containerPointToLatLng(
+ L.point(centerPoint.x + widthPx / 2, centerPoint.y - heightPx / 2)
+ );
+ setRefImageBounds(L.latLngBounds(southWest, northEast));
+ }
+ setRefImageAspectRatio(nextAspectRatio);
+ setRefImageRotation(0);
+ setRefImageUrl(candidateUrl);
+ setRefImageLocked(false);
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(candidateUrl);
+ setToastConfig({
+ title: 'Decode Failed',
+ description:
+ 'The file could not be read as an image. It may be corrupt or an unsupported format.',
+ variant: 'destructive',
+ });
+ setShowToast(true);
+ };
+ img.src = candidateUrl;
+ },
+ [resetRefImageAutoAlignMatches, setToastConfig, setShowToast]
+ );
+
+ const handleRefImageRemove = useCallback(() => {
+ if (refImageObjectUrlRef.current) {
+ URL.revokeObjectURL(refImageObjectUrlRef.current);
+ refImageObjectUrlRef.current = null;
+ }
+ setRefImageUrl(null);
+ setRefImageBounds(null);
+ setRefImageLocked(false);
+ setRefImageRotation(0);
+ setRefImageAspectRatio(1);
+ setRefImageAutoAlignActive(false);
+ resetRefImageAutoAlignMatches();
+ }, [resetRefImageAutoAlignMatches]);
+
+ // Clean up object URL on unmount
+ useEffect(() => {
+ return () => {
+ if (refImageObjectUrlRef.current) {
+ URL.revokeObjectURL(refImageObjectUrlRef.current);
+ }
+ };
+ }, []);
+
const startAddPoint = useCallback(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
@@ -2509,7 +3492,10 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
changeset.delete.length > 0;
const totalChanges =
changeset.create.length + Object.keys(changeset.modify).length + changeset.delete.length;
-
+ const refImagePendingAutoAlignMatch =
+ refImageLatestAutoAlignMatch && !refImageLatestAutoAlignMatch.mapPoint
+ ? refImageLatestAutoAlignMatch
+ : null;
useEffect(() => {
if (showReviewPanel && !hasPendingChanges) {
setShowReviewPanel(false);
@@ -2570,7 +3556,10 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
}
return (
-
+
@@ -2612,8 +3601,8 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
);
})()}
-
-
+
+
{airportMetaLoading && !derivedCenter && (
@@ -2738,6 +3727,29 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
})()}
{/* Removed automatic BoundsFitter to prevent unwanted recentering while editing */}
+ {refImageUrl && refImageBounds && (
+
+ )}
+
+
Objects
+ {/* Image Overlay panel */}
+
+
+
+
+ Image Overlay
+
+ {refImageUrl ? (
+
+ setRefImageLocked((v) => !v)}
+ className="p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
+ >
+ {refImageLocked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ ) : (
+
refImageInputRef.current?.click()}
+ className="text-[11px] px-2 py-0.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors"
+ >
+ Upload
+
+ )}
+
+
+ {refImageUrl && (
+
+
+ Opacity
+ setRefImageOpacity(parseFloat(e.target.value))}
+ className="flex-1 h-1.5 accent-blue-500 cursor-pointer rounded-full bg-zinc-600"
+ />
+ setRefImageOpacityInput(e.target.value)}
+ onBlur={(e) => commitRefImageOpacityInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.currentTarget.blur();
+ }
+ }}
+ className="w-12 shrink-0 bg-zinc-900 border border-zinc-700 focus:border-zinc-500 focus:outline-none rounded px-1.5 py-1 text-[11px] text-right text-zinc-300 tabular-nums"
+ aria-label="Overlay opacity percent"
+ />
+ %
+
+
+ Rotate
+ setRefImageRotation(parseInt(e.target.value, 10) || 0)}
+ className="flex-1 h-1.5 accent-emerald-500 cursor-pointer rounded-full bg-zinc-600"
+ />
+ setRefImageRotationInput(e.target.value)}
+ onBlur={(e) => commitRefImageRotationInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.currentTarget.blur();
+ }
+ }}
+ className="w-12 shrink-0 bg-zinc-900 border border-zinc-700 focus:border-zinc-500 focus:outline-none rounded px-1.5 py-1 text-[11px] text-right text-zinc-300 tabular-nums"
+ aria-label="Overlay rotation degrees"
+ />
+ setRefImageRotation(0)}
+ title="Reset rotation"
+ className="p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+ disabled={Math.round(refImageRotation) === 0}
+ >
+
+
+
+
+ {/* Header */}
+
+ Auto Align
+ {!refImageAutoAlignActive ? (
+
+ Start
+
+ ) : (
+
+
+
+ )}
+
+
+ {refImageAutoAlignActive && (
+ <>
+ {/* Step instructions */}
+
+ {refImageAutoAlignExpectingOverlayPoint && (
+
+
Overlay
+
+ Click a point on your image overlay. Use a clear landmark like a
+ building corner, runway threshold, or intersection.
+
+
+ )}
+ {refImageAutoAlignExpectingMapPoint && (
+
+
Basemap
+
+ Click that same landmark on the basemap. You may need to lower the
+ overlay opacity to see the basemap clearly. Repeat process for
+ more pairs to improve accuracy.
+
+
+ )}
+ {!refImageAutoAlignExpectingOverlayPoint &&
+ !refImageAutoAlignExpectingMapPoint && (
+
+ Add more pairs or apply the alignment below.
+
+ )}
+
+
+ {/* Actions */}
+
+
+ Apply Alignment
+
+
+ Undo
+
+
+ >
+ )}
+
+
+ )}
+
{
const [showDeleteAirportConfirm, setShowDeleteAirportConfirm] = useState(false);
const [airportToDelete, setAirportToDelete] = useState(null);
const [deletingAirport, setDeletingAirport] = useState(false);
+ const [updatingContributionAirportId, setUpdatingContributionAirportId] = useState(null);
const token = getVatsimToken();
const [currentUserId, setCurrentUserId] = useState(null);
const [isLeadDev, setIsLeadDev] = useState(false);
@@ -1066,6 +1067,70 @@ const DivisionManagement = () => {
}
};
+ const handleContributionToggle = async (airport, contributionsEnabled) => {
+ if (!isDivisionMember) return;
+
+ setUpdatingContributionAirportId(airport.id);
+ setAirports((prev) =>
+ prev.map((a) =>
+ a.id === airport.id ? { ...a, contributions_enabled: contributionsEnabled } : a
+ )
+ );
+ try {
+ const response = await fetch(
+ `https://v2.stopbars.com/divisions/${divisionId}/airports/${airport.id}/contributions`,
+ {
+ method: 'PATCH',
+ headers: {
+ 'X-Vatsim-Token': token,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ contributionsEnabled }),
+ }
+ );
+
+ if (!response.ok) {
+ const contentType = response.headers.get('content-type');
+ if (contentType && contentType.includes('application/json')) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to update contribution setting');
+ }
+
+ const errorText = await response.text();
+ throw new Error(errorText || 'Failed to update contribution setting');
+ }
+
+ const updatedAirport = await response.json();
+ setAirports((prev) =>
+ prev.map((currentAirport) =>
+ currentAirport.id === updatedAirport.id ? updatedAirport : currentAirport
+ )
+ );
+ setToastConfig({
+ variant: 'success',
+ title: `${airport.icao} Contributions ${contributionsEnabled ? 'Enabled' : 'Disabled'}`,
+ description: contributionsEnabled
+ ? 'Contributions are now enabled for this airport.'
+ : 'Contributions are now disabled for this airport.',
+ });
+ setShowToast(true);
+ } catch (err) {
+ setAirports((prev) =>
+ prev.map((a) =>
+ a.id === airport.id ? { ...a, contributions_enabled: !contributionsEnabled } : a
+ )
+ );
+ setToastConfig({
+ variant: 'destructive',
+ title: 'Failed to Update Contribution Setting',
+ description: err.message || 'An error occurred while updating the contribution setting.',
+ });
+ setShowToast(true);
+ } finally {
+ setUpdatingContributionAirportId(null);
+ }
+ };
+
if (loading)
return (
@@ -1077,7 +1142,7 @@ const DivisionManagement = () => {
return (
-
+
{/* Header */}
@@ -1120,7 +1185,9 @@ const DivisionManagement = () => {
members.map((member) => {
const isSelf = String(currentUserId) === String(member.vatsim_id);
const removeDisabled =
- !canManageMembers || isSelf || (!canManageAsStaff && member.role === 'nav_head');
+ !canManageMembers ||
+ isSelf ||
+ (!canManageAsStaff && member.role === 'nav_head');
return (
{
)}
+ {airport.status === 'approved' && (
+
+
+
Scenery Contributions
+
+ {airport.contributions_enabled ? 'Enabled' : 'Disabled'}
+
+
+
+ handleContributionToggle(airport, !airport.contributions_enabled)
+ }
+ disabled={
+ !isDivisionMember || updatingContributionAirportId === airport.id
+ }
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+ airport.contributions_enabled ? 'bg-emerald-500' : 'bg-zinc-700'
+ } ${
+ !isDivisionMember || updatingContributionAirportId === airport.id
+ ? 'opacity-50 cursor-not-allowed'
+ : 'cursor-pointer'
+ }`}
+ >
+
+
+
+ )}
diff --git a/src/components/home/Airports.jsx b/src/components/home/Airports.jsx
index 1f2b4d1..b2fafc5 100644
--- a/src/components/home/Airports.jsx
+++ b/src/components/home/Airports.jsx
@@ -118,9 +118,9 @@ export const Airports = () => {
-
+
Global BARS Status
-
+
{
navigate('/status')}
- className="flex items-center self-start sm:self-center"
+ className="flex items-center self-start sm:self-center -mt-1 sm:mt-0"
>
Global Status Page
diff --git a/src/components/home/Documentation.jsx b/src/components/home/Documentation.jsx
index ed0f4ec..54fb0a2 100644
--- a/src/components/home/Documentation.jsx
+++ b/src/components/home/Documentation.jsx
@@ -35,7 +35,7 @@ export const Documentation = () => {
className="opacity-0 translate-y-12 transition-all duration-1000 ease-out"
>
-
+
Open Source
Become a Contributor
diff --git a/src/components/home/DonationBanner.jsx b/src/components/home/DonationBanner.jsx
index dc14643..b9dd638 100644
--- a/src/components/home/DonationBanner.jsx
+++ b/src/components/home/DonationBanner.jsx
@@ -8,7 +8,7 @@ export const DonationBanner = () => {
-
Keep BARS free for everyone!
+ Help Keep BARS Free!
@@ -19,8 +19,8 @@ export const DonationBanner = () => {
As our community grows, so do our costs. Your support directly keeps BARS free and
- accessible for everyone. All our finances are completely public, donations,
- expenses, and transactions, ensuring your support is used responsibly.
+ accessible for everyone. All our finances are completely public, including
+ donations, expenses, and transactions, ensuring your support is used responsibly.
diff --git a/src/components/home/Features.jsx b/src/components/home/Features.jsx
index 9e46097..1442c72 100644
--- a/src/components/home/Features.jsx
+++ b/src/components/home/Features.jsx
@@ -34,7 +34,7 @@ export const Features = () => {
Core Features
- BARS brings professional-grade airport lighting simulation to your flight simulator,
+ BARS brings advanced airport lighting simulation features to your flight simulator,
completely free and in real time with VATSIM.
diff --git a/src/components/home/Hero.jsx b/src/components/home/Hero.jsx
index c38fb08..ecfa2f7 100644
--- a/src/components/home/Hero.jsx
+++ b/src/components/home/Hero.jsx
@@ -2,18 +2,15 @@ import { useEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { Button } from '../shared/Button';
-const previewOptions = [
- 'Stopbars',
- 'Follow The Greens',
- 'Lead On Lights',
- 'EuroScope Plugin',
- 'vatSys Plugin',
-];
-
export const Hero = () => {
- const [selectedPreview, setSelectedPreview] = useState(previewOptions[0]);
const [downloadInfo, setDownloadInfo] = useState(null);
const [downloadAvailable, setDownloadAvailable] = useState(true);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => setVisible(true));
+ return () => cancelAnimationFrame(raf);
+ }, []);
useEffect(() => {
let mounted = true;
@@ -44,20 +41,36 @@ export const Hero = () => {
return (
- Advanced Airport
- Lighting Simulation
+
+ Advanced Airport
+
+
+ Lighting Simulation
+
-
+
BARS revolutionizes your VATSIM experience with completely free realistic airport lighting
simulation. Fully compatible with Microsoft Flight Simulator 2020, and 2024, seamlessly
integrated with both default and major third-party sceneries.
-
+
{
-
-
- {previewOptions.map((option) => {
- const isSelected = option === selectedPreview;
- return (
- setSelectedPreview(option)}
- className={`px-5 py-2.5 rounded-full text-sm md:text-base border transition-colors duration-300 cursor-pointer ${
- isSelected
- ? 'bg-white text-black border-white'
- : 'bg-zinc-900/60 text-zinc-300 border-zinc-700 hover:bg-zinc-800'
- }`}
- aria-pressed={isSelected}
- >
- {option}
-
- );
- })}
-
-
-
+
+
- {selectedPreview} Placeholder
+ Video Placeholder
diff --git a/src/components/home/Support.jsx b/src/components/home/Support.jsx
index f1f74e0..7801f3f 100644
--- a/src/components/home/Support.jsx
+++ b/src/components/home/Support.jsx
@@ -4,9 +4,6 @@ import { Button } from '../shared/Button';
export const Support = () => {
return (
- {/* Background decoration*/}
-
-
{/* Header */}
@@ -21,8 +18,8 @@ export const Support = () => {
{/* Documentation Card */}
-
-
+
+
Documentation
diff --git a/src/components/shared/DiscordRedirect.jsx b/src/components/shared/DiscordRedirect.jsx
index 74b99b3..09ccccb 100644
--- a/src/components/shared/DiscordRedirect.jsx
+++ b/src/components/shared/DiscordRedirect.jsx
@@ -5,5 +5,5 @@ export const DiscordRedirect = () => {
window.location.href = 'https://discord.gg/7EhmtwKWzs';
}, []);
- return
Redirecting to Discord...
;
+ return;
};
diff --git a/src/components/shared/DocsRedirect.jsx b/src/components/shared/DocsRedirect.jsx
index ce38b45..7cce04a 100644
--- a/src/components/shared/DocsRedirect.jsx
+++ b/src/components/shared/DocsRedirect.jsx
@@ -5,5 +5,5 @@ export const DocsRedirect = () => {
window.location.href = 'https://docs.stopbars.com';
}, []);
- return
Redirecting to Documentation...
;
+ return;
};
diff --git a/src/components/shared/DonateRedirect.jsx b/src/components/shared/DonateRedirect.jsx
new file mode 100644
index 0000000..7031e9f
--- /dev/null
+++ b/src/components/shared/DonateRedirect.jsx
@@ -0,0 +1,9 @@
+import { useEffect } from 'react';
+
+export const DonateRedirect = () => {
+ useEffect(() => {
+ window.location.href = 'https://opencollective.com/stopbars';
+ }, []);
+
+ return;
+};
diff --git a/src/components/shared/Dropdown.jsx b/src/components/shared/Dropdown.jsx
index 62c4005..7cbdef4 100644
--- a/src/components/shared/Dropdown.jsx
+++ b/src/components/shared/Dropdown.jsx
@@ -24,7 +24,8 @@ export function Dropdown({
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
- const currentOption = options.find((opt) => opt.value === value);
+ const currentOption = options.find((opt) => !opt.isHeader && opt.value === value);
+ const TriggerIcon = currentOption?.icon || null;
// Close dropdown when clicking outside
useEffect(() => {
@@ -64,7 +65,8 @@ export function Dropdown({
disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
-
+
+ {TriggerIcon && }
{currentOption?.label || placeholder}
- {options.map((option, index) => (
- {
- e.preventDefault();
- e.stopPropagation();
- onChange(option.value);
- setIsOpen(false);
- }}
- className={`w-full px-4 py-2.5 text-left hover:bg-zinc-700 first:rounded-t-lg last:rounded-b-lg transition-all duration-150 ${
- value === option.value
- ? 'bg-zinc-700 text-blue-400'
- : 'text-white hover:text-zinc-100'
- }`}
- style={{
- animationDelay: `${index * 25}ms`,
- animationFillMode: 'both',
- }}
- >
- {option.label}
-
- ))}
+ {options.map((option, index) => {
+ if (option.isHeader) {
+ return (
+
+ );
+ }
+ const OptionIcon = option.icon;
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onChange(option.value);
+ setIsOpen(false);
+ }}
+ className={`w-full px-4 py-2.5 text-left hover:bg-zinc-700 transition-all duration-150 flex items-center gap-2 ${
+ value === option.value
+ ? 'bg-zinc-700 text-blue-400'
+ : 'text-white hover:text-zinc-100'
+ }`}
+ style={{
+ animationDelay: `${index * 25}ms`,
+ animationFillMode: 'both',
+ }}
+ >
+ {OptionIcon && }
+ {option.label}
+
+ );
+ })}
)}
@@ -106,8 +121,10 @@ export function Dropdown({
Dropdown.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
- value: PropTypes.string.isRequired,
+ value: PropTypes.string,
label: PropTypes.string.isRequired,
+ icon: PropTypes.elementType,
+ isHeader: PropTypes.bool,
})
),
value: PropTypes.string,
diff --git a/src/components/shared/Toast.jsx b/src/components/shared/Toast.jsx
index 1ab9948..d8bc2e3 100644
--- a/src/components/shared/Toast.jsx
+++ b/src/components/shared/Toast.jsx
@@ -103,7 +103,7 @@ export const Toast = ({
return (
{
+export const Tooltip = ({ children, content, className = '', open = false }) => {
if (!content) return children;
return (
{children}
-
+
{content}
{/* Arrow */}
@@ -19,4 +21,5 @@ Tooltip.propTypes = {
children: PropTypes.node.isRequired,
content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
className: PropTypes.string,
+ open: PropTypes.bool,
};
diff --git a/src/components/staff/AirportManagement.jsx b/src/components/staff/AirportManagement.jsx
index 416f474..af6b529 100644
--- a/src/components/staff/AirportManagement.jsx
+++ b/src/components/staff/AirportManagement.jsx
@@ -27,7 +27,7 @@ const AirportCard = ({ airport, onApprove, loadingState, onInfoClick }) => {
return (
-
+
{airport.icao}
•
@@ -51,6 +51,25 @@ const AirportCard = ({ airport, onApprove, loadingState, onInfoClick }) => {
{isApproved ? airport.approved_by : airport.requested_by}
+ {isApproved && (
+
+
+ Contributions {airport.contributions_enabled ? 'Enabled' : 'Disabled'}
+
+ )}
{isPending && (
@@ -104,6 +123,7 @@ AirportCard.propTypes = {
division_name: PropTypes.string.isRequired,
requested_by: PropTypes.string,
approved_by: PropTypes.string,
+ contributions_enabled: PropTypes.bool,
}).isRequired,
onApprove: PropTypes.func.isRequired,
loadingState: PropTypes.shape({
@@ -116,7 +136,6 @@ AirportCard.propTypes = {
const AirportManagement = () => {
const [airports, setAirports] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
const [loadingState, setLoadingState] = useState({ id: null, action: null });
const [searchTerm, setSearchTerm] = useSearchQuery();
const [toast, setToast] = useState(null);
@@ -151,7 +170,11 @@ const AirportManagement = () => {
setAirports(sortedAirports);
} catch (err) {
- setError(err.message);
+ showToast({
+ title: 'Failed to load airports',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setLoading(false);
}
@@ -190,7 +213,6 @@ const AirportManagement = () => {
variant: 'success',
});
} catch (err) {
- setError(err.message);
showToast({
title: 'Error',
description: err.message,
@@ -274,18 +296,18 @@ const AirportManagement = () => {
Manage and review division airports
-
+
{pendingCount} pending
-
@@ -298,10 +320,6 @@ const AirportManagement = () => {
Loading airports...
- ) : error ? (
-
- {error}
-
) : !filteredAirports?.length ? (
@@ -325,10 +343,10 @@ const AirportManagement = () => {
type="button"
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
disabled={safePage === 1}
- className="justify-self-start flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 hover:bg-zinc-700/60 disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label="Previous page"
+ className="justify-self-start flex items-center p-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 hover:bg-zinc-700/60 disabled:opacity-50 disabled:cursor-not-allowed"
>
- Previous
Page {safePage} of{' '}
@@ -338,9 +356,9 @@ const AirportManagement = () => {
type="button"
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={safePage === totalPages}
- className="justify-self-end flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 hover:bg-zinc-700/60 disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label="Next page"
+ className="justify-self-end flex items-center p-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 hover:bg-zinc-700/60 disabled:opacity-50 disabled:cursor-not-allowed"
>
- Next
diff --git a/src/components/staff/BanManagement.jsx b/src/components/staff/BanManagement.jsx
index 4ff7c31..0e5256f 100644
--- a/src/components/staff/BanManagement.jsx
+++ b/src/components/staff/BanManagement.jsx
@@ -2,17 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getVatsimToken } from '../../utils/cookieUtils';
import { formatLocalDateTime } from '../../utils/dateUtils';
+import { Card } from '../shared/Card';
import { Dialog } from '../shared/Dialog';
-import {
- AlertTriangle,
- AlertOctagon,
- Ban as BanIcon,
- Check,
- Loader,
- Trash2,
- UserX,
- FileText,
-} from 'lucide-react';
+import { Toast } from '../shared/Toast';
+import { AlertOctagon, Ban as BanIcon, Loader, Trash2, UserX, FileText } from 'lucide-react';
const API_BASE = 'https://v2.stopbars.com';
@@ -21,8 +14,12 @@ export default function BanManagement() {
const [searchParams, setSearchParams] = useSearchParams();
const [bans, setBans] = useState([]);
const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(null);
+ const [toast, setToast] = useState({
+ show: false,
+ title: '',
+ description: '',
+ variant: 'default',
+ });
const [viewingReason, setViewingReason] = useState(null); // { targetId, reason }
const [removingBan, setRemovingBan] = useState(null); // targetId to remove
const [isRemovingBan, setIsRemovingBan] = useState(false);
@@ -56,14 +53,18 @@ export default function BanManagement() {
const fetchBans = async () => {
setLoading(true);
- setError(null);
try {
const res = await fetch(`${API_BASE}/bans`, { headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
setBans(Array.isArray(data?.bans) ? data.bans : []);
} catch (e) {
- setError(e.message || 'Failed to load bans');
+ setToast({
+ show: true,
+ title: 'Failed to Load Bans',
+ description: e.message || 'Failed to load bans.',
+ variant: 'destructive',
+ });
} finally {
setLoading(false);
}
@@ -71,25 +72,26 @@ export default function BanManagement() {
useEffect(() => {
if (!token) {
- setError('Authentication required.');
+ setToast({
+ show: true,
+ title: 'Authentication Required',
+ description: 'A valid VATSIM token is required to manage bans.',
+ variant: 'destructive',
+ });
return;
}
fetchBans();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
- // Auto-dismiss success after ~4 seconds
- useEffect(() => {
- if (!success) return;
- const t = setTimeout(() => setSuccess(null), 4000);
- return () => clearTimeout(t);
- }, [success]);
-
const handleCreateBan = async () => {
- setError(null);
- setSuccess(null);
if (!vatsimId.trim()) {
- setError('VATSIM CID is required');
+ setToast({
+ show: true,
+ title: 'Validation Error',
+ description: 'VATSIM CID is required.',
+ variant: 'destructive',
+ });
return;
}
const body = {
@@ -106,13 +108,23 @@ export default function BanManagement() {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
- setSuccess('Ban created/updated successfully');
+ setToast({
+ show: true,
+ title: 'Ban Applied',
+ description: 'The ban has been successfully created or updated.',
+ variant: 'success',
+ });
setVatsimId('');
setReason('');
setExpiresAtLocal('');
await fetchBans();
} catch (e) {
- setError(e.message || 'Failed to create ban');
+ setToast({
+ show: true,
+ title: 'Failed to Apply Ban',
+ description: e.message || 'Failed to create ban.',
+ variant: 'destructive',
+ });
} finally {
setLoading(false);
}
@@ -120,8 +132,6 @@ export default function BanManagement() {
const handleRemoveBan = async () => {
if (!removingBan) return;
- setError(null);
- setSuccess(null);
try {
setIsRemovingBan(true);
const res = await fetch(`${API_BASE}/bans/${encodeURIComponent(removingBan)}`, {
@@ -132,11 +142,21 @@ export default function BanManagement() {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `${res.status} ${res.statusText}`);
}
- setSuccess('Ban removed');
+ setToast({
+ show: true,
+ title: 'Ban Removed',
+ description: 'The ban has been removed',
+ variant: 'success',
+ });
setRemovingBan(null);
await fetchBans();
} catch (e) {
- setError(e.message || 'Failed to remove ban');
+ setToast({
+ show: true,
+ title: 'Failed to Remove Ban',
+ description: e.message || 'Failed to remove ban.',
+ variant: 'destructive',
+ });
} finally {
setIsRemovingBan(false);
}
@@ -226,26 +246,8 @@ export default function BanManagement() {
)}
- {/* Status Messages */}
- {(error || success) && (
-
- {error ? (
-
- ) : (
-
- )}
-
- {error || success}
-
-
- )}
-
{/* Create / Update Ban */}
-
+
@@ -253,7 +255,7 @@ export default function BanManagement() {
Create / Update Ban
-
+
VATSIM CID
@@ -262,11 +264,11 @@ export default function BanManagement() {
value={vatsimId}
onChange={(e) => setVatsimId(e.target.value.replace(/[^0-9]/g, ''))}
placeholder="e.g., 1234567"
- className="w-full px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
+ className="w-full min-w-0 px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
inputMode="numeric"
/>
-
+
Reason
@@ -275,10 +277,10 @@ export default function BanManagement() {
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ban reason (optional)"
- className="w-full px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
+ className="w-full min-w-0 px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
/>
-
+
Expires At
@@ -286,32 +288,20 @@ export default function BanManagement() {
type="datetime-local"
value={expiresAtLocal}
onChange={(e) => setExpiresAtLocal(e.target.value)}
- className="w-full px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
+ className="w-full min-w-0 px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
/>
-
+
Ban
- {
- setVatsimId('');
- setReason('');
- setExpiresAtLocal('');
- }}
- disabled={loading}
- className="px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-50 transition-all text-sm whitespace-nowrap"
- >
- Reset
-
-
+
{/* Existing Bans */}
@@ -374,7 +364,7 @@ export default function BanManagement() {
icon={AlertOctagon}
iconColor="red"
title="Remove Ban"
- description={`This action will remove the ban and allow ${removingBan} to access and use all BARS services again.`}
+ description={`Removing this ban will restore full access to BARS services for VATSIM CID ${removingBan}. This action cannot be undone.`}
isLoading={isRemovingBan}
closeOnBackdrop={!isRemovingBan}
closeOnEscape={!isRemovingBan}
@@ -394,6 +384,14 @@ export default function BanManagement() {
},
]}
>
+
+ setToast((t) => ({ ...t, show: false }))}
+ />
);
}
diff --git a/src/components/staff/CacheManagement.jsx b/src/components/staff/CacheManagement.jsx
index 10cb584..57091c0 100644
--- a/src/components/staff/CacheManagement.jsx
+++ b/src/components/staff/CacheManagement.jsx
@@ -194,7 +194,7 @@ export default function CacheManagement() {
Namespace
-
+
setNamespace('')}
- className="px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300 transition-all text-sm"
+ className="w-full sm:w-auto px-4 py-2.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300 transition-all text-sm"
>
Clear
@@ -249,7 +249,7 @@ export default function CacheManagement() {
Namespace
-
+
{loading ? (
@@ -284,7 +284,7 @@ export default function CacheManagement() {
{/* Purge ALL */}
-
+
@@ -299,7 +299,7 @@ export default function CacheManagement() {
{loading ? : }
Purge ALL
diff --git a/src/components/staff/ContactMessages.jsx b/src/components/staff/ContactMessages.jsx
index eccf4ae..85c7efb 100644
--- a/src/components/staff/ContactMessages.jsx
+++ b/src/components/staff/ContactMessages.jsx
@@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { getVatsimToken } from '../../utils/cookieUtils';
import { Dialog } from '../shared/Dialog';
+import { Toast } from '../shared/Toast';
import {
- AlertTriangle,
Loader,
Trash2,
Mail,
- CheckCircle2,
MessageSquare,
XCircle,
AlertOctagon,
@@ -56,17 +55,18 @@ export default function ContactMessages() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(null);
+ const [toast, setToast] = useState({
+ show: false,
+ title: '',
+ description: '',
+ variant: 'default',
+ });
const [selectedId, setSelectedId] = useState(null);
const [updatingStatusId, setUpdatingStatusId] = useState(null);
const [deletingMessage, setDeletingMessage] = useState(null);
const [isDeletingMessage, setIsDeletingMessage] = useState(false);
- const clearBanners = () => {
- setError(null);
- setSuccess(null);
- };
+ const clearBanners = () => {};
const fetchMessages = useCallback(async () => {
if (!token) return;
@@ -93,7 +93,12 @@ export default function ContactMessages() {
});
setMessages(normalized);
} catch (e) {
- setError(e.message || 'Error fetching messages');
+ setToast({
+ show: true,
+ title: 'Failed to load messages',
+ description: e.message || 'Error fetching messages',
+ variant: 'destructive',
+ });
} finally {
setLoading(false);
}
@@ -104,14 +109,7 @@ export default function ContactMessages() {
}, [fetchMessages]);
// Auto-dismiss success messages after 3 seconds
- useEffect(() => {
- if (success) {
- const timer = setTimeout(() => {
- setSuccess(null);
- }, 3000);
- return () => clearTimeout(timer);
- }
- }, [success]);
+ // (handled by Toast component)
const filteredMessages = messages; // Direct list (already newest first)
@@ -143,9 +141,19 @@ export default function ContactMessages() {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...(updatedObj || {}), status: newStatus } : m))
);
- setSuccess('Status updated');
+ setToast({
+ show: true,
+ title: 'Status updated',
+ description: `Status changed to ${newStatus}.`,
+ variant: 'success',
+ });
} catch (e) {
- setError(e.message || 'Failed to update status');
+ setToast({
+ show: true,
+ title: 'Failed to update status',
+ description: e.message || 'Failed to update status',
+ variant: 'destructive',
+ });
} finally {
setUpdatingStatusId(null);
}
@@ -168,10 +176,20 @@ export default function ContactMessages() {
// 204 No Content expected
setMessages((prev) => prev.filter((m) => m.id !== id));
if (selectedId === id) setSelectedId(null);
- setSuccess('Message deleted successfully');
+ setToast({
+ show: true,
+ title: 'Message deleted',
+ description: 'The message has been deleted successfully.',
+ variant: 'success',
+ });
setDeletingMessage(null);
} catch (e) {
- setError(e.message || 'Failed to delete message');
+ setToast({
+ show: true,
+ title: 'Failed to delete message',
+ description: e.message || 'Failed to delete message',
+ variant: 'destructive',
+ });
} finally {
setIsDeletingMessage(false);
}
@@ -204,23 +222,11 @@ export default function ContactMessages() {
{/* Status Messages */}
- {error && (
-
- )}
- {success && (
-
- )}
{/* Message Grid */}
{/* List */}
-
+
{filteredMessages.length === 0 ? (
@@ -311,7 +317,12 @@ export default function ContactMessages() {
if (selectedMessage.email) {
navigator.clipboard.writeText(selectedMessage.email).catch(() => {});
window.open('https://mail.stopbars.com', '_blank', 'noopener');
- setSuccess('Email copied & ZoHo opened');
+ setToast({
+ show: true,
+ title: 'Email copied & opened',
+ description: '',
+ variant: 'success',
+ });
}
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800/50 border border-zinc-700/50 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-all"
@@ -330,7 +341,7 @@ export default function ContactMessages() {
{/* Message Content */}
-
+
{selectedMessage.message || selectedMessage.body || '(No message content)'}
@@ -346,7 +357,7 @@ export default function ContactMessages() {
)}
) : (
-
+
Select a message to view details
@@ -399,6 +410,14 @@ export default function ContactMessages() {
+
+
setToast((t) => ({ ...t, show: false }))}
+ />
);
}
diff --git a/src/components/staff/ContributionManagement.jsx b/src/components/staff/ContributionManagement.jsx
index 213f445..14bdc01 100644
--- a/src/components/staff/ContributionManagement.jsx
+++ b/src/components/staff/ContributionManagement.jsx
@@ -427,7 +427,7 @@ const ReviewModal = ({ contribution, onClose, onApprove, onReject, onError }) =>
Contribution Details
{/* Row 1: Airport ICAO | Simulator */}
-
+
Airport ICAO
{contribution.airportIcao}
@@ -453,7 +453,7 @@ const ReviewModal = ({ contribution, onClose, onApprove, onReject, onError }) =>
{/* Row 2: Package | Submitted By */}
-
+
Package
diff --git a/src/components/staff/DivisionManagement.jsx b/src/components/staff/DivisionManagement.jsx
index 12bf98e..09cf586 100644
--- a/src/components/staff/DivisionManagement.jsx
+++ b/src/components/staff/DivisionManagement.jsx
@@ -11,7 +11,6 @@ import {
TowerControl,
Edit,
Trash2,
- AlertTriangle,
ChevronDown,
ChevronUp,
Loader,
@@ -24,7 +23,6 @@ import {
const DivisionManagement = () => {
const [divisions, setDivisions] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
const [expandedDivisions, setExpandedDivisions] = useState({});
const [divisionMembers, setDivisionMembers] = useState({});
const [divisionAirports, setDivisionAirports] = useState({});
@@ -59,7 +57,12 @@ const DivisionManagement = () => {
const divisionsData = await response.json();
setDivisions(divisionsData);
} catch (err) {
- setError(err.message);
+ setToast({
+ show: true,
+ title: 'Failed to load divisions',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setLoading(false);
}
@@ -146,7 +149,6 @@ const DivisionManagement = () => {
const handleDeleteDivision = async (divisionId) => {
setIsDeletingDivision(true);
- setError('');
const divisionName = deletingDivision?.name;
@@ -184,12 +186,10 @@ const DivisionManagement = () => {
const cancelDelete = () => {
setDeletingDivision(null);
setDeleteConfirmation('');
- setError('');
};
const handleEditDivision = async (divisionId, newName) => {
setIsEditingDivision(true);
- setError('');
try {
const response = await fetch(`https://v2.stopbars.com/divisions/${divisionId}`, {
@@ -238,12 +238,10 @@ const DivisionManagement = () => {
const cancelEdit = () => {
setEditingDivision(null);
setNewDivisionName('');
- setError('');
};
const handleCreateDivision = async () => {
setIsCreatingDivision(true);
- setError('');
try {
const response = await fetch('https://v2.stopbars.com/divisions', {
@@ -293,32 +291,41 @@ const DivisionManagement = () => {
setShowCreateDialog(false);
setCreateDivisionName('');
setCreateDivisionHeadCid('');
- setError('');
};
if (loading) {
return (
-
-
-
+ {/* Header skeleton */}
+
+ {/* Card skeletons */}
{[1, 2, 3].map((i) => (
-
-
-
+ {/* Mobile: name/date stacked, buttons below */}
+
+
-
@@ -330,18 +337,6 @@ const DivisionManagement = () => {
);
}
- if (error) {
- return (
-
-
Division Management
-
-
- );
- }
-
return (
@@ -354,7 +349,10 @@ const DivisionManagement = () => {
{divisions.length} division{divisions.length !== 1 ? 's' : ''}
-
setShowCreateDialog(true)}>
+ setShowCreateDialog(true)}
+ className="px-4 py-2 text-sm sm:px-6 sm:py-3"
+ >
Create Division
@@ -363,12 +361,6 @@ const DivisionManagement = () => {
{/* Status Messages */}
- {error && (
-
- )}
{divisions.length > 0 ? (
@@ -389,7 +381,7 @@ const DivisionManagement = () => {
>
-
+
{division.name}
@@ -399,7 +391,7 @@ const DivisionManagement = () => {
{formatDate(division.created_at)}
-
+
toggleDivisionExpansion(division.id)}
variant="outline"
@@ -495,7 +487,7 @@ const DivisionManagement = () => {
{/* Airports */}
-
+
Airports
@@ -509,7 +501,7 @@ const DivisionManagement = () => {
{airports.map((airport) => (
{airport.icao}
@@ -530,29 +522,57 @@ const DivisionManagement = () => {
-
+
- {getDataSubmitted(airport)
- ? 'Objects Added'
- : 'No objects yet'}
-
+ title={
+ getDataSubmitted(airport)
+ ? 'Some BARS objects exist'
+ : 'No BARS objects yet'
+ }
+ >
+
+ {getDataSubmitted(airport)
+ ? 'Objects Added'
+ : 'No objects yet'}
+
+ {airport.status === 'approved' && (
+
+
+ Contributions{' '}
+ {airport.contributions_enabled
+ ? 'Enabled'
+ : 'Disabled'}
+
+ )}
+
))}
diff --git a/src/components/staff/FAQManagement.jsx b/src/components/staff/FAQManagement.jsx
index 9df87dd..9308713 100644
--- a/src/components/staff/FAQManagement.jsx
+++ b/src/components/staff/FAQManagement.jsx
@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { Dialog } from '../shared/Dialog';
+import { Toast } from '../shared/Toast';
import {
HelpCircle,
- AlertTriangle,
- Check,
RefreshCw,
Plus,
Edit2,
@@ -23,8 +22,12 @@ import { getVatsimToken } from '../../utils/cookieUtils';
const FAQManagement = () => {
const [faqs, setFaqs] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
- const [success, setSuccess] = useState('');
+ const [toast, setToast] = useState({
+ show: false,
+ title: '',
+ description: '',
+ variant: 'default',
+ });
const [editingFaq, setEditingFaq] = useState(null);
const [deletingFaq, setDeletingFaq] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -53,7 +56,6 @@ const FAQManagement = () => {
const fetchFAQs = async () => {
try {
setLoading(true);
- setError('');
const response = await fetch('https://v2.stopbars.com/faqs');
@@ -69,7 +71,12 @@ const FAQManagement = () => {
);
setFaqs(sortedFaqs);
} catch (err) {
- setError('Failed to load FAQs. Please try again.');
+ setToast({
+ show: true,
+ title: 'Failed to load FAQs',
+ description: 'Please try again.',
+ variant: 'destructive',
+ });
console.error('Error fetching FAQs:', err);
} finally {
setLoading(false);
@@ -99,13 +106,21 @@ const FAQManagement = () => {
const data = await response.json();
setFaqs([...faqs, data]);
- setSuccess('FAQ published successfully');
+ setToast({
+ show: true,
+ title: 'FAQ published',
+ description: 'The FAQ has been published successfully.',
+ variant: 'success',
+ });
setIsAdding(false);
setNewFaq({ question: '', answer: '' });
- setTimeout(() => setSuccess(''), 3000);
} catch (err) {
- setError(err.message);
- setTimeout(() => setError(''), 5000);
+ setToast({
+ show: true,
+ title: 'Failed to publish FAQ',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setIsPublishing(false);
}
@@ -132,13 +147,21 @@ const FAQManagement = () => {
const data = await response.json();
setFaqs(faqs.map((faq) => (faq.id === faqId ? { ...faq, ...data } : faq)));
- setSuccess('FAQ updated successfully');
+ setToast({
+ show: true,
+ title: 'FAQ updated',
+ description: 'The FAQ has been updated successfully.',
+ variant: 'success',
+ });
setEditingFaq(null);
setEditForm({ question: '', answer: '' });
- setTimeout(() => setSuccess(''), 3000);
} catch (err) {
- setError(err.message);
- setTimeout(() => setError(''), 5000);
+ setToast({
+ show: true,
+ title: 'Failed to update FAQ',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setIsSaving(false);
}
@@ -161,12 +184,20 @@ const FAQManagement = () => {
}
setFaqs(faqs.filter((faq) => faq.id !== faqId));
- setSuccess('FAQ deleted successfully');
+ setToast({
+ show: true,
+ title: 'FAQ deleted',
+ description: 'The FAQ has been deleted successfully.',
+ variant: 'success',
+ });
setDeletingFaq(null);
- setTimeout(() => setSuccess(''), 3000);
} catch (err) {
- setError(err.message);
- setTimeout(() => setError(''), 5000);
+ setToast({
+ show: true,
+ title: 'Failed to delete FAQ',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setIsDeleting(false);
}
@@ -227,8 +258,12 @@ const FAQManagement = () => {
setMoveDirection(null);
}, 2000);
} catch (err) {
- setError(err.message);
- setTimeout(() => setError(''), 5000);
+ setToast({
+ show: true,
+ title: 'Failed to reorder FAQ',
+ description: err.message,
+ variant: 'destructive',
+ });
// Revert to original order
fetchFAQs();
// Clear visual feedback on error too
@@ -292,8 +327,12 @@ const FAQManagement = () => {
setMoveDirection(null);
}, 2000);
} catch (err) {
- setError(err.message);
- setTimeout(() => setError(''), 5000);
+ setToast({
+ show: true,
+ title: 'Failed to reorder FAQ',
+ description: err.message,
+ variant: 'destructive',
+ });
// Revert to original order
fetchFAQs();
// Clear visual feedback on error too
@@ -336,21 +375,6 @@ const FAQManagement = () => {
- {/* Status Messages */}
- {error && (
-
- )}
-
- {success && (
-
- )}
-
{/* Add FAQ Form */}
{isAdding && (
@@ -524,7 +548,65 @@ const FAQManagement = () => {
) : (
// View Mode
-
+ {/* Mobile: top row — number + movers on left, edit + delete on right */}
+
+
+ {faqs.length > 1 && (
+ <>
+
+ #{index + 1}
+
+
+ handleMoveFaqUp(faq, index)}
+ disabled={index === 0}
+ className={`p-1 rounded-md ${index === 0 ? 'text-zinc-700 cursor-not-allowed' : recentlyMovedFaq === faq.id && moveDirection === 'up' ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'} transition-colors`}
+ title="Move up"
+ >
+
+
+ handleMoveFaqDown(faq, index)}
+ disabled={index === faqs.length - 1}
+ className={`p-1 rounded-md ${index === faqs.length - 1 ? 'text-zinc-700 cursor-not-allowed' : recentlyMovedFaq === faq.id && moveDirection === 'down' ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'} transition-colors`}
+ title="Move down"
+ >
+
+
+
+ >
+ )}
+
+
+ {
+ setEditingFaq(faq.id);
+ setEditForm({ question: faq.question, answer: faq.answer });
+ setOriginalEditForm({ question: faq.question, answer: faq.answer });
+ }}
+ className="p-2 rounded-lg text-zinc-400 hover:text-blue-400 hover:bg-blue-500/10 transition-colors"
+ title="Edit FAQ"
+ >
+
+
+ setDeletingFaq(faq)}
+ className="p-2 rounded-lg text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
+ title="Delete FAQ"
+ >
+
+
+
+
+ {/* Mobile: question title */}
+
+ {faq.question}
+
+
+ {/* Desktop: original layout */}
+
{faqs.length > 1 && (
@@ -628,6 +710,14 @@ const FAQManagement = () => {
+
+
setToast((t) => ({ ...t, show: false }))}
+ />
);
};
diff --git a/src/components/staff/PackagesManagement.jsx b/src/components/staff/PackagesManagement.jsx
index 3ca817a..424e692 100644
--- a/src/components/staff/PackagesManagement.jsx
+++ b/src/components/staff/PackagesManagement.jsx
@@ -1,17 +1,9 @@
import { useState } from 'react';
import { Button } from '../shared/Button';
import { Card, CardHeader, CardTitle, CardContent } from '../shared/Card';
+import { Toast } from '../shared/Toast';
import { getVatsimToken } from '../../utils/cookieUtils';
-import {
- Upload,
- Package,
- Check,
- X,
- AlertTriangle,
- Info,
- FileArchive,
- RefreshCw,
-} from 'lucide-react';
+import { Upload, Package, Check, X, Info, FileArchive, RefreshCw } from 'lucide-react';
/**
* PackagesManagement
@@ -60,15 +52,19 @@ const PackagesManagement = () => {
const [selectedType, setSelectedType] = useState('models');
const [file, setFile] = useState(null);
const [dragActive, setDragActive] = useState(false);
- const [error, setError] = useState('');
+ const [toast, setToast] = useState({
+ show: false,
+ title: '',
+ description: '',
+ variant: 'default',
+ });
const [success, setSuccess] = useState(null); // {type,key,size,sha256,url,etag}
const [uploading, setUploading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const reset = () => {
setFile(null);
- setError('');
- setUploading(false);
+ setToast((t) => ({ ...t, show: false }));
};
const validate = (f) => {
@@ -84,11 +80,10 @@ const PackagesManagement = () => {
if (!f) return;
const v = validate(f);
if (v) {
- setError(v);
+ setToast({ show: true, title: 'Invalid file', description: v, variant: 'destructive' });
return;
}
setFile(f);
- setError('');
};
const onDrop = (e) => {
@@ -99,27 +94,31 @@ const PackagesManagement = () => {
if (!f) return;
const v = validate(f);
if (v) {
- setError(v);
+ setToast({ show: true, title: 'Invalid file', description: v, variant: 'destructive' });
return;
}
setFile(f);
- setError('');
};
const handleUpload = async () => {
if (uploading) return;
const v = validate(file);
if (v) {
- setError(v);
+ setToast({ show: true, title: 'Invalid file', description: v, variant: 'destructive' });
return;
}
- setError('');
+ setToast((t) => ({ ...t, show: false }));
setSuccess(null);
try {
setUploading(true);
const token = getVatsimToken();
if (!token) {
- setError('Missing auth token');
+ setToast({
+ show: true,
+ title: 'Not authenticated',
+ description: 'Missing auth token. Please log in again.',
+ variant: 'destructive',
+ });
setUploading(false);
return;
}
@@ -143,12 +142,24 @@ const PackagesManagement = () => {
}
const data = await res.json();
setSuccess(data.package || null);
+ // Show toast notification
+ setToast({
+ show: true,
+ title: 'Package uploaded',
+ description: 'The package has been uploaded successfully.',
+ variant: 'success',
+ });
// Auto-clear file after success to avoid accidental reupload
setFile(null);
setShowMeta(true);
setTimeout(() => setSuccess(null), 15000); // fade success after 15s
} catch (err) {
- setError(err.message);
+ setToast({
+ show: true,
+ title: 'Upload failed',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setUploading(false);
}
@@ -189,7 +200,6 @@ const PackagesManagement = () => {
key={pt.id}
onClick={() => {
setSelectedType(pt.id);
- setError('');
}}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-all ${active ? 'bg-blue-600 border-blue-500 text-white' : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700/70'}`}
>
@@ -291,16 +301,6 @@ const PackagesManagement = () => {
/>
- {error && (
-
-
-
{error}
-
setError('')} className="text-red-400/70 hover:text-red-300">
-
-
-
- )}
-
{success && (
@@ -376,6 +376,14 @@ const PackagesManagement = () => {
+
+
setToast((t) => ({ ...t, show: false }))}
+ />
);
};
diff --git a/src/components/staff/ReleaseManagement.jsx b/src/components/staff/ReleaseManagement.jsx
index 4ef2018..167f23a 100644
--- a/src/components/staff/ReleaseManagement.jsx
+++ b/src/components/staff/ReleaseManagement.jsx
@@ -1,13 +1,13 @@
import { useState, useEffect } from 'react';
import { Card } from '../shared/Card';
import { Dialog } from '../shared/Dialog';
+import { Toast } from '../shared/Toast';
import { getVatsimToken } from '../../utils/cookieUtils';
import {
Upload,
Image as ImageIcon,
Check,
AlertTriangle,
- History,
X,
Plus,
Edit,
@@ -23,7 +23,7 @@ import {
} from 'lucide-react';
import { marked } from 'marked';
// Configure marked to treat single line breaks as
and enable GitHub-flavored markdown.
-marked.setOptions({
+marked.use({
breaks: true, // so a single newline becomes a line break
gfm: true,
});
@@ -56,14 +56,17 @@ const ReleaseManagement = () => {
const [image, setImage] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
- const [uploadSuccess, setUploadSuccess] = useState('');
+ const [toast, setToast] = useState({
+ show: false,
+ title: '',
+ description: '',
+ variant: 'default',
+ });
// Changelog edit state
const [editReleaseId, setEditReleaseId] = useState('');
const [newChangelog, setNewChangelog] = useState('');
const [updating, setUpdating] = useState(false);
- const [updateError, setUpdateError] = useState('');
- const [updateSuccess, setUpdateSuccess] = useState('');
// Releases list state
const [releases, setReleases] = useState([]);
@@ -360,8 +363,6 @@ const ReleaseManagement = () => {
const resetUpdateForm = () => {
setEditReleaseId('');
setNewChangelog('');
- setUpdateError('');
- // Don't clear success message here - let it show for 4 seconds
};
const renderMarkdown = (md) => {
@@ -415,7 +416,6 @@ const ReleaseManagement = () => {
const executeUpload = async () => {
if (!pendingUploadData) return;
setUploadError('');
- setUploadSuccess('');
setConfirmOpen(false);
try {
setUploading(true);
@@ -442,7 +442,12 @@ const ReleaseManagement = () => {
}
throw new Error(message);
}
- setUploadSuccess('Release created successfully');
+ setToast({
+ show: true,
+ title: 'Release Published',
+ description: 'The new release has been published and is now available for download.',
+ variant: 'success',
+ });
resetUploadForm();
setIsAdding(false);
fetchReleases(productFilter); // Refresh the releases list
@@ -451,7 +456,6 @@ const ReleaseManagement = () => {
} finally {
setUploading(false);
setPendingUploadData(null);
- setTimeout(() => setUploadSuccess(''), 4000);
}
};
@@ -462,14 +466,12 @@ const ReleaseManagement = () => {
setShowProductDropdown(false);
setShowFilterDropdown(false);
resetUploadForm();
- setUploadSuccess(''); // Clear any previous success message
};
const handleStartUpdate = () => {
setIsUpdating(true);
setIsAdding(false);
resetUpdateForm();
- setUpdateSuccess(''); // Clear any previous success message
};
const handleCancel = () => {
@@ -479,27 +481,38 @@ const ReleaseManagement = () => {
setShowFilterDropdown(false);
resetUploadForm();
resetUpdateForm();
- setUpdateSuccess(''); // Clear success message when canceling
- setUploadSuccess(''); // Clear upload success message when canceling
};
// submitUpload replaced by confirmation modal flow
const submitChangelogUpdate = async (e) => {
e.preventDefault();
- setUpdateError('');
- setUpdateSuccess('');
if (!editReleaseId.trim()) {
- setUpdateError('Release ID is required');
+ setToast({
+ show: true,
+ title: 'Validation Error',
+ description: 'Release ID is required.',
+ variant: 'destructive',
+ });
return;
}
if (!newChangelog.trim()) {
- setUpdateError('Changelog content is required');
+ setToast({
+ show: true,
+ title: 'Validation Error',
+ description: 'Changelog content is required.',
+ variant: 'destructive',
+ });
return;
}
if (newChangelog.length > 20000) {
- setUpdateError('Changelog exceeds 20,000 character limit');
+ setToast({
+ show: true,
+ title: 'Validation Error',
+ description: 'Changelog exceeds 20,000 character limit.',
+ variant: 'destructive',
+ });
return;
}
@@ -526,16 +539,23 @@ const ReleaseManagement = () => {
throw new Error(message);
}
- setUpdateSuccess('Changelog updated successfully');
+ setToast({
+ show: true,
+ title: 'Changelog Updated',
+ description: 'The release changelog has been updated.',
+ variant: 'success',
+ });
fetchReleases(productFilter); // Refresh the releases list
resetUpdateForm();
} catch (err) {
- setUpdateError(err.message);
+ setToast({
+ show: true,
+ title: 'Update Failed',
+ description: err.message,
+ variant: 'destructive',
+ });
} finally {
setUpdating(false);
- setTimeout(() => {
- setUpdateSuccess('');
- }, 4000);
}
};
@@ -617,7 +637,7 @@ const ReleaseManagement = () => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
- className="flex items-center justify-between w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg focus:outline-none focus:border-zinc-500 text-white transition-all duration-200 hover:border-zinc-600 hover:bg-zinc-750 text-sm min-w-[180px]"
+ className="flex items-center justify-between w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg focus:outline-none focus:border-zinc-500 text-white transition-all duration-200 hover:border-zinc-600 hover:bg-zinc-750 text-sm min-w-45"
>
{currentOption?.label || 'All Products'}
@@ -670,7 +690,7 @@ const ReleaseManagement = () => {
{!isAdding && !isUpdating && (
New Release
@@ -722,51 +742,11 @@ const ReleaseManagement = () => {
- {/* Success Messages */}
- {uploadSuccess && (
-
-
-
-
Release Published Successfully
-
- The new release has been published and is now available for download.
-
-
-
setUploadSuccess('')}
- className="text-emerald-400/60 hover:text-emerald-400 transition-colors shrink-0"
- >
-
-
-
- )}
-
- {updateSuccess && (
-
-
-
-
Changelog Updated Successfully
-
- The release changelog has been updated.
-
-
-
setUpdateSuccess('')}
- className="text-emerald-400/60 hover:text-emerald-400 transition-colors shrink-0"
- >
-
-
-
- )}
-
{/* Add New Release Section */}
{isAdding && (
-
-
- Create New Release
-
+
Create New Release