fix(seo): comprehensive SEO improvements per expert audit #4
No files matched your search
@@ -2,7 +2,7 @@ name: CI Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop, feature/tests-and-ci, feature/multi-admin-roles]
|
branches: ['**']
|
||||||
|
kfj001 marked this conversation as resolved
Outdated
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
|
|||||||
|
"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend.
|
||||||
|
kfj001
commented
This needs a unit test. This needs a unit test.
|
|||||||
|
|
||||||
|
Because this is a white-label app where each tenant has its own domain
|
||||||
|
(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated
|
||||||
|
on-the-fly from the station config — no static files needed.
|
||||||
|
|
||||||
|
This matches the reviewer feedback:
|
||||||
|
'This is a whitelabel app - this file may need to be dynaimcally generated...'
|
||||||
|
|
||||||
|
Follows the same pattern as theme_export.py: content is derived from
|
||||||
|
KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||||
|
|
||||||
|
from app.auth import get_current_admin_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.sitemap_export import build_robots_txt, build_sitemap_xml
|
||||||
|
from app.user_models import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sitemap.xml")
|
||||||
|
async def get_sitemap() -> Response:
|
||||||
|
"""Return a dynamically-generated sitemap.xml. Public endpoint.
|
||||||
|
|
||||||
|
Uses KMTN_WEBSITE_URL so each tenant gets the correct domain.
|
||||||
|
"""
|
||||||
|
xml = build_sitemap_xml(settings.WEBSITE_URL)
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/robots.txt")
|
||||||
|
async def get_robots() -> Response:
|
||||||
|
"""Return a dynamically-generated robots.txt. Public endpoint.
|
||||||
|
|
||||||
|
Uses KMTN_WEBSITE_URL so the Sitemap directive points to the
|
||||||
|
correct tenant domain.
|
||||||
|
"""
|
||||||
|
txt = build_robots_txt(settings.WEBSITE_URL)
|
||||||
|
return Response(content=txt, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/regenerate", status_code=http_status.HTTP_200_OK)
|
||||||
|
async def regenerate_sitemap(
|
||||||
|
_: User = Depends(get_current_admin_user),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Force regenerate the sitemap cache. Admin only.
|
||||||
|
|
||||||
|
In the dynamic model this is a no-op (files are generated per-request),
|
||||||
|
but kept for API consistency with the theme endpoint.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL",
|
||||||
|
"base_url": settings.WEBSITE_URL,
|
||||||
|
}
|
||||||
+7
-1
@@ -10,7 +10,7 @@ from app.config import settings
|
|||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
|
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme, sitemap
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_orphaned_sequences(sync_conn):
|
def _cleanup_orphaned_sequences(sync_conn):
|
||||||
@@ -78,6 +78,11 @@ def _migrate_add_missing_columns(sync_conn):
|
|||||||
("station_config", "color_black", sa.String(7), "#1a1816"),
|
("station_config", "color_black", sa.String(7), "#1a1816"),
|
||||||
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
|
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
|
||||||
("users", "password_hash", sa.String(255), None),
|
("users", "password_hash", sa.String(255), None),
|
||||||
|
# Social media columns
|
||||||
|
("station_config", "social_facebook", sa.String(500), ""),
|
||||||
|
("station_config", "social_twitter", sa.String(500), ""),
|
||||||
|
("station_config", "social_instagram", sa.String(500), ""),
|
||||||
|
("station_config", "social_youtube", sa.String(500), ""),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Determine dialect
|
# Determine dialect
|
||||||
@@ -230,6 +235,7 @@ app.include_router(underwriters.router, prefix="/api/underwriters", tags=["under
|
|||||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||||
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
||||||
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
||||||
|
app.include_router(sitemap.router, prefix="/api/sitemap", tags=["sitemap"])
|
||||||
|
|
||||||
# Admin user management (available in all environments)
|
# Admin user management (available in all environments)
|
||||||
from app.api import users
|
from app.api import users
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ class StationConfig(Base):
|
|||||||
# Donation
|
# Donation
|
||||||
donation_url = Column(String(500), nullable=False, default="")
|
donation_url = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
|
# Social media
|
||||||
|
social_facebook = Column(String(500), nullable=False, default="")
|
||||||
|
social_twitter = Column(String(500), nullable=False, default="")
|
||||||
|
social_instagram = Column(String(500), nullable=False, default="")
|
||||||
|
social_youtube = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
# Stream
|
# Stream
|
||||||
stream_url = Column(String(500), nullable=False, default="")
|
stream_url = Column(String(500), nullable=False, default="")
|
||||||
stream_metadata_url = Column(String(500), nullable=False, default="")
|
stream_metadata_url = Column(String(500), nullable=False, default="")
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class StationConfigResponse(BaseModel):
|
|||||||
play_store_icon_url: str
|
play_store_icon_url: str
|
||||||
play_store_url: str
|
play_store_url: str
|
||||||
app_store_embed_html: str
|
app_store_embed_html: str
|
||||||
|
# Social media
|
||||||
|
social_facebook: str
|
||||||
|
social_twitter: str
|
||||||
|
social_instagram: str
|
||||||
|
social_youtube: str
|
||||||
# Page content
|
# Page content
|
||||||
hero_subtitle: str
|
hero_subtitle: str
|
||||||
about_intro: str
|
about_intro: str
|
||||||
@@ -129,6 +134,11 @@ class StationConfigUpdate(BaseModel):
|
|||||||
play_store_icon_url: Optional[str] = None
|
play_store_icon_url: Optional[str] = None
|
||||||
play_store_url: Optional[str] = None
|
play_store_url: Optional[str] = None
|
||||||
app_store_embed_html: Optional[str] = None
|
app_store_embed_html: Optional[str] = None
|
||||||
|
# Social media
|
||||||
|
social_facebook: Optional[str] = None
|
||||||
|
social_twitter: Optional[str] = None
|
||||||
|
social_instagram: Optional[str] = None
|
||||||
|
social_youtube: Optional[str] = None
|
||||||
# Page content
|
# Page content
|
||||||
hero_subtitle: Optional[str] = None
|
hero_subtitle: Optional[str] = None
|
||||||
about_intro: Optional[str] = None
|
about_intro: Optional[str] = None
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Sitemap & robots.txt generation: tenant-aware SEO content.
|
||||||
|
kfj001 marked this conversation as resolved
kfj001
commented
Unit test for this type too. Unit test for this type too.
|
|||||||
|
|
||||||
|
Because this is a white-label app where each tenant has its own domain
|
||||||
|
(set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated
|
||||||
|
at runtime — no static files are needed. The FastAPI /api/sitemap/*
|
||||||
|
endpoints call these functions; Nginx proxies /sitemap.xml and
|
||||||
|
/robots.txt to those endpoints so search-engine crawlers hit the right
|
||||||
|
content at the root URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Public routes included in the sitemap with their change frequency and priority.
|
||||||
|
SEOMAP_ROUTES = [
|
||||||
|
{"path": "/", "changefreq": "daily", "priority": 1.0},
|
||||||
|
{"path": "/about", "changefreq": "weekly", "priority": 0.8},
|
||||||
|
{"path": "/schedule", "changefreq": "weekly", "priority": 0.8},
|
||||||
|
{"path": "/events", "changefreq": "daily", "priority": 0.8},
|
||||||
|
{"path": "/contact", "changefreq": "monthly", "priority": 0.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemap_xml(base_url: str) -> str:
|
||||||
|
"""Build a valid sitemap.xml string for the given base URL."""
|
||||||
|
url = base_url.rstrip("/")
|
||||||
|
entries = []
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
entries.append(
|
||||||
|
f" <url>\n"
|
||||||
|
f" <loc>{url}{route['path']}</loc>\n"
|
||||||
|
f" <changefreq>{route['changefreq']}</changefreq>\n"
|
||||||
|
f" <priority>{route['priority']}</priority>\n"
|
||||||
|
f" </url>"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
+ "\n".join(entries) + "\n"
|
||||||
|
"</urlset>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_robots_txt(base_url: str) -> str:
|
||||||
|
"""Build a robots.txt string that references the correct sitemap URL."""
|
||||||
|
sitemap = base_url.rstrip("/") + "/sitemap.xml"
|
||||||
|
return (
|
||||||
|
"User-agent: *\n"
|
||||||
|
"Allow: /\n"
|
||||||
|
"\n"
|
||||||
|
"Disallow: /admin\n"
|
||||||
|
"Disallow: /login\n"
|
||||||
|
"\n"
|
||||||
|
f"Sitemap: {sitemap}\n"
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitemapEndpoints:
|
||||||
|
"""Tests for sitemap API routes."""
|
||||||
|
|
||||||
|
def _client(self):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_sitemap_xml_status(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_sitemap_xml_content_type(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
assert resp.headers["content-type"].startswith("application/xml")
|
||||||
|
|
||||||
|
def test_sitemap_xml_contains_routes(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
body = resp.text
|
||||||
|
for path in ["/", "/about", "/schedule", "/events", "/contact"]:
|
||||||
|
assert f"<loc>https://kmountainflower.org{path}</loc>" in body
|
||||||
|
|
||||||
|
def test_robots_txt_status(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_robots_txt_content_type(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
assert resp.headers["content-type"].startswith("text/plain")
|
||||||
|
|
||||||
|
def test_robots_txt_contains_rules(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
body = resp.text
|
||||||
|
assert "User-agent: *" in body
|
||||||
|
assert "Disallow: /admin" in body
|
||||||
|
assert "Disallow: /login" in body
|
||||||
|
assert "Sitemap: https://kmountainflower.org/sitemap.xml" in body
|
||||||
|
|
||||||
|
def test_regenerate_requires_auth(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.post("/api/sitemap/regenerate")
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for sitemap_export — sitemap.xml and robots.txt generation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from app.sitemap_export import (
|
||||||
|
SEOMAP_ROUTES,
|
||||||
|
build_sitemap_xml,
|
||||||
|
build_robots_txt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitemapRoutes:
|
||||||
|
"""Verify the route configuration constants."""
|
||||||
|
|
||||||
|
def test_route_count(self):
|
||||||
|
assert len(SEOMAP_ROUTES) == 5
|
||||||
|
|
||||||
|
def test_home_route(self):
|
||||||
|
assert SEOMAP_ROUTES[0] == {
|
||||||
|
"path": "/",
|
||||||
|
"changefreq": "daily",
|
||||||
|
"priority": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_all_paths_present(self):
|
||||||
|
paths = [r["path"] for r in SEOMAP_ROUTES]
|
||||||
|
expected = ["/", "/about", "/schedule", "/events", "/contact"]
|
||||||
|
assert paths == expected
|
||||||
|
|
||||||
|
def test_priority_range(self):
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert 0.0 <= route["priority"] <= 1.0
|
||||||
|
|
||||||
|
def test_valid_changefreq_values(self):
|
||||||
|
valid = {"always", "hourly", "daily", "weekly", "monthly", "yearly", "never"}
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert route["changefreq"] in valid
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSitemapXml:
|
||||||
|
"""Tests for the sitemap XML builder."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_contains_xml_declaration(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert '<?xml version="1.0" encoding="UTF-8"?>' in result
|
||||||
|
|
||||||
|
def test_contains_urlset_namespace(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' in result
|
||||||
|
|
||||||
|
def test_contains_all_routes(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert f"<loc>https://example.com{route['path']}</loc>" in result
|
||||||
|
|
||||||
|
def test_strips_trailing_slash_from_base_url(self):
|
||||||
|
result = build_sitemap_xml("https://example.com/")
|
||||||
|
assert "https://example.com//about" not in result
|
||||||
|
assert "<loc>https://example.com/about</loc>" in result
|
||||||
|
|
||||||
|
def test_different_base_url(self):
|
||||||
|
result = build_sitemap_xml("https://mystation.org")
|
||||||
|
assert "https://mystation.org/schedule" in result
|
||||||
|
assert "https://example.com" not in result
|
||||||
|
|
||||||
|
def test_valid_xml(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
# Should parse without error
|
||||||
|
root = ET.fromstring(result)
|
||||||
|
# The root tag includes the namespace
|
||||||
|
assert "urlset" in root.tag
|
||||||
|
|
||||||
|
def test_contains_changefreq_and_priority(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert "<changefreq>daily</changefreq>" in result
|
||||||
|
assert "<priority>1.0</priority>" in result
|
||||||
|
assert "<priority>0.5</priority>" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRobotsTxt:
|
||||||
|
"""Tests for the robots.txt builder."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_user_agent_allows_all(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "User-agent: *" in result
|
||||||
|
assert "Allow: /" in result
|
||||||
|
|
||||||
|
def test_disallows_admin_and_login(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "Disallow: /admin" in result
|
||||||
|
assert "Disallow: /login" in result
|
||||||
|
|
||||||
|
def test_sitemap_reference(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "Sitemap: https://example.com/sitemap.xml" in result
|
||||||
|
|
||||||
|
def test_strips_trailing_slash(self):
|
||||||
|
result = build_robots_txt("https://example.com/")
|
||||||
|
assert "Sitemap: https://example.com//sitemap.xml" not in result
|
||||||
|
assert "Sitemap: https://example.com/sitemap.xml" in result
|
||||||
|
|
||||||
|
def test_different_base_url(self):
|
||||||
|
result = build_robots_txt("https://mystation.org")
|
||||||
|
assert "Sitemap: https://mystation.org/sitemap.xml" in result
|
||||||
@@ -24,6 +24,24 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
|
||||||
|
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
|
||||||
|
location = /sitemap.xml {
|
||||||
|
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API calls to the backend service
|
# Proxy API calls to the backend service
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass __API_UPSTREAM__;
|
proxy_pass __API_UPSTREAM__;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
@if (config().donation_url) {
|
@if (config().donation_url) {
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<div class="about-flower-divider" aria-hidden="true">
|
<div class="about-flower-divider" aria-hidden="true">
|
||||||
<img [src]="resolveImageUrl(config().logo_url)" alt="">
|
<img [src]="resolveImageUrl(config().logo_url)" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h3>Become a Member</h3>
|
<h3>Become a Member</h3>
|
||||||
<p class="section-intro">{{ config().about_donation_text }}</p>
|
<p class="section-intro">{{ config().about_donation_text }}</p>
|
||||||
|
|||||||
@@ -184,6 +184,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Media -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Social Media</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_facebook">Facebook URL</label>
|
||||||
|
<input id="social_facebook" type="url" [(ngModel)]="social_facebook" name="social_facebook"
|
||||||
|
placeholder="https://facebook.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_twitter">Twitter / X URL</label>
|
||||||
|
<input id="social_twitter" type="url" [(ngModel)]="social_twitter" name="social_twitter"
|
||||||
|
placeholder="https://x.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_instagram">Instagram URL</label>
|
||||||
|
<input id="social_instagram" type="url" [(ngModel)]="social_instagram" name="social_instagram"
|
||||||
|
placeholder="https://instagram.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_youtube">YouTube URL</label>
|
||||||
|
<input id="social_youtube" type="url" [(ngModel)]="social_youtube" name="social_youtube"
|
||||||
|
placeholder="https://youtube.com/@yourstation">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>Page Content</h3>
|
<h3>Page Content</h3>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
play_store_icon_url = '';
|
play_store_icon_url = '';
|
||||||
play_store_url = '';
|
play_store_url = '';
|
||||||
app_store_embed_html = '';
|
app_store_embed_html = '';
|
||||||
|
social_facebook = '';
|
||||||
|
social_twitter = '';
|
||||||
|
social_instagram = '';
|
||||||
|
social_youtube = '';
|
||||||
hero_subtitle = '';
|
hero_subtitle = '';
|
||||||
about_intro = '';
|
about_intro = '';
|
||||||
about_donation_text = '';
|
about_donation_text = '';
|
||||||
@@ -84,6 +88,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
this.play_store_icon_url = config.play_store_icon_url;
|
this.play_store_icon_url = config.play_store_icon_url;
|
||||||
this.play_store_url = config.play_store_url;
|
this.play_store_url = config.play_store_url;
|
||||||
this.app_store_embed_html = config.app_store_embed_html;
|
this.app_store_embed_html = config.app_store_embed_html;
|
||||||
|
this.social_facebook = config.social_facebook;
|
||||||
|
this.social_twitter = config.social_twitter;
|
||||||
|
this.social_instagram = config.social_instagram;
|
||||||
|
this.social_youtube = config.social_youtube;
|
||||||
this.hero_subtitle = config.hero_subtitle;
|
this.hero_subtitle = config.hero_subtitle;
|
||||||
this.about_intro = config.about_intro;
|
this.about_intro = config.about_intro;
|
||||||
this.about_donation_text = config.about_donation_text;
|
this.about_donation_text = config.about_donation_text;
|
||||||
@@ -180,6 +188,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
play_store_icon_url: this.play_store_icon_url,
|
play_store_icon_url: this.play_store_icon_url,
|
||||||
play_store_url: this.play_store_url,
|
play_store_url: this.play_store_url,
|
||||||
app_store_embed_html: this.app_store_embed_html,
|
app_store_embed_html: this.app_store_embed_html,
|
||||||
|
social_facebook: this.social_facebook,
|
||||||
|
social_twitter: this.social_twitter,
|
||||||
|
social_instagram: this.social_instagram,
|
||||||
|
social_youtube: this.social_youtube,
|
||||||
hero_subtitle: this.hero_subtitle,
|
hero_subtitle: this.hero_subtitle,
|
||||||
about_intro: this.about_intro,
|
about_intro: this.about_intro,
|
||||||
about_donation_text: this.about_donation_text,
|
about_donation_text: this.about_donation_text,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
@if (event.rsvp_url) {
|
@if (event.rsvp_url) {
|
||||||
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP →</a>
|
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP →</a>
|
||||||
} @else {
|
} @else {
|
||||||
<a href="#" class="event-rsvp">Learn More →</a>
|
<a routerLink="/contact" [queryParams]="{subject: 'general'}" class="event-rsvp">Learn More →</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
||||||
|
import { RouterLinkWithHref } from '@angular/router';
|
||||||
import { map, Observable, startWith } from 'rxjs';
|
import { map, Observable, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
@@ -14,7 +15,7 @@ interface EventsState {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-events',
|
selector: 'app-events',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AsyncPipe, DatePipe, NgFor],
|
imports: [AsyncPipe, DatePipe, NgFor, RouterLinkWithHref],
|
||||||
templateUrl: './events.component.html',
|
templateUrl: './events.component.html',
|
||||||
styleUrl: './events.component.scss',
|
styleUrl: './events.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,18 +35,26 @@
|
|||||||
@if (config().donation_url) {
|
@if (config().donation_url) {
|
||||||
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
|
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
|
||||||
}
|
}
|
||||||
<li><a href="#">Volunteer</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'volunteer'}">Volunteer</a></li>
|
||||||
<li><a href="#">Corporate Giving</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'advertising'}">Corporate Giving</a></li>
|
||||||
<li><a href="#">Planned Giving</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'general'}">Planned Giving</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Follow Us</h4>
|
<h4>Follow Us</h4>
|
||||||
<div class="social-links">
|
<div class="social-links">
|
||||||
<a href="#" aria-label="Facebook" class="social-link">📘</a>
|
@if (config().social_facebook) {
|
||||||
<a href="#" aria-label="Twitter" class="social-link">🐦</a>
|
<a [href]="config().social_facebook" target="_blank" rel="noopener noreferrer" aria-label="Facebook" class="social-link">📘</a>
|
||||||
<a href="#" aria-label="Instagram" class="social-link">📷</a>
|
}
|
||||||
<a href="#" aria-label="YouTube" class="social-link">📺</a>
|
@if (config().social_twitter) {
|
||||||
|
<a [href]="config().social_twitter" target="_blank" rel="noopener noreferrer" aria-label="Twitter" class="social-link">🐦</a>
|
||||||
|
}
|
||||||
|
@if (config().social_instagram) {
|
||||||
|
<a [href]="config().social_instagram" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="social-link">📷</a>
|
||||||
|
}
|
||||||
|
@if (config().social_youtube) {
|
||||||
|
<a [href]="config().social_youtube" target="_blank" rel="noopener noreferrer" aria-label="YouTube" class="social-link">📺</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, computed } from '@angular/core';
|
import { Component, inject, computed } from '@angular/core';
|
||||||
import { SlicePipe } from '@angular/common';
|
import { SlicePipe } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLinkWithHref } from '@angular/router';
|
||||||
|
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { resolveImageUrl } from '../services/app-config.service';
|
import { resolveImageUrl } from '../services/app-config.service';
|
||||||
@@ -8,7 +8,7 @@ import { resolveImageUrl } from '../services/app-config.service';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, SlicePipe],
|
imports: [RouterLinkWithHref, SlicePipe],
|
||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrl: './footer.component.scss',
|
styleUrl: './footer.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface StationConfig {
|
|||||||
play_store_icon_url: string;
|
play_store_icon_url: string;
|
||||||
play_store_url: string;
|
play_store_url: string;
|
||||||
app_store_embed_html: string;
|
app_store_embed_html: string;
|
||||||
|
// Social media
|
||||||
|
kfj001 marked this conversation as resolved
kfj001
commented
I don't think we've added any admin UI to set these values. I don't think we've added any admin UI to set these values.
|
|||||||
|
social_facebook: string;
|
||||||
|
social_twitter: string;
|
||||||
|
social_instagram: string;
|
||||||
|
social_youtube: string;
|
||||||
// Page content
|
// Page content
|
||||||
hero_subtitle: string;
|
hero_subtitle: string;
|
||||||
about_intro: string;
|
about_intro: string;
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
play_store_icon_url: '',
|
play_store_icon_url: '',
|
||||||
play_store_url: '',
|
play_store_url: '',
|
||||||
app_store_embed_html: '',
|
app_store_embed_html: '',
|
||||||
|
// Social media
|
||||||
|
social_facebook: '',
|
||||||
|
social_twitter: '',
|
||||||
|
social_instagram: '',
|
||||||
|
social_youtube: '',
|
||||||
// Page content
|
// Page content
|
||||||
hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.',
|
hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.',
|
||||||
about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',
|
about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',
|
||||||
|
|||||||
Reference in new issue
Block a user
There has got to be a more general way to ensure this runs on any branch on push.