data driven about us screen

This commit is contained in:
2026-06-21 05:24:40 +00:00
parent f52625b37b
commit 1b537b3dfd
34 changed files with 2119 additions and 44 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+79
View File
@@ -0,0 +1,79 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import CommunityHighlight
from app.schemas import CommunityHighlightCreate, CommunityHighlightUpdate, CommunityHighlightResponse
from app.user_models import User
router = APIRouter()
@router.get("/", response_model=list[CommunityHighlightResponse])
async def list_community_highlights(
active: Optional[bool] = Query(None, description="Filter by active status"),
session: AsyncSession = Depends(get_session),
):
query = select(CommunityHighlight).order_by(CommunityHighlight.display_order)
if active is not None:
query = query.where(CommunityHighlight.active == active)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{highlight_id}", response_model=CommunityHighlightResponse)
async def get_community_highlight(highlight_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(CommunityHighlight).where(CommunityHighlight.id == highlight_id))
highlight = result.scalar_one_or_none()
if not highlight:
return {"error": "Community highlight not found"}
return highlight
@router.post("/", response_model=CommunityHighlightResponse, status_code=201)
async def create_community_highlight(
payload: CommunityHighlightCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
highlight = CommunityHighlight(**payload.model_dump(), active=True)
session.add(highlight)
await session.commit()
await session.refresh(highlight)
return highlight
@router.put("/{highlight_id}", response_model=CommunityHighlightResponse)
async def update_community_highlight(
highlight_id: int,
payload: CommunityHighlightUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(CommunityHighlight).where(CommunityHighlight.id == highlight_id))
highlight = result.scalar_one_or_none()
if not highlight:
return {"error": "Community highlight not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(highlight, key, value)
await session.commit()
await session.refresh(highlight)
return highlight
@router.delete("/{highlight_id}", status_code=204)
async def delete_community_highlight(
highlight_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(CommunityHighlight).where(CommunityHighlight.id == highlight_id))
highlight = result.scalar_one_or_none()
if not highlight:
return {"error": "Community highlight not found"}
await session.delete(highlight)
await session.commit()
+79
View File
@@ -0,0 +1,79 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import HistoryEntry
from app.schemas import HistoryEntryCreate, HistoryEntryUpdate, HistoryEntryResponse
from app.user_models import User
router = APIRouter()
@router.get("/", response_model=list[HistoryEntryResponse])
async def list_history_entries(
active: Optional[bool] = Query(None, description="Filter by active status"),
session: AsyncSession = Depends(get_session),
):
query = select(HistoryEntry).order_by(HistoryEntry.display_order)
if active is not None:
query = query.where(HistoryEntry.active == active)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{entry_id}", response_model=HistoryEntryResponse)
async def get_history_entry(entry_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(HistoryEntry).where(HistoryEntry.id == entry_id))
entry = result.scalar_one_or_none()
if not entry:
return {"error": "History entry not found"}
return entry
@router.post("/", response_model=HistoryEntryResponse, status_code=201)
async def create_history_entry(
payload: HistoryEntryCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
entry = HistoryEntry(**payload.model_dump(), active=True)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
@router.put("/{entry_id}", response_model=HistoryEntryResponse)
async def update_history_entry(
entry_id: int,
payload: HistoryEntryUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(HistoryEntry).where(HistoryEntry.id == entry_id))
entry = result.scalar_one_or_none()
if not entry:
return {"error": "History entry not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, key, value)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/{entry_id}", status_code=204)
async def delete_history_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(HistoryEntry).where(HistoryEntry.id == entry_id))
entry = result.scalar_one_or_none()
if not entry:
return {"error": "History entry not found"}
await session.delete(entry)
await session.commit()
+79
View File
@@ -0,0 +1,79 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import TeamMember
from app.schemas import TeamMemberCreate, TeamMemberUpdate, TeamMemberResponse
from app.user_models import User
router = APIRouter()
@router.get("/", response_model=list[TeamMemberResponse])
async def list_team_members(
active: Optional[bool] = Query(None, description="Filter by active status"),
session: AsyncSession = Depends(get_session),
):
query = select(TeamMember).order_by(TeamMember.display_order)
if active is not None:
query = query.where(TeamMember.active == active)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{member_id}", response_model=TeamMemberResponse)
async def get_team_member(member_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(TeamMember).where(TeamMember.id == member_id))
member = result.scalar_one_or_none()
if not member:
return {"error": "Team member not found"}
return member
@router.post("/", response_model=TeamMemberResponse, status_code=201)
async def create_team_member(
payload: TeamMemberCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
member = TeamMember(**payload.model_dump(), active=True)
session.add(member)
await session.commit()
await session.refresh(member)
return member
@router.put("/{member_id}", response_model=TeamMemberResponse)
async def update_team_member(
member_id: int,
payload: TeamMemberUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(TeamMember).where(TeamMember.id == member_id))
member = result.scalar_one_or_none()
if not member:
return {"error": "Team member not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(member, key, value)
await session.commit()
await session.refresh(member)
return member
@router.delete("/{member_id}", status_code=204)
async def delete_team_member(
member_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(TeamMember).where(TeamMember.id == member_id))
member = result.scalar_one_or_none()
if not member:
return {"error": "Team member not found"}
await session.delete(member)
await session.commit()
+5 -2
View File
@@ -8,9 +8,9 @@ from sqlalchemy import func, select
from app.config import settings from app.config import settings
from app.database import async_session, engine from app.database import async_session, engine
from app.models import Base, Program, StationConfig from app.models import Base, Program, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
from app.user_models import User from app.user_models import User
from app.api import programs, events, tiers, auth, station_config from app.api import programs, events, tiers, auth, station_config, history, team, community
async def _ensure_station_config(session): async def _ensure_station_config(session):
@@ -96,6 +96,9 @@ app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"]) app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]) app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
app.include_router(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"])
app.include_router(community.router, prefix="/api/community", tags=["community"])
# Serve uploaded files (dev only — Nginx handles this in production) # Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production": if settings.ENV != "production":
+34
View File
@@ -80,3 +80,37 @@ class StationConfig(Base):
phone = Column(String(30), nullable=False, default="(970) 555-0198") phone = Column(String(30), nullable=False, default="(970) 555-0198")
email = Column(String(100), nullable=False, default="hello@kmountainflower.org") email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
website = Column(String(200), nullable=False, default="kmountainflower.org") website = Column(String(200), nullable=False, default="kmountainflower.org")
class HistoryEntry(Base):
__tablename__ = "history_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
year = Column(Integer, nullable=False)
title = Column(String(200), nullable=False)
body = Column(Text, nullable=False)
display_order = Column(Integer, nullable=False)
active = Column(Boolean, default=True)
class TeamMember(Base):
__tablename__ = "team_members"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
title = Column(String(150), nullable=False)
bio = Column(Text, nullable=True)
photo_url = Column(String(500), nullable=True)
display_order = Column(Integer, nullable=False)
active = Column(Boolean, default=True)
class CommunityHighlight(Base):
__tablename__ = "community_highlights"
id = Column(Integer, primary_key=True, autoincrement=True)
icon = Column(String(10), nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
display_order = Column(Integer, nullable=False)
active = Column(Boolean, default=True)
+87
View File
@@ -156,3 +156,90 @@ class StationConfigUpdate(BaseModel):
phone: Optional[str] = None phone: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
# ── HistoryEntry ──────────────────────────────────────────
class HistoryEntryCreate(BaseModel):
year: int
title: str
body: str
display_order: int
class HistoryEntryUpdate(BaseModel):
year: Optional[int] = None
title: Optional[str] = None
body: Optional[str] = None
display_order: Optional[int] = None
active: Optional[bool] = None
class HistoryEntryResponse(BaseModel):
id: int
year: int
title: str
body: str
display_order: int
active: bool
model_config = {"from_attributes": True}
# ── TeamMember ─────────────────────────────────────────────
class TeamMemberCreate(BaseModel):
name: str
title: str
bio: Optional[str] = None
photo_url: Optional[str] = None
display_order: int
class TeamMemberUpdate(BaseModel):
name: Optional[str] = None
title: Optional[str] = None
bio: Optional[str] = None
photo_url: Optional[str] = None
display_order: Optional[int] = None
active: Optional[bool] = None
class TeamMemberResponse(BaseModel):
id: int
name: str
title: str
bio: Optional[str]
photo_url: Optional[str]
display_order: int
active: bool
model_config = {"from_attributes": True}
# ── CommunityHighlight ─────────────────────────────────────
class CommunityHighlightCreate(BaseModel):
icon: str
title: str
description: str
display_order: int
class CommunityHighlightUpdate(BaseModel):
icon: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
display_order: Optional[int] = None
active: Optional[bool] = None
class CommunityHighlightResponse(BaseModel):
id: int
icon: str
title: str
description: str
display_order: int
active: bool
model_config = {"from_attributes": True}
+149
View File
@@ -15,6 +15,7 @@ from sqlalchemy import select
from app.database import async_session from app.database import async_session
from app.models import Program, Event, DonationTier, StationConfig from app.models import Program, Event, DonationTier, StationConfig
from app.models import HistoryEntry, TeamMember, CommunityHighlight
# ── Seed data ────────────────────────────────────────────── # ── Seed data ──────────────────────────────────────────────
@@ -113,6 +114,91 @@ STATION_CONFIG: dict = {
"website": "kmountainflower.org", "website": "kmountainflower.org",
} }
HISTORY_ENTRIES: list[dict] = [
{
"year": 1987,
"title": "The Idea Takes Root",
"body": "A small group of mountain residents gathered around a kitchen table with a shared dream: a radio station owned by the community, for the community. With nothing but conviction and a stack of handwritten letters to the FCC, they began the long road toward bringing independent radio to the high country.",
"display_order": 1,
},
{
"year": 1991,
"title": "FCC License Granted",
"body": "After years of paperwork, public hearings, and grassroots fundraising, the Federal Communications Commission approved the construction permit. The call sign KMTN was chosen — a nod to the mountain terrain that defines the region and the people who call it home.",
"display_order": 2,
},
{
"year": 1995,
"title": "First Broadcast",
"body": "On a crisp September morning, KMTN signed on the air for the first time. The opening broadcast featured a live bluegrass set from the studio, followed by interviews with local farmers, teachers, and business owners. The signal reached every valley and ridge within a 40-mile radius.",
"display_order": 3,
},
{
"year": 2008,
"title": "Studio Expansion",
"body": "A generous community-driven capital campaign funded the construction of a new broadcast studio — doubling production space and adding a dedicated live-performance room. The expansion also brought upgraded transmission equipment, extending the station's reach to surrounding counties.",
"display_order": 4,
},
]
TEAM_MEMBERS: list[dict] = [
{
"name": "Margaret Ellis",
"title": "Board Chair",
"bio": "Margaret has served on the KMTN board for over fifteen years. A retired schoolteacher, she believes community radio is the backbone of civic engagement.",
"photo_url": None,
"display_order": 1,
},
{
"name": "James Whitfield",
"title": "General Manager",
"bio": "James joined KMTN as a volunteer DJ in 2002 and has been steering the station's operations ever since. He oversees programming, finance, and community outreach.",
"photo_url": None,
"display_order": 2,
},
{
"name": "Dr. Priya Nair",
"title": "Program Director",
"bio": "Dr. Nair brings two decades of media experience to KMTN. She curates the station's diverse lineup and mentors the next generation of local broadcasters.",
"photo_url": None,
"display_order": 3,
},
{
"name": "Carlos Mendez",
"title": "Community Outreach Coordinator",
"bio": "Carlos organizes station events, volunteer drives, and school partnerships. His Evening Echoes show is one of the station's most-listened-to programs.",
"photo_url": None,
"display_order": 4,
},
]
COMMUNITY_HIGHLIGHTS: list[dict] = [
{
"icon": "🎵",
"title": "Our Music",
"description": "From bluegrass and folk to jazz and classical, our playlists reflect the rich cultural tapestry of the high country and its neighbors. We feature local artists alongside the world's best voices.",
"display_order": 1,
},
{
"icon": "🤝",
"title": "Our Community",
"description": "Every show is staffed by volunteers who live and work in these mountains. From high school DJs to retired teachers, our team is your community — passionate about sharing what they love.",
"display_order": 2,
},
{
"icon": "📡",
"title": "Our Reach",
"description": "Over the air on 98.7 FM and streaming live worldwide. Whether you're hiking the ridgeline or on the other side of the planet, our signal — and our welcome — goes everywhere.",
"display_order": 3,
},
{
"icon": "🌱",
"title": "Our Mission",
"description": "To keep public radio free, independent, and deeply rooted in place. No corporate advertising, no spin — just music, conversation, and community served with care.",
"display_order": 4,
},
]
# ── Helpers ──────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────
@@ -174,6 +260,51 @@ async def _upsert_station_config(session, data: dict) -> None:
session.add(StationConfig(**data)) session.add(StationConfig(**data))
async def _upsert_history_entry(session, data: dict) -> None:
"""Get-or-create a history entry matched on (year, title)."""
existing = await session.execute(
select(HistoryEntry).where(
HistoryEntry.year == data["year"],
HistoryEntry.title == data["title"],
)
)
entry = existing.scalar_one_or_none()
if entry:
for key, value in data.items():
setattr(entry, key, value)
else:
session.add(HistoryEntry(**data, active=True))
async def _upsert_team_member(session, data: dict) -> None:
"""Get-or-create a team member matched on name."""
existing = await session.execute(
select(TeamMember).where(TeamMember.name == data["name"])
)
member = existing.scalar_one_or_none()
if member:
for key, value in data.items():
setattr(member, key, value)
else:
session.add(TeamMember(**data, active=True))
async def _upsert_community_highlight(session, data: dict) -> None:
"""Get-or-create a community highlight matched on (icon, title)."""
existing = await session.execute(
select(CommunityHighlight).where(
CommunityHighlight.icon == data["icon"],
CommunityHighlight.title == data["title"],
)
)
highlight = existing.scalar_one_or_none()
if highlight:
for key, value in data.items():
setattr(highlight, key, value)
else:
session.add(CommunityHighlight(**data, active=True))
# ── Main ────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────
async def seed() -> None: async def seed() -> None:
@@ -198,6 +329,21 @@ async def seed() -> None:
await session.commit() await session.commit()
print(" ✓ Seeded station config") print(" ✓ Seeded station config")
for data in HISTORY_ENTRIES:
await _upsert_history_entry(session, data)
await session.commit()
print(f" ✓ Seeded {len(HISTORY_ENTRIES)} history entries")
for data in TEAM_MEMBERS:
await _upsert_team_member(session, data)
await session.commit()
print(f" ✓ Seeded {len(TEAM_MEMBERS)} team members")
for data in COMMUNITY_HIGHLIGHTS:
await _upsert_community_highlight(session, data)
await session.commit()
print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights")
async def truncate_all() -> None: async def truncate_all() -> None:
"""Remove all seed data from the database.""" """Remove all seed data from the database."""
@@ -206,6 +352,9 @@ async def truncate_all() -> None:
await session.execute(Event.__table__.delete()) await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete()) await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete()) await session.execute(StationConfig.__table__.delete())
await session.execute(HistoryEntry.__table__.delete())
await session.execute(TeamMember.__table__.delete())
await session.execute(CommunityHighlight.__table__.delete())
await session.commit() await session.commit()
print(" ✓ Truncated all tables") print(" ✓ Truncated all tables")
+63 -38
View File
@@ -1,5 +1,6 @@
<section class="about"> <section class="about">
<div class="container"> <div class="container">
<!-- Intro -->
<div class="about-intro"> <div class="about-intro">
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true"> <img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2> <h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
@@ -10,45 +11,69 @@
</p> </p>
</div> </div>
<div class="about-grid"> <!-- Community Highlights Grid -->
<div class="about-card"> @if (communityHighlights$ | async; as highlights) {
<div class="card-icon">🎵</div> @if (highlights.length) {
<h3>Our Music</h3> <div class="about-grid">
<p> @for (highlight of highlights; track highlight.id) {
From bluegrass and folk to jazz and classical, our playlists reflect <div class="about-card">
the rich cultural tapestry of the high country and its neighbors. We <div class="card-icon">{{ highlight.icon }}</div>
feature local artists alongside the world's best voices. <h3>{{ highlight.title }}</h3>
</p> <p>{{ highlight.description }}</p>
</div> </div>
<div class="about-card"> }
<div class="card-icon">🤝</div> </div>
<h3>Our Community</h3> }
<p> }
Every show is staffed by volunteers who live and work in these
mountains. From high school DJs to retired teachers, our team is your
community — passionate about sharing what they love.
</p>
</div>
<div class="about-card">
<div class="card-icon">📡</div>
<h3>Our Reach</h3>
<p>
Over the air on {{ config().frequency }} and streaming live worldwide. Whether you're
hiking the ridgeline or on the other side of the planet, our signal
— and our welcome — goes everywhere.
</p>
</div>
<div class="about-card">
<div class="card-icon">🌱</div>
<h3>Our Mission</h3>
<p>
To keep public radio free, independent, and deeply rooted in place.
No corporate advertising, no spin — just music, conversation, and
community served with care.
</p>
</div>
</div>
<!-- History Timeline -->
@if (historyEntries$ | async; as entries) {
@if (entries.length) {
<div class="timeline-section">
<h2 class="section-title">Our <span class="text-accent">History</span></h2>
<div class="timeline">
@for (entry of entries; track entry.id; let index = $index) {
<div class="timeline-item" [class.even]="index % 2 === 1">
<div class="timeline-card">
<span class="timeline-year">{{ entry.year }}</span>
<h3>{{ entry.title }}</h3>
<p>{{ entry.body }}</p>
</div>
</div>
}
</div>
</div>
}
}
<!-- Team Roster -->
@if (teamMembers$ | async; as members) {
@if (members.length) {
<div class="team-section">
<h2 class="section-title">Our <span class="text-accent">Team</span></h2>
<div class="team-grid">
@for (member of members; track member.id) {
<div class="team-card">
<div class="team-photo">
@if (member.photo_url) {
<img [src]="member.photo_url" [alt]="member.name">
} @else {
<span>{{ member.name.charAt(0) }}</span>
}
</div>
<h3>{{ member.name }}</h3>
<p class="team-title">{{ member.title }}</p>
@if (member.bio) {
<p class="team-bio">{{ member.bio }}</p>
}
</div>
}
</div>
</div>
}
}
<!-- Donation CTA -->
<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]="config().logo_url" alt=""> <img [src]="config().logo_url" alt="">
+187 -1
View File
@@ -50,6 +50,166 @@
} }
} }
.section-title {
text-align: center;
font-size: 2rem;
font-family: $font-heading;
color: var(--color-primary);
margin-bottom: $spacing-2xl;
}
// ── Timeline ──────────────────────────────────────────────
.timeline-section {
margin-bottom: $spacing-3xl;
}
.timeline {
position: relative;
padding: $spacing-lg 0;
// Central vertical line
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 3px;
background: var(--color-primary);
transform: translateX(-50%);
}
}
.timeline-item {
position: relative;
width: 50%;
padding: 0 $spacing-xl $spacing-2xl;
// Dot on the timeline
&::before {
content: '';
position: absolute;
top: $spacing-sm;
width: 16px;
height: 16px;
background: var(--color-accent);
border: 3px solid var(--color-white);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color-primary);
z-index: 1;
}
// Odd items — left side
&:not(.even) {
left: 0;
text-align: right;
&::before {
right: -8px;
}
}
// Even items — right side
&.even {
left: 50%;
text-align: left;
&::before {
left: -8px;
}
}
}
.timeline-card {
@include card-style;
padding: $spacing-lg;
background: var(--color-white);
.timeline-year {
display: inline-block;
background: var(--color-primary);
color: var(--color-white);
font-weight: 700;
font-size: 0.9rem;
padding: $spacing-xs $spacing-md;
border-radius: $radius-md;
margin-bottom: $spacing-sm;
}
h3 {
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: $spacing-sm;
}
p {
font-size: 0.95rem;
color: var(--color-dark);
line-height: 1.7;
}
}
// ── Team Grid ─────────────────────────────────────────────
.team-section {
margin-bottom: $spacing-3xl;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: $spacing-lg;
}
.team-card {
@include card-style;
padding: $spacing-xl;
text-align: center;
background: var(--color-white);
}
.team-photo {
@include flex-center;
width: 80px;
height: 80px;
margin: 0 auto $spacing-md;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-white);
font-size: 1.8rem;
font-weight: 700;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.team-card h3 {
font-size: 1.1rem;
color: var(--color-primary);
margin-bottom: $spacing-xs;
}
.team-title {
font-size: 0.9rem;
color: var(--color-accent);
font-weight: 600;
margin-bottom: $spacing-sm;
}
.team-bio {
font-size: 0.9rem;
color: var(--color-dark);
line-height: 1.6;
}
// ── Donation CTA ──────────────────────────────────────────
.about-cta { .about-cta {
text-align: center; text-align: center;
padding: $spacing-2xl $spacing-lg; padding: $spacing-2xl $spacing-lg;
@@ -80,8 +240,34 @@
} }
} }
@media (max-width: #{$bp-md - 1px}) { // ── Responsive ────────────────────────────────────────────
@include responsive(md) {
.about-grid { .about-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
// Collapse timeline to single-column
.timeline {
&::before {
left: $spacing-md;
}
}
.timeline-item {
width: 100%;
left: 0 !important;
text-align: left !important;
padding-left: $spacing-3xl;
padding-right: 0;
&::before {
left: #{$spacing-md - 0.5rem} !important;
right: auto !important;
}
}
.team-grid {
grid-template-columns: 1fr;
}
} }
+36 -1
View File
@@ -1,15 +1,50 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { StationConfigService } from '../services/station-config.service'; import { StationConfigService } from '../services/station-config.service';
import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink, AsyncPipe],
templateUrl: './about.component.html', templateUrl: './about.component.html',
styleUrl: './about.component.scss', styleUrl: './about.component.scss',
}) })
export class AboutComponent { export class AboutComponent {
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
readonly config = inject(StationConfigService).config; readonly config = inject(StationConfigService).config;
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
constructor() {
this.load();
}
async load(): Promise<void> {
try {
const [history, team, highlights] = await Promise.all([
this.historyEntryService.getHistoryEntries(true).toPromise(),
this.teamMemberService.getTeamMembers(true).toPromise(),
this.communityHighlightService.getCommunityHighlights(true).toPromise(),
]);
this.historyEntries$.next(history ?? []);
this.teamMembers$.next(team ?? []);
this.communityHighlights$.next(highlights ?? []);
} catch {
// Silently fail — page still renders with empty state
}
}
} }
@@ -0,0 +1,50 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Community Highlight' : 'Add Community Highlight' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form">
<div class="form-row">
<div class="form-group">
<label for="icon">Icon</label>
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="🎵" maxlength="10">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., Our Music" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" [(ngModel)]="description" name="description" rows="4" placeholder="Describe this highlight..." required [class.is-invalid]="submitted && !description"></textarea>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" [(ngModel)]="active" name="active">
Active
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,146 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.form-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
@include flex-center;
z-index: $z-modal;
@include fade-in;
}
.form-dialog {
background: $neutral-white;
border-radius: $radius-lg;
padding: $spacing-xl;
width: 90%;
max-width: 560px;
box-shadow: $shadow-xl;
max-height: 90vh;
overflow-y: auto;
}
.form-header {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
margin: 0;
font-size: 1.4rem;
}
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: $neutral-medium;
line-height: 1;
padding: $spacing-xs;
&:hover {
color: $neutral-dark;
}
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input, select, textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
transition: border-color $transition-fast;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
&.is-invalid {
border-color: $danger-red;
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
}
}
}
.checkbox-group {
label {
display: flex;
align-items: center;
gap: $spacing-sm;
font-weight: 500;
cursor: pointer;
input[type="checkbox"] {
width: auto;
margin: 0;
}
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-cancel {
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
}
@@ -0,0 +1,104 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { CommunityHighlightService } from '../services/community-highlight.service';
@Component({
selector: 'app-admin-community-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-community-form.component.html',
styleUrl: './admin-community-form.component.scss',
})
export class AdminCommunityFormComponent implements OnChanges {
private communityHighlightService = inject(CommunityHighlightService);
@Input() editItem: CommunityHighlight | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
icon = '🎵';
title = '';
description = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(highlight: CommunityHighlight): void {
this.id = highlight.id;
this.icon = highlight.icon;
this.title = highlight.title;
this.description = highlight.description;
this.display_order = highlight.display_order;
this.active = highlight.active;
}
formatError(err: any): string {
if (err?.error?.detail) return err.error.detail;
if (err?.error?.message) return err.error.message;
if (err?.message) return err.message;
return 'Failed to save. Please try again.';
}
reset(): void {
this.id = null;
this.icon = '🎵';
this.title = '';
this.description = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.title || !this.description) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
icon: this.icon,
title: this.title,
description: this.description,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.communityHighlightService.updateCommunityHighlight(this.id, payload));
} else {
await firstValueFrom(this.communityHighlightService.createCommunityHighlight(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,50 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit History Entry' : 'Add History Entry' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form">
<div class="form-row">
<div class="form-group">
<label for="year">Year</label>
<input id="year" type="number" [(ngModel)]="year" name="year" required [class.is-invalid]="submitted && !year">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., First Broadcast" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="body">Body</label>
<textarea id="body" [(ngModel)]="body" name="body" rows="4" placeholder="Describe this milestone..." required [class.is-invalid]="submitted && !body"></textarea>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" [(ngModel)]="active" name="active">
Active
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,146 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.form-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
@include flex-center;
z-index: $z-modal;
@include fade-in;
}
.form-dialog {
background: $neutral-white;
border-radius: $radius-lg;
padding: $spacing-xl;
width: 90%;
max-width: 560px;
box-shadow: $shadow-xl;
max-height: 90vh;
overflow-y: auto;
}
.form-header {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
margin: 0;
font-size: 1.4rem;
}
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: $neutral-medium;
line-height: 1;
padding: $spacing-xs;
&:hover {
color: $neutral-dark;
}
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input, select, textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
transition: border-color $transition-fast;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
&.is-invalid {
border-color: $danger-red;
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
}
}
}
.checkbox-group {
label {
display: flex;
align-items: center;
gap: $spacing-sm;
font-weight: 500;
cursor: pointer;
input[type="checkbox"] {
width: auto;
margin: 0;
}
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-cancel {
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
}
@@ -0,0 +1,104 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { HistoryEntry } from '../interfaces/history-entry';
import { HistoryEntryService } from '../services/history-entry.service';
@Component({
selector: 'app-admin-history-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-history-form.component.html',
styleUrl: './admin-history-form.component.scss',
})
export class AdminHistoryFormComponent implements OnChanges {
private historyEntryService = inject(HistoryEntryService);
@Input() editItem: HistoryEntry | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
year = '';
title = '';
body = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(entry: HistoryEntry): void {
this.id = entry.id;
this.year = String(entry.year);
this.title = entry.title;
this.body = entry.body;
this.display_order = entry.display_order;
this.active = entry.active;
}
formatError(err: any): string {
if (err?.error?.detail) return err.error.detail;
if (err?.error?.message) return err.error.message;
if (err?.message) return err.message;
return 'Failed to save. Please try again.';
}
reset(): void {
this.id = null;
this.year = '';
this.title = '';
this.body = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.year || !this.title || !this.body) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
year: parseInt(this.year, 10),
title: this.title,
body: this.body,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.historyEntryService.updateHistoryEntry(this.id, payload));
} else {
await firstValueFrom(this.historyEntryService.createHistoryEntry(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,55 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Team Member' : 'Add Team Member' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form">
<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" [(ngModel)]="name" name="name" placeholder="Full name" required [class.is-invalid]="submitted && !name">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., Board Chair" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="bio">Bio (optional)</label>
<textarea id="bio" [(ngModel)]="bio" name="bio" rows="3" placeholder="Short biography..."></textarea>
</div>
<div class="form-group">
<label for="photo_url">Photo URL (optional)</label>
<input id="photo_url" type="url" [(ngModel)]="photo_url" name="photo_url" placeholder="https://...">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" [(ngModel)]="active" name="active">
Active
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,146 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.form-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
@include flex-center;
z-index: $z-modal;
@include fade-in;
}
.form-dialog {
background: $neutral-white;
border-radius: $radius-lg;
padding: $spacing-xl;
width: 90%;
max-width: 560px;
box-shadow: $shadow-xl;
max-height: 90vh;
overflow-y: auto;
}
.form-header {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
margin: 0;
font-size: 1.4rem;
}
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: $neutral-medium;
line-height: 1;
padding: $spacing-xs;
&:hover {
color: $neutral-dark;
}
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input, select, textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
transition: border-color $transition-fast;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
&.is-invalid {
border-color: $danger-red;
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
}
}
}
.checkbox-group {
label {
display: flex;
align-items: center;
gap: $spacing-sm;
font-weight: 500;
cursor: pointer;
input[type="checkbox"] {
width: auto;
margin: 0;
}
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-cancel {
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { TeamMember } from '../interfaces/team-member';
import { TeamMemberService } from '../services/team-member.service';
@Component({
selector: 'app-admin-team-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-team-form.component.html',
styleUrl: './admin-team-form.component.scss',
})
export class AdminTeamFormComponent implements OnChanges {
private teamMemberService = inject(TeamMemberService);
@Input() editItem: TeamMember | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
name = '';
title = '';
bio = '';
photo_url = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(member: TeamMember): void {
this.id = member.id;
this.name = member.name;
this.title = member.title;
this.bio = member.bio ?? '';
this.photo_url = member.photo_url ?? '';
this.display_order = member.display_order;
this.active = member.active;
}
formatError(err: any): string {
if (err?.error?.detail) return err.error.detail;
if (err?.error?.message) return err.error.message;
if (err?.message) return err.message;
return 'Failed to save. Please try again.';
}
reset(): void {
this.id = null;
this.name = '';
this.title = '';
this.bio = '';
this.photo_url = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.name || !this.title) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
name: this.name,
title: this.title,
bio: this.bio || null,
photo_url: this.photo_url || null,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.teamMemberService.updateTeamMember(this.id, payload));
} else {
await firstValueFrom(this.teamMemberService.createTeamMember(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
+146 -1
View File
@@ -2,7 +2,7 @@
<div class="container"> <div class="container">
<div class="admin-header"> <div class="admin-header">
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
<p>Manage programs, events, and donation tiers</p> <p>Manage programs, events, tiers, history, team, and community content</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
@@ -22,6 +22,21 @@
[class.active]="activeTab() === 'tiers'" [class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')" (click)="activeTab.set('tiers')"
>Tiers</button> >Tiers</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'history'"
(click)="activeTab.set('history')"
>History</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'team'"
(click)="activeTab.set('team')"
>Team</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'community'"
(click)="activeTab.set('community')"
>Community</button>
<button <button
class="tab-btn" class="tab-btn"
[class.active]="activeTab() === 'station'" [class.active]="activeTab() === 'station'"
@@ -150,6 +165,127 @@
</div> </div>
} }
} }
<!-- History Tab -->
@if (activeTab() === 'history') {
@if (historyEntries$ | async; as entries) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>History Timeline ({{ entries.length }})</h2>
<button class="btn btn-add" (click)="openHistoryForm()">+ Add Entry</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Year</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of entries; track entry.id) {
<tr>
<td>{{ entry.year }}</td>
<td>{{ entry.title }}</td>
<td>{{ entry.display_order }}</td>
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editHistory(entry)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteHistory(entry.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Team Tab -->
@if (activeTab() === 'team') {
@if (teamMembers$ | async; as members) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Team Members ({{ members.length }})</h2>
<button class="btn btn-add" (click)="openTeamForm()">+ Add Member</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (member of members; track member.id) {
<tr>
<td>{{ member.name }}</td>
<td>{{ member.title }}</td>
<td>{{ member.display_order }}</td>
<td>{{ member.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editTeam(member)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteTeam(member.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Community Tab -->
@if (activeTab() === 'community') {
@if (communityHighlights$ | async; as highlights) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Community Highlights ({{ highlights.length }})</h2>
<button class="btn btn-add" (click)="openCommunityForm()">+ Add Highlight</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Icon</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (highlight of highlights; track highlight.id) {
<tr>
<td>{{ highlight.icon }}</td>
<td>{{ highlight.title }}</td>
<td>{{ highlight.display_order }}</td>
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editCommunity(highlight)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteCommunity(highlight.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Station Tab --> <!-- Station Tab -->
@if (activeTab() === 'station') { @if (activeTab() === 'station') {
@if (stationConfig$ | async; as config) { @if (stationConfig$ | async; as config) {
@@ -215,4 +351,13 @@
@if (showStationForm) { @if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" /> <app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
} }
@if (showHistoryForm) {
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
}
@if (showTeamForm) {
<app-admin-team-form [editItem]="editingTeam" (saved)="onTeamSaved()" (closed)="onTeamClosed()" />
}
@if (showCommunityForm) {
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
}
</section> </section>
+130 -1
View File
@@ -7,16 +7,25 @@ import { Program } from '../interfaces/program';
import { Event } from '../interfaces/event'; import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier'; import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config'; import { StationConfig } from '../interfaces/station-config';
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { ProgramService } from '../services/program.service'; import { ProgramService } from '../services/program.service';
import { EventService } from '../services/event.service'; import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.service'; import { TierService } from '../services/tier.service';
import { StationConfigService } from '../services/station-config.service'; import { StationConfigService } from '../services/station-config.service';
import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
import { AdminProgramFormComponent } from './admin-program-form.component'; import { AdminProgramFormComponent } from './admin-program-form.component';
import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-form.component'; import { AdminTierFormComponent } from './admin-tier-form.component';
import { AdminStationFormComponent } from './admin-station-form.component'; import { AdminStationFormComponent } from './admin-station-form.component';
import { AdminHistoryFormComponent } from './admin-history-form.component';
import { AdminTeamFormComponent } from './admin-team-form.component';
import { AdminCommunityFormComponent } from './admin-community-form.component';
type TabKey = 'programs' | 'events' | 'tiers' | 'station'; type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
@Component({ @Component({
selector: 'app-admin', selector: 'app-admin',
@@ -29,6 +38,9 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station';
AdminEventFormComponent, AdminEventFormComponent,
AdminTierFormComponent, AdminTierFormComponent,
AdminStationFormComponent, AdminStationFormComponent,
AdminHistoryFormComponent,
AdminTeamFormComponent,
AdminCommunityFormComponent,
], ],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
styleUrl: './admin.component.scss', styleUrl: './admin.component.scss',
@@ -38,6 +50,9 @@ export class AdminComponent implements OnInit {
private eventService = inject(EventService); private eventService = inject(EventService);
private tierService = inject(TierService); private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService); private stationConfigService = inject(StationConfigService);
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
activeTab = signal<TabKey>('programs'); activeTab = signal<TabKey>('programs');
@@ -45,17 +60,26 @@ export class AdminComponent implements OnInit {
editingProgram: Program | null = null; editingProgram: Program | null = null;
editingEvent: Event | null = null; editingEvent: Event | null = null;
editingTier: Tier | null = null; editingTier: Tier | null = null;
editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | null = null;
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ /** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
readonly programs$ = new BehaviorSubject<Program[]>([]); readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]); readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]); readonly tiers$ = new BehaviorSubject<Tier[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null); readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
showProgramForm = false; showProgramForm = false;
showEventForm = false; showEventForm = false;
showTierForm = false; showTierForm = false;
showStationForm = false; showStationForm = false;
showHistoryForm = false;
showTeamForm = false;
showCommunityForm = false;
ngOnInit(): void { ngOnInit(): void {
this.loadAll(); this.loadAll();
@@ -81,12 +105,30 @@ export class AdminComponent implements OnInit {
this.stationConfig$.next(this.stationConfigService.config()); this.stationConfig$.next(this.stationConfigService.config());
} }
async reloadHistoryEntries(): Promise<void> {
const data = await firstValueFrom(this.historyEntryService.getHistoryEntries());
this.historyEntries$.next(data);
}
async reloadTeamMembers(): Promise<void> {
const data = await firstValueFrom(this.teamMemberService.getTeamMembers());
this.teamMembers$.next(data);
}
async reloadCommunityHighlights(): Promise<void> {
const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
this.communityHighlights$.next(data);
}
async loadAll(): Promise<void> { async loadAll(): Promise<void> {
await Promise.all([ await Promise.all([
this.reloadPrograms(), this.reloadPrograms(),
this.reloadEvents(), this.reloadEvents(),
this.reloadTiers(), this.reloadTiers(),
this.reloadStationConfig(), this.reloadStationConfig(),
this.reloadHistoryEntries(),
this.reloadTeamMembers(),
this.reloadCommunityHighlights(),
]); ]);
} }
@@ -191,4 +233,91 @@ export class AdminComponent implements OnInit {
onStationClosed(): void { onStationClosed(): void {
this.showStationForm = false; this.showStationForm = false;
} }
// ── History CRUD ────────────────────────────────────────
openHistoryForm(): void {
this.editingHistory = null;
this.showHistoryForm = true;
}
editHistory(entry: HistoryEntry): void {
this.editingHistory = entry;
this.showHistoryForm = true;
}
onHistorySaved(): void {
this.reloadHistoryEntries();
this.showHistoryForm = false;
this.editingHistory = null;
}
onHistoryClosed(): void {
this.showHistoryForm = false;
this.editingHistory = null;
}
async deleteHistory(id: number): Promise<void> {
if (!confirm('Delete this history entry?')) return;
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
await this.reloadHistoryEntries();
}
// ── Team CRUD ───────────────────────────────────────────
openTeamForm(): void {
this.editingTeam = null;
this.showTeamForm = true;
}
editTeam(member: TeamMember): void {
this.editingTeam = member;
this.showTeamForm = true;
}
onTeamSaved(): void {
this.reloadTeamMembers();
this.showTeamForm = false;
this.editingTeam = null;
}
onTeamClosed(): void {
this.showTeamForm = false;
this.editingTeam = null;
}
async deleteTeam(id: number): Promise<void> {
if (!confirm('Delete this team member?')) return;
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
await this.reloadTeamMembers();
}
// ── Community CRUD ──────────────────────────────────────
openCommunityForm(): void {
this.editingCommunity = null;
this.showCommunityForm = true;
}
editCommunity(highlight: CommunityHighlight): void {
this.editingCommunity = highlight;
this.showCommunityForm = true;
}
onCommunitySaved(): void {
this.reloadCommunityHighlights();
this.showCommunityForm = false;
this.editingCommunity = null;
}
onCommunityClosed(): void {
this.showCommunityForm = false;
this.editingCommunity = null;
}
async deleteCommunity(id: number): Promise<void> {
if (!confirm('Delete this community highlight?')) return;
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
await this.reloadCommunityHighlights();
}
} }
@@ -0,0 +1,8 @@
export interface CommunityHighlight {
id: number;
icon: string;
title: string;
description: string;
display_order: number;
active: boolean;
}
+8
View File
@@ -0,0 +1,8 @@
export interface HistoryEntry {
id: number;
year: number;
title: string;
body: string;
display_order: number;
active: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
export interface TeamMember {
id: number;
name: string;
title: string;
bio: string | null;
photo_url: string | null;
display_order: number;
active: boolean;
}
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class CommunityHighlightService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/community`;
/** Fetch community highlights, optionally filtered by active status. */
getCommunityHighlights(active?: boolean): Observable<CommunityHighlight[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<CommunityHighlight[]>(this.baseUrl, { params });
}
getCommunityHighlight(id: number): Observable<CommunityHighlight> {
return this.http.get<CommunityHighlight>(`${this.baseUrl}/${id}`);
}
createCommunityHighlight(payload: Omit<CommunityHighlight, 'id'>): Observable<CommunityHighlight> {
return this.http.post<CommunityHighlight>(this.baseUrl, payload);
}
updateCommunityHighlight(id: number, payload: Partial<CommunityHighlight>): Observable<CommunityHighlight> {
return this.http.put<CommunityHighlight>(`${this.baseUrl}/${id}`, payload);
}
deleteCommunityHighlight(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { HistoryEntry } from '../interfaces/history-entry';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class HistoryEntryService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/history`;
/** Fetch history entries, optionally filtered by active status. */
getHistoryEntries(active?: boolean): Observable<HistoryEntry[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<HistoryEntry[]>(this.baseUrl, { params });
}
getHistoryEntry(id: number): Observable<HistoryEntry> {
return this.http.get<HistoryEntry>(`${this.baseUrl}/${id}`);
}
createHistoryEntry(payload: Omit<HistoryEntry, 'id'>): Observable<HistoryEntry> {
return this.http.post<HistoryEntry>(this.baseUrl, payload);
}
updateHistoryEntry(id: number, payload: Partial<HistoryEntry>): Observable<HistoryEntry> {
return this.http.put<HistoryEntry>(`${this.baseUrl}/${id}`, payload);
}
deleteHistoryEntry(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TeamMember } from '../interfaces/team-member';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class TeamMemberService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/team`;
/** Fetch team members, optionally filtered by active status. */
getTeamMembers(active?: boolean): Observable<TeamMember[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<TeamMember[]>(this.baseUrl, { params });
}
getTeamMember(id: number): Observable<TeamMember> {
return this.http.get<TeamMember>(`${this.baseUrl}/${id}`);
}
createTeamMember(payload: Omit<TeamMember, 'id'>): Observable<TeamMember> {
return this.http.post<TeamMember>(this.baseUrl, payload);
}
updateTeamMember(id: number, payload: Partial<TeamMember>): Observable<TeamMember> {
return this.http.put<TeamMember>(`${this.baseUrl}/${id}`, payload);
}
deleteTeamMember(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}