Compare commits

...

3 Commits

Author SHA1 Message Date
Ettore
bc18448be7 Offline add-to-homescreen sources 2026-05-23 00:28:49 +02:00
Ettore
6f90bd2891 Add button for home screen add instructions 2026-05-22 23:50:05 +02:00
Ettore
eeea8dfad8 Add keypass schedule 2026-05-22 20:49:03 +02:00
50 changed files with 1467 additions and 39 deletions

View File

@@ -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. MondayFriday 08:0018: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 MonSun. 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:

View File

@@ -59,6 +59,7 @@ class Keypass(Base):
revoked: Mapped[bool] = mapped_column(Boolean, default=False) revoked: Mapped[bool] = mapped_column(Boolean, default=False)
revoked_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) revoked_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
allowed_gates: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates allowed_gates: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates
schedule: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON schedule rule; NULL = always allowed
class GateAccessLog(Base): class GateAccessLog(Base):
@@ -107,3 +108,9 @@ def get_db():
def init_db() -> None: def init_db() -> None:
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Lightweight migrations: add columns that may not exist in older databases
with engine.connect() as conn:
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(keypasses)"))}
if "schedule" not in existing:
conn.execute(text("ALTER TABLE keypasses ADD COLUMN schedule TEXT"))
conn.commit()

View File

@@ -1,3 +1,4 @@
import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -48,4 +49,19 @@ def require_keypass(
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
if kp.expires_at is not None and kp.expires_at < utcnow(): if kp.expires_at is not None and kp.expires_at < utcnow():
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
if kp.schedule:
try:
sched = json.loads(kp.schedule)
except (ValueError, TypeError):
sched = {}
now_local = datetime.now()
allowed_days = sched.get("days")
if allowed_days is not None and now_local.weekday() not in allowed_days:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
time_start = sched.get("time_start")
time_end = sched.get("time_end")
if time_start and time_end:
now_hhmm = now_local.strftime("%H:%M")
if now_hhmm < time_start or now_hhmm > time_end:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
return kp return kp

View File

@@ -32,6 +32,13 @@ class AdminLoginResponse(BaseModel):
# ── Keypasses ───────────────────────────────────────────────────────────────── # ── Keypasses ─────────────────────────────────────────────────────────────────
class ScheduleRule(BaseModel):
"""Optional time/day-of-week restriction for a keypass."""
days: Optional[list[int]] = None # 0=Mon..6=Sun; None/absent = any day
time_start: Optional[str] = None # "HH:MM" 24-hour server local time
time_end: Optional[str] = None # "HH:MM" 24-hour server local time
class KeypassCreate(BaseModel): class KeypassCreate(BaseModel):
description: str description: str
expires_at: Optional[datetime] = None # None = never expires expires_at: Optional[datetime] = None # None = never expires
@@ -40,12 +47,14 @@ class KeypassCreate(BaseModel):
# Auto-generation options (ignored when `code` is supplied manually) # Auto-generation options (ignored when `code` is supplied manually)
length: int = 12 # 632 length: int = 12 # 632
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase" charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
schedule: Optional[ScheduleRule] = None # None = always allowed
class KeypassPatch(BaseModel): class KeypassPatch(BaseModel):
description: Optional[str] = None description: Optional[str] = None
expires_at: Optional[datetime] = None # None = never expires expires_at: Optional[datetime] = None # None = never expires
gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates
schedule: Optional[ScheduleRule] = None # absent = keep unchanged; null = clear
class KeypassResponse(BaseModel): class KeypassResponse(BaseModel):
@@ -58,9 +67,16 @@ class KeypassResponse(BaseModel):
revoked: bool revoked: bool
revoked_at: Optional[datetime] = None revoked_at: Optional[datetime] = None
allowed_gate_ids: list[int] # empty = all gates allowed_gate_ids: list[int] # empty = all gates
schedule: Optional[ScheduleRule] = None
def keypass_to_response(kp: Keypass) -> KeypassResponse: def keypass_to_response(kp: Keypass) -> KeypassResponse:
sched: Optional[ScheduleRule] = None
if kp.schedule:
try:
sched = ScheduleRule(**json.loads(kp.schedule))
except Exception:
pass
return KeypassResponse( return KeypassResponse(
id=kp.id, id=kp.id,
code=kp.code, code=kp.code,
@@ -70,6 +86,7 @@ def keypass_to_response(kp: Keypass) -> KeypassResponse:
revoked=kp.revoked, revoked=kp.revoked,
revoked_at=kp.revoked_at, revoked_at=kp.revoked_at,
allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [], allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [],
schedule=sched,
) )

View File

@@ -103,7 +103,10 @@ 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';"
" script-src 'self';"
" style-src 'self' 'unsafe-inline';"
" font-src 'self';"
" img-src 'self' data: blob: https://*.tile.openstreetmap.org;" " img-src 'self' data: blob: https://*.tile.openstreetmap.org;"
" connect-src 'self' https://*.tile.openstreetmap.org" " connect-src 'self' https://*.tile.openstreetmap.org"
) )

View File

