Docker Image and first iteration
This commit is contained in:
1 parent
723b64b1c4
commit
bd9209ebb7
40 files changed
+26643
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
frontend/node_modules
|
||||||
|
showcase/static/showcase/dist
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DJANGO_SECRET_KEY=django-insecure-local-development-only-change-me
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
frontend/node_modules/
|
||||||
|
showcase/static/showcase/dist/
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This repository is a foundational Dockerized Django portfolio website. Local
|
||||||
|
development currently uses SQLite and a single Docker Compose `web` service.
|
||||||
|
Do not assume a frontend stack, deployment platform, or CI provider unless the
|
||||||
|
repository explicitly defines one.
|
||||||
|
|
||||||
|
## Working Principles
|
||||||
|
|
||||||
|
- Prefer small, reviewable changes with a clear purpose.
|
||||||
|
- Inspect the repository before making changes. Follow existing patterns once
|
||||||
|
they exist instead of introducing parallel conventions.
|
||||||
|
- Keep local development Docker-first. Add and document Docker Compose commands
|
||||||
|
when the Compose configuration is introduced.
|
||||||
|
- Do not hard-code credentials, secret keys, hostnames, or environment-specific
|
||||||
|
configuration.
|
||||||
|
- Update `README.md`, this file, and `SECURITY.md` when implementation changes
|
||||||
|
make their instructions inaccurate.
|
||||||
|
|
||||||
|
## Django Conventions
|
||||||
|
|
||||||
|
- Keep environment-specific settings outside committed source code. Load
|
||||||
|
sensitive values from environment variables or a deployment secret store.
|
||||||
|
- Organize Django apps by domain responsibility. Avoid creating apps solely to
|
||||||
|
mirror technical layers.
|
||||||
|
- Use Django migrations for schema changes. Commit generated migration files
|
||||||
|
with the code that requires them.
|
||||||
|
- Review migrations before committing them. Avoid destructive schema changes
|
||||||
|
without an explicit data migration or rollout plan.
|
||||||
|
- Keep views, forms, serializers, templates, and models focused. Extract shared
|
||||||
|
behavior only when there is a concrete reuse case.
|
||||||
|
- Use Django's built-in security protections unless there is a documented
|
||||||
|
reason to override them.
|
||||||
|
|
||||||
|
## Docker Workflow
|
||||||
|
|
||||||
|
Docker Compose is the preferred local interface:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose exec web python manage.py check
|
||||||
|
docker compose exec web python manage.py migrate
|
||||||
|
docker compose exec web python manage.py test
|
||||||
|
docker compose run --rm frontend npm run test
|
||||||
|
docker compose run --rm frontend npm run build
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep containers reproducible:
|
||||||
|
|
||||||
|
- Pin runtime versions and dependency versions using the chosen package
|
||||||
|
manager's lock mechanism.
|
||||||
|
- Run application processes as a non-root user where practical.
|
||||||
|
- Do not bake secrets or local environment files into images.
|
||||||
|
- Add a `.dockerignore` when Docker files are introduced.
|
||||||
|
- Use a `.env.example` to record environment variables, and do not touch the production `.env`.
|
||||||
|
- The Web Container should be entirely self contained, without splitting the functionality into multiple containers. It should include both backend, frontend, integrated database, webserver, etc.
|
||||||
|
|
||||||
|
## Frontend Conventions
|
||||||
|
|
||||||
|
- Keep Django responsible for page rendering and use React only for focused
|
||||||
|
interactive islands unless the architecture is intentionally changed.
|
||||||
|
- Build frontend assets with the one-shot Docker Compose `frontend` service.
|
||||||
|
- Keep npm dependencies pinned and commit `frontend/package-lock.json`.
|
||||||
|
- Keep generated frontend bundles out of version control. The Docker image
|
||||||
|
builds its own production bundle.
|
||||||
|
- Treat committed TopoJSON map files as generated assets derived from Natural
|
||||||
|
Earth data. Regenerate them deliberately rather than editing them by hand.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Before considering a change complete, run the checks supported by the current
|
||||||
|
repository. Once available, this should include:
|
||||||
|
|
||||||
|
- Django system checks.
|
||||||
|
- The automated test suite.
|
||||||
|
- Migration consistency checks.
|
||||||
|
- Formatting, linting, and type checks selected by the project.
|
||||||
|
- A Docker image build and a basic container startup check for infrastructure
|
||||||
|
changes.
|
||||||
|
|
||||||
|
If a check is not yet configured or cannot be run, state that clearly in the
|
||||||
|
change summary. Do not invent placeholder commands that appear executable.
|
||||||
|
|
||||||
|
## Change Hygiene
|
||||||
|
|
||||||
|
- Never commit `.env` files, secret values, local databases, uploaded media,
|
||||||
|
caches, or generated build output.
|
||||||
|
- Add tests for behavior changes and regression fixes once the test harness
|
||||||
|
exists.
|
||||||
|
- Keep documentation aligned with actual repository behavior.
|
||||||
|
- Avoid unrelated refactors in focused feature or bug-fix changes.
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
FROM node:24-bookworm-slim AS frontend-deps
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM frontend-deps AS frontend-builder
|
||||||
|
|
||||||
|
COPY frontend ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system django \
|
||||||
|
&& adduser --system --ingroup django django
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=django:django . .
|
||||||
|
COPY --from=frontend-builder --chown=django:django /app/showcase/static/showcase/dist ./showcase/static/showcase/dist
|
||||||
|
|
||||||
|
USER django
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Portfolio
|
||||||
|
|
||||||
|
Foundational Dockerized Django project for a portfolio website.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm frontend npm run build
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose exec web python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
The development server is available at <http://localhost:8000>. Portfolio
|
||||||
|
entries and their map regions can be managed at <http://localhost:8000/admin/>.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec web python manage.py check
|
||||||
|
docker compose exec web python manage.py migrate
|
||||||
|
docker compose exec web python manage.py test
|
||||||
|
docker compose run --rm frontend npm run test
|
||||||
|
docker compose run --rm frontend npm run build
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` contains local-only development defaults. Create a separate
|
||||||
|
untracked `.env` and update `compose.yaml` before using environment-specific
|
||||||
|
values outside local development.
|
||||||
|
|
||||||
|
## Map Data
|
||||||
|
|
||||||
|
The React map island uses committed, optimized TopoJSON assets derived from
|
||||||
|
public-domain [Natural Earth](https://www.naturalearthdata.com/) Admin-0 and
|
||||||
|
Admin-1 data. The browser does not request a map provider or API key.
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report suspected vulnerabilities privately. Do not open a public issue
|
||||||
|
for an unpatched security problem.
|
||||||
|
|
||||||
|
Send a concise report to **`security@ntbx.io`**.
|
||||||
|
|
||||||
|
Include the affected area, reproduction steps, potential impact, and any
|
||||||
|
suggested mitigation. Avoid including secrets or personal data unless they are
|
||||||
|
required to explain the issue. No response-time or remediation-time guarantee
|
||||||
|
is currently offered.
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
This project is an initial scaffold and has no released or supported versions
|
||||||
|
yet. Define the supported release policy here before the first public
|
||||||
|
deployment.
|
||||||
|
|
||||||
|
## Engineering Security Baseline
|
||||||
|
|
||||||
|
The following requirements apply as the Dockerized Django application is
|
||||||
|
built. They are implementation guardrails, not claims that the corresponding
|
||||||
|
components already exist.
|
||||||
|
|
||||||
|
### Django Settings
|
||||||
|
|
||||||
|
- Read `SECRET_KEY`, database credentials, allowed hosts, and other
|
||||||
|
environment-specific values from environment variables or a deployment
|
||||||
|
secret store.
|
||||||
|
- Keep debug mode disabled outside local development.
|
||||||
|
- Configure allowed hosts and trusted CSRF origins explicitly for each deployed
|
||||||
|
environment.
|
||||||
|
- Enable secure cookie, HTTPS redirect, HSTS, and proxy SSL settings as
|
||||||
|
appropriate for the production hosting topology.
|
||||||
|
- Preserve Django's CSRF, escaping, password validation, and clickjacking
|
||||||
|
protections unless an exception is documented and reviewed.
|
||||||
|
|
||||||
|
### Containers
|
||||||
|
|
||||||
|
- Pin base image versions and rebuild images regularly for security updates.
|
||||||
|
- Run the application as a non-root user where practical.
|
||||||
|
- Keep images minimal and exclude source-control metadata, local environment
|
||||||
|
files, caches, and development-only artifacts with `.dockerignore`.
|
||||||
|
- Do not publish database or internal service ports unless a documented
|
||||||
|
environment requires it.
|
||||||
|
- Keep development conveniences separate from production configuration.
|
||||||
|
|
||||||
|
### Secrets and Dependencies
|
||||||
|
|
||||||
|
- Never commit secrets, API keys, private keys, production credentials, or
|
||||||
|
populated `.env` files.
|
||||||
|
- Use separate credentials for local development, tests, staging, and
|
||||||
|
production.
|
||||||
|
- Pin application dependencies with the selected package manager's lock or
|
||||||
|
constraints mechanism.
|
||||||
|
- Review dependency updates and use automated vulnerability scanning when CI is
|
||||||
|
introduced.
|
||||||
|
- Rotate any credential immediately if it is exposed, including exposure in
|
||||||
|
version-control history.
|
||||||
|
|
||||||
|
### Authentication and Authorization
|
||||||
|
|
||||||
|
- Prefer Django's built-in authentication and password hashing facilities.
|
||||||
|
- Protect administrative routes with strong credentials and restrict access
|
||||||
|
further at the network or identity layer where the deployment supports it.
|
||||||
|
- Enforce authorization checks server-side for every protected action. Do not
|
||||||
|
rely on hidden UI controls.
|
||||||
|
- Add rate limiting or equivalent abuse protection for authentication and
|
||||||
|
public write endpoints when those endpoints are introduced.
|
||||||
|
|
||||||
|
### Uploads and User-Controlled Content
|
||||||
|
|
||||||
|
- Treat filenames, metadata, form fields, URL parameters, and uploaded files as
|
||||||
|
untrusted input.
|
||||||
|
- Validate upload size and content type, generate storage-safe filenames, and
|
||||||
|
keep uploaded files outside executable application paths.
|
||||||
|
- Do not render user-controlled HTML as safe unless it has been sanitized by an
|
||||||
|
explicitly selected and tested mechanism.
|
||||||
|
- Store and serve media according to the chosen deployment platform's security
|
||||||
|
model.
|
||||||
|
|
||||||
|
### Database and Logging
|
||||||
|
|
||||||
|
- Use least-privilege database credentials and avoid exposing the database
|
||||||
|
directly to the public internet.
|
||||||
|
- Back up production data and test the restore procedure once a production
|
||||||
|
database exists.
|
||||||
|
- Do not log passwords, secret values, authentication tokens, or unnecessary
|
||||||
|
personal data.
|
||||||
|
- Record security-relevant failures with enough context to investigate them
|
||||||
|
without leaking sensitive information.
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
- Serve the application over HTTPS.
|
||||||
|
- Keep production debug tooling disabled.
|
||||||
|
- Apply migrations deliberately during deployment and maintain a rollback or
|
||||||
|
recovery plan for risky changes.
|
||||||
|
- Monitor application errors, suspicious authentication activity, and
|
||||||
|
dependency vulnerabilities once operational tooling is selected.
|
||||||
|
- Document the deployment-specific security configuration before the first
|
||||||
|
public release.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
profiles:
|
||||||
|
- tools
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: frontend-deps
|
||||||
|
working_dir: /app/frontend
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app/frontend
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- ./showcase/static/showcase/dist:/app/showcase/static/showcase/dist
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env.example
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_node_modules:
|
||||||
Generated
+2383
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "xav-portfolio-map",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-simple-maps": "3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@types/react": "18.3.30",
|
||||||
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@types/react-simple-maps": "3.0.6",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"vite": "8.0.16",
|
||||||
|
"vitest": "4.1.8"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"d3-color": "3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PortfolioMap } from "./PortfolioMap";
|
||||||
|
|
||||||
|
vi.mock("react-simple-maps", () => ({
|
||||||
|
ComposableMap: ({ children }: { children: React.ReactNode }) => <svg>{children}</svg>,
|
||||||
|
Geographies: ({
|
||||||
|
children,
|
||||||
|
geography,
|
||||||
|
}: {
|
||||||
|
children: (value: { geographies: unknown[] }) => React.ReactNode;
|
||||||
|
geography: string;
|
||||||
|
}) =>
|
||||||
|
children({
|
||||||
|
geographies: geography.includes("subdivisions")
|
||||||
|
? [{ rsmKey: "us-ca", properties: { code: "US-CA", name: "California" } }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
Geography: (props: React.SVGProps<SVGPathElement>) => <path {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("PortfolioMap", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
regions: [
|
||||||
|
{ code: "USA", name: "United States of America", level: "country", parentCode: null, entries: [] },
|
||||||
|
{ code: "US-CA", name: "California", level: "subdivision", parentCode: "USA", entries: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a region and renders its empty state", async () => {
|
||||||
|
render(<PortfolioMap />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("California")).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: "US-CA" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("No published experience is attached to this region yet.")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "View United States of America" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a pinned region", async () => {
|
||||||
|
render(<PortfolioMap />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("California")).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: "US-CA" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("No published experience is attached to this region yet.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a map region with the keyboard", async () => {
|
||||||
|
render(<PortfolioMap />);
|
||||||
|
|
||||||
|
const region = await screen.findByRole("button", { name: "California" });
|
||||||
|
fireEvent.keyDown(region, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(screen.getByText("No published experience is attached to this region yet.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ComposableMap,
|
||||||
|
Geographies,
|
||||||
|
Geography,
|
||||||
|
} from "react-simple-maps";
|
||||||
|
|
||||||
|
import type { Region, RegionsResponse } from "./types";
|
||||||
|
|
||||||
|
const countriesUrl = "/static/showcase/maps/countries.topo.json";
|
||||||
|
const subdivisionsUrl = "/static/showcase/maps/subdivisions.topo.json";
|
||||||
|
const visibleCountryCodes = new Set([
|
||||||
|
"ALB", "AND", "AUT", "BEL", "BGR", "BIH", "BLR", "CAN", "CHE", "CZE",
|
||||||
|
"DEU", "DNK", "ESP", "EST", "FIN", "FRA", "GBR", "GRC", "GRL", "HRV",
|
||||||
|
"HUN", "IRL", "ISL", "ITA", "KOS", "LTU", "LUX", "LVA", "MDA", "MEX",
|
||||||
|
"MKD", "MNE", "NLD", "NOR", "POL", "PRT", "ROU", "RUS", "SRB", "SVK",
|
||||||
|
"SVN", "SWE", "TUR", "UKR", "USA",
|
||||||
|
]);
|
||||||
|
|
||||||
|
type GeographyShape = {
|
||||||
|
rsmKey: string;
|
||||||
|
properties: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function RegionLayer({
|
||||||
|
geographyUrl,
|
||||||
|
regions,
|
||||||
|
selectedCode,
|
||||||
|
visibleCodes,
|
||||||
|
onPreview,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
geographyUrl: string;
|
||||||
|
regions: Map<string, Region>;
|
||||||
|
selectedCode: string | null;
|
||||||
|
visibleCodes?: Set<string>;
|
||||||
|
onPreview: (region: Region | null) => void;
|
||||||
|
onSelect: (region: Region) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Geographies geography={geographyUrl}>
|
||||||
|
{({ geographies }: { geographies: GeographyShape[] }) =>
|
||||||
|
geographies.map((geography) => {
|
||||||
|
if (visibleCodes && !visibleCodes.has(geography.properties.code)) return null;
|
||||||
|
|
||||||
|
const region = regions.get(geography.properties.code);
|
||||||
|
if (!region) return null;
|
||||||
|
|
||||||
|
const active = region.entries.length > 0;
|
||||||
|
const selected = selectedCode === region.code;
|
||||||
|
return (
|
||||||
|
<Geography
|
||||||
|
aria-label={region.name}
|
||||||
|
className={`map-region${active ? " has-entries" : ""}${selected ? " is-selected" : ""}`}
|
||||||
|
geography={geography}
|
||||||
|
key={geography.rsmKey}
|
||||||
|
onBlur={() => onPreview(null)}
|
||||||
|
onClick={() => onSelect(region)}
|
||||||
|
onFocus={() => onPreview(region)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect(region);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onPreview(region)}
|
||||||
|
onMouseLeave={() => onPreview(null)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Geographies>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanel({
|
||||||
|
region,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
region: Region | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
if (!region) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside aria-live="polite" className="detail-panel">
|
||||||
|
<button className="close-button" onClick={onClose} type="button">
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
<p className="eyebrow">{region.level}</p>
|
||||||
|
<h3>{region.name}</h3>
|
||||||
|
{region.entries.length === 0 ? (
|
||||||
|
<p className="empty-state">No published experience is attached to this region yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="entry-list">
|
||||||
|
{region.entries.map((entry) => (
|
||||||
|
<article className="entry-card" key={entry.id}>
|
||||||
|
<p className="entry-type">{entry.type}</p>
|
||||||
|
<h4>{entry.title}</h4>
|
||||||
|
{entry.organization && <p>{entry.organization}</p>}
|
||||||
|
<p>{entry.summary}</p>
|
||||||
|
{entry.url && (
|
||||||
|
<a href={entry.url} rel="noreferrer" target="_blank">
|
||||||
|
View details
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortfolioMap() {
|
||||||
|
const [regions, setRegions] = useState<Region[]>([]);
|
||||||
|
const [preview, setPreview] = useState<Region | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Region | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/map/regions/")
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Unable to load map data");
|
||||||
|
return response.json() as Promise<RegionsResponse>;
|
||||||
|
})
|
||||||
|
.then((response) => setRegions(response.regions))
|
||||||
|
.catch(() => setError(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const regionsByCode = new Map(regions.map((region) => [region.code, region]));
|
||||||
|
const visibleRegions = regions.filter(
|
||||||
|
(region) => region.level === "subdivision" || visibleCountryCodes.has(region.code),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p className="map-status">The portfolio map could not be loaded.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`map-island${selected ? " has-detail" : ""}`}>
|
||||||
|
<div className="map-stage">
|
||||||
|
<div className="map-toolbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Interactive geography</p>
|
||||||
|
<h2 id="map-title">Atlas</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="sr-only">Choose a region</span>
|
||||||
|
<select
|
||||||
|
onChange={(event) => setSelected(regionsByCode.get(event.target.value) ?? null)}
|
||||||
|
value={selected?.code ?? ""}
|
||||||
|
>
|
||||||
|
<option value="">Select a region</option>
|
||||||
|
{visibleRegions.map((region) => (
|
||||||
|
<option key={region.code} value={region.code}>
|
||||||
|
{region.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="map-canvas">
|
||||||
|
<ComposableMap
|
||||||
|
height={440}
|
||||||
|
projection="geoMercator"
|
||||||
|
projectionConfig={{ center: [-55, 53], scale: 185 }}
|
||||||
|
>
|
||||||
|
<RegionLayer
|
||||||
|
geographyUrl={countriesUrl}
|
||||||
|
onPreview={setPreview}
|
||||||
|
onSelect={setSelected}
|
||||||
|
regions={regionsByCode}
|
||||||
|
selectedCode={selected?.code ?? null}
|
||||||
|
visibleCodes={visibleCountryCodes}
|
||||||
|
/>
|
||||||
|
<RegionLayer
|
||||||
|
geographyUrl={subdivisionsUrl}
|
||||||
|
onPreview={setPreview}
|
||||||
|
onSelect={setSelected}
|
||||||
|
regions={regionsByCode}
|
||||||
|
selectedCode={selected?.code ?? null}
|
||||||
|
/>
|
||||||
|
</ComposableMap>
|
||||||
|
<div className="map-preview" role="status">
|
||||||
|
{preview ? (
|
||||||
|
<>
|
||||||
|
<strong>{preview.name}</strong>
|
||||||
|
<span>{preview.entries.length} linked {preview.entries.length === 1 ? "entry" : "entries"}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Hover or choose a region</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DetailPanel onClose={() => setSelected(null)} region={selected} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import { PortfolioMap } from "./PortfolioMap";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("portfolio-map-root");
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
createRoot(root).render(<PortfolioMap />);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
:root {
|
||||||
|
color: #1e2b26;
|
||||||
|
background: #f3efe4;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 15%, rgba(190, 209, 196, 0.55), transparent 30rem),
|
||||||
|
#f3efe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header,
|
||||||
|
.site-shell {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1480px;
|
||||||
|
padding-inline: clamp(1rem, 4vw, 5rem);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-block: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
height: calc(100dvh - 4.2rem);
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell section,
|
||||||
|
#portfolio-map-root,
|
||||||
|
.map-island,
|
||||||
|
.map-stage {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-mark {
|
||||||
|
color: #1e2b26;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-mark span {
|
||||||
|
color: #bf5b3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav {
|
||||||
|
display: flex;
|
||||||
|
gap: clamp(0.9rem, 3vw, 2.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav a {
|
||||||
|
color: #53645d;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav a:hover,
|
||||||
|
.site-header nav a:focus {
|
||||||
|
color: #bf5b3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.entry-type {
|
||||||
|
color: #bf5b3a;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-island {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-island.has-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-stage,
|
||||||
|
.detail-panel {
|
||||||
|
background: rgba(255, 252, 243, 0.72);
|
||||||
|
border: 1px solid rgba(55, 83, 72, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar h2 {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: #fffaf0;
|
||||||
|
border: 1px solid #9aa99e;
|
||||||
|
color: #1e2b26;
|
||||||
|
max-width: 15rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas svg {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-region {
|
||||||
|
cursor: pointer;
|
||||||
|
fill: #d7ddd3;
|
||||||
|
outline: none;
|
||||||
|
stroke: #fffaf0;
|
||||||
|
stroke-width: 0.5;
|
||||||
|
transition: fill 160ms ease, stroke 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-region.has-entries {
|
||||||
|
fill: #91a899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-region:hover,
|
||||||
|
.map-region:focus,
|
||||||
|
.map-region.is-selected {
|
||||||
|
fill: #bf5b3a;
|
||||||
|
stroke: #71311f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-preview {
|
||||||
|
align-items: start;
|
||||||
|
background: rgba(255, 250, 240, 0.88);
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #9aa99e;
|
||||||
|
color: #1e2b26;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
float: right;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
border-top: 1px solid rgba(55, 83, 72, 0.18);
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card a {
|
||||||
|
color: #9e452c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.map-status {
|
||||||
|
color: #53645d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.site-header {
|
||||||
|
max-width: none;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
padding-block: 1rem;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
grid-template-columns: repeat(3, auto);
|
||||||
|
justify-content: end;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav a {
|
||||||
|
font-size: 0.56rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
height: calc(100dvh - 3.5rem);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-island {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
background: #fffaf0;
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -0.5rem 2rem rgba(30, 43, 38, 0.18);
|
||||||
|
left: 0;
|
||||||
|
max-height: 58vh;
|
||||||
|
overflow: auto;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type PortfolioEntry = {
|
||||||
|
id: number;
|
||||||
|
type: "work" | "cv";
|
||||||
|
title: string;
|
||||||
|
organization: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
summary: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Region = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: "country" | "subdivision";
|
||||||
|
parentCode: string | null;
|
||||||
|
entries: PortfolioEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegionsResponse = {
|
||||||
|
regions: Region[];
|
||||||
|
};
|
||||||
|
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify(mode === "test" ? "test" : "production"),
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "../showcase/static/showcase/dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
lib: {
|
||||||
|
entry: "src/main.tsx",
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "map.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
assetFileNames: "map.[ext]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||||
|
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
host for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if host
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"showcase",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "portfolio.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "portfolio.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("", include("showcase.urls")),
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
asgiref==3.11.1
|
||||||
|
Django==5.2.14
|
||||||
|
sqlparse==0.5.5
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import PortfolioEntry, Region
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Region)
|
||||||
|
class RegionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "code", "level", "parent")
|
||||||
|
list_filter = ("level", "parent")
|
||||||
|
search_fields = ("name", "code")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PortfolioEntry)
|
||||||
|
class PortfolioEntryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "organization", "entry_type", "is_published", "display_order")
|
||||||
|
list_filter = ("entry_type", "is_published", "regions")
|
||||||
|
search_fields = ("title", "organization", "summary")
|
||||||
|
filter_horizontal = ("regions",)
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ShowcaseConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "showcase"
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Region",
|
||||||
|
fields=[
|
||||||
|
("code", models.CharField(max_length=8, primary_key=True, serialize=False)),
|
||||||
|
("name", models.CharField(max_length=120)),
|
||||||
|
(
|
||||||
|
"level",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("country", "Country"), ("subdivision", "Subdivision")],
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"parent",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="subdivisions",
|
||||||
|
to="showcase.region",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={"ordering": ("name",)},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PortfolioEntry",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("entry_type", models.CharField(choices=[("work", "Work"), ("cv", "CV")], max_length=8)),
|
||||||
|
("title", models.CharField(max_length=160)),
|
||||||
|
("organization", models.CharField(blank=True, max_length=160)),
|
||||||
|
("start_date", models.DateField(blank=True, null=True)),
|
||||||
|
("end_date", models.DateField(blank=True, null=True)),
|
||||||
|
("summary", models.TextField()),
|
||||||
|
("url", models.URLField(blank=True)),
|
||||||
|
("is_published", models.BooleanField(default=False)),
|
||||||
|
("display_order", models.PositiveIntegerField(default=0)),
|
||||||
|
("regions", models.ManyToManyField(related_name="entries", to="showcase.region")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name_plural": "portfolio entries",
|
||||||
|
"ordering": ("display_order", "-start_date", "title"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_regions(apps, schema_editor):
|
||||||
|
region_model = apps.get_model("showcase", "Region")
|
||||||
|
seed_path = Path(__file__).with_name("regions.json")
|
||||||
|
regions = json.loads(seed_path.read_text())
|
||||||
|
|
||||||
|
for region in regions["countries"]:
|
||||||
|
region_model.objects.update_or_create(
|
||||||
|
code=region["code"],
|
||||||
|
defaults={"name": region["name"], "level": "country", "parent": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
for region in regions["subdivisions"]:
|
||||||
|
region_model.objects.update_or_create(
|
||||||
|
code=region["code"],
|
||||||
|
defaults={
|
||||||
|
"name": region["name"],
|
||||||
|
"level": "subdivision",
|
||||||
|
"parent_id": region["parentCode"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_regions(apps, schema_editor):
|
||||||
|
apps.get_model("showcase", "Region").objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("showcase", "0001_initial")]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(seed_regions, unseed_regions)]
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Region(models.Model):
|
||||||
|
class Level(models.TextChoices):
|
||||||
|
COUNTRY = "country", "Country"
|
||||||
|
SUBDIVISION = "subdivision", "Subdivision"
|
||||||
|
|
||||||
|
code = models.CharField(max_length=8, primary_key=True)
|
||||||
|
name = models.CharField(max_length=120)
|
||||||
|
level = models.CharField(max_length=16, choices=Level.choices)
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="subdivisions",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("name",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioEntry(models.Model):
|
||||||
|
class EntryType(models.TextChoices):
|
||||||
|
WORK = "work", "Work"
|
||||||
|
CV = "cv", "CV"
|
||||||
|
|
||||||
|
entry_type = models.CharField(max_length=8, choices=EntryType.choices)
|
||||||
|
title = models.CharField(max_length=160)
|
||||||
|
organization = models.CharField(max_length=160, blank=True)
|
||||||
|
start_date = models.DateField(blank=True, null=True)
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
summary = models.TextField()
|
||||||
|
url = models.URLField(blank=True)
|
||||||
|
is_published = models.BooleanField(default=False)
|
||||||
|
display_order = models.PositiveIntegerField(default=0)
|
||||||
|
regions = models.ManyToManyField(Region, related_name="entries")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("display_order", "-start_date", "title")
|
||||||
|
verbose_name_plural = "portfolio entries"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Xavier Bukowski - Atlas</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'showcase/dist/map.css' %}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="site-mark" href="/">Xavier Bukowski<span>.</span></a>
|
||||||
|
<nav aria-label="Primary navigation">
|
||||||
|
<a href="#map">Atlas</a>
|
||||||
|
<a href="#work">Work</a>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="site-shell">
|
||||||
|
<section id="map" aria-labelledby="map-title">
|
||||||
|
<div id="portfolio-map-root" data-testid="portfolio-map-root"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="{% static 'showcase/dist/map.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from showcase.models import PortfolioEntry, Region
|
||||||
|
|
||||||
|
|
||||||
|
class MapRegionsViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.us = Region.objects.get(code="USA")
|
||||||
|
self.california = Region.objects.get(code="US-CA")
|
||||||
|
self.canada = Region.objects.get(code="CAN")
|
||||||
|
|
||||||
|
def regions_by_code(self):
|
||||||
|
response = self.client.get(reverse("map-regions"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return {region["code"]: region for region in response.json()["regions"]}
|
||||||
|
|
||||||
|
def test_homepage_contains_map_mount(self):
|
||||||
|
response = self.client.get(reverse("home"))
|
||||||
|
|
||||||
|
self.assertContains(response, 'id="portfolio-map-root"')
|
||||||
|
self.assertContains(response, "showcase/dist/map.js")
|
||||||
|
|
||||||
|
def test_api_returns_seeded_regions_with_empty_entries(self):
|
||||||
|
regions = self.regions_by_code()
|
||||||
|
|
||||||
|
self.assertIn("USA", regions)
|
||||||
|
self.assertIn("CAN", regions)
|
||||||
|
self.assertIn("US-CA", regions)
|
||||||
|
self.assertEqual(regions["US-CA"]["parentCode"], "USA")
|
||||||
|
self.assertEqual(regions["USA"]["entries"], [])
|
||||||
|
|
||||||
|
def test_country_aggregates_subdivision_entries_and_deduplicates(self):
|
||||||
|
entry = PortfolioEntry.objects.create(
|
||||||
|
entry_type=PortfolioEntry.EntryType.WORK,
|
||||||
|
title="West Coast delivery",
|
||||||
|
summary="Delivered a regional engagement.",
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
entry.regions.add(self.us, self.california)
|
||||||
|
|
||||||
|
regions = self.regions_by_code()
|
||||||
|
|
||||||
|
self.assertEqual(len(regions["USA"]["entries"]), 1)
|
||||||
|
self.assertEqual(regions["US-CA"]["entries"][0]["title"], entry.title)
|
||||||
|
|
||||||
|
def test_api_excludes_unpublished_entries(self):
|
||||||
|
entry = PortfolioEntry.objects.create(
|
||||||
|
entry_type=PortfolioEntry.EntryType.CV,
|
||||||
|
title="Draft entry",
|
||||||
|
summary="Not visible yet.",
|
||||||
|
is_published=False,
|
||||||
|
)
|
||||||
|
entry.regions.add(self.canada)
|
||||||
|
|
||||||
|
regions = self.regions_by_code()
|
||||||
|
|
||||||
|
self.assertEqual(regions["CAN"]["entries"], [])
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.home, name="home"),
|
||||||
|
path("api/map/regions/", views.map_regions, name="map-regions"),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from .models import PortfolioEntry, Region
|
||||||
|
|
||||||
|
|
||||||
|
def home(request):
|
||||||
|
return render(request, "showcase/home.html")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_entry(entry):
|
||||||
|
return {
|
||||||
|
"id": entry.id,
|
||||||
|
"type": entry.entry_type,
|
||||||
|
"title": entry.title,
|
||||||
|
"organization": entry.organization,
|
||||||
|
"startDate": entry.start_date.isoformat() if entry.start_date else None,
|
||||||
|
"endDate": entry.end_date.isoformat() if entry.end_date else None,
|
||||||
|
"summary": entry.summary,
|
||||||
|
"url": entry.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_regions(request):
|
||||||
|
entries = PortfolioEntry.objects.filter(is_published=True).prefetch_related("regions")
|
||||||
|
entries_by_region = {}
|
||||||
|
for entry in entries:
|
||||||
|
payload = _serialize_entry(entry)
|
||||||
|
for region in entry.regions.all():
|
||||||
|
entries_by_region.setdefault(region.code, {})[entry.id] = payload
|
||||||
|
|
||||||
|
regions = list(Region.objects.select_related("parent").prefetch_related("subdivisions"))
|
||||||
|
payload = []
|
||||||
|
for region in regions:
|
||||||
|
related = dict(entries_by_region.get(region.code, {}))
|
||||||
|
if region.level == Region.Level.COUNTRY:
|
||||||
|
for subdivision in region.subdivisions.all():
|
||||||
|
related.update(entries_by_region.get(subdivision.code, {}))
|
||||||
|
|
||||||
|
payload.append(
|
||||||
|
{
|
||||||
|
"code": region.code,
|
||||||
|
"name": region.name,
|
||||||
|
"level": region.level,
|
||||||
|
"parentCode": region.parent_id,
|
||||||
|
"entries": list(related.values()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({"regions": payload})
|
||||||
|
|
||||||
Reference in new issue
Block a user