From 1b537b3dfd77527222b7543cd419d2afffc8b8f5 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sun, 21 Jun 2026 05:24:40 +0000 Subject: [PATCH] data driven about us screen --- backend/__pycache__/seed.cpython-312.pyc | Bin 10644 -> 18135 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 6803 -> 7258 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 4047 -> 5592 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 5822 -> 8968 bytes .../api/__pycache__/community.cpython-312.pyc | Bin 0 -> 5017 bytes .../api/__pycache__/history.cpython-312.pyc | Bin 0 -> 4921 bytes .../app/api/__pycache__/team.cpython-312.pyc | Bin 0 -> 4895 bytes backend/app/api/community.py | 79 ++++++++ backend/app/api/history.py | 79 ++++++++ backend/app/api/team.py | 79 ++++++++ backend/app/main.py | 7 +- backend/app/models.py | 34 ++++ backend/app/schemas.py | 87 ++++++++ backend/seed.py | 149 ++++++++++++++ src/app/about/about.component.html | 101 ++++++---- src/app/about/about.component.scss | 188 +++++++++++++++++- src/app/about/about.component.ts | 37 +++- .../admin/admin-community-form.component.html | 50 +++++ .../admin/admin-community-form.component.scss | 146 ++++++++++++++ .../admin/admin-community-form.component.ts | 104 ++++++++++ .../admin/admin-history-form.component.html | 50 +++++ .../admin/admin-history-form.component.scss | 146 ++++++++++++++ src/app/admin/admin-history-form.component.ts | 104 ++++++++++ src/app/admin/admin-team-form.component.html | 55 +++++ src/app/admin/admin-team-form.component.scss | 146 ++++++++++++++ src/app/admin/admin-team-form.component.ts | 108 ++++++++++ src/app/admin/admin.component.html | 147 +++++++++++++- src/app/admin/admin.component.ts | 131 +++++++++++- src/app/interfaces/community-highlight.ts | 8 + src/app/interfaces/history-entry.ts | 8 + src/app/interfaces/team-member.ts | 9 + .../services/community-highlight.service.ts | 37 ++++ src/app/services/history-entry.service.ts | 37 ++++ src/app/services/team-member.service.ts | 37 ++++ 34 files changed, 2119 insertions(+), 44 deletions(-) create mode 100644 backend/app/api/__pycache__/community.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/history.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/team.cpython-312.pyc create mode 100644 backend/app/api/community.py create mode 100644 backend/app/api/history.py create mode 100644 backend/app/api/team.py create mode 100644 src/app/admin/admin-community-form.component.html create mode 100644 src/app/admin/admin-community-form.component.scss create mode 100644 src/app/admin/admin-community-form.component.ts create mode 100644 src/app/admin/admin-history-form.component.html create mode 100644 src/app/admin/admin-history-form.component.scss create mode 100644 src/app/admin/admin-history-form.component.ts create mode 100644 src/app/admin/admin-team-form.component.html create mode 100644 src/app/admin/admin-team-form.component.scss create mode 100644 src/app/admin/admin-team-form.component.ts create mode 100644 src/app/interfaces/community-highlight.ts create mode 100644 src/app/interfaces/history-entry.ts create mode 100644 src/app/interfaces/team-member.ts create mode 100644 src/app/services/community-highlight.service.ts create mode 100644 src/app/services/history-entry.service.ts create mode 100644 src/app/services/team-member.service.ts diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc index 1c25c33c9beec594123a979deafce1cc639f28f1..a59b3386cbd2548ca7cfdb2f87b118504e889b62 100644 GIT binary patch delta 7858 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 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 3ee04a6384b1e39dfb706bbc020baa18a592439a..070a407bdb71134a132defde44ee48c8ac9660d8 100644 GIT binary patch delta 1557 zcmaJ=&u<$=6rR~^9LI?r+o^xW@ozizCLtDys-$X33Kd{crKnLGiH!tuGt+h#k8Q>_ zs?&p2Dd&o4RB@nxK#_B(XhGb_ol7f$C+4k_s*P<{+HdE5?|n1hyqOi} ztToTiZnq1`C$YV1Uho}w!pbxCAivjU0X@KidXRB`?KaJr#SJG;s^{X+!0TdKzm^3)D$wLOC8)}PrfAIQF@GC z7-FR~V4lK>o=0VGcL#KbX!6E6Y2k4Gq?F_j7hXG(|Le+}y#0&v#xLv3Ll1BHweF{0 zStM_fr8nE(!Y!;7ghy>-hj^gI*R08+QEyav>-|cDw=CCnT4I}&-K5;|U#>FNtQd_} z(YSr5EZ>HPE%ip7a?L4zPzy8G*qxr(QX5idjqjc)p8lmCC=+q%geL%!;{KTCI37M3 z`xq~L=ZraVOvJt42(h|b ztyK6~$maph18CwK-@JDo^3Jkxi`HwU3NJqW)n}-{YGYy0CIuD57gZU5kCT;3?!#oUC{a;!ghhZw@lkl=>LnPy3-BJmWq=PQSPptuo?+b@W!0v<0^sdi^tVLB zd)>PzeKgB-otu(_AErL3h;BILEp<`p50v~9?R3%3FK9=MM*fNahDN%`{{s2HTK#(c xVEysZ(fE(|o+JNDbVa7b7m@Ut^WEvpi|Nes#fwK*j^43z*Tlb(hpHhb{|{S{A|n6* delta 1147 zcmbVLF>ezw6t*vy5E7CmSK3^1X|8RWkb{;E5o&<~Y6}!0L0Kxq4M0@8RgPQ|d`Z=I z>Cy!;RTdK~3rZx0QU*3AB$yajPC_7l01OCL@XMuAQOSab&(Gic{GQ*l?N0ME(f5%^ z2#N7{e#%l}J5g2o>f0%7Zec_a2~`H{L36NUhbswNHB~!lChe4&vNcn)(`H)uj%_1z zh)N_v#%Md=6Wa>|4(_>+eQm5>{^b;tp>^ ziP@~U-{~~W; zAHVO(Qq=OWmX~YQDjNa+C_oV)%lDL%u`%!m zE9DjH)Js)1xBFcw%Xp68Ni5-h{vwebiwVVMc(PP4PtrA7Z8$6rOsK;&%9J@w;lC1v zs2i%Y8fh*!%C#z+;6$Ac-V#bE<~w&jZW=TmXPS>IUd~*=abg->x;Q4F=htqaOkh^Xi-t riJKLAkrBbcGjsMZUr3EaiXTw%6B_=EW3T>Q;D diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 026edc878cd257486c901e5030e8be4a938f1996..49ca2e0ad9ba1d13bedf13a391ad79e6305fe99d 100644 GIT binary patch delta 1342 zcmbVMO-vI(6yDwb?{>TWSr8C3Du_!JtbiDd5Jd`vl*FWjKujv73$0R0riHj6q$T0t z$+Ve-^kUL@u?8-t7mvoP2NJUfx;Jm?xyGZu*+N^4!Nf`C+j-xcdAs?(d3$*A$m{sT z^DG79S8(X*3un&JLVwM5zwtBF1Qp?Tsfa`7_6=kMMJ+gWRsh!7z`AtS1+2S)bt@i8 zeD^tJq~De%OwSqGhd)?+=0T`~2Jufzk{&EQv%X@a8^A~gbcZC)6Cea?2hfbuf#8h9 z9MdHq=V#2LBs*H#=9g)D6d%&9^d8$$t z150h6L$OLW%|01RrV>aQUrQlH6Qb29BtsaJlR>Xp-JVsAt|=yhl;bR*q;fM zc~5qt5LEe|9e?$ebGmjPLA&9!>A+kQ!%75#X^B|&Y4`>hk;c4D7mx)3>t}SK^)uGm znZ|robknIiyk5rvb!^kMmkSCjaT*(x7vljr9+r`2Pr*7A*Mnmd&;|IWsuSP#V2Qj0 zs&+W>ru|wP4HKrWz9Ae-XtwoeB9#bjpw)l)@R%GX@C0Tu20(nc{1+c`SMwh1Et*uW zx6zCH$7Zn3tXMFrY*%Ki>}<|$=9lnRF{nCkWv0&d=CSV0>;Lwq(J$Q_Xo5zr-UQZ~ z^bW-)v6_1<5sz=I#Zt;-Y$duHTe%rcY2wle8H!ffXzIiZ(x<1o0&iP-7myF=x&f$< zglFIWFxf(26=rfCfT-T{r|MWWT*9qpd*_*#o79<+Jovj~zDfV%=zk5p!E|@ScuMGC wmthyV;>j(%E(H53R6G5Mu8de|Z+4(U!K2b_gSA+p;8AG-A}{_Ryxu{-0k>f_IsgCw delta 177 zcmcbieO{jLG%qg~0}yOcGS3p{naC%>xNM^Oa+XS7O}@<{j3q3LnwvXWE0`D!CNpsu zvY7x?7MV=80g*_E!FH4NL}j_cfx?VHTpT_*R5XP9D}xMUAY+jbPy_%} CG$acE diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index c9e4a7537623454b048149fa06f4f76ad594e2d8..085474c50d2c742b635bfee5b4ec05e24d5e29dd 100644 GIT binary patch literal 8968 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/team.cpython-312.pyc b/backend/app/api/__pycache__/team.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8323220e0aa2356732ec5a6dd80ee7189b388dc GIT binary patch literal 4895 zcmd5=Z){W76~EW-J^#1!$BC0T33Ui0aYGVXpmZfGO#(K4wShDZq)iha_^^sh;^QVw+Mr*$ZXfKK&EcuhsHSP-n*;lR__TB0^Ru1c zu}Ry9Ug_O??z!ild!EnloO7Rl@AJ6{w9ilMP5lI@zv9Gd#tKXx$~Ho-5ShqSA!!<= z6lt4cOAApUEk?z(6qO8~pxD!nsKcN|#hG?RT?Q>F?zAWBF=)HuP5YuggLWwXbW60w zpq)xBVFgZ8b9-pH2ws6Ik<=h3W|%q+x} zh@iWsUVrtCOip85cN}0@wjisz^vxXOiy&xMnZgnpP~ItZaUpSxsVZ!QdGKwf#S%Hr z*@6~}%jx7oET=LKD;``?Yl80MES^5X(sQsV=&kkH3}sM05Zw@fcziZt_UAW5?K&1g4_e0?#ytR@z~s=?vs;hR*5gh42~|H zImI~Rs9bljvn&C)(?unzYT%PZT#0j47vhQ%5e*sMfr8RIh(_HGUe76-ihc@1dptUE zCc{sw*?5Af6KB|5ES}9y%*7L@0mBnW$Fs=^4bV59UDVs~kQmNlX#rlxv zTo8vV0`WilSZ#-M&rfZi?xeSd>2yH6HL?Tf_q-GDn!0M zvCIMkxWyJ=1RM>ZG~$SFK|y=f`xc7$GstbhoYeph9szD2JnBj43~byp{d@g|m1j$X zd&-0Rii7)p{n~Gi{OZVOZxtusEDe6E6gXM-om@V&>I;;8Lq*?E$v3im=&q-2wY96z zv$N3hT*3L=s;6!FNX13`Z&3>Xwy8mSOQcf+;w`Bk==;BeGb1*R;i-GfOcqN(rqdnS z_@a`D%Q}rEYHkIo8W`@tWOM40g(SEw)hG{A*erKkr=}~e(;rZ94Q-a%@`B}TNL(ShmtCcDU zxUWj^i+S;>!7t^dhTv~n!en7ZwD6Xyl`MGkYO}C*fP4p%=(nLbFWHwITCJevrMx}w zur7U+eB2=t_=TQQ)S6(>mg=<5tB-lfva87s$w*}90ks40xDDh=vmAZQ5mCqCLgHg* z6G~2Iu^ilMw1}r!I^#pQV;H-g(CJcAV`&wyEpV;TIDZ~jc0m_$n)z)L&@s~)XxD|) zY*ClqjVn2(VmimdiLHwMoN?pYfPTD0qki?5&>85j_(?}E*0%pS_XYdQ!I5Hc?ApWk-r{i6>!-bx~T^=C_ zslHyljSOA`wu-m!MkJftHXs>4*ABp=o`cRrviJXmWD}QHgC%#kEQSkWcthqdilK_f zgrt*51CZ%MkJT5z3V`Hv7eaEn4Inv=^e*G(x4R4I+a5aIDc<(lkq%JoJF$-p>UM20 zn_PICV-?pO^KrNpWRo0g0>@v1V@1&2>N`qYNt|NoMdN;?2kVS+c2*mY<2PI~!!Z-+ zPPihE$8*{#j>TE`;uOBW;&XBM(87-o-Hj}x%&KP1^&msV1L7+8f(XaY5AH|d7F@$m zK=Y$a<(RhN(r$iQ3UI;VA>@d2nT%rGy>%hEpcywUUCd@wjbq`}g=|jan1uMt*r5|S z{vGiU9%7~~=Oh@FQ`pP=b>LyyQ&*s`2$a$_())KZRwQFa;4fsh zNM_ea_)Ay&_j{M^6+5vxKMI`-U6{Eh7Pjv#hF@F`mFU;L?B9BAcVW-$o&KYD`v$Ka zF7@qPmz;ED)!T9D_{G_Z0Q5ay&jaK=6o~DmiVdhr(9$+r82vgbdin13tDlXQrr%sA z5^cYBtb)VM$p<*#zWo&&P?c8P*L$w_!m+}@uHOchdrS1-x}EfIGfuwi?<@p&l>B4s z4i{}-_4QW-7~%Me1S7nH)B6BdP~jn3u_3ej2}{!Xp!iYn+P?sq C7!O4N literal 0 HcmV?d00001 diff --git a/backend/app/api/community.py b/backend/app/api/community.py new file mode 100644 index 0000000..2ab7611 --- /dev/null +++ b/backend/app/api/community.py @@ -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() diff --git a/backend/app/api/history.py b/backend/app/api/history.py new file mode 100644 index 0000000..d791741 --- /dev/null +++ b/backend/app/api/history.py @@ -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() diff --git a/backend/app/api/team.py b/backend/app/api/team.py new file mode 100644 index 0000000..f28ac56 --- /dev/null +++ b/backend/app/api/team.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 4dc0a10..fabf42b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,9 +8,9 @@ from sqlalchemy import func, select from app.config import settings 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.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): @@ -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(tiers.router, prefix="/api/tiers", tags=["tiers"]) 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) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index 7b93e09..00199b4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -80,3 +80,37 @@ class StationConfig(Base): phone = Column(String(30), nullable=False, default="(970) 555-0198") email = Column(String(100), nullable=False, default="hello@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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 68ea471..41e107d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -156,3 +156,90 @@ class StationConfigUpdate(BaseModel): phone: Optional[str] = None email: 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} diff --git a/backend/seed.py b/backend/seed.py index ab2f416..6ea4fcb 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -15,6 +15,7 @@ from sqlalchemy import select from app.database import async_session from app.models import Program, Event, DonationTier, StationConfig +from app.models import HistoryEntry, TeamMember, CommunityHighlight # ── Seed data ────────────────────────────────────────────── @@ -113,6 +114,91 @@ STATION_CONFIG: dict = { "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 ──────────────────────────────────────────────── @@ -174,6 +260,51 @@ async def _upsert_station_config(session, data: dict) -> None: 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 ────────────────────────────────────────────────── async def seed() -> None: @@ -198,6 +329,21 @@ async def seed() -> None: await session.commit() 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: """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(DonationTier.__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() print(" ✓ Truncated all tables") diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index eaa1add..f9a35c1 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -1,5 +1,6 @@
+

About {{ config().name_primary }} {{ config().name_secondary }}

@@ -10,45 +11,69 @@

-
-
-
🎵
-

Our Music

-

- 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. -

-
-
-
🤝
-

Our Community

-

- 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. -

-
-
-
📡
-

Our Reach

-

- 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. -

-
-
-
🌱
-

Our Mission

-

- To keep public radio free, independent, and deeply rooted in place. - No corporate advertising, no spin — just music, conversation, and - community served with care. -

-
-
+ + @if (communityHighlights$ | async; as highlights) { + @if (highlights.length) { +
+ @for (highlight of highlights; track highlight.id) { +
+
{{ highlight.icon }}
+

{{ highlight.title }}

+

{{ highlight.description }}

+
+ } +
+ } + } + + @if (historyEntries$ | async; as entries) { + @if (entries.length) { +
+

Our History

+
+ @for (entry of entries; track entry.id; let index = $index) { +
+
+ {{ entry.year }} +

{{ entry.title }}

+

{{ entry.body }}

+
+
+ } +
+
+ } + } + + + @if (teamMembers$ | async; as members) { + @if (members.length) { +
+

Our Team

+
+ @for (member of members; track member.id) { +
+
+ @if (member.photo_url) { + + } @else { + {{ member.name.charAt(0) }} + } +
+

{{ member.name }}

+

{{ member.title }}

+ @if (member.bio) { +

{{ member.bio }}

+ } +
+ } +
+
+ } + } + +
+ } + } + + + @if (activeTab() === 'team') { + @if (teamMembers$ | async; as members) { +
+
+

Team Members ({{ members.length }})

+ +
+ + + + + + + + + + + + + @for (member of members; track member.id) { + + + + + + + + } @empty { + + } + +
NameTitleOrderActiveActions
{{ member.name }}{{ member.title }}{{ member.display_order }}{{ member.active ? 'Yes' : 'No' }} + + +
No team members yet. Click "Add Member" to create one.
+
+ } + } + + + @if (activeTab() === 'community') { + @if (communityHighlights$ | async; as highlights) { +
+
+

Community Highlights ({{ highlights.length }})

+ +
+ + + + + + + + + + + + + @for (highlight of highlights; track highlight.id) { + + + + + + + + } @empty { + + } + +
IconTitleOrderActiveActions
{{ highlight.icon }}{{ highlight.title }}{{ highlight.display_order }}{{ highlight.active ? 'Yes' : 'No' }} + + +
No highlights yet. Click "Add Highlight" to create one.
+
+ } + } + @if (activeTab() === 'station') { @if (stationConfig$ | async; as config) { @@ -215,4 +351,13 @@ @if (showStationForm) { } + @if (showHistoryForm) { + + } + @if (showTeamForm) { + + } + @if (showCommunityForm) { + + }
diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 18efcd0..0d2442b 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -7,16 +7,25 @@ import { Program } from '../interfaces/program'; import { Event } from '../interfaces/event'; import { Tier } from '../interfaces/tier'; 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 { EventService } from '../services/event.service'; import { TierService } from '../services/tier.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 { AdminEventFormComponent } from './admin-event-form.component'; import { AdminTierFormComponent } from './admin-tier-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({ selector: 'app-admin', @@ -29,6 +38,9 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station'; AdminEventFormComponent, AdminTierFormComponent, AdminStationFormComponent, + AdminHistoryFormComponent, + AdminTeamFormComponent, + AdminCommunityFormComponent, ], templateUrl: './admin.component.html', styleUrl: './admin.component.scss', @@ -38,6 +50,9 @@ export class AdminComponent implements OnInit { private eventService = inject(EventService); private tierService = inject(TierService); private stationConfigService = inject(StationConfigService); + private historyEntryService = inject(HistoryEntryService); + private teamMemberService = inject(TeamMemberService); + private communityHighlightService = inject(CommunityHighlightService); activeTab = signal('programs'); @@ -45,17 +60,26 @@ export class AdminComponent implements OnInit { editingProgram: Program | null = null; editingEvent: Event | 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. */ readonly programs$ = new BehaviorSubject([]); readonly events$ = new BehaviorSubject([]); readonly tiers$ = new BehaviorSubject([]); readonly stationConfig$ = new BehaviorSubject(null); + readonly historyEntries$ = new BehaviorSubject([]); + readonly teamMembers$ = new BehaviorSubject([]); + readonly communityHighlights$ = new BehaviorSubject([]); showProgramForm = false; showEventForm = false; showTierForm = false; showStationForm = false; + showHistoryForm = false; + showTeamForm = false; + showCommunityForm = false; ngOnInit(): void { this.loadAll(); @@ -81,12 +105,30 @@ export class AdminComponent implements OnInit { this.stationConfig$.next(this.stationConfigService.config()); } + async reloadHistoryEntries(): Promise { + const data = await firstValueFrom(this.historyEntryService.getHistoryEntries()); + this.historyEntries$.next(data); + } + + async reloadTeamMembers(): Promise { + const data = await firstValueFrom(this.teamMemberService.getTeamMembers()); + this.teamMembers$.next(data); + } + + async reloadCommunityHighlights(): Promise { + const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights()); + this.communityHighlights$.next(data); + } + async loadAll(): Promise { await Promise.all([ this.reloadPrograms(), this.reloadEvents(), this.reloadTiers(), this.reloadStationConfig(), + this.reloadHistoryEntries(), + this.reloadTeamMembers(), + this.reloadCommunityHighlights(), ]); } @@ -191,4 +233,91 @@ export class AdminComponent implements OnInit { onStationClosed(): void { 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 { + 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 { + 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 { + if (!confirm('Delete this community highlight?')) return; + await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id)); + await this.reloadCommunityHighlights(); + } } diff --git a/src/app/interfaces/community-highlight.ts b/src/app/interfaces/community-highlight.ts new file mode 100644 index 0000000..85675f7 --- /dev/null +++ b/src/app/interfaces/community-highlight.ts @@ -0,0 +1,8 @@ +export interface CommunityHighlight { + id: number; + icon: string; + title: string; + description: string; + display_order: number; + active: boolean; +} diff --git a/src/app/interfaces/history-entry.ts b/src/app/interfaces/history-entry.ts new file mode 100644 index 0000000..6d5a6a2 --- /dev/null +++ b/src/app/interfaces/history-entry.ts @@ -0,0 +1,8 @@ +export interface HistoryEntry { + id: number; + year: number; + title: string; + body: string; + display_order: number; + active: boolean; +} diff --git a/src/app/interfaces/team-member.ts b/src/app/interfaces/team-member.ts new file mode 100644 index 0000000..ef347ad --- /dev/null +++ b/src/app/interfaces/team-member.ts @@ -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; +} diff --git a/src/app/services/community-highlight.service.ts b/src/app/services/community-highlight.service.ts new file mode 100644 index 0000000..de8a36a --- /dev/null +++ b/src/app/services/community-highlight.service.ts @@ -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 { + let params = new HttpParams(); + if (active !== undefined) params = params.set('active', String(active)); + return this.http.get(this.baseUrl, { params }); + } + + getCommunityHighlight(id: number): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + createCommunityHighlight(payload: Omit): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateCommunityHighlight(id: number, payload: Partial): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteCommunityHighlight(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/src/app/services/history-entry.service.ts b/src/app/services/history-entry.service.ts new file mode 100644 index 0000000..73ffb97 --- /dev/null +++ b/src/app/services/history-entry.service.ts @@ -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 { + let params = new HttpParams(); + if (active !== undefined) params = params.set('active', String(active)); + return this.http.get(this.baseUrl, { params }); + } + + getHistoryEntry(id: number): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + createHistoryEntry(payload: Omit): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateHistoryEntry(id: number, payload: Partial): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteHistoryEntry(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/src/app/services/team-member.service.ts b/src/app/services/team-member.service.ts new file mode 100644 index 0000000..0c5bddf --- /dev/null +++ b/src/app/services/team-member.service.ts @@ -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 { + let params = new HttpParams(); + if (active !== undefined) params = params.set('active', String(active)); + return this.http.get(this.baseUrl, { params }); + } + + getTeamMember(id: number): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + createTeamMember(payload: Omit): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateTeamMember(id: number, payload: Partial): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteTeamMember(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +}