feat(frontend): integrate tailwind pipeline and update templates

This commit is contained in:
Alfredo Di Stasio
2026-03-10 12:49:25 +01:00
parent 4d49d30495
commit 3d795991fe
27 changed files with 1211 additions and 435 deletions

View File

@ -26,6 +26,7 @@ CELERY_RESULT_BACKEND=redis://redis:6379/0
# Runtime behavior # Runtime behavior
AUTO_APPLY_MIGRATIONS=1 AUTO_APPLY_MIGRATIONS=1
AUTO_COLLECTSTATIC=1 AUTO_COLLECTSTATIC=1
AUTO_BUILD_TAILWIND=1
GUNICORN_WORKERS=3 GUNICORN_WORKERS=3
# Providers / ingestion # Providers / ingestion

3
.gitignore vendored
View File

@ -26,3 +26,6 @@ venv/
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
# Frontend
node_modules/

View File

@ -29,12 +29,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 postgresql-client curl \ && apt-get install -y --no-install-recommends libpq5 postgresql-client curl nodejs npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY . /app COPY . /app
RUN if [ -f package.json ]; then npm install --no-audit --no-fund; fi
RUN if [ -f package.json ]; then npm run build; fi
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
RUN mkdir -p /app/staticfiles /app/media /app/runtime RUN mkdir -p /app/staticfiles /app/media /app/runtime

View File

@ -9,6 +9,7 @@ A minimal read-only API is included as a secondary integration surface.
- Python 3.12+ - Python 3.12+
- Django - Django
- Django Templates + HTMX - Django Templates + HTMX
- Tailwind CSS (CLI build pipeline)
- PostgreSQL - PostgreSQL
- Redis - Redis
- Celery + Celery Beat - Celery + Celery Beat
@ -45,6 +46,8 @@ A minimal read-only API is included as a secondary integration surface.
├── docs/ ├── docs/
├── nginx/ ├── nginx/
├── requirements/ ├── requirements/
├── package.json
├── tailwind.config.js
├── static/ ├── static/
├── templates/ ├── templates/
├── tests/ ├── tests/
@ -91,8 +94,10 @@ docker compose exec web python manage.py createsuperuser
## Setup and Run Notes ## Setup and Run Notes
- `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness. - `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness.
- `web` service also builds Tailwind CSS before `collectstatic` when `AUTO_BUILD_TAILWIND=1`.
- `celery_worker` executes background sync work. - `celery_worker` executes background sync work.
- `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider). - `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider).
- `tailwind` service runs watch mode for development (`npm run dev`).
- nginx proxies web traffic and serves static/media volume mounts. - nginx proxies web traffic and serves static/media volume mounts.
## Docker Volumes and Persistence ## Docker Volumes and Persistence
@ -104,6 +109,7 @@ docker compose exec web python manage.py createsuperuser
- `media_data`: user/provider media artifacts - `media_data`: user/provider media artifacts
- `runtime_data`: app runtime files (e.g., celery beat schedule) - `runtime_data`: app runtime files (e.g., celery beat schedule)
- `redis_data`: Redis persistence (`/data` for RDB/AOF files) - `redis_data`: Redis persistence (`/data` for RDB/AOF files)
- `node_modules_data`: Node modules cache for Tailwind builds in containers
This keeps persistent state outside container lifecycles. This keeps persistent state outside container lifecycles.
@ -135,6 +141,22 @@ Run a focused module:
docker compose run --rm web sh -lc 'pip install -r requirements/dev.txt && pytest -q tests/test_api.py' docker compose run --rm web sh -lc 'pip install -r requirements/dev.txt && pytest -q tests/test_api.py'
``` ```
## Frontend Assets (Tailwind)
Build Tailwind once:
```bash
docker compose run --rm web sh -lc 'npm install --no-audit --no-fund && npm run build'
```
Run Tailwind in watch mode during development:
```bash
docker compose up tailwind
```
Source CSS lives in `static/src/tailwind.css` and compiles to `static/css/main.css`.
## Superuser and Auth ## Superuser and Auth
Create superuser: Create superuser:

View File

@ -31,6 +31,7 @@ services:
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3}
volumes: volumes:
- .:/app - .:/app
- node_modules_data:/app/node_modules
- static_data:/app/staticfiles - static_data:/app/staticfiles
- media_data:/app/media - media_data:/app/media
- runtime_data:/app/runtime - runtime_data:/app/runtime
@ -43,6 +44,18 @@ services:
retries: 8 retries: 8
restart: unless-stopped restart: unless-stopped
tailwind:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
command: npm run dev
volumes:
- .:/app
- node_modules_data:/app/node_modules
restart: unless-stopped
celery_worker: celery_worker:
build: build:
context: . context: .
@ -118,3 +131,4 @@ volumes:
media_data: media_data:
runtime_data: runtime_data:
redis_data: redis_data:
node_modules_data:

