From bdb2f88b90dbce74c712bf8b5729464c2787876b Mon Sep 17 00:00:00 2001 From: ejones Date: Fri, 12 Jun 2026 16:06:03 -0400 Subject: [PATCH] inital commit --- .dockerignore | 8 + .gitignore | 1 + Dockerfile | 10 + Makefile | 24 +++ README.md | 148 ++++++++++++++ package.json | 9 + public/css/style.css | 464 +++++++++++++++++++++++++++++++++++++++++++ public/index.html | 108 ++++++++++ public/js/app.js | 237 ++++++++++++++++++++++ serve.py | 43 ++++ 10 files changed, 1052 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 package.json create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100755 serve.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb704d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +README.md +docker-compose.yml +Makefile +package.json +serve.py +*.tar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee1397b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.27-alpine + +LABEL org.opencontainers.image.title="Euchre Scoreboard" +LABEL org.opencontainers.image.description="Web-based Euchre scoring app" +LABEL org.opencontainers.image.licenses="MIT" + + +COPY public/ /usr/share/nginx/html/ + +EXPOSE 80 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e433f2a --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: run docker stop docker-image docker-save docker-push + +IMAGE ?= euchre-scoreboard +TAG ?= latest +FULL_IMAGE = $(IMAGE):$(TAG) + +run: + python3 serve.py + +docker: + docker compose up --build + +stop: + docker compose down + +docker-image: + docker build -t $(FULL_IMAGE) . + +docker-save: docker-image + docker save -o euchre-scoreboard-$(TAG).tar $(FULL_IMAGE) + @echo "Saved euchre-scoreboard-$(TAG).tar — upload or share this file" + +docker-push: docker-image + docker push $(FULL_IMAGE) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7160cdf --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Euchre Scoreboard + +A web-based Euchre scoring app with a card-table theme. Track scores for two teams, handle march/euchre/lone outcomes, and play to 10 or 11 points. + +## Features + +- Two-team scoreboard with editable team names +- Quick scoring: Made (+1), March (+2), Lone (+4) +- Defensive scoring: Euchre (+2), Lone stopped (+4) +- Hand history with undo +- Configurable target score (10 or 11) +- Scores persist in browser localStorage +- Mobile-friendly layout + +## Quick Start + +Both local and Docker modes serve the app at [http://localhost:20010](http://localhost:20010). + +### Run locally + +Requires Python 3.7+ (usually pre-installed on Linux and macOS). + +```bash +python3 serve.py +``` + +Other ways to start locally: + +```bash +make run +# or +npm start +``` + +Options: + +```bash +python3 serve.py --port 3000 # use a different port +python3 serve.py --no-open # don't open a browser automatically +``` + +### Run with Docker + +Requires Docker and Docker Compose. + +```bash +docker compose up --build +``` + +Other ways to start with Docker: + +```bash +make docker +# or +npm run start:docker +``` + +Without Compose: + +```bash +docker build -t euchre-scoreboard . +docker run -p 20010:80 euchre-scoreboard +``` + +Stop Docker: + +```bash +make stop +# or +docker compose down +``` + +## Publish the Docker image + +Build a tagged image ready to push or share: + +```bash +# Local tag (default) +make docker-image + +# Your registry tag (Docker Hub, GHCR, etc.) +make docker-image IMAGE=yourusername/euchre-scoreboard TAG=latest +``` + +### Push to a registry + +Log in first (`docker login`), then push: + +```bash +make docker-push IMAGE=yourusername/euchre-scoreboard TAG=latest +``` + +Examples: + +```bash +# Docker Hub +docker login +make docker-push IMAGE=yourusername/euchre-scoreboard TAG=1.0.0 + +# GitHub Container Registry +docker login ghcr.io +make docker-push IMAGE=ghcr.io/yourusername/euchre-scoreboard TAG=latest +``` + +Others can run your published image with: + +```bash +docker run -p 20010:80 yourusername/euchre-scoreboard:latest +``` + +### Export as a file (upload elsewhere) + +Creates a `.tar` archive you can upload to any host and load with `docker load`: + +```bash +make docker-save IMAGE=euchre-scoreboard TAG=1.0.0 +``` + +On the destination machine: + +```bash +docker load -i euchre-scoreboard-1.0.0.tar +docker run -p 20010:80 euchre-scoreboard:1.0.0 +``` + +## Scoring Reference + +| Outcome | Points | +|---------|--------| +| Makers win 3–4 tricks | +1 | +| Makers win all 5 tricks (march) | +2 | +| Makers win 0–2 tricks (euchred) | +2 to defenders | +| Lone wins 3–4 tricks | +1 | +| Lone wins all 5 tricks | +4 | +| Lone stopped (0–2 tricks) | +4 to defenders | + +## Project Layout + +``` +Euchre/ +├── public/ # Static web app (HTML, CSS, JS) +├── serve.py # Local dev server +├── Dockerfile # Production-style nginx container +├── docker-compose.yml +└── Makefile # run / docker shortcuts +``` + +No build step is required for local development. diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd88cc1 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "euchre-scoreboard", + "private": true, + "description": "Web-based Euchre scoring app", + "scripts": { + "start": "python3 serve.py", + "start:docker": "docker compose up --build" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..6221cf8 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,464 @@ +:root { + --felt: #1a4d2e; + --felt-dark: #0f3320; + --felt-light: #2d6a4f; + --gold: #d4a853; + --gold-light: #f0d78c; + --cream: #f5f0e6; + --cream-muted: #c9c0b0; + --card-bg: #faf7f0; + --team-us: #2e7d5a; + --team-them: #8b3a3a; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + --radius: 16px; + --radius-sm: 10px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Inter", system-ui, sans-serif; + color: var(--cream); + background: + radial-gradient(ellipse at 50% 0%, rgba(45, 106, 79, 0.4) 0%, transparent 60%), + linear-gradient(160deg, var(--felt-dark) 0%, var(--felt) 40%, var(--felt-dark) 100%); + background-attachment: fixed; +} + +.app { + max-width: 720px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.header { + text-align: center; + margin-bottom: 1.75rem; +} + +.header-top { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.header h1 { + margin: 0; + font-family: "DM Serif Display", Georgia, serif; + font-size: clamp(1.75rem, 5vw, 2.25rem); + font-weight: 400; + letter-spacing: 0.02em; + color: var(--gold-light); +} + +.subtitle { + margin: 0.35rem 0 0; + font-size: 0.9rem; + color: var(--cream-muted); +} + +.settings-panel { + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(212, 168, 83, 0.25); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.settings-panel h2 { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: 600; + color: var(--gold); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.field select { + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(0, 0, 0, 0.3); + color: var(--cream); + font: inherit; +} + +.field.checkbox { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.scoreboard { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 0.75rem; + align-items: stretch; + margin-bottom: 1.5rem; +} + +@media (max-width: 540px) { + .scoreboard { + grid-template-columns: 1fr; + gap: 1rem; + } + + .vs-divider { + display: none; + } +} + +.team-card { + background: var(--card-bg); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow); + color: #1a1a1a; + border-top: 4px solid transparent; + transition: transform 0.15s ease; +} + +.team-card.winner { + animation: pulse 0.6s ease; +} + +.team-us { + border-top-color: var(--team-us); +} + +.team-them { + border-top-color: var(--team-them); +} + +.team-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.team-name { + flex: 1; + border: none; + background: transparent; + font-family: "DM Serif Display", Georgia, serif; + font-size: 1.35rem; + color: #1a1a1a; + padding: 0; + min-width: 0; +} + +.team-name:focus { + outline: 2px solid var(--gold); + outline-offset: 2px; + border-radius: 4px; +} + +.team-badge { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #888; + white-space: nowrap; +} + +.score-display { + font-family: "DM Serif Display", Georgia, serif; + font-size: clamp(3rem, 12vw, 4.5rem); + line-height: 1; + text-align: center; + margin: 0.5rem 0 1rem; + color: #111; +} + +.team-us .score-display { + color: var(--team-us); +} + +.team-them .score-display { + color: var(--team-them); +} + +.score-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.vs-divider { + display: flex; + align-items: center; + justify-content: center; +} + +.vs-divider span { + font-family: "DM Serif Display", Georgia, serif; + font-size: 1.1rem; + color: var(--gold); + opacity: 0.7; +} + +.euchre-panel { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.euchre-panel h2 { + margin: 0 0 0.35rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--gold-light); +} + +.hint { + margin: 0 0 1rem; + font-size: 0.8rem; + color: var(--cream-muted); + line-height: 1.4; +} + +.euchre-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +@media (max-width: 420px) { + .euchre-actions { + grid-template-columns: 1fr; + } +} + +.controls { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.controls .btn { + flex: 1; +} + +.history { + background: rgba(0, 0, 0, 0.2); + border-radius: var(--radius); + padding: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.history-header h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--gold-light); +} + +.hand-count { + font-size: 0.75rem; + color: var(--cream-muted); +} + +.history-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 220px; + overflow-y: auto; +} + +.history-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + margin-bottom: 0.35rem; + background: rgba(255, 255, 255, 0.05); +} + +.history-list li:last-child { + margin-bottom: 0; +} + +.history-empty { + justify-content: center !important; + color: var(--cream-muted); + font-style: italic; +} + +.history-points { + font-weight: 700; + color: var(--gold); +} + +.btn { + font: inherit; + cursor: pointer; + border: none; + border-radius: var(--radius-sm); + padding: 0.65rem 1rem; + font-weight: 600; + font-size: 0.85rem; + transition: background 0.15s, transform 0.1s, opacity 0.15s; +} + +.btn:active:not(:disabled) { + transform: scale(0.97); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-primary { + background: var(--gold); + color: var(--felt-dark); +} + +.btn-primary:hover:not(:disabled) { + background: var(--gold-light); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.12); + color: var(--cream); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.18); +} + +.btn-danger { + background: rgba(139, 58, 58, 0.6); + color: var(--cream); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.btn-danger:hover:not(:disabled) { + background: rgba(139, 58, 58, 0.85); +} + +.btn-ghost { + background: transparent; + color: var(--cream-muted); + padding: 0.4rem 0.6rem; + font-size: 1.1rem; +} + +.btn-ghost:hover { + color: var(--gold); +} + +.btn-score { + background: rgba(26, 77, 46, 0.12); + color: var(--team-us); + border: 1px solid rgba(46, 125, 90, 0.3); +} + +.team-them .btn-score { + color: var(--team-them); + background: rgba(139, 58, 58, 0.1); + border-color: rgba(139, 58, 58, 0.3); +} + +.btn-score:hover:not(:disabled) { + filter: brightness(1.05); +} + +.btn-euchre { + background: rgba(212, 168, 83, 0.15); + color: var(--gold-light); + border: 1px solid rgba(212, 168, 83, 0.3); + font-size: 0.8rem; + padding: 0.55rem 0.75rem; +} + +.btn-euchre:hover:not(:disabled) { + background: rgba(212, 168, 83, 0.25); +} + +.lone-btn.hidden { + display: none; +} + +.winner-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 100; + backdrop-filter: blur(4px); +} + +.winner-overlay[hidden] { + display: none !important; +} + +.winner-modal { + background: var(--card-bg); + color: #1a1a1a; + border-radius: var(--radius); + padding: 2rem; + text-align: center; + max-width: 360px; + width: 100%; + box-shadow: var(--shadow); + border-top: 4px solid var(--gold); +} + +.winner-label { + margin: 0; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #888; +} + +.winner-modal h2 { + margin: 0.5rem 0; + font-family: "DM Serif Display", Georgia, serif; + font-size: 2rem; + color: var(--team-us); +} + +.winner-score { + margin: 0 0 1.5rem; + color: #666; + font-size: 0.95rem; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.02); } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9f0ea54 --- /dev/null +++ b/public/index.html @@ -0,0 +1,108 @@ + + + + + + Euchre Scoreboard + + + + + + +
+
+
+

