diff --git a/README.md b/README.md index 044b23e..f4f7f12 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,8 @@ Before the session starts, please clone this repository and run `npm install` an 1) Install JS dependencies with `npm install`. 2) Install Python dependencies with `uv sync`. If `uv` is unavailable, first run `pip install uv`. 3) Run `npm run start`. You should be able to view the app at [http://localhost:3000/](http://localhost:3000/). It will hot reload as you make changes. + +**A note on the API port** +The API will attempt to run on port 5000. +If 5000 is already in use, the next available port is used automatically (logged to the terminal on startup). +You can pin the API_PORT if needed (rare): `API_PORT=5010 npm start`. diff --git a/api/app.py b/api/app.py index 29ab50f..eb896a6 100644 --- a/api/app.py +++ b/api/app.py @@ -1,11 +1,23 @@ from typing import Callable from flask import Flask, jsonify, request from flask_cors import CORS -from pagination import get_page, get_page_filtered from models import Project import json +import os +import traceback from pathlib import Path +# Wrap candidate-edited module imports so a syntax error doesn't kill the +# Werkzeug reloader (its parent process exits when the child exits with a +# non-restart code, and there is nothing to bring it back). +_pagination_error: str | None = None +try: + from pagination import get_page, get_page_filtered +except Exception: + _pagination_error = traceback.format_exc() + get_page = None # type: ignore[assignment] + get_page_filtered = None # type: ignore[assignment] + app = Flask(__name__) CORS(app) @@ -31,6 +43,9 @@ def get_users(): @app.route("/api/projects", methods=["GET"]) def get_projects(): + if get_page is None: + return jsonify({"error": f"pagination module failed to load:\n{_pagination_error}"}), 500 + projects = load_json("projects.json") # Handle pagination using startAfterId @@ -61,4 +76,9 @@ def get_projects(): if __name__ == "__main__": - app.run(port=5000, debug=True) + port = int(os.environ.get("API_PORT") or 5000) + # Watch all .py files in the api/ directory so the reloader picks up fixes + # to files that failed to import (and therefore aren't in sys.modules). + api_dir = Path(__file__).parent + extra = [str(p) for p in api_dir.glob("*.py")] + app.run(port=port, debug=True, extra_files=extra) diff --git a/package-lock.json b/package-lock.json index aeec7f0..876be08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "@types/lodash": "^4.14.195", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", - "concurrently": "^8.2.2", "react-scripts": "^5.0.1", "typescript": "^4.5.4" } @@ -5569,20 +5568,6 @@ "node": ">=0.10.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5801,33 +5786,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/concurrently": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", - "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.2", - "date-fns": "^2.30.0", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "spawn-command": "0.0.2", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": "^14.13.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -6411,22 +6369,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, "node_modules/debounce-promise": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz", @@ -14436,15 +14378,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -15088,12 +15021,6 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, - "node_modules/spawn-command": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", - "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", - "dev": true - }, "node_modules/spdy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", @@ -16144,15 +16071,6 @@ "node": ">=8" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -17497,33 +17415,6 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8db4ce2..e1e2b6b 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,11 @@ "@types/lodash": "^4.14.195", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", - "concurrently": "^8.2.2", "react-scripts": "^5.0.1", "typescript": "^4.5.4" }, "scripts": { - "start": "concurrently \"npm run start-frontend\" \"npm run start-api\"", + "start": "bash scripts/start.sh", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject", diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..8ef07a8 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,31 @@ +#!/bin/bash +trap 'kill 0' EXIT + +# Find a free port for the API, starting from 5000. +# Override: API_PORT=5010 npm start +if [ -z "$API_PORT" ]; then + API_PORT=$(python3 -c " +import socket, sys +for port in range(5000, 5020): + s = socket.socket() + try: + s.bind(('127.0.0.1', port)) + print(port) + s.close() + sys.exit(0) + except OSError: + s.close() +print('ERROR: no free port in 5000-5019', file=sys.stderr) +sys.exit(1) +") || exit 1 +fi + +export API_PORT +export REACT_APP_API_PORT=$API_PORT + +echo "" +echo " API port: $API_PORT (override with API_PORT= npm start)" +echo "" + +npm run start-api & +npm run start-frontend diff --git a/src/Projects.tsx b/src/Projects.tsx index 9d6fdc6..223c153 100644 --- a/src/Projects.tsx +++ b/src/Projects.tsx @@ -13,17 +13,19 @@ interface ProjectsProps { export default function Projects({ selectedUser, nameById }: ProjectsProps) { const [projects, setProjects] = React.useState(() => null); const [hasMoreResults, setHasMoreResults] = React.useState(false); + const [error, setError] = React.useState(null); const fetchProjects = React.useCallback((startAfter?: ProjectData, overwrite = false) => { SERVER.getProjects({ pageSize: 5, startAfter, userId: selectedUser?.toString() }).then((page) => { + setError(null); if (overwrite) { setProjects(_ => ([...(page.projects ?? [])])); } else { setProjects(projects => [...(projects ?? []), ...page.projects]); } setHasMoreResults(page.hasMoreResults); - }).catch(() => { - alert("Something went wrong..."); + }).catch((err) => { + setError(err.message); }); }, [selectedUser]); @@ -36,6 +38,15 @@ export default function Projects({ selectedUser, nameById }: ProjectsProps) { fetchProjects(undefined, true); }, [selectedUser, fetchProjects]); + if (error) { + return ( +
+

Server error:

+
{error}
+
+ ); + } + return (
{projects?.length === 0 && (
No projects found.
)} diff --git a/src/api/apiImpl.ts b/src/api/apiImpl.ts index 961ef13..c40e2d6 100644 --- a/src/api/apiImpl.ts +++ b/src/api/apiImpl.ts @@ -5,9 +5,11 @@ export interface ProjectsResponse { hasMoreResults: boolean; } +const API_BASE = `http://127.0.0.1:${process.env.REACT_APP_API_PORT || "5000"}`; + class DefaultServer { async getUsers(): Promise { - const response = await fetch('http://127.0.0.1:5000/api/users'); + const response = await fetch(`${API_BASE}/api/users`); return response.json(); } @@ -16,7 +18,7 @@ class DefaultServer { startAfter?: ProjectData; pageSize?: number; }): Promise { - const url = new URL('http://127.0.0.1:5000/api/projects'); + const url = new URL(`${API_BASE}/api/projects`); if (options?.userId != null) { url.searchParams.append('userId', options.userId); @@ -29,6 +31,10 @@ class DefaultServer { } const response = await fetch(url); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.error ?? `API error ${response.status}`); + } return response.json(); } } diff --git a/src/styles.css b/src/styles.css index 0e2a9ca..1a815b8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -44,4 +44,35 @@ body { flex-direction: row; gap: 10px; justify-content: space-between; +} + +.error-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgb(35, 33, 32); + color: rgb(232, 232, 232); + padding: 2rem; + overflow: auto; + z-index: 9999; + font-family: sans-serif; +} + +.error-overlay h1 { + color: rgb(252, 98, 93); + font-size: 1.4rem; + margin: 0 0 1.5rem 0; +} + +.error-overlay pre { + color: rgb(252, 98, 93); + background: rgba(206, 17, 38, 0.1); + padding: 1rem; + border-radius: 4px; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; } \ No newline at end of file