40 Commits

Author SHA1 Message Date
2ed10bc86d Update README to use a direct link for the showcase image
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 27s
CI / check (push) Successful in 33s
CI / build (push) Successful in 55s
Publish NPM Package / publish (push) Successful in 52s
Build and Publish Docker Image / build (push) Has been cancelled
2025-12-25 15:12:54 -06:00
c222981f69 Update package version to 1.3.1 in package.json and package-lock.json 2025-12-25 15:11:32 -06:00
606cbc0770 Update package-lock.json to reflect version bump to 1.3.0, add missing dependencies, and specify Node.js engine requirements. 2025-12-25 15:11:09 -06:00
81ce2fbbe6 Set default HOST and PORT environment variables in surveilled.js 2025-12-25 15:09:08 -06:00
3ba66ff179 Update README 2025-12-25 15:09:03 -06:00
f386459449 Update package.json by adding main and types fields, specifying Node.js engine version, updating file patterns, and introducing a start script for local development 2025-12-25 15:08:51 -06:00
70503fcb56 Update package version to 1.3.0, add bin entry for surveilled.js, and enhance package.json with additional build and file configurations
Some checks failed
CI / check (push) Failing after 25s
CI / build (push) Has been skipped
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 25s
Publish NPM Package / publish (push) Successful in 37s
Build and Publish Docker Image / build (push) Successful in 12m26s
2025-12-25 14:48:07 -06:00
42bcfc2dfc Update Svelte configuration to use adapter-node and add surveilled.js script for Node.js execution 2025-12-25 14:48:00 -06:00
b9bb7ec8b5 Update npm-publish workflow to use NPM_TOKEN for authentication 2025-12-25 14:45:41 -06:00
f92c8fced7 Update package version to 1.2.2 in package.json and package-lock.json
Some checks failed
CI / check (push) Successful in 52s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
Publish NPM Package / publish (push) Failing after 49s
CI / build (push) Successful in 48s
Build and Publish Docker Image / build (push) Successful in 14m35s
2025-12-25 14:26:32 -06:00
f80919c45f Update npm-publish workflow to use updated Gitea token variable for authentication 2025-12-25 14:26:20 -06:00
ad4fa4f93a Update npm-publish workflow to configure registry for publishing and ensure dependencies are installed from npmjs.org
Some checks failed
CI / check (push) Successful in 1m7s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 35s
CI / build (push) Successful in 1m11s
Publish NPM Package / publish (push) Failing after 1m38s
Build and Publish Docker Image / build (push) Has been cancelled
2025-12-25 14:15:37 -06:00
66a1933cfb Improve app version retrieval in vite.config.ts to declare process.env for improved type safety and clarity.
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 30s
Build and Publish Docker Image / build (push) Has been cancelled
CI / check (push) Successful in 52s
CI / build (push) Successful in 1m13s
2025-12-25 14:14:28 -06:00
620e63b7ba Fix
Some checks failed
CI / check (push) Failing after 27s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 23s
CI / build (push) Has been skipped
Build and Publish Docker Image / build (push) Has been cancelled
Publish NPM Package / publish (push) Failing after 26s
2025-12-25 14:12:27 -06:00
9918638cb8 Refactor Camera interface in map.ts to export as a type for improved type management and clarity. 2025-12-25 14:11:32 -06:00
165f06e06b Update app version retrieval in vite.config.ts to use import.meta.env for improved compatibility with Vite's environment variables 2025-12-25 14:11:26 -06:00
405a254824 Add eslint directive to suppress unused variable warning for setStatusMessage function in +page.svelte 2025-12-25 14:11:19 -06:00
0bb79ff612 Add app version definition in vite.config.ts for improved version management 2025-12-25 14:09:40 -06:00
55a90bd146 Refactor APP_VERSION definition in version.ts to support multiple sources 2025-12-25 14:09:28 -06:00
7dcfb1ff7c format workflow 2025-12-25 14:01:35 -06:00
6b35ab80a2 Update module exports in index.ts to include API, constants, map, settings, and version files for improved accessibility and organization. 2025-12-25 14:00:30 -06:00
d9d97db0f9 Refactor package configuration to use scoped name and enhance export settings
- Updated package name to '@quad4/surveilled' for better namespace management.
- Added publish configuration for custom NPM registry.
- Defined exports for module entry points and included necessary files for packaging.
- Updated dependencies in package-lock.json to include new packages and versions.
2025-12-25 14:00:12 -06:00
6796d85a6a Update Makefile to include new package and publish targets, and modify clean command to remove 'dist' and 'package' directories. 2025-12-25 13:59:56 -06:00
6f1428b8e8 Configure NPM registry and authentication token in .npmrc for package management 2025-12-25 13:59:51 -06:00
1b38193ca3 Update .dockerignore and .gitignore to include 'dist/' directory for package management 2025-12-25 13:59:44 -06:00
f258254adf Add NPM publish workflow for automated package deployment 2025-12-25 13:59:39 -06:00
61c47c2d60 Update version to 1.2.0 in package.json and package-lock.json 2025-12-25 13:53:10 -06:00
5b31ef951d Update README
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 24s
CI / check (push) Successful in 28s
CI / build (push) Successful in 45s
Build and Publish Docker Image / build (push) Successful in 10m17s
2025-12-25 13:43:16 -06:00
3c574b58f6 Enhance camera map interface with new settings and improved functionality
- Added settings management for Overpass API, custom tile URLs, and Nominatim search endpoints, allowing users to customize their experience.
- Implemented responsive design adjustments for mobile views, including a modal for location search.
- Improved camera selection and measurement features with enhanced visual feedback and distance labeling.
- Updated footer behavior to persist user preferences and improve usability.
2025-12-25 13:36:09 -06:00
07d5c540b6 Add settings management for Overpass API, custom tiles, Nominatim, basemap, and footer preferences
- Implemented Svelte stores to manage user preferences for Overpass API endpoints, custom tile URLs, Nominatim search endpoints, basemap selection, and footer visibility.
- Integrated localStorage for persistent settings across sessions, enhancing user experience and customization options.
2025-12-25 13:36:01 -06:00
b826ee8f4f Refactor API endpoint handling to prioritize user-defined settings
- Introduced dynamic endpoint selection for Overpass API based on user preferences.
- Updated Nominatim API URL construction to utilize a configurable base URL from settings.
- Enhanced the fetchOverpassWithFallback function to improve reliability by using preferred endpoints.
2025-12-25 13:35:55 -06:00
2df526c606 Update Overpass API endpoints in constants.ts to include additional servers for improved reliability 2025-12-25 13:35:48 -06:00
26f91f1aee Add responsive styling for zoom control in mobile view 2025-12-25 13:35:42 -06:00
85febfacc1 Update ESLint configuration to add readonly definitions for HTMLButtonElement and localStorage 2025-12-25 13:35:34 -06:00
0eab72db49 Add Docker support to Makefile 2025-12-25 13:34:20 -06:00
dffd3ff838 Format yaml 2025-12-25 13:34:08 -06:00
f5407e56e1 Add Docker workflow for building and publishing images
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 23s
CI / check (push) Successful in 39s
Build and Publish Docker Image / build (push) Failing after 58s
CI / build (push) Successful in 46s
2025-12-25 13:16:25 -06:00
ivan
8adc0aa85d Update README.md
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 22s
CI / check (push) Successful in 24s
CI / build (push) Successful in 39s
2025-12-25 04:50:40 +00:00
ivan
2e7780e711 Upload files to "showcase"
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / check (push) Successful in 24s
CI / build (push) Successful in 39s
2025-12-25 04:47:24 +00:00
c0dd901def Add validation functions for API responses and map parameters
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / check (push) Successful in 22s
CI / build (push) Successful in 39s
- Introduced validation functions for Overpass and Nominatim API responses to ensure correct data structure.
- Added latitude, longitude, and bounds validation functions in the Svelte component to enhance input handling.
- Updated the map state restoration logic to utilize these validation checks, improving robustness and error handling.
2025-12-24 21:20:07 -06:00
22 changed files with 1319 additions and 193 deletions