@@ -16,6 +16,16 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"]) router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
def _serialize_schedule(s) -> Optional[str]:
"""Serialize a ScheduleRule to a JSON string, or None if effectively empty."""
if s is None:
return None
d = s.model_dump(exclude_none=True)
if "days" in d and not d["days"]:
del d["days"]
return json.dumps(d) if d else None
# ── Word list for passphrase mode ───────────────────────────────────────────── # ── Word list for passphrase mode ─────────────────────────────────────────────
_WORDS = [ _WORDS = [
"apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain", "apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain",
@@ -102,6 +112,7 @@ async def create_keypass(
expires_at=req.expires_at, expires_at=req.expires_at,
revoked=False, revoked=False,
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None, allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
schedule=_serialize_schedule(req.schedule),
) )
db.add(kp) db.add(kp)
db.commit() db.commit()
@@ -126,6 +137,8 @@ async def update_keypass(
kp.expires_at = req.expires_at kp.expires_at = req.expires_at
if req.gate_ids is not None: if req.gate_ids is not None:
kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
if "schedule" in req.model_fields_set:
kp.schedule = _serialize_schedule(req.schedule)
db.commit() db.commit()
db.refresh(kp) db.refresh(kp)
return keypass_to_response(kp) return keypass_to_response(kp)

View File

@@ -0,0 +1,823 @@
@import url(roboto.css);
.adhs-container {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0);
z-index: 999999;
opacity: 0;
transition: opacity 0.3s ease-in-out, background-color 0.3s ease-in-out;
font-family: "Roboto", sans-serif;
color: #212121;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.adhs-container.visible {
background-color: rgba(0, 0, 0, 0.9);
opacity: 1;
}
.adhs-container .adhs-modal {
background-color: #f5f5f5;
border-radius: 7px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
left: 50%;
margin: auto;
max-width: 400px;
padding: 49px 19px 32px 19px;
position: absolute;
transition: opacity 0.3s ease-in-out;
display: flex;
flex-direction: column;
line-height: normal;
top: 40%;
transform: translateY(-50%) translateX(-50%);
width: 90%;
}
@keyframes adhs-bounce-modal {
0%,
20%,
50%,
80%,
100% {
transform: translateY(-50%) translateX(-50%);
}
40% {
transform: translateY(-40%) translateX(-50%);
}
60% {
transform: translateY(-55%) translateX(-50%);
}
}
.adhs-container.visible .adhs-modal {
opacity: 1;
animation: adhs-bounce-modal 0.5s;
}
.adhs-container .adhs-logo {
position: absolute;
left: 0;
right: 0;
margin: auto;
height: 80px;
width: 80px;
top: -45px;
z-index: 1000000;
}
.adhs-container .adhs-logo img {
width: 80px;
height: 80px;
border-radius: 12px;
}
.adhs-container .adhs-modal .adhs-title {
font-size: 27px;
font-weight: 700;
padding: 0px 0px 18px 0px;
text-align: justify;
word-spacing: -0.1em;
}
.adhs-container .adhs-modal .adhs-error-title {
font-size: 24px;
font-weight: 700;
margin: 5px 10px 15px 10px;
text-align: justify;
}
.adhs-container .adhs-modal .adhs-error-body {
font-size: 20px;
margin: 0px 10px 20px 10px;
text-align: justify;
}
.adhs-container .adhs-modal .adhs-error-copy-link-button {
background-color: white;
border: 1px solid;
border-radius: 4px;
font-size: 19px;
padding: 5px;
}
.adhs-container .adhs-modal .adhs-list {
display: flex;
flex-direction: column;
}
.adhs-container .adhs-modal .adhs-list .adhs-emphasis {
font-weight: 700;
font-size: 20px;
}
.adhs-container .adhs-modal .adhs-list .adhs-your-app-icon {
height: 40px;
margin: 0px 5px -13px 5px;
border-radius: 6px;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item {
display: flex;
flex-direction: row;
align-items: start;
justify-content: flex-start;
margin: 0px 0px 10px 0px;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-number-container {
flex-direction: column;
display: flex;
/* margin: 0px 5px 0px 0px; */
}
.adhs-container.adhs-desktop
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-number-container {
margin: 0px 5px 0px 0px;
}
.adhs-container.adhs-mobile
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-number-container {
margin: 5px 5px 0px 0px;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-circle {
font-size: 30px;
height: 32px;
width: 32px;
border-radius: 100px;
background-color: #000000;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-number {
padding: 6px 0px 0px 11px;
color: white;
font-size: 17px;
font-weight: 100;
}
[dir="rtl"]
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-number {
padding: 6px 11px 0px 11px;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-instruction {
font-size: 19px;
line-height: 27px;
padding: 0px 5px 0px 5px;
/* text-align: justify; */
}
.adhs-container.adhs-mobile
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-instruction:has(.adhs-list-button) {
padding-top: 5px;
}
.adhs-container .adhs-modal .adhs-list .adhs-list-item .adhs-list-button {
display: inline-block;
/* border: 0.5px solid lightgrey; */
border-radius: 4px;
box-shadow: 1px 1px 2px gray;
margin-right: 3px;
padding: 2px 8px;
white-space: nowrap;
}
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-list-button:has(.adhs-list-button-image-only) {
display: inline-block;
padding: 1px 8px;
}
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-list-button
.adhs-list-button-text {
display: inline;
font-size: 16px;
position: relative;
top: -1px;
}
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-list-button
.adhs-list-button-image-only {
display: inline;
position: relative;
left: 0px;
top: 3px;
}
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-list-button
.adhs-list-button-image-left {
display: inline;
position: relative;
margin: 0 5px 0 0;
top: 2px;
}
.adhs-container
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-list-button
.adhs-list-button-image-right {
display: inline;
position: relative;
margin: 0 0 0 5px;
top: 2px;
}
/* IOS Safari */
.adhs-container.adhs-ios.adhs-safari
.adhs-modal
.adhs-list
.adhs-ios-safari-sharing-api-button {
height: 35px;
margin: 0px 5px -7px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
background-color: #ffffff;
}
.adhs-container.adhs-ios.adhs-safari
.adhs-modal
.adhs-list
.adhs-ios-safari-add-to-home-screen-button {
height: 33px;
margin: 0px 5px -7px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
background-color: #ffffff;
}
@keyframes adhs-ios-safari-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-30px);
}
60% {
transform: translateY(-15px);
}
}
@keyframes adhs-ios-ipad-safari-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0) rotate(180deg);
}
40% {
transform: translateY(30px) rotate(180deg);
}
60% {
transform: translateY(15px) rotate(180deg);
}
}
@media (orientation: landscape) {
.adhs-container .adhs-bouncing-arrow {
display: none;
}
}
.adhs-container.adhs-ios.adhs-safari
.adhs-ios-safari-bouncing-arrow-container
img {
height: 60px;
position: absolute;
top: calc(100dvh - 60px);
bottom: unset;
left: 0;
right: 0;
margin: auto;
animation: adhs-ios-safari-bouncing-arrow-animation 2s infinite;
}
.adhs-container.adhs-ios.adhs-safari.adhs-ios26
.adhs-ios-safari-bouncing-arrow-container
img {
height: 60px;
position: absolute;
top: calc(100dvh - 60px);
bottom: unset;
left: unset;
right: 7%;
margin: 0px 0px;
animation: adhs-ios-safari-bouncing-arrow-animation 2s infinite;
}
.adhs-container.adhs-ios.adhs-safari
.adhs-ios-ipad-safari-bouncing-arrow-container
img {
height: 60px;
position: absolute;
top: 0;
bottom: unset;
left: unset;
right: 11%;
margin: 0px 0px;
animation: adhs-ios-ipad-safari-bouncing-arrow-animation 2s infinite;
}
.adhs-container.adhs-ios.adhs-safari.adhs-ios26
.adhs-ios-ipad-safari-bouncing-arrow-container
img {
height: 60px;
position: absolute;
top: 0;
bottom: unset;
left: unset;
right: 9%;
margin: 0px 0px;
animation: adhs-ios-ipad-safari-bouncing-arrow-animation 2s infinite;
}
/* IOS Chrome */
@keyframes adhs-ios-chrome-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(20px);
}
60% {
transform: translateY(5px);
}
}
.adhs-container.adhs-ios.adhs-chrome
.adhs-modal
.adhs-list
.adhs-ios-chrome-more-button {
height: 35px;
margin: 0px 5px -7px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
background-color: #ffffff;
}
.adhs-container.adhs-ios.adhs-chrome
.adhs-ios-chrome-bouncing-arrow-container
img {
height: 40px;
position: absolute;
top: 0;
right: 18px;
animation: adhs-ios-chrome-bouncing-arrow-animation 1.5s infinite;
}
.adhs-container.adhs-ios.adhs-chrome
.adhs-modal
.adhs-list
.adhs-ios-chrome-add-to-home-screen-button {
height: 35px;
margin: 0px 5px -7px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
background-color: #ffffff;
}
/* Android Chrome */
@keyframes adhs-android-chrome-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(20px);
}
60% {
transform: translateY(5px);
}
}
.adhs-container.adhs-android.adhs-chrome
.adhs-android-chrome-bouncing-arrow-container
img {
height: 40px;
position: absolute;
top: 0;
right: 15px;
animation: adhs-android-chrome-bouncing-arrow-animation 1.5s infinite;
}
@keyframes adhs-android-edge-portrait-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-5px);
}
}
@keyframes adhs-android-edge-landscape-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(20px);
}
60% {
transform: translateY(5px);
}
}
.adhs-container.adhs-android.adhs-edge
.adhs-android-edge-bouncing-arrow-container
img {
position: absolute;
height: 40px;
top: calc(100dvh - 60px);
right: 25px;
animation: adhs-android-edge-portrait-bouncing-arrow-animation 1.5s infinite;
}
@media (orientation: landscape) {
.adhs-container.adhs-android.adhs-edge
.adhs-android-edge-bouncing-arrow-container
img {
position: absolute;
height: 40px;
top: 20px;
right: 33px;
animation: adhs-android-edge-landscape-bouncing-arrow-animation 1.5s infinite;
}
}
.adhs-container.adhs-android.adhs-chrome
.adhs-modal
.adhs-list
.adhs-android-chrome-more-button {
height: 30px;
background: white;
margin: 0px 5px -7px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
}
.adhs-container.adhs-android.adhs-chrome
.adhs-modal
.adhs-list
.adhs-android-chrome-add-to-homescreen-button {
height: 30px;
background: white;
margin: 0px 5px -9px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
padding: 0px 7px 0px 4px;
}
.adhs-container.adhs-android.adhs-chrome
.adhs-modal
.adhs-list
.adhs-android-chrome-install-app {
height: 30px;
background: white;
margin: 10px 5px -10px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
padding: 0px;
}
.adhs-container.adhs-android.adhs-chrome
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-instruction {
line-height: 34px;
}
/* In-App System Browser */
.adhs-container.adhs-inappbrowser-openinsystembrowser .adhs-modal .adhs-title {
padding: 0px 0px 9px 0px;
}
.adhs-container.adhs-inappbrowser-openinsystembrowser
.adhs-modal
.adhs-list
.adhs-emphasis {
padding: 6px 8px;
border-radius: 5px;
margin: 0px 0px 0px 5px;
box-shadow: 1px 1px 2px gray;
background: white;
width: fit-content;
font-weight: 500;
}
.adhs-container.adhs-inappbrowser-openinsystembrowser
.adhs-modal
.adhs-list
.adhs-list-item {
margin: 6px 0px 9px 0px;
}
.adhs-container.adhs-inappbrowser-openinsystembrowser
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-instruction {
padding: 0px 0px 2px 9px;
line-height: 42px;
}
@keyframes adhs-inappbrowser-openinsystembrowser-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(20px);
}
60% {
transform: translateY(5px);
}
}
.adhs-container.adhs-inappbrowser-openinsystembrowser
.adhs-inappbrowser-openinsystembrowser-bouncing-arrow-container
img {
height: 40px;
position: absolute;
top: 0;
right: 6px;
animation: adhs-inappbrowser-openinsystembrowser-bouncing-arrow-animation 1.5s
infinite;
}
.adhs-container.adhs-inappbrowser-openinsystembrowser
.adhs-modal
.adhs-list
.adhs-more-button {
height: 35px;
background: white;
margin: 0px 5px -11px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
}
/* In-App Safari Browser */
.adhs-container.adhs-inappbrowser-openinsafari .adhs-modal .adhs-title {
padding: 0px 0px 9px 0px;
}
.adhs-container.adhs-inappbrowser-openinsafari
.adhs-modal
.adhs-list
.adhs-emphasis {
padding: 6px 8px;
border-radius: 5px;
margin: 0px 0px 0px 5px;
box-shadow: 1px 1px 2px gray;
background: white;
width: fit-content;
font-weight: 500;
}
.adhs-container.adhs-inappbrowser-openinsafari
.adhs-modal
.adhs-list
.adhs-list-item {
margin: 6px 0px 9px 0px;
}
.adhs-container.adhs-inappbrowser-openinsafari
.adhs-modal
.adhs-list
.adhs-list-item
.adhs-instruction {
padding: 0px 0px 0px 9px;
line-height: 34px;
margin: 4px 0px 0px 0px;
}
@keyframes adhs-inappbrowser-openinsafari-bouncing-arrow-animation {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-5px);
}
}
.adhs-container.adhs-inappbrowser-openinsafari
.adhs-inappbrowser-openinsafari-bouncing-arrow-container
img {
height: 40px;
position: absolute;
top: calc(100dvh - 40px);
right: 15px;
animation: adhs-inappbrowser-openinsafari-bouncing-arrow-animation 1.5s
infinite;
}
.adhs-container.adhs-inappbrowser-openinsafari
.adhs-modal
.adhs-list
.adhs-more-button {
height: 35px;
background: white;
margin: 0px 5px -11px 5px;
box-shadow: 1px 1px 2px gray;
border-radius: 4px;
}
.adhs-container.adhs-desktop .adhs-modal {
background-color: #f5f5f5;
border-radius: 7px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 45px 45px 28px 40px;
margin: auto;
max-width: 400px;
}
.adhs-container .adhs-modal .adhs-install-app {
/* font-size: 22px; */
font-weight: 600;
text-align: center;
/* margin: 0px 0px 0px 0px; */
padding: 0px;
}
.adhs-container.adhs-desktop .adhs-modal .adhs-install-app {
font-size: 22px;
margin: 0px 0px 0px 0px;
}
.adhs-container.adhs-mobile .adhs-modal .adhs-install-app {
font-size: 23px;
margin: -2px 0px 0px 0px;
}
.adhs-container .adhs-modal .adhs-app-name {
/* font-size: 22px; */
font-weight: 600;
margin: 5px auto 1px auto;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.adhs-container.adhs-desktop .adhs-modal .adhs-app-name {
font-size: 22px;
margin: 0px auto 1px auto;
}
.adhs-container.adhs-mobile .adhs-modal .adhs-app-name {
font-size: 23px;
margin: 0px auto 18px auto;
}
.adhs-container .adhs-modal .adhs-app-url {
font-size: 13px;
font-weight: 300;
margin: 0 auto 24px auto;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.adhs-container.adhs-desktop-chrome .adhs-modal .adhs-app-url {
margin-bottom: 18px;
}
.adhs-container.adhs-desktop-safari .adhs-modal .adhs-app-url {
margin-bottom: 24px;
}
.adhs-container.adhs-mobile .adhs-modal .adhs-app-url {
margin: 0 auto 20px auto;
}
.adhs-container .adhs-modal .adhs-blurb {
font-size: 13px;
font-weight: 400;
margin: 0px 0px 14px 0px;
}
.adhs-container.adhs-mobile .adhs-modal .adhs-blurb {
font-size: 15px;
margin: 13px 0px 5px 0px;
}
.adhs-container.adhs-desktop-chrome .adhs-modal .adhs-blurb {
margin-top: 0px;
}
.adhs-container.adhs-desktop-safari .adhs-modal .adhs-blurb {
margin-top: 12px;
}
.adhs-container .adhs-modal .adhs-button-container {
display: flex;
flex-direction: row;
justify-content: space-around;
margin-top: 11px;
}
.adhs-container .adhs-modal .adhs-button {
background-color: #1d64f0;
color: white;
cursor: pointer;
font-size: 16px;
font-weight: 400;
border: none;
padding: 10px 33px;
border-radius: 9px;
}
.adhs-container.adhs-desktop-safari
.adhs-desktop-safari-bouncing-arrow-container
img {
height: 40px;
position: absolute;
top: 0;
right: 88px;
animation: adhs-ios-chrome-bouncing-arrow-animation 1.5s infinite;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="17px" height="22px" viewBox="0 0 17 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
<title>add_to_home_screen</title>
<desc>Created with Sketch.</desc>
<g id="Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Rounded" transform="translate(-783.000000, -1705.000000)">
<g id="Device" transform="translate(100.000000, 1650.000000)">
<g id="-Round-/-Device-/-add_to_home_screen" transform="translate(680.000000, 54.000000)">
<g transform="translate(0.000000, 0.000000)">
<polygon id="Path" points="0 0 24 0 24 24 0 24"></polygon>
<path d="M18,1.01 L8,1 C6.9,1 6,1.9 6,3 L6,6 C6,6.55 6.45,7 7,7 C7.55,7 8,6.55 8,6 L8,5 L18,5 L18,19 L8,19 L8,18 C8,17.45 7.55,17 7,17 C6.45,17 6,17.45 6,18 L6,21 C6,22.1 6.9,23 8,23 L18,23 C19.1,23 20,22.1 20,21 L20,3 C20,1.9 19.1,1.01 18,1.01 Z M11,15 C11.55,15 12,14.55 12,14 L12,9 C12,8.45 11.55,8 11,8 L6,8 C5.45,8 5,8.45 5,9 C5,9.55 5.45,10 6,10 L8.59,10 L3.7,14.89 C3.31,15.28 3.31,15.91 3.7,16.3 C4.09,16.69 4.72,16.69 5.11,16.3 L10,11.41 L10,14 C10,14.55 10.45,15 11,15 Z" id="🔹Icon-Color" fill="#1D1D1D"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1,5 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="19" r="2" fill="black"/>
<circle cx="11" cy="3" r="2" fill="black"/>
<circle cx="11" cy="11" r="2" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11M13.75 2A2.25 2.25 0 0 1 16 4.25v6.924a6.5 6.5 0 0 0-1.5.558V4.25a.75.75 0 0 0-.75-.75h-7.5a.75.75 0 0 0-.75.75v15.5c0 .414.336.75.75.75l5.483.001a6.5 6.5 0 0 0 1.077 1.5L6.25 22A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2zm3.75 12l-.09.007a.5.5 0 0 0-.402.402L17 14.5V17h-2.502l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17v2.503l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402l.008-.09V18l2.504.001l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.403-.402l-.09-.008H18v-2.5l-.008-.09a.5.5 0 0 0-.402-.403zm-8.751 3.504L11 17.499c0 .517.06 1.02.174 1.5l-2.423.005a.75.75 0 0 1-.002-1.5"/></svg>

After

Width:  |  Height:  |  Size: 823 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1,3 @@
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="22.33" height="16.4975" rx="1.5" stroke="black"/>
<line x1="1.16626" y1="4.16602" x2="22.1633" y2="4.16602" stroke="black"/>
<rect x="3.49951" y="11.665" width="16.331" height="3.4995" rx="1" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@@ -0,0 +1,10 @@
<svg width="17" height="22" viewBox="0 0 17 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_377_20)">
<path d="M2.862 21.708H14.094C15.066 21.708 15.768 21.492 16.254 21.006C16.74 20.52 16.956 19.818 16.956 18.9V8.856C16.956 7.938 16.74 7.236 16.254 6.75C15.768 6.264 15.066 6.048 14.094 6.048H11.34V7.236H14.094C14.634 7.236 15.066 7.398 15.336 7.668C15.606 7.938 15.768 8.37 15.768 8.964V18.792C15.768 19.332 15.606 19.764 15.336 20.088C15.066 20.412 14.634 20.52 14.094 20.52H2.916C2.376 20.52 1.944 20.358 1.674 20.088C1.404 19.818 1.242 19.386 1.242 18.792V8.91C1.242 8.37 1.404 7.938 1.674 7.614C1.944 7.29 2.376 7.182 2.916 7.182H5.67V5.994H2.862C1.89 5.994 1.188 6.21 0.702 6.696C0.216 7.182 0 7.938 0 8.856V18.846C0 19.764 0.216 20.52 0.702 20.952C1.188 21.384 1.944 21.708 2.862 21.708ZM8.478 14.202C8.64 14.202 8.802 14.148 8.91 14.04C9.018 13.932 9.072 13.77 9.072 13.608V3.078L9.018 1.674L9.882 2.592L11.556 4.32C11.664 4.428 11.826 4.482 11.988 4.482C12.15 4.482 12.258 4.428 12.366 4.32C12.474 4.212 12.528 4.104 12.528 3.942C12.528 3.78 12.474 3.672 12.366 3.564L8.91 0.216C8.856 0.162 8.748 0.108 8.694 0.054C8.64 0 8.586 0 8.478 0C8.424 0 8.316 0 8.262 0.054C8.208 0.108 8.1 0.162 8.046 0.216L4.644 3.564C4.482 3.672 4.428 3.78 4.428 3.942C4.428 4.104 4.482 4.212 4.59 4.32C4.698 4.428 4.806 4.482 4.968 4.482C5.13 4.482 5.292 4.428 5.4 4.32L7.074 2.592L7.938 1.674L7.884 3.078V13.608C7.884 13.77 7.938 13.878 8.046 14.04C8.154 14.202 8.316 14.202 8.478 14.202Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_377_20">
<rect width="17" height="22" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M207.858-432Q188-432 174-446.142q-14-14.141-14-34Q160-500 174.142-514q14.141-14 34-14Q228-528 242-513.858q14 14.141 14 34Q256-460 241.858-446q-14.141 14-34 14Zm272 0Q460-432 446-446.142q-14-14.141-14-34Q432-500 446.142-514q14.141-14 34-14Q500-528 514-513.858q14 14.141 14 34Q528-460 513.858-446q-14.141 14-34 14Zm272 0Q732-432 718-446.142q-14-14.141-14-34Q704-500 718.142-514q14.141-14 34-14Q772-528 786-513.858q14 14.141 14 34Q800-460 785.858-446q-14.141 14-34 14Z"/></svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@@ -0,0 +1,3 @@
<svg width="82" height="114" viewBox="0 0 82 114" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.3609 111.891C39.5088 114.039 42.9912 114.039 45.1391 111.891L80.1409 76.8891C82.2888 74.7412 82.2888 71.2588 80.1409 69.1109C77.993 66.963 74.5106 66.963 72.3627 69.1109L41.25 100.224L10.1373 69.1109C7.98942 66.963 4.50701 66.963 2.35913 69.1109C0.211243 71.2588 0.211243 74.7412 2.35913 76.8891L37.3609 111.891ZM35.75 -0.000549316L35.75 108.002H46.75L46.75 -0.000549316L35.75 -0.000549316Z" fill="#3478F6"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,3 @@
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1,3 @@
<svg width="82" height="115" viewBox="0 0 82 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.4071 2.12778C43.2691 -0.0300287 39.7868 -0.0461506 37.629 2.09177L2.46551 36.9311C0.307703 39.0691 0.291581 42.5514 2.4295 44.7092C4.56741 46.867 8.04978 46.8832 10.2076 44.7452L41.464 13.7769L72.4323 45.0333C74.5702 47.1911 78.0526 47.2072 80.2104 45.0693C82.3682 42.9314 82.3843 39.449 80.2464 37.2912L45.4071 2.12778ZM46.4999 114.025L46.9999 6.02428L36.0001 5.97335L35.5001 113.975L46.4999 114.025Z" fill="#3478F6"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1,10 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_539_19)">
<path d="M1.5 22C1.1 22 0.75 21.85 0.45 21.55C0.15 21.25 0 20.9 0 20.5V7.775C0 7.375 0.15 7.025 0.45 6.725C0.75 6.425 1.1 6.275 1.5 6.275H5.725V7.775H1.5V20.5H14.5V7.775H10.225V6.275H14.5C14.9 6.275 15.25 6.425 15.55 6.725C15.85 7.025 16 7.375 16 7.775V20.5C16 20.9 15.85 21.25 15.55 21.55C15.25 21.85 14.9 22 14.5 22H1.5ZM7.225 14.325V2.9L5.025 5.1L3.95 4.025L7.975 0L12 4.025L10.925 5.1L8.725 2.9V14.325H7.225Z" fill="#3A3838"/>
</g>
<defs>
<clipPath id="clip0_539_19">
<rect width="16" height="22" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 674 B

View File

@@ -0,0 +1,12 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_538_4)">
<path d="M16.984 13.2512C16.983 13.7444 16.885 14.2325 16.696 14.6878C16.506 15.1431 16.229 15.5566 15.88 15.9047C15.532 16.2528 15.118 16.5287 14.662 16.7166C14.207 16.9046 13.719 17.0009 13.226 17L3.74401 16.9834C2.74901 16.9817 1.796 16.5844 1.094 15.8789C0.392002 15.1734 -0.00199242 14.2175 7.57729e-06 13.2216L0.016014 3.74879C0.017014 3.25563 0.115008 2.76747 0.304008 2.31218C0.494008 1.8569 0.771003 1.44339 1.12 1.09529C1.468 0.747176 1.88201 0.471286 2.33801 0.283356C2.79301 0.0954257 3.28101 -0.000854289 3.77401 5.71079e-06L13.256 0.0165758C14.251 0.0183158 15.204 0.415636 15.906 1.12113C16.608 1.82661 17.002 2.78247 17 3.77844L16.984 13.2512ZM15.759 3.95072C15.759 3.59929 15.691 3.25117 15.557 2.92625C15.423 2.60133 15.227 2.30597 14.979 2.05703C14.731 1.80809 14.437 1.61045 14.113 1.47539C13.789 1.34034 13.442 1.27051 13.091 1.26989L3.924 1.25387C3.573 1.25325 3.22501 1.32186 2.90101 1.45578C2.57601 1.5897 2.28101 1.78631 2.03201 2.03438C1.78401 2.28244 1.586 2.57712 1.452 2.90156C1.317 3.22601 1.24701 3.57389 1.24601 3.92532L1.23101 12.9839C1.23001 13.3353 1.29801 13.6834 1.43201 14.0083C1.56601 14.3333 1.762 14.6286 2.01 14.8776C2.258 15.1265 2.55201 15.3241 2.87601 15.4592C3.20001 15.5943 3.54801 15.6641 3.89901 15.6647L13.065 15.6807C13.416 15.6813 13.764 15.6127 14.089 15.4788C14.413 15.3449 14.708 15.1483 14.957 14.9002C15.205 14.6522 15.403 14.3575 15.538 14.033C15.672 13.7086 15.742 13.3607 15.743 13.0093L15.759 3.95072Z" fill="black"/>
<path d="M12.1893 8.01892H5.13272C4.77843 8.01892 4.49121 8.30613 4.49121 8.66043V8.66043C4.49121 9.01473 4.77843 9.30194 5.13272 9.30194H12.1893C12.5436 9.30194 12.8308 9.01473 12.8308 8.66043C12.8308 8.30613 12.5436 8.01892 12.1893 8.01892Z" fill="black"/>
<path d="M9.30176 12.1887V5.13211C9.30176 4.77781 9.01454 4.4906 8.66025 4.4906H8.66025C8.30595 4.4906 8.01874 4.77781 8.01874 5.13211V12.1887C8.01874 12.543 8.30595 12.8302 8.66025 12.8302C9.01454 12.8302 9.30176 12.543 9.30176 12.1887Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_538_4">
<rect width="17" height="17" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="33" height="33" viewBox="233.5 233.5 33 33" xml:space="preserve">
<desc>Created with Fabric.js 5.3.0</desc>
<defs>
</defs>
<g transform="matrix(0.05 0 0 0.05 250 250)" id="SxIUNrmYlm-UtiEj48uHt" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,122,255); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-480, 480)" d="M 480 -160 L 160 -480 L 202 -522 L 450 -274 L 450 -800 L 510 -800 L 510 -274 L 758 -522 L 800 -480 L 480 -160 Z" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@@ -0,0 +1 @@
<svg width="22" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1a9de3c1165" viewBox="0 0 22 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1a9de3c1165"><defs><clipPath id="frame-clip-deae4d7e-8911-807f-8006-d1a9de3c1165-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-deae4d7e-8911-807f-8006-d1a9de3c1165-render-1)" fill="none"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a9de3c1165"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" class="frame-background"/></g><g class="frame-children"><g id="shape-deae4d7e-8911-807f-8006-d1a99b14a6fe"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a99b14a6fe"><ellipse cx="11" cy="11" rx="11" ry="11" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(225, 223, 222); fill-opacity: 1;"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1aa4e1dc3bb"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1aa4e1dc3bb"><ellipse cx="14" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33b61f8d93b"><g class="fills" id="fills-529e0883-3585-806e-8006-d33b61f8d93b"><ellipse cx="11" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33b658407f5"><g class="fills" id="fills-529e0883-3585-806e-8006-d33b658407f5"><ellipse cx="8" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg width="22" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1ab0933fe5c" viewBox="0 0 22 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5c"><defs><clipPath id="frame-clip-deae4d7e-8911-807f-8006-d1ab0933fe5c-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-deae4d7e-8911-807f-8006-d1ab0933fe5c-render-1)" fill="none"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5c"><rect rx="0" ry="0" x="0" y="0" width="22" height="22" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" class="frame-background"/></g><g class="frame-children"><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5d"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5d"><ellipse cx="11" cy="11" rx="11" ry="11" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(252, 252, 252); fill-opacity: 1;"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1ab0933fe5e"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1ab0933fe5e"><ellipse cx="15" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33ad86e6a89"><g class="fills" id="fills-529e0883-3585-806e-8006-d33ad86e6a89"><ellipse cx="11" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g><g id="shape-529e0883-3585-806e-8006-d33adbcd1201"><g class="fills" id="fills-529e0883-3585-806e-8006-d33adbcd1201"><ellipse cx="7" cy="11" rx="1" ry="1" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg width="17" xmlns="http://www.w3.org/2000/svg" height="22" id="screenshot-deae4d7e-8911-807f-8006-d1a8485085de" viewBox="0 0 17 22" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-deae4d7e-8911-807f-8006-d1a8485085de" width="17" height="22" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-deae4d7e-8911-807f-8006-d1a848516fae" style="display: none;"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a848516fae"><rect width="17" height="22" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-deae4d7e-8911-807f-8006-d1a848521481"><g class="fills" id="fills-deae4d7e-8911-807f-8006-d1a848521481"><path d="M2.768157958984375,21.20001220703125L13.631805419921875,21.20001220703125C14.571990966796875,21.20001220703125,15.251007080078125,20.9910888671875,15.72100830078125,20.52099609375C16.19110107421875,20.0509033203125,16.399993896484375,19.37188720703125,16.399993896484375,18.484130859375L16.399993896484375,8.7694091796875C16.399993896484375,7.8814697265625,16.19110107421875,7.2025146484375,15.72100830078125,6.73248291015625C15.251007080078125,6.26239013671875,14.571990966796875,6.053466796875,13.631805419921875,6.053466796875L10.96820068359375,6.053466796875L10.96820068359375,7.2025146484375L13.631805419921875,7.2025146484375C14.154098510742188,7.2025146484375,14.571990966796875,7.35919189453125,14.833099365234375,7.620361328125C15.09429931640625,7.8814697265625,15.251007080078125,8.29931640625,15.251007080078125,8.87384033203125L15.251007080078125,18.37957763671875C15.251007080078125,18.90191650390625,15.09429931640625,19.3197021484375,14.833099365234375,19.63311767578125C14.571990966796875,19.94647216796875,14.154098510742188,20.0509033203125,13.631805419921875,20.0509033203125L2.82037353515625,20.0509033203125C2.298095703125,20.0509033203125,1.8802490234375,19.89422607421875,1.619110107421875,19.63311767578125C1.3579559326171875,19.37188720703125,1.201263427734375,18.9541015625,1.201263427734375,18.37957763671875L1.201263427734375,8.8216552734375C1.201263427734375,8.29931640625,1.3579559326171875,7.8814697265625,1.619110107421875,7.568115234375C1.8802490234375,7.2547607421875,2.298095703125,7.1502685546875,2.82037353515625,7.1502685546875L5.4840850830078125,7.1502685546875L5.4840850830078125,6.001220703125L2.768157958984375,6.001220703125C1.828033447265625,6.001220703125,1.1490478515625,6.21014404296875,0.678985595703125,6.68023681640625C0.20892333984375,7.1502685546875,0,7.8814697265625,0,8.7694091796875L0,18.43182373046875C0,19.3197021484375,0.20892333984375,20.0509033203125,0.678985595703125,20.46881103515625C1.1490478515625,20.8865966796875,1.8802490234375,21.20001220703125,2.768157958984375,21.20001220703125ZM8.199996948242188,13.94012451171875C8.356689453125,13.94012451171875,8.513381958007812,13.88787841796875,8.617828369140625,13.78338623046875C8.7222900390625,13.67901611328125,8.774520874023438,13.52227783203125,8.774520874023438,13.3656005859375L8.774520874023438,3.18084716796875L8.7222900390625,1.8228759765625L9.557952880859375,2.7108154296875L11.177093505859375,4.38214111328125C11.281494140625,4.486572265625,11.438201904296875,4.538818359375,11.594894409179688,4.538818359375C11.751602172851562,4.538818359375,11.856094360351562,4.486572265625,11.960494995117188,4.38214111328125C12.06500244140625,4.2777099609375,12.117202758789062,4.1732177734375,12.117202758789062,4.01654052734375C12.117202758789062,3.85986328125,12.06500244140625,3.75537109375,11.960494995117188,3.65093994140625L8.617828369140625,0.4127197265625C8.565597534179688,0.3604736328125,8.461151123046875,0.3082275390625,8.408920288085938,0.25604248046875C8.356689453125,0.20379638671875,8.304458618164062,0.20379638671875,8.199996948242188,0.20379638671875C8.14776611328125,0.20379638671875,8.043304443359375,0.20379638671875,7.9910888671875,0.25604248046875C7.9388427734375,0.3082275390625,7.834381103515625,0.3604736328125,7.78216552734375,0.4127197265625L4.491729736328125,3.65093994140625C4.33502197265625,3.75537109375,4.282806396484375,3.85986328125,4.282806396484375,4.01654052734375C4.282806396484375,4.1732177734375,4.33502197265625,4.2777099609375,4.439483642578125,4.38214111328125C4.5439453125,4.486572265625,4.648406982421875,4.538818359375,4.8050994873046875,4.538818359375C4.9617767333984375,4.538818359375,5.11846923828125,4.486572265625,5.222930908203125,4.38214111328125L6.842041015625,2.7108154296875L7.677703857421875,1.8228759765625L7.62548828125,3.18084716796875L7.62548828125,13.3656005859375C7.62548828125,13.52227783203125,7.677703857421875,13.626708984375,7.78216552734375,13.78338623046875C7.886627197265625,13.94012451171875,8.043304443359375,13.94012451171875,8.199996948242188,13.94012451171875Z" style="fill: rgb(0, 0, 0); fill-opacity: 1;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="17" height="22" viewBox="0 0 17 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.76815 21.2H13.6318C14.572 21.2 15.251 20.9911 15.721 20.521C16.1911 20.0509 16.4 19.3719 16.4 18.4841V8.7694C16.4 7.8815 16.1911 7.20252 15.721 6.73246C15.251 6.26239 14.572 6.05348 13.6318 6.05348H10.9682V7.20252H13.6318C14.1541 7.20252 14.572 7.35921 14.8331 7.62036C15.0943 7.8815 15.251 8.29934 15.251 8.87386V18.3796C15.251 18.9019 15.0943 19.3197 14.8331 19.6331C14.572 19.9465 14.1541 20.0509 13.6318 20.0509H2.82038C2.29809 20.0509 1.88025 19.8942 1.61911 19.6331C1.35796 19.3719 1.20127 18.9541 1.20127 18.3796V8.82163C1.20127 8.29934 1.35796 7.8815 1.61911 7.56813C1.88025 7.25475 2.29809 7.15029 2.82038 7.15029H5.48408V6.00125H2.76815C1.82803 6.00125 1.14904 6.21017 0.678981 6.68023C0.208917 7.15029 0 7.8815 0 8.7694V18.4318C0 19.3197 0.208917 20.0509 0.678981 20.4688C1.14904 20.8866 1.88025 21.2 2.76815 21.2ZM8.2 13.9401C8.35669 13.9401 8.51338 13.8879 8.61783 13.7834C8.72229 13.679 8.77452 13.5223 8.77452 13.3656V3.18087L8.72229 1.8229L9.55796 2.7108L11.1771 4.38214C11.2815 4.4866 11.4382 4.53883 11.5949 4.53883C11.7516 4.53883 11.8561 4.4866 11.9605 4.38214C12.065 4.27768 12.1172 4.17322 12.1172 4.01654C12.1172 3.85985 12.065 3.75539 11.9605 3.65093L8.61783 0.412714C8.5656 0.360484 8.46115 0.308255 8.40892 0.256026C8.35669 0.203796 8.30446 0.203796 8.2 0.203796C8.14777 0.203796 8.04331 0.203796 7.99108 0.256026C7.93885 0.308255 7.83439 0.360484 7.78217 0.412714L4.49172 3.65093C4.33503 3.75539 4.2828 3.85985 4.2828 4.01654C4.2828 4.17322 4.33503 4.27768 4.43949 4.38214C4.54395 4.4866 4.64841 4.53883 4.8051 4.53883C4.96178 4.53883 5.11847 4.4866 5.22293 4.38214L6.84204 2.7108L7.67771 1.8229L7.62548 3.18087V13.3656C7.62548 13.5223 7.67771 13.6267 7.78217 13.7834C7.88662 13.9401 8.04331 13.9401 8.2 13.9401Z" fill="#007AFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,270 @@
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(fonts/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -159,6 +159,7 @@
<th>Description</th> <th>Description</th>
<th>Gates</th> <th>Gates</th>
<th>Expires</th> <th>Expires</th>
<th>Schedule</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
</tr> </tr>
@@ -468,6 +469,38 @@
<div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div> <div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div> </div>
</div> </div>
<div class="field">
<label style="margin-bottom:.5rem">Access schedule</label>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
<input type="checkbox" id="kp-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Always accessible (no restriction)</span>
</label>
<div id="kp-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
<div>
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
<input id="kp-time-start" type="time" style="width:auto;font-size:.9rem" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
<input id="kp-time-end" type="time" style="width:auto;font-size:.9rem" />
</div>
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
</div>
</div>
</div>
<p id="kp-error" class="error-msg hidden"></p> <p id="kp-error" class="error-msg hidden"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button> <button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button>
@@ -505,6 +538,38 @@
<div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div> <div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div> </div>
</div> </div>
<div class="field">
<label style="margin-bottom:.5rem">Access schedule</label>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
<input type="checkbox" id="kp-edit-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Always accessible (no restriction)</span>
</label>
<div id="kp-edit-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
<div>
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-edit-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
<input id="kp-edit-time-start" type="time" style="width:auto;font-size:.9rem" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-edit-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
<input id="kp-edit-time-end" type="time" style="width:auto;font-size:.9rem" />
</div>
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
</div>
</div>
</div>
<p id="kp-edit-error" class="error-msg hidden"></p> <p id="kp-edit-error" class="error-msg hidden"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button> <button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button>

View File

@@ -132,12 +132,20 @@ function showToast(msg, isError = false) {
} }
// ── Keypasses ───────────────────────────────────────────────────────────────── // ── Keypasses ─────────────────────────────────────────────────────────────────
const _DAY_SHORT = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
function formatSchedule(sched) {
if (!sched) return null;
const parts = [];
if (sched.days && sched.days.length > 0) parts.push(sched.days.map(d => _DAY_SHORT[d]).join(" "));
if (sched.time_start && sched.time_end) parts.push(`${sched.time_start}${sched.time_end}`);
return parts.length ? parts.join(" · ") : null;
}
async function loadKeypasses() { async function loadKeypasses() {
const rows = await api("GET", "/api/admin/keypasses"); const rows = await api("GET", "/api/admin/keypasses");
const tbody = document.getElementById("keypasses-body"); const tbody = document.getElementById("keypasses-body");
tbody.innerHTML = ""; tbody.innerHTML = "";
if (!rows.length) { if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
return; return;
} }
for (const kp of rows) { for (const kp of rows) {
@@ -156,18 +164,24 @@ async function loadKeypasses() {
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>` ? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
: '<span style="color:var(--text-muted)">Never</span>'; : '<span style="color:var(--text-muted)">Never</span>';
const schedText = formatSchedule(kp.schedule);
const schedCell = schedText
? `<span style="font-size:.85em;white-space:nowrap">${esc(schedText)}</span>`
: '<span style="color:var(--text-muted)">Always</span>';
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td> <td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
<td>${esc(kp.description)}</td> <td>${esc(kp.description)}</td>
<td>${gatesCell}</td> <td>${gatesCell}</td>
<td>${expiresCell}</td> <td>${expiresCell}</td>
<td>${schedCell}</td>
<td>${badge}</td> <td>${badge}</td>
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap"> <td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" ${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""} data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" ${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""} data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids, schedule:kp.schedule})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" ${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-kp-id="${kp.id}">Revoke</button>` : ""} data-kp-id="${kp.id}">Revoke</button>` : ""}
</div></td>`; </div></td>`;
@@ -210,6 +224,17 @@ async function loadKeypasses() {
allGatesCb.checked = !allowedIds; allGatesCb.checked = !allowedIds;
checksContainer.style.display = allowedIds ? "flex" : "none"; checksContainer.style.display = allowedIds ? "flex" : "none";
document.getElementById("kp-edit-error").classList.add("hidden"); document.getElementById("kp-edit-error").classList.add("hidden");
// Schedule
const sched = kp.schedule;
const noSchedCb = document.getElementById("kp-edit-no-schedule");
const schedWrap = document.getElementById("kp-edit-schedule-wrap");
noSchedCb.checked = !sched;
schedWrap.style.display = sched ? "flex" : "none";
document.querySelectorAll('input[name="kp-edit-day"]').forEach(cb => {
cb.checked = sched && sched.days ? sched.days.includes(parseInt(cb.value)) : false;
});
document.getElementById("kp-edit-time-start").value = (sched && sched.time_start) || "";
document.getElementById("kp-edit-time-end").value = (sched && sched.time_end) || "";
document.getElementById("kp-edit-modal").classList.remove("hidden"); document.getElementById("kp-edit-modal").classList.remove("hidden");
}); });
}); });
@@ -287,6 +312,12 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
// Reset All gates checkbox // Reset All gates checkbox
document.getElementById("kp-all-gates").checked = true; document.getElementById("kp-all-gates").checked = true;
checksContainer.style.display = "none"; checksContainer.style.display = "none";
// Reset schedule
document.getElementById("kp-no-schedule").checked = true;
document.getElementById("kp-schedule-wrap").style.display = "none";
document.querySelectorAll('input[name="kp-day"]').forEach(cb => cb.checked = false);
document.getElementById("kp-time-start").value = "";
document.getElementById("kp-time-end").value = "";
document.getElementById("kp-error").classList.add("hidden"); document.getElementById("kp-error").classList.add("hidden");
document.getElementById("keypass-modal").classList.remove("hidden"); document.getElementById("keypass-modal").classList.remove("hidden");
}); });
@@ -310,6 +341,11 @@ document.getElementById("kp-never-expires").addEventListener("change", e => {
kpExpInput.style.opacity = e.target.checked ? ".4" : ""; kpExpInput.style.opacity = e.target.checked ? ".4" : "";
}); });
// Schedule toggle
document.getElementById("kp-no-schedule").addEventListener("change", e => {
document.getElementById("kp-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
});
// All gates toggle // All gates toggle
document.getElementById("kp-all-gates").addEventListener("change", e => { document.getElementById("kp-all-gates").addEventListener("change", e => {
const checksContainer = document.getElementById("kp-gate-checks"); const checksContainer = document.getElementById("kp-gate-checks");
@@ -337,6 +373,9 @@ document.getElementById("kp-edit-never").addEventListener("change", e => {
expInput.disabled = e.target.checked; expInput.disabled = e.target.checked;
expInput.style.opacity = e.target.checked ? ".4" : ""; expInput.style.opacity = e.target.checked ? ".4" : "";
}); });
document.getElementById("kp-edit-no-schedule").addEventListener("change", e => {
document.getElementById("kp-edit-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
});
document.getElementById("kp-edit-all-gates").addEventListener("change", e => { document.getElementById("kp-edit-all-gates").addEventListener("change", e => {
const checksContainer = document.getElementById("kp-edit-gate-checks"); const checksContainer = document.getElementById("kp-edit-gate-checks");
if (e.target.checked) { if (e.target.checked) {
@@ -368,10 +407,22 @@ document.getElementById("kp-edit-form").addEventListener("submit", async e => {
const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString(); const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString();
const allGates = document.getElementById("kp-edit-all-gates").checked; const allGates = document.getElementById("kp-edit-all-gates").checked;
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value)); const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value));
const noEditSched = document.getElementById("kp-edit-no-schedule").checked;
let schedule = null;
if (!noEditSched) {
const days = Array.from(document.querySelectorAll('input[name="kp-edit-day"]:checked')).map(cb => parseInt(cb.value));
const time_start = document.getElementById("kp-edit-time-start").value || undefined;
const time_end = document.getElementById("kp-edit-time-end").value || undefined;
schedule = {};
if (days.length) schedule.days = days;
if (time_start) schedule.time_start = time_start;
if (time_end) schedule.time_end = time_end;
if (!Object.keys(schedule).length) schedule = null;
}
const errEl = document.getElementById("kp-edit-error"); const errEl = document.getElementById("kp-edit-error");
errEl.classList.add("hidden"); errEl.classList.add("hidden");
try { try {
await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids }); await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids, schedule });
document.getElementById("kp-edit-modal").classList.add("hidden"); document.getElementById("kp-edit-modal").classList.add("hidden");
showToast("Keypass updated"); showToast("Keypass updated");
loadKeypasses(); loadKeypasses();
@@ -390,10 +441,22 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString(); const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
const allGates = document.getElementById("kp-all-gates").checked; const allGates = document.getElementById("kp-all-gates").checked;
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value)); const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value));
const noSched = document.getElementById("kp-no-schedule").checked;
let schedule = null;
if (!noSched) {
const days = Array.from(document.querySelectorAll('input[name="kp-day"]:checked')).map(cb => parseInt(cb.value));
const time_start = document.getElementById("kp-time-start").value || undefined;
const time_end = document.getElementById("kp-time-end").value || undefined;
schedule = {};
if (days.length) schedule.days = days;
if (time_start) schedule.time_start = time_start;
if (time_end) schedule.time_end = time_end;
if (!Object.keys(schedule).length) schedule = null;
}
const errEl = document.getElementById("kp-error"); const errEl = document.getElementById("kp-error");
errEl.classList.add("hidden"); errEl.classList.add("hidden");
try { try {
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length }); await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length, schedule });
document.getElementById("keypass-modal").classList.add("hidden"); document.getElementById("keypass-modal").classList.add("hidden");
showToast("Keypass created"); showToast("Keypass created");
loadKeypasses(); loadKeypasses();

View File

@@ -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: "/static/add-to-homescreen/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;
_deferredInstallPrompt = null;
});
document.getElementById("install-dismiss").addEventListener("click", () => { // Auto-show banner once per session
document.getElementById("install-banner").classList.add("hidden"); if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) {
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1"); _installBanner.classList.remove("hidden");
_deferredInstallPrompt = null; }
});
window.addEventListener("appinstalled", () => { document.getElementById("install-btn").addEventListener("click", () => {
document.getElementById("install-banner").classList.add("hidden"); _installBanner.classList.add("hidden");
_deferredInstallPrompt = null; _addToHomeScreenInstance.show("en");
}); });
document.getElementById("install-dismiss").addEventListener("click", () => {
_installBanner.classList.add("hidden");
sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
});
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -11,7 +11,8 @@
<link rel="icon" type="image/svg+xml" href="/static/images/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/static/images/logo.svg" />
<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/leaflet.css" />
<link rel="stylesheet" href="/static/add-to-homescreen/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" />
@@ -199,7 +203,8 @@
</div> </div>
</div> </div>
<script src="/static/leaflet.js"></script> <script src="/static/leaflet/leaflet.js"></script>
<script src="/static/add-to-homescreen/add-to-homescreen.min.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,29 @@
"src": "/static/images/mobile_icon.png", "src": "/static/images/mobile_icon.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any"
},
{
"src": "/static/images/mobile_icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/static/images/screenshot-mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Lagomare Gates login screen"
},
{
"src": "/static/images/screenshot-wide.png",
"sizes": "1280x800",
"type": "image/png",
"form_factor": "wide",
"label": "Lagomare Gates login screen"
} }
], ],
"lang": "en" "lang": "en"

View File

@@ -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;
@@ -251,4 +250,4 @@ tr:last-child td { border-bottom: none; }
@keyframes banner-in { @keyframes banner-in {
from { opacity: 0; transform: translateX(-50%) translateY(1rem); } from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
to { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 1; transform: translateX(-50%) translateY(0); }
} }