Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e694fcbf0
|
|||
|
077dbf95c2
|
|||
|
fc7892170e
|
|||
|
22b49c9548
|
|||
|
754eb33e10
|
|||
|
82ee5ad723
|
|||
|
8f4cb3e320
|
|||
|
f4a3be855a
|
|||
|
912e115b18
|
|||
|
92e919f165
|
|||
|
c93a304c74
|
|||
|
178e5ebbc3
|
|||
|
5f23d9ccfe
|
|||
|
dace6bdce3
|
|||
|
35a4486fd3
|
|||
|
d49797807c
|
|||
|
97ded97249
|
|||
|
45ba6b14dd
|
|||
|
175bbc710b
|
|||
|
b911350ddb
|
|||
|
cbb007662d
|
|||
|
20c1b654a4
|
|||
|
|
8de528cab0 | ||
|
|
610c3ed707 | ||
|
33530f6e51
|
|||
|
2ed10bc86d
|
|||
|
c222981f69
|
|||
|
606cbc0770
|
|||
|
81ce2fbbe6
|
|||
|
3ba66ff179
|
|||
|
f386459449
|
|||
|
70503fcb56
|
|||
|
42bcfc2dfc
|
|||
|
b9bb7ec8b5
|
|||
|
f92c8fced7
|
|||
|
f80919c45f
|
|||
|
ad4fa4f93a
|
|||
|
66a1933cfb
|
|||
|
620e63b7ba
|
|||
|
9918638cb8
|
|||
|
165f06e06b
|
|||
|
405a254824
|
|||
|
0bb79ff612
|
|||
|
55a90bd146
|
|||
|
7dcfb1ff7c
|
|||
|
6b35ab80a2
|
|||
|
d9d97db0f9
|
|||
|
6796d85a6a
|
|||
|
6f1428b8e8
|
|||
|
1b38193ca3
|
|||
|
f258254adf
|
|||
|
61c47c2d60
|
|||
|
5b31ef951d
|
|||
|
3c574b58f6
|
|||
|
07d5c540b6
|
|||
|
b826ee8f4f
|
|||
|
2df526c606
|
|||
|
26f91f1aee
|
|||
|
85febfacc1
|
|||
|
0eab72db49
|
|||
|
dffd3ff838
|
|||
|
f5407e56e1
|
|||
|
|
8adc0aa85d | ||
|
|
2e7780e711 | ||
|
c0dd901def
|
@@ -21,3 +21,7 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Package
|
||||
dist/
|
||||
|
||||
|
||||
@@ -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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
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
.gitea/workflows/docker.yml
Normal file
64
.gitea/workflows/docker.yml
Normal 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
.gitea/workflows/npm-publish.yml
Normal file
45
.gitea/workflows/npm-publish.yml
Normal 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
.gitea/workflows/sbom.yml
Normal file
58
.gitea/workflows/sbom.yml
Normal 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 }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Package
|
||||
dist/
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -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
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal 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
|
||||
39
LICENSE
39
LICENSE
@@ -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
Makefile
32
Makefile
@@ -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
|
||||
|
||||
131
README.md
131
README.md
@@ -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
Taskfile.yml
Normal file
118
Taskfile.yml
Normal 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
bin/surveilled.js
Executable file
6
bin/surveilled.js
Executable 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';
|
||||
@@ -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 ./
|
||||
@@ -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/**'],
|
||||
},
|
||||
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal 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
flake.nix
Normal file
57
flake.nix
Normal 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
package-lock.json
generated
9070
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@@ -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
pnpm-lock.yaml
generated
Normal file
7762
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
showcase/surveilled.png
Normal file
BIN
showcase/surveilled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 837 KiB |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
src/lib/settings.ts
Normal file
129
src/lib/settings.ts
Normal 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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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/{z}/{x}/{y}.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/{z}/{x}/{y}.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>
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user