Før hastighetsøkning
This commit is contained in:
parent
fec5f4e8c6
commit
dc7ed19f02
7 changed files with 704 additions and 282 deletions
137
backend/course_status_history.py
Normal file
137
backend/course_status_history.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
OSLO_TIMEZONE = ZoneInfo("Europe/Oslo")
|
||||||
|
|
||||||
|
|
||||||
|
def get_oslo_today() -> date:
|
||||||
|
return datetime.now(OSLO_TIMEZONE).date()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_course_status_history_table(conn) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS course_status_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
old_status TEXT,
|
||||||
|
new_status TEXT NOT NULL,
|
||||||
|
change_source TEXT NOT NULL,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_course_status_history_changed_at
|
||||||
|
ON course_status_history (changed_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_course_status_history_course_id
|
||||||
|
ON course_status_history (course_id, changed_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_course_status_history_facility_id
|
||||||
|
ON course_status_history (facility_id, changed_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_course_status_change(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
course_id: int,
|
||||||
|
facility_id: int,
|
||||||
|
old_status: str | None,
|
||||||
|
new_status: str | None,
|
||||||
|
change_source: str,
|
||||||
|
changed_by: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
normalized_old = str(old_status or "").strip().lower() or "ukjent"
|
||||||
|
normalized_new = str(new_status or "").strip().lower() or "ukjent"
|
||||||
|
normalized_source = str(change_source or "").strip().lower()
|
||||||
|
normalized_changed_by = str(changed_by or "").strip() or None
|
||||||
|
|
||||||
|
if not normalized_source:
|
||||||
|
raise ValueError("change_source må være satt")
|
||||||
|
|
||||||
|
if normalized_old == normalized_new:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO course_status_history (
|
||||||
|
course_id,
|
||||||
|
facility_id,
|
||||||
|
old_status,
|
||||||
|
new_status,
|
||||||
|
change_source,
|
||||||
|
changed_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
course_id,
|
||||||
|
facility_id,
|
||||||
|
normalized_old,
|
||||||
|
normalized_new,
|
||||||
|
normalized_source,
|
||||||
|
normalized_changed_by,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_course_status_history(conn, *, changed_on: date | None = None, limit: int = 100):
|
||||||
|
target_date = changed_on or get_oslo_today()
|
||||||
|
start_at = datetime.combine(target_date, time.min, tzinfo=OSLO_TIMEZONE)
|
||||||
|
end_at = start_at + timedelta(days=1)
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.course_id,
|
||||||
|
h.facility_id,
|
||||||
|
h.old_status,
|
||||||
|
h.new_status,
|
||||||
|
h.change_source,
|
||||||
|
h.changed_by,
|
||||||
|
h.changed_at,
|
||||||
|
c.name AS course_name,
|
||||||
|
f.name AS facility_name,
|
||||||
|
f.slug AS facility_slug
|
||||||
|
FROM course_status_history h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
JOIN facilities f ON f.id = h.facility_id
|
||||||
|
WHERE h.changed_at >= $1
|
||||||
|
AND h.changed_at < $2
|
||||||
|
ORDER BY h.changed_at DESC, h.id DESC
|
||||||
|
LIMIT $3
|
||||||
|
""",
|
||||||
|
start_at,
|
||||||
|
end_at,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"course_id": row["course_id"],
|
||||||
|
"facility_id": row["facility_id"],
|
||||||
|
"old_status": row["old_status"],
|
||||||
|
"new_status": row["new_status"],
|
||||||
|
"change_source": row["change_source"],
|
||||||
|
"changed_by": row["changed_by"],
|
||||||
|
"changed_at": row["changed_at"].isoformat() if row["changed_at"] else None,
|
||||||
|
"course_name": row["course_name"],
|
||||||
|
"facility_name": row["facility_name"],
|
||||||
|
"facility_slug": row["facility_slug"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
608
backend/main.py
608
backend/main.py
|
|
@ -22,6 +22,7 @@ import re
|
||||||
import secrets
|
import secrets
|
||||||
import hashlib
|
import hashlib
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import unicodedata
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -41,6 +42,12 @@ from scrape_jobs import (
|
||||||
ensure_scrape_jobs_table,
|
ensure_scrape_jobs_table,
|
||||||
list_scrape_jobs,
|
list_scrape_jobs,
|
||||||
)
|
)
|
||||||
|
from course_status_history import (
|
||||||
|
ensure_course_status_history_table,
|
||||||
|
get_oslo_today,
|
||||||
|
list_course_status_history,
|
||||||
|
log_course_status_change,
|
||||||
|
)
|
||||||
from env_config import get_database_url, get_required_env
|
from env_config import get_database_url, get_required_env
|
||||||
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows
|
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows
|
||||||
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
|
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
|
||||||
|
|
@ -679,6 +686,43 @@ class PublicFacilityFeedbackRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
website: Optional[str] = ""
|
website: Optional[str] = ""
|
||||||
started_at: Optional[int] = None
|
started_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_FACILITY_FIELD_ALIASES = {
|
||||||
|
'vtg_presentasjon': 'vtg_beskrivelse',
|
||||||
|
'vtg_kursdatoer': 'vtg_datoer',
|
||||||
|
}
|
||||||
|
|
||||||
|
FACILITY_ALLOWED_FIELDS = [
|
||||||
|
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
||||||
|
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
||||||
|
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||||
|
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
||||||
|
'image_url', 'logo_url', 'front_image_url', 'gallery',
|
||||||
|
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
||||||
|
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
|
||||||
|
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||||
|
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||||
|
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
||||||
|
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
||||||
|
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
|
||||||
|
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
|
||||||
|
'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
|
||||||
|
'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
|
||||||
|
'golfpakker_draft', 'golfpakker_updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
FACILITY_MEMBERSHIP_FIELDS = {
|
||||||
|
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||||
|
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
|
||||||
|
}
|
||||||
|
FACILITY_VTG_CONTENT_FIELDS = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
|
||||||
|
FACILITY_VTG_COURSE_FIELDS = {'vtg_datoer'}
|
||||||
|
FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS | {
|
||||||
|
'vtg_updated_at',
|
||||||
|
'vtg_content_updated_at',
|
||||||
|
'vtg_courses_updated_at',
|
||||||
|
}
|
||||||
# --- FUNKSJONER ---
|
# --- FUNKSJONER ---
|
||||||
def format_row(row):
|
def format_row(row):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1151,6 +1195,227 @@ def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: lis
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_facility_slug(value: Any) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKD", str(value or "").strip().lower())
|
||||||
|
normalized = "".join(char for char in normalized if not unicodedata.combining(char))
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||||
|
return normalized.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_legacy_facility_field_aliases(data: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
normalized = dict(data or {})
|
||||||
|
for legacy_field, canonical_field in LEGACY_FACILITY_FIELD_ALIASES.items():
|
||||||
|
if legacy_field in normalized and canonical_field not in normalized:
|
||||||
|
normalized[canonical_field] = normalized[legacy_field]
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_facility_indexnow_submission_for_fields(
|
||||||
|
facility_slug: str,
|
||||||
|
changed_field_names: set[str],
|
||||||
|
reason: str,
|
||||||
|
):
|
||||||
|
extra_paths = ["/golfbaner"]
|
||||||
|
if changed_field_names & FACILITY_MEMBERSHIP_FIELDS:
|
||||||
|
extra_paths.append("/medlemskap")
|
||||||
|
if changed_field_names & FACILITY_VTG_FIELDS:
|
||||||
|
extra_paths.append("/vtg")
|
||||||
|
schedule_indexnow_submission(
|
||||||
|
collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]:
|
||||||
|
normalized_data = apply_legacy_facility_field_aliases(data)
|
||||||
|
update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS}
|
||||||
|
changed_field_names = set(update_data.keys())
|
||||||
|
|
||||||
|
facility_slug = str(
|
||||||
|
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
|
||||||
|
).strip()
|
||||||
|
if not facility_slug:
|
||||||
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||||
|
|
||||||
|
facility_columns = await get_table_columns(conn, "facilities")
|
||||||
|
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
|
||||||
|
|
||||||
|
if update_data:
|
||||||
|
if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
|
||||||
|
existing_footnote = await conn.fetchval(
|
||||||
|
"SELECT footnote FROM facilities WHERE id = $1",
|
||||||
|
facility_id
|
||||||
|
)
|
||||||
|
incoming_footnote = str(update_data.get('footnote') or '').strip()
|
||||||
|
current_footnote = str(existing_footnote or '').strip()
|
||||||
|
|
||||||
|
if incoming_footnote != current_footnote:
|
||||||
|
update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
|
||||||
|
|
||||||
|
set_clauses = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
date_fields = [
|
||||||
|
'membership_updated_at',
|
||||||
|
'greenfee_updated_at',
|
||||||
|
'vtg_updated_at',
|
||||||
|
'vtg_content_updated_at',
|
||||||
|
'vtg_courses_updated_at',
|
||||||
|
'status_updated_at',
|
||||||
|
'footnote_updated_at',
|
||||||
|
'golfpakker_updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
if changed_field_names & FACILITY_VTG_CONTENT_FIELDS:
|
||||||
|
vtg_content_ts = datetime.utcnow()
|
||||||
|
update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
|
||||||
|
update_data.setdefault('vtg_updated_at', vtg_content_ts)
|
||||||
|
|
||||||
|
if changed_field_names & FACILITY_VTG_COURSE_FIELDS:
|
||||||
|
vtg_course_ts = datetime.utcnow()
|
||||||
|
update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
|
||||||
|
update_data.setdefault('vtg_updated_at', vtg_course_ts)
|
||||||
|
|
||||||
|
for i, (k, v) in enumerate(update_data.items(), 1):
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
set_clauses.append(f"{k} = ${i}::jsonb")
|
||||||
|
values.append(json.dumps(v))
|
||||||
|
elif k in date_fields:
|
||||||
|
set_clauses.append(f"{k} = ${i}")
|
||||||
|
if v == "" or v is None:
|
||||||
|
values.append(None)
|
||||||
|
else:
|
||||||
|
dt_str = str(v).replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt_obj = datetime.fromisoformat(dt_str)
|
||||||
|
values.append(dt_obj)
|
||||||
|
except ValueError:
|
||||||
|
values.append(None)
|
||||||
|
else:
|
||||||
|
set_clauses.append(f"{k} = ${i}")
|
||||||
|
values.append(v)
|
||||||
|
|
||||||
|
values.append(facility_id)
|
||||||
|
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
||||||
|
await conn.execute(query, *values)
|
||||||
|
|
||||||
|
if 'vtg_datoer' in update_data:
|
||||||
|
await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
|
||||||
|
|
||||||
|
if 'courses' in normalized_data:
|
||||||
|
submitted_courses = [course for course in (normalized_data.get('courses') or []) if course]
|
||||||
|
normalized_courses: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for course in submitted_courses:
|
||||||
|
normalized_course = dict(course)
|
||||||
|
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
|
||||||
|
normalized_courses.append(normalized_course)
|
||||||
|
|
||||||
|
if normalized_courses:
|
||||||
|
if not any(course['is_main_course'] for course in normalized_courses):
|
||||||
|
normalized_courses[0]['is_main_course'] = True
|
||||||
|
else:
|
||||||
|
main_assigned = False
|
||||||
|
for course in normalized_courses:
|
||||||
|
if course['is_main_course'] and not main_assigned:
|
||||||
|
main_assigned = True
|
||||||
|
else:
|
||||||
|
course['is_main_course'] = False
|
||||||
|
|
||||||
|
retained_course_ids: list[int] = []
|
||||||
|
|
||||||
|
for course in normalized_courses:
|
||||||
|
course_id = course.get('id')
|
||||||
|
holes = [hole for hole in (course.get('holes') or []) if hole]
|
||||||
|
hole_count = len(holes) or None
|
||||||
|
course_par = parse_optional_int(course.get('par'))
|
||||||
|
course_length_meters = parse_optional_int(course.get('length_meters'))
|
||||||
|
|
||||||
|
valid_until_str = course.get('slope_valid_until')
|
||||||
|
if valid_until_str == "" or valid_until_str is None:
|
||||||
|
valid_until = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
date_part = str(valid_until_str).split('T')[0]
|
||||||
|
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
valid_until = None
|
||||||
|
|
||||||
|
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
|
||||||
|
|
||||||
|
if course_id:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE courses
|
||||||
|
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
|
||||||
|
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
|
||||||
|
slope_valid_until=$9
|
||||||
|
WHERE id=$10 AND facility_id=$11
|
||||||
|
""",
|
||||||
|
course.get('name'), hole_count, course_par, course_length_meters,
|
||||||
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
tee_boxes_json, valid_until, course_id, facility_id)
|
||||||
|
else:
|
||||||
|
course_id = await conn.fetchval("""
|
||||||
|
INSERT INTO courses (
|
||||||
|
facility_id, name, holes, par, length_meters, architect,
|
||||||
|
status, is_main_course, tee_boxes, slope_valid_until
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
|
||||||
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
tee_boxes_json, valid_until)
|
||||||
|
|
||||||
|
retained_course_ids.append(int(course_id))
|
||||||
|
|
||||||
|
retained_hole_ids: list[int] = []
|
||||||
|
for hole in holes:
|
||||||
|
hole_id = hole.get('id')
|
||||||
|
hole_number = parse_optional_int(hole.get('hole_number'))
|
||||||
|
hole_par = parse_optional_int(hole.get('par'))
|
||||||
|
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
|
||||||
|
lengths_json = json.dumps(hole.get('lengths') or {})
|
||||||
|
if hole_id:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE holes
|
||||||
|
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
|
||||||
|
WHERE id=$5 AND course_id=$6
|
||||||
|
""",
|
||||||
|
hole_number, hole_par, hole_hcp_index,
|
||||||
|
lengths_json, hole_id, course_id)
|
||||||
|
else:
|
||||||
|
hole_id = await conn.fetchval("""
|
||||||
|
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
course_id, hole_number, hole_par, hole_hcp_index,
|
||||||
|
lengths_json)
|
||||||
|
|
||||||
|
retained_hole_ids.append(int(hole_id))
|
||||||
|
|
||||||
|
if retained_hole_ids:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
|
||||||
|
course_id,
|
||||||
|
retained_hole_ids,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
|
||||||
|
|
||||||
|
if retained_course_ids:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
|
||||||
|
facility_id,
|
||||||
|
retained_course_ids,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
|
||||||
|
|
||||||
|
return facility_slug, changed_field_names
|
||||||
|
|
||||||
|
|
||||||
def build_hero_images_from_media_gallery(
|
def build_hero_images_from_media_gallery(
|
||||||
media_gallery: list[dict[str, str]],
|
media_gallery: list[dict[str, str]],
|
||||||
fallback_hero_images: list[dict[str, str]],
|
fallback_hero_images: list[dict[str, str]],
|
||||||
|
|
@ -1825,6 +2090,7 @@ async def lifespan(app: FastAPI):
|
||||||
await ensure_articles_table(conn)
|
await ensure_articles_table(conn)
|
||||||
await ensure_public_user_tables(conn)
|
await ensure_public_user_tables(conn)
|
||||||
await ensure_scrape_jobs_table(conn)
|
await ensure_scrape_jobs_table(conn)
|
||||||
|
await ensure_course_status_history_table(conn)
|
||||||
await ensure_weather_forecast_table(conn)
|
await ensure_weather_forecast_table(conn)
|
||||||
app.state.weather_sync_stop_event = asyncio.Event()
|
app.state.weather_sync_stop_event = asyncio.Event()
|
||||||
app.state.weather_sync_task = asyncio.create_task(
|
app.state.weather_sync_task = asyncio.create_task(
|
||||||
|
|
@ -2827,6 +3093,70 @@ async def get_admin_facility(slug: str):
|
||||||
return format_row(row)
|
return format_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/facilities")
|
||||||
|
async def create_admin_facility(request: Request):
|
||||||
|
"""Oppretter et nytt golfanlegg og lagrer full editor-payload i samme operasjon."""
|
||||||
|
data = apply_legacy_facility_field_aliases(await request.json())
|
||||||
|
|
||||||
|
facility_name = str(data.get("name") or "").strip()
|
||||||
|
if not facility_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Anleggsnavn mangler.")
|
||||||
|
|
||||||
|
normalized_slug = normalize_facility_slug(data.get("slug") or facility_name)
|
||||||
|
if not normalized_slug:
|
||||||
|
raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.")
|
||||||
|
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
existing_id = await conn.fetchval(
|
||||||
|
"SELECT id FROM facilities WHERE slug = $1",
|
||||||
|
normalized_slug,
|
||||||
|
)
|
||||||
|
if existing_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Slug er allerede i bruk.")
|
||||||
|
|
||||||
|
facility_columns = await get_table_columns(conn, "facilities")
|
||||||
|
insert_fields = ["name", "slug"]
|
||||||
|
insert_values: list[Any] = [facility_name, normalized_slug]
|
||||||
|
|
||||||
|
if "is_published" in facility_columns:
|
||||||
|
insert_fields.append("is_published")
|
||||||
|
insert_values.append(bool(data.get("is_published")) if "is_published" in data else False)
|
||||||
|
|
||||||
|
placeholders = ", ".join(f"${index}" for index in range(1, len(insert_values) + 1))
|
||||||
|
created = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO facilities ({", ".join(insert_fields)})
|
||||||
|
VALUES ({placeholders})
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
*insert_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
facility_id = int(created["id"])
|
||||||
|
data["name"] = facility_name
|
||||||
|
data["slug"] = normalized_slug
|
||||||
|
if "is_published" in facility_columns and "is_published" not in data:
|
||||||
|
data["is_published"] = False
|
||||||
|
|
||||||
|
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
||||||
|
saved_row = await conn.fetchrow(
|
||||||
|
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
|
||||||
|
facility_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_facility_indexnow_submission_for_fields(
|
||||||
|
facility_slug,
|
||||||
|
changed_field_names,
|
||||||
|
reason="facility create",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Golfanlegget ble opprettet.",
|
||||||
|
"facility": format_row(saved_row),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/place-pages/{slug}")
|
@app.get("/api/admin/place-pages/{slug}")
|
||||||
async def get_admin_place_page(slug: str):
|
async def get_admin_place_page(slug: str):
|
||||||
normalized_slug = str(slug or "").strip().lower()
|
normalized_slug = str(slug or "").strip().lower()
|
||||||
|
|
@ -3268,7 +3598,7 @@ async def delete_admin_article(article_id: int):
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
||||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate, http_request: Request):
|
||||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
try:
|
try:
|
||||||
|
|
@ -3295,7 +3625,29 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
|
||||||
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
||||||
if settings.scrape_method == 'manual' and settings.courses:
|
if settings.scrape_method == 'manual' and settings.courses:
|
||||||
for c in settings.courses:
|
for c in settings.courses:
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
|
current_course = await conn.fetchrow(
|
||||||
|
"SELECT id, facility_id, status FROM courses WHERE id = $1 AND facility_id = $2",
|
||||||
|
c.id,
|
||||||
|
facility_id,
|
||||||
|
)
|
||||||
|
if not current_course:
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_status = current_course["status"] or "ukjent"
|
||||||
|
new_status = c.status
|
||||||
|
if str(old_status or "").strip().lower() == str(new_status or "").strip().lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
await log_course_status_change(
|
||||||
|
conn,
|
||||||
|
course_id=int(current_course["id"]),
|
||||||
|
facility_id=int(current_course["facility_id"]),
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status,
|
||||||
|
change_source="manual",
|
||||||
|
changed_by=getattr(http_request.state, "admin_username", None),
|
||||||
|
)
|
||||||
|
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
|
||||||
|
|
||||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
||||||
|
|
||||||
|
|
@ -3308,241 +3660,15 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
|
||||||
@app.put("/api/admin/facilities/{facility_id}/full")
|
@app.put("/api/admin/facilities/{facility_id}/full")
|
||||||
async def update_facility_full(facility_id: int, request: Request):
|
async def update_facility_full(facility_id: int, request: Request):
|
||||||
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
||||||
data = await request.json()
|
data = apply_legacy_facility_field_aliases(await request.json())
|
||||||
|
|
||||||
legacy_field_aliases = {
|
|
||||||
'vtg_presentasjon': 'vtg_beskrivelse',
|
|
||||||
'vtg_kursdatoer': 'vtg_datoer',
|
|
||||||
}
|
|
||||||
for legacy_field, canonical_field in legacy_field_aliases.items():
|
|
||||||
if legacy_field in data and canonical_field not in data:
|
|
||||||
data[canonical_field] = data[legacy_field]
|
|
||||||
|
|
||||||
# Felter som er trygge å oppdatere manuelt på anlegget
|
|
||||||
allowed_fields = [
|
|
||||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
|
||||||
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
|
||||||
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
|
||||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
|
||||||
'image_url', 'logo_url', 'front_image_url', 'gallery',
|
|
||||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
|
||||||
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
|
|
||||||
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
|
||||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
|
||||||
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
|
||||||
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
|
||||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
|
|
||||||
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
|
|
||||||
'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
|
|
||||||
'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
|
|
||||||
'golfpakker_draft', 'golfpakker_updated_at'
|
|
||||||
]
|
|
||||||
|
|
||||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
||||||
membership_fields = {
|
|
||||||
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
|
||||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
|
|
||||||
}
|
|
||||||
vtg_content_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
|
|
||||||
vtg_course_fields = {'vtg_datoer'}
|
|
||||||
vtg_fields = vtg_content_fields | vtg_course_fields | {'vtg_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'}
|
|
||||||
changed_field_names = set(update_data.keys())
|
|
||||||
|
|
||||||
facility_slug = ""
|
|
||||||
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():
|
||||||
facility_slug = str(
|
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
||||||
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
|
|
||||||
).strip()
|
|
||||||
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)
|
|
||||||
if update_data:
|
|
||||||
if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
|
|
||||||
existing_footnote = await conn.fetchval(
|
|
||||||
"SELECT footnote FROM facilities WHERE id = $1",
|
|
||||||
facility_id
|
|
||||||
)
|
|
||||||
incoming_footnote = str(update_data.get('footnote') or '').strip()
|
|
||||||
current_footnote = str(existing_footnote or '').strip()
|
|
||||||
|
|
||||||
if incoming_footnote != current_footnote:
|
schedule_facility_indexnow_submission_for_fields(
|
||||||
update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
|
facility_slug,
|
||||||
|
changed_field_names,
|
||||||
set_clauses = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
# Definer hvilke felt som er datoer i databasen
|
|
||||||
date_fields = [
|
|
||||||
'membership_updated_at',
|
|
||||||
'greenfee_updated_at',
|
|
||||||
'vtg_updated_at',
|
|
||||||
'vtg_content_updated_at',
|
|
||||||
'vtg_courses_updated_at',
|
|
||||||
'status_updated_at',
|
|
||||||
'footnote_updated_at',
|
|
||||||
'golfpakker_updated_at'
|
|
||||||
]
|
|
||||||
|
|
||||||
if changed_field_names & vtg_content_fields:
|
|
||||||
vtg_content_ts = datetime.utcnow()
|
|
||||||
update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
|
|
||||||
update_data.setdefault('vtg_updated_at', vtg_content_ts)
|
|
||||||
|
|
||||||
if changed_field_names & vtg_course_fields:
|
|
||||||
vtg_course_ts = datetime.utcnow()
|
|
||||||
update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
|
|
||||||
update_data.setdefault('vtg_updated_at', vtg_course_ts)
|
|
||||||
|
|
||||||
for i, (k, v) in enumerate(update_data.items(), 1):
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
set_clauses.append(f"{k} = ${i}::jsonb")
|
|
||||||
values.append(json.dumps(v))
|
|
||||||
elif k in date_fields:
|
|
||||||
set_clauses.append(f"{k} = ${i}")
|
|
||||||
# Håndter tomme datoer og konverter til Python datetime
|
|
||||||
if v == "" or v is None:
|
|
||||||
values.append(None)
|
|
||||||
else:
|
|
||||||
# Tving strengen over til et ekte datetime-objekt.
|
|
||||||
# .replace() håndterer Next.js' "Z"-format.
|
|
||||||
dt_str = str(v).replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
dt_obj = datetime.fromisoformat(dt_str)
|
|
||||||
values.append(dt_obj)
|
|
||||||
except ValueError:
|
|
||||||
values.append(None)
|
|
||||||
else:
|
|
||||||
set_clauses.append(f"{k} = ${i}")
|
|
||||||
values.append(v)
|
|
||||||
|
|
||||||
values.append(facility_id)
|
|
||||||
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
|
||||||
await conn.execute(query, *values)
|
|
||||||
|
|
||||||
if 'vtg_datoer' in update_data:
|
|
||||||
await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
|
|
||||||
|
|
||||||
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
|
||||||
if 'courses' in data:
|
|
||||||
submitted_courses = [course for course in (data.get('courses') or []) if course]
|
|
||||||
normalized_courses: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for index, course in enumerate(submitted_courses):
|
|
||||||
normalized_course = dict(course)
|
|
||||||
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
|
|
||||||
normalized_courses.append(normalized_course)
|
|
||||||
|
|
||||||
if normalized_courses:
|
|
||||||
if not any(course['is_main_course'] for course in normalized_courses):
|
|
||||||
normalized_courses[0]['is_main_course'] = True
|
|
||||||
else:
|
|
||||||
main_assigned = False
|
|
||||||
for course in normalized_courses:
|
|
||||||
if course['is_main_course'] and not main_assigned:
|
|
||||||
main_assigned = True
|
|
||||||
else:
|
|
||||||
course['is_main_course'] = False
|
|
||||||
|
|
||||||
retained_course_ids: list[int] = []
|
|
||||||
|
|
||||||
for course in normalized_courses:
|
|
||||||
course_id = course.get('id')
|
|
||||||
holes = [hole for hole in (course.get('holes') or []) if hole]
|
|
||||||
hole_count = len(holes) or None
|
|
||||||
course_par = parse_optional_int(course.get('par'))
|
|
||||||
course_length_meters = parse_optional_int(course.get('length_meters'))
|
|
||||||
|
|
||||||
valid_until_str = course.get('slope_valid_until')
|
|
||||||
if valid_until_str == "" or valid_until_str is None:
|
|
||||||
valid_until = None
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
date_part = str(valid_until_str).split('T')[0]
|
|
||||||
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
|
|
||||||
except ValueError:
|
|
||||||
valid_until = None
|
|
||||||
|
|
||||||
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
|
|
||||||
|
|
||||||
if course_id:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
|
|
||||||
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
|
|
||||||
slope_valid_until=$9
|
|
||||||
WHERE id=$10 AND facility_id=$11
|
|
||||||
""",
|
|
||||||
course.get('name'), hole_count, course_par, course_length_meters,
|
|
||||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
|
||||||
tee_boxes_json, valid_until, course_id, facility_id)
|
|
||||||
else:
|
|
||||||
course_id = await conn.fetchval("""
|
|
||||||
INSERT INTO courses (
|
|
||||||
facility_id, name, holes, par, length_meters, architect,
|
|
||||||
status, is_main_course, tee_boxes, slope_valid_until
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
|
|
||||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
|
||||||
tee_boxes_json, valid_until)
|
|
||||||
|
|
||||||
retained_course_ids.append(int(course_id))
|
|
||||||
|
|
||||||
retained_hole_ids: list[int] = []
|
|
||||||
for hole in holes:
|
|
||||||
hole_id = hole.get('id')
|
|
||||||
hole_number = parse_optional_int(hole.get('hole_number'))
|
|
||||||
hole_par = parse_optional_int(hole.get('par'))
|
|
||||||
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
|
|
||||||
lengths_json = json.dumps(hole.get('lengths') or {})
|
|
||||||
if hole_id:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE holes
|
|
||||||
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
|
|
||||||
WHERE id=$5 AND course_id=$6
|
|
||||||
""",
|
|
||||||
hole_number, hole_par, hole_hcp_index,
|
|
||||||
lengths_json, hole_id, course_id)
|
|
||||||
else:
|
|
||||||
hole_id = await conn.fetchval("""
|
|
||||||
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
|
|
||||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
course_id, hole_number, hole_par, hole_hcp_index,
|
|
||||||
lengths_json)
|
|
||||||
|
|
||||||
retained_hole_ids.append(int(hole_id))
|
|
||||||
|
|
||||||
if retained_hole_ids:
|
|
||||||
await conn.execute(
|
|
||||||
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
|
|
||||||
course_id,
|
|
||||||
retained_hole_ids,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
|
|
||||||
|
|
||||||
if retained_course_ids:
|
|
||||||
await conn.execute(
|
|
||||||
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
|
|
||||||
facility_id,
|
|
||||||
retained_course_ids,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
|
|
||||||
|
|
||||||
extra_paths = ["/golfbaner"]
|
|
||||||
if changed_field_names & membership_fields:
|
|
||||||
extra_paths.append("/medlemskap")
|
|
||||||
if changed_field_names & vtg_fields:
|
|
||||||
extra_paths.append("/vtg")
|
|
||||||
schedule_indexnow_submission(
|
|
||||||
collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
|
|
||||||
reason="facility full update",
|
reason="facility full update",
|
||||||
)
|
)
|
||||||
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
||||||
|
|
@ -3580,6 +3706,20 @@ async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit:
|
||||||
return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit)
|
return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/course-status-history")
|
||||||
|
async def get_admin_course_status_history(
|
||||||
|
changed_on: Optional[date] = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
):
|
||||||
|
"""Henter banestatusendringer for en gitt dato, med Oslo som standard for 'i dag'."""
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
return await list_course_status_history(
|
||||||
|
conn,
|
||||||
|
changed_on=changed_on or get_oslo_today(),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/2fa/setup")
|
@app.post("/api/admin/2fa/setup")
|
||||||
async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request):
|
async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request):
|
||||||
"""Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator."""
|
"""Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator."""
|
||||||
|
|
|
||||||
31
frontend/src/app/admin/nytt-anlegg/page.tsx
Normal file
31
frontend/src/app/admin/nytt-anlegg/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { API_URL } from "@/config/constants";
|
||||||
|
import EditFacilityClient from "../rediger/[slug]/EditFacilityClient";
|
||||||
|
|
||||||
|
const EMPTY_FACILITY = {
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
is_published: false,
|
||||||
|
courses: [],
|
||||||
|
gallery: [],
|
||||||
|
greenfee: [],
|
||||||
|
golfpakker: [],
|
||||||
|
social_links: [],
|
||||||
|
vtg_datoer: [],
|
||||||
|
cooperating_clubs: [],
|
||||||
|
amenities: {},
|
||||||
|
nsg_data: {},
|
||||||
|
golfamore_data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NewFacilityPage() {
|
||||||
|
const cookieHeader = (await cookies()).toString();
|
||||||
|
const allRes = await fetch(`${API_URL}/admin/facilities`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allFacilities = allRes.ok ? await allRes.json() : [];
|
||||||
|
|
||||||
|
return <EditFacilityClient initialData={EMPTY_FACILITY} allFacilities={allFacilities} />;
|
||||||
|
}
|
||||||
|
|
@ -1462,6 +1462,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/nytt-anlegg"
|
||||||
|
className="btn btn-md btn-primary"
|
||||||
|
>
|
||||||
|
Nytt anlegg
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/steder"
|
href="/admin/steder"
|
||||||
className="btn btn-md btn-secondary"
|
className="btn btn-md btn-secondary"
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,30 @@ const normalizeStringList = (value: any): string[] => {
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeStringArray = (value: any): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => String(entry || "").trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return normalizeStringArray(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
const getMediaFieldLabel = (field: string) => {
|
const getMediaFieldLabel = (field: string) => {
|
||||||
if (field === 'image_url') return 'hovedbildet';
|
if (field === 'image_url') return 'hovedbildet';
|
||||||
if (field === 'logo_url') return 'logoen';
|
if (field === 'logo_url') return 'logoen';
|
||||||
|
|
@ -480,9 +504,10 @@ const getMediaFieldLabel = (field: string) => {
|
||||||
|
|
||||||
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isCreateMode = typeof initialData?.id !== 'number';
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
...initialData,
|
...initialData,
|
||||||
is_published: initialData?.is_published !== false,
|
is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
|
||||||
courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
|
courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
|
||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = useState('generelt');
|
const [activeTab, setActiveTab] = useState('generelt');
|
||||||
|
|
@ -490,6 +515,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
const [deletingFacility, setDeletingFacility] = useState(false);
|
const [deletingFacility, setDeletingFacility] = useState(false);
|
||||||
const [mediaFeedback, setMediaFeedback] = useState("");
|
const [mediaFeedback, setMediaFeedback] = useState("");
|
||||||
const [uploadingTarget, setUploadingTarget] = useState<string | null>(null);
|
const [uploadingTarget, setUploadingTarget] = useState<string | null>(null);
|
||||||
|
const [slugTouched, setSlugTouched] = useState(Boolean(initialData?.slug));
|
||||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const logoImageInputRef = useRef<HTMLInputElement | null>(null);
|
const logoImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
@ -499,14 +525,27 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
|
|
||||||
// Sørg for at cooperating_clubs er et array
|
// Sørg for at cooperating_clubs er et array
|
||||||
const [coopClubs, setCoopClubs] = useState<string[]>(
|
const [coopClubs, setCoopClubs] = useState<string[]>(
|
||||||
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
|
normalizeStringArray(initialData?.cooperating_clubs)
|
||||||
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const facilityName = String(formData.name || initialData?.name || "").trim() || "Nytt anlegg";
|
||||||
|
const facilitySlug = String(formData.slug || initialData?.slug || "").trim();
|
||||||
|
const publicFacilityUrl = facilitySlug ? `/golfbaner/${facilitySlug}` : "";
|
||||||
|
|
||||||
const handleChange = (field: string, value: any) => {
|
const handleChange = (field: string, value: any) => {
|
||||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setFormData((prev: any) => {
|
||||||
|
const next = { ...prev, name: value };
|
||||||
|
if (isCreateMode && !slugTouched) {
|
||||||
|
next.slug = slugify(value);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateCourses = (updater: (courses: any[]) => any[]) => {
|
const updateCourses = (updater: (courses: any[]) => any[]) => {
|
||||||
const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []);
|
const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []);
|
||||||
handleChange('courses', nextCourses);
|
handleChange('courses', nextCourses);
|
||||||
|
|
@ -656,17 +695,31 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch(`/api/admin/facilities/${initialData.id}/full`, {
|
const endpoint = isCreateMode ? '/api/admin/facilities' : `/api/admin/facilities/${initialData.id}/full`;
|
||||||
method: 'PUT',
|
const method = isCreateMode ? 'POST' : 'PUT';
|
||||||
|
const res = await adminFetch(endpoint, {
|
||||||
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(formData)
|
body: JSON.stringify(formData)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Lagret suksessfullt!");
|
if (isCreateMode) {
|
||||||
router.refresh();
|
const payload = await res.json();
|
||||||
|
const createdSlug = String(payload?.facility?.slug || "").trim();
|
||||||
|
if (!createdSlug) {
|
||||||
|
alert("Anlegget ble opprettet, men mangler slug i svaret.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/admin/rediger/${createdSlug}`);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
alert("Lagret suksessfullt!");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert("Noe gikk galt under lagring.");
|
const error = await res.json().catch(() => null);
|
||||||
|
alert(error?.detail || "Noe gikk galt under lagring.");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Nettverksfeil.");
|
alert("Nettverksfeil.");
|
||||||
|
|
@ -675,6 +728,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFacility = async () => {
|
const handleDeleteFacility = async () => {
|
||||||
|
if (isCreateMode) return;
|
||||||
const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`);
|
const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
|
@ -723,42 +777,44 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
<div>
|
<div>
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||||
<h1 className="text-4xl font-black text-[#11280f]">
|
<h1 className="text-4xl font-black text-[#11280f]">
|
||||||
Rediger:{" "}
|
{isCreateMode ? "Nytt anlegg: " : "Rediger: "}
|
||||||
{formData.is_published ? (
|
{!isCreateMode && formData.is_published && publicFacilityUrl ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/golfbaner/${initialData.slug}`}
|
href={publicFacilityUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#8bc34a]"
|
className="text-[#8bc34a]"
|
||||||
title="Åpne anleggssiden i ny fane"
|
title="Åpne anleggssiden i ny fane"
|
||||||
>
|
>
|
||||||
{initialData.name}
|
{facilityName}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[#8bc34a]">{initialData.name}</span>
|
<span className="text-[#8bc34a]">{facilityName}</span>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest">
|
<p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest">
|
||||||
<span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}>
|
<span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}>
|
||||||
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'}
|
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {initialData.slug}</span>
|
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {facilitySlug || 'ikke satt ennå'}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
|
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
|
||||||
<button
|
{!isCreateMode ? (
|
||||||
onClick={handleDeleteFacility}
|
<button
|
||||||
disabled={deletingFacility}
|
onClick={handleDeleteFacility}
|
||||||
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
|
disabled={deletingFacility}
|
||||||
>
|
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
|
||||||
{deletingFacility ? "Sletter..." : "Slett anlegg"}
|
>
|
||||||
</button>
|
{deletingFacility ? "Sletter..." : "Slett anlegg"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
|
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "Lagrer..." : "Lagre endringer"}
|
{saving ? (isCreateMode ? "Oppretter..." : "Lagrer...") : (isCreateMode ? "Opprett anlegg" : "Lagre endringer")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -801,8 +857,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
|
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleNameChange(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{isCreateMode ? (
|
||||||
|
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Slug</label>
|
||||||
|
<input
|
||||||
|
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none"
|
||||||
|
value={facilitySlug}
|
||||||
|
onChange={e => {
|
||||||
|
setSlugTouched(true);
|
||||||
|
handleChange('slug', slugify(e.target.value));
|
||||||
|
}}
|
||||||
|
placeholder="f.eks. oslo-golfklubb"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">Brukes i URL-en. Hvis du lar feltet stå tomt, foreslås slug automatisk fra navnet.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
|
||||||
|
|
@ -932,7 +1003,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]">
|
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]">
|
||||||
<div className="aspect-[16/10]">
|
<div className="aspect-[16/10]">
|
||||||
{getValue('image_url', 'text') ? (
|
{getValue('image_url', 'text') ? (
|
||||||
<img src={getValue('image_url', 'text')} alt={`${initialData.name} hovedbilde`} className="h-full w-full object-cover" />
|
<img src={getValue('image_url', 'text')} alt={`${facilityName} hovedbilde`} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70">
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70">
|
||||||
Ingen hovedbilde valgt
|
Ingen hovedbilde valgt
|
||||||
|
|
@ -978,7 +1049,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]">
|
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]">
|
||||||
<div className="aspect-square max-w-[240px]">
|
<div className="aspect-square max-w-[240px]">
|
||||||
{getValue('logo_url', 'text') ? (
|
{getValue('logo_url', 'text') ? (
|
||||||
<img src={getValue('logo_url', 'text')} alt={`${initialData.name} logo`} className="h-full w-full object-contain p-4" />
|
<img src={getValue('logo_url', 'text')} alt={`${facilityName} logo`} className="h-full w-full object-contain p-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45">
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45">
|
||||||
Ingen logo valgt
|
Ingen logo valgt
|
||||||
|
|
@ -1024,7 +1095,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
<div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]">
|
||||||
<div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]">
|
<div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]">
|
||||||
<div className="aspect-[4/3]">
|
<div className="aspect-[4/3]">
|
||||||
<img src={url} alt={`${initialData.name} galleri ${index + 1}`} className="h-full w-full object-cover" />
|
<img src={url} alt={`${facilityName} galleri ${index + 1}`} className="h-full w-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { unstable_cache } from "next/cache";
|
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData";
|
import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData";
|
||||||
|
|
||||||
|
|
@ -115,23 +114,19 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
|
||||||
return Object.fromEntries(aliases);
|
return Object.fromEntries(aliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedFacilityAliasMap = unstable_cache(
|
async function getFacilityAliasMap() {
|
||||||
async () => {
|
const response = await fetch(`${API_URL}/facilities?summary=true`, {
|
||||||
const response = await fetch(`${API_URL}/facilities`, {
|
cache: "no-store",
|
||||||
next: { revalidate: 3600 },
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : [];
|
const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : [];
|
||||||
return buildFacilityAliasMap(facilities);
|
return buildFacilityAliasMap(facilities);
|
||||||
},
|
}
|
||||||
["facility-short-aliases"],
|
|
||||||
{ revalidate: 3600 },
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function resolveFacilityAlias(alias: string) {
|
export async function resolveFacilityAlias(alias: string) {
|
||||||
const normalizedAlias = slugify(alias);
|
const normalizedAlias = slugify(alias);
|
||||||
|
|
@ -140,6 +135,6 @@ export async function resolveFacilityAlias(alias: string) {
|
||||||
return MANUAL_FACILITY_ALIASES[normalizedAlias];
|
return MANUAL_FACILITY_ALIASES[normalizedAlias];
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliasMap = await getCachedFacilityAliasMap();
|
const aliasMap = await getFacilityAliasMap();
|
||||||
return aliasMap[normalizedAlias] || null;
|
return aliasMap[normalizedAlias] || null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,41 @@ const formatPhoneForUrl = (phone: string) => {
|
||||||
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
|
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getYouTubeEmbedUrl = (url: string) => {
|
||||||
|
const match = url.match(
|
||||||
|
/(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i,
|
||||||
|
);
|
||||||
|
return match ? `https://www.youtube.com/embed/${match[1]}?rel=0` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVimeoEmbedUrl = (url: string) => {
|
||||||
|
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
|
||||||
|
return match ? `https://player.vimeo.com/video/${match[1]}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFacilityVideoEmbedUrl = (url: string | null | undefined) => {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
return getYouTubeEmbedUrl(raw) || getVimeoEmbedUrl(raw) || raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getYrMeteogramUrl = (url: string | null | undefined) => {
|
||||||
|
const raw = String(url || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const normalized = raw.replace(/\/$/, "");
|
||||||
|
const locationIdMatch = normalized.match(/\/(10-\d+)(?:\/|$)/);
|
||||||
|
if (locationIdMatch) {
|
||||||
|
return `https://www.yr.no/nb/innhold/${locationIdMatch[1]}/meteogram.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("/graf/dag/")) {
|
||||||
|
return `${normalized.replace("/graf/dag/", "/innhold/")}/meteogram.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized}/meteogram.svg`;
|
||||||
|
};
|
||||||
|
|
||||||
const renderValue = (val: unknown, fallback = "Nei") => {
|
const renderValue = (val: unknown, fallback = "Nei") => {
|
||||||
const raw = String(val || "").trim();
|
const raw = String(val || "").trim();
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
|
|
@ -285,7 +320,8 @@ export default function FacilityDetailView({
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
||||||
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
const weatherImg = getYrMeteogramUrl(facility.weather_url);
|
||||||
|
const videoEmbedUrl = getFacilityVideoEmbedUrl(facility.video_url);
|
||||||
const getGalleryImageAlt = (imageUrl: string) => {
|
const getGalleryImageAlt = (imageUrl: string) => {
|
||||||
const normalized = String(imageUrl || "").trim().toLowerCase();
|
const normalized = String(imageUrl || "").trim().toLowerCase();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -601,7 +637,7 @@ export default function FacilityDetailView({
|
||||||
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
||||||
<div className="w-full flex justify-center px-4 md:px-0">
|
<div className="w-full flex justify-center px-4 md:px-0">
|
||||||
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
{weatherImg ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -624,11 +660,17 @@ export default function FacilityDetailView({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 7. VIDEO SEKSJON */}
|
{/* 7. VIDEO SEKSJON */}
|
||||||
{facility.video_url && (
|
{videoEmbedUrl && (
|
||||||
<section id="video" className="space-y-6">
|
<section id="video" 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">Video <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">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
||||||
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
|
<iframe
|
||||||
|
src={videoEmbedUrl}
|
||||||
|
title={`Video fra ${facility.name}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue