Etter dag 1 ny server
This commit is contained in:
parent
6511a3aee2
commit
e2f94dcaaa
19 changed files with 958 additions and 313 deletions
17
.env.example
17
.env.example
|
|
@ -1,17 +0,0 @@
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
||||||
PUBLIC_BASE_URL=https://teeoff.no
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://teeoff.no
|
|
||||||
PUBLIC_SESSION_SECRET=replace-with-a-long-random-secret
|
|
||||||
JWT_SECRET=replace-with-a-separate-long-random-secret
|
|
||||||
PUBLIC_COMMENT_DEFAULT_STATUS=pending
|
|
||||||
SMTP_SERVER=send.one.com
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USER=comment@example.com
|
|
||||||
SMTP_PASS=replace-with-your-smtp-password
|
|
||||||
PUBLIC_FROM_EMAIL=TeeOff kommentarer <comment@example.com>
|
|
||||||
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES=20
|
|
||||||
POSTGRES_USER=teeoff_admin
|
|
||||||
POSTGRES_PASSWORD=replace-with-your-postgres-password
|
|
||||||
POSTGRES_DB=teeoff
|
|
||||||
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff
|
|
||||||
138
backend/bootstrap_admin_access.py
Normal file
138
backend/bootstrap_admin_access.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""
|
||||||
|
TEE OFF ADMIN ACCESS BOOTSTRAP
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FUNKSJON: Oppretter eller oppdaterer én administrator uten å påvirke andre.
|
||||||
|
Passord leses skjult fra terminalen, og 2FA kan genereres/roteres.
|
||||||
|
STATUS: Trygg erstatning for create_admin.py når flere admins skal eksistere.
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import pyotp
|
||||||
|
from passlib.hash import pbkdf2_sha256
|
||||||
|
|
||||||
|
from env_config import get_database_url
|
||||||
|
|
||||||
|
DB_URL = get_database_url()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Opprett eller oppdater en admin-bruker trygt.")
|
||||||
|
parser.add_argument("--username", required=True, help="Brukernavn for admin-brukeren")
|
||||||
|
parser.add_argument("--email", required=True, help="E-post for admin-brukeren")
|
||||||
|
parser.add_argument(
|
||||||
|
"--rotate-2fa",
|
||||||
|
action="store_true",
|
||||||
|
help="Generer en ny 2FA-hemmelighet selv om brukeren allerede har en",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_admin() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
username = args.username.strip()
|
||||||
|
email = args.email.strip().lower()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
print("❌ Brukernavn kan ikke være tomt.")
|
||||||
|
sys.exit(1)
|
||||||
|
if "@" not in email:
|
||||||
|
print("❌ E-postadressen ser ugyldig ut.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
password = getpass.getpass("Skriv inn passord: ")
|
||||||
|
password_confirm = getpass.getpass("Gjenta passord: ")
|
||||||
|
|
||||||
|
if password != password_confirm:
|
||||||
|
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
||||||
|
break
|
||||||
|
|
||||||
|
password_hash = pbkdf2_sha256.hash(password)
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, username, email, otp_secret
|
||||||
|
FROM admins
|
||||||
|
WHERE username = $1 OR email = $2
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
|
||||||
|
otp_secret = pyotp.random_base32() if (args.rotate_2fa or not existing or not existing["otp_secret"]) else existing["otp_secret"]
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE admins
|
||||||
|
SET username = $1,
|
||||||
|
email = $2,
|
||||||
|
password_hash = $3,
|
||||||
|
otp_secret = $4
|
||||||
|
WHERE id = $5
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
otp_secret,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
action = "oppdatert"
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO admins (username, email, password_hash, otp_secret)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
otp_secret,
|
||||||
|
)
|
||||||
|
action = "opprettet"
|
||||||
|
except asyncpg.UniqueViolationError:
|
||||||
|
print("❌ Brukernavn eller e-post er allerede i bruk av en annen admin.")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"❌ Kunne ikke bootstrappe admin-brukeren: {type(exc).__name__}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
provisioning_uri = pyotp.TOTP(otp_secret).provisioning_uri(
|
||||||
|
name=email or username,
|
||||||
|
issuer_name="TeeOff.no",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n✅ ADMIN BRUKER KLAR")
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"Brukeren '{username}' er {action}.")
|
||||||
|
print("Passordet ble satt skjult i terminalen.")
|
||||||
|
print("Legg inn denne 2FA-hemmeligheten i authenticator-appen:")
|
||||||
|
print(f"2FA-nøkkel: {otp_secret}")
|
||||||
|
print("Alternativt kan du bruke provisioning URI:")
|
||||||
|
print(provisioning_uri)
|
||||||
|
print("-" * 50 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(bootstrap_admin())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nAvbrutt.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
@ -80,6 +80,8 @@ def get_int_env(name: str, default: int) -> int:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12)
|
||||||
|
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
|
||||||
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
|
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
|
||||||
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
|
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
|
||||||
|
|
||||||
|
|
@ -479,6 +481,19 @@ async def resolve_cooperating_club_slugs(
|
||||||
return resolved_slugs
|
return resolved_slugs
|
||||||
|
|
||||||
|
|
||||||
|
async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
""",
|
||||||
|
schema_name,
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
return {str(row["column_name"]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
||||||
image = qrcode.make(
|
image = qrcode.make(
|
||||||
provisioning_uri,
|
provisioning_uri,
|
||||||
|
|
@ -1047,6 +1062,8 @@ async def require_admin_session_for_admin_routes(request: Request, call_next):
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
async def login(data: dict):
|
async def login(data: dict):
|
||||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
||||||
|
remember_me = bool(data.get("remember_me") or data.get("rememberMe"))
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
admin = await conn.fetchrow(
|
admin = await conn.fetchrow(
|
||||||
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
||||||
|
|
@ -1066,19 +1083,25 @@ async def login(data: dict):
|
||||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||||
|
|
||||||
temp_token = jwt.encode(
|
temp_token = jwt.encode(
|
||||||
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
{
|
||||||
|
"sub": admin['username'],
|
||||||
|
"partial": True,
|
||||||
|
"remember_me": remember_me,
|
||||||
|
"exp": datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
},
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
SECRET_KEY, algorithm=ALGORITHM
|
||||||
)
|
)
|
||||||
return {"step": "2fa", "temp_token": temp_token}
|
return {"step": "2fa", "temp_token": temp_token}
|
||||||
|
|
||||||
@app.post("/api/auth/verify-2fa")
|
@app.post("/api/auth/verify-2fa")
|
||||||
async def verify_2fa(data: dict, response: Response):
|
async def verify_2fa(data: dict, response: Response, request: Request):
|
||||||
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
if not payload.get("partial"):
|
if not payload.get("partial"):
|
||||||
raise JWTError()
|
raise JWTError()
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
|
remember_me = bool(payload.get("remember_me"))
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
||||||
|
|
||||||
|
|
@ -1090,29 +1113,36 @@ async def verify_2fa(data: dict, response: Response):
|
||||||
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
|
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
|
||||||
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
||||||
|
|
||||||
|
session_max_age = (
|
||||||
|
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS
|
||||||
|
if remember_me
|
||||||
|
else ADMIN_SESSION_MAX_AGE_SECONDS
|
||||||
|
)
|
||||||
final_token = jwt.encode(
|
final_token = jwt.encode(
|
||||||
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
{"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)},
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
SECRET_KEY, algorithm=ALGORITHM
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sett som HTTP-only cookie
|
# Sett som HTTP-only cookie
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="admin_session",
|
key="admin_session",
|
||||||
value=final_token,
|
value=final_token,
|
||||||
|
max_age=session_max_age,
|
||||||
|
expires=session_max_age,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=False # Sett til True i produksjon (HTTPS)
|
secure=should_use_secure_cookies(request),
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
async def logout(response: Response):
|
async def logout(response: Response, request: Request):
|
||||||
"""Logger ut admin ved å slette sesjonscookien."""
|
"""Logger ut admin ved å slette sesjonscookien."""
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
key="admin_session",
|
key="admin_session",
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=False,
|
secure=should_use_secure_cookies(request),
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
@ -1927,6 +1957,8 @@ async def update_facility_full(facility_id: int, request: Request):
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
async with conn.transaction(): # Sikrer at alt lagres samlet
|
||||||
|
facility_columns = await get_table_columns(conn, "facilities")
|
||||||
|
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
|
||||||
|
|
||||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
# 1. OPPDATER ANLEGG (FACILITIES)
|
||||||
if update_data:
|
if update_data:
|
||||||
|
|
@ -2196,6 +2228,9 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
||||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
|
facility_columns = await get_table_columns(conn, "facilities")
|
||||||
|
has_cooperating_clubs = "cooperating_clubs" in facility_columns
|
||||||
|
|
||||||
for approval in request.approvals:
|
for approval in request.approvals:
|
||||||
draft_row = await conn.fetchrow(
|
draft_row = await conn.fetchrow(
|
||||||
"SELECT greenfee_draft FROM facilities WHERE id = $1",
|
"SELECT greenfee_draft FROM facilities WHERE id = $1",
|
||||||
|
|
@ -2210,17 +2245,26 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
||||||
exclude_facility_id=approval.facility_id,
|
exclude_facility_id=approval.facility_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
if has_cooperating_clubs:
|
||||||
UPDATE facilities
|
await conn.execute("""
|
||||||
SET greenfee = $1::jsonb,
|
UPDATE facilities
|
||||||
cooperating_clubs = CASE
|
SET greenfee = $1::jsonb,
|
||||||
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
|
cooperating_clubs = CASE
|
||||||
ELSE $2::jsonb
|
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
|
||||||
END,
|
ELSE $2::jsonb
|
||||||
greenfee_updated_at = NOW(),
|
END,
|
||||||
greenfee_draft = NULL
|
greenfee_updated_at = NOW(),
|
||||||
WHERE id = $3
|
greenfee_draft = NULL
|
||||||
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
|
WHERE id = $3
|
||||||
|
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
|
||||||
|
else:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE facilities
|
||||||
|
SET greenfee = $1::jsonb,
|
||||||
|
greenfee_updated_at = NOW(),
|
||||||
|
greenfee_draft = NULL
|
||||||
|
WHERE id = $2
|
||||||
|
""", json.dumps(approval.greenfee), approval.facility_id)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@app.post("/api/admin/run-greenfee-scraper")
|
@app.post("/api/admin/run-greenfee-scraper")
|
||||||
|
|
|
||||||
BIN
bilde1.png
Normal file
BIN
bilde1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
32
deploy/Caddyfile
Normal file
32
deploy/Caddyfile
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
email {$ACME_EMAIL}
|
||||||
|
}
|
||||||
|
|
||||||
|
www.teeoff.no {
|
||||||
|
redir https://teeoff.no{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
teeoff.no {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
|
||||||
|
# This upload route is implemented in Next.js, not FastAPI.
|
||||||
|
handle /api/admin/uploads/images* {
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# All other /api traffic goes to the FastAPI backend.
|
||||||
|
# Use handle, not handle_path, so the /api prefix is preserved.
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else is served by Next.js.
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
95
docker-compose.prod.yml
Normal file
95
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:15-3.4
|
||||||
|
container_name: teeoff_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- teeoff_db_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
container_name: teeoff_api
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
|
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
|
||||||
|
PUBLIC_COMMENT_DEFAULT_STATUS: ${PUBLIC_COMMENT_DEFAULT_STATUS}
|
||||||
|
SMTP_SERVER: ${SMTP_SERVER}
|
||||||
|
SMTP_PORT: ${SMTP_PORT}
|
||||||
|
SMTP_USER: ${SMTP_USER}
|
||||||
|
SMTP_PASS: ${SMTP_PASS}
|
||||||
|
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
|
||||||
|
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./frontend/src/content:/shared/frontend-content:ro
|
||||||
|
- ./frontend/public/media:/app/public/media
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: ./backend
|
||||||
|
container_name: teeoff_worker
|
||||||
|
command: python worker.py
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||||
|
SMTP_SERVER: ${SMTP_SERVER}
|
||||||
|
SMTP_PORT: ${SMTP_PORT}
|
||||||
|
SMTP_USER: ${SMTP_USER}
|
||||||
|
SMTP_PASS: ${SMTP_PASS}
|
||||||
|
EMAIL_TO: ${EMAIL_TO}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
container_name: teeoff_frontend
|
||||||
|
command: npm start
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
volumes:
|
||||||
|
- ./frontend/public/uploads:/app/public/uploads
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
container_name: teeoff_caddy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
environment:
|
||||||
|
ACME_EMAIL: ${ACME_EMAIL}
|
||||||
|
volumes:
|
||||||
|
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
teeoff_db_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
141
docs/vps-deploy-teeoff.md
Normal file
141
docs/vps-deploy-teeoff.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# TeeOff VPS deployment
|
||||||
|
|
||||||
|
This project can be served on `https://teeoff.no` with Docker Compose and Caddy.
|
||||||
|
|
||||||
|
## 1. DNS at one.com
|
||||||
|
|
||||||
|
Create or update these records:
|
||||||
|
|
||||||
|
- `A` record for the root domain (`@` / empty hostname) -> `85.137.228.98`
|
||||||
|
- `CNAME` for `www` -> `teeoff.no`
|
||||||
|
|
||||||
|
If `teeoff.no` currently has old `A`, `AAAA`, web-forward, or alias records pointing elsewhere, remove or replace them.
|
||||||
|
|
||||||
|
If you do not actively use IPv6 on the VPS, remove any stale `AAAA` record for `teeoff.no` and `www`.
|
||||||
|
|
||||||
|
one.com documents A/CNAME management here:
|
||||||
|
|
||||||
|
- https://help.one.com/hc/en-us/articles/360000799298-How-do-I-create-an-A-record
|
||||||
|
- https://help.one.com/hc/en-us/articles/360000803517-How-do-I-create-a-CNAME-record
|
||||||
|
|
||||||
|
## 2. Required environment values
|
||||||
|
|
||||||
|
On the VPS, create `.env` in the project root and make sure these values are correct:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLIC_BASE_URL=https://teeoff.no
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://teeoff.no
|
||||||
|
DATABASE_URL=postgresql://teeoff_admin:...@db:5432/teeoff
|
||||||
|
POSTGRES_USER=teeoff_admin
|
||||||
|
POSTGRES_PASSWORD=...
|
||||||
|
POSTGRES_DB=teeoff
|
||||||
|
JWT_SECRET=...
|
||||||
|
PUBLIC_SESSION_SECRET=...
|
||||||
|
ACME_EMAIL=you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
If public comments or Google login are used, keep the SMTP and Google OAuth values configured too.
|
||||||
|
|
||||||
|
Important Google OAuth update:
|
||||||
|
|
||||||
|
- Authorized redirect URI should include `https://teeoff.no/api/public/auth/google/callback`
|
||||||
|
|
||||||
|
## 3. Start the production stack
|
||||||
|
|
||||||
|
Use the production compose file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This stack exposes only:
|
||||||
|
|
||||||
|
- `80/tcp`
|
||||||
|
- `443/tcp`
|
||||||
|
|
||||||
|
The app containers stay internal and are reached through Caddy only.
|
||||||
|
|
||||||
|
## 4. Reverse proxy layout
|
||||||
|
|
||||||
|
The Caddy config is in `deploy/Caddyfile`.
|
||||||
|
|
||||||
|
Routing is:
|
||||||
|
|
||||||
|
- `https://teeoff.no/api/admin/uploads/images` -> Next.js
|
||||||
|
- all other `https://teeoff.no/api/*` -> FastAPI
|
||||||
|
- everything else -> Next.js frontend
|
||||||
|
|
||||||
|
That exception matters because the image upload endpoint is implemented in Next.js, while the rest of the API lives in FastAPI.
|
||||||
|
The `/api` prefix must be preserved when proxying, because both apps define routes with `/api/...` paths.
|
||||||
|
|
||||||
|
## 5. VPS hardening
|
||||||
|
|
||||||
|
Recommended minimum host steps on Ubuntu 24.04:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install -y ufw fail2ban
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
Important Docker note: published Docker ports can bypass `ufw` if you expose them directly on the host. That is why the production compose file publishes only Caddy's `80` and `443`.
|
||||||
|
|
||||||
|
Useful official references:
|
||||||
|
|
||||||
|
- Ubuntu UFW docs: https://ubuntu.com/server/docs/how-to/security/firewalls/
|
||||||
|
- Docker firewall warning: https://docs.docker.com/engine/network/packet-filtering-firewalls/
|
||||||
|
- Docker port publishing warning: https://docs.docker.com/engine/network/port-publishing/
|
||||||
|
- Caddy automatic HTTPS: https://caddyserver.com/docs/automatic-https
|
||||||
|
|
||||||
|
## 6. SSH hardening
|
||||||
|
|
||||||
|
Do this after you have verified SSH key login works:
|
||||||
|
|
||||||
|
- create a non-root sudo user if you do not already use one
|
||||||
|
- disable password authentication in `/etc/ssh/sshd_config`
|
||||||
|
- disable root SSH login if you do not need it
|
||||||
|
- restart SSH
|
||||||
|
|
||||||
|
Typical settings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PasswordAuthentication no
|
||||||
|
PermitRootLogin no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Verification checklist
|
||||||
|
|
||||||
|
After DNS has propagated and the stack is up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://teeoff.no
|
||||||
|
curl -I https://teeoff.no
|
||||||
|
curl -I https://www.teeoff.no
|
||||||
|
curl -I https://teeoff.no/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected results:
|
||||||
|
|
||||||
|
- `http://teeoff.no` redirects to HTTPS
|
||||||
|
- `https://www.teeoff.no` redirects to `https://teeoff.no`
|
||||||
|
- `https://teeoff.no/api/health` returns the API health payload
|
||||||
|
|
||||||
|
## 8. Existing compose file
|
||||||
|
|
||||||
|
`docker-compose.yml` is still useful for the earlier/local setup.
|
||||||
|
|
||||||
|
For the VPS cutover, prefer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20
|
||||||
|
|
@ -2,15 +2,18 @@ FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Kopier package.json og installer avhengigheter
|
ARG NEXT_PUBLIC_SITE_URL
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
||||||
|
|
||||||
|
# Kopier package.json og installer avhengigheter deterministisk
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
# Kopier resten av koden
|
# Kopier resten av koden
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# BYGG koden her (kjøres jyb én gang når imaget bygges
|
# Bygg koden én gang ved image-build
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Vi starter serveren i "produksjons"-modus (utviklingsmodus).
|
# Start Next i produksjonsmodus
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^3.22.3",
|
"@tiptap/extension-image": "^3.22.3",
|
||||||
|
|
|
||||||
|
|
@ -236,13 +236,20 @@ const sanitizeHref = (value: string) => {
|
||||||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSiteOrigin = () => {
|
||||||
|
if (typeof window !== "undefined" && window.location?.origin) {
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
|
||||||
|
};
|
||||||
|
|
||||||
const isInternalTeeoffHref = (href: string) => {
|
const isInternalTeeoffHref = (href: string) => {
|
||||||
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(href, "https://nye.teeoff.no");
|
const url = new URL(href, getSiteOrigin());
|
||||||
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { API_URL } from "@/config/constants";
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
const [formData, setFormData] = useState({ username: '', password: '', code: '', rememberMe: false });
|
||||||
const [tempToken, setTempToken] = useState('');
|
const [tempToken, setTempToken] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -28,7 +28,11 @@ export default function AdminLogin() {
|
||||||
const res = await fetch(`${API_URL}/auth/login`, {
|
const res = await fetch(`${API_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
body: JSON.stringify({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
remember_me: formData.rememberMe,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -85,6 +89,15 @@ export default function AdminLogin() {
|
||||||
<>
|
<>
|
||||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
||||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl bg-[#f7faF4] px-4 py-4 text-sm font-bold text-[#11280f]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-[#11280f] focus:ring-[#8bc34a]"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={e => setFormData(prevState => ({ ...prevState, rememberMe: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Husk meg i 30 dager på denne enheten
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Icon as LeafletIcon } from "leaflet";
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
||||||
|
import { STATUS_MAP } from "@/config/constants";
|
||||||
|
import { STATUS_ICON_PATHS } from "@/app/facilityData";
|
||||||
|
|
||||||
|
type FacilityDetailLeafletMapProps = {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name: string;
|
||||||
|
city?: string | null;
|
||||||
|
county?: string | null;
|
||||||
|
primaryStatus: string;
|
||||||
|
mapUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
|
||||||
|
|
||||||
|
const getDetailMarkerIcon = (status: string) => {
|
||||||
|
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||||
|
if (!detailMarkerIconCache[key]) {
|
||||||
|
detailMarkerIconCache[key] = new LeafletIcon({
|
||||||
|
iconUrl: STATUS_ICON_PATHS[key],
|
||||||
|
iconSize: [34, 48],
|
||||||
|
iconAnchor: [17, 48],
|
||||||
|
popupAnchor: [0, -42],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return detailMarkerIconCache[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FacilityDetailLeafletMap({
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
name,
|
||||||
|
city,
|
||||||
|
county,
|
||||||
|
primaryStatus,
|
||||||
|
mapUrl,
|
||||||
|
}: FacilityDetailLeafletMapProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-[450px] w-full md:h-[650px]">
|
||||||
|
<MapContainer
|
||||||
|
center={[lat, lng]}
|
||||||
|
zoom={13}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
zoomControl
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<Marker
|
||||||
|
position={[lat, lng]}
|
||||||
|
icon={getDetailMarkerIcon(primaryStatus)}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-extrabold text-[#112015]">{name}</p>
|
||||||
|
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||||
|
{city} • {county}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||||
|
{STATUS_MAP[primaryStatus] || "Ukjent status"}
|
||||||
|
</div>
|
||||||
|
{mapUrl && (
|
||||||
|
<a
|
||||||
|
href={mapUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
>
|
||||||
|
Åpne kart
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,27 +14,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Icon as LeafletIcon } from "leaflet";
|
import dynamic from "next/dynamic";
|
||||||
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
|
||||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||||
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import CourseDisplay from './CourseDisplay';
|
import CourseDisplay from './CourseDisplay';
|
||||||
|
|
||||||
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
|
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
||||||
|
ssr: false,
|
||||||
const getDetailMarkerIcon = (status: string) => {
|
loading: () => (
|
||||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
<div className="flex h-[450px] w-full items-center justify-center bg-[#f1f7ed] text-sm font-bold text-[#617063] md:h-[650px]">
|
||||||
if (!detailMarkerIconCache[key]) {
|
Laster kart…
|
||||||
detailMarkerIconCache[key] = new LeafletIcon({
|
</div>
|
||||||
iconUrl: STATUS_ICON_PATHS[key],
|
),
|
||||||
iconSize: [34, 48],
|
});
|
||||||
iconAnchor: [17, 48],
|
|
||||||
popupAnchor: [0, -42],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return detailMarkerIconCache[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPhoneForUrl = (phone: string) => {
|
const formatPhoneForUrl = (phone: string) => {
|
||||||
if (!phone) return "";
|
if (!phone) return "";
|
||||||
|
|
@ -65,13 +58,20 @@ const sanitizeHref = (value: string) => {
|
||||||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSiteOrigin = () => {
|
||||||
|
if (typeof window !== "undefined" && window.location?.origin) {
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
|
||||||
|
};
|
||||||
|
|
||||||
const isInternalTeeoffHref = (href: string) => {
|
const isInternalTeeoffHref = (href: string) => {
|
||||||
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(href, "https://nye.teeoff.no");
|
const url = new URL(href, getSiteOrigin());
|
||||||
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -445,20 +445,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SAMARBEIDENDE KLUBBER */}
|
|
||||||
{cooperatingClubs.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{cooperatingClubs.map((slug: string) => (
|
|
||||||
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
|
|
||||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{golfpakkerRaw.length > 0 && (
|
{golfpakkerRaw.length > 0 && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<span className="text-gray-400 block mb-2">Golfpakker:</span>
|
<span className="text-gray-400 block mb-2">Golfpakker:</span>
|
||||||
|
|
@ -503,48 +489,15 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
<section id="map" className="space-y-6">
|
<section id="map" className="space-y-6">
|
||||||
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||||
<div className="teeoff-map overflow-hidden md:rounded-[3rem] border-y-4 md:border-[12px] border-white bg-white shadow-xl">
|
<div className="teeoff-map overflow-hidden md:rounded-[3rem] border-y-4 md:border-[12px] border-white bg-white shadow-xl">
|
||||||
<div className="h-[450px] md:h-[650px] w-full">
|
<FacilityDetailLeafletMap
|
||||||
<MapContainer
|
lat={facility.lat as number}
|
||||||
center={[facility.lat as number, facility.lng as number]}
|
lng={facility.lng as number}
|
||||||
zoom={13}
|
name={facility.name}
|
||||||
scrollWheelZoom={false}
|
city={facility.city}
|
||||||
zoomControl
|
county={facility.county}
|
||||||
className="h-full w-full"
|
primaryStatus={primaryStatus}
|
||||||
>
|
mapUrl={mapUrl}
|
||||||
<TileLayer
|
/>
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<Marker
|
|
||||||
position={[facility.lat as number, facility.lng as number]}
|
|
||||||
icon={getDetailMarkerIcon(primaryStatus)}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-extrabold text-[#112015]">{facility.name}</p>
|
|
||||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
|
||||||
{facility.city} • {facility.county}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
|
||||||
{STATUS_MAP[primaryStatus] || "Ukjent status"}
|
|
||||||
</div>
|
|
||||||
{mapUrl && (
|
|
||||||
<a
|
|
||||||
href={mapUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="btn btn-sm btn-secondary"
|
|
||||||
>
|
|
||||||
Åpne kart
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
</MapContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -655,6 +608,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
Krav: {facility.guest_requirements}
|
Krav: {facility.guest_requirements}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{cooperatingClubs.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-gray-50 pt-4">
|
||||||
|
<span className="mb-3 block text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||||
|
Samarbeidende klubber
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{cooperatingClubs.map((slug: string) => (
|
||||||
|
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
|
||||||
|
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Icon, LatLngBounds } from "leaflet";
|
import { type EnrichedFacility, STATUS_ICON_PATHS } from "@/app/facilityData";
|
||||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
|
||||||
import {
|
|
||||||
type EnrichedFacility,
|
|
||||||
STATUS_ICON_PATHS,
|
|
||||||
buildMapUrl,
|
|
||||||
formatUpdatedDate,
|
|
||||||
getStatusLabel,
|
|
||||||
parseJson,
|
|
||||||
} from "@/app/facilityData";
|
|
||||||
|
|
||||||
type PlaceMapProps = {
|
type PlaceMapProps = {
|
||||||
facilities: EnrichedFacility[];
|
facilities: EnrichedFacility[];
|
||||||
placeLabel: string;
|
placeLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const markerIconCache: Record<string, Icon> = {};
|
const PlaceMapLeaflet = dynamic(() => import("./PlaceMapLeaflet"), {
|
||||||
|
ssr: false,
|
||||||
const getMarkerIcon = (status: string) => {
|
loading: () => (
|
||||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
<div className="flex h-[26rem] w-full items-center justify-center bg-[#F3F6EE] text-sm font-bold text-[#617063] sm:h-[34rem]">
|
||||||
if (!markerIconCache[key]) {
|
Laster kart…
|
||||||
markerIconCache[key] = new Icon({
|
</div>
|
||||||
iconUrl: STATUS_ICON_PATHS[key],
|
),
|
||||||
iconSize: [34, 48],
|
});
|
||||||
iconAnchor: [17, 48],
|
|
||||||
popupAnchor: [0, -42],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return markerIconCache[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
function ShiftScrollZoomGuard() {
|
|
||||||
const map = useMap();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateWheelMode = (shiftPressed: boolean) => {
|
|
||||||
if (window.innerWidth < 1024) {
|
|
||||||
map.scrollWheelZoom.enable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shiftPressed) {
|
|
||||||
map.scrollWheelZoom.enable();
|
|
||||||
} else {
|
|
||||||
map.scrollWheelZoom.disable();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Shift") updateWheelMode(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Shift") updateWheelMode(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBlur = () => updateWheelMode(false);
|
|
||||||
const onResize = () => updateWheelMode(false);
|
|
||||||
|
|
||||||
updateWheelMode(false);
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("keyup", onKeyUp);
|
|
||||||
window.addEventListener("blur", onBlur);
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("keyup", onKeyUp);
|
|
||||||
window.removeEventListener("blur", onBlur);
|
|
||||||
window.removeEventListener("resize", onResize);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
|
|
||||||
const map = useMap();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const withCoords = facilities.filter(
|
|
||||||
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (withCoords.length === 0) return;
|
|
||||||
if (withCoords.length === 1) {
|
|
||||||
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = new LatLngBounds(
|
|
||||||
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
|
|
||||||
);
|
|
||||||
map.fitBounds(bounds, { padding: [36, 36] });
|
|
||||||
}, [facilities, map]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
|
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
|
||||||
if (type === "teeoff") {
|
if (type === "teeoff") {
|
||||||
|
|
@ -252,108 +169,7 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="teeoff-map overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
|
<div className="teeoff-map overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
|
||||||
<div className="h-[26rem] w-full sm:h-[34rem]">
|
<PlaceMapLeaflet facilities={mapFacilities} />
|
||||||
<MapContainer
|
|
||||||
center={[64.5, 15.5]}
|
|
||||||
zoom={5}
|
|
||||||
scrollWheelZoom
|
|
||||||
zoomControl
|
|
||||||
className="h-full w-full"
|
|
||||||
>
|
|
||||||
<TileLayer
|
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<ShiftScrollZoomGuard />
|
|
||||||
<FitMapBounds facilities={mapFacilities} />
|
|
||||||
|
|
||||||
{mapFacilities.map((facility) => {
|
|
||||||
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
|
||||||
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
|
||||||
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={facility.id}
|
|
||||||
position={[facility.lat as number, facility.lng as number]}
|
|
||||||
icon={getMarkerIcon(facility.primaryStatus)}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
|
|
||||||
{facility.name}
|
|
||||||
</Link>
|
|
||||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
|
||||||
{facility.city} • {facility.county}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
|
||||||
{getStatusLabel(facility.primaryStatus)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{facility.status_updated_at && (
|
|
||||||
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
|
|
||||||
<ActionGlyph type="teeoff" />
|
|
||||||
</a>
|
|
||||||
{facility.phone && (
|
|
||||||
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
|
|
||||||
<ActionGlyph type="phone" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.email && (
|
|
||||||
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
|
|
||||||
<ActionGlyph type="mail" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.website_url && (
|
|
||||||
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
|
|
||||||
<ActionGlyph type="home" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.golfbox_tournament_url && (
|
|
||||||
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
|
|
||||||
<ActionGlyph type="calendar" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.weather_url && (
|
|
||||||
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
|
|
||||||
<ActionGlyph type="weather" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facebook && (
|
|
||||||
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
|
|
||||||
<ActionGlyph type="facebook" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{instagram && (
|
|
||||||
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
|
|
||||||
<ActionGlyph type="instagram" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{buildMapUrl(facility.lat, facility.lng) && (
|
|
||||||
<a
|
|
||||||
href={buildMapUrl(facility.lat, facility.lng) || "#"}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
|
|
||||||
>
|
|
||||||
Åpne kart
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MapContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
267
frontend/src/components/PlaceMapLeaflet.tsx
Normal file
267
frontend/src/components/PlaceMapLeaflet.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Icon, LatLngBounds } from "leaflet";
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||||
|
import {
|
||||||
|
buildMapUrl,
|
||||||
|
formatUpdatedDate,
|
||||||
|
getStatusLabel,
|
||||||
|
parseJson,
|
||||||
|
STATUS_ICON_PATHS,
|
||||||
|
type EnrichedFacility,
|
||||||
|
} from "@/app/facilityData";
|
||||||
|
|
||||||
|
type PlaceMapLeafletProps = {
|
||||||
|
facilities: EnrichedFacility[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerIconCache: Record<string, Icon> = {};
|
||||||
|
|
||||||
|
const getMarkerIcon = (status: string) => {
|
||||||
|
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||||
|
if (!markerIconCache[key]) {
|
||||||
|
markerIconCache[key] = new Icon({
|
||||||
|
iconUrl: STATUS_ICON_PATHS[key],
|
||||||
|
iconSize: [34, 48],
|
||||||
|
iconAnchor: [17, 48],
|
||||||
|
popupAnchor: [0, -42],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return markerIconCache[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
function ShiftScrollZoomGuard() {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateWheelMode = (shiftPressed: boolean) => {
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
map.scrollWheelZoom.enable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftPressed) {
|
||||||
|
map.scrollWheelZoom.enable();
|
||||||
|
} else {
|
||||||
|
map.scrollWheelZoom.disable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Shift") updateWheelMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Shift") updateWheelMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => updateWheelMode(false);
|
||||||
|
const onResize = () => updateWheelMode(false);
|
||||||
|
|
||||||
|
updateWheelMode(false);
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("keyup", onKeyUp);
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const withCoords = facilities.filter(
|
||||||
|
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (withCoords.length === 0) return;
|
||||||
|
if (withCoords.length === 1) {
|
||||||
|
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = new LatLngBounds(
|
||||||
|
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
|
||||||
|
);
|
||||||
|
map.fitBounds(bounds, { padding: [36, 36] });
|
||||||
|
}, [facilities, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
|
||||||
|
if (type === "teeoff") {
|
||||||
|
return <span className="text-lg font-black leading-none text-[#FF5722]">t</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-[#FF5722]"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{type === "phone" && <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />}
|
||||||
|
{type === "mail" && (
|
||||||
|
<>
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
|
<polyline points="22,6 12,13 2,6" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "home" && (
|
||||||
|
<>
|
||||||
|
<path d="M3 11.5 12 4l9 7.5" />
|
||||||
|
<path d="M5 10.5V20h14v-9.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "calendar" && (
|
||||||
|
<>
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
<path d="M8 3v4" />
|
||||||
|
<path d="M16 3v4" />
|
||||||
|
<rect x="4" y="5" width="16" height="16" rx="2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "weather" && (
|
||||||
|
<>
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
|
<path d="M15.947 12.65a4 4 0 0 0-5.925-4.128" />
|
||||||
|
<path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "facebook" && <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />}
|
||||||
|
{type === "instagram" && (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
||||||
|
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
||||||
|
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-[26rem] w-full sm:h-[34rem]">
|
||||||
|
<MapContainer
|
||||||
|
center={[64.5, 15.5]}
|
||||||
|
zoom={5}
|
||||||
|
scrollWheelZoom
|
||||||
|
zoomControl
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<ShiftScrollZoomGuard />
|
||||||
|
<FitMapBounds facilities={facilities} />
|
||||||
|
|
||||||
|
{facilities.map((facility) => {
|
||||||
|
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
||||||
|
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
||||||
|
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={facility.id}
|
||||||
|
position={[facility.lat as number, facility.lng as number]}
|
||||||
|
icon={getMarkerIcon(facility.primaryStatus)}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
|
||||||
|
{facility.name}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||||
|
{facility.city} • {facility.county}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||||
|
{getStatusLabel(facility.primaryStatus)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{facility.status_updated_at && (
|
||||||
|
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
|
||||||
|
<ActionGlyph type="teeoff" />
|
||||||
|
</a>
|
||||||
|
{facility.phone && (
|
||||||
|
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
|
||||||
|
<ActionGlyph type="phone" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{facility.email && (
|
||||||
|
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
|
||||||
|
<ActionGlyph type="mail" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{facility.website_url && (
|
||||||
|
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
|
||||||
|
<ActionGlyph type="home" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{facility.golfbox_tournament_url && (
|
||||||
|
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
|
||||||
|
<ActionGlyph type="calendar" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{facility.weather_url && (
|
||||||
|
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
|
||||||
|
<ActionGlyph type="weather" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{facebook && (
|
||||||
|
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
|
||||||
|
<ActionGlyph type="facebook" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{instagram && (
|
||||||
|
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
|
||||||
|
<ActionGlyph type="instagram" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{buildMapUrl(facility.lat, facility.lng) && (
|
||||||
|
<a
|
||||||
|
href={buildMapUrl(facility.lat, facility.lng) || "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
|
||||||
|
>
|
||||||
|
Åpne kart
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
init.sql
2
init.sql
|
|
@ -20,7 +20,7 @@ CREATE TABLE facilities (
|
||||||
proshop_url VARCHAR(255),
|
proshop_url VARCHAR(255),
|
||||||
cafe_url VARCHAR(255),
|
cafe_url VARCHAR(255),
|
||||||
nsg_agreement BOOLEAN DEFAULT FALSE,
|
nsg_agreement BOOLEAN DEFAULT FALSE,
|
||||||
cooperating_clubs TEXT,
|
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
|
||||||
47
migrations/2026-04-17_add_cooperating_clubs_jsonb.sql
Normal file
47
migrations/2026-04-17_add_cooperating_clubs_jsonb.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'facilities'
|
||||||
|
AND column_name = 'cooperating_clubs'
|
||||||
|
AND udt_name = 'text'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.facilities
|
||||||
|
RENAME COLUMN cooperating_clubs TO cooperating_clubs_legacy;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'facilities'
|
||||||
|
AND column_name = 'cooperating_clubs'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.facilities
|
||||||
|
ADD COLUMN cooperating_clubs jsonb DEFAULT '[]'::jsonb;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities
|
||||||
|
ALTER COLUMN cooperating_clubs SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'facilities'
|
||||||
|
AND column_name = 'cooperating_clubs_legacy'
|
||||||
|
) THEN
|
||||||
|
UPDATE public.facilities
|
||||||
|
SET cooperating_clubs = CASE
|
||||||
|
WHEN cooperating_clubs_legacy IS NULL OR btrim(cooperating_clubs_legacy) = '' THEN '[]'::jsonb
|
||||||
|
WHEN cooperating_clubs_legacy ~ '^\s*\[' THEN cooperating_clubs_legacy::jsonb
|
||||||
|
ELSE to_jsonb(regexp_split_to_array(cooperating_clubs_legacy, '\s*,\s*'))
|
||||||
|
END
|
||||||
|
WHERE cooperating_clubs = '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities
|
||||||
|
DROP COLUMN cooperating_clubs_legacy;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -24,6 +24,7 @@ CREATE TABLE facilities (
|
||||||
website_url VARCHAR(255),
|
website_url VARCHAR(255),
|
||||||
golfbox_booking_url VARCHAR(255),
|
golfbox_booking_url VARCHAR(255),
|
||||||
golfbox_tournament_url VARCHAR(255),
|
golfbox_tournament_url VARCHAR(255),
|
||||||
|
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
|
||||||
facebook_url VARCHAR(255),
|
facebook_url VARCHAR(255),
|
||||||
instagram_url VARCHAR(255),
|
instagram_url VARCHAR(255),
|
||||||
weather_url VARCHAR(255),
|
weather_url VARCHAR(255),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue