66 Commits

Author SHA1 Message Date
Renovate Bot
e65a88a1c8 Update https://git.quad4.io/actions/setup-node action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9s
2025-12-31 00:03:42 +00:00
1e694fcbf0 Update README.md
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 10s
CI / check (push) Successful in 9m26s
CI / build (push) Successful in 9m36s
2025-12-29 20:13:27 -06:00
077dbf95c2 Add container image scanning and SBOM generation tasks to Taskfile 2025-12-29 20:13:19 -06:00
fc7892170e Add SBOM workflow
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 4s
CI / check (push) Successful in 27s
CI / build (push) Successful in 9m32s
2025-12-29 17:46:56 -06:00
22b49c9548 Update README.md
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 10s
CI / check (push) Successful in 9m24s
CI / build (push) Successful in 9m35s
2025-12-29 17:37:53 -06:00
754eb33e10 Add tasks to bump version in Taskfile for minor and major updates 2025-12-29 17:37:51 -06:00
82ee5ad723 Add Podman support to Taskfile for building and running containers
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 5s
CI / check (push) Successful in 24s
CI / build (push) Successful in 36s
2025-12-29 17:34:59 -06:00
8f4cb3e320 Update Dockerfile to optimize build process by removing unnecessary node_modules and ensuring production dependencies are installed correctly. 2025-12-29 17:34:55 -06:00
f4a3be855a Update package version to 1.4.0 in package.json
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 6s
CI / check (push) Successful in 23s
CI / build (push) Successful in 35s
2025-12-29 17:07:12 -06:00
912e115b18 Add CHANGELOG.md 2025-12-29 17:07:12 -06:00
92e919f165 Update README.md 2025-12-29 17:07:12 -06:00
c93a304c74 Add flake.lock 2025-12-29 17:07:12 -06:00
178e5ebbc3 Add flake.nix 2025-12-29 17:07:12 -06:00
5f23d9ccfe Enable eslint security rule for object injection detection in map.ts 2025-12-29 17:07:12 -06:00
dace6bdce3 Enable eslint security rule for object injection detection in +page.svelte 2025-12-29 17:07:12 -06:00
35a4486fd3 Clean up surveilled.js by removing unnecessary blank lines 2025-12-29 17:07:11 -06:00
d49797807c Add Taskfile 2025-12-29 17:07:11 -06:00
97ded97249 Remove makefile, bash scripts 2025-12-29 17:07:11 -06:00
45ba6b14dd Add eslint-plugin-security for enhanced security checks and configure global process variable for bin scripts 2025-12-29 17:07:11 -06:00
175bbc710b Move dockerfile 2025-12-29 17:07:11 -06:00
b911350ddb Remove package-lock.json, add pnpm-lock.yaml for dependency management, update package.json to include license, author, and package manager details, and add eslint-plugin-security as a new dependency. 2025-12-29 17:07:11 -06:00
cbb007662d Update CI, Docker, and NPM publish workflows to use direct action links, switch to PNPM caching, and replace npm commands with task commands for improved consistency and efficiency. 2025-12-29 17:07:11 -06:00
20c1b654a4 Change license from MIT to BSD 3-Clause, update README to reflect PNPM usage, and modify Svelte page to indicate new license type. 2025-12-29 17:07:10 -06:00
ivan
8de528cab0 Merge pull request 'Configure Renovate' (#4) from renovate/configure into master
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 23s
CI / check (push) Successful in 32s
CI / build (push) Successful in 46s
Reviewed-on: #4
2025-12-27 20:32:29 +00:00
Renovate Bot
610c3ed707 Add renovate.json
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 19s
2025-12-27 20:30:40 +00:00
33530f6e51 Update Docker workflow
All checks were successful
CI / check (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build (push) Successful in 42s
2025-12-25 15:18:44 -06:00
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
36 changed files with 9250 additions and 9335 deletions
+4
View File
@@ -21,3 +21,7 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Package
dist/
+22 -10
View File
@@ -11,29 +11,41 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: 22
cache: npm
cache: pnpm
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: npm ci
run: task install:ci
- name: Svelte check (fail on warnings)
run: bash scripts/check.sh
run: task check
build:
runs-on: ubuntu-latest
needs: check
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: 22
cache: npm
cache: pnpm
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: npm ci
run: task install:ci
- name: Build
run: bash scripts/build.sh
run: task build
+64
View File
@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
workflow_dispatch:
push:
tags:
- 'v*'
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: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up QEMU
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to the Container registry
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
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: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+45
View File
@@ -0,0 +1,45 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: '22'
cache: pnpm
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: task install:ci
- name: Package
run: task package
- name: Configure npm for publishing
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: task publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: OSV scan
run: bash scripts/osv_scan.sh
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: OSV scan
run: bash scripts/osv_scan.sh
+58
View File
@@ -0,0 +1,58 @@
name: Generate SBOM
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
generate-sbom:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 22
cache: pnpm
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: task install:ci
- name: Download Trivy
run: |
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
- name: Generate SBOM
run: |
mkdir -p sbom
trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
- name: Commit and Push Changes
run: |
git config --global user.name "Gitea Action"
git config --global user.email "actions@noreply.quad4.io"
git remote set-url origin https://${{ secrets.GITEA_TOKEN }}@git.quad4.io/${{ github.repository }}.git
git fetch origin master
git checkout master
git add sbom/
git diff --quiet && git diff --staged --quiet || (git commit -m "Auto-update SBOM [skip ci]" && git push origin master)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
Vendored
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Package
dist/
+2 -1
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}
+37
View File
@@ -0,0 +1,37 @@
# Changelog
## 1.4.0 - 2025-12-29
### Major Codebase Changes
- Migrated from `npm` to `pnpm` (v10.25.0)
- Migrated from `Makefile` to `Taskfile` for all development tasks
- Updated license from `MIT` to `BSD-3-Clause`
- Added `license` and `author` fields to `package.json`
- Moved Dockerfile to `docker/` folder
- Added Nix flake for development environment
### Features
- Added Task section to README with available tasks
- Improved README structure
### Security
- Added `eslint-plugin-security` for security linting
- Updated Docker containers to use specific pnpm version (10.25.0)
### CI/CD Updates
- Updated workflows to use `task` commands
- Added `setup-task` action to workflows
- Updated workflows to use full action URLs with commit hashes
- Updated Docker workflow to reference `docker/Dockerfile`
- All workflows use pnpm with `--frozen-lockfile`
### Development
- Added `setup` task for enabling corepack
- Added `install:ci` task for CI dependency installation
- Updated Taskfile to reference `docker/Dockerfile` for Docker builds
- Added flake.nix with Task, Node.js 20, and pnpm
+23 -16
View File
@@ -1,22 +1,29 @@
MIT License
BSD 3-Clause License
Copyright (c) 2025 Quad4.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-32
View File
@@ -1,32 +0,0 @@
.PHONY: help install dev build preview check lint format clean
help:
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install:
npm install
dev:
npm run dev
build:
npm run build
preview:
npm run preview
check:
npm run check
lint:
npm run lint
format:
npm run format
clean:
rm -rf .svelte-kit build node_modules/.vite
+116 -15
View File
@@ -1,40 +1,141 @@
# Surveilled
[Website](https://surveilled.quad4.io)
A map of cameras in the world using OpenStreetMap overpass data.
A map of cameras in the world using OpenStreetMap data.
<img src="https://git.quad4.io/Quad4-Software/Surveilled/raw/commit/70503fcb56994c4c1bb019dfbcc3b589ebd94039/showcase/surveilled.png" alt="showcase image" width="900">
Check out the live website at [surveilled.quad4.io](https://surveilled.quad4.io)
## 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
## Quick Start
### Using Docker
```sh
docker run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
```
Then open your browser at `http://localhost:3000`
### Using Podman
```sh
podman run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
```
Then open your browser at `http://localhost:3000`
### Using PNPM
```sh
pnpm config set @quad4:registry=https://git.quad4.io/api/packages/Quad4-Software/npm/
pnpm install -g @quad4/surveilled
surveilled
```
Or with custom port and host:
```sh
PORT=8080 HOST=0.0.0.0 surveilled
```
## Development
```sh
make dev
```
### Prerequisites
## Building
- Node.js `>=18.0.0`
- pnpm `>=10.25.0`
### Setup
```sh
make build
git clone https://git.quad4.io/Quad4-Software/Surveilled
cd Surveilled
pnpm install
```
## Preview
### Task
```sh
make preview
The project uses [Task](https://taskfile.dev/) for all development tasks.
```
| Task | Description |
|---------------|---------------------------------------|
| default | Show available tasks |
| dev | Run development server |
| build | Build the application |
| package | Package the application |
| publish | Publish to npm registry |
| preview | Preview production build |
| check | Run type checking |
| lint | Run linter |
| format | Format code |
| clean | Clean build artifacts |
| docker-build | Build Docker image |
| docker-run | Run Docker container |
| docker | Build and run Docker container |
| podman-build | Build Podman image |
| podman-run | Run Podman container |
| podman | Build and run Podman container |
| scan | Scan container image with trivy |
| sbom | Generate SBOM with trivy |
| version:minor | Bump version minor |
| version:major | Bump version major |
| setup | Setup development environment |
| install | Install dependencies |
| install:ci | Install dependencies for CI |
example: task dev
you might nee to set alias alias task=`go-task`
```
## Docker
## Building Container Image
Uses Chainguard Images which are rootless and very minimal images.
### Using Docker
```sh
docker build -t surveilled .
docker run -p 3000:3000 surveilled
task docker-build
task docker-run
```
## LICENSE
Or use the combined task:
[MIT](LICENSE)
```sh
task docker
```
### Using Podman
```sh
task podman-build
task podman-run
```
Or use the combined task:
```sh
task podman
```
## Contributing
Send email to [team@quad4.io](mailto:team@quad4.io) with your feedback or any issues you may have.
## License
[BSD 3-Clause](LICENSE)
+118
View File
@@ -0,0 +1,118 @@
version: '3'
tasks:
default:
desc: Show available tasks
cmds:
- task --list
dev:
desc: Run development server
cmds:
- pnpm install
- pnpm run dev
build:
desc: Build the application
cmds:
- pnpm run build
package:
desc: Package the application
cmds:
- pnpm run package
publish:
desc: Publish to npm registry
cmds:
- pnpm publish --no-git-checks
preview:
desc: Preview production build
cmds:
- pnpm run preview
check:
desc: Run type checking
cmds:
- pnpm run check
lint:
desc: Run linter
cmds:
- pnpm run lint
format:
desc: Format code
cmds:
- pnpm run format
clean:
desc: Clean build artifacts
cmds:
- rm -rf .svelte-kit build node_modules/.vite dist package
docker-build:
desc: Build Docker image
cmds:
- docker build -f docker/Dockerfile -t surveilled .
docker-run:
desc: Run Docker container
cmds:
- docker run --rm -p 3000:3000 surveilled
docker:
desc: Build and run Docker container
deps: [docker-build, docker-run]
podman-build:
desc: Build Podman image
cmds:
- podman build -f docker/Dockerfile -t surveilled .
podman-run:
desc: Run Podman container
cmds:
- podman run --rm -p 3000:3000 surveilled
podman:
desc: Build and run Podman container
deps: [podman-build, podman-run]
scan:
desc: Scan container image with trivy
cmds:
- trivy image --scanners vuln surveilled
sbom:
desc: Generate SBOM with trivy
cmds:
- mkdir -p sbom
- trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
- trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
setup:
desc: Setup development environment
cmds:
- corepack enable
install:
desc: Install dependencies
cmds:
- pnpm install
install:ci:
desc: Install dependencies for CI (frozen lockfile)
cmds:
- pnpm install --frozen-lockfile
version:minor:
desc: Bump version minor
cmds:
- pnpm version minor
version:major:
desc: Bump version major
cmds:
- pnpm version major
+6
View File
@@ -0,0 +1,6 @@
#!/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';
+13 -7
View File
@@ -2,23 +2,29 @@ FROM cgr.dev/chainguard/node:latest-dev AS builder
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
USER root
RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
USER node
RUN npm install --save-dev @sveltejs/adapter-node@latest
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
RUN pnpm add -D @sveltejs/adapter-node@latest
COPY --chown=node:node . .
COPY --chown=node:node svelte.config.docker.js svelte.config.js
RUN npm run build
RUN pnpm run build
RUN rm -rf node_modules && \
pnpm install --prod --frozen-lockfile && \
pnpm store prune
FROM cgr.dev/chainguard/node:latest AS runtime
WORKDIR /app
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev && \
npm cache clean --force
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/build ./build
COPY --from=builder --chown=node:node /app/package.json ./
+13
View File
@@ -3,6 +3,7 @@ import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import sveltePlugin from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import securityPlugin from 'eslint-plugin-security';
export default [
js.configs.recommended,
@@ -22,9 +23,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',
@@ -41,9 +44,11 @@ export default [
plugins: {
'@typescript-eslint': tsPlugin,
svelte: sveltePlugin,
security: securityPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
...securityPlugin.configs.recommended.rules,
},
},
{
@@ -61,6 +66,14 @@ export default [
...sveltePlugin.configs.recommended.rules,
},
},
{
files: ['bin/**/*.js'],
languageOptions: {
globals: {
process: 'readonly',
},
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**'],
},
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766902085,
"narHash": "sha256-coBu0ONtFzlwwVBzmjacUQwj3G+lybcZ1oeNSQkgC0M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0b0e0fddf73fd517c3471e546c0df87a42d53f4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+57
View File
@@ -0,0 +1,57 @@
{
description = "Surveilled development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
task = pkgs.buildGoModule rec {
pname = "task";
version = "3.46.3";
src = pkgs.fetchFromGitHub {
owner = "go-task";
repo = "task";
rev = "v${version}";
hash = "sha256-1bS8ZZAcemgRG7PTeGTFfd49T9u6U6CxxrbotwCM15A=";
};
vendorHash = "sha256-Tm0tqureCRwcP5KKDTa9TO1yZ3Px3ulf9/jKQDDTjDw=";
subPackages = [ "cmd/task" ];
doCheck = false;
meta = with pkgs.lib; {
description = "A task runner / simpler Make alternative written in Go";
homepage = "https://taskfile.dev/";
license = licenses.mit;
maintainers = with maintainers; [ ];
};
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
task
nodejs_20
nodePackages.pnpm
];
shellHook = ''
echo "Surveilled Development Environment"
echo "Task version: $(task --version 2>/dev/null || echo 'installed')"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
'';
};
});
}
-9070
View File
File diff suppressed because it is too large Load Diff
+46 -8
View File
@@ -1,41 +1,79 @@
{
"name": "surveilled",
"private": true,
"version": "1.1.0",
"name": "@quad4/surveilled",
"version": "1.4.0",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"surveilled": "./bin/surveilled.js"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@10.25.0",
"publishConfig": {
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
},
"pnpm": {
"overrides": {
"cookie": "1.1.1"
}
},
"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-security": "^3.0.1",
"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"
}
}
+7762
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Building app..."
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Running Svelte sync..."
npx svelte-kit sync
echo "Running svelte-check (fail on errors)..."
npx svelte-check --tsconfig ./tsconfig.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

+6
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;
+41 -5
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);
+2
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
+5 -1
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';
+22 -8
View File
@@ -1,3 +1,4 @@
/* eslint-disable security/detect-object-injection */
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import { fromLonLat, toLonLat, transformExtent } from 'ol/proj.js';
@@ -28,18 +29,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 +58,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
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();
+20 -1
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';
+558 -140
View File
@@ -1,8 +1,10 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
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 +22,7 @@
Loader2,
ChevronUp,
ChevronDown,
Settings,
} from 'lucide-svelte';
import VectorSource from 'ol/source/Vector.js';
import Cluster from 'ol/source/Cluster.js';
@@ -48,7 +51,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 +80,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 +101,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 +125,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 +143,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 +163,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 +253,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 +279,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 +355,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 +473,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 +491,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 +558,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 +679,7 @@
function updateSelectionBoxCenterPixel() {
if (!map || !lastSelectionBounds) {
selectionBoxCenterPixel = null;
selectionBoxTopCenterPixel = null;
return;
}
const centerLon = (lastSelectionBounds[0] + lastSelectionBounds[2]) / 2;
@@ -578,6 +687,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 +804,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 +853,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 +918,9 @@
locationSearchOpen = false;
locationSearchSelectedIndex = -1;
locationSearchLoading = false;
if (isMobile) {
locationSearchModalOpen = false;
}
}
function handleLocationSearchBlur() {
@@ -811,8 +957,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 +992,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 +1005,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 +1015,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 +1062,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 +1111,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 +1140,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 +1157,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 +1191,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 +1235,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 +1414,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 +1521,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">
@@ -1159,7 +1600,7 @@
rel="noopener noreferrer"
class="text-accent-red-light hover:underline">Quad4</a
>
- Open-Source MIT
- Open-Source BSD 3-Clause
<a
href="https://git.quad4.io/Quad4-Software/Surveilled"
target="_blank"
@@ -1187,26 +1628,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>
+2 -4
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(),
},
};
+10
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({