Euchre Scoreboard

+ +
+

Track hands, march, euchre, and lone calls

+
+ + + +
+
+
+ + Team 1 +
+
0
+
+ + + +
+
+ +
+ VS +
+ +
+
+ + Team 2 +
+
0
+
+ + + +
+
+
+ +
+

Defensive Scoring

+

When the calling team fails (0–2 tricks), the defenders score 2. Lone stopped awards 4.

+
+ + + + +
+
+ +
+ + +
+ +
+
+

Hand History

+ 0 hands +
+
    +
  • No hands played yet
  • +
+
+ + +
+ + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..9587e50 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,237 @@ +const STORAGE_KEY = "euchre-scoreboard"; + +const defaultState = () => ({ + teamUsName: "Us", + teamThemName: "Them", + teamUsScore: 0, + teamThemScore: 0, + targetScore: 10, + loneEnabled: true, + history: [], + gameOver: false, +}); + +let state = loadState(); + +const els = { + teamUsName: document.getElementById("teamUsName"), + teamThemName: document.getElementById("teamThemName"), + teamUsScore: document.getElementById("teamUsScore"), + teamThemScore: document.getElementById("teamThemScore"), + targetScore: document.getElementById("targetScore"), + loneEnabled: document.getElementById("loneEnabled"), + historyList: document.getElementById("historyList"), + handCount: document.getElementById("handCount"), + undoBtn: document.getElementById("undoBtn"), + resetBtn: document.getElementById("resetBtn"), + settingsBtn: document.getElementById("settingsBtn"), + settingsPanel: document.getElementById("settingsPanel"), + closeSettings: document.getElementById("closeSettings"), + winnerOverlay: document.getElementById("winnerOverlay"), + winnerText: document.getElementById("winnerText"), + winnerScore: document.getElementById("winnerScore"), + newGameBtn: document.getElementById("newGameBtn"), + teamUsCard: document.getElementById("teamUsCard"), + teamThemCard: document.getElementById("teamThemCard"), +}; + +function loadState() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + return { ...defaultState(), ...JSON.parse(saved) }; + } + } catch { + /* ignore corrupt storage */ + } + return defaultState(); +} + +function saveState() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +function teamLabel(team) { + return team === "us" ? state.teamUsName : state.teamThemName; +} + +function addScore(team, points, label) { + if (state.gameOver) return; + + const entry = { + team, + points, + label, + teamUsScore: state.teamUsScore, + teamThemScore: state.teamThemScore, + timestamp: Date.now(), + }; + + if (team === "us") { + state.teamUsScore += points; + } else { + state.teamThemScore += points; + } + + state.history.push(entry); + checkWinner(); + saveState(); + render(); +} + +function undoLast() { + if (!state.history.length) return; + + const last = state.history.pop(); + state.teamUsScore = last.teamUsScore; + state.teamThemScore = last.teamThemScore; + state.gameOver = false; + saveState(); + render(); +} + +function resetGame(skipConfirm = false) { + if ( + !skipConfirm && + state.history.length && + !confirm("Start a new game? Current scores will be lost.") + ) { + return; + } + state = defaultState(); + state.teamUsName = els.teamUsName.value.trim() || "Us"; + state.teamThemName = els.teamThemName.value.trim() || "Them"; + state.targetScore = Number(els.targetScore.value); + state.loneEnabled = els.loneEnabled.checked; + saveState(); + render(); +} + +function checkWinner() { + const target = state.targetScore; + state.gameOver = state.teamUsScore >= target || state.teamThemScore >= target; +} + +function renderHistory() { + const count = state.history.length; + els.handCount.textContent = `${count} hand${count === 1 ? "" : "s"}`; + + if (!count) { + els.historyList.innerHTML = '
  • No hands played yet
  • '; + return; + } + + els.historyList.innerHTML = state.history + .slice() + .reverse() + .map((entry, i) => { + const handNum = count - i; + const name = teamLabel(entry.team); + return `
  • + #${handNum} — ${name}: ${entry.label} + +${entry.points} +
  • `; + }) + .join(""); +} + +function renderWinner() { + if (!state.gameOver) { + els.winnerOverlay.hidden = true; + els.teamUsCard.classList.remove("winner"); + els.teamThemCard.classList.remove("winner"); + return; + } + + const usWins = state.teamUsScore >= state.targetScore; + const winner = usWins ? state.teamUsName : state.teamThemName; + const winnerScore = usWins ? state.teamUsScore : state.teamThemScore; + const loserScore = usWins ? state.teamThemScore : state.teamUsScore; + + els.winnerText.textContent = `${winner} wins!`; + els.winnerScore.textContent = `${winnerScore} – ${loserScore}`; + els.winnerOverlay.hidden = false; + + if (usWins) { + els.teamUsCard.classList.add("winner"); + els.teamThemCard.classList.remove("winner"); + } else { + els.teamThemCard.classList.add("winner"); + els.teamUsCard.classList.remove("winner"); + } +} + +function updateLoneButtons() { + document.querySelectorAll(".lone-btn").forEach((btn) => { + btn.classList.toggle("hidden", !state.loneEnabled); + }); +} + +function render() { + els.teamUsName.value = state.teamUsName; + els.teamThemName.value = state.teamThemName; + els.teamUsScore.textContent = state.teamUsScore; + els.teamThemScore.textContent = state.teamThemScore; + els.targetScore.value = String(state.targetScore); + els.loneEnabled.checked = state.loneEnabled; + + els.undoBtn.disabled = !state.history.length; + updateLoneButtons(); + renderHistory(); + renderWinner(); + + document.querySelectorAll(".btn-score, .btn-euchre").forEach((btn) => { + btn.disabled = state.gameOver; + }); +} + +document.querySelectorAll(".btn-score, .btn-euchre").forEach((btn) => { + btn.addEventListener("click", () => { + addScore(btn.dataset.team, Number(btn.dataset.points), btn.dataset.label); + }); +}); + +els.undoBtn.addEventListener("click", undoLast); +els.resetBtn.addEventListener("click", resetGame); +els.newGameBtn.addEventListener("click", () => resetGame(true)); + +els.settingsBtn.addEventListener("click", () => { + els.settingsPanel.hidden = !els.settingsPanel.hidden; +}); + +els.closeSettings.addEventListener("click", () => { + state.targetScore = Number(els.targetScore.value); + state.loneEnabled = els.loneEnabled.checked; + saveState(); + render(); + els.settingsPanel.hidden = true; +}); + +els.teamUsName.addEventListener("change", () => { + state.teamUsName = els.teamUsName.value.trim() || "Us"; + saveState(); + renderHistory(); +}); + +els.teamThemName.addEventListener("change", () => { + state.teamThemName = els.teamThemName.value.trim() || "Them"; + saveState(); + renderHistory(); +}); + +els.targetScore.addEventListener("change", () => { + state.targetScore = Number(els.targetScore.value); + checkWinner(); + saveState(); + render(); +}); + +els.loneEnabled.addEventListener("change", () => { + state.loneEnabled = els.loneEnabled.checked; + saveState(); + render(); +}); + +checkWinner(); +saveState(); +render(); diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..6370c74 --- /dev/null +++ b/serve.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Local dev server for Euchre Scoreboard.""" + +import argparse +import http.server +import os +import socketserver +import webbrowser + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public") +DEFAULT_PORT = 20010 + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=ROOT, **kwargs) + + def end_headers(self): + self.send_header("Cache-Control", "no-cache") + super().end_headers() + + +def main(): + parser = argparse.ArgumentParser(description="Run Euchre Scoreboard locally") + parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--no-open", action="store_true", help="Do not open a browser tab") + args = parser.parse_args() + + url = f"http://localhost:{args.port}" + + with socketserver.TCPServer(("", args.port), Handler) as httpd: + print(f"Serving Euchre Scoreboard at {url}") + print("Press Ctrl+C to stop") + if not args.no_open: + webbrowser.open(url) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + + +if __name__ == "__main__": + main()