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 && ( + + )} + +
+ {/* Image Overlay panel */} +
+
+
+
+ {refImageUrl ? ( +
+ + +
+ ) : ( + + )} + +
+ {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" + /> + +
+
+ {/* Header */} +
+ Auto Align + {!refImageAutoAlignActive ? ( + + ) : ( + + )} +
+ + {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 */} +
+ + +
+ + )} +
+
+ )} +
{ 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'} +

+
+ +
+ )}
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

-
+
{
-
-
- {previewOptions.map((option) => { - const isSelected = option === selectedPreview; - return ( - - ); - })} -
- -
+
+
- {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) => ( - - ))} + {options.map((option, index) => { + if (option.isHeader) { + return ( +
+

+ {option.label} +

+
+ ); + } + const OptionIcon = option.icon; + return ( + + ); + })}
)}
@@ -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

-
+
@@ -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" />
-
+
@@ -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" />
-
+
@@ -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" />
-
+
-
-
+ {/* 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() { -
+
@@ -249,7 +249,7 @@ export default function CacheManagement() { -
+
{loading ? ( @@ -284,7 +284,7 @@ export default function CacheManagement() { {/* Purge ALL */}
-
+
@@ -299,7 +299,7 @@ export default function CacheManagement() {
{/* Status Messages */} - {error && ( -
- -

{error}

-
- )} - {success && ( -
- -

{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

-
- -

{error}

-
-
- ); - } - return (
@@ -354,7 +349,10 @@ const DivisionManagement = () => { {divisions.length} division{divisions.length !== 1 ? 's' : ''} - @@ -363,12 +361,6 @@ const DivisionManagement = () => {
{/* Status Messages */} - {error && ( -
- -

{error}

-
- )} {divisions.length > 0 ? (
@@ -389,7 +381,7 @@ const DivisionManagement = () => { >
-
+

{division.name}

@@ -399,7 +391,7 @@ const DivisionManagement = () => { {formatDate(division.created_at)}

-
+
- {/* Status Messages */} - {error && ( -
- -

{error}

-
- )} - - {success && ( -
- -

{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} + +
+ + +
+ + )} +
+
+ + +
+
+ {/* 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} - -
- )} - {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 && (
- {/* Success Messages */} - {uploadSuccess && ( -
- -
-

Release Published Successfully

-

- The new release has been published and is now available for download. -

-
- -
- )} - - {updateSuccess && ( -
- -
-

Changelog Updated Successfully

-

- The release changelog has been updated. -

-
- -
- )} - {/* Add New Release Section */} {isAdding && (
-

- - Create New Release -

+

Create New Release

{ {product === 'Installer' ? 'Installer File' : 'Release File'} * -
+
SimConnect.NET (External) @@ -1076,13 +1056,6 @@ const ReleaseManagement = () => { )}
- - {updateError && ( -
- - {updateError} -
- )}
)} @@ -1090,11 +1063,8 @@ const ReleaseManagement = () => { {/* Existing Releases Section */} {!isAdding && !isUpdating && (
-
-
- -

Existing Releases

-
+
+

Existing Releases

{renderFilterDropdown( productFilter, @@ -1144,15 +1114,13 @@ const ReleaseManagement = () => { {rel.id} - - {rel.product} - + {rel.product} {rel.version} {rel.created_at ? new Date(rel.created_at).toLocaleDateString() : '-'} - + {rel.changelog ? ( rel.changelog.slice(0, 60) ) : ( @@ -1251,7 +1219,7 @@ const ReleaseManagement = () => { {product === 'Installer' ? 'Installer File:' : 'ZIP File:'} - + {file?.name}
@@ -1267,7 +1235,7 @@ const ReleaseManagement = () => { )}
Promo Image: - + {image ? image.name : '(none)'}
@@ -1315,6 +1283,14 @@ const ReleaseManagement = () => {
)} + + setToast((t) => ({ ...t, show: false }))} + />
); }; diff --git a/src/components/staff/StaffManagement.jsx b/src/components/staff/StaffManagement.jsx index 68aad1d..123f535 100644 --- a/src/components/staff/StaffManagement.jsx +++ b/src/components/staff/StaffManagement.jsx @@ -205,7 +205,7 @@ export default function StaffManagement() { {/* Add / Update Form */}

- + Add Staff Member

@@ -252,7 +252,7 @@ export default function StaffManagement() { {/* Staff List */}

- + Current Staff

{staff.length === 0 ? ( @@ -284,7 +284,7 @@ export default function StaffManagement() { {member.name || member.full_name || '—'} - + {member.role || member.staff_role || 'UNKNOWN'} diff --git a/src/components/staff/UserManagement.jsx b/src/components/staff/UserManagement.jsx index 8f57e71..c762d31 100644 --- a/src/components/staff/UserManagement.jsx +++ b/src/components/staff/UserManagement.jsx @@ -34,6 +34,7 @@ const USERS_PER_PAGE = 6; const TruncatedName = ({ name }) => { const textRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); useEffect(() => { const checkTruncation = () => { @@ -46,14 +47,37 @@ const TruncatedName = ({ name }) => { return () => window.removeEventListener('resize', checkTruncation); }, [name]); + // Close tooltip when clicking elsewhere + useEffect(() => { + if (!tooltipOpen) return; + const handler = () => setTooltipOpen(false); + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [tooltipOpen]); + const content = ( -

+

{ + e.stopPropagation(); + setTooltipOpen((o) => !o); + } + : undefined + } + > {name}

); if (isTruncated) { - return {content}; + return ( + + {content} + + ); } return content; @@ -293,7 +317,7 @@ const UserManagement = () => {
{!loading && ( - + {totalUsers} users @@ -305,7 +329,7 @@ const UserManagement = () => { placeholder="Search users..." value={searchTerm} onChange={handleSearch} - className="pl-9 pr-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 w-64 transition-all" + className="pl-9 pr-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 w-full sm:w-64 transition-all" />
@@ -564,10 +588,10 @@ const UserManagement = () => { Page {currentPage} of{' '} @@ -576,9 +600,9 @@ const UserManagement = () => {
diff --git a/src/components/staff/VatSysProfiles.jsx b/src/components/staff/VatSysProfiles.jsx index 5524735..abe3ec0 100644 --- a/src/components/staff/VatSysProfiles.jsx +++ b/src/components/staff/VatSysProfiles.jsx @@ -345,7 +345,7 @@ const VatSysProfiles = () => {
-
+
ICAO
Name
diff --git a/src/components/staff/notamManagement.jsx b/src/components/staff/notamManagement.jsx index 3cfc8bc..ff403b7 100644 --- a/src/components/staff/notamManagement.jsx +++ b/src/components/staff/notamManagement.jsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; import { MessageSquareWarning, - AlertTriangle, Info, Loader, Plus, @@ -13,10 +12,10 @@ import { Tag, Send, HardDriveDownload, - CheckCircle2, Copy, Check, } from 'lucide-react'; +import { Toast } from '../shared/Toast'; import { getVatsimToken } from '../../utils/cookieUtils'; import DOMPurify from 'dompurify'; @@ -49,7 +48,12 @@ const NotamManagement = () => { const [showTypeDropdown, setShowTypeDropdown] = useState(false); const [showNewTypeDropdown, setShowNewTypeDropdown] = useState(false); const [saving, setSaving] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); + const [toast, setToast] = useState({ + show: false, + title: '', + description: '', + variant: 'default', + }); const [hasEditChanges, setHasEditChanges] = useState(false); const [copied, setCopied] = useState(false); @@ -114,7 +118,7 @@ const NotamManagement = () => { bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400', - icon: AlertTriangle, + icon: MessageSquareWarning, circle: 'bg-amber-400', }; case 'info': @@ -146,7 +150,7 @@ const NotamManagement = () => { bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-400', - icon: AlertTriangle, + icon: MessageSquareWarning, circle: 'bg-red-400', }; default: @@ -275,10 +279,21 @@ const NotamManagement = () => { setEditType(type); // Show success message - setSaveSuccess(true); + setToast({ + show: true, + title: 'NOTAM Updated', + description: + 'The NOTAM has been published successfully. Changes may take a short time to propagate.', + variant: 'success', + }); } catch (err) { console.error('Error saving NOTAM:', err); - setError(err.message || 'Failed to save NOTAM'); + setToast({ + show: true, + title: 'NOTAM Failed', + description: err.message || 'Failed to save NOTAM', + variant: 'destructive', + }); } finally { setSaving(false); } @@ -417,32 +432,12 @@ const NotamManagement = () => {
- {/* Success Message */} - {saveSuccess && ( -
- -
-

NOTAM Updated Successfully

-

- The endpoint may take a short time to update. Users will see changes after cache - expires. -

-
- -
- )} - {/* Add New NOTAM Section */} {isAdding && (
-
- +
+

Add New NOTAM

@@ -502,8 +497,8 @@ const NotamManagement = () => { {isEditing && notamData?.notam && (
-
- +
+

Edit Current NOTAM

@@ -557,7 +552,7 @@ const NotamManagement = () => { {/* Current NOTAM Display */} {!isEditing && !isAdding && (
-
+
@@ -574,7 +569,6 @@ const NotamManagement = () => {
) : error ? (
-

{error}

) : notamData?.notam ? ( @@ -605,6 +599,14 @@ const NotamManagement = () => { )}
)} + + setToast((t) => ({ ...t, show: false }))} + />
); }; diff --git a/src/main.jsx b/src/main.jsx index 29f0eba..08ab033 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -45,6 +45,11 @@ const DocsRedirect = lazy(() => default: module.DocsRedirect, })) ); +const DonateRedirect = lazy(() => + import('./components/shared/DonateRedirect.jsx').then((module) => ({ + default: module.DonateRedirect, + })) +); const router = createBrowserRouter([ { @@ -116,6 +121,11 @@ const router = createBrowserRouter([ element: , errorElement: , }, + { + path: '/donate', + element: , + errorElement: , + }, { path: '/documentation', element: , diff --git a/src/pages/About.jsx b/src/pages/About.jsx index 51c0b35..5e11ec8 100644 --- a/src/pages/About.jsx +++ b/src/pages/About.jsx @@ -68,7 +68,7 @@ const About = () => { return ( {/* Hero Banner */} -
+
About banner {

Account Settings

{staffRoles?.isStaff && ( - -
+ +
@@ -423,7 +423,7 @@ const Account = () => {
{/* Display Name Mode */} -
-
-
-

Preferred Display Name Mode

-

- Choose how your name appears publicly across BARS. -

-
+
+
+

Preferred Display Name Mode

+

+ Choose how your name appears publicly across BARS. +

{user?.display_name && ( -
-
+
+
Current: {user.display_name}
)}
-
+
{displayModeOptions.map((opt) => { const active = Number(displayMode) === opt.value; return ( @@ -631,8 +629,8 @@ const Account = () => { {userDivisions.length > 0 && ( - -
+ +

Your Divisions

@@ -646,7 +644,7 @@ const Account = () => { division && (

{division.name}

@@ -657,7 +655,7 @@ const Account = () => { onClick={() => (window.location.href = `/divisions/${division.id}/manage`) } - className="bg-blue-500 hover:bg-blue-600" + className="w-full sm:w-auto bg-blue-500 hover:bg-blue-600" > Manage Division @@ -670,32 +668,36 @@ const Account = () => { )} - -
+ +

Danger Zone

-
+

Sign Out

End your current session

-
-
+

Delete Account

Permanently delete your BARS account and all stored data.

- diff --git a/src/pages/Changelog.jsx b/src/pages/Changelog.jsx index 808c243..0a166d6 100644 --- a/src/pages/Changelog.jsx +++ b/src/pages/Changelog.jsx @@ -8,7 +8,7 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; // 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, }); @@ -282,14 +282,14 @@ const Changelog = () => { text-decoration: underline !important; } `} -
+
{/* Header Section */} -
-

Changelog

+
+

Changelog

{/* Filter Dropdown */} -
+
{ ) : (
{/* Main Content (Right Side) */} -
+
{filteredReleases.map((release, index) => (
0 ? 'mt-20' : ''}`} + className={`relative ${index > 0 ? 'mt-12 md:mt-20' : ''}`} > - {/* Timeline dot positioned relative to this release */} -
+ {/* Timeline dot — hidden on mobile, shown md+ */} +
{index === 0 && ( - <> -
-
- +
)} {/* Timeline line connecting to next release */} {index < filteredReleases.length - 1 && ( @@ -383,6 +380,11 @@ const Changelog = () => {
+ {/* Mobile date — shown below md */} +

+ {formatDate(release.created_at)} +

+ {/* Release content */}

{formatProductName(release.product)} v{release.version} diff --git a/src/pages/ContributeDetails.jsx b/src/pages/ContributeDetails.jsx index dd1baba..dd26068 100644 --- a/src/pages/ContributeDetails.jsx +++ b/src/pages/ContributeDetails.jsx @@ -10,6 +10,10 @@ import { useWindowSize } from '../hooks/useWindowSize'; import { ArrowRight, FileUp, Upload, Check, Loader, Search, UserPen, Plus } from 'lucide-react'; import { useAuth } from '../hooks/useAuth'; import { getVatsimToken } from '../utils/cookieUtils'; +import { + fetchContributionPolicy, + getContributionDisabledMessage, +} from '../utils/contributionPolicy'; const ContributeDetails = () => { const { icao } = useParams(); @@ -22,6 +26,7 @@ const ContributeDetails = () => { const [selectedFile, setSelectedFile] = useState(null); const [preloaded, setPreloaded] = useState(false); const [airport, setAirport] = useState(null); + const [contributionPolicy, setContributionPolicy] = useState(null); const [notes, setNotes] = useState(''); const [error, setError] = useState(''); const [errorTitle, setErrorTitle] = useState('Error'); @@ -37,6 +42,9 @@ const ContributeDetails = () => { const [confettiRun, setConfettiRun] = useState(true); const [acknowledged, setAcknowledged] = useState(false); const [simulator, setSimulator] = useState('msfs2024'); + const contributionsDisabled = + contributionPolicy?.managed && !contributionPolicy?.contributionsEnabled; + const disabledContributionMessage = getContributionDisabledMessage(contributionPolicy); // Preload file from navigation state if provided useEffect(() => { @@ -60,8 +68,19 @@ const ContributeDetails = () => { useEffect(() => { const fetchAirport = async () => { try { - const response = await fetch(`https://v2.stopbars.com/airports?icao=${icao}`); - if (response.ok) { + const [responseResult, policyResult] = await Promise.allSettled([ + fetch(`https://v2.stopbars.com/airports?icao=${icao}`), + fetchContributionPolicy(icao), + ]); + + if (policyResult.status === 'fulfilled') { + setContributionPolicy(policyResult.value); + } else { + console.error('Error fetching contribution policy:', policyResult.reason); + } + + if (responseResult.status === 'fulfilled' && responseResult.value.ok) { + const response = responseResult.value; const data = await response.json(); setAirport({ icao: data.icao, @@ -131,6 +150,13 @@ const ContributeDetails = () => { const handleSubmit = async (e) => { e.preventDefault(); + if (contributionsDisabled) { + setErrorTitle('Contributions Disabled'); + setError(disabledContributionMessage); + setShowErrorToast(true); + return; + } + if (!user) { setErrorTitle('Error'); setError('You must be logged in to submit a contribution'); @@ -272,6 +298,13 @@ const ContributeDetails = () => {

+ {contributionsDisabled && ( +
+ +

{disabledContributionMessage}

+
+ )} +
@@ -294,7 +327,7 @@ const ContributeDetails = () => {
{/* Skeleton for Top Packages */} -
+
{[...Array(4)].map((_, index) => (
{ {/* Top Packages */} {topPackages.length > 0 && ( -
+
{topPackages.map((pkg, index) => ( -
+const POINT_TYPE_STYLES = { + stopbar: { accent: 'border-t-2 border-red-500' }, + lead_on: { accent: 'border-t-2 border-amber-500' }, + taxiway: { accent: 'border-t-2 border-green-500' }, + stand: { accent: 'border-t-2 border-blue-500' }, +}; + +const PopupRow = ({ label, value }) => ( +
+ {label} + {value} +
+); -
-
- Type: - {formatPointType(point.type)} +PopupRow.propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.node.isRequired, +}; + +const PointPopupContent = React.memo(({ point, copiedId, onCopy }) => { + const style = POINT_TYPE_STYLES[point.type] || POINT_TYPE_STYLES.taxiway; + const stopPopupInteraction = (event) => { + event.stopPropagation(); + event.nativeEvent?.stopImmediatePropagation?.(); + }; + + return ( +
+
+
+

{point.name}

+
+ +
+ +
+ +
+ + + {point.type === 'stopbar' && ( + <> + {point.directionality}} + /> + + + + )} + + {point.type === 'taxiway' && ( + <> + {point.directionality}} + /> + + + )} +
- {point.type === 'stopbar' && ( - <> -
- Directionality: - {point.directionality} -
-
- Elevated Bar: - {point.elevated ? 'Yes' : 'No'} -
-
- IHP: - {point.ihp ? 'Yes' : 'No'} -
- - )} - - {point.type === 'taxiway' && ( - <> -
- Directionality: - {point.directionality} -
-
- Color Style: - {formatPointColorStyle(point.color)} -
- - )} +
+ + + +
-
-)); + ); +}); PointPopupContent.displayName = 'PointPopupContent'; @@ -542,12 +584,31 @@ style.textContent = ` background-color: #4ade80 !important; } .maplibregl-popup-content { - background: transparent; - padding: 0; - box-shadow: none; + background: transparent !important; + padding: 0 !important; + box-shadow: none !important; + overflow: visible !important; + } + .maplibregl-popup-close-button { + font-size: 22px !important; + width: 26px !important; + height: 26px !important; + line-height: 26px !important; + padding: 0 !important; + color: #a1a1aa !important; + right: 6px !important; + top: 6px !important; + border-radius: 4px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + .maplibregl-popup-close-button:hover { + color: #ffffff !important; + background: rgba(255, 255, 255, 0.1) !important; } .maplibregl-popup-tip { - border-top-color: #18181b; + display: none !important; } `; document.head.appendChild(style); @@ -645,6 +706,7 @@ const ContributeMap = () => { const [loading, setLoading] = useState(true); const [airport, setAirport] = useState(null); const [points, setPoints] = useState([]); + const [contributionPolicy, setContributionPolicy] = useState(null); const [activePointId, setActivePointId] = useState(null); const [viewState, setViewState] = useState({ longitude: 0, @@ -654,17 +716,25 @@ const ContributeMap = () => { const [copiedId, setCopiedId] = useState(null); const [mapStyle, setMapStyle] = useState(SATELLITE_STYLE); const [styleName, setStyleName] = useState('Satellite'); + const contributionsDisabled = + contributionPolicy?.managed && !contributionPolicy?.contributionsEnabled; + const disabledContributionMessage = getContributionDisabledMessage(contributionPolicy); + const owningDivisionLabel = contributionPolicy?.divisionName || 'the owning Division'; useEffect(() => { const fetchData = async () => { try { setLoading(true); - const airportResponse = await fetch(`https://v2.stopbars.com/airports?icao=${icao}`); + const [airportResponse, policy] = await Promise.all([ + fetch(`https://v2.stopbars.com/airports?icao=${icao}`), + fetchContributionPolicy(icao), + ]); if (!airportResponse.ok) { throw new Error('Failed to fetch airport data'); } const airportData = await airportResponse.json(); + setContributionPolicy(policy); setAirport({ icao: airportData.icao, @@ -853,6 +923,24 @@ const ContributeMap = () => { setActivePointId(null); }, []); + const setMapCanvasCursor = useCallback((cursor) => { + const canvas = mapRef.current?.getCanvas?.(); + if (canvas) { + canvas.style.cursor = cursor; + } + }, []); + + const handleInteractiveHover = useCallback( + (event) => { + const hasInteractiveFeature = + Array.isArray(event?.features) && + event.features.some((feature) => INTERACTIVE_LAYER_IDS.includes(feature?.layer?.id)); + + setMapCanvasCursor(hasInteractiveFeature ? 'pointer' : ''); + }, + [setMapCanvasCursor] + ); + const handleMarkerClick = useCallback((point, e) => { e.originalEvent.stopPropagation(); setActivePointId(point.id); @@ -877,6 +965,7 @@ const ContributeMap = () => { ); const handleContinue = () => { + if (contributionsDisabled) return; navigate(`/contribute/test/${icao}`); }; @@ -884,6 +973,7 @@ const ContributeMap = () => { if (event) { event.stopPropagation(); event.preventDefault(); + event.nativeEvent?.stopImmediatePropagation?.(); } navigator.clipboard.writeText(id); setCopiedId(id); @@ -958,11 +1048,13 @@ const ContributeMap = () => {
{/* Map */} -
+
setViewState(evt.viewState)} + onMouseMove={handleInteractiveHover} + onMouseLeave={() => setMapCanvasCursor('')} onLoad={onMapLoad} onStyleData={(e) => addCapIcons(e.target)} mapStyle={mapStyle} @@ -1209,13 +1301,13 @@ const ContributeMap = () => {

XML Generator

- {points.length === 0 ? ( + {contributionsDisabled ? ( +
+ +

{disabledContributionMessage}

+
+ ) : points.length === 0 ? (

- This airport currently has no airport lighting data submitted by the owning - Division. Please check back later, or contact the Division requesting this - airport. + This airport currently has no airport lighting data submitted by{' '} + {owningDivisionLabel}. Please check back later, or contact the Division + requesting this airport.

) : (

- This is the existing airport data for this airport, set by the owning Division. - Your contribution will add support for a specific simulator scenery package. + This is the existing airport data for this airport, set by {owningDivisionLabel} + . Your contribution will add support for a specific simulator scenery package.

)}
+ {contributionsDisabled && ( +
+ +

{disabledContributionMessage}

+
+ )} +
{/* XML Map Preview */} @@ -318,7 +360,7 @@ const ContributeTest = () => { {/* Test XML button */} @@ -417,7 +416,7 @@ const ContributionDashboard = () => { disabled={!user} >
- + Your Contributions
@@ -434,7 +433,7 @@ const ContributionDashboard = () => { disabled={!user} >
- + Your Contributions
@@ -445,7 +444,7 @@ const ContributionDashboard = () => { {currentTab === 'user' && userContributionSummary && (

Your Contribution Summary

-
+
{userContributionSummary.total}
Total
@@ -502,7 +501,7 @@ const ContributionDashboard = () => { {filteredContributions.map((airport) => (
{/* Airport Header */} @@ -522,7 +521,7 @@ const ContributionDashboard = () => { .map((contribution) => (
{ : 'bg-zinc-800/50' }`} > -
-
- {contribution.scenery} +
+
+ + {contribution.scenery} + {contribution.simulator && ( { {contribution.status === 'approved' && ( )} {contribution.status === 'pending' && ( -
+
) : ( -
+
No reason provided
-
+
@@ -268,7 +268,7 @@ const GlobalStatus = () => { />
-
+
@@ -368,7 +368,7 @@ const GlobalStatus = () => { ) : (
-
+
diff --git a/src/pages/Privacy.jsx b/src/pages/Privacy.jsx index 5143497..d21b73a 100644 --- a/src/pages/Privacy.jsx +++ b/src/pages/Privacy.jsx @@ -1,15 +1,17 @@ import { Layout } from '../components/layout/Layout'; import { Card } from '../components/shared/Card'; import { ConsentBanner } from '../components/shared/ConsentBanner'; +import { Button } from '../components/shared/Button'; import { useState } from 'react'; const Privacy = () => { const [showConsentBanner, setShowConsentBanner] = useState(false); return ( -
+
-

Privacy Policy

+

Privacy Policy

+

Last updated: March 29, 2026

Overview

@@ -19,15 +21,13 @@ const Privacy = () => { software and services. We are committed to protecting your privacy and handling your data in a transparent and secure manner.

-

- Data controller: Edward Mitchell (BARS). Contact: edward@stopbars.com. -

This policy applies globally. For users in the EEA/UK, we process personal data in accordance with GDPR/UK GDPR. For California residents, we provide the disclosures - required by the California Privacy Rights Act (CPRA). + required by the California Privacy Rights Act (CPRA). As an Australian-based + operator, we comply with the Australian Privacy Act 1988 (Cth). The data controller + is Edward Mitchell (BARS); for enquiries, please contact legal@stopbars.com.

-

Last updated: September 6, 2025

@@ -44,10 +44,10 @@ const Privacy = () => {
  • Personal API key and last regeneration timestamp
  • Account creation and last login timestamps
  • -

    - Storing your full name enables accurate display names, proper attribution of - contributions, and certain account features that depend on your verified - identity. +

    + Note: the terms "API Key" and "API Token" are used + interchangeably across BARS software, documentation, and these policies. Both + refer to the same personal authentication credential issued to your account.

    @@ -59,9 +59,32 @@ const Privacy = () => {
  • API key regeneration attempts (aggregate)
  • +
    +

    Technical Data

    +
      +
    • + IP addresses - received transiently during requests and used for rate limiting + and abuse prevention; we store only a hashed (SHA‑256) derivative for counting + purposes and do not retain the plaintext IP +
    • +
    • + User-agent strings (browser/client identification), received transiently +
    • +
    +
    + +

    Automated Decision-Making

    +

    + We do not use automated decision-making or profiling that produces legal or + similarly significant effects on you, as described under GDPR Article 22. Rate + limiting and abuse detection are automated operational controls and do not involve + profiling of individuals for decisions with legal effect. +

    +
    +

    How We Use Your Information

      @@ -136,9 +159,9 @@ const Privacy = () => {

      Analytics

      - We use PostHog Cloud EU for website analytics. Analytics are{' '} - disabled by default and only enabled after you consent in the - cookie banner. We honor Global Privacy Control (GPC) and Do Not Track (DNT). + We use PostHog Cloud EU for website analytics. Analytics are disabled by default and + only enabled after you consent in the cookie banner. We honor Global Privacy Control + (GPC) and Do Not Track (DNT).

      When consent is denied or unknown, analytics are disabled and no cookies persist. @@ -176,7 +199,7 @@ const Privacy = () => {

    You can exercise these rights through your account settings or by contacting us at{' '} - support@stopbars.com. We will respond within 30 days where required by law + legal@stopbars.com. We will respond within 30 days where required by law and will verify your identity before fulfilling access or deletion requests. We will request only the minimum information necessary to verify your identity. This may include providing valid legal identification. @@ -185,7 +208,18 @@ const Privacy = () => {

    EEA/UK

    You also have the right to object to processing based on legitimate interests - and to withdraw consent at any time (without affecting prior processing). + and to withdraw consent at any time (without affecting prior processing). You + have the right to lodge a complaint with your national data protection + supervisory authority. +

    +
    +
    +

    Australia

    +

    + Under the Australian Privacy Act 1988 (Cth) and the APPs, you have the right to + access and seek correction of personal information we hold about you. If you are + unsatisfied with our handling of a privacy complaint, you may refer the matter + to the Office of the Australian Information Commissioner (OAIC).

    @@ -275,8 +309,10 @@ const Privacy = () => {

    Contact Information

    - For privacy questions or rights requests, contact support@stopbars.com. - Security reports: edward@stopbars.com (PGP key in repository root on GitHub). + For privacy questions or to exercise your data rights, please contact us at{' '} + legal@stopbars.com. For security disclosures and vulnerability reports, + please contact legal@stopbars.com. A PGP key is available through our GitHub + repositories for encrypted correspondence.

    @@ -285,12 +321,7 @@ const Privacy = () => {

    You can manage your analytics cookie preferences at any time using the button below.

    - +
    diff --git a/src/pages/StaffDashboard.jsx b/src/pages/StaffDashboard.jsx index 4041652..68bbd80 100644 --- a/src/pages/StaffDashboard.jsx +++ b/src/pages/StaffDashboard.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Layout } from '../components/layout/Layout'; import { Card } from '../components/shared/Card'; import { Button } from '../components/shared/Button'; @@ -21,6 +21,7 @@ import { FileUp, } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Dropdown } from '../components/shared/Dropdown'; import { getVatsimToken } from '../utils/cookieUtils'; // Import existing components @@ -280,6 +281,50 @@ const StaffDashboard = () => { }, 100); }; + // Mobile nav options — grouped with section headers and icons + const mobileNavOptions = useMemo(() => { + if (!staffRoles) return []; + const hasAccess = (tab) => tab.roles.some((r) => staffRoles[r.toLowerCase()] === 1); + const groups = [ + { + label: 'System Management', + ids: [ + 'userManagement', + 'staffManagement', + 'divisionManagement', + 'cacheManagement', + 'banManagement', + 'releaseManagement', + ], + }, + { + label: 'Content Management', + ids: [ + 'airportManagement', + 'contributionManagement', + 'notamManagement', + 'faqManagement', + 'contactMessages', + ], + }, + { + label: 'Data Management', + ids: ['packagesManagement', 'vatsysProfiles'], + }, + ]; + const opts = []; + for (const group of groups) { + const tabs = group.ids.map((id) => TABS[id]).filter((tab) => tab && hasAccess(tab)); + if (tabs.length > 0) { + opts.push({ label: group.label, isHeader: true }); + for (const tab of tabs) { + opts.push({ value: tab.id, label: tab.label, icon: tab.icon }); + } + } + } + return opts; + }, [staffRoles]); + // Check if user has access to a specific tab const hasTabAccess = (tab) => { if (!staffRoles) return false; @@ -311,7 +356,7 @@ const StaffDashboard = () => { return (
    -
    +
    @@ -325,7 +370,7 @@ const StaffDashboard = () => { return (
    -
    +

    {error}

    @@ -342,8 +387,8 @@ const StaffDashboard = () => { return (
    -
    -
    +
    +

    Staff Dashboard

    @@ -363,7 +408,7 @@ const StaffDashboard = () => { })()}

    -
    +