View File

@ -14,6 +14,14 @@ if [ "${AUTO_APPLY_MIGRATIONS:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
fi fi
if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
if [ "${AUTO_BUILD_TAILWIND:-1}" = "1" ] && [ -f /app/package.json ]; then
echo "Building Tailwind assets..."
if [ ! -d /app/node_modules ]; then
npm install --no-audit --no-fund
fi
npm run build
fi
echo "Collecting static files..." echo "Collecting static files..."
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
fi fi

858
package-lock.json generated Normal file
View File

@ -0,0 +1,858 @@
{
"name": "hoopscout-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoopscout-frontend",
"version": "1.0.0",
"devDependencies": {
"tailwindcss": "^3.4.17"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "5.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/commander": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mz": {
"version": "2.7.0",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-import": {
"version": "15.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-js": {
"version": "4.1.0",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
},
"engines": {
"node": "^12 || ^14 || >= 16"
},
"peerDependencies": {
"postcss": "^8.4.21"
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.1.1"
},
"engines": {
"node": ">=12.0"
},
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
}
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "hoopscout-frontend",
"version": "1.0.0",
"private": true,
"description": "Tailwind pipeline for HoopScout Django templates",
"scripts": {
"build": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify",
"dev": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.17"
}
}

File diff suppressed because one or more lines are too long

94
static/src/tailwind.css Normal file
View File

@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-100 text-slate-900 antialiased;
}
h1 {
@apply text-2xl font-semibold tracking-tight text-slate-900;
}
h2 {
@apply text-xl font-semibold tracking-tight text-slate-900;
}
h3 {
@apply text-lg font-semibold text-slate-900;
}
a {
@apply text-brand-700 hover:text-brand-600;
}
label {
@apply mb-1 block text-sm font-medium text-slate-700;
}
input,
select,
textarea {
@apply w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none ring-brand-600 transition focus:border-brand-600 focus:ring-2;
}
input[type='checkbox'] {
@apply h-4 w-4 rounded border-slate-300 p-0 text-brand-700;
}
summary {
@apply cursor-pointer font-medium text-slate-800;
}
}
@layer components {
.page-container {
@apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
}
.panel {
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-soft;
}
.btn {
@apply inline-flex items-center justify-center rounded-md border border-brand-700 bg-brand-700 px-3 py-2 text-sm font-medium text-white transition hover:bg-brand-600;
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50;
}
.table-wrap {
@apply overflow-x-auto rounded-lg border border-slate-200;
}
.data-table {
@apply min-w-full divide-y divide-slate-200 text-sm;
}
.data-table thead {
@apply bg-slate-50;
}
.data-table th {
@apply px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-slate-600;
}
.data-table td {
@apply whitespace-nowrap px-3 py-2 text-slate-700;
}
.empty-state {
@apply rounded-lg border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-600;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: block;
}
}

25
tailwind.config.js Normal file
View File

@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./apps/**/templates/**/*.html',
'./apps/**/*.py'
],
theme: {
extend: {
colors: {
brand: {
50: '#eef6ff',
100: '#d8e8ff',
600: '#1d63dd',
700: '#184fb3',
900: '#142746'
}
},
boxShadow: {
soft: '0 8px 24px -14px rgba(16, 35, 64, 0.35)'
}
}
},
plugins: []
};

View File

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -8,31 +8,34 @@
<link rel="stylesheet" href="{% static 'css/main.css' %}"> <link rel="stylesheet" href="{% static 'css/main.css' %}">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script> <script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head> </head>
<body> <body class="min-h-full bg-slate-100 text-slate-900">
<header class="site-header"> <header class="border-b border-slate-200 bg-white">
<div class="container row-between"> <div class="page-container flex flex-wrap items-center justify-between gap-4 py-3">
<a class="brand" href="{% url 'core:home' %}">HoopScout</a> <a class="text-xl font-bold tracking-tight text-slate-900 no-underline" href="{% url 'core:home' %}">HoopScout</a>
<nav class="row-gap"> <nav class="flex flex-wrap items-center gap-2 text-sm">
<a href="{% url 'players:index' %}">Players</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'players:index' %}">Players</a>
<a href="{% url 'competitions:index' %}">Competitions</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'competitions:index' %}">Competitions</a>
<a href="{% url 'teams:index' %}">Teams</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'teams:index' %}">Teams</a>
<a href="{% url 'scouting:index' %}">Scouting</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'scouting:index' %}">Scouting</a>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<a href="{% url 'core:dashboard' %}">Dashboard</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'core:dashboard' %}">Dashboard</a>
<form method="post" action="{% url 'users:logout' %}"> <form method="post" action="{% url 'users:logout' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="link-button">Logout</button> <button type="submit" class="btn-secondary px-2 py-1 text-xs">Logout</button>
</form> </form>
{% else %} {% else %}
<a href="{% url 'users:login' %}">Login</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'users:login' %}">Login</a>
<a href="{% url 'users:signup' %}">Signup</a> <a class="btn px-2 py-1 text-xs" href="{% url 'users:signup' %}">Signup</a>
{% endif %} {% endif %}
</nav> </nav>
</div> </div>
</header> </header>
<main class="container"> <main class="page-container py-6">
{% include 'partials/messages.html' %} {% include 'partials/messages.html' %}
<div id="htmx-loading" class="htmx-indicator mb-4 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600" aria-live="polite">
Loading...
</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>

