commit 83beebf35c7fcbddf5c27d823bf6ef6440364926 Author: claude Date: Mon Aug 24 13:15:13 2026 -0400 scout-website: greenlanescouts73.org initial deploy (from scoutpoc redesign) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d67954c --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# scout-website + +Public recruiting site for **Cub Scout Pack 73 and Scouts BSA Troop 73** (Zieglerville / Green Lane, PA). +Live at **https://greenlanescouts73.org** (NPM proxy → `scout-website` container, port 8131 on arrstack). +Replaces the `scoutpoc` proof of concept (claude-workspace/stacks/scoutpoc). + +## Updating the calendar (the part you'll actually do) + +All dates on the site (home "Next up," both program pages, the Calendar page) render from **one JSON file**, read fresh on every request: + +``` +/srv/scout-website/data/events.json ← the LIVE file. Edit this. +app/events.json ← seed only; copied to /data on first boot. +``` + +Edit the live file on the host (or ask Claude), save, refresh the site. **No rebuild, no redeploy.** + +Event format (one object per event): + +```json +{"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM", + "title": "Fall Campout", "location": "Green Lane Park", + "unit": "both", "badge": "OVERNIGHT"} +``` + +- `date` required (YYYY-MM-DD). `end` only for multi-day events. +- `unit`: `"pack"`, `"troop"`, or `"both"` (default pack). Drives the Pack/Troop calendar filter and each page's "Next up." +- `badge`: optional shouty label (`FALL OPEN HOUSE`, `OVERNIGHT`, `THE BIG ONE`). +- Past events are hidden from "Next up" automatically; keep or prune them in the file, your call. +- TeamSnap (team 8198615) stays the source of truth; this file is the public snapshot. + +To change the **seed** for future rebuilds too, edit `app/events.json` here in Gitea and redeploy the stack. + +## Layout + +- `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). Interest form appends to `/data/leads.jsonl` + pushes ntfy (`NTFY_URL` stack env). `/contact` 301s to `/join`. +- `app/events.json` — calendar seed (see above). +- `docker-compose.yml` — Portainer Repository stack definition (port 8131). +- Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`. + +## Leads + +`/srv/scout-website/data/leads.jsonl` — one JSON line per interest-form submission, plus an ntfy push per lead. diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..8e1f8ee --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +RUN pip install --no-cache-dir fastapi "uvicorn[standard]" python-multipart +WORKDIR /app +COPY . /app +RUN mkdir -p /app/static/img +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..6a68107 --- /dev/null +++ b/app/app.py @@ -0,0 +1,545 @@ +import json, os, shutil, datetime, urllib.request +from pathlib import Path +from fastapi import FastAPI, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles + +# --------------------------------------------------------------------------- +# Pack & Troop 73 - greenlanescouts73.org +# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on +# first boot). Edit that file on the host (or ask Claude) and every page that +# shows dates updates on the next refresh. No rebuild needed. +# --------------------------------------------------------------------------- + +DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True) +LEADS = DATA / "leads.jsonl" +EVENTS_LIVE = DATA / "events.json" +EVENTS_SEED = Path(__file__).parent / "events.json" +NTFY_URL = os.environ.get("NTFY_URL", "") + +app = FastAPI(title="Pack & Troop 73") +app.mount("/static", StaticFiles(directory="/app/static"), name="static") + +if not EVENTS_LIVE.exists() and EVENTS_SEED.exists(): + try: shutil.copyfile(EVENTS_SEED, EVENTS_LIVE) + except Exception: pass + +MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] +MF = ['January','February','March','April','May','June','July','August','September','October','November','December'] +DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] +DWU = ['MON','TUE','WED','THU','FRI','SAT','SUN'] + +def load_events(): + for p in (EVENTS_LIVE, EVENTS_SEED): + try: + with open(p, encoding="utf-8") as f: + return json.load(f).get("events", []) + except Exception: + continue + return [] + +def _dates(ev): + s = datetime.date.fromisoformat(ev["date"]) + e = datetime.date.fromisoformat(ev.get("end", ev["date"])) + return s, e + +def next_up(units, n=3): + today = datetime.date.today() + out = [] + for ev in load_events(): + u = ev.get("unit", "pack") + if u not in units and u != "both": continue + s, e = _dates(ev) + if e >= today: out.append((s, ev)) + out.sort(key=lambda t: t[0]) + return [ev for _, ev in out[:n]] + +def unit_label(u): return {"troop": "TROOP", "both": "ALL 73"}.get(u, "PACK") + +def ev_meta(ev, s): + return " · ".join(x for x in [DW[s.weekday()], ev.get("time"), ev.get("location")] if x) + +def next_up_cards(events, show_unit=False): + rows = [] + for ev in events: + s, _ = _dates(ev) + chips = "" + if show_unit: + chips += f'{unit_label(ev.get("unit","pack"))}' + if ev.get("badge"): + chips += f'{ev["badge"]}' + rows.append(f'''
+
{MB[s.month-1]}
{s.day}
+
{ev["title"]}{chips}
{ev_meta(ev, s)}
''') + return "\n".join(rows) + +CSS = """ +*{box-sizing:border-box}html{scroll-behavior:smooth} +body{margin:0;background:#FBF8F1;color:#232838;font-family:'Public Sans',system-ui,sans-serif;line-height:1.6} +a{color:#35608F}a:hover{color:#1E2F52}p{text-wrap:pretty} +::selection{background:#F0AD2D;color:#1E2F52} +.wrap{max-width:1160px;margin:0 auto;padding:0 24px} +.arch{font-family:'Archivo',sans-serif} +header.nav{position:sticky;top:0;z-index:60;background:rgba(251,248,241,.94);backdrop-filter:blur(10px);border-bottom:1px solid #E8E1D0} +.navrow{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:70px;flex-wrap:wrap} +.brand{display:flex;align-items:center;gap:12px;text-decoration:none;padding:10px 0} +.brand img{width:48px;height:48px;border-radius:50%;object-fit:cover;display:block;box-shadow:0 1px 4px rgba(16,25,44,.3)} +.brand .b1{font-family:'Archivo';font-weight:800;font-size:15px;letter-spacing:.06em;color:#1E2F52;display:block;line-height:1.25} +.brand .b2{font-size:11px;color:#6A7280;letter-spacing:.02em;display:block;line-height:1.25} +nav.links{display:flex;align-items:center;gap:26px;flex-wrap:wrap} +nav.links a{text-decoration:none;font-weight:600;font-size:15px;color:#333D52;padding:6px 0} +nav.links a:hover{color:#1E2F52} +nav.links a.on{font-weight:700;color:#1E2F52;border-bottom:2px solid #F0AD2D} +a.joinbtn{font-family:'Archivo';font-weight:700;font-size:15px;background:#F0AD2D;color:#1E2F52 !important;padding:11px 22px;border-radius:999px;border-bottom:none !important} +a.joinbtn:hover{background:#DB9714} +.hero{position:relative;display:flex;align-items:flex-end;min-height:520px} +.hero .wrap{padding-top:110px;padding-bottom:58px;width:100%} +.eyebrow{font-family:'Archivo';font-weight:700;font-size:13px;letter-spacing:.2em;text-transform:uppercase;margin-bottom:12px} +.eyebrow.gold{color:#F7C556}.eyebrow.dark{color:#B07B12} +.hero h1{margin:0 0 16px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.05;letter-spacing:-.015em;color:#fff;max-width:15ch} +.hero p{margin:0;font-size:clamp(1.05rem,1.7vw,1.2rem);color:rgba(255,255,255,.94);max-width:620px} +.cta{display:inline-block;font-family:'Archivo';font-weight:700;font-size:16px;background:#F0AD2D;color:#1E2F52;padding:15px 30px;border-radius:999px;text-decoration:none} +.cta:hover{background:#F7C556;color:#1E2F52} +.cta.ghost{background:transparent;color:#fff;border:2px solid rgba(255,255,255,.85);padding:13px 28px} +.cta.ghost:hover{background:rgba(255,255,255,.14);color:#fff} +h2.sec{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.4rem);letter-spacing:-.01em;color:#1E2F52} +.lead{margin:0 0 40px;color:#5A6272;font-size:1.08rem;max-width:640px} +.facts{background:#fff;border-radius:16px;box-shadow:0 18px 40px rgba(16,25,44,.12);display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1px;overflow:hidden;border:1px solid #EBE4D4;position:relative;z-index:5;margin-top:-44px} +.facts>div{padding:22px 26px;background:#fff} +.facts .fl{font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#8B93A3;margin-bottom:4px} +.facts .fv{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52} +.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:28px} +.grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px} +.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px} +.gridcards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px} +.pcard{text-decoration:none;background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 6px 18px rgba(16,25,44,.06);transition:transform .15s;color:inherit} +.pcard:hover{transform:translateY(-4px);color:inherit} +.pcard img{width:100%;height:270px;object-fit:cover;display:block} +.pcard .pad{padding:26px 28px 30px;display:flex;flex-direction:column;gap:10px} +.pcard h3{margin:0;font-family:'Archivo';font-weight:800;font-size:1.45rem;color:#1E2F52} +.chip{font-size:12px;font-weight:700;letter-spacing:.08em;padding:4px 12px;border-radius:999px;white-space:nowrap} +.chip.gold{background:#F3EBD8;color:#7A5B00}.chip.blue{background:#E3E9F2;color:#1E2F52} +.pcard p{margin:0;color:#5A6272} +.plink{font-family:'Archivo';font-weight:700;color:#35608F} +.evcard{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px} +.datechip{width:60px;min-width:60px;text-align:center;background:#1E2F52;border-radius:12px;padding:9px 4px} +.dc-m{font-size:11px;font-weight:700;letter-spacing:.12em;color:#F7C556} +.dc-d{font-family:'Archivo';font-weight:800;font-size:21px;color:#fff;line-height:1.15} +.ev-t{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap} +.ev-m{color:#6A7280;font-size:14px} +.uchip{font-size:11px;font-weight:700;letter-spacing:.08em;padding:3px 10px;border-radius:999px;white-space:nowrap} +.uchip-n{background:#EEF1F6;color:#33415E} +.uchip-pack{background:#F3EBD8;color:#7A5B00}.uchip-troop{background:#F6E3DA;color:#8A3B1D}.uchip-both{background:#E3E9F2;color:#1E2F52} +.badge{font-size:11px;font-weight:700;letter-spacing:.08em;background:#F0AD2D;color:#1E2F52;padding:3px 10px;border-radius:999px;white-space:nowrap} +.noev{background:#fff;border:1px dashed #CBD2DC;border-radius:14px;padding:22px 24px;color:#5A6272;font-size:15px} +.steps{display:flex;flex-direction:column;gap:22px} +.step{display:flex;gap:18px} +.step .n{font-family:'Archivo';font-weight:800;font-size:28px;color:#F0AD2D;min-width:44px} +.step .t{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52;margin-bottom:2px} +.step .d{color:#5A6272;font-size:15px} +.photoband img{width:100%;height:250px;object-fit:cover;border-radius:14px;display:block} +.volband{background:#1E2F52;border-radius:20px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))} +.volband img{width:100%;height:100%;min-height:320px;object-fit:cover;display:block} +.volband .pad{padding:46px 44px;display:flex;flex-direction:column;justify-content:center} +.volband h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.3rem);color:#fff;letter-spacing:-.01em} +.volband p{margin:0 0 12px;color:#D8DFEA;font-size:15.5px;line-height:1.7} +.vchip{font-size:12.5px;font-weight:700;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.22);color:#fff;padding:6px 14px;border-radius:999px} +.band{position:relative;padding:96px 24px;text-align:center} +.band h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.9rem,3.6vw,2.7rem);color:#fff;letter-spacing:-.01em} +.band p{margin:0 auto 28px;color:rgba(255,255,255,.92);font-size:1.08rem;max-width:560px} +.rank{border-radius:14px;padding:20px 18px;border:1px solid #EBE4D4} +.rank .rl{font-size:11px;font-weight:700;letter-spacing:.12em;margin-bottom:6px;color:#B07B12} +.rank .rn{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;margin-bottom:4px} +.rank .rd{font-size:13.5px;color:#6A7280} +.rankgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px} +.r1{background:linear-gradient(180deg,#fff,#FFFBF2);border-top:5px solid #F7C556} +.r2{background:linear-gradient(180deg,#fff,#FEF7E8);border-top:5px solid #F0AD2D} +.r3{background:linear-gradient(180deg,#fff,#FDF2DC);border-top:5px solid #E29A25} +.r4{background:linear-gradient(180deg,#fff,#FBEDD2);border-top:5px solid #D4842A}.r4 .rl{color:#A86A15} +.r5{background:linear-gradient(180deg,#fff,#F9E6D0);border-top:5px solid #C4552D}.r5 .rl{color:#A86A15} +.r6{background:linear-gradient(180deg,#fff,#F3DECD);border-top:5px solid #8A3B1D}.r6 .rl{color:#8A3B1D} +.r7{background:linear-gradient(160deg,#1E2F52,#2C4470);border:1px solid #1E2F52;border-top:5px solid #F0AD2D} +.r7 .rl{color:#F7C556}.r7 .rn{color:#fff}.r7 .rd{color:#C3CBDA} +.ycard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;overflow:hidden} +.ycard img{width:100%;height:190px;object-fit:cover;display:block} +.ycard .pad{padding:20px 22px} +.ycard .t{font-family:'Archivo';font-weight:800;font-size:1.1rem;color:#1E2F52;margin-bottom:4px} +.ycard .d{font-size:14.5px;color:#5A6272} +.infocard{background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));align-items:stretch} +.infocard .pad{padding:34px 36px} +.infocard img{width:100%;height:100%;min-height:300px;object-fit:cover;display:block} +.rrow{display:flex;gap:12px;align-items:baseline} +.rrow .k{font-family:'Archivo';font-weight:800;color:#F0AD2D;font-size:1.05rem;min-width:96px} +.rrow .v{color:#333D52;font-size:15px} +.calhead{background:#1E2F52;padding:72px 0 64px} +.calhead h1{margin:0 0 18px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.02;letter-spacing:-.015em;color:#fff} +.calhead .sub{margin:0 0 34px;color:#C3CBDA;font-size:1.08rem;max-width:640px} +.rhythm{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px} +.rhythm>div{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:18px 22px} +.rhythm .t{font-family:'Archivo';font-weight:800;color:#F7C556;font-size:1.05rem;margin-bottom:2px} +.rhythm .d{color:#D8DFEA;font-size:14px} +select.unitsel{font-family:'Archivo';font-weight:700;font-size:15px;color:#1E2F52;background:#fff;border:1.5px solid #CBD2DC;border-radius:12px;padding:12px 18px;cursor:pointer;min-width:240px} +.mcard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:26px 28px} +.mhead{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid #F0AD2D;padding-bottom:10px;margin-bottom:16px} +.mhead .mn{font-family:'Archivo';font-weight:800;font-size:1.35rem;color:#1E2F52} +.mhead .my{font-size:13px;font-weight:700;color:#8B93A3} +.mrow{display:flex;gap:14px;align-items:baseline} +.mrow .ml{font-family:'Archivo';font-weight:800;color:#B07B12;min-width:84px;font-size:14px} +.mrow .mt{font-size:15px;color:#232838} +.mrow .mm{color:#5A6272} +.note{background:#F3EBD8;border:1px solid #E8DCBB;border-radius:14px;padding:18px 24px;color:#6B5510;font-size:14.5px} +form.lead{background:#fff;border:1px solid #EBE4D4;border-radius:18px;padding:32px 32px 34px;display:grid;gap:18px;box-shadow:0 14px 34px rgba(16,25,44,.08)} +form.lead .ft{font-family:'Archivo';font-weight:800;font-size:1.3rem;color:#1E2F52} +.frow{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px} +label{display:block;font-weight:600;font-size:14px;margin-bottom:6px;color:#333D52} +input,select,textarea{width:100%;padding:12px 13px;border:1px solid #CBD2DC;border-radius:9px;font:inherit;background:#fff} +input:focus,select:focus,textarea:focus{outline:2px solid #35608F;border-color:#35608F} +textarea{resize:vertical} +button.cta{border:none;cursor:pointer} +.req{color:#C4552D} +.success{background:#E4F2E4;border:1px solid #9CC79C;color:#1E5A2E;border-radius:12px;padding:16px 20px;font-weight:600;margin-bottom:18px} +.faq{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:24px 26px} +.faq .q{font-family:'Archivo';font-weight:800;font-size:1.05rem;color:#1E2F52;margin-bottom:6px} +.faq .a{font-size:14.5px;color:#5A6272} +footer{background:#1E2F52;color:#C3CBDA;padding:52px 0 40px;margin-top:auto} +.footgrid{display:flex;justify-content:space-between;gap:36px;flex-wrap:wrap} +footer .fh{font-family:'Archivo';font-weight:700;color:#F7C556;font-size:13px;letter-spacing:.14em;margin-bottom:12px} +footer a{color:#D8DFEA;text-decoration:none;font-size:14px} +footer a:hover{color:#fff} +footer a.gold{color:#F7C556;font-weight:700} +""" + +def page(title, body, active=""): + def on(k): return ' class="on"' if active == k else "" + return f""" + + +{title} + + + + + + +{body} + +""" + +def hero(img, pos, eyebrow, h1, sub, extra="", minh=520): + return f"""
+
{eyebrow}

{h1}

{sub}

{extra}
""" + +@app.get("/health") +def health(): return {"status": "ok"} + +@app.get("/", response_class=HTMLResponse) +def home(): + up = next_up({"pack", "troop"}, 3) + ev_html = next_up_cards(up, show_unit=True) or '
Nothing on the books right now. New dates land on the shared calendar first, so check back soon or message us.
' + body = f""" +{hero('autumn-hike.jpg', 'center', 'Family Scouting · Zieglerville, PA', 'Where kids find their trail.', + 'A Cub Scout pack and a Scout troop for kindergarten through age 17. Camp, build, lead, and grow alongside friends, right here in the Perkiomen Valley.', + '
Join us this fallSee the year
', 640)} +
+
Meetings
Tue · Pack 6:00 · Troop 7:00
+
Where
St. Luke's Lutheran Church
+
Ages
Kindergarten to 17
+
Everyone
Every kid welcome · first visit free
+
+
+
+
Coming up at 73

Next up at 73.

+
{ev_html}
+
This list updates itself from the shared 73 calendar.
+See the full 2026-2027 calendar →
+
New here?

Your first visit, step by step.

+
+
01
Say hello
Send the interest form and a real leader (a local parent, not a call center) replies within a day or two.
+
02
Drop in on a Tuesday
Come watch a meeting, no commitment and nothing to bring. Kids usually join the fun within ten minutes.
+
03
Take it at your pace
Register when your family is ready. We help with uniforms, costs, and every question in between.
+
+Start with the form →
+
+
+Scouts building model rockets +A Lion Cub Scout working on a craft +A scout climbing at the crag +
+
+Parents and scouts exploring a trail together +
Parents make it go

We run on volunteers.

+

Every derby track, campfire, and trail snack table is parent-powered. Nobody shows up knowing how; training is provided and the pack will happily teach you the knots.

+

Help comes in every size: one chocolate booth shift, planning a hike, running a derby heat, or leading a den. The more hands we have, the better the year gets for every kid.

+
One-shift helpersHike plannersDerby pit crewDen leaders
+Raise your hand →
+
+
+

Your first campfire is waiting.

+

Tell us a little about your child and we will save you a seat at the next meeting.

+Join Pack & Troop 73
""" + return page("Pack & Troop 73 · Family Scouting in Zieglerville, PA", body, "home") + +@app.get("/cubs", response_class=HTMLResponse) +def cubs(): + up = next_up({"pack"}, 3) + ev_html = next_up_cards(up) or '
Nothing on the books right now. New pack dates land on the shared calendar first, so check back soon or message us.
' + body = f""" +{hero('circle-game.jpg', 'center 30%', 'Pack 73 · Grades K-5', 'Little kids. Big adventures.', + 'Cub Scouts is where it starts: derby cars, first hikes, campfire songs, and a den of friends who cheer each other on.')} +
+
How it works

A den for every grade, a pack for every family.

+

Scouts meet in small grade-level dens, and the whole pack comes together for the big stuff: derbies, campouts, and award nights. Meetings are Tuesdays at 6:00 PM at St. Luke's, so siblings in different dens land on the same night.

+

A parent or guardian stays for every Cub Scout meeting and event; it is a do-it-together program. Lions and Tigers work hands-on with their grown-up, while older dens mostly need you in the room (and extra hands are always put to work).

+A Lion Cub Scout working on a craft +
+
+
The trail through Cub Scouts

Six ranks, kindergarten to crossover.

+
+
KINDERGARTEN
Lion
Big firsts with a grown-up along for every step.
+
1ST GRADE
Tiger
Games, field trips, and the first real badges.
+
2ND GRADE
Wolf
More independence, more time on the trail.
+
3RD GRADE
Bear
Whittling chips, science, and real outdoor skills.
+
4TH GRADE
Webelos
Camping like the big kids and leading the little ones.
+
5TH GRADE
Arrow of Light
The bridge to the troop, crossing over each spring.
+
+
+
A year in the life

The traditions your kid will talk about all week.

+
+
Painting pinewood derby cars
Derby season, twice
Boxcar Derby in September, the classic Pinewood Derby in February. Build it, paint it, race it.
+
Setting up tents at the fall campout
Family campouts
Fall and spring weekends under the stars at Green Lane Park and Camp Garrison, whole families welcome.
+
Scouts on a monthly pack hike
Monthly pack hikes
A different trailhead almost every month, from the Perkiomen Trail to Green Lane. Strollers welcome.
+
Scouts with model rockets
Movie nights & more
Rockets, Trunk or Treat, Blue & Gold banquet, Mario Party night, and a kickball party to close the year.
+
+
+
On the calendar

Next up for Pack 73.

+
{ev_html}
+See the full pack calendar → +
+
+
+
Keeping it affordable
+

The famous chocolate booth.

+

You will find us selling chocolate bars at the Green Lane Farmer's Market on Saturday mornings. Fundraisers like the booth keep dues low and help every scout get to camp. Cost should never keep a kid out of Scouting; if it is a stretch, talk to us.

+
+
TuesdaysDen and pack meetings, 6:00 PM at St. Luke's
+
MonthlyWeekend pack hike, usually 1:00 PM
+
SaturdaysChocolate booth at the Green Lane Farmer's Market, 7:00 AM
+
+
+

Come try a Tuesday.

+

Your first visit is free and easy: just show up and see if it clicks. Most kids know by the end of the night.

+Tell us you're coming
""" + return page("Cub Scouts · Pack 73 · Zieglerville, PA", body, "cubs") + +@app.get("/troop", response_class=HTMLResponse) +def troop(): + up = next_up({"troop"}, 3) + ev_html = next_up_cards(up) or '
Nothing on the books right now. New troop dates land on the shared calendar first, so check back soon or message us.
' + body = f""" +{hero('troop-hero.jpg', 'center 35%', 'Troop 73 · Ages 11-17', 'Youth led. Outdoors first.', + 'The troop is where kids run the show: planning trips, leading patrols, and learning the kind of confidence you cannot teach indoors.')} +
+
How it works

The scouts run it. Really.

+

Troop 73 is organized into patrols that elect their leaders, plan the trips, cook the meals, and fix their own mistakes. Trained, background-checked adults coach from the back row.

+

We meet Tuesdays at 7:00 PM at St. Luke's, right after the pack, so scouting families land on one night. Every kid ages 11 to 17 is welcome, with or without a Cub Scout past. (Officially we charter as a Scouts BSA troop. Around here, it's just the troop.)

+

Founded in spring 2026 at St. Luke's, the troop is built by the scouts in it; the traditions are theirs to invent.

+Scouts around the campfire circle at dusk +
+
+
The trail through the troop

Seven ranks, first knot to Eagle.

+
+
STOP 1
Scout
The basics, the oath, and the handshake. Everyone starts here.
+
STOP 2
Tenderfoot
First campout skills: knots, flags, and first aid.
+
STOP 3
Second Class
Navigation, outdoor cooking, and safe swimming.
+
STOP 4
First Class
The full outdoor toolkit, ready to lead a patrol.
+
STOP 5
Star
Leadership roles and merit badges start stacking up.
+
STOP 6
Life
Leading service projects and mentoring younger scouts.
+
THE SUMMIT
Eagle
The one on the resume, earned with a project that outlasts you.
+
+
+
A year in the life

Weekends worth putting the phone down for.

+
+
Scouts relaxing at a campsite
Campouts in every season
Fall, winter, and spring weekends. Scouts plan the menu, pack the trailer, and run the camp.
+
Scouts whitewater rafting
On the water
Canoe and river trips when the weather warms, including the troop's May river run.
+
A scout on a climbing wall
Climb, build, navigate
Climbing, orienteering, camp gadgets, and the Klondike winter derby: skills merit badges are made of.
+
Scouts doing a community service project
Service & Courts of Honor
Projects that make the town better, then a Court of Honor where every rank gets its moment.
+
+
+
On the calendar

Next up for Troop 73.

+
{ev_html}
+See the full troop calendar → +
+
+
The rhythm
+

One night a week, one adventure a month.

+
+
TuesdaysTroop meeting, 7:00 PM at St. Luke's, right after the pack
+
Most monthsA weekend campout, trip, or service project
+
All yearMerit badges and rank work at each scout's own pace
+
+

Registration plus modest troop dues; fundraisers keep costs low and gear can be borrowed. Cost never keeps a kid off the trail.

+Scouts in uniform at a Court of Honor +
+
+

No experience needed. Curiosity helps.

+

Plenty of great scouts never spent a day in Cub Scouts. If your kid is 11 to 17 and likes the idea of a weekend outdoors, they belong here.

+
+

Come watch a Tuesday.

+

The troop takes over at 7:00 PM at St. Luke's. Drop in, meet the patrols, stay if it clicks.

+Message the troop
""" + return page("The Troop · Troop 73 · Zieglerville, PA", body, "troop") + +@app.get("/calendar", response_class=HTMLResponse) +def calendar(unit: str = "all"): + if unit not in ("all", "pack", "troop"): unit = "all" + groups = {} + for ev in load_events(): + u = ev.get("unit", "pack") + if unit != "all" and u != unit and u != "both": continue + s, e = _dates(ev) + key = s.year * 12 + s.month + groups.setdefault(key, []).append((s, e, ev)) + cards = [] + for key in sorted(groups): + rows = [] + for s, e, ev in sorted(groups[key], key=lambda t: t[0]): + label = f"{DWU[s.weekday()]} {s.day}" + (f"-{e.day}" if e != s else "") + u = ev.get("unit", "pack") + badge = f' {ev["badge"]}' if ev.get("badge") else "" + meta = " · ".join(x for x in [ev.get("time"), ev.get("location")] if x) + meta = f' · {meta}' if meta else "" + rows.append(f'
{label}{ev["title"]} {unit_label(u)}{badge}{meta}
') + y, m = divmod(key - 1, 12) + cards.append(f'
{MF[m]}{y}
{"".join(rows)}
') + sel = {u: " selected" if unit == u else "" for u in ("all", "pack", "troop")} + body = f""" +
+
Pack & Troop 73 program year
+

The 2026-2027 year, at a glance.

+

The highlights below are open to visiting families. Come to any of them, or just show up on a Tuesday.

+
+
Pack Tuesdays · 6:00 PM
Pack or den meetings at St. Luke's Lutheran Church (off on school holidays)
+
Troop Tuesdays · 7:00 PM
Troop 73 meets right after the pack, same building
+
About monthly · weekends
Pack hikes at rotating trailheads, usually 1:00 PM
+
Saturdays · 7:00 AM
Chocolate booth at the Green Lane Farmer's Market
+
+
+
+
+ + + +
+
{"".join(cards)}
+
+
+Dates occasionally shift. Registered families get the live TeamSnap calendar with every den and troop meeting; the newest info always wins there. +
""" + return page("2026-2027 Calendar · Pack & Troop 73", body, "cal") + +FAQ = [ + ("What does it cost?", "Annual registration plus modest pack or troop dues. Fundraisers like our chocolate booth keep costs down, and quiet help is available. Cost never keeps a kid out of Pack 73; just talk to us."), + ("Do parents stay at meetings?", "Yes: a parent or guardian stays at every Cub Scout meeting and event. Lions and Tigers pair up with their grown-up for the hands-on parts; for older dens, being in the room is enough. Troop scouts (11 to 17) can be dropped off."), + ("Which program fits my kid?", "K through 5th grade join Pack 73's dens. Ages 11 to 17 join Troop 73. Fifth graders get the best of both: Arrow of Light now, crossover to the troop in spring."), + ("Can we join mid-year?", "Any Tuesday works. Badges and adventures meet kids where they are, so a January start is just as good as a September one."), + ("What about uniforms and gear?", "Start with sneakers and a water bottle. Uniform pieces come gradually, and we keep a stash of hand-me-downs. Nobody needs camping gear to start."), + ("Who can join?", "Every kid. Pack 73 and Troop 73 are open to all children and teens, whatever their background or beliefs. No exceptions, no fine print. If your kid wants to camp, build, and belong, they are one of us."), +] + +def join_body(sent=False): + success = '
Thanks! Your note is on its way. A leader will be in touch soon.
' if sent else '' + faq_html = "".join(f'
{q}
{a}
' for q, a in FAQ) + return f""" +
+
Join Pack & Troop 73
+

Come see a meeting. That's the whole ask.

+

Tell us a little about your child and a leader will reach out with the right den or troop, what to expect, and directions. No commitment, and your first visit is always free.

+
+
1Send the form. It goes straight to our volunteer leaders' phones.
+
2Hear back within a day or two from a real Pack or Troop 73 parent.
+
3Visit a Tuesday at St. Luke's: Pack 6:00, Troop 7:00. Sneakers recommended.
+
+Scouts cheering during a pack game
+
{success} +
+
Interest form
+
+
+
+
+
+
+
+
+
+
+ +
We only use this to reply about Scouting. No newsletters, no sharing.
+
+
+
+
New-family FAQ

The questions every new family asks.

+
{faq_html}
+
+
+

See you Tuesday.

+

St. Luke's Lutheran Church, Zieglerville · Pack 6:00, Troop 7:00. Look for the kids having the most fun in the building.

""" + +@app.get("/join", response_class=HTMLResponse) +def join_get(sent: int = 0): + return page("Join us · Pack & Troop 73", join_body(bool(sent))) + +@app.get("/contact") +def contact_redirect(): + return RedirectResponse(url="/join", status_code=301) + +@app.post("/join") +def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""), + children: str = Form(""), program: str = Form(""), message: str = Form("")): + rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"), + "parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(), + "children": children.strip(), "program": program.strip(), "message": message.strip()} + try: + with open(LEADS, "a", encoding="utf-8") as f: + f.write(json.dumps(rec) + "\n") + except Exception: + pass + if NTFY_URL: + try: + b = ("New Scout interest: %s (%s) - %s" % (rec["parent_name"], rec["email"], rec["program"] or "program TBD")).encode() + rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead", "Tags": "tent"}) + urllib.request.urlopen(rq, timeout=3) + except Exception: + pass + return RedirectResponse(url="/join?sent=1", status_code=303) diff --git a/app/events.json b/app/events.json new file mode 100644 index 0000000..47f4cb0 --- /dev/null +++ b/app/events.json @@ -0,0 +1,39 @@ +{ + "updated": "2026-08-24", + "note": "One event per object. Home shows the next 3 automatically; Calendar groups by month with Pack/Troop filters. Fields: date (YYYY-MM-DD), end (optional), time (optional), title, location (optional), unit ('pack' | 'troop' | 'both'; default pack), badge (optional short label).", + "events": [ + {"date": "2026-08-29", "time": "1:00 PM", "title": "Pack Hike", "location": "Plank Road City Park", "unit": "pack"}, + {"date": "2026-09-01", "time": "6:30 PM", "title": "Kickoff Party", "location": "Green Lane Park, Morrow Pavilion", "unit": "both"}, + {"date": "2026-09-12", "time": "10:00 AM", "title": "Lower Frederick Community Day", "location": "PV Middle School West", "unit": "both"}, + {"date": "2026-09-19", "time": "7:00 AM", "title": "Farmer's Market chocolate booth", "location": "Green Lane Park", "unit": "pack"}, + {"date": "2026-09-20", "time": "1:00 PM", "title": "Pack Hike", "location": "Perkiomen Trail, Foy Park", "unit": "pack"}, + {"date": "2026-09-22", "time": "6:00 PM", "title": "Boxcar Derby", "location": "St. Luke's", "unit": "pack", "badge": "FALL OPEN HOUSE"}, + {"date": "2026-09-26", "time": "10:30 AM", "title": "Upper Frederick Community Day", "location": "Municipal Building", "unit": "both"}, + {"date": "2026-10-02", "time": "6:00 PM", "title": "Parent & Scout Movie Night", "location": "St. Luke's", "unit": "pack"}, + {"date": "2026-10-10", "title": "Haunted Hayride (scouts performing)", "location": "Green Lane Park", "unit": "pack"}, + {"date": "2026-10-17", "time": "7:00 AM", "title": "Farmer's Market chocolate booth", "location": "Green Lane Park", "unit": "pack"}, + {"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM", "title": "Fall Campout", "location": "Green Lane Park", "unit": "both", "badge": "OVERNIGHT"}, + {"date": "2026-10-27", "time": "6:00 PM", "title": "Trunk or Treat pack meeting", "location": "St. Luke's", "unit": "pack"}, + {"date": "2026-11-07", "time": "1:00 PM", "title": "Pack Hike", "location": "Schwenksville", "unit": "pack"}, + {"date": "2026-11-14", "title": "First Court of Honor", "location": "St. Luke's", "unit": "troop"}, + {"date": "2026-12-05", "time": "1:00 PM", "title": "Pack Hike", "location": "Perkiomen Trail, Crusher Road", "unit": "pack"}, + {"date": "2026-12-11", "time": "6:00 PM", "title": "Parent & Scout Movie Night", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-01-16", "title": "Pack Mario Party", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-01-23", "time": "1:00 PM", "title": "Pack Hike", "location": "Red Trail, Pennsburg", "unit": "pack"}, + {"date": "2027-02-06", "title": "Blue & Gold banquet", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-02-13", "title": "Klondike Winter Derby", "unit": "troop"}, + {"date": "2027-02-20", "title": "Pinewood Derby", "location": "St. Luke's", "unit": "pack", "badge": "THE BIG ONE"}, + {"date": "2027-02-27", "time": "1:00 PM", "title": "Pack Hike", "location": "East Greenville", "unit": "pack"}, + {"date": "2027-03-13", "time": "1:00 PM", "title": "Pack Hike", "location": "Black Rock Rd, Royersford", "unit": "pack"}, + {"date": "2027-03-19", "time": "6:00 PM", "title": "Parent & Scout Movie Night", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-04-09", "title": "Troop Spring Campout", "unit": "troop", "badge": "OVERNIGHT"}, + {"date": "2027-04-10", "title": "Pack Spring Campout", "location": "Camp Garrison", "unit": "pack", "badge": "OVERNIGHT"}, + {"date": "2027-04-24", "title": "Arrow of Light Crossover & Awards", "location": "St. Luke's", "unit": "both"}, + {"date": "2027-04-30", "title": "Founder's Day: the troop turns one", "unit": "troop"}, + {"date": "2027-05-08", "time": "1:00 PM", "title": "Pack Bike Ride", "location": "Swamp Creek Park", "unit": "pack"}, + {"date": "2027-05-09", "title": "River Canoe Trip", "unit": "troop"}, + {"date": "2027-05-21", "time": "6:00 PM", "title": "Parent & Scout Movie Night", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-05-25", "time": "6:00 PM", "title": "Kickball Party", "location": "St. Luke's", "unit": "pack"}, + {"date": "2027-05-29", "time": "1:00 PM", "title": "Pack Hike", "location": "Perkiomenville", "unit": "pack"} + ] +} diff --git a/app/static/img/.gitkeep b/app/static/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e02e7fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +# scout-website — greenlanescouts73.org (Pack & Troop 73) +# +# Deployed as a Portainer Repository stack pointed at this repo +# (ops/portainer-stacks/00-the-rule.md in claude-workspace applies): +# - no relative bind mounts: `.` resolves to Portainer's clone, not /srv +# - no env_file: NTFY_URL comes from the Portainer stack env +# Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted +# read-only) so photos never bloat clones. +# The live, hand-editable calendar is /srv/scout-website/data/events.json +# (seeded from app/events.json on first boot; see README). + +name: scout-website +services: + scout-website: + build: + context: ./app + container_name: scout-website + restart: unless-stopped + ports: + - "8131:8000" + environment: + - NTFY_URL=${NTFY_URL} + volumes: + - /srv/scout-website/data:/data + - /srv/scout-website-assets/img:/app/static/img:ro + networks: + - default + - npm +networks: + npm: + external: true + name: arrstack_arr_net