Add button for home screen add instructions
This commit is contained in:
22
README.md
22
README.md
@@ -6,7 +6,8 @@ A web-based gate access management and control system. Authorized users can remo
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
|
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist, an optional expiration date, and an optional time/day-of-week schedule
|
||||||
|
- **Keypass schedules** — restrict a keypass to specific days of the week and/or a time window (e.g. Monday–Friday 08:00–18:00, server local time)
|
||||||
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays
|
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays
|
||||||
- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector
|
- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector
|
||||||
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
|
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
|
||||||
@@ -93,7 +94,7 @@ data/
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/admin/keypasses` | List all keypasses |
|
| GET | `/api/admin/keypasses` | List all keypasses |
|
||||||
| POST | `/api/admin/keypasses` | Create a keypass |
|
| POST | `/api/admin/keypasses` | Create a keypass |
|
||||||
| PATCH | `/api/admin/keypasses/{kp_id}` | Update a keypass |
|
| PATCH | `/api/admin/keypasses/{kp_id}` | Update a keypass (description, expiry, gates, schedule) |
|
||||||
| DELETE | `/api/admin/keypasses/{kp_id}` | Revoke a keypass |
|
| DELETE | `/api/admin/keypasses/{kp_id}` | Revoke a keypass |
|
||||||
| GET | `/api/admin/keypasses/{kp_id}/qr` | Download QR code PNG for a keypass |
|
| GET | `/api/admin/keypasses/{kp_id}/qr` | Download QR code PNG for a keypass |
|
||||||
|
|
||||||
@@ -240,6 +241,23 @@ The Shelly server URI and auth key are stored encrypted. Configure them under **
|
|||||||
|
|
||||||
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing.
|
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing.
|
||||||
|
|
||||||
|
## Keypass Schedules
|
||||||
|
|
||||||
|
In addition to an expiry date and a per-gate allowlist, each keypass can be restricted to a configurable time window:
|
||||||
|
|
||||||
|
- **Days of the week** — select any combination of Mon–Sun. If no day is checked the restriction applies on any day.
|
||||||
|
- **Time window** — a `From` / `To` time in 24-hour format (server local time). Both values must be provided together.
|
||||||
|
|
||||||
|
When a keypass with a schedule is used outside its allowed window the API returns **HTTP 403** (`Keypass is not valid today` / `Keypass is not valid at this time`) and the gate will not open. The restriction is enforced in `require_keypass` in `src/core/dependencies.py`.
|
||||||
|
|
||||||
|
The schedule is stored as a JSON object in the `schedule` column of the `keypasses` table:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "days": [0, 1, 2, 3, 4], "time_start": "08:00", "time_end": "18:00" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`days` uses Python's `weekday()` convention: 0 = Monday, 6 = Sunday. Any absent key is treated as unrestricted (e.g. omitting `days` means any day is allowed).
|
||||||
|
|
||||||
## Keypass QR Codes
|
## Keypass QR Codes
|
||||||
|
|
||||||
Each active keypass has a **QR** button in the admin panel. Clicking it generates a PNG QR code that encodes the URL:
|
Each active keypass has a **QR** button in the admin panel. Clicking it generates a PNG QR code that encodes the URL:
|
||||||
|
|||||||
@@ -103,8 +103,11 @@ async def _security_headers(request: Request, call_next) -> Response:
|
|||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
|
"default-src 'self';"
|
||||||
" img-src 'self' data: blob: https://*.tile.openstreetmap.org;"
|
" script-src 'self' https://cdn.jsdelivr.net;"
|
||||||
|
" style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com;"
|
||||||
|
" font-src 'self' https://fonts.gstatic.com;"
|
||||||
|
" img-src 'self' data: blob: https://*.tile.openstreetmap.org https://cdn.jsdelivr.net;"
|
||||||
" connect-src 'self' https://*.tile.openstreetmap.org"
|
" connect-src 'self' https://*.tile.openstreetmap.org"
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -342,33 +342,43 @@ if ("serviceWorker" in navigator) {
|
|||||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PWA install banner ────────────────────────────────────────────────────────
|
// ── Add to Home Screen ────────────────────────────────────────────────────────
|
||||||
|
const _isInstalled = window.matchMedia("(display-mode: standalone)").matches || !!navigator.standalone;
|
||||||
|
const _installAppBtn = document.getElementById("install-app-btn");
|
||||||
|
const _installBanner = document.getElementById("install-banner");
|
||||||
const INSTALL_DISMISSED_KEY = "lg_install_dismissed";
|
const INSTALL_DISMISSED_KEY = "lg_install_dismissed";
|
||||||
let _deferredInstallPrompt = null;
|
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
if (_isInstalled) {
|
||||||
e.preventDefault();
|
// Already installed — hide both
|
||||||
if (sessionStorage.getItem(INSTALL_DISMISSED_KEY)) return;
|
_installAppBtn.style.display = "none";
|
||||||
_deferredInstallPrompt = e;
|
} else if (typeof window.AddToHomeScreen === "function") {
|
||||||
document.getElementById("install-banner").classList.remove("hidden");
|
const _addToHomeScreenInstance = window.AddToHomeScreen({
|
||||||
|
appName: "Lagomare Gates",
|
||||||
|
appIconUrl: "/static/images/mobile_icon.png",
|
||||||
|
assetUrl: "https://cdn.jsdelivr.net/npm/pwa-add-to-homescreen@4/dist/assets/img/",
|
||||||
|
maxModalDisplayCount: -1,
|
||||||
|
displayOptions: { showMobile: true, showDesktop: true },
|
||||||
|
allowClose: true,
|
||||||
|
showArrow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("install-btn").addEventListener("click", async () => {
|
// Header button — always triggers the library UI
|
||||||
const banner = document.getElementById("install-banner");
|
_installAppBtn.addEventListener("click", () => {
|
||||||
banner.classList.add("hidden");
|
_addToHomeScreenInstance.show("en");
|
||||||
if (!_deferredInstallPrompt) return;
|
});
|
||||||
_deferredInstallPrompt.prompt();
|
|
||||||
await _deferredInstallPrompt.userChoice;
|
// Auto-show banner once per session
|
||||||
_deferredInstallPrompt = null;
|
if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) {
|
||||||
|
_installBanner.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("install-btn").addEventListener("click", () => {
|
||||||
|
_installBanner.classList.add("hidden");
|
||||||
|
_addToHomeScreenInstance.show("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("install-dismiss").addEventListener("click", () => {
|
document.getElementById("install-dismiss").addEventListener("click", () => {
|
||||||
document.getElementById("install-banner").classList.add("hidden");
|
_installBanner.classList.add("hidden");
|
||||||
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
|
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
|
||||||
_deferredInstallPrompt = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("appinstalled", () => {
|
|
||||||
document.getElementById("install-banner").classList.add("hidden");
|
|
||||||
_deferredInstallPrompt = null;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
1
src/static/images/add_to_home_screen.svg
Normal file
1
src/static/images/add_to_home_screen.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M0 0h24v24H0V0z" fill="none"/><path d="M18 1.01L8 1c-1.1 0-2 .9-2 2v3h2V5h10v14H8v-1H6v3c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM10 15h2V8H5v2h3.59L3 15.59 4.41 17 10 11.41z"/></svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -12,6 +12,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/images/mobile_icon.png" />
|
<link rel="apple-touch-icon" href="/static/images/mobile_icon.png" />
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
<link rel="stylesheet" href="/static/leaflet.css" />
|
<link rel="stylesheet" href="/static/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pwa-add-to-homescreen@4/dist/add-to-homescreen.min.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Login view ──────────────────────────────────────────────────────── */
|
/* ── Login view ──────────────────────────────────────────────────────── */
|
||||||
@@ -148,6 +149,9 @@
|
|||||||
<div class="app-header h2">Lagomare Gates</div>
|
<div class="app-header h2">Lagomare Gates</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:.5rem">
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
|
<button id="install-app-btn" class="btn btn-primary" style="padding:.4rem .6rem;line-height:0" aria-label="Add to Home Screen" title="Add to Home Screen">
|
||||||
|
<img src="/static/images/add_to_home_screen.svg" alt="" style="width:22px;height:22px;display:block;filter:invert(1)" />
|
||||||
|
</button>
|
||||||
<button id="map-btn" class="btn btn-ghost hidden" style="padding:.4rem .6rem;line-height:0" aria-label="Show map">
|
<button id="map-btn" class="btn btn-ghost hidden" style="padding:.4rem .6rem;line-height:0" aria-label="Show map">
|
||||||
<img src="/static/images/map.svg" alt="Map" style="width:22px;height:22px;filter:invert(1)" />
|
<img src="/static/images/map.svg" alt="Map" style="width:22px;height:22px;filter:invert(1)" />
|
||||||
</button>
|
</button>
|
||||||
@@ -184,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── PWA install banner ──────────────────────────────────────────────── -->
|
<!-- ── PWA install banner ─────────────────────────────────────────────────────── -->
|
||||||
<div id="install-banner" class="install-banner hidden" role="banner" aria-label="Install app">
|
<div id="install-banner" class="install-banner hidden" role="banner" aria-label="Install app">
|
||||||
<div class="install-banner-body">
|
<div class="install-banner-body">
|
||||||
<img src="/static/images/logo.svg" alt="" class="install-banner-icon" />
|
<img src="/static/images/logo.svg" alt="" class="install-banner-icon" />
|
||||||
@@ -200,6 +204,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/leaflet.js"></script>
|
<script src="/static/leaflet.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pwa-add-to-homescreen@4/dist/add-to-homescreen.min.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -188,8 +188,7 @@ tr:last-child td { border-bottom: none; }
|
|||||||
|
|
||||||
/* ── Error text ────────────────────────────────────────────────────────────── */
|
/* ── Error text ────────────────────────────────────────────────────────────── */
|
||||||
.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
|
.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
|
||||||
|
/* ── PWA install banner ──────────────────────────────────────────────── */
|
||||||
/* ── PWA install banner ────────────────────────────────────────────────────── */
|
|
||||||
.install-banner {
|
.install-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user