Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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/
|
||||
|
||||
|
||||
66
.gitea/workflows/docker.yml
Normal file
66
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
REGISTRY: git.quad4.io
|
||||
IMAGE_NAME: quad4-software/surveilled
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image_digest: ${{ steps.build.outputs.digest }}
|
||||
image_tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
36
.gitea/workflows/npm-publish.yml
Normal file
36
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Publish NPM Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --registry=https://registry.npmjs.org/
|
||||
|
||||
- name: Package
|
||||
run: make package
|
||||
|
||||
- name: Configure npm for publishing
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
|
||||
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_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}
|
||||
|
||||
22
Makefile
22
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev build preview check lint format clean
|
||||
.PHONY: help install dev build preview check lint format clean docker-build docker-run docker package publish
|
||||
|
||||
help:
|
||||
@echo 'Usage: make [target]'
|
||||
@@ -6,15 +6,19 @@ help:
|
||||
@echo 'Available targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
dev:
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
build:
|
||||
npm run build
|
||||
|
||||
package:
|
||||
npm run package
|
||||
|
||||
publish:
|
||||
npm publish
|
||||
|
||||
preview:
|
||||
npm run preview
|
||||
|
||||
@@ -28,5 +32,13 @@ format:
|
||||
npm run format
|
||||
|
||||
clean:
|
||||
rm -rf .svelte-kit build node_modules/.vite
|
||||
rm -rf .svelte-kit build node_modules/.vite dist package
|
||||
|
||||
docker-build:
|
||||
docker build -t surveilled .
|
||||
|
||||
docker-run:
|
||||
docker run --rm -p 3000:3000 surveilled
|
||||
|
||||
docker: docker-build docker-run
|
||||
|
||||
|
||||
70
README.md
70
README.md
@@ -1,40 +1,84 @@
|
||||
# Surveilled
|
||||
|
||||
[Website](https://surveilled.quad4.io)
|
||||
<img src="showcase/surveilled.png" alt="showcase image" width="900">
|
||||
|
||||
A map of cameras in the world using OpenStreetMap data.
|
||||
Check out the live website at [surveilled.quad4.io](https://surveilled.quad4.io)
|
||||
|
||||
A map of cameras in the world using OpenStreetMap overpass data.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Data is fetched from OSM Overpass and may not be accurate or up to date as this data is community-sourced.
|
||||
|
||||
## Features
|
||||
|
||||
- Draw a box to get cameras for that area
|
||||
- Measure distance between two points/cameras
|
||||
- Export or copy GeoJSON of cameras in an area
|
||||
- Customize Nominatim, Overpass, and Tile endpoints
|
||||
- No reliance on external CDNs or Google fonts.
|
||||
- PWA installable
|
||||
- Mobile-friendly
|
||||
|
||||
## Self-hosting
|
||||
|
||||
### NPM
|
||||
|
||||
coming soon
|
||||
|
||||
### Docker
|
||||
|
||||
```sh
|
||||
docker run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
|
||||
```
|
||||
|
||||
### Podman
|
||||
|
||||
```sh
|
||||
podman run -p 3000:3000 git.quad4.io/quad4-software/surveilled:latest
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
git clone https://git.quad4.io/Quad4-Software/Surveilled
|
||||
cd Surveilled
|
||||
```
|
||||
|
||||
### NPM
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Makefile
|
||||
|
||||
```sh
|
||||
make dev
|
||||
```
|
||||
|
||||
## Building
|
||||
#### Check/Lint/Format
|
||||
|
||||
```sh
|
||||
make build
|
||||
make check
|
||||
make lint
|
||||
make format
|
||||
```
|
||||
|
||||
## Preview
|
||||
|
||||
```sh
|
||||
make preview
|
||||
```
|
||||
|
||||
## Docker
|
||||
## Building Docker Image
|
||||
|
||||
Uses Chainguard Images which are rootless and very minimal images.
|
||||
|
||||
```sh
|
||||
docker build -t surveilled .
|
||||
docker run -p 3000:3000 surveilled
|
||||
docker run --rm -p 3000:3000 surveilled
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Send email to [team@quad4.io](mailto:team@quad4.io) with your feedback or any issues you may have.
|
||||
|
||||
## LICENSE
|
||||
|
||||
[MIT](LICENSE)
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -22,9 +22,11 @@ export default [
|
||||
HTMLElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
HTMLButtonElement: 'readonly',
|
||||
navigator: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
Blob: 'readonly',
|
||||
Event: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
|
||||
91
package-lock.json
generated
91
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "surveilled",
|
||||
"version": "1.1.0",
|
||||
"name": "@quad4/surveilled",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "surveilled",
|
||||
"version": "1.1.0",
|
||||
"name": "@quad4/surveilled",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"autoprefixer": "^10.4.23",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.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",
|
||||
@@ -2914,6 +2915,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/package": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.7.tgz",
|
||||
"integrity": "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"kleur": "^4.1.5",
|
||||
"sade": "^1.8.1",
|
||||
"semver": "^7.5.4",
|
||||
"svelte2tsx": "~0.7.33"
|
||||
},
|
||||
"bin": {
|
||||
"svelte-package": "svelte-package.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.14 || >=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/package/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/package/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz",
|
||||
@@ -3933,6 +3987,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent-js": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
|
||||
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -7080,6 +7141,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -7728,6 +7796,21 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte2tsx": {
|
||||
"version": "0.7.46",
|
||||
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.46.tgz",
|
||||
"integrity": "sha512-S++Vw3w47a8rBuhbz4JK0fcGea8tOoX1boT53Aib8+oUO2EKeOG+geXprJVTDfBlvR+IJdf3jIpR2RGwT6paQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dedent-js": "^1.0.1",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
|
||||
"typescript": "^4.9.4 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
|
||||
25
package.json
25
package.json
@@ -1,8 +1,23 @@
|
||||
{
|
||||
"name": "surveilled",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"name": "@quad4/surveilled",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"svelte": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"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",
|
||||
@@ -11,12 +26,14 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.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",
|
||||
|
||||
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';
|
||||
|
||||
@@ -28,18 +28,27 @@ import {
|
||||
FOV_MIN_ZOOM,
|
||||
LAYER_RENDER_BUFFER,
|
||||
} from './constants';
|
||||
import type { Camera } from './api';
|
||||
|
||||
export interface Camera {
|
||||
lon: number;
|
||||
lat: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
operator?: string;
|
||||
description?: string;
|
||||
}
|
||||
export type { Camera };
|
||||
|
||||
const canUseCacheApi = typeof caches !== 'undefined';
|
||||
|
||||
function isValidTileUrl(url: string): boolean {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
if (url.startsWith('javascript:')) return false;
|
||||
if (url.startsWith('data:')) {
|
||||
if (!url.startsWith('data:image/')) return false;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cachedTileLoad(imageTile: Tile, src: string) {
|
||||
if (!(imageTile instanceof ImageTile)) {
|
||||
return;
|
||||
@@ -48,6 +57,10 @@ async function cachedTileLoad(imageTile: Tile, src: string) {
|
||||
if (!(image instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
if (!isValidTileUrl(src)) {
|
||||
console.error('Invalid tile URL:', src);
|
||||
return;
|
||||
}
|
||||
if (!canUseCacheApi) {
|
||||
image.src = src;
|
||||
return;
|
||||
|
||||
129
src/lib/settings.ts
Normal file
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,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import Map from 'ol/Map.js';
|
||||
import type MapBrowserEvent from 'ol/MapBrowserEvent.js';
|
||||
import { Style, Stroke, Fill } from 'ol/style.js';
|
||||
import { Style, Stroke, Fill, Text } from 'ol/style.js';
|
||||
import { fromLonLat, toLonLat, transformExtent } from 'ol/proj.js';
|
||||
import { Polygon, Point, LineString } from 'ol/geom.js';
|
||||
import Feature from 'ol/Feature.js';
|
||||
@@ -20,6 +21,7 @@
|
||||
Loader2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
} from 'lucide-svelte';
|
||||
import VectorSource from 'ol/source/Vector.js';
|
||||
import Cluster from 'ol/source/Cluster.js';
|
||||
@@ -48,7 +50,15 @@
|
||||
MAP_DEFAULT_ZOOM_USER,
|
||||
STATUS_MESSAGES,
|
||||
ERROR_MESSAGES,
|
||||
OVERPASS_ENDPOINTS,
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
overpassEndpoint,
|
||||
customTileUrl,
|
||||
nominatimEndpoint,
|
||||
basemapPreference,
|
||||
footerCollapsedPref,
|
||||
} from '$lib/settings';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
@@ -69,12 +79,16 @@
|
||||
let measureDistanceKm = 0;
|
||||
let measureDistanceMiles = 0;
|
||||
let fetchInFlight = 0;
|
||||
let statusMessage = '';
|
||||
let statusError = false;
|
||||
let lastUpdated = 'never';
|
||||
let basemap: 'dark' | 'light' | 'satellite' = 'dark';
|
||||
let basemap: 'dark' | 'light' | 'satellite' | 'custom' =
|
||||
$basemapPreference === 'dark' ||
|
||||
$basemapPreference === 'light' ||
|
||||
$basemapPreference === 'satellite' ||
|
||||
$basemapPreference === 'custom'
|
||||
? $basemapPreference
|
||||
: 'dark';
|
||||
let selectedCamera: Camera | null = null;
|
||||
let infoOpen = true;
|
||||
let selectedCameraPixel: [number, number] | null = null;
|
||||
let isMobile = false;
|
||||
let boxStartPoint: [number, number] | null = null;
|
||||
let boxFeature: Feature | null = null;
|
||||
@@ -86,9 +100,17 @@
|
||||
let locationSearchOpen = false;
|
||||
let locationSearchLoading = false;
|
||||
let locationSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let locationSearchModalOpen = false;
|
||||
let locationSearchInput: HTMLInputElement;
|
||||
let selectionBoxCenterPixel: [number, number] | null = null;
|
||||
let footerCollapsed = false;
|
||||
let selectionBoxTopCenterPixel: [number, number] | null = null;
|
||||
let footerCollapsed = $footerCollapsedPref;
|
||||
let showBoxTooltip = false;
|
||||
let showEndpointSettings = false;
|
||||
let settingsTab: 'overpass' | 'tiles' | 'nominatim' = 'overpass';
|
||||
let customEndpoint = '';
|
||||
let customTileUrlInput = '';
|
||||
let nominatimEndpointInput = '';
|
||||
let boxSelectButton: HTMLButtonElement;
|
||||
let boxTooltipPosition: { left: number; top: number } | null = null;
|
||||
|
||||
@@ -102,10 +124,13 @@
|
||||
let baseLayer: TileLayer<XYZ> | null = null;
|
||||
let basemapSources: ReturnType<typeof createBasemapSources>;
|
||||
|
||||
$: if (locationSearchModalOpen && isMobile && locationSearchInput) {
|
||||
setTimeout(() => locationSearchInput.focus(), 100);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const updateIsMobile = () => {
|
||||
isMobile = window.matchMedia('(max-width: 640px)').matches;
|
||||
if (isMobile) infoOpen = false;
|
||||
};
|
||||
updateIsMobile();
|
||||
window.addEventListener('resize', updateIsMobile);
|
||||
@@ -117,7 +142,16 @@
|
||||
const firstLayer = map.getLayers().item(0);
|
||||
if (firstLayer instanceof TileLayer) {
|
||||
baseLayer = firstLayer as TileLayer<XYZ>;
|
||||
baseLayer.setSource(basemapSources.dark);
|
||||
if (basemap === 'custom' && $customTileUrl) {
|
||||
baseLayer.setSource(
|
||||
new XYZ({
|
||||
url: $customTileUrl,
|
||||
crossOrigin: 'anonymous',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
baseLayer.setSource(basemapSources[basemap === 'custom' ? 'dark' : basemap]);
|
||||
}
|
||||
}
|
||||
|
||||
cameraSource = createCameraSource();
|
||||
@@ -128,13 +162,54 @@
|
||||
measureSource = new VectorSource();
|
||||
measureLayer = new VectorLayer({
|
||||
source: measureSource,
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}),
|
||||
style: (feature) => {
|
||||
const styles = [
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
const geometry = feature.getGeometry();
|
||||
if (geometry instanceof LineString) {
|
||||
const distanceKm = feature.get('distanceKm');
|
||||
const distanceMi = feature.get('distanceMi');
|
||||
if (distanceKm && distanceKm > 0) {
|
||||
const coords = geometry.getCoordinates();
|
||||
const midCoord = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2];
|
||||
const dx = coords[1][0] - coords[0][0];
|
||||
const dy = coords[1][1] - coords[0][1];
|
||||
let angle = Math.atan2(dy, dx);
|
||||
let perpAngle = angle + Math.PI / 2;
|
||||
if (Math.abs(angle) > Math.PI / 2) {
|
||||
angle = angle > 0 ? angle - Math.PI : angle + Math.PI;
|
||||
perpAngle = angle + Math.PI / 2;
|
||||
}
|
||||
const offset = 20;
|
||||
const labelCoord = [
|
||||
midCoord[0] + Math.cos(perpAngle) * offset,
|
||||
midCoord[1] + Math.sin(perpAngle) * offset,
|
||||
];
|
||||
styles.push(
|
||||
new Style({
|
||||
geometry: new Point(labelCoord),
|
||||
text: new Text({
|
||||
text: `${distanceMi.toFixed(2)} mi\n\n${distanceKm.toFixed(2)} km`,
|
||||
font: 'bold 11px sans-serif',
|
||||
fill: new Fill({ color: '#ffffff' }),
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
rotation: angle,
|
||||
offsetY: -2,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return styles;
|
||||
},
|
||||
updateWhileInteracting: true,
|
||||
});
|
||||
|
||||
@@ -177,8 +252,19 @@
|
||||
if (clustered && clustered.length === 1) {
|
||||
const inner = clustered[0];
|
||||
if (inner.get('camera')) {
|
||||
selectedCamera = inner.get('camera');
|
||||
setStatusMessage('');
|
||||
const camera = inner.get('camera') as Camera;
|
||||
const geometry = inner.getGeometry();
|
||||
if (geometry instanceof Point) {
|
||||
const pixel = map!.getPixelFromCoordinate(geometry.getCoordinates());
|
||||
if (selectedCamera === camera && selectedCameraPixel) {
|
||||
selectedCamera = null;
|
||||
selectedCameraPixel = null;
|
||||
} else {
|
||||
selectedCamera = camera;
|
||||
selectedCameraPixel = [pixel[0], pixel[1]];
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}
|
||||
} else if (clustered && clustered.length > 1) {
|
||||
const geometry = feature.getGeometry();
|
||||
@@ -192,20 +278,31 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedCamera = null;
|
||||
selectedCameraPixel = null;
|
||||
}
|
||||
});
|
||||
|
||||
map.getView().on('change:resolution', () => {
|
||||
rebuildFovFromCameras();
|
||||
updateSelectionBoxCenterPixel();
|
||||
updateSelectedCameraPixel();
|
||||
});
|
||||
|
||||
map.getView().on('change:center', () => {
|
||||
updateSelectionBoxCenterPixel();
|
||||
updateSelectedCameraPixel();
|
||||
});
|
||||
|
||||
restoreStateFromUrl();
|
||||
|
||||
if (!OVERPASS_ENDPOINTS.includes($overpassEndpoint as (typeof OVERPASS_ENDPOINTS)[number])) {
|
||||
customEndpoint = $overpassEndpoint;
|
||||
}
|
||||
customTileUrlInput = $customTileUrl;
|
||||
nominatimEndpointInput = $nominatimEndpoint;
|
||||
|
||||
const hasSeenTooltip = localStorage.getItem('surveilled-box-tooltip-seen');
|
||||
if (!hasSeenTooltip) {
|
||||
setTimeout(async () => {
|
||||
@@ -257,9 +354,9 @@
|
||||
});
|
||||
});
|
||||
|
||||
function setStatusMessage(message: string, isError = false) {
|
||||
statusMessage = message;
|
||||
statusError = isError;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function setStatusMessage(_message: string, ..._args: unknown[]) {
|
||||
// Status messages are currently not displayed in the UI
|
||||
}
|
||||
|
||||
function setLoading(active: boolean) {
|
||||
@@ -375,9 +472,13 @@
|
||||
measureSource.clear();
|
||||
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
|
||||
measureSource.addFeature(new Feature(new Point(fromLonLat(measureEnd))));
|
||||
measureSource.addFeature(
|
||||
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)]))
|
||||
const lineFeature = new Feature(
|
||||
new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)])
|
||||
);
|
||||
lineFeature.set('distanceKm', measureDistanceKm);
|
||||
lineFeature.set('distanceMi', measureDistanceMiles);
|
||||
measureSource.addFeature(lineFeature);
|
||||
measureLayer.changed();
|
||||
}
|
||||
|
||||
function updateMeasurePreview(coordinate: number[]) {
|
||||
@@ -389,9 +490,13 @@
|
||||
measureSource.clear();
|
||||
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
|
||||
measureSource.addFeature(new Feature(new Point(fromLonLat(current))));
|
||||
measureSource.addFeature(
|
||||
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(current)]))
|
||||
const lineFeature = new Feature(
|
||||
new LineString([fromLonLat(measureStart), fromLonLat(current)])
|
||||
);
|
||||
lineFeature.set('distanceKm', measureDistanceKm);
|
||||
lineFeature.set('distanceMi', measureDistanceMiles);
|
||||
measureSource.addFeature(lineFeature);
|
||||
measureLayer.changed();
|
||||
}
|
||||
|
||||
function toggleMeasureMode() {
|
||||
@@ -452,7 +557,9 @@
|
||||
|
||||
function dismissBoxTooltip() {
|
||||
showBoxTooltip = false;
|
||||
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoxSelectMode() {
|
||||
@@ -571,6 +678,7 @@
|
||||
function updateSelectionBoxCenterPixel() {
|
||||
if (!map || !lastSelectionBounds) {
|
||||
selectionBoxCenterPixel = null;
|
||||
selectionBoxTopCenterPixel = null;
|
||||
return;
|
||||
}
|
||||
const centerLon = (lastSelectionBounds[0] + lastSelectionBounds[2]) / 2;
|
||||
@@ -578,6 +686,21 @@
|
||||
const centerCoord = fromLonLat([centerLon, centerLat]);
|
||||
const pixel = map.getPixelFromCoordinate(centerCoord);
|
||||
selectionBoxCenterPixel = [pixel[0], pixel[1]];
|
||||
|
||||
const topLat = lastSelectionBounds[3];
|
||||
const topCenterCoord = fromLonLat([centerLon, topLat]);
|
||||
const topPixel = map.getPixelFromCoordinate(topCenterCoord);
|
||||
selectionBoxTopCenterPixel = [topPixel[0], topPixel[1]];
|
||||
}
|
||||
|
||||
function updateSelectedCameraPixel() {
|
||||
if (!map || !selectedCamera) {
|
||||
selectedCameraPixel = null;
|
||||
return;
|
||||
}
|
||||
const coord = fromLonLat([selectedCamera.lon, selectedCamera.lat]);
|
||||
const pixel = map.getPixelFromCoordinate(coord);
|
||||
selectedCameraPixel = [pixel[0], pixel[1]];
|
||||
}
|
||||
|
||||
function finishBoxSelection(bounds: [number, number, number, number]) {
|
||||
@@ -680,9 +803,19 @@
|
||||
|
||||
function handleBasemapChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
basemap = target.value as 'dark' | 'light' | 'satellite';
|
||||
if (baseLayer && basemapSources) {
|
||||
baseLayer.setSource(basemapSources[basemap]);
|
||||
basemap = target.value as 'dark' | 'light' | 'satellite' | 'custom';
|
||||
basemapPreference.set(basemap);
|
||||
if (baseLayer) {
|
||||
if (basemap === 'custom' && $customTileUrl) {
|
||||
baseLayer.setSource(
|
||||
new XYZ({
|
||||
url: $customTileUrl,
|
||||
crossOrigin: 'anonymous',
|
||||
})
|
||||
);
|
||||
} else if (basemapSources && basemap !== 'custom') {
|
||||
baseLayer.setSource(basemapSources[basemap as keyof typeof basemapSources]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,6 +852,15 @@
|
||||
}
|
||||
|
||||
function handleLocationSearchKeydown(event: KeyboardEvent) {
|
||||
if (isMobile && event.key === 'Escape' && locationSearchModalOpen) {
|
||||
locationSearchModalOpen = false;
|
||||
locationSearchQuery = '';
|
||||
locationSearchResults = [];
|
||||
locationSearchOpen = false;
|
||||
locationSearchSelectedIndex = -1;
|
||||
locationSearchLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!locationSearchOpen || locationSearchResults.length === 0) {
|
||||
if (event.key === 'Escape') {
|
||||
locationSearchQuery = '';
|
||||
@@ -775,6 +917,9 @@
|
||||
locationSearchOpen = false;
|
||||
locationSearchSelectedIndex = -1;
|
||||
locationSearchLoading = false;
|
||||
if (isMobile) {
|
||||
locationSearchModalOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLocationSearchBlur() {
|
||||
@@ -811,8 +956,28 @@
|
||||
params.delete('bbox');
|
||||
params.delete('count');
|
||||
}
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
const newUrl = `?${params.toString()}`;
|
||||
replaceState(newUrl, {});
|
||||
}
|
||||
|
||||
function isValidLatitude(lat: number): boolean {
|
||||
return !Number.isNaN(lat) && lat >= -90 && lat <= 90;
|
||||
}
|
||||
|
||||
function isValidLongitude(lon: number): boolean {
|
||||
return !Number.isNaN(lon) && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
function isValidBounds(bounds: [number, number, number, number]): boolean {
|
||||
const [minLon, minLat, maxLon, maxLat] = bounds;
|
||||
return (
|
||||
isValidLongitude(minLon) &&
|
||||
isValidLatitude(minLat) &&
|
||||
isValidLongitude(maxLon) &&
|
||||
isValidLatitude(maxLat) &&
|
||||
minLon < maxLon &&
|
||||
minLat < maxLat
|
||||
);
|
||||
}
|
||||
|
||||
function restoreStateFromUrl() {
|
||||
@@ -826,7 +991,12 @@
|
||||
|
||||
if (centerParam) {
|
||||
const centerParts = centerParam.split(',').map(Number);
|
||||
if (centerParts.length === 2 && centerParts.every((v) => !Number.isNaN(v))) {
|
||||
if (
|
||||
centerParts.length === 2 &&
|
||||
centerParts.every((v) => !Number.isNaN(v)) &&
|
||||
isValidLongitude(centerParts[0]) &&
|
||||
isValidLatitude(centerParts[1])
|
||||
) {
|
||||
map.getView().setCenter(fromLonLat(centerParts));
|
||||
restored = true;
|
||||
}
|
||||
@@ -834,7 +1004,7 @@
|
||||
|
||||
if (zoomParam) {
|
||||
const zoomValue = parseFloat(zoomParam);
|
||||
if (!Number.isNaN(zoomValue)) {
|
||||
if (!Number.isNaN(zoomValue) && zoomValue >= 0 && zoomValue <= 30) {
|
||||
map.getView().setZoom(zoomValue);
|
||||
restored = true;
|
||||
}
|
||||
@@ -844,18 +1014,20 @@
|
||||
const parts = bboxParam.split(',').map(Number);
|
||||
if (parts.length === 4 && parts.every((v) => !Number.isNaN(v))) {
|
||||
bounds = [parts[0], parts[1], parts[2], parts[3]];
|
||||
lastSelectionBounds = bounds;
|
||||
renderSelection(bounds, selectionLayer);
|
||||
if (!centerParam) {
|
||||
const extent = transformExtent(
|
||||
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
||||
'EPSG:4326',
|
||||
'EPSG:3857'
|
||||
);
|
||||
map.getView().fit(extent, { duration: 0 });
|
||||
if (isValidBounds(bounds)) {
|
||||
lastSelectionBounds = bounds;
|
||||
renderSelection(bounds, selectionLayer);
|
||||
if (!centerParam) {
|
||||
const extent = transformExtent(
|
||||
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
||||
'EPSG:4326',
|
||||
'EPSG:3857'
|
||||
);
|
||||
map.getView().fit(extent, { duration: 0 });
|
||||
}
|
||||
handleFetchCameras(bounds);
|
||||
restored = true;
|
||||
}
|
||||
handleFetchCameras(bounds);
|
||||
restored = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,19 +1061,37 @@
|
||||
|
||||
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
|
||||
<header
|
||||
class="bg-bg-secondary border-b border-border-color px-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0"
|
||||
class="bg-bg-secondary border-b border-border-color px-3 sm:px-6 py-2 sm:py-3 flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center flex-shrink-0"
|
||||
>
|
||||
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
|
||||
<img src={favicon} alt="Surveilled logo" class="h-5 w-5" />
|
||||
Surveilled
|
||||
</h1>
|
||||
<div class="text-text-secondary text-xs sm:text-sm flex items-center gap-2">
|
||||
<span class="text-accent-red-light font-semibold">{cameras.length}</span>
|
||||
<span>cameras</span>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full sm:w-auto gap-1.5 sm:gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-base sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2"
|
||||
>
|
||||
<a
|
||||
href="https://git.quad4.io/Quad4-Software/Surveilled"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 sm:gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src={favicon} alt="Surveilled logo" class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Surveilled
|
||||
</a>
|
||||
</h1>
|
||||
{#if lastUpdated !== 'never'}
|
||||
<span class="text-xs">• {lastUpdated}</span>
|
||||
<div class="text-text-secondary text-[10px] sm:text-xs">
|
||||
{lastUpdated}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
class="w-full sm:w-64 bg-bg-primary border border-border-color rounded px-3 py-1.5 text-xs sm:text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
|
||||
type="text"
|
||||
placeholder="Filter cameras..."
|
||||
bind:value={filterQuery}
|
||||
on:input={handleFilterInput}
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="h-1 bg-accent-red transition-all duration-300 {fetchInFlight > 0 ? 'w-full' : 'w-0'}"
|
||||
@@ -920,6 +1110,16 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectionBoxTopCenterPixel && lastSelectionBounds && cameras.length > 0}
|
||||
<div
|
||||
class="absolute z-[1001] pointer-events-none"
|
||||
style="left: {selectionBoxTopCenterPixel[0]}px; top: {selectionBoxTopCenterPixel[1]}px; transform: translate(-50%, -100%);"
|
||||
>
|
||||
<div class="text-accent-red text-xs font-semibold whitespace-nowrap px-2 py-0.5">
|
||||
{cameras.length} cameras
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isMobile}
|
||||
<div
|
||||
class="absolute bottom-4 left-4 z-[1000] flex flex-col gap-1 text-[11px] text-text-secondary bg-bg-secondary/90 border border-border-color rounded px-2 py-1 shadow"
|
||||
@@ -939,10 +1139,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute top-4 sm:top-2 left-4 right-20 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1000] flex flex-col sm:flex-row gap-2 items-center"
|
||||
class="absolute top-3 sm:top-1.5 left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1100] flex flex-col sm:flex-row gap-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none"
|
||||
class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none hidden sm:block"
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
@@ -956,7 +1156,7 @@
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg pl-10 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
|
||||
class="w-full bg-bg-secondary/95 backdrop-blur-sm border border-border-color rounded-lg pl-10 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
|
||||
placeholder="Search location..."
|
||||
bind:value={locationSearchQuery}
|
||||
on:input={handleLocationSearchInput}
|
||||
@@ -990,8 +1190,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="toolbar bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
|
||||
class="toolbar bg-bg-secondary/95 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="sm:hidden toolbar-btn"
|
||||
title="Search location"
|
||||
on:click={() => (locationSearchModalOpen = true)}
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<div class="sm:hidden w-px h-4 bg-border-color mx-0.5"></div>
|
||||
<div class="relative">
|
||||
{#if showBoxTooltip}
|
||||
<div
|
||||
@@ -1026,6 +1234,176 @@
|
||||
>
|
||||
<Ruler size={16} />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border-color mx-0.5"></div>
|
||||
<div class="relative flex items-center">
|
||||
<button
|
||||
class="toolbar-btn {showEndpointSettings ? 'active' : ''}"
|
||||
title="Settings"
|
||||
on:click={() => (showEndpointSettings = !showEndpointSettings)}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
|
||||
{#if showEndpointSettings}
|
||||
<div
|
||||
class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-[1100] bg-bg-secondary border border-border-color rounded-lg p-0 shadow-xl w-80 text-text-primary overflow-hidden"
|
||||
>
|
||||
<div class="flex border-b border-border-color">
|
||||
<button
|
||||
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
|
||||
'overpass'
|
||||
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
|
||||
: 'text-text-secondary hover:text-text-primary'}"
|
||||
on:click={() => (settingsTab = 'overpass')}
|
||||
>
|
||||
Overpass
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
|
||||
'nominatim'
|
||||
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
|
||||
: 'text-text-secondary hover:text-text-primary'}"
|
||||
on:click={() => (settingsTab = 'nominatim')}
|
||||
>
|
||||
Nominatim
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
|
||||
'tiles'
|
||||
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
|
||||
: 'text-text-secondary hover:text-text-primary'}"
|
||||
on:click={() => (settingsTab = 'tiles')}
|
||||
>
|
||||
Tiles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
{#if settingsTab === 'overpass'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each OVERPASS_ENDPOINTS as endpoint}
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="overpass-endpoint-toolbar"
|
||||
value={endpoint}
|
||||
checked={$overpassEndpoint === endpoint}
|
||||
on:change={() => {
|
||||
overpassEndpoint.set(endpoint);
|
||||
customEndpoint = '';
|
||||
}}
|
||||
class="accent-accent-red"
|
||||
/>
|
||||
<span class="truncate text-[10px] opacity-90">{endpoint.split('/')[2]}</span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
<div class="mt-1 border-t border-border-color/50 pt-2 flex flex-col gap-1.5">
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="overpass-endpoint-toolbar"
|
||||
value="custom"
|
||||
checked={customEndpoint !== '' && $overpassEndpoint === customEndpoint}
|
||||
on:change={() => {
|
||||
if (customEndpoint) overpassEndpoint.set(customEndpoint);
|
||||
}}
|
||||
class="accent-accent-red"
|
||||
/>
|
||||
<span class="text-[10px] opacity-90 font-medium">Custom Endpoint</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://your-overpass/api/interpreter"
|
||||
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
|
||||
bind:value={customEndpoint}
|
||||
on:input={() => {
|
||||
if (customEndpoint.startsWith('http')) {
|
||||
overpassEndpoint.set(customEndpoint);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="mt-3 text-[9px] opacity-50 leading-relaxed border-t border-border-color/30 pt-2"
|
||||
>
|
||||
Preferred endpoint for camera data. Automatic fallback will occur if selected
|
||||
service is unavailable.
|
||||
</p>
|
||||
{:else if settingsTab === 'nominatim'}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[10px] font-medium text-text-secondary mb-1">
|
||||
Nominatim Search Endpoint
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://nominatim.openstreetmap.org/search"
|
||||
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
|
||||
bind:value={nominatimEndpointInput}
|
||||
on:input={() => {
|
||||
if (nominatimEndpointInput.startsWith('http')) {
|
||||
nominatimEndpoint.set(nominatimEndpointInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="bg-bg-primary/30 rounded p-2 border border-border-color/30">
|
||||
<div class="text-[9px] font-semibold text-accent-red-light mb-1 uppercase">
|
||||
Default: OSM Public
|
||||
</div>
|
||||
<code class="text-[9px] break-all opacity-70">
|
||||
https://nominatim.openstreetmap.org/search
|
||||
</code>
|
||||
</div>
|
||||
<p class="text-[9px] opacity-50 leading-relaxed mt-1">
|
||||
Used for geocoding location searches. Ensure the endpoint supports the
|
||||
standard Nominatim query parameters.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[10px] font-medium text-text-secondary mb-1">
|
||||
Tile URL (XYZ format)
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://tile.openstreetmap.org/{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 +1413,88 @@
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
<option value="custom">Custom Tiles</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if locationSearchModalOpen && isMobile}
|
||||
<div
|
||||
class="fixed inset-0 z-[1200] bg-black/50 backdrop-blur-sm flex items-start justify-center pt-4 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search location"
|
||||
tabindex="-1"
|
||||
on:click={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
locationSearchModalOpen = false;
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Escape') locationSearchModalOpen = false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-bg-secondary border border-border-color rounded-lg shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-border-color flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-text-primary">Search Location</h3>
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary transition-colors"
|
||||
on:click={() => (locationSearchModalOpen = false)}
|
||||
>
|
||||
Ă—
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="relative mb-4">
|
||||
<div
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none flex items-center"
|
||||
>
|
||||
{#if locationSearchLoading}
|
||||
<Loader2 class="text-text-secondary animate-spin" size={18} stroke-width={2} />
|
||||
{:else}
|
||||
<Search class="text-text-secondary" size={18} stroke-width={2} />
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-primary border border-border-color rounded-lg pl-10 pr-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-2 focus:ring-accent-red"
|
||||
placeholder="Search location..."
|
||||
bind:value={locationSearchQuery}
|
||||
on:input={handleLocationSearchInput}
|
||||
on:keydown={handleLocationSearchKeydown}
|
||||
bind:this={locationSearchInput}
|
||||
/>
|
||||
</div>
|
||||
{#if locationSearchResults.length > 0}
|
||||
<div class="max-h-[60vh] overflow-y-auto space-y-1">
|
||||
{#each locationSearchResults as result, index}
|
||||
<button
|
||||
class="w-full text-left px-4 py-3 rounded-lg hover:bg-bg-primary transition-colors border border-transparent hover:border-border-color {index ===
|
||||
locationSearchSelectedIndex
|
||||
? 'bg-bg-primary border-border-color'
|
||||
: ''}"
|
||||
on:click={() => selectLocation(result)}
|
||||
>
|
||||
<div class="text-sm text-text-primary font-medium">
|
||||
{result.display_name}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary mt-1">
|
||||
{result.class} · {result.type}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if locationSearchQuery && !locationSearchLoading}
|
||||
<div class="text-center py-8 text-text-secondary text-sm">No results found</div>
|
||||
{:else if !locationSearchQuery}
|
||||
<div class="text-center py-8 text-text-secondary text-sm">
|
||||
Start typing to search for a location
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showBoxTooltip && boxTooltipPosition}
|
||||
<div
|
||||
class="absolute z-[1300] pointer-events-none"
|
||||
@@ -1062,94 +1520,76 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="sm:hidden fixed bottom-4 left-4 z-[1100] bg-bg-secondary border border-border-color rounded-full px-3 py-2 text-xs font-semibold text-text-primary shadow-lg"
|
||||
on:click={() => (infoOpen = !infoOpen)}
|
||||
>
|
||||
{infoOpen ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
<div
|
||||
class="absolute z-[1000] bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg p-4 shadow-lg max-w-md w-[calc(100%-2rem)] left-1/2 -translate-x-1/2 bottom-4 sm:bottom-auto sm:top-4 sm:right-4 sm:left-auto sm:translate-x-0"
|
||||
class:hidden={!infoOpen && isMobile}
|
||||
>
|
||||
<div class="space-y-3 max-h-[70vh] sm:max-h-none overflow-y-auto">
|
||||
<h2 class="text-base font-semibold text-text-primary">Surveillance Camera Map</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Click on camera markers to view details. Use "Select Area" to choose a specific region to
|
||||
search.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
class="w-full bg-bg-primary border border-border-color rounded px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
|
||||
type="text"
|
||||
placeholder="Filter by notes, type, operator, or direction"
|
||||
bind:value={filterQuery}
|
||||
on:input={handleFilterInput}
|
||||
/>
|
||||
</div>
|
||||
{#if statusMessage}
|
||||
<div class="status-message {statusError ? 'error' : ''}">
|
||||
{statusMessage}
|
||||
{#if selectedCamera && selectedCameraPixel}
|
||||
<div
|
||||
class="absolute z-[1200] pointer-events-none"
|
||||
style="left: {selectedCameraPixel[0]}px; top: {selectedCameraPixel[1] +
|
||||
20}px; transform: translateX(-50%);"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-color rounded-lg shadow-xl text-text-primary max-w-[280px] sm:max-w-sm pointer-events-auto"
|
||||
>
|
||||
<div class="p-3 border-b border-border-color">
|
||||
<h3 class="text-sm font-semibold text-accent-red-light">Surveillance Camera</h3>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedCamera}
|
||||
<div class="camera-item">
|
||||
<h3 class="text-sm font-semibold text-accent-red-light mb-2">Surveillance Camera</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Type:</strong>
|
||||
{selectedCamera.type || 'Unknown'}
|
||||
<div class="text-text-secondary">{selectedCamera.type || 'Unknown'}</div>
|
||||
</div>
|
||||
{#if selectedCamera.operator}
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Operator:</strong>
|
||||
{selectedCamera.operator}
|
||||
<div class="text-text-secondary">{selectedCamera.operator}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Lat:</strong>
|
||||
{selectedCamera.lat.toFixed(5)}
|
||||
<div class="text-text-secondary">{selectedCamera.lat.toFixed(5)}</div>
|
||||
</div>
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Lon:</strong>
|
||||
{selectedCamera.lon.toFixed(5)}
|
||||
<div class="text-text-secondary">{selectedCamera.lon.toFixed(5)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedCamera.direction}
|
||||
<div class="mt-2 p-2 bg-bg-secondary border border-border-color rounded">
|
||||
<div class="text-sm text-text-primary font-medium">
|
||||
Direction: {directionLabel(selectedCamera.direction)}
|
||||
</div>
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
|
||||
<strong class="text-text-primary">Direction:</strong>
|
||||
<div class="text-text-secondary">{directionLabel(selectedCamera.direction)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedCamera.description}
|
||||
<div class="mt-2 bg-bg-primary border border-border-color rounded p-2 text-xs">
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
|
||||
<strong class="text-text-primary">Notes:</strong>
|
||||
{selectedCamera.description}
|
||||
<div class="text-text-secondary mt-1">{selectedCamera.description}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<footer
|
||||
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary relative"
|
||||
class="bg-bg-secondary border-border-color px-4 flex-shrink-0 text-xs text-text-secondary relative transition-all duration-300 {footerCollapsed
|
||||
? 'py-0 h-0 min-h-0 border-t-0'
|
||||
: 'py-3 border-t'}"
|
||||
>
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-bg-secondary border border-border-color rounded-full p-1.5 shadow-lg hover:bg-bg-primary transition-colors"
|
||||
on:click={() => (footerCollapsed = !footerCollapsed)}
|
||||
title={footerCollapsed ? 'Expand footer' : 'Collapse footer'}
|
||||
>
|
||||
{#if footerCollapsed}
|
||||
<ChevronUp class="text-text-secondary" size={16} />
|
||||
{:else}
|
||||
<ChevronDown class="text-text-secondary" size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed && isMobile}>
|
||||
<button
|
||||
class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-bg-secondary border border-border-color rounded-full p-1.5 shadow-lg hover:bg-bg-primary transition-colors z-[10]"
|
||||
on:click={() => {
|
||||
footerCollapsed = !footerCollapsed;
|
||||
footerCollapsedPref.set(footerCollapsed);
|
||||
}}
|
||||
title={footerCollapsed ? 'Expand footer' : 'Collapse footer'}
|
||||
>
|
||||
{#if footerCollapsed}
|
||||
<ChevronUp class="text-text-secondary" size={16} />
|
||||
{:else}
|
||||
<ChevronDown class="text-text-secondary" size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed}>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
||||
<div class="space-y-1">
|
||||
<p class="flex items-center gap-1">
|
||||
@@ -1187,26 +1627,3 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-message {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border-width: 1px;
|
||||
border-color: #262626;
|
||||
background-color: #0a0a0a;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
border-color: #dc2626;
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.camera-item > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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