View File

@@ -21,3 +21,7 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Package
dist/

View File

@@ -0,0 +1,66 @@
name: Build and Publish Docker Image
on:
workflow_dispatch:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
env:
REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/surveilled
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_digest: ${{ steps.build.outputs.digest }}
image_tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Log in to the Container registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
id: build
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -0,0 +1,36 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --registry=https://registry.npmjs.org/
- name: Package
run: make package
- name: Configure npm for publishing
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Package
dist/

3
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@quad4:registry=https://git.quad4.io/api/packages/quad4-software/npm/
//git.quad4.io/api/packages/quad4-software/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build preview check lint format clean
.PHONY: help install dev build preview check lint format clean docker-build docker-run docker package publish
help:
@echo 'Usage: make [target]'
@@ -6,15 +6,19 @@ help:
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install:
npm install
dev:
npm install
npm run dev
build:
npm run build
package:
npm run package
publish:
npm publish
preview:
npm run preview
@@ -28,5 +32,13 @@ format:
npm run format
clean:
rm -rf .svelte-kit build node_modules/.vite
rm -rf .svelte-kit build node_modules/.vite dist package
docker-build:
docker build -t surveilled .
docker-run:
docker run --rm -p 3000:3000 surveilled
docker: docker-build docker-run

View File

