From 3ba92120d1b78b225876a4db96ae7b4b49ed0008 Mon Sep 17 00:00:00 2001 From: Samuel Bancroft Date: Wed, 10 Jun 2026 15:03:48 +0100 Subject: [PATCH 1/2] initial commit --- README.md | 123 ++- deploy-webapp.sh | 37 + docker-compose.traccar.yml | 1 + docker-compose.webapp.yml | 18 + traccar.xml | 2 + web-app/DEPLOY.md | 161 ++++ web-app/PLAN.md | 774 ++++++++++++++++ web-app/config.example.js | 8 + web-app/config.js | 7 + web-app/css/app.css | 1042 ++++++++++++++++++++++ web-app/dev.sh | 13 + web-app/icons/apple-touch-icon.png | Bin 0 -> 495 bytes web-app/icons/icon-192.png | Bin 0 -> 546 bytes web-app/icons/icon-512.png | Bin 0 -> 1880 bytes web-app/index.html | 25 + web-app/manifest.json | 30 + web-app/nginx-local.conf | 53 ++ web-app/service-worker.js | 90 ++ web-app/src/api/geocode.js | 32 + web-app/src/api/hologram.js | 69 ++ web-app/src/api/traccar.js | 188 ++++ web-app/src/app.js | 32 + web-app/src/components/device-modal.js | 116 +++ web-app/src/components/drawer.js | 279 ++++++ web-app/src/components/geofence-modal.js | 127 +++ web-app/src/components/map.js | 240 +++++ web-app/src/screens/home.js | 345 +++++++ web-app/src/screens/login.js | 44 + web-app/src/store.js | 46 + web-app/src/utils.js | 5 + 30 files changed, 3904 insertions(+), 3 deletions(-) create mode 100644 deploy-webapp.sh create mode 100644 docker-compose.webapp.yml create mode 100644 web-app/DEPLOY.md create mode 100644 web-app/PLAN.md create mode 100644 web-app/config.example.js create mode 100644 web-app/config.js create mode 100644 web-app/css/app.css create mode 100644 web-app/dev.sh create mode 100644 web-app/icons/apple-touch-icon.png create mode 100644 web-app/icons/icon-192.png create mode 100644 web-app/icons/icon-512.png create mode 100644 web-app/index.html create mode 100644 web-app/manifest.json create mode 100644 web-app/nginx-local.conf create mode 100644 web-app/service-worker.js create mode 100644 web-app/src/api/geocode.js create mode 100644 web-app/src/api/hologram.js create mode 100644 web-app/src/api/traccar.js create mode 100644 web-app/src/app.js create mode 100644 web-app/src/components/device-modal.js create mode 100644 web-app/src/components/drawer.js create mode 100644 web-app/src/components/geofence-modal.js create mode 100644 web-app/src/components/map.js create mode 100644 web-app/src/screens/home.js create mode 100644 web-app/src/screens/login.js create mode 100644 web-app/src/store.js create mode 100644 web-app/src/utils.js diff --git a/README.md b/README.md index 2651f85..d253288 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,122 @@ -# cloud -Cloud Setup using Docker for FindMyCat +# FindMyCat Cloud +Docker-based cloud stack for [FindMyCat](https://www.findmycat.io) — runs the MQTT broker, GPS tracking server, reverse proxy, and web app on a single VPS. -Please checkout the latest docs on how to use this repository to setup FindMyCat cloud: https://www.findmycat.io/docs/CloudSetup +## Stack + +| Service | Compose file | Purpose | +|---|---|---| +| EMQX | `docker-compose.emqx.yml` | MQTT / MQTT-SN broker for device communication | +| Traccar | `docker-compose.traccar.yml` | GPS tracking server and REST + WebSocket API | +| Nginx Proxy Manager | `docker-compose.ngnix-proxy.yml` | TLS termination, Let's Encrypt certificates, reverse proxy | +| Web app | `docker-compose.webapp.yml` | Browser-based tracker UI (local dev only; production is served by Traccar) | + +All services share the `cloud_emqx-bridge` Docker network. + +--- + +## Prerequisites + +- A Linux VPS (tested on Ubuntu 22.04+) +- Docker and Docker Compose installed +- A domain name pointing at the server +- An EMQX Enterprise licence (`tmp/emqx.lic`) +- A Hologram account with an API key and organisation ID (for device commands) + +--- + +## Deployment + +### 1. Clone and configure + +```bash +git clone https://github.com/FindMyCat/cloud +cd cloud +``` + +Place your EMQX licence at `tmp/emqx.lic`. + +### 2. Start Nginx Proxy Manager + +```bash +docker compose -f docker-compose.ngnix-proxy.yml up -d +``` + +Open `http://your-server:81` and create a Proxy Host for your domain pointing at `traccar:8082`. Enable **Websockets Support** — required for real-time GPS updates. + +### 3. Start EMQX + +```bash +docker compose -f docker-compose.emqx.yml up -d +``` + +Dashboard: `http://your-server:18083` (default credentials: `admin` / `public` — change immediately). + +Ports used by devices: +- `1883` — MQTT TCP +- `1885/udp` — MQTT-SN (UDP, used by FindMyCat trackers) + +### 4. Start Traccar + +```bash +docker compose -f docker-compose.traccar.yml up -d +``` + +Traccar reads `traccar.xml` from the repo root. The default config uses an embedded H2 database stored in `/var/docker/traccar/data`. Key settings already in place: + +- `server.sessionTimeout` = 604800 (7-day session, keeps the web app and iOS app logged in) + +Traccar's web UI is reachable at `https://your-domain.com` once NPM is configured. + +### 5. Deploy the web app + +See [web-app/DEPLOY.md](web-app/DEPLOY.md) for the full guide. Summary: + +```bash +./deploy-webapp.sh user@your-server +``` + +This rsyncs the web app files to `/var/docker/traccar/web/cat/` — Traccar serves them at `https://your-domain.com/cat/`. Create `config.js` on the server from the example and fill in your Hologram credentials. + +You also need a `/hologram/` custom location in NPM to proxy the Hologram API for Sound / Lost-mode — details in [web-app/DEPLOY.md](web-app/DEPLOY.md). + +--- + +## Configuration files + +| File | Purpose | Committed | +|---|---|---| +| `traccar.xml` | Traccar server config | Yes | +| `emqx_conf/emqx_sn.conf` | MQTT-SN plugin config | Yes | +| `tmp/emqx.lic` | EMQX Enterprise licence | **No** — add manually | +| `web-app/config.js` | Web app runtime config (API keys, tile URL) | **No** — create from `config.example.js` | + +--- + +## Updating + +Each service can be updated independently: + +```bash +# Traccar +docker compose -f docker-compose.traccar.yml pull && docker compose -f docker-compose.traccar.yml up -d + +# EMQX (pinned to 4.4.16 — do not upgrade without checking MQTT-SN compatibility) +docker compose -f docker-compose.emqx.yml up -d + +# Web app (no container restart needed — Traccar serves files from disk) +./deploy-webapp.sh user@your-server +``` + +--- + +## Local development + +```bash +cp web-app/config.example.js web-app/config.js # fill in Hologram values +docker compose -f docker-compose.webapp.yml up -d +``` + +Open `http://localhost:8080/cat/`. This uses an nginx container that proxies `/api/` to the `traccar` container and `/hologram/` to `dashboard.hologram.io`. Requires the Traccar stack to be running locally. + +For UI-only work (no login needed): `./web-app/dev.sh` diff --git a/deploy-webapp.sh b/deploy-webapp.sh new file mode 100644 index 0000000..03e0b1a --- /dev/null +++ b/deploy-webapp.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Deploy the FindMyCat web app to the server. +# Usage: ./deploy-webapp.sh user@your-server + +set -euo pipefail + +SERVER="${1:?Usage: $0 user@your-server}" +REMOTE_DIR="/var/docker/traccar/web/cat" +LOCAL_DIR="$(dirname "$0")/web-app" + +echo "==> Creating remote directory..." +ssh "$SERVER" "mkdir -p $REMOTE_DIR" + +echo "==> Syncing web app files..." +rsync -av --delete \ + --exclude='config.js' \ + --exclude='*.md' \ + --exclude='nginx-local.conf' \ + --exclude='dev.sh' \ + "$LOCAL_DIR/" "$SERVER:$REMOTE_DIR/" + +echo "" +echo "==> Done. Checking config.js on server..." +if ssh "$SERVER" "test -f $REMOTE_DIR/config.js"; then + echo " config.js exists — no changes made." +else + echo " config.js MISSING. Creating from template..." + scp "$LOCAL_DIR/config.example.js" "$SERVER:$REMOTE_DIR/config.js" + echo "" + echo " *** ACTION REQUIRED ***" + echo " Edit config.js on the server and fill in your Hologram credentials:" + echo " ssh $SERVER" + echo " nano $REMOTE_DIR/config.js" +fi + +echo "" +echo "==> Web app live at: https:///cat/" diff --git a/docker-compose.traccar.yml b/docker-compose.traccar.yml index 3501fcf..34d6a48 100755 --- a/docker-compose.traccar.yml +++ b/docker-compose.traccar.yml @@ -14,6 +14,7 @@ services: - "/opt/traccar/logs:/opt/traccar/logs" - "./traccar.xml:/opt/traccar/conf/traccar.xml:ro" - "/var/docker/traccar/data:/opt/traccar/data:rw" + - "/var/docker/traccar/web/cat:/opt/traccar/web/cat:ro" networks: emqx-bridge: aliases: diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml new file mode 100644 index 0000000..3624e13 --- /dev/null +++ b/docker-compose.webapp.yml @@ -0,0 +1,18 @@ +version: '3.8' +services: + webapp: + image: nginx:alpine + container_name: webapp + restart: unless-stopped + ports: + - "8080:80" + volumes: + - ./web-app:/usr/share/nginx/html:ro + - ./web-app/nginx-local.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - emqx-bridge + +networks: + emqx-bridge: + external: true + name: cloud_emqx-bridge diff --git a/traccar.xml b/traccar.xml index aab9ba3..a41a356 100755 --- a/traccar.xml +++ b/traccar.xml @@ -23,4 +23,6 @@ sa + 604800 + diff --git a/web-app/DEPLOY.md b/web-app/DEPLOY.md new file mode 100644 index 0000000..a2522c9 --- /dev/null +++ b/web-app/DEPLOY.md @@ -0,0 +1,161 @@ +# FindMyCat Web App — Deployment Guide + +## Where does the web app live? + +**Bundled inside the cloud repo** (`github.com/FindMyCat/cloud`) as a `web-app/` subdirectory. + +Why: the deployment step *is* a cloud repo operation — one volume line in `docker-compose.traccar.yml` and a copy command on the server. Keeping both in the same repo means one clone, one place to make changes, and a single deploy script. There's no build step, so the source files are the artifact. + +If you ever intend to open-source just the web app (not the infra config), extract it to its own repo at that point. + +--- + +## Two ways to run it + +| Mode | What serves the app | Use for | +|---|---|---| +| **Production** | Traccar's Jetty (volume mount) behind Nginx Proxy Manager | The real deployment | +| **Local** | `docker-compose.webapp.yml` (nginx:alpine container) | Testing against a locally running Traccar stack | + +`dev.sh` also exists for pure UI work (static file server, no `/api` or `/hologram` proxy — login and commands will not work). + +--- + +# Production deployment + +## Prerequisites + +- The cloud stack is already running (Traccar reachable at `https://your-domain.com`) +- SSH access to the server +- NPM admin UI accessible at `http://your-server:81` +- Traccar is behind NPM with HTTPS and a Let's Encrypt cert + +## Step 1 — Volume mount (already in the repo) + +`docker-compose.traccar.yml` already mounts the host directory into Traccar's Jetty web root, so the app is served at `/cat/` by the same server that handles `/api/` — same-origin, no CORS config needed: + +```yaml +- "/var/docker/traccar/web/cat:/opt/traccar/web/cat:ro" +``` + +If your running stack predates this line, apply it: `docker compose -f docker-compose.traccar.yml up -d` + +## Step 2 — Deploy the files + +From the repo root: + +```bash +./deploy-webapp.sh user@your-server +``` + +This rsyncs `web-app/` to `/var/docker/traccar/web/cat/` (excluding `config.js`, docs, and local-dev files) and creates `config.js` from the template if it's missing. + +## Step 3 — Configure config.js on the server + +`config.js` is gitignored and lives only on the server. It is the only file that differs between environments. + +```javascript +window.FINDMYCAT_CONFIG = { + traccarHost: "", // leave BLANK — served same-origin, no host needed + hologramApiKey: "", // leave blank if NPM injects the auth header (recommended, see Step 5) + hologramOrgId: 12345, // your Hologram organisation ID + hologramPort: 12345, // UDP port the tracker listens on for cloud messages + tileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + tileAttribution: "© OpenStreetMap contributors", +}; +``` + +`traccarHost` must be blank (empty string) when the web app is served from the same domain as Traccar. All `/api/` calls become relative — same-origin, cookie sent automatically. + +## Step 4 — Enable WebSocket support in NPM + +The real-time position stream uses WebSocket (`wss://`). NPM must forward the `Upgrade` header or the connection silently fails (devices load once but never update). + +1. Open NPM admin: `http://your-server:81` +2. Find the Proxy Host for your Traccar domain +3. Edit → **Websockets Support: ON** +4. Save + +## Step 5 — Hologram proxy (REQUIRED for Sound / Lost mode) + +The web app calls Hologram via relative `/hologram/` paths (the browser cannot call `dashboard.hologram.io` directly — CORS). Without this proxy the Sound and Lost-mode buttons return 404. + +On the Traccar proxy host in NPM, add custom Nginx config in the **Advanced** tab: + +```nginx +location /hologram/ { + rewrite ^/hologram/(.*)$ /api/1/$1 break; + proxy_pass https://dashboard.hologram.io; + proxy_ssl_server_name on; + proxy_set_header Host dashboard.hologram.io; + # Recommended: inject the key server-side so it never ships to browsers. + # base64 of "apikey:YOUR_HOLOGRAM_API_KEY": + proxy_set_header Authorization "Basic YOUR_BASE64_VALUE_HERE"; +} +``` + +With the `Authorization` line present, leave `hologramApiKey` blank in `config.js` — the key stays off the client entirely. Without it, the browser sends the header itself using `hologramApiKey` from `config.js` (works, but anyone who can reach `/cat/config.js` can read the key). + +## Step 6 — Verify + +Open `https://your-domain.com/cat/` in a browser. + +- Login screen appears; after login the map loads and devices appear as emoji markers +- Positions update in real time (DevTools → Network → WS tab shows an open `/api/socket` connection) +- Sound / Lost-mode buttons return success toasts (DevTools → Network: `/hologram/devices/...` returns 200) +- On mobile: "Add to Home Screen" installs the PWA + +## Updating the web app + +```bash +./deploy-webapp.sh user@your-server +``` + +No container restart needed — Jetty serves static files directly from disk. The service worker cache name (`findmycat-vN` in `service-worker.js`) is bumped on app changes so clients pick up new assets. + +--- + +# Local deployment (docker compose) + +Runs the app in an `nginx:alpine` container that serves `/cat/` and proxies `/api/` + `/api/socket` to the `traccar` container and `/hologram/` to Hologram (config in `web-app/nginx-local.conf`). + +```bash +cp web-app/config.example.js web-app/config.js # then fill in Hologram values +docker compose -f docker-compose.webapp.yml up -d +``` + +Open `http://localhost:8080/cat/`. + +Assumptions: + +- The Traccar stack is running and was started **from this repo directory** (the compose file joins the external network `cloud_emqx-bridge`; Docker Compose prefixes network names with the project name, which defaults to the directory name — clone the repo as anything other than `cloud` and you must adjust the `name:` under `networks:` in `docker-compose.webapp.yml`). +- Service workers require HTTPS **or localhost** — the PWA/offline features work at `http://localhost:8080` but not over plain HTTP from another machine. +- For Hologram commands, either set `hologramApiKey` in `config.js` (browser sends the auth header through the proxy) or hardcode the `Authorization` header in `nginx-local.conf` as the comment there shows. + +--- + +# Operational notes + +## Traccar session timeout + +The repo's `traccar.xml` already sets a 7-day session (`server.sessionTimeout` = 604800) so an emergency login from a phone browser isn't lost when the tab closes. Note this applies to **all** Traccar clients, including the iOS app. + +## Tile source + +The map uses the public OSM tile CDN by default (`tileUrl` in `config.js`). For offline/rural use, point `tileUrl` at a self-hosted tile server. + +--- + +# Feature status vs the native iOS app + +Implemented and at parity: login with session restore, live map with emoji markers, real-time WebSocket updates, device drawer with battery % and relative last-seen (refreshed every 20 s like iOS), ping/lost mode via Hologram, logout (web-only — iOS has none), offline banner, loading/error states, installable PWA. + +| Not implemented | Notes | +|---|---| +| Add / edit / delete device | iOS has full CRUD with an emoji picker. Web calls would be `POST/PUT/DELETE /api/devices`. | +| Reverse-geocoded address per device | iOS uses Apple CLGeocoder client-side. Web equivalent would need Nominatim or Traccar's `address` field. | +| BLE pairing / UWB Precise Find | Not possible in browsers (Web Bluetooth is Chromium-desktop only; no NearbyInteraction). | +| Push notifications | Requires a VAPID push server. | +| Satellite map toggle | Add a second raster source + toggle. | + +The app fully covers the primary use case: **open URL → log in → see your cat's location on a map in real time, and trigger lost mode**. diff --git a/web-app/PLAN.md b/web-app/PLAN.md new file mode 100644 index 0000000..cdd831d --- /dev/null +++ b/web-app/PLAN.md @@ -0,0 +1,774 @@ +# FindMyCat — Web App Port Plan + +**Purpose:** A browser-based Progressive Web App (PWA) that surfaces GPS tracking from the same Traccar backend, accessible from any device with a browser and internet connection. Primary use case: your phone is gone, you borrow any device and open a URL. + +**Scope:** GPS map view + real-time updates + lost mode. No BLE, no UWB — those require OS-level native APIs that browsers cannot provide. + +--- + +## 1. Why a Web App Fits This Use Case + +| Property | Native iOS | Native Android | Web App | +|---|---|---|---| +| Requires installation | App Store | Play Store | None — open URL | +| Works on borrowed device | No | No | Yes | +| Works on desktop/laptop | No | No | Yes | +| GPS tracking | ✓ | ✓ | ✓ (via Traccar) | +| Real-time updates | ✓ | ✓ | ✓ (WebSocket) | +| BLE Precise Find | ✓ | ✓ (RSSI) | ✗ | +| Installable to home screen | N/A | N/A | ✓ (PWA) | +| Single codebase for iOS+Android | No | No | Yes | +| Works offline (cached) | Partial | Partial | Partial | + +The web app is not a replacement for the native apps — it is the **emergency fallback** and a **zero-install companion**. For the primary use case (find your cat from a borrowed laptop or unfamiliar Android phone), it is strictly superior to native apps. + +--- + +## 2. Tech Stack + +### Recommendation: Vanilla ES Modules + MapLibre GL JS + OpenStreetMap tiles + +No build tooling. No framework. No API keys required for the map. Zero dependencies to install. Served as static files directly from your Traccar server. + +**Why:** +- Opening a URL on a borrowed device should not require anything other than a modern browser. +- Static HTML/CSS/JS hosted on the same server as Traccar means same-origin requests — zero CORS configuration needed. +- MapLibre GL JS is the open-source MIT-licensed fork of Mapbox GL JS. Identical API, no token required for raster tile maps. +- OpenStreetMap raster tiles are free for personal/low-traffic use and require no account or API key. +- ES module `import` is supported in every browser released since 2018. +- There is no compilation step that could break in an unfamiliar environment. + +**Dependencies (all via CDN ` + +``` + +### API base URL handling + +```javascript +// src/api/traccar.js +const base = () => { + const host = window.FINDMYCAT_CONFIG?.traccarHost; + return host ? `https://${host}/api` : '/api'; // relative = same-origin +}; +``` + +When `traccarHost` is blank, all requests go to `/api/...` — same server, same origin, no CORS headers needed anywhere. + +### Security model + +| Secret | Native apps | Web app (self-hosted) | +|---|---|---| +| Traccar host | BuildConfig / plist | Implicit (same server) | +| Hologram API key | BuildConfig / plist | `config.js` (not committed) | +| JSESSIONID | OS keychain/DataStore | HttpOnly cookie, same-origin | +| Map API key | Mapbox token in BuildConfig | None (OSM tiles, no key) | + +Self-hosting on the Traccar server reduces the secret surface to just the Hologram API key. The JSESSIONID cookie is HttpOnly when set by Traccar, so JavaScript cannot read it — it is sent automatically by the browser on same-origin requests. This is more secure than the native apps, where the cookie value is read and stored in DataStore/Keychain. + +Anyone with access to the web server filesystem can read `config.js`. For a personal tracker on a private server this is acceptable. If you want stricter isolation, move Hologram calls to a small server-side script (PHP, Python one-liner, nginx `proxy_pass`) so the Hologram key never leaves the server. + +--- + +## 7. CORS and Deployment Options + +The browser's same-origin policy blocks `fetch()` to a different domain unless the server sends CORS headers. Self-hosting solves this entirely. + +--- + +### Option A — Serve from the Traccar server (recommended, fully self-hosted) + +Traccar is a Java web server (Jetty). It serves its own web UI from a configurable directory and exposes `/api/` on the same port. Placing the web app files in a subdirectory makes all API calls same-origin. + +**Setup:** + +1. Find Traccar's web root. Default location after a standard Linux install: + ``` + /opt/traccar/web/ + ``` + Or check `traccar.xml` for `web.path`. + +2. Create a subdirectory for the web app: + ```bash + mkdir /opt/traccar/web/cat + ``` + +3. Copy the web app files there: + ```bash + cp -r web-app/* /opt/traccar/web/cat/ + ``` + +4. Edit `config.js`: leave `traccarHost` blank (all `/api/` calls are relative). + +5. Access at: `https://your-traccar-domain.com/cat/` + +**Result:** Zero CORS headers needed. The JSESSIONID cookie is same-origin so it's sent automatically. The Traccar server restarts are not needed — the web directory is served as static files. + +**Nginx alternative (if Traccar sits behind nginx):** + +If your setup has nginx proxying to Traccar, add a location block that serves the web app files and lets `/api/` pass through to Traccar: + +```nginx +server { + server_name traccar.example.com; + + # Serve the web app at /cat/ + location /cat/ { + alias /opt/traccar/web/cat/; + try_files $uri $uri/ /cat/index.html; + } + + # Proxy API + WebSocket to Traccar + location /api/ { + proxy_pass http://localhost:8082; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +--- + +### Option B — Separate server, configure Traccar CORS + +If the web app must live on a different server (e.g. GitHub Pages, a CDN), configure Traccar to allow that origin. + +In `traccar.xml`: +```xml +https://your-web-app-domain.com +``` + +Traccar sends `Access-Control-Allow-Origin` and `Access-Control-Allow-Credentials: true` on all `/api/` responses. Use `credentials: 'include'` in all `fetch()` calls to send the cookie cross-origin. Set `traccarHost` in `config.js`. + +--- + +### Option C — Cloudflare Worker proxy (no server access needed) + +A 20-line Cloudflare Worker proxies Traccar requests and appends CORS headers. Free tier handles thousands of requests/day. Useful if you cannot modify the Traccar server config. + +--- + +### Self-hosted tile server (full offline capability) + +The default config uses OpenStreetMap's public tile CDN — this still requires internet access. For a fully offline-capable installation on a local network: + +1. Download a region's `.mbtiles` file from [OpenMapTiles](https://openmaptiles.org/downloads/) or [BBBike](https://extract.bbbike.org/). +2. Serve it with [tileserver-gl](https://github.com/maptiler/tileserver-gl) on the same machine as Traccar: + ```bash + docker run -p 8080:8080 -v /path/to/tiles:/data maptiler/tileserver-gl + ``` +3. Set `tileUrl` in `config.js` to `http://localhost:8080/styles/basic-preview/{z}/{x}/{y}.png`. + +The web app, map tiles, and GPS data all serve from your own hardware. The only external call is to Hologram's API for lost mode commands. + +--- + +## 8. PWA Capabilities + +### manifest.json + +```json +{ + "name": "FindMyCat", + "short_name": "FindMyCat", + "start_url": "/", + "display": "standalone", + "background_color": "#1A1A1A", + "theme_color": "#1A1A1A", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} +``` + +With this, Chrome and Safari will offer "Add to Home Screen" on mobile. The installed PWA launches full-screen with no browser chrome — indistinguishable from a native app to the user. + +### service-worker.js (cache strategy) + +``` +Cache-first for: index.html, app CSS/JS, Mapbox GL JS, icons +Network-first for: all /api/ requests (real data) +Offline fallback: show cached map tiles + "Last known positions" banner +``` + +On a borrowed device: the service worker won't be pre-installed, so the first load requires network. Subsequent offline access works after the first visit. + +### Geolocation API + +Standard `navigator.geolocation.watchPosition()` shows the user's location as a blue dot on the map. Works in all browsers, prompts for permission, useful for "how far am I from my cat." + +--- + +## 9. Screen Designs + +### Login screen +``` +┌─────────────────────────┐ +│ │ +│ 🐱 │ +│ │ +│ Welcome, please │ +│ log in. │ +│ │ +│ ┌─────────────────┐ │ +│ │ Email │ │ +│ └─────────────────┘ │ +│ ┌─────────────────┐ │ +│ │ Password │ │ +│ └─────────────────┘ │ +│ │ +│ [ Log In ] │ +│ │ +└─────────────────────────┘ +``` +On load: silently check existing session (`GET /session`). If 200, skip to home. If not, show form. This is the web equivalent of the session-check-on-launch pattern in iOS/Android. + +### Home screen (portrait mobile) +``` +┌─────────────────────────┐ +│ 🗺 Mapbox map │ +│ (fills screen) │ +│ │ +│ 📍 Whiskers │ +│ │ +│ │ +├─────────────────────────┤ ← drag handle +│ Devices │ +├─────────────────────────┤ +│ 🐱 Whiskers │ +│ 3m ago · 87% │ [📍] [🔊] +├─────────────────────────┤ +│ 🐶 Biscuit │ +│ 12m ago · 62% │ [📍] [🔊] +└─────────────────────────┘ +``` + +The bottom panel is a CSS-animated sheet, draggable up to reveal the full device list. Same pattern as iOS FittedSheets and Android BottomSheetScaffold. + +`[📍]` centres the map on that device. `[🔊]` sends the sound command via Hologram. + +### No Precise Find screen + +When the user taps a Precise Find equivalent on web, show a modal: +``` +┌──────────────────────────────┐ +│ Precise Find not available │ +│ │ +│ Bluetooth-based ranging │ +│ requires the native app. │ +│ │ +│ The map shows Whiskers' │ +│ last GPS position: │ +│ 3 minutes ago │ +│ │ +│ [ Open iOS App ] │ +│ [ Open Android App ] │ +│ [ Close ] │ +└──────────────────────────────┘ +``` + +--- + +## 10. File Structure + +``` +web-app/ +├── PLAN.md ← this file +├── index.html ← app shell (single page) +├── manifest.json ← PWA manifest +├── service-worker.js ← offline cache +├── config.example.js ← template (committed) +├── config.js ← actual config (gitignored) +├── .gitignore +│ +├── src/ +│ ├── app.js ← entry: routing, init, startup session check +│ ├── store.js ← minimal observable state +│ ├── api/ +│ │ ├── traccar.js ← REST + WebSocket client +│ │ └── hologram.js ← cloud message API +│ ├── screens/ +│ │ ├── login.js ← login form + session check +│ │ └── home.js ← map + drawer orchestration +│ └── components/ +│ ├── map.js ← Mapbox GL JS map wrapper +│ └── drawer.js ← device list bottom sheet +│ +├── css/ +│ └── app.css ← dark theme, sheet animations +│ +└── icons/ + ├── icon-192.png ← PWA icon (generate from iOS asset) + └── icon-512.png ← PWA icon large +``` + +Total estimated file count: ~15 files. Total JS lines: ~600–800. No package.json. No node_modules. + +--- + +## 11. Implementation Phases + +### Phase 1 — Core GPS viewer (everything the emergency use case needs) + +1. `index.html` — app shell with CDN imports for Mapbox GL JS +2. `src/api/traccar.js` — `getSession`, `login`, `fetchDevices`, `fetchPositions`, `openSocket` +3. `src/store.js` — 15-line observable state +4. `src/screens/login.js` — login form, session check on load, redirect to home +5. `src/screens/home.js` — orchestrates map + drawer, WebSocket merge logic +6. `src/components/map.js` — Mapbox map, emoji markers at device positions, user location dot +7. `src/components/drawer.js` — device list with battery%, last seen, centre-on-map button +8. `manifest.json` + `service-worker.js` — installable PWA, offline fallback +9. `css/app.css` — dark theme (#1A1A1A background), bottom sheet animation +10. `config.example.js` + `.gitignore` + +**Deliverable**: Open URL → log in → see your cat on a map in real time. Works on any device. + +### Phase 2 — Commands and device management + +11. `src/api/hologram.js` — send cloud message (sound, lost mode) +12. Add Sound and Lost Mode buttons to drawer +13. Add/Edit/Delete device screens (simple modal forms calling Traccar REST) +14. Share location link (Web Share API / clipboard) + +### Phase 3 — Polish + +15. PWA push notifications (requires a notification server — evaluate separately) +16. Reverse geocoding for last known address (Mapbox Geocoding API) +17. "No precise find" deep-link modal pointing to native apps +18. Configurable map style (satellite vs. streets) +19. Multi-language support + +--- + +## 12. Self-Hosting Dependency Map + +What runs where in the recommended self-hosted setup: + +``` +Your server (e.g. VPS or home server) +├── Traccar (Java) ← GPS backend, device registry, REST + WS +│ └── /opt/traccar/web/cat/ ← web app static files served here +│ ├── index.html +│ ├── src/ +│ └── config.js ← gitignored, contains Hologram key only +│ +└── tileserver-gl (optional Docker) ← local map tiles (fully offline) + +External (unavoidable) +└── dashboard.hologram.io ← SIM cellular service + cloud messages + (only called for Lost Mode / Sound — GPS tracking works without it) +``` + +In this setup: +- **GPS tracking**: 100% self-hosted. Traccar receives positions from the SIM tracker via cellular, your server stores them, the web app reads them. +- **Maps**: self-hosted (tileserver-gl) or free OSM CDN. No Mapbox. +- **Lost Mode / Sound**: calls Hologram's cloud API. This is the tracker's SIM provider — unavoidable without replacing the hardware. +- **Auth session**: lives in a same-origin HttpOnly cookie. No external identity provider. + +--- + +## 13. Cloud Repo Integration (`github.com/FindMyCat/cloud`) + +The existing cloud setup is Docker Compose with three services: Traccar, EMQX, and Nginx Proxy Manager (NPM). No changes are needed to EMQX. Two modifications are needed, plus one optional addition. + +### What the cloud repo currently has + +| Component | Image | Role | Exposed ports | +|---|---|---|---| +| Traccar | `traccar/traccar:latest` | GPS backend + REST API | 8082 (web), 5000–5150 (device protocols) | +| EMQX | `emqx/emqx` | MQTT broker (for tracker telemetry) | 1883, 1885, 18083 | +| Nginx Proxy Manager | `jc21/nginx-proxy-manager:latest` | TLS termination + reverse proxy | 80, 443, 81 (admin) | + +Traccar's data persists at `/var/docker/traccar/data`. Its web UI (and our web app) would live at `/opt/traccar/web/` inside the container. + +No CORS headers are configured anywhere. No Hologram proxy exists. + +--- + +### Modification 1 — Serve the web app from Traccar's web root (no new container needed) + +Traccar's Jetty web server already serves static files from `/opt/traccar/web/`. Mounting a subdirectory there makes the web app same-origin as the API — zero CORS config. + +**Add one volume line to `docker-compose.traccar.yml`:** + +```yaml +services: + traccar: + image: traccar/traccar:latest + restart: unless-stopped + volumes: + - /var/docker/traccar/data:/opt/traccar/data + - /var/docker/traccar/web/cat:/opt/traccar/web/cat # ← add this line + ports: + - "8082:8082" + - "5000-5150:5000-5150" + - "5000-5150:5000-5150/udp" +``` + +Then deploy: +```bash +mkdir -p /var/docker/traccar/web/cat +cp -r /path/to/web-app/* /var/docker/traccar/web/cat/ +# Edit config.js: leave traccarHost blank (same-origin) +docker compose -f docker-compose.traccar.yml up -d +``` + +The web app is now accessible at `https://your-traccar-domain.com/cat/`. All `/api/` calls are same-origin — no CORS headers needed anywhere. + +--- + +### Modification 2 — Configure NPM to proxy the Traccar domain + +This is done via the NPM admin UI at `http://your-server:81` (not code, since NPM stores routes in its own database volume). + +In the NPM admin UI, create a **Proxy Host**: + +| Field | Value | +|---|---| +| Domain Name | `traccar.yourdomain.com` (or whatever domain you use) | +| Forward Hostname/IP | `traccar` (Docker service name, resolves on the compose network) | +| Forward Port | `8082` | +| Websockets Support | **ON** (required for the `/api/socket` WebSocket connection) | +| SSL Certificate | Request a new Let's Encrypt cert → enable Force HTTPS | + +WebSocket support must be on — the real-time update stream uses `wss://` and NPM must forward the `Upgrade` header. + +--- + +### Modification 3 (optional) — Hologram proxy via NPM custom location + +To avoid exposing the Hologram API key in `config.js`, add a custom location in the same NPM proxy host that forwards `/hologram/` to Hologram's API server-side. + +In the NPM admin UI, on the Traccar proxy host, add a **Custom Location**: + +| Field | Value | +|---|---| +| Location | `/hologram/` | +| Forward Hostname/IP | `dashboard.hologram.io` | +| Forward Port | `443` | + +Then add custom Nginx config in the "Advanced" tab: +```nginx +location /hologram/ { + rewrite ^/hologram/(.*)$ /api/1/$1 break; + proxy_pass https://dashboard.hologram.io; + proxy_ssl_server_name on; + proxy_set_header Authorization "Basic YOUR_BASE64_ENCODED_APIKEY_HERE"; + proxy_set_header Host dashboard.hologram.io; +} +``` + +This moves the Hologram API key to the server side — `config.js` no longer needs `hologramApiKey`. The web app calls `/hologram/devices/` instead of `https://dashboard.hologram.io/api/1/devices/`, and the server adds the auth header. Entirely optional for Phase 2. + +--- + +### Summary of cloud repo changes + +| Change | Required? | Where | +|---|---|---| +| Add volume mount for `/opt/traccar/web/cat` in Traccar compose | Yes | `docker-compose.traccar.yml` | +| Configure NPM proxy host for Traccar domain with WS enabled | Yes | NPM admin UI (port 81) | +| Optional Hologram proxy via NPM custom location | No (Phase 2) | NPM admin UI "Advanced" tab | +| CORS configuration in `traccar.xml` | No (same-origin handles it) | — | +| New Docker service for web app | No (Traccar serves it) | — | + +--- + +## 14. Open Questions Before Implementation + +1. **Hologram API browser CORS**: The plan assumes `dashboard.hologram.io` sends `Access-Control-Allow-Origin: *` on its REST API. Verify before implementing Phase 2: + ```bash + curl -sI "https://dashboard.hologram.io/api/1/devices/" | grep -i access-control + ``` + If Hologram blocks browser cross-origin calls, use the NPM custom location approach from Modification 3 in section 13 to proxy through the Traccar server instead. + +2. **Traccar session lifetime**: Traccar's default session timeout is browser-session (expires on tab close). For the emergency use case a longer lifetime is better. Configure in `traccar.xml`: + ```xml + 604800 + ``` + +3. **Tile source for offline use**: If the tracker is used in areas with poor internet (mountains, rural areas), consider downloading a regional `.mbtiles` file and running tileserver-gl. The OSM public CDN requires internet. + +4. **HTTPS requirement**: Service workers (required for PWA install + offline) only work on HTTPS or localhost. The Traccar server should be behind a TLS terminator. Caddy makes this zero-config: + ``` + traccar.example.com { + reverse_proxy localhost:8082 + } + ``` + Caddy auto-provisions a Let's Encrypt certificate. diff --git a/web-app/config.example.js b/web-app/config.example.js new file mode 100644 index 0000000..a4a03a7 --- /dev/null +++ b/web-app/config.example.js @@ -0,0 +1,8 @@ +window.FINDMYCAT_CONFIG = { + traccarHost: "", // blank = same-origin (recommended; app is served by the same host as Traccar) + hologramApiKey: "", // leave blank if the /hologram/ proxy injects the Authorization header server-side + hologramOrgId: 0, + hologramPort: 12345, + tileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + tileAttribution: "© OpenStreetMap contributors", +}; diff --git a/web-app/config.js b/web-app/config.js new file mode 100644 index 0000000..bb9f852 --- /dev/null +++ b/web-app/config.js @@ -0,0 +1,7 @@ +window.FINDMYCAT_CONFIG = { + traccarHost: "", + hologramApiKey: "", + hologramOrgId: 0, + tileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + tileAttribution: "© OpenStreetMap contributors", +}; diff --git a/web-app/css/app.css b/web-app/css/app.css new file mode 100644 index 0000000..e22f3d2 --- /dev/null +++ b/web-app/css/app.css @@ -0,0 +1,1042 @@ +/* FindMyCat PWA — Dark Theme Stylesheet */ + +/* Reset */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Base */ +html, +body { + height: 100%; + overflow: hidden; + background: #111118; + color: #F0F0F5; + font-family: system-ui, -apple-system, sans-serif; +} + +/* ─── Screens ─────────────────────────────────────────────────── */ + +.screen { + position: fixed; + inset: 0; + display: none; +} + +.screen.is-active { + display: flex; +} + +/* Login screen */ +.screen-login { + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(160deg, #111118, #1A1A2E); +} + +.login-wrap { + padding: 24px; + max-width: 400px; + width: 100%; +} + +.hero-emoji { + font-size: 72px; + text-align: center; + display: block; + margin-bottom: 16px; +} + +.screen-login h1 { + font-size: 24px; + font-weight: 700; + text-align: center; + margin-bottom: 8px; +} + +.screen-login p { + text-align: center; + color: #888899; + margin-bottom: 32px; +} + +/* Form fields */ +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.field label { + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888899; +} + +.field input { + width: 100%; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + color: #F0F0F5; + font-size: 16px; +} + +.field input:focus { + border-color: #6C63FF; + box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.2); + outline: none; +} + +/* Primary button */ +.btn-primary { + width: 100%; + padding: 14px; + background: #6C63FF; + color: white; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +/* Login error */ +.login-error { + display: none; + padding: 12px 16px; + background: rgba(231, 76, 60, 0.15); + border: 1px solid rgba(231, 76, 60, 0.3); + border-radius: 8px; + color: #E74C3C; + font-size: 14px; + margin-top: 12px; +} + +.login-error.is-visible { + display: block; +} + +/* Spinner */ +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ─── Home screen / Map ───────────────────────────────────────── */ + +.screen-home { + display: block; +} + +#map { + position: absolute; + inset: 0; + z-index: 0; +} + +/* ─── Drawer ──────────────────────────────────────────────────── */ + +.drawer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 75vh; + z-index: 10; + background: rgba(18, 18, 26, 0.97); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-radius: 20px 20px 0 0; + transform: translateY(calc(75vh - 130px)); + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; +} + +.drawer.is-expanded { + transform: translateY(0); +} + +.drawer-handle { + width: 40px; + height: 4px; + background: rgba(255, 255, 255, 0.25); + border-radius: 2px; + margin: 12px auto 8px; + cursor: pointer; + flex-shrink: 0; +} + +.drawer-handle:hover { + background: rgba(255, 255, 255, 0.45); +} + +.drawer-header { + padding: 4px 16px 12px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + flex-shrink: 0; + user-select: none; +} + +.drawer-header h2 { + font-size: 17px; + font-weight: 700; +} + +.drawer-count { + font-size: 13px; + color: #888899; + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: 10px; +} + +/* ─── Device list ─────────────────────────────────────────────── */ + +.device-list { + overflow-y: auto; + flex: 1; + padding-bottom: 32px; +} + +.device-list::-webkit-scrollbar { + width: 4px; +} + +.device-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} + +.device-item { + display: flex; + align-items: center; + padding: 12px 16px; + gap: 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.device-item:hover { + background: rgba(255, 255, 255, 0.04); +} + +.device-emoji { + width: 44px; + height: 44px; + font-size: 22px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border-radius: 12px; + flex-shrink: 0; +} + +.device-info { + flex: 1; + min-width: 0; +} + +.device-name { + font-size: 15px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.device-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 3px; +} + +.badge-battery, +.badge-time, +.meta-dot { + font-size: 12px; + color: #888899; +} + +.device-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Icon buttons */ +.btn-icon { + width: 34px; + height: 34px; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + color: #F0F0F5; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.14); +} + +.btn-icon.btn-lost { + background: rgba(231, 76, 60, 0.2); + color: #E74C3C; +} + +.btn-icon.btn-lost.is-active { + background: rgba(231, 76, 60, 0.5); +} + +/* ─── Empty state ─────────────────────────────────────────────── */ + +.empty-state { + padding: 48px 24px; + text-align: center; + color: #888899; +} + +.empty-icon { + font-size: 48px; + display: block; + margin-bottom: 16px; +} + +/* ─── Offline bar ─────────────────────────────────────────────── */ + +.offline-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #E74C3C; + color: white; + text-align: center; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + transform: translateY(-100%); + transition: transform 0.2s ease; +} + +.offline-bar.is-visible { + transform: translateY(0); +} + +/* ─── Loading / error overlay ────────────────────────────────── */ + +.loading-overlay { + position: absolute; + inset: 0; + z-index: 50; + background: #111118; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} + +.spinner-lg { + width: 36px; + height: 36px; + border-width: 3px; +} + +.loading-overlay.is-error { + gap: 12px; +} + +.error-icon { + font-size: 48px; +} + +.error-message { + color: #888899; + font-size: 15px; + text-align: center; + max-width: 280px; + line-height: 1.5; +} + +.btn-retry { + width: auto; + padding: 12px 32px; + margin-top: 4px; +} + +/* ─── Toast notifications ─────────────────────────────────────── */ + +.toast { + position: fixed; + bottom: 148px; + left: 50%; + transform: translateX(-50%) translateY(12px); + background: rgba(30, 30, 42, 0.97); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: #F0F0F5; + padding: 10px 16px; + border-radius: 10px; + font-size: 14px; + opacity: 0; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 200; + max-width: calc(100% - 32px); + text-align: center; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toast.is-visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.toast.toast-error { + border-left: 3px solid #E74C3C; +} + +/* ─── Drawer header actions ───────────────────────────────────── */ + +.drawer-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-logout { + background: none; + border: 1px solid rgba(255, 255, 255, 0.12); + color: #888899; + font-size: 12px; + font-weight: 500; + cursor: pointer; + padding: 4px 10px; + border-radius: 6px; + transition: all 0.15s; +} + +.btn-logout:hover { + background: rgba(255, 255, 255, 0.08); + color: #F0F0F5; +} + +/* ─── Map markers ─────────────────────────────────────────────── */ + +.user-location-dot { + width: 16px; + height: 16px; + background: #4A90E2; + border: 3px solid white; + border-radius: 50%; + box-shadow: 0 0 0 6px rgba(74, 144, 226, 0.25); +} + +.map-marker { + cursor: pointer; + user-select: none; +} + +/* ─── Responsive ──────────────────────────────────────────────── */ + +/* ─── Drawer tabs ─────────────────────────────────────────────── */ + +.drawer-tabs { + display: flex; + padding: 0 16px; + gap: 4px; + flex-shrink: 0; + cursor: pointer; + user-select: none; +} + +.drawer-tab { + flex: 1; + padding: 8px 0; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: #888899; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.03em; + cursor: pointer; + transition: all 0.15s; +} + +.drawer-tab.is-active { + color: #F0F0F5; + border-bottom-color: #6C63FF; +} + +.drawer-panel { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.drawer-panel[hidden] { + display: none; +} + +.panel-header { + padding: 10px 16px 8px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + cursor: pointer; + user-select: none; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-icon-sm { + width: 26px; + height: 26px; + border: none; + border-radius: 6px; + background: rgba(108, 99, 255, 0.2); + color: #A89FFF; + font-size: 18px; + font-weight: 300; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: all 0.15s; +} + +.btn-icon-sm:hover { + background: rgba(108, 99, 255, 0.4); +} + +/* ─── Device card enhancements ────────────────────────────────── */ + +.device-emoji-wrap { + position: relative; + flex-shrink: 0; +} + +.status-dot { + position: absolute; + bottom: 1px; + right: 1px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid #12121a; +} + +.status-dot.status-online { background: #2ECC71; } +.status-dot.status-offline { background: #888899; } + +.device-address { + font-size: 11px; + color: #888899; + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Compact icon buttons to fit 5 in a row */ +.btn-icon { + width: 30px; + height: 30px; + font-size: 14px; +} + +.device-actions { + gap: 2px; +} + +/* ─── Zone list ───────────────────────────────────────────────── */ + +.zone-list { + overflow-y: auto; + flex: 1; + padding-bottom: 32px; +} + +.zone-list::-webkit-scrollbar { width: 4px; } +.zone-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; } + +.zone-item { + display: flex; + align-items: center; + padding: 12px 16px; + gap: 12px; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.zone-item:hover { background: rgba(255,255,255,0.04); } + +.zone-icon { + width: 44px; + height: 44px; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(108, 99, 255, 0.15); + border: 1.5px solid rgba(108, 99, 255, 0.4); + border-radius: 50%; + flex-shrink: 0; +} + +.zone-info { + flex: 1; + min-width: 0; +} + +.zone-name { + font-size: 15px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.zone-meta { + font-size: 12px; + color: #888899; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.empty-sub { + font-size: 13px; + color: #666677; + margin-top: 4px; +} + +/* ─── Modal ───────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 400; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: flex-end; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.modal-overlay.is-visible { opacity: 1; } + +.modal { + background: #1A1A2E; + border-radius: 20px 20px 0 0; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + transform: translateY(40px); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.modal-overlay.is-visible .modal { transform: translateY(0); } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 0; + flex-shrink: 0; +} + +.modal-header h2 { font-size: 18px; font-weight: 700; } + +.modal-close { + background: rgba(255,255,255,0.1); + border: none; + color: #888899; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.modal-close:hover { background: rgba(255,255,255,0.2); color: #F0F0F5; } + +.modal-body { + padding: 16px 20px; + flex: 1; +} + +.modal-footer { + padding: 12px 20px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; +} + +.modal-footer-left { flex: 1; } +.modal-footer-right { display: flex; gap: 8px; } + +.modal-save-btn { width: auto; padding: 12px 24px; margin: 0; } + +.btn-secondary { + padding: 12px 20px; + background: rgba(255,255,255,0.08); + color: #F0F0F5; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.btn-secondary:hover { background: rgba(255,255,255,0.14); } + +.btn-danger { + padding: 12px 16px; + background: rgba(231,76,60,0.15); + color: #E74C3C; + border: 1px solid rgba(231,76,60,0.3); + border-radius: 10px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.btn-danger:hover { background: rgba(231,76,60,0.3); } +.btn-danger:disabled { opacity: 0.5; cursor: default; } + +.modal-error { + color: #E74C3C; + font-size: 13px; + padding: 0 20px 12px; + min-height: 18px; +} + +.modal-hint { + font-size: 13px; + color: #888899; + padding: 8px 12px; + background: rgba(255,255,255,0.05); + border-radius: 8px; + line-height: 1.4; +} + +.modal-hint.modal-hint-warn { + color: #F39C12; + background: rgba(243,156,18,0.1); + border: 1px solid rgba(243,156,18,0.25); +} + +.mt-16 { margin-top: 16px; } + +.label-hint { + font-size: 11px; + color: #555566; + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +.field input[readonly] { opacity: 0.5; cursor: default; } + +/* ─── Emoji picker ────────────────────────────────────────────── */ + +.emoji-preview-wrap { + display: flex; + justify-content: center; + margin-bottom: 12px; +} + +.emoji-preview { + width: 64px; + height: 64px; + font-size: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.07); + border-radius: 16px; + border: 2px solid rgba(255,255,255,0.1); +} + +.emoji-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 4px; + margin-bottom: 4px; +} + +.emoji-btn { + width: 100%; + aspect-ratio: 1; + background: rgba(255,255,255,0.05); + border: 1.5px solid transparent; + border-radius: 8px; + font-size: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; +} + +.emoji-btn:hover { background: rgba(255,255,255,0.12); } + +.emoji-btn.is-selected { + background: rgba(108,99,255,0.25); + border-color: #6C63FF; +} + +/* ─── Device assignment checklist ─────────────────────────────── */ + +.check-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.check-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(255,255,255,0.04); + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: background 0.1s; +} + +.check-row:hover { background: rgba(255,255,255,0.08); } + +.check-row input[type=checkbox] { + width: 16px; + height: 16px; + accent-color: #6C63FF; + flex-shrink: 0; +} + +.empty-hint { + font-size: 13px; + color: #555566; + padding: 8px 0; +} + +/* ─── History bar ─────────────────────────────────────────────── */ + +.history-bar { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + background: rgba(18, 18, 26, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 14px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + max-width: calc(100vw - 24px); +} + +.history-device-name { + font-size: 13px; + font-weight: 600; + color: #F0F0F5; + flex-shrink: 0; +} + +.history-presets { + display: flex; + gap: 4px; +} + +.preset { + padding: 4px 10px; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 20px; + color: #888899; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.preset:hover { background: rgba(255,255,255,0.15); } + +.preset.is-active { + background: rgba(255, 107, 53, 0.25); + border-color: rgba(255, 107, 53, 0.5); + color: #FF6B35; +} + +.history-close { + background: none; + border: 1px solid rgba(255,255,255,0.12); + color: #888899; + font-size: 12px; + font-weight: 500; + padding: 4px 10px; + border-radius: 20px; + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.history-close:hover { background: rgba(255,255,255,0.08); color: #F0F0F5; } + +/* ─── Placement banner ────────────────────────────────────────── */ + +.placement-banner { + position: absolute; + bottom: 148px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + background: rgba(108, 99, 255, 0.92); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 12px; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + font-weight: 500; + color: white; + box-shadow: 0 4px 20px rgba(108,99,255,0.4); + white-space: nowrap; +} + +.placement-cancel { + background: rgba(255,255,255,0.2); + border: none; + color: white; + padding: 4px 10px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} +.placement-cancel:hover { background: rgba(255,255,255,0.3); } + +/* ─── Map controls ────────────────────────────────────────────── */ + +.map-style-btn { + position: absolute; + bottom: 32px; + right: 12px; + z-index: 5; + width: 40px; + height: 40px; + background: rgba(18, 18, 26, 0.9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + font-size: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + transition: all 0.15s; +} +.map-style-btn:hover { background: rgba(30, 30, 45, 0.95); } + +/* ─── Toast — info variant ────────────────────────────────────── */ + +.toast.toast-info { + border-left: 3px solid #6C63FF; +} + +@media (min-width: 640px) { + .drawer { + max-width: 480px; + left: 50%; + right: auto; + width: 480px; + transform: translateX(-50%) translateY(calc(75vh - 130px)); + } + + .drawer.is-expanded { + transform: translateX(-50%) translateY(0); + } + + .login-wrap { + margin: 0 auto; + } +} diff --git a/web-app/dev.sh b/web-app/dev.sh new file mode 100644 index 0000000..0602847 --- /dev/null +++ b/web-app/dev.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Serve the web-app at /cat/ to match production path. +# Usage: ./dev.sh [port] (default port: 8080) + +PORT=${1:-8080} +TMPDIR=$(mktemp -d) +ln -s "$(cd "$(dirname "$0")" && pwd)" "$TMPDIR/cat" + +echo "Dev server: http://localhost:$PORT/cat/" +echo "Serving via $TMPDIR (Ctrl-C to stop)" + +cd "$TMPDIR" && python3 -m http.server "$PORT" +rm -rf "$TMPDIR" diff --git a/web-app/icons/apple-touch-icon.png b/web-app/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4ae773f0340309d459df55238789b01136f30e9b GIT binary patch literal 495 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofpMFsi(^Q|oVS+@1sN0s4jAbC zX{%orPS6-@u4u@O1Ta JS?83{1OP1szNP>G literal 0 HcmV?d00001 diff --git a/web-app/icons/icon-192.png b/web-app/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..dcf2ac817cb5a3c72511363d92a823ed0e9c26d2 GIT binary patch literal 546 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^oMi(^Q|oVS-Yaxy3g9B>H# zaqh2rDSL27K>_oU6SHm>#$YxVA6zFX9;BiS(5MrJv(Q(8 z^uqj~2m4}o9Z83MXWlU@WSrw>ut;QJ2xMarNFAj{gJ3ijjAn$ + + + + + + + + + + + FindMyCat + + + + + + + + + +
+ + + diff --git a/web-app/manifest.json b/web-app/manifest.json new file mode 100644 index 0000000..dd346e8 --- /dev/null +++ b/web-app/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "FindMyCat", + "short_name": "FindMyCat", + "description": "Track your cat's GPS location from any browser", + "start_url": "/cat/", + "scope": "/cat/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#111118", + "theme_color": "#111118", + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/web-app/nginx-local.conf b/web-app/nginx-local.conf new file mode 100644 index 0000000..e730c06 --- /dev/null +++ b/web-app/nginx-local.conf @@ -0,0 +1,53 @@ +# Map needed for WebSocket upgrade passthrough +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + + # Resolve upstreams at request time via Docker's embedded DNS so nginx + # starts even when the traccar container is down (it would otherwise + # crash-loop on "host not found in upstream"). + resolver 127.0.0.11 valid=10s ipv6=off; + set $traccar_upstream http://traccar:8082; + set $hologram_upstream https://dashboard.hologram.io; + + # Web app — served at /cat/ to match production path and manifest scope + location /cat/ { + alias /usr/share/nginx/html/; + index index.html; + } + + # Traccar WebSocket — must be before the /api/ block + location /api/socket { + proxy_pass $traccar_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Traccar REST API + location /api/ { + proxy_pass $traccar_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Hologram API — required for the Sound / Lost-mode buttons. + # The browser's Authorization header (built from config.js hologramApiKey) + # is forwarded as-is. To keep the key off the client entirely, set it here + # instead: proxy_set_header Authorization "Basic "; + location /hologram/ { + rewrite ^/hologram/(.*)$ /api/1/$1 break; + proxy_pass $hologram_upstream; + proxy_ssl_server_name on; + proxy_set_header Host dashboard.hologram.io; + } + +} diff --git a/web-app/service-worker.js b/web-app/service-worker.js new file mode 100644 index 0000000..8eb82c2 --- /dev/null +++ b/web-app/service-worker.js @@ -0,0 +1,90 @@ +const CACHE = "findmycat-v4"; + +const STATIC_ASSETS = [ + ".", + "index.html", + "css/app.css", + "src/app.js", + "src/store.js", + "src/utils.js", + "src/api/traccar.js", + "src/api/hologram.js", + "src/api/geocode.js", + "src/screens/login.js", + "src/screens/home.js", + "src/components/map.js", + "src/components/drawer.js", + "src/components/device-modal.js", + "src/components/geofence-modal.js", + "manifest.json", + "config.js", +]; + +// Install: cache all static assets and activate immediately +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate: remove old caches and take control of all clients +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +// Fetch: network-first for API/cross-origin, cache-first for everything else +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + const isApi = url.pathname.startsWith("/api/"); + const isCrossOrigin = url.hostname !== self.location.hostname; + + // Cache API only supports GET — put() on other methods throws + const cacheable = request.method === "GET"; + + if (isApi || isCrossOrigin) { + // Network first, fall back to cache + event.respondWith( + fetch(request) + .then((response) => { + if (cacheable) { + const clone = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match(request)) + ); + } else { + // Cache first, then network (cache successful network response) + event.respondWith( + caches.match(request).then((cached) => { + if (cached) { + return cached; + } + return fetch(request).then((response) => { + if (response.ok && cacheable) { + const clone = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }); + }) + ); + } +}); diff --git a/web-app/src/api/geocode.js b/web-app/src/api/geocode.js new file mode 100644 index 0000000..e9be33a --- /dev/null +++ b/web-app/src/api/geocode.js @@ -0,0 +1,32 @@ +// Nominatim reverse geocoding — in-memory cache, 1 req/s rate limit +const cache = new Map() +let lastRequest = 0 + +function round(v) { return Math.round(v * 1000) / 1000 } + +export async function reverseGeocode(lat, lng) { + const key = round(lat) + ',' + round(lng) + if (cache.has(key)) return cache.get(key) + + const wait = Math.max(0, 1100 - (Date.now() - lastRequest)) + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + lastRequest = Date.now() + + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=14`, + { headers: { 'Accept-Language': 'en' } } + ) + if (!res.ok) { cache.set(key, null); return null } + const d = await res.json() + const a = d.address || {} + const city = a.city || a.town || a.village || a.hamlet || a.county || '' + const country = a.country_code?.toUpperCase() || '' + const label = city ? (country ? city + ', ' + country : city) : null + cache.set(key, label) + return label + } catch { + cache.set(key, null) + return null + } +} diff --git a/web-app/src/api/hologram.js b/web-app/src/api/hologram.js new file mode 100644 index 0000000..96f1a66 --- /dev/null +++ b/web-app/src/api/hologram.js @@ -0,0 +1,69 @@ +// Hologram cloud commands +// Config: window.FINDMYCAT_CONFIG = { hologramApiKey, hologramOrgId, hologramPort, ... } + +import { fetchTimeout } from "../utils.js"; + +function authHeaders() { + const { hologramApiKey } = window.FINDMYCAT_CONFIG ?? {}; + // Key may be blank when the /hologram/ proxy injects the Authorization header server-side + return hologramApiKey + ? { Authorization: "Basic " + btoa("apikey:" + hologramApiKey) } + : {}; +} + +/** + * Send a command to a device via Hologram. + * + * @param {Device} device — must have a .uniqueId property; Hologram device names + * are the Traccar uniqueId (same convention as the iOS app) + * @param {string} command — raw command string to send + */ +export async function sendCommand(device, command) { + const { hologramOrgId, hologramPort } = window.FINDMYCAT_CONFIG; + const port = String(hologramPort ?? 12345); + + // 1. Look up the Hologram device id by name (= Traccar uniqueId) + // Proxied through /hologram/ to avoid browser CORS block on dashboard.hologram.io + const lookupUrl = + "/hologram/devices/?name=" + + encodeURIComponent(device.uniqueId) + + "&orgid=" + + hologramOrgId; + + const lookupRes = await fetchTimeout(lookupUrl, { + headers: authHeaders(), + }, 15000); + + if (!lookupRes.ok) { + throw new Error("Hologram device lookup failed: " + lookupRes.status + " " + lookupRes.statusText); + } + + const lookupJson = await lookupRes.json(); + + if (!lookupJson.data || lookupJson.data.length === 0) { + throw new Error("Hologram device not found for uniqueId: " + device.uniqueId); + } + + const deviceHologramId = lookupJson.data[0].id; + + // 2. Send the command + const sendRes = await fetchTimeout("/hologram/devices/messages", { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + deviceids: [deviceHologramId], + data: command, + port, + protocol: "UDP", + }), + }, 15000); + + if (!sendRes.ok) { + throw new Error("Hologram send command failed: " + sendRes.status + " " + sendRes.statusText); + } + + return sendRes.json(); +} diff --git a/web-app/src/api/traccar.js b/web-app/src/api/traccar.js new file mode 100644 index 0000000..a92f234 --- /dev/null +++ b/web-app/src/api/traccar.js @@ -0,0 +1,188 @@ +// Traccar REST + WebSocket client +// Config: window.FINDMYCAT_CONFIG = { traccarHost, ... } + +import { fetchTimeout } from "../utils.js"; + +const host = window.FINDMYCAT_CONFIG?.traccarHost; +const API = host ? "https://" + host + "/api" : "/api"; + +// --- REST --- + +export async function getSession() { + const res = await fetchTimeout(API + "/session", { credentials: "include" }); + if (!res.ok) throw new Error("No active session"); + return res.json(); +} + +export async function login(email, pw) { + const res = await fetchTimeout(API + "/session", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ email, password: pw }), + }); + if (!res.ok) throw new Error("Invalid credentials"); + return res.json(); +} + +export async function logout() { + await fetchTimeout(API + "/session", { method: "DELETE", credentials: "include" }); +} + +export async function fetchDevices() { + const res = await fetchTimeout(API + "/devices", { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch devices"); + return res.json(); +} + +export async function fetchPositions() { + const res = await fetchTimeout(API + "/positions", { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch positions"); + return res.json(); +} + +// --- Device CRUD --- + +export async function createDevice(device) { + const res = await fetchTimeout(API + "/devices", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(device), + }) + if (!res.ok) throw new Error("Failed to create device") + return res.json() +} + +export async function updateDevice(id, device) { + const res = await fetchTimeout(API + "/devices/" + id, { + method: "PUT", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(device), + }) + if (!res.ok) throw new Error("Failed to update device") + return res.json() +} + +export async function deleteDevice(id) { + const res = await fetchTimeout(API + "/devices/" + id, { method: "DELETE", credentials: "include" }) + if (!res.ok) throw new Error("Failed to delete device") +} + +// --- Geofences --- + +export async function fetchGeofences() { + const res = await fetchTimeout(API + "/geofences", { credentials: "include" }) + if (!res.ok) throw new Error("Failed to fetch geofences") + return res.json() +} + +export async function fetchGeofencesByDevice(deviceId) { + const res = await fetchTimeout(API + "/geofences?deviceId=" + deviceId, { credentials: "include" }) + if (!res.ok) throw new Error("Failed to fetch device geofences") + return res.json() +} + +export async function createGeofence(geofence) { + const res = await fetchTimeout(API + "/geofences", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(geofence), + }) + if (!res.ok) throw new Error("Failed to create geofence") + return res.json() +} + +export async function updateGeofence(id, geofence) { + const res = await fetchTimeout(API + "/geofences/" + id, { + method: "PUT", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(geofence), + }) + if (!res.ok) throw new Error("Failed to update geofence") + return res.json() +} + +export async function deleteGeofence(id) { + const res = await fetchTimeout(API + "/geofences/" + id, { method: "DELETE", credentials: "include" }) + if (!res.ok) throw new Error("Failed to delete geofence") +} + +export async function addPermission(permission) { + const res = await fetchTimeout(API + "/permissions", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(permission), + }) + if (!res.ok) throw new Error("Failed to add permission") +} + +export async function removePermission(permission) { + const res = await fetchTimeout(API + "/permissions", { + method: "DELETE", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(permission), + }) + if (!res.ok) throw new Error("Failed to remove permission") +} + +// --- Position history --- + +export async function fetchHistory(deviceId, from, to) { + const params = new URLSearchParams({ deviceId: String(deviceId), from: from.toISOString(), to: to.toISOString() }) + const res = await fetchTimeout(API + "/positions?" + params, { credentials: "include" }, 30000) + if (!res.ok) throw new Error("Failed to fetch history") + return res.json() +} + +// --- WebSocket --- + +let unloading = false; +window.addEventListener("beforeunload", () => { unloading = true; }); + +/** + * Open a WebSocket connection to the Traccar event stream. + * Reconnects automatically after 3 s on close/error until close() is called. + * + * @param {(data: object) => void} onMessage — called with the parsed JSON payload + * @returns {{ close: () => void }} + */ +export function openSocket(onMessage) { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const hostPart = window.FINDMYCAT_CONFIG?.traccarHost || location.host; + const url = protocol + "//" + hostPart + "/api/socket"; + + let ws = null; + let timer = null; + let closed = false; + + function connect() { + ws = new WebSocket(url); + + ws.onmessage = (event) => { + try { + onMessage(JSON.parse(event.data)); + } catch { + // ignore malformed frames + } + }; + + const reconnect = () => { + if (!closed && !unloading) { + timer = setTimeout(connect, 3000); + } + }; + + ws.onclose = reconnect; + ws.onerror = reconnect; + } + + connect(); + + return { + close() { + closed = true; + clearTimeout(timer); + ws?.close(); + }, + }; +} diff --git a/web-app/src/app.js b/web-app/src/app.js new file mode 100644 index 0000000..d131b31 --- /dev/null +++ b/web-app/src/app.js @@ -0,0 +1,32 @@ +import { render as renderLogin } from "./screens/login.js" +import { render as renderHome, destroy as destroyHome } from "./screens/home.js" +import { getSession } from "./api/traccar.js" + +function route(hash) { + const app = document.getElementById("app") + destroyHome() + if (hash === "#home") { + renderHome(app) + } else { + renderLogin(app, () => { location.hash = "#home" }) + } +} + +document.addEventListener("DOMContentLoaded", async () => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("service-worker.js").catch((e) => console.warn("Service worker registration failed:", e)) + } + + window.addEventListener("hashchange", e => route(new URL(e.newURL).hash)) + + try { + await getSession() + // If hash already matches, hashchange won't fire — call route() directly. + // If hash differs, set it and let hashchange call route() — don't call both. + if (location.hash === "#home") route("#home") + else location.hash = "#home" + } catch { + if (location.hash === "#login") route("#login") + else location.hash = "#login" + } +}) diff --git a/web-app/src/components/device-modal.js b/web-app/src/components/device-modal.js new file mode 100644 index 0000000..5953413 --- /dev/null +++ b/web-app/src/components/device-modal.js @@ -0,0 +1,116 @@ +// Add / Edit device modal with emoji picker + +const EMOJIS = [ + '🐱','🐈','🐈‍⬛','😸','😺','🐾','🐶','🐕','🦊','🐻','🐼','🐨','🦁', + '🐯','🐺','🐗','🦝','🦔','🐭','🐹','🐰','🦇','🦉','🦅','🐦','🦜', + '🌟','⭐','💫','✨','❤️','🔥','🌈','🎯','📍','🏠','🌲','🌊','🎪','🎭', +] + +function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +export function openDeviceModal({ device = null, onSave, onDelete, onClose }) { + const isEdit = !!device + let emoji = device?.attributes?.emoji || '🐱' + + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + + document.body.appendChild(overlay) + requestAnimationFrame(() => overlay.classList.add('is-visible')) + + const nameInput = overlay.querySelector('#mname') + const uidInput = overlay.querySelector('#muid') + const preview = overlay.querySelector('#epv') + const errEl = overlay.querySelector('#merr') + + function close() { + overlay.classList.remove('is-visible') + setTimeout(() => { overlay.remove(); onClose?.() }, 200) + } + + overlay.querySelector('#mc').addEventListener('click', close) + overlay.querySelector('#mcan').addEventListener('click', close) + overlay.addEventListener('click', e => { if (e.target === overlay) close() }) + + overlay.querySelector('#egrid').addEventListener('click', e => { + const btn = e.target.closest('.emoji-btn') + if (!btn) return + emoji = btn.dataset.e + preview.textContent = emoji + overlay.querySelectorAll('.emoji-btn').forEach(b => b.classList.toggle('is-selected', b.dataset.e === emoji)) + }) + + overlay.querySelector('#msave').addEventListener('click', async () => { + const name = nameInput.value.trim() + const uniqueId = uidInput.value.trim() + errEl.textContent = '' + if (!name) { nameInput.focus(); return } + if (!uniqueId) { uidInput.focus(); return } + + const btn = overlay.querySelector('#msave') + btn.disabled = true; btn.textContent = 'Saving…' + try { + await onSave({ + ...(isEdit ? { id: device.id } : {}), + name, + uniqueId, + attributes: { ...(device?.attributes || {}), emoji }, + }) + close() + } catch (err) { + btn.disabled = false; btn.textContent = 'Save' + errEl.textContent = err?.message || 'Save failed' + } + }) + + overlay.querySelector('#mdel')?.addEventListener('click', async () => { + if (!confirm(`Remove "${device.name}" from your account?\n\nThis cannot be undone.`)) return + const btn = overlay.querySelector('#mdel') + btn.disabled = true; btn.textContent = 'Removing…' + try { + await onDelete() + close() + } catch { + btn.disabled = false; btn.textContent = 'Remove' + errEl.textContent = 'Failed to remove device' + } + }) + + setTimeout(() => nameInput.focus(), 250) +} diff --git a/web-app/src/components/drawer.js b/web-app/src/components/drawer.js new file mode 100644 index 0000000..ce079be --- /dev/null +++ b/web-app/src/components/drawer.js @@ -0,0 +1,279 @@ +import { reverseGeocode } from '../api/geocode.js' + +// ── Module state ────────────────────────────────────────────────────────────── + +let lastDevices = [], lastPositions = [], lastGeofences = [], lastGeofenceDevices = {}, lastCallbacks = {} +let lostModeSet = new Set() +let refreshTimer = null +// geocode cache: Map +const addressCache = new Map() +// geocode in-flight tracking: Map +const geocodedKey = new Map() + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function relTime(iso) { + if (!iso) return 'Never' + const ms = new Date(iso).getTime() + if (isNaN(ms)) return 'Never' + const elapsed = (Date.now() - ms) / 1000 + if (elapsed < 60) return 'Just now' + if (elapsed < 3600) return Math.floor(elapsed / 60) + 'm ago' + if (elapsed < 86400) return Math.floor(elapsed / 3600) + 'h ago' + return Math.floor(elapsed / 86400) + 'd ago' +} + +function round(v) { return Math.round(v * 1000) / 1000 } + +function scheduleGeocode(device, pos) { + if (!pos) return + const key = round(pos.latitude) + ',' + round(pos.longitude) + if (geocodedKey.get(device.id) === key) return + geocodedKey.set(device.id, key) + + reverseGeocode(pos.latitude, pos.longitude).then(addr => { + addressCache.set(device.id, addr) + if (!addr) return + const el = document.querySelector(`.device-item[data-id="${device.id}"] .device-address`) + if (el) { el.textContent = addr; el.hidden = false } + }) +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +export function initDrawer(containerId, callbacks) { + destroy() + lostModeSet = new Set() + + const container = document.getElementById(containerId) + container.innerHTML = ` +
+
+ + +
+
+
+ 0 devices +
+ + +
+
+
+
+ + ` + + lastCallbacks = callbacks || {} + + const drawer = document.getElementById('drawer') + + function toggleExpanded() { drawer.classList.toggle('is-expanded') } + + document.getElementById('dh').addEventListener('click', toggleExpanded) + + // Tabs — expand drawer and switch panel + document.getElementById('dtabs').addEventListener('click', e => { + const tab = e.target.closest('.drawer-tab') + if (!tab) return + drawer.classList.add('is-expanded') + const target = tab.dataset.tab + document.querySelectorAll('.drawer-tab').forEach(t => t.classList.toggle('is-active', t.dataset.tab === target)) + document.querySelectorAll('.drawer-panel').forEach(p => { p.hidden = p.dataset.panel !== target }) + }) + + // Panel header tap also expands + document.getElementById('dph').addEventListener('click', toggleExpanded) + + // Logout + document.getElementById('blo').addEventListener('click', e => { + e.stopPropagation() + lastCallbacks.onLogout?.() + }) + + // Add device button + document.getElementById('badd').addEventListener('click', e => { + e.stopPropagation() + lastCallbacks.onAddDevice?.() + }) + + // Add zone button + document.getElementById('baddz').addEventListener('click', e => { + e.stopPropagation() + lastCallbacks.onAddZone?.() + }) + + // Device list — click delegation + document.getElementById('dl').addEventListener('click', e => { + const item = e.target.closest('.device-item') + if (!item) return + const id = Number(item.dataset.id) + const device = lastDevices.find(d => d.id === id) + const pos = lastPositions.find(p => p.deviceId === id) + if (!device) return + + if (e.target.closest('.btn-sound')) { + lastCallbacks.onSound?.(device) + } else if (e.target.closest('.btn-lost')) { + if (lostModeSet.has(id)) lostModeSet.delete(id); else lostModeSet.add(id) + lastCallbacks.onLostMode?.(device, lostModeSet.has(id)) + updateDevices() + } else if (e.target.closest('.btn-history')) { + lastCallbacks.onHistory?.(device) + } else if (e.target.closest('.btn-edit')) { + lastCallbacks.onEditDevice?.(device) + } else { + lastCallbacks.onCenterMap?.(device, pos) + } + }) + + // Zone list — click delegation + document.getElementById('zl').addEventListener('click', e => { + const item = e.target.closest('.zone-item') + if (!item) return + const id = Number(item.dataset.id) + const geofence = lastGeofences.find(g => g.id === id) + if (!geofence) return + if (e.target.closest('.btn-edit-zone')) { + lastCallbacks.onEditZone?.(geofence) + } + }) + + refreshTimer = setInterval(() => updateDevices(), 20000) +} + +export function destroy() { + clearInterval(refreshTimer) + refreshTimer = null +} + +// ── Update devices panel ────────────────────────────────────────────────────── + +export function updateDevices(devices, positions, callbacks) { + if (devices !== undefined) lastDevices = devices + if (positions !== undefined) lastPositions = positions + if (callbacks !== undefined) lastCallbacks = callbacks + + const dc = document.getElementById('dc') + const dl = document.getElementById('dl') + if (!dc || !dl) return + + const N = lastDevices?.length ?? 0 + dc.textContent = N === 1 ? '1 device' : N + ' devices' + + if (!N) { + dl.innerHTML = ` +
+ 🐱 +