View File

@ -1,7 +1,13 @@
{% if messages %} {% if messages %}
<section class="messages"> <section class="mb-4 space-y-2" aria-live="polite">
{% for message in messages %} {% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div> {% if message.tags == "success" %}
<div role="status" class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ message }}</div>
{% elif message.tags == "error" %}
<div role="alert" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">{{ message }}</div>
{% else %}
<div role="status" class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700">{{ message }}</div>
{% endif %}
{% endfor %} {% endfor %}
</section> </section>
{% endif %} {% endif %}

View File

@ -4,50 +4,51 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1>{{ player.full_name }}</h1> <h1>{{ player.full_name }}</h1>
<p class="muted-text"> <p class="mt-1 text-sm text-slate-600">{{ player.nominal_position.name|default:"No nominal position" }} · {{ player.inferred_role.name|default:"No inferred role" }}</p>
{{ player.nominal_position.name|default:"No nominal position" }}
· {{ player.inferred_role.name|default:"No inferred role" }}
</p>
</div> </div>
<div class="row-gap"> <div class="flex flex-wrap items-center gap-2">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{% include "scouting/partials/favorite_button.html" with player=player is_favorite=is_favorite next_url=request.get_full_path %} {% include "scouting/partials/favorite_button.html" with player=player is_favorite=is_favorite next_url=request.get_full_path %}
{% endif %} {% endif %}
<a class="button ghost" href="{% url 'players:index' %}">Back to search</a> <a class="btn-secondary" href="{% url 'players:index' %}">Back to search</a>
</div> </div>
</div> </div>
<div class="detail-grid mt-16"> <div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Summary</h2> <h2 class="text-base">Summary</h2>
<p><strong>Nationality:</strong> {{ player.nationality.name|default:"-" }}</p> <dl class="mt-2 space-y-1 text-sm">
<p><strong>Origin competition:</strong> {{ player.origin_competition.name|default:"-" }}</p> <div><dt class="inline font-semibold">Nationality:</dt> <dd class="inline">{{ player.nationality.name|default:"-" }}</dd></div>
<p><strong>Origin team:</strong> {{ player.origin_team.name|default:"-" }}</p> <div><dt class="inline font-semibold">Origin competition:</dt> <dd class="inline">{{ player.origin_competition.name|default:"-" }}</dd></div>
<p><strong>Birth date:</strong> {{ player.birth_date|date:"Y-m-d"|default:"-" }}</p> <div><dt class="inline font-semibold">Origin team:</dt> <dd class="inline">{{ player.origin_team.name|default:"-" }}</dd></div>
<p><strong>Age:</strong> {{ age|default:"-" }}</p> <div><dt class="inline font-semibold">Birth date:</dt> <dd class="inline">{{ player.birth_date|date:"Y-m-d"|default:"-" }}</dd></div>
<p><strong>Height:</strong> {{ player.height_cm|default:"-" }} cm</p> <div><dt class="inline font-semibold">Age:</dt> <dd class="inline">{{ age|default:"-" }}</dd></div>
<p><strong>Weight:</strong> {{ player.weight_kg|default:"-" }} kg</p> <div><dt class="inline font-semibold">Height:</dt> <dd class="inline">{{ player.height_cm|default:"-" }} cm</dd></div>
<p><strong>Dominant hand:</strong> {{ player.get_dominant_hand_display|default:"-" }}</p> <div><dt class="inline font-semibold">Weight:</dt> <dd class="inline">{{ player.weight_kg|default:"-" }} kg</dd></div>
<div><dt class="inline font-semibold">Dominant hand:</dt> <dd class="inline">{{ player.get_dominant_hand_display|default:"-" }}</dd></div>
</dl>
</div> </div>
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Current Assignment</h2> <h2 class="text-base">Current Assignment</h2>
{% if current_assignment %} {% if current_assignment %}
<p><strong>Team:</strong> {{ current_assignment.team.name|default:"-" }}</p> <dl class="mt-2 space-y-1 text-sm">
<p><strong>Competition:</strong> {{ current_assignment.competition.name|default:"-" }}</p> <div><dt class="inline font-semibold">Team:</dt> <dd class="inline">{{ current_assignment.team.name|default:"-" }}</dd></div>
<p><strong>Season:</strong> {{ current_assignment.season.label|default:"-" }}</p> <div><dt class="inline font-semibold">Competition:</dt> <dd class="inline">{{ current_assignment.competition.name|default:"-" }}</dd></div>
<p><strong>Games:</strong> {{ current_assignment.games_played }}</p> <div><dt class="inline font-semibold">Season:</dt> <dd class="inline">{{ current_assignment.season.label|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Games:</dt> <dd class="inline">{{ current_assignment.games_played }}</dd></div>
</dl>
{% else %} {% else %}
<p>No active assignment available.</p> <div class="empty-state mt-2">No active assignment available.</div>
{% endif %} {% endif %}
</div> </div>
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Aliases</h2> <h2 class="text-base">Aliases</h2>
<ul> <ul class="mt-2 list-inside list-disc text-sm text-slate-700">
{% for alias in player.aliases.all %} {% for alias in player.aliases.all %}
<li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li> <li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li>
{% empty %} {% empty %}
@ -58,50 +59,33 @@
</div> </div>
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Team History</h2> <h2>Team History</h2>
{% if season_rows %} {% if season_rows %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead><tr><th>Season</th><th>Team</th><th>Competition</th></tr></thead>
<tr> <tbody class="divide-y divide-slate-100 bg-white">
<th>Season</th>
<th>Team</th>
<th>Competition</th>
</tr>
</thead>
<tbody>
{% for row in season_rows %} {% for row in season_rows %}
<tr> <tr><td>{{ row.season.label|default:"-" }}</td><td>{{ row.team.name|default:"-" }}</td><td>{{ row.competition.name|default:"-" }}</td></tr>
<td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No team history available.</p> <div class="empty-state mt-3">No team history available.</div>
{% endif %} {% endif %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Career History</h2> <h2>Career History</h2>
{% if career_entries %} {% if career_entries %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr><th>Season</th><th>Team</th><th>Competition</th><th>Role</th><th>From</th><th>To</th></tr>
<th>Season</th>
<th>Team</th>
<th>Competition</th>
<th>Role</th>
<th>From</th>
<th>To</th>
</tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for entry in career_entries %} {% for entry in career_entries %}
<tr> <tr>
<td>{{ entry.season.label|default:"-" }}</td> <td>{{ entry.season.label|default:"-" }}</td>
@ -116,44 +100,28 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No career entries available.</p> <div class="empty-state mt-3">No career entries available.</div>
{% endif %} {% endif %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Season-by-Season Stats</h2> <h2>Season-by-Season Stats</h2>
{% if season_rows %} {% if season_rows %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Season</th> <th>Season</th><th>Team</th><th>Competition</th><th>Games</th><th>MPG</th><th>PPG</th><th>RPG</th><th>APG</th><th>SPG</th><th>BPG</th><th>TOPG</th><th>FG%</th><th>3P%</th><th>FT%</th><th>Impact</th>
<th>Team</th>
<th>Competition</th>
<th>Games</th>
<th>MPG</th>
<th>PPG</th>
<th>RPG</th>
<th>APG</th>
<th>SPG</th>
<th>BPG</th>
<th>TOPG</th>
<th>FG%</th>
<th>3P%</th>
<th>FT%</th>
<th>Impact</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for row in season_rows %} {% for row in season_rows %}
<tr> <tr>
<td>{{ row.season.label|default:"-" }}</td> <td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td> <td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td> <td>{{ row.competition.name|default:"-" }}</td>
<td>{{ row.games_played }}</td> <td>{{ row.games_played }}</td>
<td> <td>{% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %}</td>
{% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %}
</td>
<td>{% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}</td>
@ -170,7 +138,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No season stats available.</p> <div class="empty-state mt-3">No season stats available.</div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -5,18 +5,19 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<h1>Player Search</h1> <h1>Player Search</h1>
<p>Filter players by profile, context, and production metrics.</p> <p class="mt-1 text-sm text-slate-600">Filter players by profile, origin, context, and production metrics.</p>
<form <form
method="get" method="get"
class="stack search-form" class="mt-4 space-y-4"
hx-get="{% url 'players:index' %}" hx-get="{% url 'players:index' %}"
hx-target="#player-results" hx-target="#player-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-push-url="true" hx-push-url="true"
hx-indicator="#htmx-loading"
hx-trigger="submit, change delay:200ms from:select, keyup changed delay:400ms from:#id_q" hx-trigger="submit, change delay:200ms from:select, keyup changed delay:400ms from:#id_q"
> >
<div class="filter-grid filter-grid-4"> <div class="grid gap-3 md:grid-cols-4">
<div> <div>
<label for="id_q">Name</label> <label for="id_q">Name</label>
{{ search_form.q }} {{ search_form.q }}
@ -29,13 +30,13 @@
<label for="id_page_size">Page size</label> <label for="id_page_size">Page size</label>
{{ search_form.page_size }} {{ search_form.page_size }}
</div> </div>
<div class="filter-actions"> <div class="flex items-end gap-2">
<button type="submit" class="button">Apply</button> <button type="submit" class="btn">Apply</button>
<a class="button ghost" href="{% url 'players:index' %}">Reset</a> <a class="btn-secondary" href="{% url 'players:index' %}">Reset</a>
</div> </div>
</div> </div>
<div class="filter-grid filter-grid-3"> <div class="grid gap-3 md:grid-cols-3">
<div><label for="id_nominal_position">Nominal position</label>{{ search_form.nominal_position }}</div> <div><label for="id_nominal_position">Nominal position</label>{{ search_form.nominal_position }}</div>
<div><label for="id_inferred_role">Inferred role</label>{{ search_form.inferred_role }}</div> <div><label for="id_inferred_role">Inferred role</label>{{ search_form.inferred_role }}</div>
<div><label for="id_nationality">Nationality</label>{{ search_form.nationality }}</div> <div><label for="id_nationality">Nationality</label>{{ search_form.nationality }}</div>
@ -46,9 +47,9 @@
<div><label for="id_origin_team">Origin team</label>{{ search_form.origin_team }}</div> <div><label for="id_origin_team">Origin team</label>{{ search_form.origin_team }}</div>
</div> </div>
<details> <details class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<summary>Physical and age filters</summary> <summary>Physical and age filters</summary>
<div class="filter-grid filter-grid-4"> <div class="mt-3 grid gap-3 md:grid-cols-4">
<div><label for="id_age_min">Age min</label>{{ search_form.age_min }}</div> <div><label for="id_age_min">Age min</label>{{ search_form.age_min }}</div>
<div><label for="id_age_max">Age max</label>{{ search_form.age_max }}</div> <div><label for="id_age_max">Age max</label>{{ search_form.age_max }}</div>
<div><label for="id_height_min">Height min (cm)</label>{{ search_form.height_min }}</div> <div><label for="id_height_min">Height min (cm)</label>{{ search_form.height_min }}</div>
@ -58,34 +59,29 @@
</div> </div>
</details> </details>
<details> <details class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<summary>Statistical filters</summary> <summary>Statistical filters</summary>
<div class="filter-grid filter-grid-4"> <div class="mt-3 grid gap-3 md:grid-cols-4">
<div><label for="id_games_played_min">Games min</label>{{ search_form.games_played_min }}</div> <div><label for="id_games_played_min">Games min</label>{{ search_form.games_played_min }}</div>
<div><label for="id_games_played_max">Games max</label>{{ search_form.games_played_max }}</div> <div><label for="id_games_played_max">Games max</label>{{ search_form.games_played_max }}</div>
<div><label for="id_minutes_per_game_min">MPG min</label>{{ search_form.minutes_per_game_min }}</div> <div><label for="id_minutes_per_game_min">MPG min</label>{{ search_form.minutes_per_game_min }}</div>
<div><label for="id_minutes_per_game_max">MPG max</label>{{ search_form.minutes_per_game_max }}</div> <div><label for="id_minutes_per_game_max">MPG max</label>{{ search_form.minutes_per_game_max }}</div>
<div><label for="id_points_per_game_min">PPG min</label>{{ search_form.points_per_game_min }}</div> <div><label for="id_points_per_game_min">PPG min</label>{{ search_form.points_per_game_min }}</div>
<div><label for="id_points_per_game_max">PPG max</label>{{ search_form.points_per_game_max }}</div> <div><label for="id_points_per_game_max">PPG max</label>{{ search_form.points_per_game_max }}</div>
<div><label for="id_rebounds_per_game_min">RPG min</label>{{ search_form.rebounds_per_game_min }}</div> <div><label for="id_rebounds_per_game_min">RPG min</label>{{ search_form.rebounds_per_game_min }}</div>
<div><label for="id_rebounds_per_game_max">RPG max</label>{{ search_form.rebounds_per_game_max }}</div> <div><label for="id_rebounds_per_game_max">RPG max</label>{{ search_form.rebounds_per_game_max }}</div>
<div><label for="id_assists_per_game_min">APG min</label>{{ search_form.assists_per_game_min }}</div> <div><label for="id_assists_per_game_min">APG min</label>{{ search_form.assists_per_game_min }}</div>
<div><label for="id_assists_per_game_max">APG max</label>{{ search_form.assists_per_game_max }}</div> <div><label for="id_assists_per_game_max">APG max</label>{{ search_form.assists_per_game_max }}</div>
<div><label for="id_steals_per_game_min">SPG min</label>{{ search_form.steals_per_game_min }}</div> <div><label for="id_steals_per_game_min">SPG min</label>{{ search_form.steals_per_game_min }}</div>
<div><label for="id_steals_per_game_max">SPG max</label>{{ search_form.steals_per_game_max }}</div> <div><label for="id_steals_per_game_max">SPG max</label>{{ search_form.steals_per_game_max }}</div>
<div><label for="id_blocks_per_game_min">BPG min</label>{{ search_form.blocks_per_game_min }}</div> <div><label for="id_blocks_per_game_min">BPG min</label>{{ search_form.blocks_per_game_min }}</div>
<div><label for="id_blocks_per_game_max">BPG max</label>{{ search_form.blocks_per_game_max }}</div> <div><label for="id_blocks_per_game_max">BPG max</label>{{ search_form.blocks_per_game_max }}</div>
<div><label for="id_turnovers_per_game_min">TOPG min</label>{{ search_form.turnovers_per_game_min }}</div> <div><label for="id_turnovers_per_game_min">TOPG min</label>{{ search_form.turnovers_per_game_min }}</div>
<div><label for="id_turnovers_per_game_max">TOPG max</label>{{ search_form.turnovers_per_game_max }}</div> <div><label for="id_turnovers_per_game_max">TOPG max</label>{{ search_form.turnovers_per_game_max }}</div>
<div><label for="id_fg_pct_min">FG% min</label>{{ search_form.fg_pct_min }}</div> <div><label for="id_fg_pct_min">FG% min</label>{{ search_form.fg_pct_min }}</div>
<div><label for="id_fg_pct_max">FG% max</label>{{ search_form.fg_pct_max }}</div> <div><label for="id_fg_pct_max">FG% max</label>{{ search_form.fg_pct_max }}</div>
<div><label for="id_three_pct_min">3P% min</label>{{ search_form.three_pct_min }}</div> <div><label for="id_three_pct_min">3P% min</label>{{ search_form.three_pct_min }}</div>
<div><label for="id_three_pct_max">3P% max</label>{{ search_form.three_pct_max }}</div> <div><label for="id_three_pct_max">3P% max</label>{{ search_form.three_pct_max }}</div>
<div><label for="id_ft_pct_min">FT% min</label>{{ search_form.ft_pct_min }}</div> <div><label for="id_ft_pct_min">FT% min</label>{{ search_form.ft_pct_min }}</div>
<div><label for="id_ft_pct_max">FT% max</label>{{ search_form.ft_pct_max }}</div> <div><label for="id_ft_pct_max">FT% max</label>{{ search_form.ft_pct_max }}</div>
<div><label for="id_efficiency_metric_min">Impact min</label>{{ search_form.efficiency_metric_min }}</div> <div><label for="id_efficiency_metric_min">Impact min</label>{{ search_form.efficiency_metric_min }}</div>
@ -95,7 +91,7 @@
</form> </form>
</section> </section>
<section id="player-results" class="panel mt-16"> <section id="player-results" class="panel mt-4" aria-live="polite">
{% include "players/partials/results.html" %} {% include "players/partials/results.html" %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -1,8 +1,8 @@
{% load player_query %} {% load player_query %}
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h2>Results</h2> <h2>Results</h2>
<div class="muted-text"> <div class="text-sm text-slate-600">
{{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found {{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found
</div> </div>
</div> </div>
@ -12,8 +12,8 @@
{% endif %} {% endif %}
{% if players %} {% if players %}
<div class="table-wrap"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>Player</th>
@ -29,20 +29,15 @@
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %} {% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for player in players %} {% for player in players %}
<tr> <tr>
<td> <td><a class="font-medium" href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a></td>
<a href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a>
</td>
<td>{{ player.nationality.name|default:"-" }}</td> <td>{{ player.nationality.name|default:"-" }}</td>
<td> <td>{{ player.nominal_position.code|default:"-" }} / {{ player.inferred_role.name|default:"-" }}</td>
{{ player.nominal_position.code|default:"-" }}
/ {{ player.inferred_role.name|default:"-" }}
</td>
<td> <td>
{{ player.origin_competition.name|default:"-" }} {{ player.origin_competition.name|default:"-" }}
{% if player.origin_team %}<br><span class="muted-text">{{ player.origin_team.name }}</span>{% endif %} {% if player.origin_team %}<div class="text-xs text-slate-500">{{ player.origin_team.name }}</div>{% endif %}
</td> </td>
<td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td> <td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td>
<td>{{ player.games_played_value|floatformat:0 }}</td> <td>{{ player.games_played_value|floatformat:0 }}</td>
@ -65,37 +60,21 @@
</table> </table>
</div> </div>
<div class="pagination row-gap mt-16"> <div class="mt-4 flex items-center justify-between gap-3">
<div>
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
{% query_transform page=page_obj.previous_page_number as prev_query %} {% query_transform page=page_obj.previous_page_number as prev_query %}
<a <a class="btn-secondary" href="?{{ prev_query }}" hx-get="?{{ prev_query }}" hx-target="#player-results" hx-swap="innerHTML" hx-push-url="true" hx-indicator="#htmx-loading">Previous</a>
class="button ghost"
href="?{{ prev_query }}"
hx-get="?{{ prev_query }}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
>
Previous
</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
{% query_transform page=page_obj.next_page_number as next_query %}
<a
class="button ghost"
href="?{{ next_query }}"
hx-get="?{{ next_query }}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
>
Next
</a>
{% endif %} {% endif %}
</div> </div>
<span class="text-sm text-slate-600">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
<div>
{% if page_obj.has_next %}
{% query_transform page=page_obj.next_page_number as next_query %}
<a class="btn-secondary" href="?{{ next_query }}" hx-get="?{{ next_query }}" hx-target="#player-results" hx-swap="innerHTML" hx-push-url="true" hx-indicator="#htmx-loading">Next</a>
{% endif %}
</div>
</div>
{% else %} {% else %}
<p>No players matched the current filters.</p> <div class="empty-state mt-4">No players matched the current filters.</div>
{% endif %} {% endif %}

View File

@ -4,24 +4,24 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1>Scouting Workspace</h1> <h1>Scouting Workspace</h1>
<p class="muted-text">Manage saved searches and your player watchlist.</p> <p class="mt-1 text-sm text-slate-600">Manage saved searches and your player watchlist.</p>
</div> </div>
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<a class="button ghost" href="{% url 'scouting:saved_search_list' %}">All saved searches</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_list' %}">All saved searches</a>
<a class="button ghost" href="{% url 'scouting:watchlist' %}">Watchlist</a> <a class="btn-secondary" href="{% url 'scouting:watchlist' %}">Watchlist</a>
</div> </div>
</div> </div>
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Saved Searches</h2> <h2>Saved Searches</h2>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %} {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Watchlist</h2> <h2>Watchlist</h2>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %} {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section> </section>

View File

@ -5,12 +5,13 @@
hx-post="{% url 'scouting:favorite_toggle' player.id %}" hx-post="{% url 'scouting:favorite_toggle' player.id %}"
hx-target="#favorite-form-{{ player.id }}" hx-target="#favorite-form-{{ player.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="#htmx-loading"
> >
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ next_url }}"> <input type="hidden" name="next" value="{{ next_url }}">
{% if is_favorite %} {% if is_favorite %}
<button type="submit" class="button ghost">Remove favorite</button> <button type="submit" class="btn-secondary">Remove favorite</button>
{% else %} {% else %}
<button type="submit" class="button ghost">Add favorite</button> <button type="submit" class="btn-secondary">Add favorite</button>
{% endif %} {% endif %}
</form> </form>

View File

@ -1,3 +1,5 @@
<div class="message {% if ok %}success{% else %}error{% endif %}"> {% if ok %}
{{ message }} <div class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800" role="status">{{ message }}</div>
</div> {% else %}
<div class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800" role="alert">{{ message }}</div>
{% endif %}

View File

@ -1,18 +1,23 @@
<div class="panel mt-16"> <div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<h3>Save Current Search</h3> <h3>Save Current Search</h3>
<p class="muted-text">Store current filters and replay them later.</p> <p class="mt-1 text-sm text-slate-600">Store current filters and replay them later.</p>
<form <form
method="post" method="post"
action="{% url 'scouting:saved_search_create' %}" action="{% url 'scouting:saved_search_create' %}"
class="row-gap" class="mt-3 flex flex-wrap items-end gap-3"
hx-post="{% url 'scouting:saved_search_create' %}" hx-post="{% url 'scouting:saved_search_create' %}"
hx-target="#saved-search-feedback" hx-target="#saved-search-feedback"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#htmx-loading"
> >
{% csrf_token %} {% csrf_token %}
<input type="text" name="name" placeholder="Search name" required> <div class="min-w-56 flex-1">
<label class="inline-label"> <label for="saved-search-name">Search name</label>
<input id="saved-search-name" type="text" name="name" placeholder="Search name" required>
</div>
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
<input type="checkbox" name="is_public"> <input type="checkbox" name="is_public">
Public Public
</label> </label>
@ -21,7 +26,7 @@
<input type="hidden" name="{{ key }}" value="{{ value }}"> <input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %} {% endfor %}
<button class="button" type="submit">Save search</button> <button class="btn" type="submit">Save search</button>
</form> </form>
<div id="saved-search-feedback" class="mt-16"></div> <div id="saved-search-feedback" class="mt-3" aria-live="polite"></div>
</div> </div>

View File

@ -1,6 +1,6 @@
{% if saved_searches %} {% if saved_searches %}
<div class="table-wrap mt-16"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -10,20 +10,20 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for saved_search in saved_searches %} {% for saved_search in saved_searches %}
<tr> <tr>
<td>{{ saved_search.name }}</td> <td class="font-medium text-slate-800">{{ saved_search.name }}</td>
<td>{% if saved_search.is_public %}Public{% else %}Private{% endif %}</td> <td>{% if saved_search.is_public %}Public{% else %}Private{% endif %}</td>
<td>{{ saved_search.updated_at|date:"Y-m-d H:i" }}</td> <td>{{ saved_search.updated_at|date:"Y-m-d H:i" }}</td>
<td>{{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }}</td> <td>{{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }}</td>
<td> <td>
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<a class="button ghost" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a>
<a class="button ghost" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a>
<form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}"> <form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}">
{% csrf_token %} {% csrf_token %}
<button class="button ghost" type="submit">Delete</button> <button class="btn-secondary" type="submit">Delete</button>
</form> </form>
</div> </div>
</td> </td>
@ -33,5 +33,5 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="mt-16">No saved searches yet.</p> <div class="empty-state mt-4">No saved searches yet.</div>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{% if favorites %} {% if favorites %}
<div class="table-wrap mt-16"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>Player</th>
@ -10,15 +10,12 @@
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for favorite in favorites %} {% for favorite in favorites %}
<tr> <tr>
<td><a href="{% url 'players:detail' favorite.player_id %}">{{ favorite.player.full_name }}</a></td> <td><a class="font-medium" href="{% url 'players:detail' favorite.player_id %}">{{ favorite.player.full_name }}</a></td>
<td>{{ favorite.player.nationality.name|default:"-" }}</td> <td>{{ favorite.player.nationality.name|default:"-" }}</td>
<td> <td>{{ favorite.player.nominal_position.code|default:"-" }} / {{ favorite.player.inferred_role.name|default:"-" }}</td>
{{ favorite.player.nominal_position.code|default:"-" }}
/ {{ favorite.player.inferred_role.name|default:"-" }}
</td>
<td>{{ favorite.created_at|date:"Y-m-d" }}</td> <td>{{ favorite.created_at|date:"Y-m-d" }}</td>
<td> <td>
<div id="favorite-toggle-{{ favorite.player_id }}"> <div id="favorite-toggle-{{ favorite.player_id }}">
@ -31,5 +28,5 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="mt-16">No players in your watchlist yet.</p> <div class="empty-state mt-4">No players in your watchlist yet.</div>
{% endif %} {% endif %}

View File

@ -3,14 +3,14 @@
{% block title %}HoopScout | Edit Saved Search{% endblock %} {% block title %}HoopScout | Edit Saved Search{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Edit Saved Search</h1> <h1>Edit Saved Search</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<button type="submit" class="button">Update</button> <button type="submit" class="btn">Update</button>
<a class="button ghost" href="{% url 'scouting:index' %}">Cancel</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Cancel</a>
</div> </div>
</form> </form>
</section> </section>

View File

@ -4,9 +4,9 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1>Saved Searches</h1> <h1>Saved Searches</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Back to scouting</a>
</div> </div>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %} {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section> </section>

View File

@ -4,9 +4,9 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1>Watchlist</h1> <h1>Watchlist</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Back to scouting</a>
</div> </div>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %} {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section> </section>

View File

@ -3,12 +3,12 @@
{% block title %}HoopScout | Login{% endblock %} {% block title %}HoopScout | Login{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Login</h1> <h1>Login</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="button">Sign in</button> <button type="submit" class="btn">Sign in</button>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -3,12 +3,12 @@
{% block title %}HoopScout | Signup{% endblock %} {% block title %}HoopScout | Signup{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Create account</h1> <h1>Create account</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="button">Create account</button> <button type="submit" class="btn">Create account</button>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}