@@ -1,40 +1,94 @@
# Surveilled
[Website](https://surveilled.quad4.io)
<img src="https://git.quad4.io/Quad4-Software/Surveilled/raw/commit/70503fcb56994c4c1bb019dfbcc3b589ebd94039/showcase/surveilled.png" alt="showcase image" width="900">
A map of cameras in the world using OpenStreetMap data.
Check out the live website at [surveilled.quad4.io](https://surveilled.quad4.io)
A map of cameras in the world using OpenStreetMap overpass data.
## Disclaimer
Data is fetched from OSM Overpass and may not be accurate or up to date as this data is community-sourced.
## Features
- Draw a box to get cameras for that area
- Measure distance between two points/cameras
- Export or copy GeoJSON of cameras in an area
- Customize Nominatim, Overpass, and Tile endpoints
- No reliance on external CDNs or Google fonts.
- PWA installable
- Mobile-friendly
## Self-hosting
### NPM
```sh
npm config set @quad4:registry=https://git.quad4.io/api/packages/Quad4-Software/npm/
npm install -g @quad4/surveilled
surveilled
```
Or
```sh
PORT=8080 HOST=0.0.0.0 surveilled
```
### Docker
```sh
docker run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
```
### Podman
```sh
podman run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
```
## Development
```sh
git clone https://git.quad4.io/Quad4-Software/Surveilled
cd Surveilled
```
### NPM
```sh
npm install
npm run dev
```
### Makefile
```sh
make dev
```
## Building
#### Check/Lint/Format
```sh
make build
make check
make lint
make format
```
## Preview
```sh
make preview
```
## Docker
## Building Docker Image
Uses Chainguard Images which are rootless and very minimal images.
```sh
docker build -t surveilled .
docker run -p 3000:3000 surveilled
docker run --rm -p 3000:3000 surveilled
```
## Contributing
Send email to [team@quad4.io](mailto:team@quad4.io) with your feedback or any issues you may have.
## LICENSE
[MIT](LICENSE)
[MIT](LICENSE)

8
bin/surveilled.js Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
process.env.HOST = process.env.HOST || '127.0.0.1';
process.env.PORT = process.env.PORT || '3000';
import '../build/index.js';

View File

@@ -22,9 +22,11 @@ export default [
HTMLElement: 'readonly',
HTMLImageElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLButtonElement: 'readonly',
navigator: 'readonly',
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
Blob: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',

293
package-lock.json generated
View File

@@ -1,42 +1,51 @@
{
"name": "surveilled",
"version": "1.1.0",
"name": "@quad4/surveilled",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "surveilled",
"version": "1.1.0",
"name": "@quad4/surveilled",
"version": "1.3.1",
"dependencies": {
"autoprefixer": "^10.4.23",
"lucide-svelte": "^0.562.0",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
"ol": "^10.7.0"
},
"bin": {
"surveilled": "bin/surveilled.js"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/package": "^2.3.9",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.13.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vite-plugin-pwa": "^1.2.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -2403,6 +2412,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -2416,6 +2426,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -2425,6 +2436,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -2447,6 +2459,64 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.9",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz",
"integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -2875,6 +2945,47 @@
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/adapter-node": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz",
"integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"rollup": "^4.9.5"
},
"peerDependencies": {
"@sveltejs/kit": "^2.4.0"
}
},
"node_modules/@sveltejs/adapter-node/node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sveltejs/kit": {
"version": "2.49.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
@@ -2914,6 +3025,59 @@
}
}
},
"node_modules/@sveltejs/package": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.7.tgz",
"integrity": "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"kleur": "^4.1.5",
"sade": "^1.8.1",
"semver": "^7.5.4",
"svelte2tsx": "~0.7.33"
},
"bin": {
"svelte-package": "svelte-package.js"
},
"engines": {
"node": "^16.14 || >=18"
},
"peerDependencies": {
"svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/@sveltejs/package/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sveltejs/package/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz",
@@ -3298,12 +3462,14 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -3317,6 +3483,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -3329,6 +3496,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -3417,6 +3585,7 @@
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3537,6 +3706,7 @@
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -3546,6 +3716,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3568,6 +3739,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -3580,6 +3752,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3680,6 +3853,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3689,6 +3863,7 @@
"version": "1.0.30001761",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3771,6 +3946,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3786,6 +3962,13 @@
"node": ">=4.0.0"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3853,6 +4036,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -3933,6 +4117,13 @@
}
}
},
"node_modules/dedent-js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3996,12 +4187,14 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": {
@@ -4052,6 +4245,7 @@
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -4243,6 +4437,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4555,6 +4750,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -4571,6 +4767,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -4614,6 +4811,7 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -4623,6 +4821,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -4676,6 +4875,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4759,6 +4959,7 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -4788,6 +4989,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4802,6 +5004,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4969,6 +5172,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -5128,6 +5332,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5253,6 +5458,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -5295,6 +5501,7 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -5345,6 +5552,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5400,6 +5608,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -5445,6 +5654,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -5706,6 +5916,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -5879,6 +6090,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-character": {
@@ -5973,6 +6185,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -5982,6 +6195,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -5995,6 +6209,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -6060,6 +6275,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -6071,6 +6287,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6096,12 +6313,14 @@
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6111,6 +6330,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6120,6 +6340,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -6310,6 +6531,7 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@@ -6355,12 +6577,14 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -6373,6 +6597,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6382,6 +6607,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -6401,6 +6627,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6429,6 +6656,7 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -6446,6 +6674,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6511,6 +6740,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6536,6 +6766,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -6617,6 +6848,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@@ -6689,6 +6921,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6746,6 +6979,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -6881,6 +7115,7 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -6920,6 +7155,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -6972,6 +7208,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -7080,6 +7317,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -7311,6 +7555,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -7592,6 +7837,7 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -7627,6 +7873,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7728,10 +7975,26 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/svelte2tsx": {
"version": "0.7.46",
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.46.tgz",
"integrity": "sha512-S++Vw3w47a8rBuhbz4JK0fcGea8tOoX1boT53Aib8+oUO2EKeOG+geXprJVTDfBlvR+IJdf3jIpR2RGwT6paQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"dedent-js": "^1.0.1",
"scule": "^1.3.0"
},
"peerDependencies": {
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
"typescript": "^4.9.4 || ^5.0.0"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -7769,6 +8032,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -7793,6 +8057,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -7805,6 +8070,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -7817,6 +8083,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -7829,6 +8096,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -7871,6 +8139,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -7884,6 +8153,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -7951,6 +8221,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -7960,6 +8231,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -7972,6 +8244,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -7988,6 +8261,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -8033,6 +8307,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/type-check": {
@@ -8254,6 +8529,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -8294,6 +8570,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {

View File

@@ -1,41 +1,70 @@
{
"name": "surveilled",
"private": true,
"version": "1.1.0",
"name": "@quad4/surveilled",
"version": "1.3.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"surveilled": "./bin/surveilled.js"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist/**/*",
"build/**/*",
"bin/**/*",
"package/**/*",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"prepare": "svelte-kit sync || echo ''",
"start": "HOST=127.0.0.1 PORT=3000 node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"package": "svelte-kit sync && svelte-package && vite build"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/package": "^2.3.9",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.13.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vite-plugin-pwa": "^1.2.0"
},
"dependencies": {
"autoprefixer": "^10.4.23",
"lucide-svelte": "^0.562.0",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
"ol": "^10.7.0"
}
}

BIN
showcase/surveilled.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

View File

@@ -80,6 +80,12 @@
background-color: #262626 !important;
}
@media (max-width: 640px) {
.ol-zoom {
display: none !important;
}
}
.ol-attribution.ol-unselectable.ol-control {
background-color: rgba(23, 23, 23, 0.9) !important;
color: #fafafa !important;

View File

@@ -1,9 +1,11 @@
import { get } from 'svelte/store';
import {
OVERPASS_ENDPOINTS,
OVERPASS_TIMEOUT,
OVERPASS_MAX_BOUNDS_SPAN,
ERROR_MESSAGES,
} from './constants';
import { overpassEndpoint, nominatimEndpoint } from './settings';
export interface Camera {
lon: number;
@@ -27,9 +29,19 @@ export interface OverpassResponse {
elements: OverpassElement[];
}
function isValidOverpassResponse(data: unknown): data is OverpassResponse {
if (!data || typeof data !== 'object') return false;
if (!('elements' in data)) return false;
if (!Array.isArray(data.elements)) return false;
return true;
}
export async function fetchOverpassWithFallback(query: string): Promise<OverpassResponse> {
let lastErr: Error | null = null;
for (const endpoint of OVERPASS_ENDPOINTS) {
const preferred = get(overpassEndpoint);
const endpoints = [preferred, ...OVERPASS_ENDPOINTS.filter((e) => e !== preferred)];
for (const endpoint of endpoints) {
try {
const url = `${endpoint}?data=${encodeURIComponent(query)}`;
const response = await fetch(url);
@@ -39,7 +51,11 @@ export async function fetchOverpassWithFallback(query: string): Promise<Overpass
if (response.status === 429) continue;
else continue;
}
return JSON.parse(text);
const parsed = JSON.parse(text);
if (!isValidOverpassResponse(parsed)) {
throw new Error('Invalid Overpass response structure');
}
return parsed;
} catch (err) {
lastErr = err as Error;
continue;
@@ -132,13 +148,30 @@ export interface NominatimResult {
export type NominatimResponse = NominatimResult[];
function isValidNominatimResponse(data: unknown): data is NominatimResponse {
if (!Array.isArray(data)) return false;
return data.every((item) => {
return (
item &&
typeof item === 'object' &&
typeof item.place_id === 'number' &&
typeof item.display_name === 'string' &&
typeof item.lat === 'string' &&
typeof item.lon === 'string'
);
});
}
export async function searchNominatim(query: string): Promise<NominatimResult[]> {
if (!query.trim()) {
return [];
}
const url = new URL('https://nominatim.openstreetmap.org/search');
url.searchParams.set('q', query);
const sanitizedQuery = query.trim().slice(0, 200);
const baseUrl = get(nominatimEndpoint);
const url = new URL(baseUrl);
url.searchParams.set('q', sanitizedQuery);
url.searchParams.set('format', 'json');
url.searchParams.set('limit', '10');
url.searchParams.set('addressdetails', '0');
@@ -156,7 +189,10 @@ export async function searchNominatim(query: string): Promise<NominatimResult[]>
throw new Error(`Nominatim error ${response.status}`);
}
const data: NominatimResponse = await response.json();
const data: unknown = await response.json();
if (!isValidNominatimResponse(data)) {
throw new Error('Invalid Nominatim response structure');
}
return data;
} catch (err) {
console.error('Nominatim search failed:', err);

View File

@@ -1,7 +1,9 @@
// Overpass API Configuration
export const OVERPASS_ENDPOINTS = [
'https://overpass-api.de/api/interpreter',
'https://lz4.overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.openstreetmap.fr/api/interpreter',
] as const;
export const OVERPASS_TIMEOUT = 25; // seconds

View File

@@ -1 +1,5 @@
// place files you want to import through the `$lib` alias in this folder.
export * from './api';
export * from './constants';
export * from './map';
export * from './settings';
export * from './version';

View File

@@ -28,18 +28,27 @@ import {
FOV_MIN_ZOOM,
LAYER_RENDER_BUFFER,
} from './constants';
import type { Camera } from './api';
export interface Camera {
lon: number;
lat: number;
type: string;
direction: string | null;
operator?: string;
description?: string;
}
export type { Camera };
const canUseCacheApi = typeof caches !== 'undefined';
function isValidTileUrl(url: string): boolean {
if (!url || typeof url !== 'string') return false;
if (url.startsWith('javascript:')) return false;
if (url.startsWith('data:')) {
if (!url.startsWith('data:image/')) return false;
return true;
}
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function cachedTileLoad(imageTile: Tile, src: string) {
if (!(imageTile instanceof ImageTile)) {
return;
@@ -48,6 +57,10 @@ async function cachedTileLoad(imageTile: Tile, src: string) {
if (!(image instanceof HTMLImageElement)) {
return;
}
if (!isValidTileUrl(src)) {
console.error('Invalid tile URL:', src);
return;
}
if (!canUseCacheApi) {
image.src = src;
return;

129
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,129 @@
import { writable } from 'svelte/store';
import { OVERPASS_ENDPOINTS } from './constants';
const STORAGE_KEY = 'surveilled-overpass-endpoint';
const TILE_STORAGE_KEY = 'surveilled-custom-tile-url';
const NOMINATIM_STORAGE_KEY = 'surveilled-nominatim-endpoint';
const BASEMAP_STORAGE_KEY = 'surveilled-basemap';
const FOOTER_COLLAPSED_KEY = 'surveilled-footer-collapsed';
function createOverpassSettings() {
// Default to the first endpoint in the constants
const defaultEndpoint: string = OVERPASS_ENDPOINTS[0];
// Initial value from localStorage if available
let initialEndpoint: string = defaultEndpoint;
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
initialEndpoint = stored;
}
}
const { subscribe, set } = writable<string>(initialEndpoint);
return {
subscribe,
set: (value: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, value);
}
set(value);
},
reset: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
set(defaultEndpoint);
},
};
}
export const overpassEndpoint = createOverpassSettings();
function createTileSettings() {
let initialUrl = '';
if (typeof window !== 'undefined') {
initialUrl = localStorage.getItem(TILE_STORAGE_KEY) || '';
}
const { subscribe, set } = writable<string>(initialUrl);
return {
subscribe,
set: (value: string) => {
if (typeof window !== 'undefined') {
if (value) localStorage.setItem(TILE_STORAGE_KEY, value);
else localStorage.removeItem(TILE_STORAGE_KEY);
}
set(value);
},
};
}
export const customTileUrl = createTileSettings();
function createNominatimSettings() {
let initialEndpoint = 'https://nominatim.openstreetmap.org/search';
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(NOMINATIM_STORAGE_KEY);
if (stored) initialEndpoint = stored;
}
const { subscribe, set } = writable<string>(initialEndpoint);
return {
subscribe,
set: (value: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem(NOMINATIM_STORAGE_KEY, value);
}
set(value);
},
};
}
export const nominatimEndpoint = createNominatimSettings();
function createBasemapSettings() {
let initialBasemap = 'dark';
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(BASEMAP_STORAGE_KEY);
if (stored) initialBasemap = stored;
}
const { subscribe, set } = writable<string>(initialBasemap);
return {
subscribe,
set: (value: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem(BASEMAP_STORAGE_KEY, value);
}
set(value);
},
};
}
export const basemapPreference = createBasemapSettings();
function createFooterSettings() {
let initialCollapsed = false;
if (typeof window !== 'undefined') {
initialCollapsed = localStorage.getItem(FOOTER_COLLAPSED_KEY) === 'true';
}
const { subscribe, set } = writable<boolean>(initialCollapsed);
return {
subscribe,
set: (value: boolean) => {
if (typeof window !== 'undefined') {
localStorage.setItem(FOOTER_COLLAPSED_KEY, String(value));
}
set(value);
},
};
}
export const footerCollapsedPref = createFooterSettings();

View File

@@ -1 +1,20 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev';
declare const __APP_VERSION__: string | undefined;
type ProcessLike = {
env?: Record<string, string | undefined>;
};
const definedVersion =
typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ ? __APP_VERSION__ : undefined;
const processEnv =
typeof globalThis === 'object' && globalThis !== null
? ((globalThis as unknown as { process?: ProcessLike }).process?.env ?? undefined)
: undefined;
const envVersion =
typeof processEnv?.npm_package_version === 'string' && processEnv.npm_package_version
? processEnv.npm_package_version
: undefined;
export const APP_VERSION = definedVersion ?? envVersion ?? 'dev';

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { replaceState } from '$app/navigation';
import Map from 'ol/Map.js';
import type MapBrowserEvent from 'ol/MapBrowserEvent.js';
import { Style, Stroke, Fill } from 'ol/style.js';
import { Style, Stroke, Fill, Text } from 'ol/style.js';
import { fromLonLat, toLonLat, transformExtent } from 'ol/proj.js';
import { Polygon, Point, LineString } from 'ol/geom.js';
import Feature from 'ol/Feature.js';
@@ -20,6 +21,7 @@
Loader2,
ChevronUp,
ChevronDown,
Settings,
} from 'lucide-svelte';
import VectorSource from 'ol/source/Vector.js';
import Cluster from 'ol/source/Cluster.js';
@@ -48,7 +50,15 @@
MAP_DEFAULT_ZOOM_USER,
STATUS_MESSAGES,
ERROR_MESSAGES,
OVERPASS_ENDPOINTS,
} from '$lib/constants';
import {
overpassEndpoint,
customTileUrl,
nominatimEndpoint,
basemapPreference,
footerCollapsedPref,
} from '$lib/settings';
import { APP_VERSION } from '$lib/version';
import favicon from '$lib/assets/favicon.svg';
@@ -69,12 +79,16 @@
let measureDistanceKm = 0;
let measureDistanceMiles = 0;
let fetchInFlight = 0;
let statusMessage = '';
let statusError = false;
let lastUpdated = 'never';
let basemap: 'dark' | 'light' | 'satellite' = 'dark';
let basemap: 'dark' | 'light' | 'satellite' | 'custom' =
$basemapPreference === 'dark' ||
$basemapPreference === 'light' ||
$basemapPreference === 'satellite' ||
$basemapPreference === 'custom'
? $basemapPreference
: 'dark';
let selectedCamera: Camera | null = null;
let infoOpen = true;
let selectedCameraPixel: [number, number] | null = null;
let isMobile = false;
let boxStartPoint: [number, number] | null = null;
let boxFeature: Feature | null = null;
@@ -86,9 +100,17 @@
let locationSearchOpen = false;
let locationSearchLoading = false;
let locationSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let locationSearchModalOpen = false;
let locationSearchInput: HTMLInputElement;
let selectionBoxCenterPixel: [number, number] | null = null;
let footerCollapsed = false;
let selectionBoxTopCenterPixel: [number, number] | null = null;
let footerCollapsed = $footerCollapsedPref;
let showBoxTooltip = false;
let showEndpointSettings = false;
let settingsTab: 'overpass' | 'tiles' | 'nominatim' = 'overpass';
let customEndpoint = '';
let customTileUrlInput = '';
let nominatimEndpointInput = '';
let boxSelectButton: HTMLButtonElement;
let boxTooltipPosition: { left: number; top: number } | null = null;
@@ -102,10 +124,13 @@
let baseLayer: TileLayer<XYZ> | null = null;
let basemapSources: ReturnType<typeof createBasemapSources>;
$: if (locationSearchModalOpen && isMobile && locationSearchInput) {
setTimeout(() => locationSearchInput.focus(), 100);
}
onMount(() => {
const updateIsMobile = () => {
isMobile = window.matchMedia('(max-width: 640px)').matches;
if (isMobile) infoOpen = false;
};
updateIsMobile();
window.addEventListener('resize', updateIsMobile);
@@ -117,7 +142,16 @@
const firstLayer = map.getLayers().item(0);
if (firstLayer instanceof TileLayer) {
baseLayer = firstLayer as TileLayer<XYZ>;
baseLayer.setSource(basemapSources.dark);
if (basemap === 'custom' && $customTileUrl) {
baseLayer.setSource(
new XYZ({
url: $customTileUrl,
crossOrigin: 'anonymous',
})
);
} else {
baseLayer.setSource(basemapSources[basemap === 'custom' ? 'dark' : basemap]);
}
}
cameraSource = createCameraSource();
@@ -128,13 +162,54 @@
measureSource = new VectorSource();
measureLayer = new VectorLayer({
source: measureSource,
style: new Style({
stroke: new Stroke({
color: '#ef4444',
width: 2,
lineDash: [6, 4],
}),
}),
style: (feature) => {
const styles = [
new Style({
stroke: new Stroke({
color: '#ef4444',
width: 2,
lineDash: [6, 4],
}),
}),
];
const geometry = feature.getGeometry();
if (geometry instanceof LineString) {
const distanceKm = feature.get('distanceKm');
const distanceMi = feature.get('distanceMi');
if (distanceKm && distanceKm > 0) {
const coords = geometry.getCoordinates();
const midCoord = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2];
const dx = coords[1][0] - coords[0][0];
const dy = coords[1][1] - coords[0][1];
let angle = Math.atan2(dy, dx);
let perpAngle = angle + Math.PI / 2;
if (Math.abs(angle) > Math.PI / 2) {
angle = angle > 0 ? angle - Math.PI : angle + Math.PI;
perpAngle = angle + Math.PI / 2;
}
const offset = 20;
const labelCoord = [
midCoord[0] + Math.cos(perpAngle) * offset,
midCoord[1] + Math.sin(perpAngle) * offset,
];
styles.push(
new Style({
geometry: new Point(labelCoord),
text: new Text({
text: `${distanceMi.toFixed(2)} mi\n\n${distanceKm.toFixed(2)} km`,
font: 'bold 11px sans-serif',
fill: new Fill({ color: '#ffffff' }),
textAlign: 'center',
textBaseline: 'middle',
rotation: angle,
offsetY: -2,
}),
})
);
}
}
return styles;
},
updateWhileInteracting: true,
});
@@ -177,8 +252,19 @@
if (clustered && clustered.length === 1) {
const inner = clustered[0];
if (inner.get('camera')) {
selectedCamera = inner.get('camera');
setStatusMessage('');
const camera = inner.get('camera') as Camera;
const geometry = inner.getGeometry();
if (geometry instanceof Point) {
const pixel = map!.getPixelFromCoordinate(geometry.getCoordinates());
if (selectedCamera === camera && selectedCameraPixel) {
selectedCamera = null;
selectedCameraPixel = null;
} else {
selectedCamera = camera;
selectedCameraPixel = [pixel[0], pixel[1]];
}
setStatusMessage('');
}
}
} else if (clustered && clustered.length > 1) {
const geometry = feature.getGeometry();
@@ -192,20 +278,31 @@
});
}
}
} else {
selectedCamera = null;
selectedCameraPixel = null;
}
});
map.getView().on('change:resolution', () => {
rebuildFovFromCameras();
updateSelectionBoxCenterPixel();
updateSelectedCameraPixel();
});
map.getView().on('change:center', () => {
updateSelectionBoxCenterPixel();
updateSelectedCameraPixel();
});
restoreStateFromUrl();
if (!OVERPASS_ENDPOINTS.includes($overpassEndpoint as (typeof OVERPASS_ENDPOINTS)[number])) {
customEndpoint = $overpassEndpoint;
}
customTileUrlInput = $customTileUrl;
nominatimEndpointInput = $nominatimEndpoint;
const hasSeenTooltip = localStorage.getItem('surveilled-box-tooltip-seen');
if (!hasSeenTooltip) {
setTimeout(async () => {
@@ -257,9 +354,9 @@
});
});
function setStatusMessage(message: string, isError = false) {
statusMessage = message;
statusError = isError;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function setStatusMessage(_message: string, ..._args: unknown[]) {
// Status messages are currently not displayed in the UI
}
function setLoading(active: boolean) {
@@ -375,9 +472,13 @@
measureSource.clear();
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
measureSource.addFeature(new Feature(new Point(fromLonLat(measureEnd))));
measureSource.addFeature(
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)]))
const lineFeature = new Feature(
new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)])
);
lineFeature.set('distanceKm', measureDistanceKm);
lineFeature.set('distanceMi', measureDistanceMiles);
measureSource.addFeature(lineFeature);
measureLayer.changed();
}
function updateMeasurePreview(coordinate: number[]) {
@@ -389,9 +490,13 @@
measureSource.clear();
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
measureSource.addFeature(new Feature(new Point(fromLonLat(current))));
measureSource.addFeature(
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(current)]))
const lineFeature = new Feature(
new LineString([fromLonLat(measureStart), fromLonLat(current)])
);
lineFeature.set('distanceKm', measureDistanceKm);
lineFeature.set('distanceMi', measureDistanceMiles);
measureSource.addFeature(lineFeature);
measureLayer.changed();
}
function toggleMeasureMode() {
@@ -452,7 +557,9 @@
function dismissBoxTooltip() {
showBoxTooltip = false;
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
if (typeof window !== 'undefined') {
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
}
}
function toggleBoxSelectMode() {
@@ -571,6 +678,7 @@
function updateSelectionBoxCenterPixel() {
if (!map || !lastSelectionBounds) {
selectionBoxCenterPixel = null;
selectionBoxTopCenterPixel = null;
return;
}
const centerLon = (lastSelectionBounds[0] + lastSelectionBounds[2]) / 2;
@@ -578,6 +686,21 @@
const centerCoord = fromLonLat([centerLon, centerLat]);
const pixel = map.getPixelFromCoordinate(centerCoord);
selectionBoxCenterPixel = [pixel[0], pixel[1]];
const topLat = lastSelectionBounds[3];
const topCenterCoord = fromLonLat([centerLon, topLat]);
const topPixel = map.getPixelFromCoordinate(topCenterCoord);
selectionBoxTopCenterPixel = [topPixel[0], topPixel[1]];
}
function updateSelectedCameraPixel() {
if (!map || !selectedCamera) {
selectedCameraPixel = null;
return;
}
const coord = fromLonLat([selectedCamera.lon, selectedCamera.lat]);
const pixel = map.getPixelFromCoordinate(coord);
selectedCameraPixel = [pixel[0], pixel[1]];
}
function finishBoxSelection(bounds: [number, number, number, number]) {
@@ -680,9 +803,19 @@
function handleBasemapChange(event: Event) {
const target = event.target as HTMLSelectElement;
basemap = target.value as 'dark' | 'light' | 'satellite';
if (baseLayer && basemapSources) {
baseLayer.setSource(basemapSources[basemap]);
basemap = target.value as 'dark' | 'light' | 'satellite' | 'custom';
basemapPreference.set(basemap);
if (baseLayer) {
if (basemap === 'custom' && $customTileUrl) {
baseLayer.setSource(
new XYZ({
url: $customTileUrl,
crossOrigin: 'anonymous',
})
);
} else if (basemapSources && basemap !== 'custom') {
baseLayer.setSource(basemapSources[basemap as keyof typeof basemapSources]);
}
}
}
@@ -719,6 +852,15 @@
}
function handleLocationSearchKeydown(event: KeyboardEvent) {
if (isMobile && event.key === 'Escape' && locationSearchModalOpen) {
locationSearchModalOpen = false;
locationSearchQuery = '';
locationSearchResults = [];
locationSearchOpen = false;
locationSearchSelectedIndex = -1;
locationSearchLoading = false;
return;
}
if (!locationSearchOpen || locationSearchResults.length === 0) {
if (event.key === 'Escape') {
locationSearchQuery = '';
@@ -775,6 +917,9 @@
locationSearchOpen = false;
locationSearchSelectedIndex = -1;
locationSearchLoading = false;
if (isMobile) {
locationSearchModalOpen = false;
}
}
function handleLocationSearchBlur() {
@@ -811,8 +956,28 @@
params.delete('bbox');
params.delete('count');
}
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
const newUrl = `?${params.toString()}`;
replaceState(newUrl, {});
}
function isValidLatitude(lat: number): boolean {
return !Number.isNaN(lat) && lat >= -90 && lat <= 90;
}
function isValidLongitude(lon: number): boolean {
return !Number.isNaN(lon) && lon >= -180 && lon <= 180;
}
function isValidBounds(bounds: [number, number, number, number]): boolean {
const [minLon, minLat, maxLon, maxLat] = bounds;
return (
isValidLongitude(minLon) &&
isValidLatitude(minLat) &&
isValidLongitude(maxLon) &&
isValidLatitude(maxLat) &&
minLon < maxLon &&
minLat < maxLat
);
}
function restoreStateFromUrl() {
@@ -826,7 +991,12 @@
if (centerParam) {
const centerParts = centerParam.split(',').map(Number);
if (centerParts.length === 2 && centerParts.every((v) => !Number.isNaN(v))) {
if (
centerParts.length === 2 &&
centerParts.every((v) => !Number.isNaN(v)) &&
isValidLongitude(centerParts[0]) &&
isValidLatitude(centerParts[1])
) {
map.getView().setCenter(fromLonLat(centerParts));
restored = true;
}
@@ -834,7 +1004,7 @@
if (zoomParam) {
const zoomValue = parseFloat(zoomParam);
if (!Number.isNaN(zoomValue)) {
if (!Number.isNaN(zoomValue) && zoomValue >= 0 && zoomValue <= 30) {
map.getView().setZoom(zoomValue);
restored = true;
}
@@ -844,18 +1014,20 @@
const parts = bboxParam.split(',').map(Number);
if (parts.length === 4 && parts.every((v) => !Number.isNaN(v))) {
bounds = [parts[0], parts[1], parts[2], parts[3]];
lastSelectionBounds = bounds;
renderSelection(bounds, selectionLayer);
if (!centerParam) {
const extent = transformExtent(
[bounds[0], bounds[1], bounds[2], bounds[3]],
'EPSG:4326',
'EPSG:3857'
);
map.getView().fit(extent, { duration: 0 });
if (isValidBounds(bounds)) {
lastSelectionBounds = bounds;
renderSelection(bounds, selectionLayer);
if (!centerParam) {
const extent = transformExtent(
[bounds[0], bounds[1], bounds[2], bounds[3]],
'EPSG:4326',
'EPSG:3857'
);
map.getView().fit(extent, { duration: 0 });
}
handleFetchCameras(bounds);
restored = true;
}
handleFetchCameras(bounds);
restored = true;
}
}
@@ -889,19 +1061,37 @@
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<header
class="bg-bg-secondary border-b border-border-color px-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0"
class="bg-bg-secondary border-b border-border-color px-3 sm:px-6 py-2 sm:py-3 flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center flex-shrink-0"
>
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
<img src={favicon} alt="Surveilled logo" class="h-5 w-5" />
Surveilled
</h1>
<div class="text-text-secondary text-xs sm:text-sm flex items-center gap-2">
<span class="text-accent-red-light font-semibold">{cameras.length}</span>
<span>cameras</span>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full sm:w-auto gap-1.5 sm:gap-2"
>
<h1
class="text-base sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2"
>
<a
href="https://git.quad4.io/Quad4-Software/Surveilled"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 sm:gap-2 hover:opacity-80 transition-opacity"
>
<img src={favicon} alt="Surveilled logo" class="h-4 w-4 sm:h-5 sm:w-5" />
Surveilled
</a>
</h1>
{#if lastUpdated !== 'never'}
<span class="text-xs">• {lastUpdated}</span>
<div class="text-text-secondary text-[10px] sm:text-xs">
{lastUpdated}
</div>
{/if}
</div>
<input
class="w-full sm:w-64 bg-bg-primary border border-border-color rounded px-3 py-1.5 text-xs sm:text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
type="text"
placeholder="Filter cameras..."
bind:value={filterQuery}
on:input={handleFilterInput}
/>
</header>
<div
class="h-1 bg-accent-red transition-all duration-300 {fetchInFlight > 0 ? 'w-full' : 'w-0'}"
@@ -920,6 +1110,16 @@
</div>
</div>
{/if}
{#if selectionBoxTopCenterPixel && lastSelectionBounds && cameras.length > 0}
<div
class="absolute z-[1001] pointer-events-none"
style="left: {selectionBoxTopCenterPixel[0]}px; top: {selectionBoxTopCenterPixel[1]}px; transform: translate(-50%, -100%);"
>
<div class="text-accent-red text-xs font-semibold whitespace-nowrap px-2 py-0.5">
{cameras.length} cameras
</div>
</div>
{/if}
{#if !isMobile}
<div
class="absolute bottom-4 left-4 z-[1000] flex flex-col gap-1 text-[11px] text-text-secondary bg-bg-secondary/90 border border-border-color rounded px-2 py-1 shadow"
@@ -939,10 +1139,10 @@
</div>
{/if}
<div
class="absolute top-4 sm:top-2 left-4 right-20 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1000] flex flex-col sm:flex-row gap-2 items-center"
class="absolute top-3 sm:top-1.5 left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1100] flex flex-col sm:flex-row gap-2 items-center"
>
<div
class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none"
class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none hidden sm:block"
>
<div class="relative">
<div
@@ -956,7 +1156,7 @@
</div>
<input
type="text"
class="w-full bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg pl-10 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
class="w-full bg-bg-secondary/95 backdrop-blur-sm border border-border-color rounded-lg pl-10 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
placeholder="Search location..."
bind:value={locationSearchQuery}
on:input={handleLocationSearchInput}
@@ -990,8 +1190,16 @@
</div>
</div>
<div
class="toolbar bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
class="toolbar bg-bg-secondary/95 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
>
<button
class="sm:hidden toolbar-btn"
title="Search location"
on:click={() => (locationSearchModalOpen = true)}
>
<Search size={16} />
</button>
<div class="sm:hidden w-px h-4 bg-border-color mx-0.5"></div>
<div class="relative">
{#if showBoxTooltip}
<div
@@ -1026,6 +1234,176 @@
>
<Ruler size={16} />
</button>
<div class="w-px h-4 bg-border-color mx-0.5"></div>
<div class="relative flex items-center">
<button
class="toolbar-btn {showEndpointSettings ? 'active' : ''}"
title="Settings"
on:click={() => (showEndpointSettings = !showEndpointSettings)}
>
<Settings size={16} />
</button>
{#if showEndpointSettings}
<div
class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-[1100] bg-bg-secondary border border-border-color rounded-lg p-0 shadow-xl w-80 text-text-primary overflow-hidden"
>
<div class="flex border-b border-border-color">
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'overpass'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'overpass')}
>
Overpass
</button>
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'nominatim'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'nominatim')}
>
Nominatim
</button>
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'tiles'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'tiles')}
>
Tiles
</button>
</div>
<div class="p-3">
{#if settingsTab === 'overpass'}
<div class="flex flex-col gap-1.5">
{#each OVERPASS_ENDPOINTS as endpoint}
<label
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
>
<input
type="radio"
name="overpass-endpoint-toolbar"
value={endpoint}
checked={$overpassEndpoint === endpoint}
on:change={() => {
overpassEndpoint.set(endpoint);
customEndpoint = '';
}}
class="accent-accent-red"
/>
<span class="truncate text-[10px] opacity-90">{endpoint.split('/')[2]}</span
>
</label>
{/each}
<div class="mt-1 border-t border-border-color/50 pt-2 flex flex-col gap-1.5">
<label
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
>
<input
type="radio"
name="overpass-endpoint-toolbar"
value="custom"
checked={customEndpoint !== '' && $overpassEndpoint === customEndpoint}
on:change={() => {
if (customEndpoint) overpassEndpoint.set(customEndpoint);
}}
class="accent-accent-red"
/>
<span class="text-[10px] opacity-90 font-medium">Custom Endpoint</span>
</label>
<input
type="text"
placeholder="https://your-overpass/api/interpreter"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={customEndpoint}
on:input={() => {
if (customEndpoint.startsWith('http')) {
overpassEndpoint.set(customEndpoint);
}
}}
/>
</div>
</div>
<p
class="mt-3 text-[9px] opacity-50 leading-relaxed border-t border-border-color/30 pt-2"
>
Preferred endpoint for camera data. Automatic fallback will occur if selected
service is unavailable.
</p>
{:else if settingsTab === 'nominatim'}
<div class="flex flex-col gap-2">
<div class="text-[10px] font-medium text-text-secondary mb-1">
Nominatim Search Endpoint
</div>
<input
type="text"
placeholder="https://nominatim.openstreetmap.org/search"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={nominatimEndpointInput}
on:input={() => {
if (nominatimEndpointInput.startsWith('http')) {
nominatimEndpoint.set(nominatimEndpointInput);
}
}}
/>
<div class="bg-bg-primary/30 rounded p-2 border border-border-color/30">
<div class="text-[9px] font-semibold text-accent-red-light mb-1 uppercase">
Default: OSM Public
</div>
<code class="text-[9px] break-all opacity-70">
https://nominatim.openstreetmap.org/search
</code>
</div>
<p class="text-[9px] opacity-50 leading-relaxed mt-1">
Used for geocoding location searches. Ensure the endpoint supports the
standard Nominatim query parameters.
</p>
</div>
{:else}
<div class="flex flex-col gap-2">
<div class="text-[10px] font-medium text-text-secondary mb-1">
Tile URL (XYZ format)
</div>
<input
type="text"
placeholder="https://tile.openstreetmap.org/&#123;z&#125;/&#123;x&#125;/&#123;y&#125;.png"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={customTileUrlInput}
on:input={() => {
customTileUrl.set(customTileUrlInput);
if (basemap === 'custom' && baseLayer) {
baseLayer.setSource(
new XYZ({
url: customTileUrlInput,
crossOrigin: 'anonymous',
})
);
}
}}
/>
<div class="bg-bg-primary/30 rounded p-2 border border-border-color/30">
<div class="text-[9px] font-semibold text-accent-red-light mb-1 uppercase">
Example: OpenStreetMap
</div>
<code class="text-[9px] break-all opacity-70">
https://tile.openstreetmap.org/&#123;z&#125;/&#123;x&#125;/&#123;y&#125;.png
</code>
</div>
<p class="text-[9px] opacity-50 leading-relaxed mt-1">
To use these tiles, select "Custom Tiles" from the basemap dropdown in the
toolbar.
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
<select
class="toolbar-select text-text-primary bg-bg-secondary border-none text-xs px-2 py-1 cursor-pointer"
title="Basemap"
@@ -1035,8 +1413,88 @@
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="satellite">Satellite</option>
<option value="custom">Custom Tiles</option>
</select>
</div>
{#if locationSearchModalOpen && isMobile}
<div
class="fixed inset-0 z-[1200] bg-black/50 backdrop-blur-sm flex items-start justify-center pt-4 px-4"
role="dialog"
aria-modal="true"
aria-label="Search location"
tabindex="-1"
on:click={(e) => {
if (e.target === e.currentTarget) {
locationSearchModalOpen = false;
}
}}
on:keydown={(e) => {
if (e.key === 'Escape') locationSearchModalOpen = false;
}}
>
<div
class="w-full max-w-md bg-bg-secondary border border-border-color rounded-lg shadow-xl overflow-hidden"
>
<div class="p-4 border-b border-border-color flex items-center justify-between">
<h3 class="text-base font-semibold text-text-primary">Search Location</h3>
<button
class="text-text-secondary hover:text-text-primary transition-colors"
on:click={() => (locationSearchModalOpen = false)}
>
Ă—
</button>
</div>
<div class="p-4">
<div class="relative mb-4">
<div
class="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none flex items-center"
>
{#if locationSearchLoading}
<Loader2 class="text-text-secondary animate-spin" size={18} stroke-width={2} />
{:else}
<Search class="text-text-secondary" size={18} stroke-width={2} />
{/if}
</div>
<input
type="text"
class="w-full bg-bg-primary border border-border-color rounded-lg pl-10 pr-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-2 focus:ring-accent-red"
placeholder="Search location..."
bind:value={locationSearchQuery}
on:input={handleLocationSearchInput}
on:keydown={handleLocationSearchKeydown}
bind:this={locationSearchInput}
/>
</div>
{#if locationSearchResults.length > 0}
<div class="max-h-[60vh] overflow-y-auto space-y-1">
{#each locationSearchResults as result, index}
<button
class="w-full text-left px-4 py-3 rounded-lg hover:bg-bg-primary transition-colors border border-transparent hover:border-border-color {index ===
locationSearchSelectedIndex
? 'bg-bg-primary border-border-color'
: ''}"
on:click={() => selectLocation(result)}
>
<div class="text-sm text-text-primary font-medium">
{result.display_name}
</div>
<div class="text-xs text-text-secondary mt-1">
{result.class} · {result.type}
</div>
</button>
{/each}
</div>
{:else if locationSearchQuery && !locationSearchLoading}
<div class="text-center py-8 text-text-secondary text-sm">No results found</div>
{:else if !locationSearchQuery}
<div class="text-center py-8 text-text-secondary text-sm">
Start typing to search for a location
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if showBoxTooltip && boxTooltipPosition}
<div
class="absolute z-[1300] pointer-events-none"
@@ -1062,94 +1520,76 @@
</div>
{/if}
</div>
<button
class="sm:hidden fixed bottom-4 left-4 z-[1100] bg-bg-secondary border border-border-color rounded-full px-3 py-2 text-xs font-semibold text-text-primary shadow-lg"
on:click={() => (infoOpen = !infoOpen)}
>
{infoOpen ? 'Hide info' : 'Show info'}
</button>
<div
class="absolute z-[1000] bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg p-4 shadow-lg max-w-md w-[calc(100%-2rem)] left-1/2 -translate-x-1/2 bottom-4 sm:bottom-auto sm:top-4 sm:right-4 sm:left-auto sm:translate-x-0"
class:hidden={!infoOpen && isMobile}
>
<div class="space-y-3 max-h-[70vh] sm:max-h-none overflow-y-auto">
<h2 class="text-base font-semibold text-text-primary">Surveillance Camera Map</h2>
<p class="text-sm text-text-secondary">
Click on camera markers to view details. Use "Select Area" to choose a specific region to
search.
</p>
<div class="flex items-center gap-2">
<input
class="w-full bg-bg-primary border border-border-color rounded px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
type="text"
placeholder="Filter by notes, type, operator, or direction"
bind:value={filterQuery}
on:input={handleFilterInput}
/>
</div>
{#if statusMessage}
<div class="status-message {statusError ? 'error' : ''}">
{statusMessage}
{#if selectedCamera && selectedCameraPixel}
<div
class="absolute z-[1200] pointer-events-none"
style="left: {selectedCameraPixel[0]}px; top: {selectedCameraPixel[1] +
20}px; transform: translateX(-50%);"
>
<div
class="bg-bg-secondary border border-border-color rounded-lg shadow-xl text-text-primary max-w-[280px] sm:max-w-sm pointer-events-auto"
>
<div class="p-3 border-b border-border-color">
<h3 class="text-sm font-semibold text-accent-red-light">Surveillance Camera</h3>
</div>
{/if}
{#if selectedCamera}
<div class="camera-item">
<h3 class="text-sm font-semibold text-accent-red-light mb-2">Surveillance Camera</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
<div class="p-3 space-y-2">
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Type:</strong>
{selectedCamera.type || 'Unknown'}
<div class="text-text-secondary">{selectedCamera.type || 'Unknown'}</div>
</div>
{#if selectedCamera.operator}
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Operator:</strong>
{selectedCamera.operator}
<div class="text-text-secondary">{selectedCamera.operator}</div>
</div>
{/if}
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Lat:</strong>
{selectedCamera.lat.toFixed(5)}
<div class="text-text-secondary">{selectedCamera.lat.toFixed(5)}</div>
</div>
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Lon:</strong>
{selectedCamera.lon.toFixed(5)}
<div class="text-text-secondary">{selectedCamera.lon.toFixed(5)}</div>
</div>
</div>
{#if selectedCamera.direction}
<div class="mt-2 p-2 bg-bg-secondary border border-border-color rounded">
<div class="text-sm text-text-primary font-medium">
Direction: {directionLabel(selectedCamera.direction)}
</div>
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
<strong class="text-text-primary">Direction:</strong>
<div class="text-text-secondary">{directionLabel(selectedCamera.direction)}</div>
</div>
{/if}
{#if selectedCamera.description}
<div class="mt-2 bg-bg-primary border border-border-color rounded p-2 text-xs">
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
<strong class="text-text-primary">Notes:</strong>
{selectedCamera.description}
<div class="text-text-secondary mt-1">{selectedCamera.description}</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
</main>
<footer
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary relative"
class="bg-bg-secondary border-border-color px-4 flex-shrink-0 text-xs text-text-secondary relative transition-all duration-300 {footerCollapsed
? 'py-0 h-0 min-h-0 border-t-0'
: 'py-3 border-t'}"
>
{#if isMobile}
<button
class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-bg-secondary border border-border-color rounded-full p-1.5 shadow-lg hover:bg-bg-primary transition-colors"
on:click={() => (footerCollapsed = !footerCollapsed)}
title={footerCollapsed ? 'Expand footer' : 'Collapse footer'}
>
{#if footerCollapsed}
<ChevronUp class="text-text-secondary" size={16} />
{:else}
<ChevronDown class="text-text-secondary" size={16} />
{/if}
</button>
{/if}
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed && isMobile}>
<button
class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-bg-secondary border border-border-color rounded-full p-1.5 shadow-lg hover:bg-bg-primary transition-colors z-[10]"
on:click={() => {
footerCollapsed = !footerCollapsed;
footerCollapsedPref.set(footerCollapsed);
}}
title={footerCollapsed ? 'Expand footer' : 'Collapse footer'}
>
{#if footerCollapsed}
<ChevronUp class="text-text-secondary" size={16} />
{:else}
<ChevronDown class="text-text-secondary" size={16} />
{/if}
</button>
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed}>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<div class="space-y-1">
<p class="flex items-center gap-1">
@@ -1187,26 +1627,3 @@
</div>
</footer>
</div>
<style>
.status-message {
font-size: 0.875rem;
line-height: 1.25rem;
padding: 0.5rem;
border-radius: 0.25rem;
border-width: 1px;
border-color: #262626;
background-color: #0a0a0a;
color: #a3a3a3;
}
.status-message.error {
border-color: #dc2626;
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
.camera-item > * + * {
margin-top: 0.5rem;
}
</style>

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@@ -8,9 +8,7 @@ const config = {
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
// adapter-node creates a Node.js server
adapter: adapter(),
},
};

View File

@@ -1,8 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import pkg from './package.json' with { type: 'json' };
declare const process: {
env: Record<string, string | undefined>;
};
const appVersion = process.env.VITE_APP_VERSION ?? pkg.version ?? 'dev';
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
plugins: [
sveltekit(),
VitePWA({