No devices found.

+

Tap + to add your first device.

+
` + return + } + + const posByDeviceId = new Map(lastPositions.map(p => [p.deviceId, p])) + const parts = [] + + for (const device of lastDevices) { + const pos = posByDeviceId.get(device.id) + const emoji = esc(device.attributes?.emoji || '📍') + const bat = pos?.attributes?.batteryLevel != null ? Math.round(pos.attributes.batteryLevel) + '%' : '–' + const time = relTime(pos?.fixTime || device?.lastUpdate) + const lostCls = lostModeSet.has(device.id) ? ' is-active' : '' + const status = device.status === 'online' ? 'online' : 'offline' + const addr = addressCache.get(device.id) + const addrHtml = addr ? `
${esc(addr)}
` : `` + + parts.push(` +
+
+
${emoji}
+
+
+
+
${esc(device.name)}
+ ${addrHtml} +
+ ${bat} + · + ${time} +
+
+
+ + + + + +
+
`) + + scheduleGeocode(device, pos) + } + + dl.innerHTML = parts.join('') +} + +// ── Update zones panel ──────────────────────────────────────────────────────── + +export function updateZones(geofences, geofenceDevices, devices, callbacks) { + if (geofences !== undefined) lastGeofences = geofences + if (geofenceDevices !== undefined) lastGeofenceDevices = geofenceDevices + if (devices !== undefined && callbacks !== undefined) lastCallbacks = callbacks + + const zc = document.getElementById('zc') + const zl = document.getElementById('zl') + if (!zc || !zl) return + + const N = lastGeofences?.length ?? 0 + zc.textContent = N === 1 ? '1 zone' : N + ' zones' + + if (!N) { + zl.innerHTML = ` +
+ 📍 +

