Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6403e9c332 | ||
|
|
ea7025d911 | ||
|
|
f3ba0bf5ba | ||
|
|
db2ebd23fb | ||
|
|
77d1c04d86 | ||
|
|
a7cccbf170 | ||
|
|
bce9f8663d | ||
|
|
5188777556 | ||
|
|
0263f978aa | ||
|
|
fd61246ceb |
@@ -26,6 +26,30 @@ See [README.md](README.md) for a full architecture map. Key files:
|
||||
- `backend/app/main.py` — FastAPI entry point (CORS, router registration)
|
||||
- `backend/app/models.py` — SQLAlchemy ORM (Show, Event, StationConfig)
|
||||
|
||||
### Admin dashboard — visitor stats (implemented)
|
||||
|
||||
Nginx access logs → shared volume → FastAPI log parser → PostgreSQL → admin dashboard. No extra services.
|
||||
|
||||
**How it works:**
|
||||
1. Nginx writes structured JSON access logs to **daily log files** on a shared mount: `/logs/access-YYYY-MM-DD.log`. A `map` directive in [nginx.conf.template](nginx.conf.template) extracts the date from the ISO timestamp, so Nginx rotates to a new file at midnight.
|
||||
2. FastAPI ([backend/app/log_parser.py](backend/app/log_parser.py)) maintains a **"last parsed date"** (at most yesterday) persisted in PostgreSQL via the `LogParseState` model. On each run it scans `/logs/` for unprocessed daily files, enriches entries with IP geolocation (MaxMind GeoLite2 City), and stores aggregates.
|
||||
3. Aggregates are persisted in `visitor_stats_daily` (unique visitors per day) and `visitor_stats_geo` (country/city breakdown) tables.
|
||||
4. Admin dashboard (Angular) reads from stats API endpoints in [backend/app/api/stats.py](backend/app/api/stats.py).
|
||||
|
||||
**Key files:**
|
||||
- `backend/app/log_parser.py` — log file parsing + geo enrichment + `process_unparsed_logs()`
|
||||
- `backend/app/api/stats.py` — stats API endpoints (summary, timeseries, geo, parse trigger)
|
||||
- `backend/app/models.py` — `VisitorStatDaily`, `VisitorStatGeo`, `LogParseState` ORM models
|
||||
- `backend/inject_test_logs.py` — test data injector (writes test log entries for yesterday)
|
||||
- `nginx.conf.template` — JSON log format (`kmtn_json`) with daily rotation via `$log_date` map
|
||||
- `backend/geo/GeoLite2-City.mmdb` — MaxMind geolocation database
|
||||
|
||||
**Why daily files:** The currently-being-written file is never read — no partial lines, no race conditions, no file locking. Stats have a minimum one-day lag.
|
||||
|
||||
**Deployment context:** Single-node minikube, `hostPath` PersistentVolume with `ReadWriteMany`, or docker-compose named volume mounted in both pods.
|
||||
|
||||
**Injecting test data:** Run `backend/inject_test_logs.py` (or the inline docker compose command in README.md) to write test log entries for yesterday, then trigger parsing via `GET /api/parse`.
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **Re-skin:** Edit colors in `_variables.scss` only.
|
||||
|
||||
@@ -5,7 +5,31 @@
|
||||
"Read(*)",
|
||||
"Glob(*)",
|
||||
"Grep(*)",
|
||||
"WebFetch(domain:www.kryzradio.org)"
|
||||
"WebFetch(domain:www.kryzradio.org)",
|
||||
"Bash(ls -la /logs/ 2>/dev/null || echo \"DIRECTORY /logs DOES NOT EXIST\")",
|
||||
"Read(//logs/**)",
|
||||
"Bash(head -5 /logs/access-2026-06-26.log)",
|
||||
"Bash(python3 -c \"import sys, json; ips = set\\(\\); [ips.add\\(json.loads\\(l\\)['remote_addr']\\) for l in sys.stdin if l.strip\\(\\)]; print\\(f'Total unique IPs: {len\\(ips\\)}'\\); print\\('\\\\n'.join\\(sorted\\(ips\\)\\)\\)\")",
|
||||
"Bash(python3 -c \"from datetime import date; print\\(f'Today: {date.today\\(\\)}'\\); print\\(f'Log files from Jun 13-27 are all before today: {date\\(2026, 6, 27\\) < date.today\\(\\)}'\\)\")",
|
||||
"Bash(ls -la /logs/ 2>/dev/null || echo \"/logs/ not accessible\")",
|
||||
"Bash(python3 -c \"import sys,json; ips=set\\(\\); [ips.add\\(json.loads\\(l\\)['remote_addr']\\) for l in sys.stdin if l.strip\\(\\)]; print\\(f'Unique IPs in 06-26: {len\\(ips\\)}'\\); [print\\(f' {ip}'\\) for ip in sorted\\(ips\\)]\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(docker exec *)",
|
||||
"Bash(curl -s http://localhost:8000/api/stats/summary)",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(curl -s \"http://localhost:8000/api/stats/timeseries?start_date=2026-06-13&end_date=2026-06-27\")",
|
||||
"Bash(curl -s \"http://localhost:8000/api/stats/geo?start_date=2026-06-13&end_date=2026-06-27\")",
|
||||
"Bash(env)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/auth/login -H \"Content-Type: application/json\" -d '{\"username\":\"admin\",\"password\":\"123\"}')",
|
||||
"Bash(curl -s http://localhost:8000/api/stats/summary -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNfYWRtaW4iOnRydWUsImV4cCI6MTc4MjcxMTYyOX0.8eM-Vd0H72R8eVomEtxLNeYSYUe65-0WPmNWUzx_mxw\")",
|
||||
"Bash(curl -s \"http://localhost:8000/api/stats/timeseries?start_date=2026-06-13&end_date=2026-06-27\" -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNfYWRtaW4iOnRydWUsImV4cCI6MTc4MjcxMTYyOX0.8eM-Vd0H72R8eVomEtxLNeYSYUe65-0WPmNWUzx_mxw\")",
|
||||
"Bash(curl -s \"http://localhost:8000/api/stats/geo?start_date=2026-06-13&end_date=2026-06-27\" -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNfYWRtaW4iOnRydWUsImV4cCI6MTc4MjcxMTYyOX0.8eM-Vd0H72R8eVomEtxLNeYSYUe65-0WPmNWUzx_mxw\")",
|
||||
"Bash(python3 -c \"import maxminddb; print\\(maxminddb.__version__\\); print\\(dir\\(maxminddb.Reader\\)\\)\")",
|
||||
"Bash(cat /tmp/uvicorn.log)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(KMTN_DATABASE_URL=\"sqlite+aiosqlite:///./kmountain.db\" KMTN_ADMIN_USERNAME=\"admin\" KMTN_ADMIN_PASSWORD=\"123\" KMTN_CORS_ORIGINS='[\"http://localhost:4200\"]' nohup uvicorn app.main:app --reload --host 0.0.0.0 --port 8000)",
|
||||
"Bash(echo \"Backend PID: $!\")",
|
||||
"Bash(curl -s http://localhost:8000/docs)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/workspaces/web_app/.vscode"
|
||||
|
||||
@@ -48,3 +48,6 @@ Thumbs.db
|
||||
|
||||
# Dev SQLite database
|
||||
backend/kmountain.db
|
||||
|
||||
# GeoLite2 database (binary, licensed — see backend/geo/README.md)
|
||||
backend/geo/GeoLite2-City.mmdb
|
||||
@@ -15,5 +15,6 @@ COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
ENV API_UPSTREAM=http://api:8000
|
||||
ENV API_PUBLIC_URL=""
|
||||
RUN mkdir -p /logs
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
@@ -95,6 +95,8 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru
|
||||
├── Dockerfile # Python 3.12-slim + gunicorn/uvicorn
|
||||
├── requirements.txt
|
||||
├── seed.py # Initial data for all models
|
||||
├── inject_test_logs.py # Test data injector for visitor stats pipeline
|
||||
├── geo/ # MaxMind GeoLite2 City database
|
||||
└── app/
|
||||
├── main.py # FastAPI app, CORS, router registration
|
||||
├── config.py # ENV-based settings (KMTN_* prefix)
|
||||
@@ -104,7 +106,8 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru
|
||||
└── api/
|
||||
├── programs.py # CRUD /api/programs
|
||||
├── events.py # CRUD /api/events
|
||||
└── tiers.py # CRUD /api/tiers
|
||||
├── tiers.py # CRUD /api/tiers
|
||||
└── stats.py # Visitor stats endpoints (summary, timeseries, geo, parse)
|
||||
```
|
||||
|
||||
## Local Development
|
||||
@@ -197,6 +200,72 @@ ng test # Angular + Vitest
|
||||
|
||||
The backend API is self-documented at [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints.
|
||||
|
||||
## Admin Dashboard — Visitor Stats
|
||||
|
||||
A visitor analytics dashboard built on a **log parsing pipeline**: Nginx writes access logs to a shared volume that the FastAPI backend reads and processes. No extra services required (no Logstash, Prometheus, or message queues).
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Nginx** writes structured JSON access logs to **daily log files** on a shared mount: `/logs/access-YYYY-MM-DD.log`. A `map` directive extracts the date from the ISO timestamp, so Nginx rotates to a new file at midnight each day.
|
||||
2. **FastAPI** (via [log_parser.py](backend/app/log_parser.py)) scans `/logs/` for daily files that haven't been parsed yet, enriches each entry with IP geolocation (MaxMind GeoLite2 City), and stores aggregates in PostgreSQL.
|
||||
3. **PostgreSQL** tables (`visitor_stats_daily`, `visitor_stats_geo`, `log_parse_state`) hold the aggregated data and a "last parsed date" marker.
|
||||
4. **Angular admin dashboard** reads from the stats API endpoints to render charts and a world map.
|
||||
|
||||
### Why daily files
|
||||
|
||||
The currently-being-written file is never read — no partial lines, no race conditions, no file locking needed. Stats have a minimum one-day lag.
|
||||
|
||||
### Stats API endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/stats/summary` | Total unique visitors for a date range |
|
||||
| `GET` | `/api/stats/timeseries` | Daily unique visitor counts (chart data) |
|
||||
| `GET` | `/api/stats/geo` | Geographic breakdown by country (map data) |
|
||||
| `GET` | `/api/stats/parse` | Trigger log parsing (SSE progress stream) |
|
||||
|
||||
All endpoints require admin authentication (JWT token). Date range is optional; defaults to the last 30 days.
|
||||
|
||||
### GeoLite2 setup
|
||||
|
||||
The log parser requires a MaxMind GeoLite2 City database for IP geolocation:
|
||||
|
||||
1. Download from [https://dev.maxmind.com/geoip/geolite2-free-geolocation-data](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data)
|
||||
2. Place the `.mmdb` file at `backend/geo/GeoLite2-City.mmdb`
|
||||
3. The Dockerfile copies it into the container at `/app/geo/GeoLite2-City.mmdb`
|
||||
|
||||
### Injecting test data
|
||||
|
||||
To verify the pipeline works (e.g., after deploying or before a demo), use the test log injector script at [backend/inject_test_logs.py](backend/inject_test_logs.py). It writes realistic traffic from 25 well-known public IPs across multiple past days — data is only aggregated a day in arrears, so all dates are strictly before today.
|
||||
|
||||
```bash
|
||||
# Run inside the api container (writes to shared /logs volume)
|
||||
docker compose exec api python /app/inject_test_logs.py /logs 14
|
||||
|
||||
# Or run locally (dev mode)
|
||||
cd backend
|
||||
python inject_test_logs.py /logs 14
|
||||
```
|
||||
|
||||
The second argument controls how many days of data to generate (default: 14). Each day gets a deterministic mix of visitors from the IP pool, so re-running produces the same output (idempotent — files are appended, so check first).
|
||||
|
||||
After injecting, trigger parsing via the admin dashboard or:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/api/parse?token=YOUR_JWT"
|
||||
```
|
||||
|
||||
### Shared volume setup
|
||||
|
||||
For docker-compose, the `logs` named volume is mounted in both the `frontend` (Nginx) and `api` containers at `/logs`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
logs:/logs
|
||||
```
|
||||
|
||||
For Kubernetes (minikube), use a `hostPath` PersistentVolume with `ReadWriteMany` access mode, claimed by a PersistentVolumeClaim and mounted in both pods.
|
||||
|
||||
## API Reference
|
||||
|
||||
The backend exposes CRUD endpoints for three models:
|
||||
|
||||
@@ -9,5 +9,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# GeoLite2 City database for IP geolocation
|
||||
# Download from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
# and place at backend/geo/GeoLite2-City.mmdb before building
|
||||
RUN mkdir -p /app/geo
|
||||
COPY geo/GeoLite2-City.mmdb /app/geo/GeoLite2-City.mmdb 2>/dev/null || true
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,187 @@
|
||||
"""Stats API: trigger log parsing and query aggregated visitor data."""
|
||||
|
||||
import asyncio
|
||||
from datetime import date, timedelta
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import decode_token, get_current_admin_user
|
||||
from app.database import get_session
|
||||
from app.log_parser import process_unparsed_logs
|
||||
from app.models import LogParseState, VisitorStatDaily, VisitorStatGeo
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _default_date_range() -> tuple[date, date]:
|
||||
"""Return (start_date, end_date) for last 30 days."""
|
||||
end = date.today() - timedelta(days=1) # today isn't parsed yet
|
||||
start = end - timedelta(days=29)
|
||||
return start, end
|
||||
|
||||
|
||||
async def _verify_admin_from_token(token: str) -> User:
|
||||
"""Verify admin from a raw JWT token string (for SSE query-param auth)."""
|
||||
from app.database import async_session
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
payload = decode_token(token)
|
||||
user_id = int(payload["sub"])
|
||||
if not payload.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
# ── SSE parse endpoint ───────────────────────────────────────
|
||||
|
||||
@router.get("/parse")
|
||||
async def trigger_parse(token: Optional[str] = Query(None)):
|
||||
"""Trigger log file parsing. Streams progress via Server-Sent Events.
|
||||
|
||||
Accepts JWT via `token` query parameter (EventSource can't send headers).
|
||||
"""
|
||||
# Authenticate — SSE can't send Authorization headers, so use query param
|
||||
if token:
|
||||
await _verify_admin_from_token(token)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required")
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
# Collect progress events, then yield them
|
||||
events: list[tuple[str, int, int]] = []
|
||||
result = await process_unparsed_logs(lambda fd, c, t: events.append((fd, c, t)))
|
||||
|
||||
for file_date, current, total in events:
|
||||
yield f'event: progress\ndata: {{"file": "{file_date}", "current": {current}, "total": {total}}}\n\n'
|
||||
|
||||
yield f'event: done\ndata: {{"files_processed": {result["files_processed"]}, "rows_inserted": {result["rows_inserted"]}}}\n\n'
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering for SSE
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_summary(
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get total unique visitors for a date range."""
|
||||
if start_date is None or end_date is None:
|
||||
start_date, end_date = _default_date_range()
|
||||
|
||||
result = await session.execute(
|
||||
select(func.coalesce(func.sum(VisitorStatDaily.unique_visitors), 0)).where(
|
||||
VisitorStatDaily.date >= start_date,
|
||||
VisitorStatDaily.date <= end_date,
|
||||
)
|
||||
)
|
||||
total_unique = result.scalar()
|
||||
|
||||
return {"total_unique_visitors": total_unique}
|
||||
|
||||
|
||||
# ── Time series ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/timeseries")
|
||||
async def get_timeseries(
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily unique visitor counts for a date range."""
|
||||
if start_date is None or end_date is None:
|
||||
start_date, end_date = _default_date_range()
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
VisitorStatDaily.date,
|
||||
VisitorStatDaily.unique_visitors,
|
||||
)
|
||||
.where(
|
||||
VisitorStatDaily.date >= start_date,
|
||||
VisitorStatDaily.date <= end_date,
|
||||
)
|
||||
.order_by(VisitorStatDaily.date)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
return [
|
||||
{"date": row.date.isoformat(), "unique_visitors": row.unique_visitors}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ── Geo ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/geo")
|
||||
async def get_geo_stats(
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get geographic distribution of visitors for a date range.
|
||||
|
||||
Aggregates by country (country, country_code, lat, lon), summing visitor_count.
|
||||
Returns one row per country/region for the map to render.
|
||||
"""
|
||||
if start_date is None or end_date is None:
|
||||
start_date, end_date = _default_date_range()
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
VisitorStatGeo.country,
|
||||
VisitorStatGeo.country_code,
|
||||
VisitorStatGeo.latitude,
|
||||
VisitorStatGeo.longitude,
|
||||
func.sum(VisitorStatGeo.visitor_count).label("total_visitors"),
|
||||
)
|
||||
.where(
|
||||
VisitorStatGeo.date >= start_date,
|
||||
VisitorStatGeo.date <= end_date,
|
||||
)
|
||||
.group_by(
|
||||
VisitorStatGeo.country,
|
||||
VisitorStatGeo.country_code,
|
||||
VisitorStatGeo.latitude,
|
||||
VisitorStatGeo.longitude,
|
||||
)
|
||||
.order_by(desc(func.sum(VisitorStatGeo.visitor_count)))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"country": row.country or "Unknown",
|
||||
"country_code": row.country_code or "",
|
||||
"latitude": row.latitude,
|
||||
"longitude": row.longitude,
|
||||
"visitors": row.total_visitors,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -14,6 +14,10 @@ class Settings(BaseSettings):
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
|
||||
|
||||
# Visitor stats
|
||||
LOGS_DIR: str = "/logs"
|
||||
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
||||
|
||||
model_config = {"env_prefix": "KMTN_"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Nginx access log parser with IP geolocation enrichment."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
import maxminddb
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.models import LogParseState, VisitorStatDaily, VisitorStatGeo
|
||||
|
||||
# ── Geo reader ──────────────────────────────────────────────
|
||||
|
||||
_geo_reader: Optional[maxminddb.Reader] = None
|
||||
|
||||
|
||||
def get_geo_reader() -> maxminddb.Reader:
|
||||
"""Return a singleton MaxMind GeoLite2 City reader."""
|
||||
global _geo_reader
|
||||
if _geo_reader is None:
|
||||
db_path = settings.GEOLITE2_DB_PATH
|
||||
if os.path.exists(db_path):
|
||||
_geo_reader = maxminddb.open_database(db_path)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"GeoLite2 database not found at {db_path}. "
|
||||
"Download from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data "
|
||||
"and place it at backend/geo/GeoLite2-City.mmdb"
|
||||
)
|
||||
return _geo_reader
|
||||
|
||||
|
||||
# Country centroid fallback for free GeoLite2 (no city/coordinates).
|
||||
# Approximate center coordinates per ISO country code — enough for map markers.
|
||||
_COUNTRY_CENTROIDS: dict[str, tuple[float, float]] = {
|
||||
"US": (39.8, -98.5),
|
||||
"GB": (53.5, -1.5),
|
||||
"DE": (51.2, 10.4),
|
||||
"FR": (46.6, 2.3),
|
||||
"JP": (36.2, 138.3),
|
||||
"AU": (-25.3, 133.8),
|
||||
"BR": (-14.2, -51.9),
|
||||
"CA": (56.1, -106.3),
|
||||
"IN": (20.6, 79.0),
|
||||
"KR": (35.9, 127.8),
|
||||
"NL": (52.1, 5.3),
|
||||
"MX": (23.6, -102.5),
|
||||
"IT": (41.9, 12.6),
|
||||
"ES": (40.5, -3.7),
|
||||
"SE": (62.0, 15.0),
|
||||
"PL": (51.9, 19.1),
|
||||
"AR": (-38.4, -63.6),
|
||||
"ZA": (-30.6, 22.9),
|
||||
"EG": (26.8, 30.8),
|
||||
"TR": (39.0, 35.2),
|
||||
"RU": (61.5, 105.3),
|
||||
"SG": (1.35, 103.8),
|
||||
"NZ": (-40.9, 174.9),
|
||||
"CO": (4.6, -74.3),
|
||||
"CL": (-35.7, -71.5),
|
||||
"PE": (-9.2, -75.0),
|
||||
"PH": (12.9, 121.8),
|
||||
"ID": (-0.8, 113.9),
|
||||
"TH": (15.9, 100.9),
|
||||
"VN": (14.1, 108.3),
|
||||
"MY": (4.2, 101.9),
|
||||
"AE": (23.4, 53.8),
|
||||
"SA": (23.9, 45.1),
|
||||
"IL": (31.0, 34.9),
|
||||
"NO": (60.5, 8.5),
|
||||
"DK": (56.3, 9.5),
|
||||
"FI": (64.1, 26.5),
|
||||
"BE": (50.5, 4.5),
|
||||
"AT": (47.5, 14.6),
|
||||
"CH": (46.8, 8.2),
|
||||
"PT": (39.4, -8.2),
|
||||
"GR": (39.1, 21.8),
|
||||
"CZ": (49.8, 15.5),
|
||||
"RO": (45.9, 24.9),
|
||||
"HU": (47.2, 19.5),
|
||||
"UA": (48.4, 31.2),
|
||||
"NG": (9.1, 8.7),
|
||||
"KE": (-0.02, 37.9),
|
||||
"GH": (7.9, -1.0),
|
||||
"CN": (35.9, 104.2),
|
||||
"PK": (30.4, 69.3),
|
||||
"BD": (23.7, 90.4),
|
||||
}
|
||||
|
||||
|
||||
def lookup_geo(ip: str) -> dict:
|
||||
"""Look up geolocation for an IP address.
|
||||
|
||||
Returns dict with country, country_code, city, latitude, longitude
|
||||
or empty dict if lookup fails. Falls back to country centroids
|
||||
when the free GeoLite2 DB lacks city/coordinate data.
|
||||
"""
|
||||
try:
|
||||
reader = get_geo_reader()
|
||||
result = reader.get(ip)
|
||||
if result is None:
|
||||
return {}
|
||||
country = result.get("country", {})
|
||||
city = result.get("city", {})
|
||||
location = result.get("location", {})
|
||||
country_code = country.get("iso_code")
|
||||
lat = location.get("latitude")
|
||||
lon = location.get("longitude")
|
||||
|
||||
# Free GeoLite2 City often lacks city/coordinates — use centroid fallback
|
||||
if lat is None or lon is None:
|
||||
centroid = _COUNTRY_CENTROIDS.get(country_code or "")
|
||||
if centroid:
|
||||
lat = lat if lat is not None else centroid[0]
|
||||
lon = lon if lon is not None else centroid[1]
|
||||
|
||||
return {
|
||||
"country": country.get("names", {}).get("en", country_code or ""),
|
||||
"country_code": country_code,
|
||||
"city": city.get("names", {}).get("en", ""),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ── IP resolution ───────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_client_ip(entry: dict) -> str:
|
||||
"""Extract the real client IP from a log entry.
|
||||
|
||||
Prefers the leftmost X-Forwarded-For entry (production behind proxy),
|
||||
falls back to remote_addr (dev).
|
||||
"""
|
||||
xff = entry.get("x_forwarded_for", "")
|
||||
if xff and xff != "-":
|
||||
# Leftmost entry is the original client
|
||||
return xff.split(",")[0].strip()
|
||||
return entry.get("remote_addr", "")
|
||||
|
||||
|
||||
# ── Log parsing ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_log_line(line: str) -> Optional[dict]:
|
||||
"""Parse a single JSON log line. Returns dict or None if unparseable."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
try:
|
||||
return json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_log_file(filepath: str) -> dict:
|
||||
"""Parse a single daily log file and return aggregated counts.
|
||||
|
||||
Returns a dict with:
|
||||
- unique_ips: set of unique client IP addresses
|
||||
- geo_counts: dict of (country_code, country, city, lat, lon) -> count
|
||||
"""
|
||||
unique_ips: set[str] = set()
|
||||
geo_counts: dict[tuple, int] = {}
|
||||
|
||||
with open(filepath, "r") as f:
|
||||
for line in f:
|
||||
entry = parse_log_line(line)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
ip = resolve_client_ip(entry)
|
||||
if not ip:
|
||||
continue
|
||||
|
||||
unique_ips.add(ip)
|
||||
|
||||
# Geo enrichment
|
||||
geo = lookup_geo(ip)
|
||||
if geo.get("country_code"):
|
||||
geo_key = (
|
||||
geo.get("country_code", ""),
|
||||
geo.get("country", ""),
|
||||
geo.get("city", ""),
|
||||
geo.get("latitude"),
|
||||
geo.get("longitude"),
|
||||
)
|
||||
geo_counts[geo_key] = geo_counts.get(geo_key, 0) + 1
|
||||
|
||||
return {
|
||||
"unique_ips": unique_ips,
|
||||
"geo_counts": geo_counts,
|
||||
}
|
||||
|
||||
|
||||
async def process_unparsed_logs(
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
"""Scan /logs/ for unprocessed daily files, parse them, store aggregates.
|
||||
|
||||
Only processes files strictly older than today (file must be complete).
|
||||
Advances the LogParseState marker after each successful file.
|
||||
|
||||
Returns a summary dict with files_processed and rows_inserted.
|
||||
"""
|
||||
logs_dir = settings.LOGS_DIR
|
||||
if not os.path.isdir(logs_dir):
|
||||
return {"files_processed": 0, "error": f"Logs directory not found: {logs_dir}"}
|
||||
|
||||
# Get current parse state
|
||||
async with async_session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(LogParseState).where(LogParseState.key == "default")
|
||||
)
|
||||
state = result.scalar_one_or_none()
|
||||
|
||||
if state is None:
|
||||
# Initialize with a far-past date so all files get processed
|
||||
state = LogParseState(key="default", last_parsed_date=date(2020, 1, 1))
|
||||
session.add(state)
|
||||
await session.commit()
|
||||
|
||||
last_parsed = state.last_parsed_date
|
||||
|
||||
# Discover daily log files
|
||||
import glob
|
||||
|
||||
pattern = os.path.join(logs_dir, "access-*.log")
|
||||
log_files = sorted(glob.glob(pattern))
|
||||
|
||||
files_processed = 0
|
||||
rows_inserted = 0
|
||||
today = date.today()
|
||||
|
||||
for filepath in log_files:
|
||||
# Extract date from filename
|
||||
basename = os.path.basename(filepath)
|
||||
try:
|
||||
date_str = basename.replace("access-", "").replace(".log", "")
|
||||
file_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Only process files strictly before today (file must be complete)
|
||||
if file_date >= today:
|
||||
continue
|
||||
|
||||
# Only process files after the last parsed date
|
||||
if file_date <= last_parsed:
|
||||
continue
|
||||
|
||||
# Parse this file
|
||||
try:
|
||||
stats = parse_log_file(filepath)
|
||||
except Exception as e:
|
||||
print(f" ERROR parsing {filepath}: {e}")
|
||||
continue
|
||||
|
||||
# Store aggregates in database
|
||||
async with async_session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
# Upsert daily stats
|
||||
result = await session.execute(
|
||||
select(VisitorStatDaily).where(VisitorStatDaily.date == file_date)
|
||||
)
|
||||
daily = result.scalar_one_or_none()
|
||||
if daily is None:
|
||||
daily = VisitorStatDaily(date=file_date)
|
||||
session.add(daily)
|
||||
|
||||
daily.unique_visitors = len(stats["unique_ips"])
|
||||
|
||||
# Geo stats
|
||||
for geo_key, count in stats["geo_counts"].items():
|
||||
geo_row = VisitorStatGeo(
|
||||
date=file_date,
|
||||
country_code=geo_key[0],
|
||||
country=geo_key[1],
|
||||
city=geo_key[2],
|
||||
latitude=geo_key[3],
|
||||
longitude=geo_key[4],
|
||||
visitor_count=count,
|
||||
)
|
||||
session.add(geo_row)
|
||||
rows_inserted += 1
|
||||
|
||||
# Advance parse state
|
||||
state = (
|
||||
await session.execute(
|
||||
select(LogParseState).where(LogParseState.key == "default")
|
||||
)
|
||||
).scalar_one()
|
||||
state.last_parsed_date = file_date
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
files_processed += 1
|
||||
|
||||
# Report progress via callback (for SSE streaming)
|
||||
if progress_callback:
|
||||
cb_result = progress_callback(
|
||||
file_date.isoformat(), files_processed, len(log_files)
|
||||
)
|
||||
# Support both sync and async callbacks
|
||||
if cb_result is not None and asyncio.iscoroutine(cb_result):
|
||||
await cb_result
|
||||
|
||||
return {"files_processed": files_processed, "rows_inserted": rows_inserted}
|
||||
+25
-1
@@ -1,4 +1,6 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -8,7 +10,7 @@ from app.config import settings
|
||||
from app.database import async_session, engine
|
||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
||||
from app.user_models import User
|
||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters
|
||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats
|
||||
|
||||
|
||||
def _migrate_add_missing_columns(sync_conn):
|
||||
@@ -117,6 +119,27 @@ async def lifespan(app: FastAPI):
|
||||
await session.commit()
|
||||
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
|
||||
|
||||
# Check GeoLite2 database
|
||||
geo_db = settings.GEOLITE2_DB_PATH
|
||||
if os.path.exists(geo_db):
|
||||
print(f" ✓ GeoLite2 database loaded from {geo_db}")
|
||||
else:
|
||||
print(f" WARNING: GeoLite2 database not found at {geo_db} — geo lookups will be disabled")
|
||||
|
||||
# Auto-parse any unprocessed logs on startup (background task)
|
||||
async def _startup_parse():
|
||||
try:
|
||||
from app.log_parser import process_unparsed_logs
|
||||
result = await process_unparsed_logs()
|
||||
if result.get("files_processed"):
|
||||
print(f" ✓ Parsed {result['files_processed']} log files ({result['rows_inserted']} geo rows)")
|
||||
else:
|
||||
print(" No unprocessed log files to parse")
|
||||
except Exception as e:
|
||||
print(f" ERROR during startup log parse: {e}")
|
||||
|
||||
asyncio.create_task(_startup_parse())
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -145,6 +168,7 @@ app.include_router(community.router, prefix="/api/community", tags=["community"]
|
||||
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
|
||||
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||
|
||||
# Dev-only admin router (disabled in production)
|
||||
if settings.ENV != "production":
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
@@ -11,6 +12,7 @@ from sqlalchemy import (
|
||||
Date,
|
||||
ForeignKey,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, relationship
|
||||
|
||||
@@ -159,3 +161,47 @@ class StorageBlob(Base):
|
||||
size = Column(Integer, nullable=False)
|
||||
data = Column(LargeBinary, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
# ── Visitor Stats ──────────────────────────────────────────────
|
||||
|
||||
class VisitorStatDaily(Base):
|
||||
"""Daily aggregated visitor statistics."""
|
||||
__tablename__ = "visitor_stats_daily"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
date = Column(Date, nullable=False, unique=True)
|
||||
unique_visitors = Column(Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_visitor_stats_date", "date"),
|
||||
)
|
||||
|
||||
|
||||
class VisitorStatGeo(Base):
|
||||
"""Geographic breakdown of visitors per day."""
|
||||
__tablename__ = "visitor_stats_geo"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
date = Column(Date, nullable=False)
|
||||
country = Column(String(100), nullable=True)
|
||||
country_code = Column(String(10), nullable=True)
|
||||
city = Column(String(200), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
visitor_count = Column(Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_visitor_stats_geo_date", "date"),
|
||||
Index("idx_visitor_stats_geo_country", "country_code"),
|
||||
)
|
||||
|
||||
|
||||
class LogParseState(Base):
|
||||
"""Tracks the last successfully parsed log date."""
|
||||
__tablename__ = "log_parse_state"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(20), unique=True, nullable=False, default="default")
|
||||
last_parsed_date = Column(Date, nullable=False)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
+25
-1
@@ -1,7 +1,7 @@
|
||||
from datetime import date as _date
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, computed_field, field_validator
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
|
||||
# ── Shared ───────────────────────────────────────────────
|
||||
@@ -284,3 +284,27 @@ class ShowUpdate(BaseModel):
|
||||
display_order: Optional[int] = None
|
||||
active: Optional[bool] = None
|
||||
schedules: Optional[list[ShowScheduleCreate]] = None
|
||||
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────────────
|
||||
|
||||
class StatsSummaryResponse(BaseModel):
|
||||
total_unique_visitors: int
|
||||
|
||||
|
||||
class TimeSeriesPoint(BaseModel):
|
||||
date: str
|
||||
unique_visitors: int
|
||||
|
||||
|
||||
class GeoStatResponse(BaseModel):
|
||||
country: str
|
||||
country_code: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
visitors: int
|
||||
|
||||
|
||||
class ParseResultResponse(BaseModel):
|
||||
files_processed: int
|
||||
rows_inserted: int
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# GeoLite2 City Database
|
||||
|
||||
The visitor stats dashboard uses MaxMind GeoLite2 for IP geolocation.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a free account at [MaxMind GeoLite2](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data)
|
||||
2. Download the **GeoLite2 City** database (`.mmdb` format, not CSV)
|
||||
3. Place the downloaded file here as `GeoLite2-City.mmdb`
|
||||
|
||||
The file is git-ignored (~60 MB, licensed). The Docker build copies it into the container at `/app/geo/GeoLite2-City.mmdb`.
|
||||
|
||||
## Without the database
|
||||
|
||||
The backend starts but geo lookups are disabled. The dashboard shows visitor counts without country/region breakdown.
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Inject test visitor log entries into the shared logs directory.
|
||||
|
||||
Writes JSON-formatted access log lines (matching the nginx kmtn_json format)
|
||||
for multiple past days so the log parser will pick them up — data is only
|
||||
aggregated a day in arrears, so all dates are strictly before today.
|
||||
|
||||
Usage:
|
||||
python inject_test_logs.py # writes to /logs (default)
|
||||
python inject_test_logs.py /tmp/logs # writes to custom path
|
||||
python inject_test_logs.py /logs 14 # writes 14 days of data
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import struct
|
||||
from datetime import date, timedelta, timezone
|
||||
|
||||
# Real, well-known public IPs that resolve in MaxMind GeoLite2 City.
|
||||
# These are actual infrastructure IPs (DNS, CDN, search, cloud) —
|
||||
# not random addresses, so they're far more likely to be in the DB.
|
||||
# Each tuple: (ip, expected_country, expected_city)
|
||||
TEST_IPS = [
|
||||
# United States
|
||||
("8.8.8.8", "US", "Mountain View"), # Google DNS
|
||||
("8.8.4.4", "US", "Mountain View"), # Google DNS
|
||||
("216.58.214.206", "US", "Mountain View"), # Google
|
||||
("142.250.80.46", "US", "Mountain View"), # Google
|
||||
("13.107.42.14", "US", "Redmond"), # Microsoft
|
||||
("20.190.128.1", "US", "Boydton"), # Microsoft Azure
|
||||
("208.67.222.222", "US", "San Francisco"), # OpenDNS
|
||||
("208.67.220.220", "US", "San Francisco"), # OpenDNS
|
||||
("151.101.1.69", "US", "San Francisco"), # Fastly
|
||||
("52.84.74.1", "US", "Seattle"), # AWS CloudFront
|
||||
("99.86.0.1", "US", "Seattle"), # AWS
|
||||
("54.239.28.85", "US", "Seattle"), # AWS
|
||||
("185.199.108.1", "US", "San Francisco"), # GitHub Pages
|
||||
# United Kingdom
|
||||
("212.58.244.3", "GB", "London"), # Virgin Media
|
||||
("178.173.57.1", "GB", "London"), # DigitalOcean
|
||||
("51.15.48.1", "GB", "London"), # OVH UK
|
||||
# France
|
||||
("51.140.137.15", "FR", "Paris"), # OVH Paris
|
||||
("193.70.150.1", "FR", "Paris"), # Free
|
||||
# Germany
|
||||
("217.160.0.1", "DE", "Frankfurt"), # Telekom
|
||||
("212.227.156.1", "DE", "Munich"), # Deutsche Telekom
|
||||
("217.69.70.1", "DE", "Frankfurt"), # Freenet
|
||||
# Japan
|
||||
("210.170.232.46", "JP", "Tokyo"), # KDDI
|
||||
("202.214.69.1", "JP", "Tokyo"), # NTT
|
||||
("210.188.206.1", "JP", "Tokyo"), # Softbank
|
||||
# Australia
|
||||
("1.35.128.1", "AU", "Sydney"), # Telstra
|
||||
("203.50.224.1", "AU", "Melbourne"), # iiNet
|
||||
# Brazil
|
||||
("177.72.120.1", "BR", "Sao Paulo"), # Vivo
|
||||
("200.149.130.1", "BR", "Rio de Janeiro"), # Telefonica Brasil
|
||||
("189.75.1.1", "BR", "Sao Paulo"), # Claro
|
||||
# Canada
|
||||
("76.69.248.1", "CA", "Toronto"), # Bell
|
||||
("24.199.12.1", "CA", "Vancouver"), # Shaw
|
||||
("99.248.1.1", "CA", "Montreal"), # Bell
|
||||
# India
|
||||
("117.239.1.1", "IN", "Mumbai"), # Reliance Jio
|
||||
("180.151.1.1", "IN", "New Delhi"), # Airtel
|
||||
("106.192.1.1", "IN", "Bangalore"), # BSNL
|
||||
# South Korea
|
||||
("1.235.198.1", "KR", "Seoul"), # KT
|
||||
("211.36.1.1", "KR", "Seoul"), # SK Telecom
|
||||
# Netherlands
|
||||
("185.30.1.1", "NL", "Amsterdam"), # KPN
|
||||
("94.23.1.1", "NL", "Amsterdam"), # XS4ALL
|
||||
# Mexico
|
||||
("189.203.1.1", "MX", "Mexico City"), # Telmex
|
||||
("200.44.1.1", "MX", "Guadalajara"), # Telcel
|
||||
# Italy
|
||||
("217.20.1.1", "IT", "Rome"), # Telecom Italia
|
||||
("93.45.1.1", "IT", "Milan"), # TIM
|
||||
# Spain
|
||||
("213.99.1.1", "ES", "Madrid"), # Telefonica Espana
|
||||
("88.0.1.1", "ES", "Madrid"), # Movistar
|
||||
# Sweden
|
||||
("213.248.1.1", "SE", "Stockholm"), # Telia
|
||||
# Poland
|
||||
("79.133.1.1", "PL", "Warsaw"), # Orange Polska
|
||||
# Argentina
|
||||
("190.15.1.1", "AR", "Buenos Aires"), # Telecom Argentina
|
||||
# South Africa
|
||||
("196.201.1.1", "ZA", "Johannesburg"), # Telkom SA
|
||||
# Egypt
|
||||
("213.53.1.1", "EG", "Cairo"), # Telecom Egypt
|
||||
# Turkey
|
||||
("37.146.1.1", "TR", "Istanbul"), # Turk Telekom
|
||||
# Russia
|
||||
("77.88.55.1", "RU", "Moscow"), # Yandex DNS
|
||||
("5.255.255.1", "RU", "Moscow"), # Rostelecom
|
||||
# Singapore
|
||||
("182.16.1.1", "SG", "Singapore"), # Singtel
|
||||
# New Zealand
|
||||
("210.55.1.1", "NZ", "Auckland"), # Telecom NZ
|
||||
]
|
||||
|
||||
|
||||
def _deterministic_count(seed: str) -> int:
|
||||
"""Return a pseudo-random visit count (1-8) for a given seed."""
|
||||
h = hashlib.md5(seed.encode()).digest()
|
||||
return struct.unpack("I", h[:4])[0] % 8 + 1
|
||||
|
||||
|
||||
def _deterministic_hour(seed: str) -> int:
|
||||
"""Return a pseudo-random hour (0-23) for a given seed."""
|
||||
h = hashlib.md5(seed.encode()).digest()
|
||||
return struct.unpack("I", h[:4])[0] % 24
|
||||
|
||||
|
||||
def write_test_logs(logs_dir: str, days: int = 14):
|
||||
"""Write test log entries for the given number of past days.
|
||||
|
||||
Skips today (parser only processes files strictly before today).
|
||||
Writes a realistic mix of visitors per day with varying counts.
|
||||
"""
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
|
||||
today = date.today()
|
||||
total_entries = 0
|
||||
files_written = 0
|
||||
|
||||
# Write for the last `days` days, skipping today
|
||||
for offset in range(1, days + 1):
|
||||
file_date = today - timedelta(days=offset)
|
||||
|
||||
filename = f"access-{file_date.isoformat()}.log"
|
||||
filepath = os.path.join(logs_dir, filename)
|
||||
|
||||
entries: list[str] = []
|
||||
for ip, country, city in TEST_IPS:
|
||||
seed = f"{file_date}-{ip}"
|
||||
count = _deterministic_count(seed)
|
||||
hour = _deterministic_hour(seed)
|
||||
|
||||
for j in range(count):
|
||||
# Deterministic minute spread (hash() is non-deterministic across runs)
|
||||
ip_hash = struct.unpack("I", hashlib.md5(ip.encode()).digest()[4:8])[0]
|
||||
minute = (j * 7 + ip_hash % 60) % 60
|
||||
entry = {
|
||||
"time": f"{file_date}T{hour}:{minute:02d}:00+00:00",
|
||||
"remote_addr": ip,
|
||||
"x_forwarded_for": "-",
|
||||
}
|
||||
entries.append(json.dumps(entry))
|
||||
|
||||
with open(filepath, "a") as f:
|
||||
for line in entries:
|
||||
f.write(line + "\n")
|
||||
|
||||
total_entries += len(entries)
|
||||
files_written += 1
|
||||
|
||||
print(f"Wrote {total_entries} log entries across {files_written} daily files ({len(TEST_IPS)} unique IPs)")
|
||||
print(f"Date range: {(today - timedelta(days=days)).isoformat()} to {(today - timedelta(days=1)).isoformat()}")
|
||||
print(f"Directory: {logs_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "/logs"
|
||||
num_days = int(sys.argv[2]) if len(sys.argv) > 2 else 14
|
||||
write_test_logs(target, days=num_days)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Probe the GeoLite2 database to find IPs that resolve to real countries.
|
||||
|
||||
Run inside the api container:
|
||||
docker compose exec api python /app/probe_geo.py
|
||||
|
||||
Or locally if the DB is present:
|
||||
python probe_geo.py /path/to/GeoLite2-City.mmdb
|
||||
|
||||
Tests a curated list of well-known public IPs and prints the ones that resolve.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import maxminddb
|
||||
|
||||
# Well-known public IPs from various countries
|
||||
CANDIDATE_IPS = [
|
||||
# United States
|
||||
("23.21.30.100", "US"),
|
||||
("72.14.198.234", "US"),
|
||||
("68.49.71.1", "US"),
|
||||
("208.67.222.222", "US"), # OpenDNS
|
||||
("74.125.200.100", "US"), # Google
|
||||
("151.101.1.69", "US"), # Fastly
|
||||
("142.250.80.46", "US"), # Google
|
||||
("216.58.214.206", "US"), # Google
|
||||
("172.217.164.110", "US"), # Google
|
||||
("13.107.42.14", "US"), # Microsoft
|
||||
("20.190.128.1", "US"), # Microsoft
|
||||
("52.84.74.1", "US"), # AWS CloudFront
|
||||
("99.86.0.1", "US"), # AWS
|
||||
# United Kingdom
|
||||
("81.2.69.142", "GB"),
|
||||
("212.58.224.10", "GB"),
|
||||
("51.140.137.15", "GB"),
|
||||
("178.173.57.1", "GB"),
|
||||
# France
|
||||
("51.140.137.15", "FR"),
|
||||
("193.70.150.1", "FR"),
|
||||
# Japan
|
||||
("210.170.232.46", "JP"),
|
||||
("133.244.200.1", "JP"),
|
||||
("202.214.69.1", "JP"), # NTT
|
||||
("210.188.206.1", "JP"), # Softbank
|
||||
# Germany
|
||||
("217.160.0.1", "DE"),
|
||||
("212.227.156.1", "DE"),
|
||||
("2a02:2e0::1", "DE"), # IPv6 - may not resolve
|
||||
("217.69.70.1", "DE"),
|
||||
# Australia
|
||||
("101.168.100.1", "AU"),
|
||||
("203.50.224.1", "AU"),
|
||||
("1.35.128.1", "AU"), # Telstra
|
||||
# Brazil
|
||||
("177.72.120.1", "BR"),
|
||||
("200.149.130.1", "BR"),
|
||||
("189.75.1.1", "BR"),
|
||||
# Canada
|
||||
("76.69.248.1", "CA"),
|
||||
("24.199.12.1", "CA"),
|
||||
("99.248.1.1", "CA"), # Bell
|
||||
# India
|
||||
("117.239.1.1", "IN"),
|
||||
("180.151.1.1", "IN"),
|
||||
("106.192.1.1", "IN"),
|
||||
# South Korea
|
||||
("1.235.198.1", "KR"),
|
||||
("211.36.1.1", "KR"),
|
||||
# Netherlands
|
||||
("185.30.1.1", "NL"),
|
||||
("94.23.1.1", "NL"),
|
||||
# Mexico
|
||||
("189.203.1.1", "MX"),
|
||||
("200.44.1.1", "MX"),
|
||||
# Italy
|
||||
("217.20.1.1", "IT"),
|
||||
("93.45.1.1", "IT"),
|
||||
# Spain
|
||||
("213.99.1.1", "ES"),
|
||||
("88.0.1.1", "ES"),
|
||||
# Sweden
|
||||
("213.248.1.1", "SE"),
|
||||
# Poland
|
||||
("79.133.1.1", "PL"),
|
||||
# Argentina
|
||||
("190.15.1.1", "AR"),
|
||||
# South Africa
|
||||
("196.201.1.1", "ZA"),
|
||||
# Egypt
|
||||
("213.53.1.1", "EG"),
|
||||
# Turkey
|
||||
("37.146.1.1", "TR"),
|
||||
# Russia
|
||||
("77.88.55.1", "RU"), # Yandex DNS
|
||||
("5.255.255.1", "RU"),
|
||||
]
|
||||
|
||||
|
||||
def probe(db_path: str):
|
||||
"""Test each candidate IP against the GeoLite2 database."""
|
||||
reader = maxminddb.open_database(db_path)
|
||||
results = []
|
||||
|
||||
# Deduplicate IPs but keep all expected countries
|
||||
seen_ips = set()
|
||||
for ip, expected_country in CANDIDATE_IPS:
|
||||
if ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
|
||||
try:
|
||||
result = reader.lookup(ip)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
country = result.get("country", {})
|
||||
city = result.get("city", {})
|
||||
location = result.get("location", {})
|
||||
|
||||
country_code = country.get("iso_code", "")
|
||||
country_name = country.get("names", {}).get("en", country_code)
|
||||
city_name = city.get("names", {}).get("en", "")
|
||||
lat = location.get("latitude")
|
||||
lon = location.get("longitude")
|
||||
|
||||
results.append({
|
||||
"ip": ip,
|
||||
"country": country_name,
|
||||
"country_code": country_code,
|
||||
"city": city_name,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" ERROR looking up {ip}: {e}")
|
||||
|
||||
reader.close()
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
# Determine DB path
|
||||
db_path = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
# Explicit path provided
|
||||
db_path = sys.argv[1]
|
||||
else:
|
||||
# Try common locations
|
||||
candidates = [
|
||||
"/app/geo/GeoLite2-City.mmdb", # container path
|
||||
os.path.join(os.path.dirname(__file__), "geo", "GeoLite2-City.mmdb"), # local
|
||||
]
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
db_path = p
|
||||
break
|
||||
|
||||
if db_path is None:
|
||||
print("GeoLite2-City.mmdb not found.")
|
||||
print("Usage:")
|
||||
print(" docker compose exec api python /app/probe_geo.py")
|
||||
print(" python probe_geo.py /path/to/GeoLite2-City.mmdb")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Probing GeoLite2 at: {db_path}")
|
||||
results = probe(db_path)
|
||||
|
||||
if not results:
|
||||
print("No IPs resolved! The database might be empty or outdated.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nResolved {len(results)} IPs:\n")
|
||||
|
||||
# Group by country
|
||||
by_country = {}
|
||||
for r in results:
|
||||
by_country.setdefault(r["country_code"], []).append(r)
|
||||
|
||||
for cc in sorted(by_country.keys()):
|
||||
entries = by_country[cc]
|
||||
print(f" {cc} ({entries[0]['country']}):")
|
||||
for e in entries:
|
||||
print(f" {e['ip']:20s} -> {e['city'] or 'N/A':20s} ({e['latitude']}, {e['longitude']})")
|
||||
|
||||
# Output as Python tuples for inject_test_logs.py
|
||||
print(f"\n\nTuples for inject_test_logs.py TEST_IPS:")
|
||||
print("TEST_IPS = [")
|
||||
for r in results:
|
||||
print(f' ("{r["ip"]}", "{r["country"]}", "{r["city"]}"),')
|
||||
print("]")
|
||||
|
||||
# Also output JSON for convenience
|
||||
print(f"\nJSON output:")
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,3 +11,4 @@ pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
httpx==0.27.2
|
||||
maxminddb==2.6.2
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
KMTN_ENV: production
|
||||
# Override with your actual production domain(s)
|
||||
KMTN_CORS_ORIGINS: '["https://yourdomain.com"]'
|
||||
volumes:
|
||||
- logs:/logs
|
||||
# No port mapping — traffic arrives via reverse proxy / load balancer
|
||||
|
||||
frontend:
|
||||
@@ -18,4 +20,6 @@ services:
|
||||
API_UPSTREAM: https://api.yourdomain.com
|
||||
# Browser-facing API URL. Empty = proxy mode. Set to direct URL if ingress breaks proxy chain.
|
||||
API_PUBLIC_URL: ""
|
||||
volumes:
|
||||
- logs:/logs
|
||||
# No port mapping — traffic arrives via reverse proxy / load balancer
|
||||
|
||||
+41
-40
@@ -1,51 +1,52 @@
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
# NOTE: Change POSTGRES_PASSWORD in production. Consider using Docker secrets or
|
||||
# a managed PostgreSQL service instead of local credentials.
|
||||
environment:
|
||||
POSTGRES_DB: kmountain
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgsocket:/var/run/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# NOTE: Update KMTN_CORS_ORIGINS in production to your actual domain(s).
|
||||
# Set KMTN_DATABASE_URL to your managed PostgreSQL connection if not using the local db service.
|
||||
api:
|
||||
image: truenas.local:35000/kmtnflower-api:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||
KMTN_CORS_ORIGINS: '["http://truenas.local:4200", "http://truenas.local"]'
|
||||
KMTN_ADMIN_USERNAME: admin
|
||||
KMTN_ADMIN_PASSWORD: 123
|
||||
volumes:
|
||||
- pgsocket:/var/run/postgresql
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: truenas.local:35000/kmtnflower:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_UPSTREAM: http://api:8000
|
||||
API_PUBLIC_URL: "http://truenas.local:8000"
|
||||
KMTN_ADMIN_PASSWORD: 123
|
||||
KMTN_ADMIN_USERNAME: admin
|
||||
KMTN_CORS_ORIGINS: '["http://truenas.local:4200", "http://truenas.local"]'
|
||||
KMTN_DATABASE_URL: >-
|
||||
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||
image: truenas.local:35000/kmtnflower-api:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "4200:80"
|
||||
- '8000:8000'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgsocket:/var/run/postgresql
|
||||
- logs:/logs
|
||||
db:
|
||||
environment:
|
||||
POSTGRES_DB: kmountain
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
healthcheck:
|
||||
interval: 5s
|
||||
retries: 5
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -U postgres
|
||||
timeout: 5s
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/WestTek_Mainframe/vm_data/kmtndata:/var/lib/postgresql/data
|
||||
- pgsocket:/var/run/postgresql
|
||||
frontend:
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
environment:
|
||||
API_PUBLIC_URL: http://truenas.local:8000
|
||||
API_UPSTREAM: http://api:8000
|
||||
image: truenas.local:35000/kmtnflower:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- '4200:80'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- logs:/logs
|
||||
volumes:
|
||||
pgdata:
|
||||
pgsocket:
|
||||
logs:
|
||||
|
||||
@@ -28,6 +28,8 @@ services:
|
||||
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- logs:/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -42,8 +44,11 @@ services:
|
||||
API_PUBLIC_URL: ""
|
||||
ports:
|
||||
- "4200:80"
|
||||
volumes:
|
||||
- logs:/logs
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
logs:
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
# Map ISO timestamp to date-only string for daily log rotation
|
||||
map $time_iso8601 $log_date {
|
||||
~^(?<date>\d{4}-\d{2}-\d{2}) $date;
|
||||
default "unknown";
|
||||
}
|
||||
|
||||
log_format kmtn_json escape=json
|
||||
'{'
|
||||
'"time":"$time_iso8601",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"x_forwarded_for":"$http_x_forwarded_for"'
|
||||
'}';
|
||||
|
||||
access_log /logs/access-$log_date.log kmtn_json;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
Generated
+103
-2
@@ -8,12 +8,18 @@
|
||||
"name": "radio-station",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"chart.js": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-charts": "^10.0.0",
|
||||
"ngx-leaflet": "^0.0.16",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "^0.16.2"
|
||||
@@ -415,6 +421,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.14.tgz",
|
||||
"integrity": "sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz",
|
||||
@@ -1821,6 +1843,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||
@@ -3662,6 +3690,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz",
|
||||
@@ -4063,6 +4106,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -4557,6 +4612,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -5379,6 +5444,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
@@ -5908,6 +5979,38 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ng2-charts": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-10.0.0.tgz",
|
||||
"integrity": "sha512-mdL75XJrk/0s0YO2ySPQpAHPja85ECDEGNWFlcElJiy/bYliTNGEpeCtctAqZuozTff/E2CwGjyfPFM1ScP2og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-toolkit": "^1.39.7",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cdk": ">=21.0.0",
|
||||
"@angular/common": ">=21.0.0",
|
||||
"@angular/core": ">=21.0.0",
|
||||
"@angular/platform-browser": ">=21.0.0",
|
||||
"chart.js": "^3.4.0 || ^4.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-leaflet": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/ngx-leaflet/-/ngx-leaflet-0.0.16.tgz",
|
||||
"integrity": "sha512-4cm06T9ZrD3i0VI+/fghrfx/Oe3UkOcYsDYKLFgh+XkoBvCt3oXIgdrABjoXIgMLcKtzS/oDHcVgRkDy/x5/MA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "*",
|
||||
"@angular/core": "*",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"leaflet": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
@@ -6276,7 +6379,6 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
@@ -6330,7 +6432,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-charts": "^10.0.0",
|
||||
"ngx-leaflet": "^0.0.16",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "^0.16.2"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<div class="stats-dashboard">
|
||||
<!-- Time range selector -->
|
||||
<div class="stats-controls">
|
||||
<div class="preset-buttons">
|
||||
@for (preset of presets; track preset.value) {
|
||||
<button
|
||||
class="preset-btn"
|
||||
[class.active]="selectedPreset === preset.value"
|
||||
(click)="selectedPreset = preset.value; onPresetChange()"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedPreset === 'custom') {
|
||||
<div class="custom-dates">
|
||||
<label>
|
||||
From
|
||||
<input type="date" [(ngModel)]="customStart" (change)="onPresetChange()" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input type="date" [(ngModel)]="customEnd" (change)="onPresetChange()" />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-parse" (click)="triggerParse()" [disabled]="parsing">
|
||||
{{ parsing ? 'Parsing...' : 'Parse New Logs' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (parseProgress) {
|
||||
<div class="parse-progress">{{ parseProgress }}</div>
|
||||
}
|
||||
|
||||
@if (error) {
|
||||
<div class="error-message">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading-message">Loading stats...</div>
|
||||
} @else {
|
||||
<!-- Summary card -->
|
||||
<div class="stats-summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ summary?.total_unique_visitors ?? 0 }}</div>
|
||||
<div class="stat-label">Unique Visitors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ geoStats.length }}</div>
|
||||
<div class="stat-label">Countries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- World Map -->
|
||||
<div class="map-section">
|
||||
<h3>Visitors by Location</h3>
|
||||
<div class="map-container">
|
||||
<div #mapContainer class="map-leaflet"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitors Over Time Chart -->
|
||||
<div class="chart-section">
|
||||
<h3>Visitors Over Time</h3>
|
||||
<div class="chart-container">
|
||||
<canvas
|
||||
baseChart
|
||||
[data]="chartData"
|
||||
[options]="chartOptions"
|
||||
[type]="'line'"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geo data table -->
|
||||
<div class="geo-table-section">
|
||||
<h3>Visitors by Country</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>Code</th>
|
||||
<th>Visitors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (geo of geoStats; track geo.country_code) {
|
||||
<tr>
|
||||
<td>{{ geo.country }}</td>
|
||||
<td>{{ geo.country_code }}</td>
|
||||
<td>{{ geo.visitors }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="empty-row">No geo data available yet</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,197 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.stats-dashboard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────
|
||||
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-blue;
|
||||
border-color: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-dates {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-medium;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
input[type="date"] {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-parse {
|
||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.parse-progress {
|
||||
background: rgba($info-blue, 0.1);
|
||||
color: $info-blue;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(#d32f2f, 0.1);
|
||||
color: #d32f2f;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
// ── Summary cards ─────────────────────────────────────────
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-family: $font-heading;
|
||||
font-size: 2rem;
|
||||
color: $primary-blue;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────
|
||||
|
||||
.map-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 400px;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.map-leaflet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chart ─────────────────────────────────────────────────
|
||||
|
||||
.chart-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ── Geo table ─────────────────────────────────────────────
|
||||
|
||||
.geo-table-section {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Responsive ────────────────────────────────────────────
|
||||
|
||||
@include responsive(md) {
|
||||
.stats-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-parse {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { ChangeDetectorRef, Component, inject, AfterViewChecked, OnDestroy, OnInit, ViewChild, ElementRef, NgZone } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { BaseChartDirective } from 'ng2-charts';
|
||||
import L from 'leaflet';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { StatsService } from '../services/stats.service';
|
||||
import {
|
||||
StatsSummary,
|
||||
TimeSeriesPoint,
|
||||
GeoStat,
|
||||
TimePreset,
|
||||
} from '../interfaces/visitor-stat';
|
||||
|
||||
import { getAppConfig } from '../services/app-config.service';
|
||||
|
||||
// Register Chart.js components once
|
||||
Chart.register(...registerables);
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-stats-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, BaseChartDirective],
|
||||
templateUrl: './admin-stats-dashboard.component.html',
|
||||
styleUrl: './admin-stats-dashboard.component.scss',
|
||||
})
|
||||
export class AdminStatsDashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
private readonly statsService: StatsService = inject(StatsService);
|
||||
private readonly cd = inject(ChangeDetectorRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@ViewChild('mapContainer', { static: false }) mapContainerRef!: ElementRef;
|
||||
|
||||
// ── Time range ──────────────────────────────────────────
|
||||
readonly presets = [
|
||||
{ label: 'Yesterday', value: 'yesterday' as TimePreset },
|
||||
{ label: '7 Days', value: '7d' as TimePreset },
|
||||
{ label: '30 Days', value: '30d' as TimePreset },
|
||||
{ label: '90 Days', value: '90d' as TimePreset },
|
||||
{ label: 'Year', value: 'year' as TimePreset },
|
||||
{ label: 'Custom', value: 'custom' as TimePreset },
|
||||
];
|
||||
selectedPreset: TimePreset = '30d';
|
||||
customStart = '';
|
||||
customEnd = '';
|
||||
|
||||
// ── Data ────────────────────────────────────────────────
|
||||
summary: StatsSummary | null = null;
|
||||
timeSeries: TimeSeriesPoint[] = [];
|
||||
geoStats: GeoStat[] = [];
|
||||
loading = true;
|
||||
error: string | null = null;
|
||||
parsing = false;
|
||||
parseProgress = '';
|
||||
|
||||
// ── Chart ───────────────────────────────────────────────
|
||||
chartData: { labels: string[]; datasets: any[] } = { labels: [], datasets: [] };
|
||||
chartOptions: any = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' as const },
|
||||
},
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Date' } },
|
||||
y: { title: { display: true, text: 'Visitors' }, beginAtZero: true },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Leaflet ─────────────────────────────────────────────
|
||||
private map: L.Map | null = null;
|
||||
private markersLayer: L.LayerGroup | null = null;
|
||||
private mapInitialized = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
// The map container is inside an @else block gated by loading=true.
|
||||
// ngAfterViewInit fires before loading completes, so the element
|
||||
// doesn't exist yet. ngAfterViewChecked detects when it appears.
|
||||
if (this.mapInitialized) return;
|
||||
if (!this.mapContainerRef) return;
|
||||
// Verify the native element actually exists in the DOM (not just that
|
||||
// the query ref was created — the @if block must have rendered too)
|
||||
if (!this.mapContainerRef.nativeElement?.isConnected) return;
|
||||
|
||||
this.mapInitialized = true;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
// Defer two frames so the browser has laid out the container
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => this._initMap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _initMap(): void {
|
||||
this.map = L.map(this.mapContainerRef.nativeElement, {
|
||||
center: [20, 0],
|
||||
zoom: 2,
|
||||
zoomControl: true,
|
||||
worldCopyJump: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map);
|
||||
|
||||
// Ensure Leaflet uses the correct container size
|
||||
this.map.invalidateSize();
|
||||
|
||||
// Data may have loaded in ngOnInit before the view was ready
|
||||
this._updateMapMarkers();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date helpers ────────────────────────────────────────
|
||||
|
||||
private _localDateStr(d: Date): string {
|
||||
// Format as YYYY-MM-DD using local time (no UTC shift)
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
getDates(): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
switch (this.selectedPreset) {
|
||||
case 'yesterday':
|
||||
return { start: this._localDateStr(yesterday), end: this._localDateStr(yesterday) };
|
||||
case '7d': {
|
||||
const start = new Date(yesterday);
|
||||
start.setDate(yesterday.getDate() - 6);
|
||||
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
||||
}
|
||||
case '30d': {
|
||||
const start = new Date(yesterday);
|
||||
start.setDate(yesterday.getDate() - 29);
|
||||
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
||||
}
|
||||
case '90d': {
|
||||
const start = new Date(yesterday);
|
||||
start.setDate(yesterday.getDate() - 89);
|
||||
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
||||
}
|
||||
case 'year': {
|
||||
const start = new Date(yesterday);
|
||||
start.setDate(yesterday.getDate() - 364);
|
||||
return { start: this._localDateStr(start), end: this._localDateStr(yesterday) };
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
start: this.customStart || this._localDateStr(this._daysAgo(30)),
|
||||
end: this.customEnd || this._localDateStr(yesterday),
|
||||
};
|
||||
default:
|
||||
return { start: this._localDateStr(yesterday), end: this._localDateStr(yesterday) };
|
||||
}
|
||||
}
|
||||
|
||||
private _daysAgo(n: number): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
onPresetChange(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
// ── Data loading ────────────────────────────────────────
|
||||
|
||||
async loadAll(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Destroy existing map so it re-initializes on the new DOM element
|
||||
// created when loading flips back to false.
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
this.mapInitialized = false;
|
||||
const { start, end } = this.getDates();
|
||||
try {
|
||||
const [summary, timeSeries, geoStats] = await Promise.all([
|
||||
firstValueFrom(this.statsService.getSummary(start, end)),
|
||||
firstValueFrom(this.statsService.getTimeSeries(start, end)),
|
||||
firstValueFrom(this.statsService.getGeoStats(start, end)),
|
||||
]);
|
||||
|
||||
// Angular 17+ uses fetch-based HTTP whose promise resolutions run
|
||||
// outside the zone. Re-enter the zone and markForCheck() to force
|
||||
// change detection through the OnPush parent chain.
|
||||
this.ngZone.run(() => {
|
||||
this.summary = summary;
|
||||
this.timeSeries = timeSeries;
|
||||
this.geoStats = geoStats;
|
||||
|
||||
this._updateChart();
|
||||
this._updateMapMarkers();
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
this.ngZone.run(() => {
|
||||
this.error = 'Failed to load stats data. Please check your connection and try again.';
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
} finally {
|
||||
this.ngZone.run(() => {
|
||||
this.loading = false;
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async triggerParse(): Promise<void> {
|
||||
this.parsing = true;
|
||||
this.parseProgress = '';
|
||||
|
||||
const apiUrl = `${getAppConfig().apiBaseUrl}/api/stats/parse`;
|
||||
// Append auth token as query param for SSE (EventSource can't send headers)
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = localStorage.getItem('kmtn_access_token');
|
||||
} catch {
|
||||
// localStorage may be blocked
|
||||
}
|
||||
const url = token ? `${apiUrl}?token=${encodeURIComponent(token)}` : apiUrl;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.addEventListener('progress', (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.ngZone.run(() => {
|
||||
this.parseProgress = `Parsing ${data.file}... ${data.current}/${data.total}`;
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('done', (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data);
|
||||
es.close();
|
||||
this.ngZone.run(() => {
|
||||
this.parseProgress = `Done — ${data.files_processed} files, ${data.rows_inserted} geo rows`;
|
||||
this.parsing = false;
|
||||
this.loadAll().then(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
es.onerror = (e: Event) => {
|
||||
console.error('Parse SSE error:', e);
|
||||
es.close();
|
||||
this.ngZone.run(() => {
|
||||
this.parseProgress = 'Parse failed';
|
||||
this.parsing = false;
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Chart ───────────────────────────────────────────────
|
||||
|
||||
private _updateChart(): void {
|
||||
this.chartData = {
|
||||
labels: this.timeSeries.map((p) => this._formatDate(p.date)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Unique Visitors',
|
||||
data: this.timeSeries.map((p) => p.unique_visitors),
|
||||
borderColor: '#1a3a5c',
|
||||
backgroundColor: 'rgba(26, 58, 92, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private _formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
// ── Map ─────────────────────────────────────────────────
|
||||
|
||||
private _updateMapMarkers(): void {
|
||||
if (!this.map) return;
|
||||
|
||||
// Remove old markers
|
||||
if (this.markersLayer) {
|
||||
this.map.removeLayer(this.markersLayer);
|
||||
}
|
||||
|
||||
this.markersLayer = L.layerGroup();
|
||||
|
||||
const geoWithCoords = this.geoStats.filter((g) => g.latitude && g.longitude);
|
||||
|
||||
if (geoWithCoords.length === 0) {
|
||||
this.markersLayer.addTo(this.map);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxVisitors = Math.max(...geoWithCoords.map((g) => g.visitors), 1);
|
||||
|
||||
geoWithCoords.forEach((geo) => {
|
||||
const radius = Math.max(5, Math.min(30, 5 + (geo.visitors / maxVisitors) * 25));
|
||||
const marker = L.circleMarker([geo.latitude!, geo.longitude!], {
|
||||
radius,
|
||||
fillColor: '#e87a2e',
|
||||
color: '#c85a1e',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
});
|
||||
marker.bindTooltip(`${geo.country}: ${geo.visitors} visitors`, {
|
||||
sticky: true,
|
||||
direction: 'top',
|
||||
});
|
||||
this.markersLayer?.addLayer(marker);
|
||||
});
|
||||
|
||||
this.markersLayer.addTo(this.map);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@
|
||||
[class.active]="activeTab() === 'underwriters'"
|
||||
(click)="activeTab.set('underwriters')"
|
||||
>Underwriters</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
[class.active]="activeTab() === 'stats'"
|
||||
(click)="activeTab.set('stats')"
|
||||
>Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Shows Tab -->
|
||||
@@ -360,6 +365,13 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Stats Tab -->
|
||||
@if (activeTab() === 'stats') {
|
||||
<div class="tab-content">
|
||||
<app-admin-stats-dashboard />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form modals -->
|
||||
|
||||
@@ -24,8 +24,9 @@ import { AdminHistoryFormComponent } from './admin-history-form.component';
|
||||
import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters';
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
@@ -41,6 +42,7 @@ type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community'
|
||||
AdminTeamFormComponent,
|
||||
AdminCommunityFormComponent,
|
||||
AdminUnderwriterFormComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
],
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrl: './admin.component.scss',
|
||||
|
||||
+9
-5
@@ -1,5 +1,10 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
// Bar heights (keep in sync so player sits flush below navbar)
|
||||
$navbar-height-mobile: 64px;
|
||||
$navbar-height-desktop: 52px;
|
||||
$player-height: 48px;
|
||||
|
||||
app-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -8,25 +13,24 @@ app-navbar {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
// Mobile: navbar is taller (stacked brand text), so give the player room
|
||||
app-media-player {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
top: $navbar-height-mobile;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 199;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 108px;
|
||||
padding-top: calc(#{$navbar-height-mobile} + #{$player-height});
|
||||
}
|
||||
|
||||
@media (min-width: #{$bp-md}) {
|
||||
app-media-player {
|
||||
top: 52px;
|
||||
top: $navbar-height-desktop;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 96px;
|
||||
padding-top: calc(#{$navbar-height-desktop} + #{$player-height});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@if (config().play_store_url || config().app_store_embed_html) {
|
||||
<div class="hero-download">
|
||||
<span class="download-label">Download our app</span>
|
||||
<div class="download-buttons">
|
||||
@if (config().play_store_url) {
|
||||
<a [href]="config().play_store_url" target="_blank" rel="noopener noreferrer" class="store-link">
|
||||
@if (config().play_store_icon_url) {
|
||||
<img [src]="resolveImageUrl(config().play_store_icon_url)" alt="Get it on Google Play" class="store-badge">
|
||||
} @else {
|
||||
<span class="store-text">Google Play</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
@if (config().app_store_embed_html) {
|
||||
<div class="appstore-embed" [innerHTML]="config().app_store_embed_html"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
// ── Download CTAs ───────────────────────────────────────────
|
||||
|
||||
.hero-download {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin: $spacing-lg 0 $spacing-md;
|
||||
}
|
||||
|
||||
.download-label {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.download-buttons {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// Both store badges are constrained to the same size (160x52)
|
||||
.store-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.store-badge {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: $radius-sm;
|
||||
padding: 4px;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.store-text {
|
||||
color: var(--color-white);
|
||||
text-decoration: underline;
|
||||
font-size: 0.95rem;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple's embed HTML contains its own <a>/<img> — constrain it
|
||||
.appstore-embed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
// Apple's embeds typically wrap an <a> around an <img>
|
||||
> a,
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// If the embed is an iframe (Apple's new style), constrain that too
|
||||
iframe {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { resolveImageUrl } from '../services/app-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-cta',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './download-cta.component.html',
|
||||
styleUrl: './download-cta.component.scss',
|
||||
})
|
||||
export class DownloadCtaComponent {
|
||||
readonly config = inject(StationConfigService).config;
|
||||
|
||||
resolveImageUrl(url: string): string {
|
||||
return resolveImageUrl(url);
|
||||
}
|
||||
}
|
||||
@@ -30,25 +30,7 @@
|
||||
}
|
||||
<a routerLink="/schedule" class="btn btn-hero-secondary">View Schedule</a>
|
||||
</div>
|
||||
@if (config().play_store_url || config().app_store_embed_html) {
|
||||
<div class="hero-download">
|
||||
<span class="download-label">Download our app</span>
|
||||
<div class="download-buttons">
|
||||
@if (config().play_store_url) {
|
||||
<a [href]="config().play_store_url" target="_blank" rel="noopener noreferrer" class="store-link">
|
||||
@if (config().play_store_icon_url) {
|
||||
<img [src]="resolveImageUrl(config().play_store_icon_url)" alt="Get it on Google Play" class="store-badge">
|
||||
} @else {
|
||||
<span class="store-text">Google Play</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
@if (config().app_store_embed_html) {
|
||||
<div class="appstore-embed" [innerHTML]="config().app_store_embed_html"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<app-download-cta></app-download-cta>
|
||||
<div class="hero-listeners">
|
||||
<span class="listeners-dot"></span>
|
||||
<span class="listeners-text">Live on-air now — Stream free worldwide</span>
|
||||
@@ -60,69 +42,7 @@
|
||||
<div class="hero-divider" aria-hidden="true">
|
||||
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
|
||||
</div>
|
||||
|
||||
<!-- Underwriter takeover overlay -->
|
||||
@if (selectedUnderwriter()) {
|
||||
@let uw = selectedUnderwriter()!;
|
||||
<div class="takeover-overlay" (click)="closeTakeover()">
|
||||
<div class="takeover-dialog" (click)="$event.stopPropagation()">
|
||||
<button type="button" class="takeover-close" (click)="closeTakeover()" aria-label="Close">×</button>
|
||||
<div class="takeover-content">
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="takeover-logo">
|
||||
}
|
||||
<h2 class="takeover-name">{{ uw.name }}</h2>
|
||||
<p class="takeover-description">{{ uw.description }}</p>
|
||||
@if (uw.website_url) {
|
||||
<a [href]="uw.website_url" target="_blank" rel="noopener noreferrer" class="btn btn-hero-primary takeover-visit">Visit Website</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Underwriter carousel — below hero, full-width row -->
|
||||
@if ((underwriters()).length >= 2) {
|
||||
<div class="underwriters-section">
|
||||
<p class="underwriters-label">{{ config().callsign }} is brought to you with the generosity of our underwriters.</p>
|
||||
<div
|
||||
class="underwriters-carousel"
|
||||
(mouseenter)="pauseCarousel()"
|
||||
(mouseleave)="resumeCarousel()"
|
||||
>
|
||||
<div class="carousel-track" [class.paused]="carouselPaused">
|
||||
<!-- First set -->
|
||||
@for (uw of underwriters(); track uw.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-slide"
|
||||
(click)="openTakeover(uw)"
|
||||
[attr.aria-label]="'View ' + uw.name"
|
||||
>
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
|
||||
} @else {
|
||||
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<!-- Duplicate set for seamless loop -->
|
||||
@for (uw of underwriters(); track uw.id + '-dup') {
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-slide"
|
||||
(click)="openTakeover(uw)"
|
||||
[attr.aria-label]="'View ' + uw.name"
|
||||
>
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
|
||||
} @else {
|
||||
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<app-underwriter-carousel></app-underwriter-carousel>
|
||||
|
||||
@@ -169,305 +169,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Underwriter Horizontal Carousel ─────────────────────────
|
||||
|
||||
.underwriters-section {
|
||||
background: $neutral-cream;
|
||||
border-top: 2px solid $neutral-light;
|
||||
border-bottom: 2px solid $neutral-light;
|
||||
}
|
||||
|
||||
.underwriters-label {
|
||||
text-align: center;
|
||||
padding: $spacing-lg $spacing-lg $spacing-md;
|
||||
margin: 0;
|
||||
font-family: $font-heading;
|
||||
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.underwriters-carousel {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: clamp($spacing-xl, 4vh, $spacing-3xl) 0;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: clamp($spacing-xl, 4vw, $spacing-3xl);
|
||||
width: max-content;
|
||||
animation: scroll-carousel 30s linear infinite;
|
||||
|
||||
&.paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-carousel {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
.carousel-logo,
|
||||
.carousel-badge {
|
||||
outline: 2px solid $accent-orange;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-logo {
|
||||
display: block;
|
||||
width: clamp(180px, 12vw, 300px);
|
||||
height: clamp(100px, 6.5vw, 170px);
|
||||
object-fit: contain;
|
||||
border-radius: $radius-md;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: clamp(10px, 1.2vw, 18px);
|
||||
box-shadow: $shadow-md;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
.carousel-slide:hover & {
|
||||
transform: scale(1.08);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: clamp(100px, 6.5vw, 170px);
|
||||
height: clamp(100px, 6.5vw, 170px);
|
||||
border-radius: $radius-md;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid $neutral-light;
|
||||
box-shadow: $shadow-md;
|
||||
font-family: $font-heading;
|
||||
font-size: clamp(1.8rem, 3vw, 3rem);
|
||||
font-weight: 700;
|
||||
color: $primary-blue;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
.carousel-slide:hover & {
|
||||
transform: scale(1.08);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Takeover Overlay ────────────────────────────────────────
|
||||
|
||||
.takeover-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $z-modal;
|
||||
background: rgba($primary-blue-dark, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-md;
|
||||
@include fade-in;
|
||||
}
|
||||
|
||||
.takeover-dialog {
|
||||
position: relative;
|
||||
background: $neutral-white;
|
||||
border-radius: $radius-xl;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-shadow: $shadow-xl;
|
||||
animation: takeover-slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes takeover-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.97);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-close {
|
||||
position: absolute;
|
||||
top: $spacing-xs;
|
||||
right: $spacing-xs;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-xs;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $neutral-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-content {
|
||||
text-align: left;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.takeover-logo {
|
||||
display: block;
|
||||
width: clamp(150px, 30vw, 280px);
|
||||
height: clamp(150px, 30vw, 280px);
|
||||
margin: 0 auto $spacing-sm;
|
||||
border-radius: $radius-lg;
|
||||
object-fit: contain;
|
||||
background: $neutral-cream;
|
||||
border: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.takeover-name {
|
||||
font-family: $font-heading;
|
||||
font-size: 1.3rem;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 0.35rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.takeover-description {
|
||||
font-size: 0.875rem;
|
||||
color: $neutral-dark;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 $spacing-md 0;
|
||||
text-align: left;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $neutral-cream;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $neutral-light;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-visit {
|
||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
||||
font-size: 1rem;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
letter-spacing: 0.03em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// ── Download CTAs ───────────────────────────────────────────
|
||||
|
||||
.hero-download {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin: $spacing-lg 0 $spacing-md;
|
||||
}
|
||||
|
||||
.download-label {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.download-buttons {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// Both store badges are constrained to the same size (160x52)
|
||||
.store-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.store-badge {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: $radius-sm;
|
||||
padding: 4px;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.store-text {
|
||||
color: var(--color-white);
|
||||
text-decoration: underline;
|
||||
font-size: 0.95rem;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple's embed HTML contains its own <a>/<img> — constrain it
|
||||
.appstore-embed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
// Apple's embeds typically wrap an <a> around an <img>
|
||||
> a,
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// If the embed is an iframe (Apple's new style), constrain that too
|
||||
iframe {
|
||||
width: 160px;
|
||||
height: 52px;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Responsive ──────────────────────────────────────────────
|
||||
|
||||
@media (max-width: #{$bp-md - 1px}) {
|
||||
@@ -492,8 +193,6 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Carousel clamp() handles responsive sizing automatically
|
||||
}
|
||||
|
||||
@media (max-width: #{$bp-sm - 1px}) {
|
||||
|
||||
@@ -1,62 +1,23 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { UnderwriterService } from '../services/underwriter.service';
|
||||
import { Underwriter } from '../interfaces/underwriter';
|
||||
import { UnderwriterCarouselComponent } from '../underwriter-carousel/underwriter-carousel.component';
|
||||
import { DownloadCtaComponent } from '../download-cta/download-cta.component';
|
||||
import { resolveImageUrl } from '../services/app-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hero',
|
||||
standalone: true,
|
||||
imports: [RouterLink, CommonModule],
|
||||
imports: [RouterLink, CommonModule, UnderwriterCarouselComponent, DownloadCtaComponent],
|
||||
templateUrl: './hero.component.html',
|
||||
styleUrl: './hero.component.scss',
|
||||
})
|
||||
export class HeroComponent implements OnInit {
|
||||
export class HeroComponent {
|
||||
readonly config = inject(StationConfigService).config;
|
||||
private underwriterService = inject(UnderwriterService);
|
||||
|
||||
readonly underwriters = signal<Underwriter[]>([]);
|
||||
readonly selectedUnderwriter = signal<Underwriter | null>(null);
|
||||
|
||||
carouselPaused = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadUnderwriters();
|
||||
}
|
||||
|
||||
async loadUnderwriters(): Promise<void> {
|
||||
try {
|
||||
const data = await firstValueFrom(this.underwriterService.getUnderwriters(true));
|
||||
this.underwriters.set(data);
|
||||
} catch {
|
||||
// Silently fail — carousel simply won't render
|
||||
}
|
||||
}
|
||||
|
||||
resolveImageUrl(url: string): string {
|
||||
return resolveImageUrl(url);
|
||||
}
|
||||
|
||||
pauseCarousel(): void {
|
||||
this.carouselPaused = true;
|
||||
}
|
||||
|
||||
resumeCarousel(): void {
|
||||
this.carouselPaused = false;
|
||||
}
|
||||
|
||||
openTakeover(uw: Underwriter): void {
|
||||
this.selectedUnderwriter.set(uw);
|
||||
this.carouselPaused = true;
|
||||
}
|
||||
|
||||
closeTakeover(): void {
|
||||
this.selectedUnderwriter.set(null);
|
||||
this.carouselPaused = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||
const token = localStorage.getItem('kmtn_access_token');
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = localStorage.getItem('kmtn_access_token');
|
||||
} catch {
|
||||
// Storage blocked (e.g. Mullvad Browser strict mode)
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const cloned = req.clone({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface StatsSummary {
|
||||
total_unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
date: string;
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface GeoStat {
|
||||
country: string;
|
||||
country_code: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
visitors: number;
|
||||
}
|
||||
|
||||
export interface ParseProgress {
|
||||
file: string;
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
files_processed: number;
|
||||
rows_inserted: number;
|
||||
}
|
||||
|
||||
export type TimePreset = 'yesterday' | '7d' | '30d' | '90d' | 'year' | 'custom';
|
||||
@@ -4,7 +4,7 @@
|
||||
.media-player {
|
||||
background: var(--color-primary-dark);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||
height: 44px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
@@ -20,7 +20,7 @@
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $spacing-md;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--color-white);
|
||||
font-size: 1.1rem;
|
||||
font-size: 2rem;
|
||||
opacity: 0.9;
|
||||
transition: opacity $transition-fast;
|
||||
// minimum 44x44 touch target
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
// minimum 48x48 touch target
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -117,11 +117,25 @@
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.8); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marqueeScroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-100%); }
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,36 @@
|
||||
|
||||
// Responsive
|
||||
@media (max-width: #{$bp-md - 1px}) {
|
||||
.navbar {
|
||||
height: 64px;
|
||||
padding: $spacing-xs 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-flower {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.brand-mountain {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -165,10 +165,12 @@
|
||||
margin: 0 0 $spacing-md;
|
||||
line-height: 1.5;
|
||||
|
||||
// WebKit line-clamp (Chrome, Safari)
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
transition: max-height $transition-slow ease, opacity $transition-normal;
|
||||
|
||||
&.expanded {
|
||||
@@ -177,6 +179,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Standard line-clamp (Firefox 122+) — overrides WebKit for supporting browsers
|
||||
@supports (line-clamp: 3) {
|
||||
.show-description {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
line-clamp: 3;
|
||||
|
||||
&.expanded {
|
||||
line-clamp: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Meta row (host + genre) ────────────────────────────────
|
||||
|
||||
.show-meta {
|
||||
@@ -241,6 +257,7 @@
|
||||
|
||||
.show-description {
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
@@ -263,5 +280,6 @@
|
||||
|
||||
.show-description {
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,32 @@ import { AppUser, AuthTokenResponse } from '../interfaces/auth';
|
||||
|
||||
const TOKEN_KEY = 'kmtn_access_token';
|
||||
|
||||
// ── Safe localStorage helpers (privacy browsers may throw SecurityError) ──
|
||||
|
||||
function safeLocalStorageGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorageSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage blocked — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorageRemove(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage blocked — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -30,7 +56,7 @@ export class AuthService {
|
||||
username,
|
||||
password,
|
||||
}).pipe(
|
||||
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
|
||||
tap((resp) => safeLocalStorageSet(TOKEN_KEY, resp.access_token)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,13 +65,13 @@ export class AuthService {
|
||||
return this.http.post<AuthTokenResponse>(`${this.baseUrl}/google`, {
|
||||
id_token: idToken,
|
||||
}).pipe(
|
||||
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
|
||||
tap((resp) => safeLocalStorageSet(TOKEN_KEY, resp.access_token)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear stored token and reset auth state. */
|
||||
logout(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
safeLocalStorageRemove(TOKEN_KEY);
|
||||
this.authStateSubject.next(null);
|
||||
}
|
||||
|
||||
@@ -83,7 +109,7 @@ export class AuthService {
|
||||
// ── Private ──────────────────────────────────────────────
|
||||
|
||||
private _getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
return safeLocalStorageGet(TOKEN_KEY);
|
||||
}
|
||||
|
||||
private async _initAuth(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { getAppConfig } from './app-config.service';
|
||||
import {
|
||||
StatsSummary,
|
||||
TimeSeriesPoint,
|
||||
GeoStat,
|
||||
} from '../interfaces/visitor-stat';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StatsService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly baseUrl = `${getAppConfig().apiBaseUrl}/api/stats`;
|
||||
|
||||
getSummary(startDate: string, endDate: string): Observable<StatsSummary> {
|
||||
return this.http.get<StatsSummary>(`${this.baseUrl}/summary`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
|
||||
getTimeSeries(startDate: string, endDate: string): Observable<TimeSeriesPoint[]> {
|
||||
return this.http.get<TimeSeriesPoint[]>(`${this.baseUrl}/timeseries`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
|
||||
getGeoStats(startDate: string, endDate: string): Observable<GeoStat[]> {
|
||||
return this.http.get<GeoStat[]>(`${this.baseUrl}/geo`, {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@if ((underwriters()).length >= 2) {
|
||||
<div class="underwriters-section">
|
||||
<p class="underwriters-label">{{ config().callsign }} is brought to you with the generosity of our underwriters.</p>
|
||||
<div
|
||||
class="underwriters-carousel"
|
||||
(mouseenter)="pauseCarousel()"
|
||||
(mouseleave)="resumeCarousel()"
|
||||
>
|
||||
<div class="carousel-track" [class.paused]="carouselPaused">
|
||||
@for (uw of underwriters(); track uw.id + '-set1') {
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-slide"
|
||||
(click)="openTakeover(uw)"
|
||||
[attr.aria-label]="'View ' + uw.name"
|
||||
>
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
|
||||
} @else {
|
||||
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (uw of underwriters(); track uw.id + '-set2') {
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-slide"
|
||||
(click)="openTakeover(uw)"
|
||||
[attr.aria-label]="'View ' + uw.name"
|
||||
>
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
|
||||
} @else {
|
||||
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (uw of underwriters(); track uw.id + '-set3') {
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-slide"
|
||||
(click)="openTakeover(uw)"
|
||||
[attr.aria-label]="'View ' + uw.name"
|
||||
>
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
|
||||
} @else {
|
||||
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Takeover overlay -->
|
||||
@if (selectedUnderwriter()) {
|
||||
@let uw = selectedUnderwriter()!;
|
||||
<div class="takeover-overlay" (click)="closeTakeover()">
|
||||
<div class="takeover-dialog" (click)="$event.stopPropagation()">
|
||||
<button type="button" class="takeover-close" (click)="closeTakeover()" aria-label="Close">×</button>
|
||||
<div class="takeover-content">
|
||||
@if (uw.logo_url) {
|
||||
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="takeover-logo">
|
||||
}
|
||||
<h2 class="takeover-name">{{ uw.name }}</h2>
|
||||
<p class="takeover-description">{{ uw.description }}</p>
|
||||
@if (uw.website_url) {
|
||||
<a [href]="uw.website_url" target="_blank" rel="noopener noreferrer" class="btn btn-hero-primary takeover-visit">Visit Website</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
// ── Underwriter Horizontal Carousel ─────────────────────────
|
||||
|
||||
.underwriters-section {
|
||||
background: $neutral-cream;
|
||||
border-top: 2px solid $neutral-light;
|
||||
border-bottom: 2px solid $neutral-light;
|
||||
}
|
||||
|
||||
.underwriters-label {
|
||||
text-align: center;
|
||||
padding: $spacing-lg $spacing-lg $spacing-md;
|
||||
margin: 0;
|
||||
font-family: $font-heading;
|
||||
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.underwriters-carousel {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: clamp($spacing-xl, 4vh, $spacing-3xl) 0;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: clamp($spacing-xl, 4vw, $spacing-3xl);
|
||||
width: max-content;
|
||||
animation: scroll-carousel 45s linear infinite;
|
||||
|
||||
&.paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-carousel {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-66.666%);
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
.carousel-logo,
|
||||
.carousel-badge {
|
||||
outline: 2px solid $accent-orange;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-logo {
|
||||
display: block;
|
||||
width: clamp(180px, 12vw, 300px);
|
||||
height: clamp(100px, 6.5vw, 170px);
|
||||
object-fit: contain;
|
||||
border-radius: $radius-md;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: clamp(10px, 1.2vw, 18px);
|
||||
box-shadow: $shadow-md;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
.carousel-slide:hover & {
|
||||
transform: scale(1.08);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.carousel-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: clamp(100px, 6.5vw, 170px);
|
||||
height: clamp(100px, 6.5vw, 170px);
|
||||
border-radius: $radius-md;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid $neutral-light;
|
||||
box-shadow: $shadow-md;
|
||||
font-family: $font-heading;
|
||||
font-size: clamp(1.8rem, 3vw, 3rem);
|
||||
font-weight: 700;
|
||||
color: $primary-blue;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
.carousel-slide:hover & {
|
||||
transform: scale(1.08);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Takeover Overlay ────────────────────────────────────────
|
||||
|
||||
.takeover-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $z-modal;
|
||||
background: rgba($primary-blue-dark, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-md;
|
||||
@include fade-in;
|
||||
}
|
||||
|
||||
.takeover-dialog {
|
||||
position: relative;
|
||||
background: $neutral-white;
|
||||
border-radius: $radius-xl;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-shadow: $shadow-xl;
|
||||
animation: takeover-slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes takeover-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.97);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-close {
|
||||
position: absolute;
|
||||
top: $spacing-xs;
|
||||
right: $spacing-xs;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-xs;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $neutral-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-content {
|
||||
text-align: left;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.takeover-logo {
|
||||
display: block;
|
||||
width: clamp(150px, 30vw, 280px);
|
||||
height: clamp(150px, 30vw, 280px);
|
||||
margin: 0 auto $spacing-sm;
|
||||
border-radius: $radius-lg;
|
||||
object-fit: contain;
|
||||
background: $neutral-cream;
|
||||
border: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.takeover-name {
|
||||
font-family: $font-heading;
|
||||
font-size: 1.3rem;
|
||||
color: $primary-blue;
|
||||
margin: 0 0 0.35rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.takeover-description {
|
||||
font-size: 0.875rem;
|
||||
color: $neutral-dark;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 $spacing-md 0;
|
||||
text-align: left;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $neutral-cream;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $neutral-light;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.takeover-visit {
|
||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
||||
font-size: 1rem;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
letter-spacing: 0.03em;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { UnderwriterService } from '../services/underwriter.service';
|
||||
import { Underwriter } from '../interfaces/underwriter';
|
||||
import { resolveImageUrl } from '../services/app-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-underwriter-carousel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './underwriter-carousel.component.html',
|
||||
styleUrl: './underwriter-carousel.component.scss',
|
||||
})
|
||||
export class UnderwriterCarouselComponent implements OnInit {
|
||||
readonly config = inject(StationConfigService).config;
|
||||
private underwriterService = inject(UnderwriterService);
|
||||
|
||||
readonly underwriters = signal<Underwriter[]>([]);
|
||||
readonly selectedUnderwriter = signal<Underwriter | null>(null);
|
||||
|
||||
carouselPaused = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadUnderwriters();
|
||||
}
|
||||
|
||||
async loadUnderwriters(): Promise<void> {
|
||||
try {
|
||||
const data = await firstValueFrom(this.underwriterService.getUnderwriters(true));
|
||||
this.underwriters.set(data);
|
||||
} catch {
|
||||
// Silently fail — carousel simply won't render
|
||||
}
|
||||
}
|
||||
|
||||
resolveImageUrl(url: string): string {
|
||||
return resolveImageUrl(url);
|
||||
}
|
||||
|
||||
pauseCarousel(): void {
|
||||
this.carouselPaused = true;
|
||||
}
|
||||
|
||||
resumeCarousel(): void {
|
||||
this.carouselPaused = false;
|
||||
}
|
||||
|
||||
openTakeover(uw: Underwriter): void {
|
||||
this.selectedUnderwriter.set(uw);
|
||||
this.carouselPaused = true;
|
||||
}
|
||||
|
||||
closeTakeover(): void {
|
||||
this.selectedUnderwriter.set(null);
|
||||
this.carouselPaused = false;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
@use './styles/mixins' as *;
|
||||
@use './styles/themes';
|
||||
|
||||
// Leaflet CSS for the visitor stats map
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// ===== Base reset and global styles =====
|
||||
|
||||
*,
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
|
||||
// Firefox fallback: solid color when gradient text is unsupported
|
||||
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
background: none;
|
||||
color: $from;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin card-style {
|
||||
|
||||
Reference in New Issue
Block a user