No zones yet.

+

Tap + to add a zone on the map.

+
` + return + } + + const deviceById = new Map(lastDevices.map(d => [d.id, d])) + + const parts = lastGeofences.map(gf => { + const linkedIds = lastGeofenceDevices[gf.id] || [] + const linkedNames = linkedIds + .map(id => deviceById.get(id)) + .filter(Boolean) + .map(d => (d.attributes?.emoji || '📍') + ' ' + d.name) + .join(', ') + + const radiusM = gf.area?.match(/CIRCLE\s*\([^,]+,\s*([\d.]+)/i)?.[1] + const radiusLabel = radiusM ? (Number(radiusM) >= 1000 ? (Number(radiusM) / 1000).toFixed(1) + 'km' : radiusM + 'm') : '' + + const meta = [radiusLabel, linkedNames].filter(Boolean).join(' · ') + + return ` +
+
📍
+
+
${esc(gf.name)}
+ ${meta ? `
${esc(meta)}
` : ''} +
+ +
` + }) + + zl.innerHTML = parts.join('') +} diff --git a/web-app/src/components/geofence-modal.js b/web-app/src/components/geofence-modal.js new file mode 100644 index 0000000..e95c94c --- /dev/null +++ b/web-app/src/components/geofence-modal.js @@ -0,0 +1,127 @@ +// Add / Edit geofence (zone) modal + +function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function parseCircle(area) { + const m = area?.match(/CIRCLE\s*\(\s*([-\d.]+)\s+([-\d.]+)\s*,\s*([\d.]+)\s*\)/i) + return m ? { lat: +m[1], lng: +m[2], radius: +m[3] } : null +} + +/** + * @param {object} opts + * @param {object|null} opts.geofence — existing geofence for edit, null for new + * @param {object[]} opts.devices — all devices (for assignment checkboxes) + * @param {number[]} opts.linkedDeviceIds — device IDs currently linked to this geofence + * @param {number|null} opts.pendingLat — lat from map tap (new zones only) + * @param {number|null} opts.pendingLng + * @param {Function} opts.onSave — async ({ name, area, selectedDeviceIds }) => void + * @param {Function} opts.onDelete — async () => void (edit only) + * @param {Function} opts.onClose + */ +export function openGeofenceModal({ geofence = null, devices = [], linkedDeviceIds = [], pendingLat, pendingLng, onSave, onDelete, onClose }) { + const isEdit = !!geofence + const existing = isEdit ? parseCircle(geofence.area) : null + const lat = existing?.lat ?? pendingLat + const lng = existing?.lng ?? pendingLng + const radius = existing?.radius ?? 200 + + const hasLocation = lat != null && lng != null + + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + + document.body.appendChild(overlay) + requestAnimationFrame(() => overlay.classList.add('is-visible')) + + const errEl = overlay.querySelector('#merr') + + function close() { + overlay.classList.remove('is-visible') + setTimeout(() => { overlay.remove(); onClose?.() }, 200) + } + + function showError(msg) { errEl.textContent = msg } + + overlay.querySelector('#mc').addEventListener('click', close) + overlay.querySelector('#mcan').addEventListener('click', close) + overlay.addEventListener('click', e => { if (e.target === overlay) close() }) + + overlay.querySelector('#msave').addEventListener('click', async () => { + const name = overlay.querySelector('#gname').value.trim() + const r = Number(overlay.querySelector('#gradius').value) + errEl.textContent = '' + if (!name) { overlay.querySelector('#gname').focus(); return } + if (!hasLocation) { showError('No location set. Cancel and tap the map to place the zone.'); return } + if (!r || r < 50) { showError('Radius must be at least 50m'); return } + + const selectedDeviceIds = [...overlay.querySelectorAll('#gcl input:checked')].map(el => Number(el.dataset.id)) + const area = `CIRCLE (${lat} ${lng}, ${r})` + + const btn = overlay.querySelector('#msave') + btn.disabled = true; btn.textContent = 'Saving…' + try { + await onSave({ name, area, selectedDeviceIds }) + close() + } catch (err) { + btn.disabled = false; btn.textContent = 'Save' + showError(err?.message || 'Save failed') + } + }) + + overlay.querySelector('#gdel')?.addEventListener('click', async () => { + if (!confirm(`Delete zone "${geofence.name}"?\n\nThis cannot be undone.`)) return + const btn = overlay.querySelector('#gdel') + btn.disabled = true; btn.textContent = 'Deleting…' + try { + await onDelete() + close() + } catch { + btn.disabled = false; btn.textContent = 'Delete' + showError('Failed to delete zone') + } + }) + + setTimeout(() => overlay.querySelector('#gname').focus(), 250) +} diff --git a/web-app/src/components/map.js b/web-app/src/components/map.js new file mode 100644 index 0000000..c1e1536 --- /dev/null +++ b/web-app/src/components/map.js @@ -0,0 +1,240 @@ +const DEFAULT_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" +const DEFAULT_TILE_ATTR = '© OpenStreetMap contributors' +const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" +const SAT_TILE_ATTR = "Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics" + +// Module-level singletons — survive re-renders, reset on destroyMap() +let _maplibregl = null +let _map = null +const markerMap = new Map() +let hasInitialFit = false +let userLocationMarker = null +let geoWatchId = null +let isSatellite = false // persists across re-renders +let placementCallback = null + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function parseCircle(area) { + const m = area?.match(/CIRCLE\s*\(\s*([-\d.]+)\s+([-\d.]+)\s*,\s*([\d.]+)\s*\)/i) + return m ? { lat: +m[1], lng: +m[2], radius: +m[3] } : null +} + +function circlePolygon(lat, lng, r, steps = 64) { + const pts = [] + for (let i = 0; i <= steps; i++) { + const a = (i / steps) * 2 * Math.PI + const dLat = (r * Math.sin(a)) / 111320 + const dLng = (r * Math.cos(a)) / (111320 * Math.cos(lat * Math.PI / 180)) + pts.push([lng + dLng, lat + dLat]) + } + return pts +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +export function destroyMap() { + placementCallback = null + if (geoWatchId != null) { + navigator.geolocation.clearWatch(geoWatchId) + geoWatchId = null + } + if (_map) { _map.remove(); _map = null } + markerMap.clear() + hasInitialFit = false + userLocationMarker = null +} + +export async function initMap(containerId) { + _maplibregl = window.maplibregl + if (!_maplibregl) throw new Error("maplibregl not loaded — check