Repository: johnpapa/heroes-react
Branch: main
Commit: ed1b29461392
Files: 70
Total size: 86.9 KB
Directory structure:
gitextract_voez_lfk/
├── .dockerignore
├── .eslintrc.json
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.yml
│ │ └── feature-request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── ci.yml
│ └── copilot-setup-steps.yml
├── .gitignore
├── .prettierrc
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── AGENTS.md
├── CHANGELOG.md
├── Dockerfile
├── README.md
├── cypress/
│ ├── fixtures/
│ │ └── example.json
│ ├── integration/
│ │ └── heroes.spec.js
│ ├── plugins/
│ │ └── index.js
│ └── support/
│ ├── commands.js
│ └── index.js
├── cypress.json
├── db.js
├── db.json
├── docker-compose.debug.yml
├── docker-compose.yml
├── package.json
├── public/
│ ├── index.html
│ └── manifest.json
├── routes.json
├── server.js
└── src/
├── About.js
├── App.css
├── App.js
├── components/
│ ├── ButtonFooter.js
│ ├── CardContent.js
│ ├── HeaderBar.js
│ ├── HeaderBarBrand.js
│ ├── HeaderBarLinks.js
│ ├── InputDetail.js
│ ├── ListHeader.js
│ ├── Modal.js
│ ├── ModalYesNo.js
│ ├── NavBar.js
│ ├── NotFound.js
│ └── index.js
├── heroes/
│ ├── HeroDetail.js
│ ├── HeroList.js
│ ├── Heroes.js
│ └── useHeroes.js
├── index.css
├── index.js
├── serviceWorker.js
├── store/
│ ├── action-utils.js
│ ├── config.js
│ ├── hero.actions.js
│ ├── hero.api.js
│ ├── hero.reducer.js
│ ├── hero.saga.js
│ ├── index.js
│ ├── villain.actions.js
│ ├── villain.api.js
│ ├── villain.reducer.js
│ └── villain.saga.js
├── styles.scss
└── villains/
├── VillainDetail.js
├── VillainList.js
├── Villains.js
└── useVillains.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
.env
*/bin
*/obj
README.md
LICENSE
.vscode
================================================
FILE: .eslintrc.json
================================================
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"prettier"
],
"rules": {
"react/prop-types": 0,
"jsx-a11y/label-has-for": 0,
"jsx-a11y/anchor-is-valid": 0,
"jsx-a11y/click-events-have-key-events": 0,
"no-console": 1,
"quotes": [
2,
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
]
},
"plugins": ["react"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"es6": true,
"browser": true,
"node": true
},
"settings": {
"react": {
"version": "detect"
}
}
}
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yml
================================================
name: Bug Report
description: Report a bug in the Heroes React app
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thank you for reporting a bug! Please fill out the details below.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear description of what the bug is.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Run `npm run quick`
2. Navigate to /heroes
3. Click on a hero to edit
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened.
validations:
required: true
- type: input
id: node-version
attributes:
label: Node.js Version
placeholder: "14.21.3"
- type: dropdown
id: browser
attributes:
label: Browser
options:
- Chrome
- Firefox
- Safari
- Edge
- Other
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, screenshots, or console errors.
================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.yml
================================================
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thank you for your suggestion! Please describe the feature you'd like to see.
- type: textarea
id: description
attributes:
label: Feature Description
description: A clear description of the feature or improvement.
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
description: Why is this feature valuable? What problem does it solve?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any alternative solutions or features you've considered.
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, mockups, or examples.
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
## Description
<!-- What does this PR do? One or two sentences. -->
## Changes
<!-- List the files changed and why. -->
-
## How to Test
1. `npm install`
2. `npm run quick`
3. Navigate to the affected feature (Heroes/Villains)
4. Verify the change works as expected
5. Run `npm run cypress` to confirm e2e tests pass
## Checklist
- [ ] `npm run lint` passes
- [ ] `npm run build` succeeds
- [ ] Cypress e2e tests pass (`npm run cypress`)
- [ ] Seed data updated in both `db.json` and `db.js` (if data shape changed)
- [ ] Store barrel re-exports updated in `src/store/index.js` (if new store files added)
- [ ] Component barrel updated in `src/components/index.js` (if new shared components added)
================================================
FILE: .github/copilot-instructions.md
================================================
# Copilot Instructions — heroes-react
## Project Type
React 16 SPA (Create React App) with Redux + Redux-Saga for state management, json-server mock backend, and Cypress for e2e testing. JavaScript only (no TypeScript).
## JavaScript Conventions
- Use ES2018+ syntax (arrow functions, destructuring, spread, async/await)
- Single quotes for strings (enforced by Prettier and ESLint)
- 2-space indentation, no tabs
- No semicolons are not enforced — the project uses semicolons
- Prefer `const` over `let`; never use `var`
- Use template literals for string interpolation
- Follow the existing ESLint config in `.eslintrc.json` (extends airbnb + prettier)
- Prettier handles formatting — see `.prettierrc` for config
- `console` usage triggers a warning (`no-console: 1`); use `const captains = console` alias pattern for intentional logging (see `server.js`, API files)
## React Patterns
- **Class components** are used for the root `App.js` — do not convert to functional unless refactoring the whole app
- **Functional components with hooks** are used for feature modules (heroes, villains)
- **Custom hooks** (`useHeroes`, `useVillains`) wrap `useSelector` + `useDispatch` — always use hooks to connect to Redux, never `connect()` HOC
- **Lazy loading** — feature route components use `React.lazy()` + `Suspense` in `App.js`
- **Shared components** live in `src/components/` and are barrel-exported from `src/components/index.js`
- **Props** — `react/prop-types` is disabled; no PropTypes validation is used
## Redux Conventions
- **Action types** follow `'[Feature] VERB_NOUN'` format (e.g., `'[Heroes] LOAD_HERO'`)
- Each feature has four store files: `*.actions.js`, `*.reducer.js`, `*.saga.js`, `*.api.js`
- **Reducers** use switch/case statements (not createSlice or Redux Toolkit)
- **Side effects** are handled by Redux-Saga (generator functions), not thunks
- **API calls** use Axios with a base URL from `REACT_APP_API` env var
- **Store barrel** — `src/store/index.js` re-exports everything and calls `combineReducers`
## Styling
- Use Bulma CSS framework classes for layout and UI components
- Custom styles go in `src/styles.scss` (global SCSS)
- Component-specific CSS is minimal — prefer Bulma utility classes
- Font Awesome for icons
## Testing
- **E2E tests** use Cypress — specs live in `cypress/integration/`
- Test against the running app on port 8626
- Use `cy.request('POST', '/api/reset', data)` in `beforeEach` to reset json-server data
- Import seed data from `../../db` (the `db.js` module)
- No unit tests currently exist — if adding unit tests, use the built-in CRA Jest setup
## File Naming
- React components: `PascalCase.js` (e.g., `HeroDetail.js`, `NavBar.js`)
- Hooks: `camelCase.js` prefixed with `use` (e.g., `useHeroes.js`)
- Store files: `entity.type.js` (e.g., `hero.actions.js`, `villain.saga.js`)
- Config/utility files: `camelCase.js` or `kebab-case.js`
## Environment Variables
- `REACT_APP_API` — API base URL (proxied in dev, full URL in production)
- `PORT` — app server port (default 8626)
- Dev config in `.env.development`, production in `.env`
## Maintenance Matrix
| When this changes... | Also update... |
|---|---|
| New entity/feature module added | `db.json`, `db.js` (seed data), `routes.json` (rewrites), `src/store/index.js` (register reducers + re-exports), `src/index.js` (run saga), `src/App.js` (route + lazy import), `src/components/NavBar.js` (nav link), `cypress/integration/` (add e2e spec) |
| New shared component added | `src/components/index.js` (barrel re-export) |
| Redux action types changed | `*.actions.js`, `*.reducer.js`, `*.saga.js` (all three must stay in sync per feature) |
| API endpoint changed | `src/store/*.api.js` (HTTP calls), `db.json` (data shape), `routes.json` (rewrites), Cypress tests (assertions) |
| Port changed | `.env`, `.env.development`, `cypress.json`, `.vscode/launch.json`, `Dockerfile` (EXPOSE) |
| Styling or CSS framework changed | `src/styles.scss`, `src/App.js` (Bulma import), `package.json` (bulma dep), all component JSX |
| Dependencies updated | `package.json`, verify `node-sass` compatibility with Node version, update Dockerfile base image if needed |
| Docker config changed | `Dockerfile`, `docker-compose.yml`, `docker-compose.debug.yml` |
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
paths-ignore:
- "**.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- "LICENSE"
- ".gitignore"
push:
branches: [main]
paths-ignore:
- "**.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- "LICENSE"
- ".gitignore"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "14"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
- name: Build
run: npm run build
================================================
FILE: .github/workflows/copilot-setup-steps.yml
================================================
name: "Copilot Setup Steps"
on: workflow_dispatch
jobs:
setup:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "14"
cache: "npm"
- name: Install dependencies
run: npm install
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
================================================
FILE: .prettierrc
================================================
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": true
}
================================================
FILE: .vscode/launch.json
================================================
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8626",
"webRoot": "${workspaceFolder}"
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"eslint.enable": true,
"peacock.color": "#61dafb",
"workbench.colorCustomizations": {
"activityBar.background": "#61dafb",
"activityBar.activeBorder": "#f925cc",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#f925cc",
"activityBarBadge.foreground": "#e7e7e7",
"titleBar.activeBackground": "#61dafb",
"titleBar.inactiveBackground": "#61dafb99",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveForeground": "#15202b99",
"statusBar.background": "#61dafb",
"statusBarItem.hoverBackground": "#2fcefa",
"statusBar.foreground": "#15202b",
"activityBar.activeBackground": "#61dafb"
}
}
================================================
FILE: AGENTS.md
================================================
# Heroes React — Agent Guide
This is a **React single-page application** (SPA) — a demo/comparison app showing the Tour of Heroes concept in React. It uses a json-server mock backend, Redux with Redux-Saga for state management, and Cypress for end-to-end testing.
Part of a family of comparative apps: [Angular](https://github.com/johnpapa/heroes-angular), [React](https://github.com/johnpapa/heroes-react), [Svelte](https://github.com/johnpapa/heroes-svelte), and [Vue](https://github.com/johnpapa/heroes-vue).
## Repository Structure
```
heroes-react/
├── .github/
│ ├── copilot-instructions.md # Coding conventions for AI agents
│ ├── workflows/
│ │ ├── ci.yml # PR validation (lint + build)
│ │ └── copilot-setup-steps.yml # Cloud agent environment setup
│ ├── ISSUE_TEMPLATE/ # Bug report and feature request forms
│ ├── PULL_REQUEST_TEMPLATE.md # PR checklist
│ └── dependabot.yml # Dependency update automation
├── cypress/
│ ├── fixtures/ # Test fixture data
│ ├── integration/ # E2E test specs (heroes.spec.js)
│ ├── plugins/ # Cypress plugins
│ └── support/ # Cypress support files
├── public/ # Static assets served by CRA
├── src/
│ ├── components/ # Shared UI components (Modal, NavBar, HeaderBar, etc.)
│ │ └── index.js # Barrel re-export for all shared components
│ ├── heroes/ # Heroes feature module
│ │ ├── Heroes.js # Route container (lazy-loaded)
│ │ ├── HeroList.js # List display component
│ │ ├── HeroDetail.js # Edit/add form component
│ │ └── useHeroes.js # Custom hook bridging Redux to component
│ ├── villains/ # Villains feature module (mirrors heroes/)
│ │ ├── Villains.js
│ │ ├── VillainList.js
│ │ ├── VillainDetail.js
│ │ └── useVillains.js
│ ├── store/ # Redux store, actions, reducers, sagas, API calls
│ │ ├── index.js # combineReducers + barrel re-exports
│ │ ├── config.js # API base URL from env
│ │ ├── action-utils.js # Shared response parsers
│ │ ├── hero.actions.js # Action type constants + action creators
│ │ ├── hero.reducer.js # Heroes + selectedHero reducers
│ │ ├── hero.saga.js # Saga side-effects for hero CRUD
│ │ ├── hero.api.js # Axios HTTP calls for heroes
│ │ ├── villain.actions.js # Action type constants + action creators
│ │ ├── villain.reducer.js # Villains + selectedVillain reducers
│ │ ├── villain.saga.js # Saga side-effects for villain CRUD
│ │ └── villain.api.js # Axios HTTP calls for villains
│ ├── App.js # Root component with routing (class component)
│ ├── About.js # About page
│ ├── index.js # Entry point — Redux store + saga setup + ReactDOM.render
│ └── styles.scss # Global SCSS styles
├── .vscode/
│ ├── launch.json # Chrome debug config (port 8626)
│ └── settings.json # Peacock color + ESLint enabled
├── db.json # json-server seed data (heroes + villains)
├── db.js # Seed data as JS module (used by Cypress)
├── routes.json # json-server route rewrites (/api/* → /*)
├── server.js # Express production server (serves built app)
├── cypress.json # Cypress config (port 8626)
├── Dockerfile # Multi-stage Docker build (Node 10 → production)
├── docker-compose.yml # Docker compose for running the app
├── docker-compose.debug.yml # Docker compose debug override
├── .eslintrc.json # ESLint config (airbnb-based + prettier)
├── .prettierrc # Prettier config (single quotes, 2-space tabs)
├── .env # Production env (port 8626, Azure API URL)
├── .env.development # Dev env (port 8626, proxied API)
├── package.json # Dependencies, scripts, proxy config
└── README.md # Project overview and getting started
```
## Tech Stack
- **Language:** JavaScript (ES2018, JSX)
- **Framework:** React 16 (class components + hooks)
- **State management:** Redux 4 + Redux-Saga
- **Routing:** React Router v5 with lazy-loaded routes
- **HTTP client:** Axios
- **CSS:** Bulma + SASS + Font Awesome
- **Mock backend:** json-server on port 8627 with route rewriting
- **Bundler:** Create React App (react-scripts 3.x)
- **Production server:** Express (server.js)
- **Container:** Docker multi-stage build
## Build & Run
```bash
# Install dependencies
npm install
# Run frontend + json-server backend concurrently
npm run quick
# Frontend only (requires separate API)
npm start
# Backend only (json-server on port 8627)
npm run backend
# Production build
npm run build
# Production server (serves built app on port 8626)
node server.js
```
**Ports:**
- `8626` — React dev server / production Express server
- `8627` — json-server mock API
The CRA proxy in `package.json` forwards `/api/*` requests from port 8626 to json-server on port 8627 during development.
## Testing
**E2E tests (Cypress):**
```bash
# Open Cypress test runner (interactive)
npm run cypress
# Run everything (app + backend + Cypress)
npm run e2e
```
- Tests live in `cypress/integration/`
- Cypress uses the same seed data from `db.js` and resets via `json-server-reset`
- Configured in `cypress.json` with port 8626
**Unit tests (react-scripts):**
```bash
npm test
```
Uses the built-in CRA test runner (Jest). No unit test files currently exist.
## Key Patterns and Conventions
- **Feature modules** — heroes and villains each get their own directory under `src/` with a container component, list, detail, and a custom hook
- **Redux pattern** — each feature has four store files: `*.actions.js` (constants + creators), `*.reducer.js`, `*.saga.js` (side effects), `*.api.js` (HTTP calls)
- **Barrel exports** — `src/store/index.js` re-exports all actions, reducers, and sagas; `src/components/index.js` re-exports all shared components
- **Action type naming** — `'[Feature] VERB_NOUN'` format (e.g., `'[Heroes] LOAD_HERO'`)
- **Custom hooks** — `useHeroes` and `useVillains` encapsulate `useSelector` + `useDispatch`, providing a clean API to container components
- **Lazy loading** — feature modules are code-split with `React.lazy()` and `Suspense`
- **API config** — base URL comes from `REACT_APP_API` environment variable via `src/store/config.js`
## Adding a New Feature Module
To add a new entity (e.g., "sidekicks"):
1. **Create seed data** — add a `"sidekicks"` array to `db.json` and `db.js`
2. **Add route rewrite** — add `"/sidekick": "/sidekicks"` to `routes.json`
3. **Create store files** in `src/store/`:
- `sidekick.actions.js` — action types and creators following the `[Sidekicks] VERB_NOUN` pattern
- `sidekick.api.js` — Axios CRUD functions using the API config
- `sidekick.reducer.js` — reducer + selectedSidekickReducer
- `sidekick.saga.js` — saga watchers and workers for each action
4. **Register in store** — update `src/store/index.js`:
- Add `sidekicksReducer` and `selectedSidekickReducer` to `combineReducers`
- Re-export all new modules (`export * from './sidekick.actions'`, etc.)
5. **Run the saga** — add `sagaMiddleware.run(sidekickSaga)` in `src/index.js`
6. **Create feature directory** `src/sidekicks/`:
- `Sidekicks.js` — container component
- `SidekickList.js` — list display
- `SidekickDetail.js` — edit/add form
- `useSidekicks.js` — custom hook
7. **Add route** — add `<Route path="/sidekicks" component={Sidekicks} />` in `App.js` with lazy import
8. **Add nav link** — update `src/components/NavBar.js`
9. **Add Cypress tests** — create `cypress/integration/sidekicks.spec.js`
## Screen Size / Responsive Rules
- Bulma handles responsive layout via its grid system (`columns`, `column`)
- The app uses a sidebar nav (`NavBar`) + main content area pattern
- No custom breakpoints — relies on Bulma defaults
## Common Pitfalls
- **Must run both servers** — `npm run quick` starts both the React dev server and json-server. Running `npm start` alone will fail API calls
- **json-server reset** — Cypress tests reset data via `json-server-reset` POST to `/api/reset`. If tests fail with stale data, restart json-server
- **Port conflicts** — the app expects 8626 (frontend) and 8627 (backend) to be free
- **node-sass version** — `node-sass@4.x` requires Node.js < 15. Use Node 14 for local development
- **Barrel re-exports** — when adding new shared components, update `src/components/index.js`; when adding new store modules, update `src/store/index.js`
- **Docker image is stale** — the Dockerfile uses `node:10.13-alpine` which is very old; update if building Docker images
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- AI-ready configuration (AGENTS.md, copilot-instructions.md, CI, issue templates, PR template, dependabot)
================================================
FILE: Dockerfile
================================================
# Client App
FROM node:10.13-alpine as client-app
LABEL authors="John Papa"
WORKDIR /usr/src/app
COPY ["package.json", "npm-shrinkwrap.json*", "./"]
RUN npm install --silent
COPY . .
ARG REACT_APP_API
ENV REACT_APP_API $REACT_APP_API
RUN npm run build
# Node server
FROM node:10.13-alpine as node-server
WORKDIR /usr/src/app
COPY ["package.json", "npm-shrinkwrap.json*", "./"]
RUN npm install --production --silent && mv node_modules ../
COPY server.js .
# Final image
FROM node:10.13-alpine
WORKDIR /usr/src/app
# get the node_modules
COPY --from=node-server /usr/src /usr/src
# get the client app
COPY --from=client-app /usr/src/app/build ./public
EXPOSE 8626
CMD ["node", "server.js"]
================================================
FILE: README.md
================================================
# Tour of Heroes
[](https://github.com/johnpapa/ai-ready)
This project was created to help represent a fundamental app written with React. The heroes and villains theme is used throughout the app.
by [John Papa](http://twitter.com/john_papa)
Comparative apps can be found here with [Angular](https://github.com/johnpapa/heroes-angular), [Svelte](https://github.com/johnpapa/heroes-svelte), and [Vue](https://github.com/johnpapa/heroes-vue)
## Why
I love JavaScript and the Web! One of the most common questions I hear is "which framework is best?". I like to flip this around and ask you "which is best for you?". The best way to know this is to try it for yourself. I'll follow up with some articles on my experiences with these frameworks but in the meantime, please try it for yourself to gain your own experience with each.
## Live Demos
Hosted in [Azure](https://azure.microsoft.com/free/?WT.mc_id=javascript-0000-jopapa)
- [Tour of Heroes with Angular](https://papa-heroes-angular.azurewebsites.net)
- [Tour of Heroes with React](https://papa-heroes-react.azurewebsites.net)
- Tour of Heroes with Svelte - coming soon!
- [Tour of Heroes with Vue](https://papa-heroes-vue.azurewebsites.net)
## Getting Started
1. Clone this repository
```bash
git clone https://github.com/johnpapa/heroes-react.git
cd heroes-react
```
1. Install the npm packages
```bash
npm install
```
1. Run the app!
```bash
npm run quick
```
## Cypress Tests
1. You can execute all of the UI tests by running the following steps
```bash
npm run cypress
```
## What's in the App
Each of these apps contain:
Each of the apps written in the various frameworks/libraries has been designed to have similar features. While consistency is key, I want these apps to be comparable, yet done in an way authentic to each respective framework.
Each project represents heroes and villains. The user can list them and edit them.
Here is a list of those features:
- [x] Start from the official quick-start and CLI
- [x] Client side routing
- [x] Three main routes Heroes, Villains, About
- [x] Handles an erroneous route, leading to a PageNotFound component
- [x] Active route is highlighted in the nav menu
- [x] Routing should use html5 mode, not hash routes
- [x] API
- [x] JSON server as a backend
- [x] App served on one port which can access API on another port proxy or CORS)
- [x] HTTP - Uses most common client http libraries for each framework
- [x] Styling
- [x] Bulma
- [x] SASS
- [x] Font Awesome
- [x] Same exact css in every app
- [x] Editing - Heroes and Villains will be editable (add, update, delete)
- [x] State/Store - Uses a store for state management
- [x] Web development server handles fallback routing
- [x] Generic components
- [x] Modal
- [x] Button Tool
- [x] Card
- [x] Header bar
- [x] List header
- [x] Nav bar
- [x] Props in and emit events out
- [x] Environment variable for the API location
### Why Cypress?
Cypress.io makes it easy to run all three apps simultaneously in end to end tests, so you can watch the results while developing.
### Why abstracted CSS?
The goal of the project was to show how each framework can be designed to create the same app. Each uses their own specific techniques in a way that is tuned to each framework. However the one caveat I wanted to achieve was to make sure all of them look the same. While I could have used specific styling for each with scoped and styled components, I chose to create a single global styles file that they all share. This allowed me to provide the same look and feel, run the same cypress tests, and focus more on the HTML and JavaScript/TypeScript.
### Why JSON Server?
The app uses a JSON server for a backend by default. This allows you to run the code without needing any database engines or cloud accounts. Enjoy!
## Problems or Suggestions
[Open an issue here](/issues)
## Thank You
Thank you to [Sarah Drasner](https://twitter.com/), [Brian Holt](https://twitter.com/), [Chris Noring](https://twitter.com/), [Craig Shoemaker](https://twitter.com/), and [Ward Bell](https://twitter.com/wardbell) for providing input and reviewing the code in some of the repos for the Angular, React, Svelte, and Vue apps:
- [heroes-angular](https://github.com/johnpapa/heroes-angular)
- [heroes-react](https://github.com/johnpapa/heroes-react)
- [heroes-svelte](https://github.com/johnpapa/heroes-svelte)
- [heroes-vue](https://github.com/johnpapa/heroes-vue)
## Resources
- [VS Code](https://code.visualstudio.com/?WT.mc_id=javascript-0000-jopapa)
- [Azure Free Trial](https://azure.microsoft.com/free/?WT.mc_id=javascript-0000-jopapa)
- [VS Code Extension for Node on Azure](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-node-azure-pack&WT.mc_id=javascript-0000-jopapa)
- [VS Code Extension Marketplace](https://marketplace.visualstudio.com/vscode?WT.mc_id=javascript-0000-jopapa)
- [VS Code - macOS keys](https://code.visualstudio.com/shortcuts/keyboard-shortcuts-macos.pdf?WT.mc_id=javascript-0000-jopapa)
- [VS Code - Windows keys](https://code.visualstudio.com/shortcuts/keyboard-shortcuts-windows.pdf?WT.mc_id=javascript-0000-jopapa)
### Debugging Resources
- [Debugging Angular in VS Code](https://code.visualstudio.com/docs/nodejs/angular-tutorial?WT.mc_id=javascript-0000-jopapa)
- [Debugging React in VS Code](https://code.visualstudio.com/docs/nodejs/reactjs-tutorial?WT.mc_id=javascript-0000-jopapa)
- [Debugging Vue in VS Code](https://code.visualstudio.com/docs/nodejs/vuejs-tutorial?WT.mc_id=javascript-0000-jopapa)
## Contributing
Contributions are welcome! You can use [Copilot CLI](https://github.com/johnpapa/ai-ready) to help:
```
Add a new feature module called "sidekicks" with CRUD operations
```
### Getting Started
1. Fork and clone the repo
2. Install dependencies: `npm install`
3. Start the app: `npm run quick`
4. Create a branch: `git checkout -b feat/my-feature`
5. Make changes, then run lint and e2e tests:
```bash
npm run lint
npm run cypress
```
6. Push and open a PR
See [AGENTS.md](AGENTS.md) for the full contributor guide, architecture, and conventions.
================================================
FILE: cypress/fixtures/example.json
================================================
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
================================================
FILE: cypress/integration/heroes.spec.js
================================================
/// <reference types="cypress" />
/* eslint-env mocha */
/* global cy expect Cypress */
import data from '../../db';
const hero = data.heroes[3];
const heroCount = 6;
const heroToDelete = data.heroes[5];
const newHero = {
id: 'heroMadelyn',
name: 'Madelyn',
description: 'the cat whisperer'
};
const port = Cypress.env('port');
const url = `http://localhost:${port}`;
const resetData = () => cy.request('POST', `${url}/api/reset`, data);
const containsHeroes = count =>
cy.get('.list .name').should('have.length', count);
const detailsAreVisible = visible => {
const val = visible ? '' : 'not.';
// return cy.get('.edit-detail input[name=name]').should(`${val}be.visible`)
return cy.get('.edit-detail input[name=name]').should(`${val}exist`);
};
context('Heroes', () => {
beforeEach(() => {
resetData().then(() => {
cy.visit(url);
cy.get('nav ul.menu-list a')
.contains('Heroes')
.click();
});
});
after(() => resetData());
specify(`Contains ${hero.name}`, () => {
cy.get('.list .name').contains(hero.name);
});
specify('Contains 6 heroes', () => {
containsHeroes(heroCount);
});
specify(`Deletes ${heroToDelete.name}`, () => {
cy.get(`.list .delete-item[data-id=${heroToDelete.id}]`).click();
cy.get(`#modal .modal-yes`).click();
containsHeroes(heroCount - 1);
cy.get(`.list .delete-item[data-id=${heroToDelete.id}]`).should(
'not.exist'
);
});
context(`${hero.name} has been Selected`, () => {
beforeEach(() => {
cy.get(`.list .edit-item[data-id=${hero.id}]`).click();
});
specify(`Shows Details for ${hero.name}`, () => {
const match = new RegExp(hero.id);
detailsAreVisible(true);
cy.get('.edit-detail input[name=id]')
.invoke('val')
.should('match', match);
});
specify(`Hero List is gone`, () => {
containsHeroes(0);
cy.get(`.list .delete-item[data-id=${heroToDelete.id}]`).should(
'not.exist'
);
cy.get(`.list .delete-item[data-id=${hero.id}]`).should('not.exist');
});
specify(`Saves changes to ${hero.name}`, () => {
const newDescription = 'slayer of javascript';
detailsAreVisible(true);
cy.get('.edit-detail input[name=description]')
.clear()
.type(newDescription);
cy.get('.edit-detail input[name=description]')
.invoke('val')
.should('not.match', new RegExp(hero.description))
.and('match', new RegExp(newDescription));
cy.get('.edit-detail .save-button').click();
detailsAreVisible(false);
cy.get('.list .description').contains(newDescription);
containsHeroes(heroCount);
});
specify(`Cancels changes to ${hero.name}`, () => {
const newDescription = 'slayer of javascript';
detailsAreVisible(true);
cy.get('.edit-detail input[name=description]')
.clear()
.type(newDescription);
cy.get('.edit-detail input[name=description]')
.invoke('val')
.should('not.match', new RegExp(hero.description))
.and('match', new RegExp(newDescription));
cy.get('.edit-detail .cancel-button').click();
detailsAreVisible(false);
cy.get('.list .description').contains(hero.description);
containsHeroes(heroCount);
});
});
context(`Add New Hero`, () => {
beforeEach(() => {
cy.get('.content-container .add-button').click();
});
specify(`Saves changes to ${newHero.name}`, () => {
detailsAreVisible(true);
cy.get('.edit-detail input[name=name]')
.clear()
.type(newHero.name);
cy.get('.edit-detail input[name=description]')
.clear()
.type(newHero.description);
cy.get('.edit-detail .save-button').click();
detailsAreVisible(false);
cy.get('.list .description').contains(newHero.description);
containsHeroes(heroCount + 1);
});
});
context(`Direct Routing`, () => {
specify(`Routes to /heroes directly and see hero list`, () => {
cy.visit(url);
cy.wait(1000);
cy.location().should(loc => {
expect(loc.host).to.eq(`localhost:${port}`);
expect(loc.hostname).to.eq('localhost');
expect(loc.href).to.eq(`${url}/heroes`);
expect(loc.origin).to.eq(url);
expect(loc.port).to.eq(port);
expect(loc.protocol).to.eq('http:');
expect(loc.toString()).to.eq(`${url}/heroes`);
});
detailsAreVisible(false);
containsHeroes(heroCount);
});
});
});
================================================
FILE: cypress/plugins/index.js
================================================
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
================================================
FILE: cypress/support/commands.js
================================================
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
================================================
FILE: cypress/support/index.js
================================================
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
================================================
FILE: cypress.json
================================================
{
"ignoreTestFiles": "/**/examples/*",
"env": {
"port": "8626"
}
}
================================================
FILE: db.js
================================================
const heroes = [
{
id: 'HeroAslaug',
name: 'Aslaug',
description: 'warrior queen',
},
{
id: 'HeroBjorn',
name: 'Bjorn Ironside',
description: 'king of 9th century Sweden',
},
{
id: 'HeroIvar',
name: 'Ivar the Boneless',
description: 'commander of the Great Heathen Army',
},
{
id: 'HeroLagertha',
name: 'Lagertha the Shieldmaiden',
description: 'aka Hlaðgerðr',
},
{
id: 'HeroRagnar',
name: 'Ragnar Lothbrok',
description: 'aka Ragnar Sigurdsson',
},
{
id: 'HeroThora',
name: 'Thora Town-hart',
description: 'daughter of Earl Herrauðr of Götaland',
},
];
const villains = [
{
id: 'VillainMadelyn',
name: 'Madelyn',
description: 'the cat whisperer',
},
{
id: 'VillainHaley',
name: 'Haley',
description: 'pen wielder',
},
{
id: 'VillainElla',
name: 'Ella',
description: 'fashionista',
},
{
id: 'VillainLandon',
name: 'Landon',
description: 'Mandalorian mauler',
},
];
const data = { heroes: heroes, villains: villains };
module.exports = data;
================================================
FILE: db.json
================================================
{
"heroes": [
{
"id": "HeroAslaug",
"name": "Aslaug",
"description": "warrior queen"
},
{
"id": "HeroBjorn",
"name": "Bjorn Ironside",
"description": "king of 9th century Sweden"
},
{
"id": "HeroIvar",
"name": "Ivar the Boneless",
"description": "commander of the Great Heathen Army"
},
{
"id": "HeroLagertha",
"name": "Lagertha the Shieldmaiden",
"description": "aka Hlaðgerðr"
},
{
"id": "HeroRagnar",
"name": "Ragnar Lothbrok",
"description": "aka Ragnar Sigurdsson"
},
{
"id": "HeroThora",
"name": "Thora Town-hart",
"description": "daughter of Earl Herrauðr of Götaland"
}
],
"villains": [
{
"id": "VillainMadelyn",
"name": "Madelyn",
"description": "the cat whisperer"
},
{
"id": "VillainHaley",
"name": "Haley",
"description": "pen wielder"
},
{
"id": "VillainElla",
"name": "Ella",
"description": "fashionista"
},
{
"id": "VillainLandon",
"name": "Landon",
"description": "Mandalorian mauler"
}
]
}
================================================
FILE: docker-compose.debug.yml
================================================
version: '2.1'
services:
heroes-react:
image: heroes-react
build: .
context: .
args:
REACT_APP_API: ${REACT_APP_API}
env_file:
- .env.development
ports:
- 8626:8626
- 9229:9229
command: node --inspect=0.0.0.0:9229 server.js
================================================
FILE: docker-compose.yml
================================================
version: '2.1'
services:
heroes-react:
image: heroes-react
build:
context: .
args:
REACT_APP_API: ${REACT_APP_API}
env_file:
- .env.development
ports:
- 8626:8626
================================================
FILE: package.json
================================================
{
"name": "heroes-react",
"version": "0.2.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"format": "prettier --write \"src/**/*.{js,jsx}\"",
"lint": "eslint \"src/**/*.{js,jsx}\" --quiet",
"quick": "concurrently \"npm run backend\" \"npm start\"",
"cypress": "npx cypress open",
"e2e": "concurrently \"npm run quick\" \"npm run cypress\"",
"backend": "json-server --watch db.json --routes routes.json --port 8627 --middlewares ./node_modules/json-server-reset"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:8627/",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"axios": "^1.7.9",
"bulma": "^0.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^8.1.3",
"react-router-dom": "^7.14.2",
"react-scripts": "5.0.1",
"redux": "^4.2.1",
"redux-saga": "^1.3.0",
"redux-thunk": "^2.4.2",
"sass": "^1.83.0"
},
"devDependencies": {
"concurrently": "^9.1.2",
"cypress": "^13.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.3",
"json-server": "^0.17.4",
"json-server-reset": "^1.6.0",
"prettier": "^3.4.2"
}
}
================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.4.1/css/all.css" integrity="sha384-5sAR7xN1Nv6T6+dT2mhtzEpVJvfS3NScPQTrOxhwjIuvcA67KV2R5Jz6kr4abQsz"
crossorigin="anonymous">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Heroes React</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="modal"></div>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
================================================
FILE: public/manifest.json
================================================
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
================================================
FILE: routes.json
================================================
{
"/api/*": "/$1",
"/hero": "/heroes",
"/reset": "/reset"
}
================================================
FILE: server.js
================================================
const express = require('express');
const app = express();
const port = process.env.PORT || 8626;
const publicweb = process.env.PUBLICWEB || './public';
const captains = console;
const start = () => {
app.use(express.static(publicweb));
captains.log(`serving ${publicweb}`);
app.get('*', (req, res) => {
res.sendFile(`index.html`, { root: publicweb });
});
app.listen(port, () => captains.log(`listening on http://localhost:${port}`));
};
start();
================================================
FILE: src/About.js
================================================
import React from 'react';
const About = () => (
<div className="content-container">
<div className="content-title-group not-found">
<h2 className="title">Tour of Heroes</h2>
<p>
This project was created to help represent a fundamental app written
with React. The heroes and villains theme is used throughout the app.
</p>
<p>
by
<a href="http://twitter.com/john_papa">John Papa</a>
</p>
<br />
<h2 className="title">Why</h2>
<p>
I love JavaScript and the Web! One of the most common questions I hear
is “which framework is best?”. I like to flip this around and ask you
“which is best for you?”. The best way to know this is to try it for
yourself. I'll follow up with some articles on my experiences with these
frameworks but in the meantime, please try it for yourself to gain your
own experience with each.
</p>
<br />
<h2 className="title">Comparative Apps</h2>
<ul>
<li>
<a href="https://github.com/johnpapa/heroes-angular">
github.com/johnpapa/heroes-angular
</a>
</li>
<li>
<a href="https://github.com/johnpapa/heroes-react">
github.com/johnpapa/heroes-react
</a>
</li>
<li>
<a href="https://github.com/johnpapa/heroes-vue">
github.com/johnpapa/heroes-vue
</a>
</li>
</ul>
<br />
<h2 className="title">Live Demos</h2>
<p>
Hosted in
<a href="https://aka.ms/jp-free">Azure</a>
</p>
<ul>
<li>
<a href="https://papa-heroes-angular.azurewebsites.net">
Tour of Heroes with Angular
</a>
</li>
<li>
<a href="https://papa-heroes-react.azurewebsites.net">
Tour of Heroes with React
</a>
</li>
<li>
<a href="https://papa-heroes-vue.azurewebsites.net">
Tour of Heroes with Vue
</a>
</li>
</ul>
</div>
</div>
);
export default About;
================================================
FILE: src/App.css
================================================
================================================
FILE: src/App.js
================================================
import React, { lazy, Suspense } from 'react';
import 'bulma/css/bulma.css';
import './styles.scss';
import { Navigate, Route, Routes } from 'react-router-dom';
import { HeaderBar, NavBar, NotFound } from './components';
import About from './About';
const Heroes = lazy(() => import(/* webpackChunkName: "heroes" */ './heroes/Heroes'));
const Villains = lazy(() => import(/* webpackChunkName: "villains" */ './villains/Villains'));
function App() {
return (
<div>
<HeaderBar />
<div className="section columns">
<NavBar />
<main className="column">
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Navigate to="/heroes" replace />} />
<Route path="/heroes/*" element={<Heroes />} />
<Route path="/villains/*" element={<Villains />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</main>
</div>
</div>
);
}
export default App;
================================================
FILE: src/components/ButtonFooter.js
================================================
import React from 'react';
const ButtonFooter = ({
label,
className,
iconClasses,
onClick,
dataIndex,
dataId
}) => {
return (
<button
className={'link card-footer-item ' + className}
aria-label={label}
tabIndex={0}
onClick={onClick}
data-index={dataIndex}
data-id={dataId}
>
<i className={iconClasses} />
<span>{label}</span>
</button>
);
};
export default ButtonFooter;
================================================
FILE: src/components/CardContent.js
================================================
import React from 'react';
const CardContent = ({ name, description }) => (
<div className="card-content">
<div className="content">
<div className="name">{name}</div>
<div className="description">{description}</div>
</div>
</div>
);
export default CardContent;
================================================
FILE: src/components/HeaderBar.js
================================================
import React from 'react';
import HeaderBarBrand from './HeaderBarBrand';
import HeaderBarLinks from './HeaderBarLinks';
const HeaderBar = () => (
<header>
<nav
className="navbar has-background-dark is-dark"
role="navigation"
aria-label="main navigation"
>
<HeaderBarBrand />
<HeaderBarLinks />
</nav>
</header>
);
export default HeaderBar;
================================================
FILE: src/components/HeaderBarBrand.js
================================================
import React from 'react';
import { NavLink } from 'react-router-dom';
const HeaderBarBrand = () => (
<div className="navbar-brand">
<a
className="navbar-item"
href="https://reactjs.org/"
target="_blank"
rel="noopener noreferrer"
>
<i className="fab js-logo fa-react fa-2x" aria-hidden="true" />
</a>
<NavLink to="/" className="navbar-item nav-home">
<span className="tour">TOUR</span>
<span className="of">OF</span>
<span className="heroes">HEROES</span>
</NavLink>
<button
className="link navbar-burger burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</button>
</div>
);
export default HeaderBarBrand;
================================================
FILE: src/components/HeaderBarLinks.js
================================================
import React from 'react';
const HeaderBarLinks = () => (
<div className="navbar-menu">
<div className="navbar-end">
<div className="navbar-item">
<div className="buttons">
<a
href="https://github.com/johnpapa/heroes-react"
target="_blank"
rel="noopener noreferrer"
>
<i className="fab fa-github fa-2x" aria-hidden="true" />
</a>
<a
href="https://twitter.com/john_papa"
target="_blank"
rel="noopener noreferrer"
>
<i className="fab fa-twitter fa-2x" aria-hidden="true" />
</a>
</div>
</div>
</div>
</div>
);
export default HeaderBarLinks;
================================================
FILE: src/components/InputDetail.js
================================================
import React from 'react';
const InputDetail = ({ name, value, placeholder, onChange, readOnly }) => (
<div className="field">
<label className="label" htmlFor={name}>
{name}
</label>
<input
name={name}
className="input"
type="text"
defaultValue={value}
placeholder={placeholder}
readOnly={!!readOnly}
onChange={onChange}
/>
</div>
);
export default InputDetail;
================================================
FILE: src/components/ListHeader.js
================================================
import React from 'react';
import { NavLink } from 'react-router-dom';
const ListHeader = ({ title, handleAdd, handleRefresh, routePath }) => {
return (
<div className="content-title-group">
<NavLink to={routePath}>
<h2 className="title">{title}</h2>
</NavLink>
<button
className="button add-button"
onClick={handleAdd}
aria-label="add"
>
<i className="fas fa-plus" aria-hidden="true" />
</button>
<button
className="button refresh-button"
onClick={handleRefresh}
aria-label="refresh"
>
<i className="fas fa-sync" aria-hidden="true" />
</button>
</div>
);
};
export default ListHeader;
================================================
FILE: src/components/Modal.js
================================================
import { Component } from 'react';
import { createPortal } from 'react-dom';
const modalRoot = document.getElementById('modal');
class Modal extends Component {
constructor(props) {
super(props);
this.el = document.createElement('div');
}
componentDidMount() {
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
modalRoot.removeChild(this.el);
}
render() {
return createPortal(this.props.children, this.el);
}
}
export default Modal;
================================================
FILE: src/components/ModalYesNo.js
================================================
import React from 'react';
import Modal from './Modal';
const ModalYesNo = ({ message, onYes, onNo }) => (
<Modal>
<div className="modal is-active">
<div className="modal-background" />
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">Confirm</p>
</header>
<section className="modal-card-body">{message}</section>
<footer className="modal-card-foot card-footer">
<button className="button modal-no" onClick={onNo}>
No
</button>
<button className="button is-primary modal-yes" onClick={onYes}>
Yes
</button>
</footer>
</div>
</div>
</Modal>
);
export default ModalYesNo;
================================================
FILE: src/components/NavBar.js
================================================
import React from 'react';
import { NavLink } from 'react-router-dom';
const NavBar = props => (
<nav className="column is-2 menu">
<p className="menu-label">Menu</p>
<ul className="menu-list">
<NavLink to="/heroes" className={({ isActive }) => isActive ? 'active-link' : ''}>
Heroes
</NavLink>
<NavLink to="/villains" className={({ isActive }) => isActive ? 'active-link' : ''}>
Villains
</NavLink>
<NavLink to="/about" className={({ isActive }) => isActive ? 'active-link' : ''}>
About
</NavLink>
</ul>
{props.children}
</nav>
);
export default NavBar;
================================================
FILE: src/components/NotFound.js
================================================
import React from 'react';
const NotFound = () => (
<div className="content-container">
<div className="content-title-group not-found">
<i className="fas fa-exclamation-triangle" aria-hidden="true" />
<span className="title">{`These aren't the bits you're looking for`}</span>
</div>
</div>
);
export default NotFound;
================================================
FILE: src/components/index.js
================================================
import ButtonFooter from './ButtonFooter';
import CardContent from './CardContent';
import HeaderBar from './HeaderBar';
import InputDetail from './InputDetail';
import ListHeader from './ListHeader';
import ModalYesNo from './ModalYesNo';
import NavBar from './NavBar';
import NotFound from './NotFound';
export {
ButtonFooter,
CardContent,
HeaderBar,
InputDetail,
ListHeader,
NavBar,
NotFound,
ModalYesNo
};
================================================
FILE: src/heroes/HeroDetail.js
================================================
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ButtonFooter, InputDetail } from '../components';
function HeroDetail({
hero: initHero,
handleCancelHero,
handleSaveHero,
}) {
const navigate = useNavigate();
const [hero, setHero] = useState(Object.assign({}, initHero));
useEffect(() => {
if (!hero) {
navigate('/'); // no hero, bail out of Details
}
}, [hero, navigate]);
function handleSave() {
const chgHero = { ...hero, id: hero.id || null };
handleSaveHero(chgHero);
}
function handleNameChange(e) {
setHero({ ...hero, name: e.target.value });
}
function handleDescriptionChange(e) {
setHero({ ...hero, description: e.target.value });
}
return (
<div className="card edit-detail">
<header className="card-header">
<p className="card-header-title">
{hero.name}
</p>
</header>
<div className="card-content">
<div className="content">
{hero.id && <InputDetail name="id" value={hero.id} readOnly="true" />}
<InputDetail
name="name"
value={hero.name}
placeholder="e.g Colleen"
onChange={handleNameChange}
/>
<InputDetail
name="description"
value={hero.description}
placeholder="e.g dance fight!"
onChange={handleDescriptionChange}
/>
</div>
</div>
<footer className="card-footer ">
<ButtonFooter
className="cancel-button"
iconClasses="fas fa-undo"
onClick={handleCancelHero}
label="Cancel"
/>
<ButtonFooter
className="save-button"
iconClasses="fas fa-save"
onClick={handleSave}
label="Save"
/>
</footer>
</div>
);
}
export default HeroDetail;
================================================
FILE: src/heroes/HeroList.js
================================================
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ButtonFooter, CardContent } from '../components';
function HeroList({ handleDeleteHero, handleSelectHero, heroes }) {
const navigate = useNavigate();
function selectHero(e) {
const hero = getSelectedHero(e);
handleSelectHero(hero);
navigate(`/heroes/${hero.id}`);
}
function deleteHero(e) {
const hero = getSelectedHero(e);
handleDeleteHero(hero);
}
function getSelectedHero(e) {
const index = +e.currentTarget.dataset.index;
return heroes[index];
}
return (
<ul className="list">
{heroes.map((hero, index) => (
<li key={hero.id} role="presentation">
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
className="delete-item"
iconClasses="fas fa-trash"
onClick={deleteHero}
label="Delete"
dataIndex={index}
dataId={hero.id}
/>
<ButtonFooter
className="edit-item"
iconClasses="fas fa-edit"
onClick={selectHero}
label="Edit"
dataIndex={index}
dataId={hero.id}
/>
</footer>
</div>
</li>
))}
</ul>
);
}
export default HeroList;
================================================
FILE: src/heroes/Heroes.js
================================================
import React, { useEffect, useState } from 'react';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { ListHeader, ModalYesNo } from '../components';
import HeroDetail from './HeroDetail';
import HeroList from './HeroList';
import useHeroes from './useHeroes';
const captains = console;
function Heroes() {
const navigate = useNavigate();
const [heroToDelete, setHeroToDelete] = useState(null);
const [showModal, setShowModal] = useState(false);
const {
addHero,
deleteHero,
getHeroes,
heroes,
selectHero,
selectedHero,
updateHero
} = useHeroes();
useEffect(() => {
getHeroes();
}, [getHeroes]);
function addNewHero() {
selectHero({});
navigate('/heroes/0');
}
function handleCancelHero() {
navigate('/');
selectHero(null);
setHeroToDelete(null);
}
function handleDeleteHero(hero) {
selectHero(null);
setHeroToDelete(hero);
setShowModal(true);
}
function handleSaveHero(hero) {
if (selectedHero && selectedHero.name) {
captains.log(hero);
updateHero(hero);
} else {
addHero(hero);
}
handleCancelHero();
}
function handleCloseModal() {
setShowModal(false);
}
function handleDeleteFromModal() {
setShowModal(false);
deleteHero(heroToDelete);
handleCancelHero();
}
function handleSelectHero(selectedHero) {
selectHero(selectedHero);
captains.log(`you selected ${selectedHero.name}`);
}
function handleRefresh() {
handleCancelHero();
getHeroes();
}
return (
<div className="content-container">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
routePath="/heroes"
/>
<div className="columns is-multiline is-variable">
<div className="column is-8">
<Routes>
<Route
index
element={
<HeroList
heroes={heroes}
selectedHero={selectedHero}
handleSelectHero={handleSelectHero}
handleDeleteHero={handleDeleteHero}
/>
}
/>
<Route
path=":id"
element={
<HeroDetail
hero={selectedHero}
handleCancelHero={handleCancelHero}
handleSaveHero={handleSaveHero}
/>
}
/>
</Routes>
</div>
</div>
{showModal && (
<ModalYesNo
message={`Would you like to delete ${heroToDelete.name}?`}
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
export default Heroes;
================================================
FILE: src/heroes/useHeroes.js
================================================
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
addHeroAction,
deleteHeroAction,
loadHeroesAction,
selectHeroAction,
updateHeroAction
} from '../store';
/** Custom hook for accessing Hero state in redux store */
function useHeroes() {
const dispatch = useDispatch();
return {
// Selectors
heroes: useSelector(state => state.heroes.data),
selectedHero: useSelector(state => state.selectedHero),
// Dispatchers
// Wrap any dispatcher that could be called within a useEffect() in a useCallback()
addHero: hero => dispatch(addHeroAction(hero)),
deleteHero: hero => dispatch(deleteHeroAction(hero)),
getHeroes: useCallback(() => dispatch(loadHeroesAction()), [dispatch]), // called within a useEffect()
selectHero: hero => dispatch(selectHeroAction(hero)),
updateHero: hero => dispatch(updateHeroAction(hero))
};
}
export default useHeroes;
================================================
FILE: src/index.css
================================================
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
================================================
FILE: src/index.js
================================================
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import App from './App';
import './index.css';
import app, { heroSaga, villainSaga } from './store';
// create and configure redux middleware (saga is a middleware)
const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
app,
composeEnhancers(applyMiddleware(sagaMiddleware))
);
sagaMiddleware.run(heroSaga);
sagaMiddleware.run(villainSaga);
const root = createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
================================================
FILE: src/serviceWorker.js
================================================
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read http://bit.ly/CRA-PWA.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit http://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}
================================================
FILE: src/store/action-utils.js
================================================
export const parseList = response => {
if (response.status !== 200) throw Error(response.message);
let list = response.data;
if (typeof list !== 'object') {
list = [];
}
return list;
};
export const parseItem = (response, code) => {
if (response.status !== code) throw Error(response.message);
let item = response.data;
if (typeof item !== 'object') {
item = undefined;
}
return item;
};
================================================
FILE: src/store/config.js
================================================
const API = process.env.REACT_APP_API;
export { API as default };
================================================
FILE: src/store/hero.actions.js
================================================
export const LOAD_HERO = '[Heroes] LOAD_HERO';
export const LOAD_HERO_SUCCESS = '[Heroes] LOAD_HERO_SUCCESS';
export const LOAD_HERO_ERROR = '[Heroes] LOAD_HERO_ERROR';
export const UPDATE_HERO = '[Heroes] UPDATE_HERO';
export const UPDATE_HERO_SUCCESS = '[Heroes] UPDATE_HERO_SUCCESS';
export const UPDATE_HERO_ERROR = '[Heroes] UPDATE_HERO_ERROR';
export const DELETE_HERO = '[Heroes] DELETE_HERO';
export const DELETE_HERO_SUCCESS = '[Heroes] DELETE_HERO_SUCCESS';
export const DELETE_HERO_ERROR = '[Heroes] DELETE_HERO_ERROR';
export const ADD_HERO = '[Heroes] ADD_HERO';
export const ADD_HERO_SUCCESS = '[Heroes] ADD_HERO_SUCCESS';
export const ADD_HERO_ERROR = '[Heroes] ADD_HERO_ERROR';
export const SELECT_HERO = '[Hero] SELECT_HERO';
export const selectHeroAction = hero => ({ type: SELECT_HERO, payload: hero });
export const loadHeroesAction = () => ({ type: LOAD_HERO });
export const updateHeroAction = hero => ({ type: UPDATE_HERO, payload: hero });
export const deleteHeroAction = hero => ({ type: DELETE_HERO, payload: hero });
export const addHeroAction = hero => ({ type: ADD_HERO, payload: hero });
================================================
FILE: src/store/hero.api.js
================================================
import axios from 'axios';
import { parseItem, parseList } from './action-utils';
import API from './config';
const captains = console;
export const deleteHeroApi = async hero => {
const response = await axios.delete(`${API}/heroes/${hero.id}`);
return parseItem(response, 200);
};
export const updateHeroApi = async hero => {
captains.log(hero.id);
const response = await axios.put(`${API}/heroes/${hero.id}`, hero);
return parseItem(response, 200);
};
export const addHeroApi = async hero => {
const response = await axios.post(`${API}/heroes`, hero);
return parseItem(response, 201);
};
export const loadHeroesApi = async () => {
const response = await axios.get(`${API}/heroes`);
return parseList(response, 200);
};
================================================
FILE: src/store/hero.reducer.js
================================================
import {
SELECT_HERO,
LOAD_HERO_SUCCESS,
LOAD_HERO,
LOAD_HERO_ERROR,
UPDATE_HERO,
UPDATE_HERO_SUCCESS,
UPDATE_HERO_ERROR,
DELETE_HERO,
DELETE_HERO_SUCCESS,
DELETE_HERO_ERROR,
ADD_HERO,
ADD_HERO_SUCCESS,
ADD_HERO_ERROR
} from './hero.actions';
let initState = {
loading: false,
data: [],
error: void 0
};
export const heroesReducer = (state = initState, action) => {
switch (action.type) {
case LOAD_HERO:
return { ...state, loading: true, error: '' };
case LOAD_HERO_SUCCESS:
return { ...state, loading: false, data: [...action.payload] };
case LOAD_HERO_ERROR:
return { ...state, loading: false, error: action.payload };
case UPDATE_HERO:
return {
...state,
data: state.data.map(h => {
if (h.id === action.payload.id) {
state.loading = true;
}
return h;
})
};
case UPDATE_HERO_SUCCESS:
return modifyHeroState(state, action.payload);
case UPDATE_HERO_ERROR:
return { ...state, loading: false, error: action.payload };
case DELETE_HERO: {
return {
...state,
loading: true,
data: state.data.filter(h => h !== action.payload)
};
}
case DELETE_HERO_SUCCESS: {
const result = { ...state, loading: false };
return result;
}
case DELETE_HERO_ERROR: {
return {
...state,
data: [...state.data, action.payload.requestData],
loading: false
};
}
case ADD_HERO: {
return { ...state, loading: true };
}
case ADD_HERO_SUCCESS: {
return {
...state,
loading: false,
data: [...state.data, { ...action.payload }]
};
}
case ADD_HERO_ERROR: {
return { ...state, loading: false };
}
default:
return state;
}
};
const modifyHeroState = (heroState, heroChanges) => {
return {
...heroState,
loading: false,
data: heroState.data.map(h => {
if (h.id === heroChanges.id) {
return { ...h, ...heroChanges };
} else {
return h;
}
})
};
};
let initialSelectedHero = null;
export const selectedHeroReducer = (state = initialSelectedHero, action) => {
switch (action.type) {
case SELECT_HERO:
return action.payload ? { ...action.payload } : null;
default:
return state;
}
};
================================================
FILE: src/store/hero.saga.js
================================================
import { put, takeEvery, call, all } from 'redux-saga/effects';
import {
LOAD_HERO,
LOAD_HERO_SUCCESS,
LOAD_HERO_ERROR,
UPDATE_HERO,
UPDATE_HERO_SUCCESS,
UPDATE_HERO_ERROR,
DELETE_HERO,
DELETE_HERO_SUCCESS,
DELETE_HERO_ERROR,
ADD_HERO,
ADD_HERO_SUCCESS,
ADD_HERO_ERROR
} from './hero.actions';
import {
addHeroApi,
deleteHeroApi,
loadHeroesApi,
updateHeroApi
} from './hero.api';
// Our worker Saga: will perform the async increment task
export function* loadingHeroesAsync() {
try {
const data = yield call(loadHeroesApi);
const heroes = [...data];
yield put({ type: LOAD_HERO_SUCCESS, payload: heroes });
} catch (err) {
yield put({ type: LOAD_HERO_ERROR, payload: err.message });
}
}
// Our watcher Saga: spawn a new incrementAsync task on each INCREMENT_ASYNC
export function* watchLoadingHeroesAsync() {
yield takeEvery(LOAD_HERO, loadingHeroesAsync);
}
export function* updatingHeroAsync({ payload }) {
try {
const data = yield call(updateHeroApi, payload);
const updatedHero = data;
yield put({ type: UPDATE_HERO_SUCCESS, payload: updatedHero });
} catch (err) {
yield put({ type: UPDATE_HERO_ERROR, payload: err.message });
}
}
export function* watchUpdatingHeroAsync() {
yield takeEvery(UPDATE_HERO, updatingHeroAsync);
}
export function* deletingHeroAsync({ payload }) {
try {
yield call(deleteHeroApi, payload);
yield put({ type: DELETE_HERO_SUCCESS, payload: null });
} catch (err) {
yield put({ type: DELETE_HERO_ERROR, payload: err.message });
}
}
export function* watchDeletingHeroAsync() {
yield takeEvery(DELETE_HERO, deletingHeroAsync);
}
export function* addingHeroAsync({ payload }) {
try {
const data = yield call(addHeroApi, payload);
const addedHero = data;
yield put({ type: ADD_HERO_SUCCESS, payload: addedHero });
} catch (err) {
yield put({ type: ADD_HERO_ERROR, payload: err.message });
}
}
export function* watchAddingHeroAsync() {
yield takeEvery(ADD_HERO, addingHeroAsync);
}
export function* heroSaga() {
yield all([
watchLoadingHeroesAsync(),
watchUpdatingHeroAsync(),
watchDeletingHeroAsync(),
watchAddingHeroAsync()
]);
}
================================================
FILE: src/store/index.js
================================================
import { combineReducers } from 'redux';
import { heroesReducer, selectedHeroReducer } from './hero.reducer';
import { selectedVillainReducer, villainsReducer } from './villain.reducer';
export * from './hero.actions';
export * from './hero.reducer';
export * from './hero.saga';
export * from './villain.actions';
export * from './villain.reducer';
export * from './villain.saga';
const store = combineReducers({
villains: villainsReducer,
heroes: heroesReducer,
selectedHero: selectedHeroReducer,
selectedVillain: selectedVillainReducer
});
export default store;
================================================
FILE: src/store/villain.actions.js
================================================
export const LOAD_VILLAIN = '[Villains] LOAD_VILLAIN';
export const LOAD_VILLAIN_SUCCESS = '[Villains] LOAD_VILLAIN_SUCCESS';
export const LOAD_VILLAIN_ERROR = '[Villains] LOAD_VILLAIN_ERROR';
export const UPDATE_VILLAIN = '[Villains] UPDATE_VILLAIN';
export const UPDATE_VILLAIN_SUCCESS = '[Villains] UPDATE_VILLAIN_SUCCESS';
export const UPDATE_VILLAIN_ERROR = '[Villains] UPDATE_VILLAIN_ERROR';
export const DELETE_VILLAIN = '[Villains] DELETE_VILLAIN';
export const DELETE_VILLAIN_SUCCESS = '[Villains] DELETE_VILLAIN_SUCCESS';
export const DELETE_VILLAIN_ERROR = '[Villains] DELETE_VILLAIN_ERROR';
export const ADD_VILLAIN = '[Villains] ADD_VILLAIN';
export const ADD_VILLAIN_SUCCESS = '[Villains] ADD_VILLAIN_SUCCESS';
export const ADD_VILLAIN_ERROR = '[Villains] ADD_VILLAIN_ERROR';
export const SELECT_VILLAIN = '[Villain] SELECT_VILLAIN';
export const selectVillainAction = villain => ({
type: SELECT_VILLAIN,
payload: villain
});
export const loadVillainsAction = () => ({ type: LOAD_VILLAIN });
export const updateVillainAction = villain => ({
type: UPDATE_VILLAIN,
payload: villain
});
export const deleteVillainAction = villain => ({
type: DELETE_VILLAIN,
payload: villain
});
export const addVillainAction = villain => ({
type: ADD_VILLAIN,
payload: villain
});
================================================
FILE: src/store/villain.api.js
================================================
import axios from 'axios';
import { parseItem, parseList } from './action-utils';
import API from './config';
const captains = console;
export const deleteVillainApi = async villain => {
const response = await axios.delete(`${API}/villains/${villain.id}`);
return parseItem(response, 200);
};
export const updateVillainApi = async villain => {
captains.log(villain.id);
const response = await axios.put(`${API}/villains/${villain.id}`, villain);
return parseItem(response, 200);
};
export const addVillainApi = async villain => {
const response = await axios.post(`${API}/villains`, villain);
return parseItem(response, 201);
};
export const loadVillainsApi = async () => {
const response = await axios.get(`${API}/villains`);
return parseList(response, 200);
};
================================================
FILE: src/store/villain.reducer.js
================================================
import {
SELECT_VILLAIN,
LOAD_VILLAIN_SUCCESS,
LOAD_VILLAIN,
LOAD_VILLAIN_ERROR,
UPDATE_VILLAIN,
UPDATE_VILLAIN_SUCCESS,
UPDATE_VILLAIN_ERROR,
DELETE_VILLAIN,
DELETE_VILLAIN_SUCCESS,
DELETE_VILLAIN_ERROR,
ADD_VILLAIN,
ADD_VILLAIN_SUCCESS,
ADD_VILLAIN_ERROR
} from './villain.actions';
let initState = {
loading: false,
data: [],
error: void 0
};
export const villainsReducer = (state = initState, action) => {
switch (action.type) {
case LOAD_VILLAIN:
return { ...state, loading: true, error: '' };
case LOAD_VILLAIN_SUCCESS:
return { ...state, loading: false, data: [...action.payload] };
case LOAD_VILLAIN_ERROR:
return { ...state, loading: false, error: action.payload };
case UPDATE_VILLAIN:
return {
...state,
data: state.data.map(h => {
if (h.id === action.payload.id) {
state.loading = true;
}
return h;
})
};
case UPDATE_VILLAIN_SUCCESS:
return modifyVillainState(state, action.payload);
case UPDATE_VILLAIN_ERROR:
return { ...state, loading: false, error: action.payload };
case DELETE_VILLAIN: {
return {
...state,
loading: true,
data: state.data.filter(h => h !== action.payload)
};
}
case DELETE_VILLAIN_SUCCESS: {
const result = { ...state, loading: false };
return result;
}
case DELETE_VILLAIN_ERROR: {
return {
...state,
data: [...state.data, action.payload.requestData],
loading: false
};
}
case ADD_VILLAIN: {
return { ...state, loading: true };
}
case ADD_VILLAIN_SUCCESS: {
return {
...state,
loading: false,
data: [...state.data, { ...action.payload }]
};
}
case ADD_VILLAIN_ERROR: {
return { ...state, loading: false };
}
default:
return state;
}
};
const modifyVillainState = (villainState, villainChanges) => {
return {
...villainState,
loading: false,
data: villainState.data.map(h => {
if (h.id === villainChanges.id) {
return { ...h, ...villainChanges };
} else {
return h;
}
})
};
};
let initialSelectedVillain = null;
export const selectedVillainReducer = (
state = initialSelectedVillain,
action
) => {
switch (action.type) {
case SELECT_VILLAIN:
return action.payload ? { ...action.payload } : null;
default:
return state;
}
};
================================================
FILE: src/store/villain.saga.js
================================================
import { put, takeEvery, call, all } from 'redux-saga/effects';
import {
LOAD_VILLAIN,
LOAD_VILLAIN_SUCCESS,
LOAD_VILLAIN_ERROR,
UPDATE_VILLAIN,
UPDATE_VILLAIN_SUCCESS,
UPDATE_VILLAIN_ERROR,
DELETE_VILLAIN,
DELETE_VILLAIN_SUCCESS,
DELETE_VILLAIN_ERROR,
ADD_VILLAIN,
ADD_VILLAIN_SUCCESS,
ADD_VILLAIN_ERROR
} from './villain.actions';
import {
addVillainApi,
deleteVillainApi,
loadVillainsApi,
updateVillainApi
} from './villain.api';
export function* loadingVillainsAsync() {
try {
const data = yield call(loadVillainsApi);
const villaines = [...data];
yield put({ type: LOAD_VILLAIN_SUCCESS, payload: villaines });
} catch (err) {
yield put({ type: LOAD_VILLAIN_ERROR, payload: err.message });
}
}
export function* watchLoadingVillainsAsync() {
yield takeEvery(LOAD_VILLAIN, loadingVillainsAsync);
}
export function* updatingVillainAsync({ payload }) {
try {
const data = yield call(updateVillainApi, payload);
const updatedVillain = data;
yield put({ type: UPDATE_VILLAIN_SUCCESS, payload: updatedVillain });
} catch (err) {
yield put({ type: UPDATE_VILLAIN_ERROR, payload: err.message });
}
}
export function* watchUpdatingVillainAsync() {
yield takeEvery(UPDATE_VILLAIN, updatingVillainAsync);
}
export function* deletingVillainAsync({ payload }) {
try {
yield call(deleteVillainApi, payload);
yield put({ type: DELETE_VILLAIN_SUCCESS, payload: null });
} catch (err) {
yield put({ type: DELETE_VILLAIN_ERROR, payload: err.message });
}
}
export function* watchDeletingVillainAsync() {
yield takeEvery(DELETE_VILLAIN, deletingVillainAsync);
}
export function* addingVillainAsync({ payload }) {
try {
const data = yield call(addVillainApi, payload);
const addedVillain = data;
yield put({ type: ADD_VILLAIN_SUCCESS, payload: addedVillain });
} catch (err) {
yield put({ type: ADD_VILLAIN_ERROR, payload: err.message });
}
}
export function* watchAddingVillainAsync() {
yield takeEvery(ADD_VILLAIN, addingVillainAsync);
}
export function* villainSaga() {
yield all([
watchLoadingVillainsAsync(),
watchUpdatingVillainAsync(),
watchDeletingVillainAsync(),
watchAddingVillainAsync()
]);
}
================================================
FILE: src/styles.scss
================================================
$vue: #42b883;
$vue-light: #42b883;
$angular: #b52e31;
$angular-light: #eb7a7c;
$react: #00b3e6;
$react-light: #61dafb;
$primary: $react;
$primary-light: $react-light;
$link: $primary; // #00b3e6; // #ff4081;
$shade-light: #fafafa;
@import 'bulma/bulma.sass';
.menu-list .active-link,
.menu-list .router-link-active {
color: #fff;
background-color: $link;
}
.not-found {
i {
font-size: 20px;
margin-right: 8px;
}
.title {
letter-spacing: 0px;
font-weight: normal;
font-size: 24px;
text-transform: none;
}
}
header {
font-weight: bold;
font-family: Arial;
span {
letter-spacing: 0px;
&.tour {
color: #fff;
}
&.of {
color: #ccc;
}
&.heroes {
color: $primary-light;
}
}
.navbar-item.nav-home {
border: 3px solid transparent;
border-radius: 0%;
&:hover {
border-right: 3px solid $primary-light;
border-left: 3px solid $primary-light;
}
}
.fab {
font-size: 24px;
&.js-logo {
color: $primary-light;
}
}
.buttons {
i.fab {
color: #fff;
margin-left: 20px;
margin-right: 10px;
&:hover {
color: $primary-light;
}
}
}
}
.edit-detail {
.input[readonly] {
background-color: $shade-light;
}
}
.content-title-group {
margin-bottom: 16px;
h2 {
border-left: 16px solid $primary;
border-bottom: 2px solid $primary;
padding-left: 8px;
padding-right: 16px;
display: inline-block;
text-transform: uppercase;
color: #555;
letter-spacing: 0px;
&:hover {
color: $link;
}
}
button.button {
border: 0;
color: #999;
&:hover {
color: $link;
}
}
}
ul.list {
box-shadow: none;
}
div.card-content {
background-color: $shade-light;
.name {
font-size: 28px;
color: #000;
}
.description {
font-size: 20px;
color: #999;
}
background-color: $shade-light;
}
.card {
margin-bottom: 2em;
}
label.label {
font-weight: normal;
}
p.card-header-title {
background-color: $primary;
text-transform: uppercase;
letter-spacing: 4px;
color: #fff;
display: block;
padding-left: 24px;
}
.card-footer button {
font-size: 16px;
i {
margin-right: 10px;
}
color: #888;
&:hover {
color: $link;
}
}
.modal-card-foot button {
display: inline-block;
width: 80px;
}
.modal-card-head,
.modal-card-body {
text-align: center;
}
.field {
margin-bottom: 0.75rem;
}
.navbar-burger {
margin-left: auto;
}
button.link {
background: none;
border: none;
cursor: pointer;
}
================================================
FILE: src/villains/VillainDetail.js
================================================
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ButtonFooter, InputDetail } from '../components';
function VillainDetail({
villain: initVillain,
handleCancelVillain,
handleSaveVillain,
}) {
const navigate = useNavigate();
const [villain, setVillain] = useState(Object.assign({}, initVillain));
useEffect(() => {
if (!villain) {
navigate('/villains'); // no villain, bail out of Details
}
}, [villain, navigate]);
function handleSave() {
const chgVillain = { ...villain, id: villain.id || null };
handleSaveVillain(chgVillain);
}
function handleNameChange(e) {
setVillain({ ...villain, name: e.target.value });
}
function handleDescriptionChange(e) {
setVillain({ ...villain, description: e.target.value });
}
return (
<div className="card edit-detail">
<header className="card-header">
<p className="card-header-title">
{villain.name}
</p>
</header>
<div className="card-content">
<div className="content">
{villain.id && (
<InputDetail name="id" value={villain.id} readOnly="true" />
)}
<InputDetail
name="name"
value={villain.name}
placeholder="e.g Colleen"
onChange={handleNameChange}
/>
<InputDetail
name="description"
value={villain.description}
placeholder="e.g dance fight!"
onChange={handleDescriptionChange}
/>
</div>
</div>
<footer className="card-footer ">
<ButtonFooter
className="cancel-button"
iconClasses="fas fa-undo"
onClick={handleCancelVillain}
label="Cancel"
/>
<ButtonFooter
className="save-button"
iconClasses="fas fa-save"
onClick={handleSave}
label="Save"
/>
</footer>
</div>
);
}
export default VillainDetail;
================================================
FILE: src/villains/VillainList.js
================================================
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ButtonFooter, CardContent } from '../components';
function VillainList({
handleDeleteVillain,
handleSelectVillain,
villains,
}) {
const navigate = useNavigate();
function selectVillain(e) {
const villain = getSelectedVillain(e);
handleSelectVillain(villain);
navigate(`/villains/${villain.id}`);
}
function deleteVillain(e) {
const villain = getSelectedVillain(e);
handleDeleteVillain(villain);
}
function getSelectedVillain(e) {
const index = +e.currentTarget.dataset.index;
return villains[index];
}
return (
<ul className="list">
{villains.map((villain, index) => (
<li key={villain.id} role="presentation">
<div className="card">
<CardContent
name={villain.name}
description={villain.description}
/>
<footer className="card-footer">
<ButtonFooter
className="delete-item"
iconClasses="fas fa-trash"
onClick={deleteVillain}
label="Delete"
dataIndex={index}
dataId={villain.id}
/>
<ButtonFooter
className="edit-item"
iconClasses="fas fa-edit"
onClick={selectVillain}
label="Edit"
dataIndex={index}
dataId={villain.id}
/>
</footer>
</div>
</li>
))}
</ul>
);
}
export default VillainList;
================================================
FILE: src/villains/Villains.js
================================================
import React, { useEffect, useState } from 'react';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { ListHeader, ModalYesNo } from '../components';
import VillainDetail from './VillainDetail';
import VillainList from './VillainList';
import useVillains from './useVillains';
const captains = console;
function Villains() {
const navigate = useNavigate();
const [villainToDelete, setVillainToDelete] = useState(null);
const [showModal, setShowModal] = useState(false);
const {
addVillain,
deleteVillain,
getVillains,
villains,
selectVillain,
selectedVillain,
updateVillain
} = useVillains();
useEffect(() => {
getVillains();
}, [getVillains]);
function addNewVillain() {
selectVillain({});
navigate('/villains/0');
}
function handleCancelVillain() {
navigate('/villains');
selectVillain(null);
setVillainToDelete(null);
}
function handleDeleteVillain(villain) {
selectVillain(null);
setVillainToDelete(villain);
setShowModal(true);
}
function handleSaveVillain(villain) {
if (selectedVillain && selectedVillain.name) {
captains.log(villain);
updateVillain(villain);
} else {
addVillain(villain);
}
handleCancelVillain();
}
function handleCloseModal() {
setShowModal(false);
}
function handleDeleteFromModal() {
setShowModal(false);
deleteVillain(villainToDelete);
handleCancelVillain();
}
function handleSelectVillain(selectedVillain) {
selectVillain(selectedVillain);
captains.log(`you selected ${selectedVillain.name}`);
}
function handleRefresh() {
handleCancelVillain();
getVillains();
}
return (
<div className="content-container">
<ListHeader
title="Villains"
handleAdd={addNewVillain}
handleRefresh={handleRefresh}
routePath="/villains"
/>
<div className="columns is-multiline is-variable">
<div className="column is-8">
<Routes>
<Route
index
element={
<VillainList
villains={villains}
selectedVillain={selectedVillain}
handleSelectVillain={handleSelectVillain}
handleDeleteVillain={handleDeleteVillain}
/>
}
/>
<Route
path=":id"
element={
<VillainDetail
villain={selectedVillain}
handleCancelVillain={handleCancelVillain}
handleSaveVillain={handleSaveVillain}
/>
}
/>
</Routes>
</div>
</div>
{showModal && (
<ModalYesNo
message={`Would you like to delete ${villainToDelete.name}?`}
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
export default Villains;
================================================
FILE: src/villains/useVillains.js
================================================
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
addVillainAction,
deleteVillainAction,
loadVillainsAction,
selectVillainAction,
updateVillainAction
} from '../store';
/** Custom hook for accessing Villain state in redux store */
function useVillains() {
const dispatch = useDispatch();
return {
// Selectors
villains: useSelector(state => state.villains.data),
selectedVillain: useSelector(state => state.selectedVillain),
// Dispatchers
// Wrap any dispatcher that could be called within a useEffect() in a useCallback()
addVillain: villain => dispatch(addVillainAction(villain)),
deleteVillain: villain => dispatch(deleteVillainAction(villain)),
getVillains: useCallback(() => dispatch(loadVillainsAction()), [dispatch]), // called within a useEffect()
selectVillain: villain => dispatch(selectVillainAction(villain)),
updateVillain: villain => dispatch(updateVillainAction(villain))
};
}
export default useVillains;
gitextract_voez_lfk/
├── .dockerignore
├── .eslintrc.json
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.yml
│ │ └── feature-request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── ci.yml
│ └── copilot-setup-steps.yml
├── .gitignore
├── .prettierrc
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── AGENTS.md
├── CHANGELOG.md
├── Dockerfile
├── README.md
├── cypress/
│ ├── fixtures/
│ │ └── example.json
│ ├── integration/
│ │ └── heroes.spec.js
│ ├── plugins/
│ │ └── index.js
│ └── support/
│ ├── commands.js
│ └── index.js
├── cypress.json
├── db.js
├── db.json
├── docker-compose.debug.yml
├── docker-compose.yml
├── package.json
├── public/
│ ├── index.html
│ └── manifest.json
├── routes.json
├── server.js
└── src/
├── About.js
├── App.css
├── App.js
├── components/
│ ├── ButtonFooter.js
│ ├── CardContent.js
│ ├── HeaderBar.js
│ ├── HeaderBarBrand.js
│ ├── HeaderBarLinks.js
│ ├── InputDetail.js
│ ├── ListHeader.js
│ ├── Modal.js
│ ├── ModalYesNo.js
│ ├── NavBar.js
│ ├── NotFound.js
│ └── index.js
├── heroes/
│ ├── HeroDetail.js
│ ├── HeroList.js
│ ├── Heroes.js
│ └── useHeroes.js
├── index.css
├── index.js
├── serviceWorker.js
├── store/
│ ├── action-utils.js
│ ├── config.js
│ ├── hero.actions.js
│ ├── hero.api.js
│ ├── hero.reducer.js
│ ├── hero.saga.js
│ ├── index.js
│ ├── villain.actions.js
│ ├── villain.api.js
│ ├── villain.reducer.js
│ └── villain.saga.js
├── styles.scss
└── villains/
├── VillainDetail.js
├── VillainList.js
├── Villains.js
└── useVillains.js
SYMBOL INDEX (45 symbols across 14 files)
FILE: src/App.js
function App (line 11) | function App() {
FILE: src/components/Modal.js
class Modal (line 6) | class Modal extends Component {
method constructor (line 7) | constructor(props) {
method componentDidMount (line 12) | componentDidMount() {
method componentWillUnmount (line 16) | componentWillUnmount() {
method render (line 20) | render() {
FILE: src/heroes/HeroDetail.js
function HeroDetail (line 6) | function HeroDetail({
FILE: src/heroes/HeroList.js
function HeroList (line 6) | function HeroList({ handleDeleteHero, handleSelectHero, heroes }) {
FILE: src/heroes/Heroes.js
function Heroes (line 11) | function Heroes() {
FILE: src/heroes/useHeroes.js
function useHeroes (line 13) | function useHeroes() {
FILE: src/serviceWorker.js
function register (line 23) | function register(config) {
function registerValidSW (line 57) | function registerValidSW(swUrl, config) {
function checkValidServiceWorker (line 98) | function checkValidServiceWorker(swUrl, config) {
function unregister (line 125) | function unregister() {
FILE: src/store/config.js
constant API (line 1) | const API = process.env.REACT_APP_API;
FILE: src/store/hero.actions.js
constant LOAD_HERO (line 1) | const LOAD_HERO = '[Heroes] LOAD_HERO';
constant LOAD_HERO_SUCCESS (line 2) | const LOAD_HERO_SUCCESS = '[Heroes] LOAD_HERO_SUCCESS';
constant LOAD_HERO_ERROR (line 3) | const LOAD_HERO_ERROR = '[Heroes] LOAD_HERO_ERROR';
constant UPDATE_HERO (line 5) | const UPDATE_HERO = '[Heroes] UPDATE_HERO';
constant UPDATE_HERO_SUCCESS (line 6) | const UPDATE_HERO_SUCCESS = '[Heroes] UPDATE_HERO_SUCCESS';
constant UPDATE_HERO_ERROR (line 7) | const UPDATE_HERO_ERROR = '[Heroes] UPDATE_HERO_ERROR';
constant DELETE_HERO (line 9) | const DELETE_HERO = '[Heroes] DELETE_HERO';
constant DELETE_HERO_SUCCESS (line 10) | const DELETE_HERO_SUCCESS = '[Heroes] DELETE_HERO_SUCCESS';
constant DELETE_HERO_ERROR (line 11) | const DELETE_HERO_ERROR = '[Heroes] DELETE_HERO_ERROR';
constant ADD_HERO (line 13) | const ADD_HERO = '[Heroes] ADD_HERO';
constant ADD_HERO_SUCCESS (line 14) | const ADD_HERO_SUCCESS = '[Heroes] ADD_HERO_SUCCESS';
constant ADD_HERO_ERROR (line 15) | const ADD_HERO_ERROR = '[Heroes] ADD_HERO_ERROR';
constant SELECT_HERO (line 17) | const SELECT_HERO = '[Hero] SELECT_HERO';
FILE: src/store/villain.actions.js
constant LOAD_VILLAIN (line 1) | const LOAD_VILLAIN = '[Villains] LOAD_VILLAIN';
constant LOAD_VILLAIN_SUCCESS (line 2) | const LOAD_VILLAIN_SUCCESS = '[Villains] LOAD_VILLAIN_SUCCESS';
constant LOAD_VILLAIN_ERROR (line 3) | const LOAD_VILLAIN_ERROR = '[Villains] LOAD_VILLAIN_ERROR';
constant UPDATE_VILLAIN (line 5) | const UPDATE_VILLAIN = '[Villains] UPDATE_VILLAIN';
constant UPDATE_VILLAIN_SUCCESS (line 6) | const UPDATE_VILLAIN_SUCCESS = '[Villains] UPDATE_VILLAIN_SUCCESS';
constant UPDATE_VILLAIN_ERROR (line 7) | const UPDATE_VILLAIN_ERROR = '[Villains] UPDATE_VILLAIN_ERROR';
constant DELETE_VILLAIN (line 9) | const DELETE_VILLAIN = '[Villains] DELETE_VILLAIN';
constant DELETE_VILLAIN_SUCCESS (line 10) | const DELETE_VILLAIN_SUCCESS = '[Villains] DELETE_VILLAIN_SUCCESS';
constant DELETE_VILLAIN_ERROR (line 11) | const DELETE_VILLAIN_ERROR = '[Villains] DELETE_VILLAIN_ERROR';
constant ADD_VILLAIN (line 13) | const ADD_VILLAIN = '[Villains] ADD_VILLAIN';
constant ADD_VILLAIN_SUCCESS (line 14) | const ADD_VILLAIN_SUCCESS = '[Villains] ADD_VILLAIN_SUCCESS';
constant ADD_VILLAIN_ERROR (line 15) | const ADD_VILLAIN_ERROR = '[Villains] ADD_VILLAIN_ERROR';
constant SELECT_VILLAIN (line 17) | const SELECT_VILLAIN = '[Villain] SELECT_VILLAIN';
FILE: src/villains/VillainDetail.js
function VillainDetail (line 6) | function VillainDetail({
FILE: src/villains/VillainList.js
function VillainList (line 6) | function VillainList({
FILE: src/villains/Villains.js
function Villains (line 11) | function Villains() {
FILE: src/villains/useVillains.js
function useVillains (line 13) | function useVillains() {
Condensed preview — 70 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (97K chars).
[
{
"path": ".dockerignore",
"chars": 127,
"preview": "node_modules\nnpm-debug.log\nDockerfile*\ndocker-compose*\n.dockerignore\n.git\n.gitignore\n.env\n*/bin\n*/obj\nREADME.md\nLICENSE\n"
},
{
"path": ".eslintrc.json",
"chars": 696,
"preview": "{\n \"extends\": [\n \"eslint:recommended\",\n \"plugin:react/recommended\",\n \"prettier\"\n ],\n \"rules\": {\n \"react/p"
},
{
"path": ".github/ISSUE_TEMPLATE/bug-report.yml",
"chars": 1470,
"preview": "name: Bug Report\ndescription: Report a bug in the Heroes React app\nlabels: [\"bug\"]\nbody:\n - type: markdown\n attribut"
},
{
"path": ".github/ISSUE_TEMPLATE/feature-request.yml",
"chars": 942,
"preview": "name: Feature Request\ndescription: Suggest a new feature or improvement\nlabels: [\"enhancement\"]\nbody:\n - type: markdown"
},
{
"path": ".github/PULL_REQUEST_TEMPLATE.md",
"chars": 707,
"preview": "## Description\n\n<!-- What does this PR do? One or two sentences. -->\n\n## Changes\n\n<!-- List the files changed and why. -"
},
{
"path": ".github/copilot-instructions.md",
"chars": 4280,
"preview": "# Copilot Instructions — heroes-react\n\n## Project Type\n\nReact 16 SPA (Create React App) with Redux + Redux-Saga for stat"
},
{
"path": ".github/dependabot.yml",
"chars": 238,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n "
},
{
"path": ".github/workflows/ci.yml",
"chars": 722,
"preview": "name: CI\n\non:\n pull_request:\n paths-ignore:\n - \"**.md\"\n - \"docs/**\"\n - \".github/ISSUE_TEMPLATE/**\"\n "
},
{
"path": ".github/workflows/copilot-setup-steps.yml",
"chars": 364,
"preview": "name: \"Copilot Setup Steps\"\non: workflow_dispatch\n\njobs:\n setup:\n runs-on: ubuntu-latest\n steps:\n - name: Ch"
},
{
"path": ".gitignore",
"chars": 296,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n\n# t"
},
{
"path": ".prettierrc",
"chars": 63,
"preview": "{\n \"tabWidth\": 2,\n \"useTabs\": false,\n \"singleQuote\": true\n}\n"
},
{
"path": ".vscode/launch.json",
"chars": 434,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n //"
},
{
"path": ".vscode/settings.json",
"chars": 724,
"preview": "{\n \"eslint.enable\": true,\n \"peacock.color\": \"#61dafb\",\n \"workbench.colorCustomizations\": {\n \"activityBar.backgroun"
},
{
"path": "AGENTS.md",
"chars": 9104,
"preview": "# Heroes React — Agent Guide\n\nThis is a **React single-page application** (SPA) — a demo/comparison app showing the Tour"
},
{
"path": "CHANGELOG.md",
"chars": 364,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
},
{
"path": "Dockerfile",
"chars": 690,
"preview": "# Client App\nFROM node:10.13-alpine as client-app\nLABEL authors=\"John Papa\"\nWORKDIR /usr/src/app\nCOPY [\"package.json\", \""
},
{
"path": "README.md",
"chars": 6271,
"preview": "# Tour of Heroes\n\n[](https://github.com/jo"
},
{
"path": "cypress/fixtures/example.json",
"chars": 154,
"preview": "{\n \"name\": \"Using fixtures to represent data\",\n \"email\": \"hello@cypress.io\",\n \"body\": \"Fixtures are a great way to mo"
},
{
"path": "cypress/integration/heroes.spec.js",
"chars": 4536,
"preview": "/// <reference types=\"cypress\" />\n/* eslint-env mocha */\n/* global cy expect Cypress */\n\nimport data from '../../db';\n\nc"
},
{
"path": "cypress/plugins/index.js",
"chars": 644,
"preview": "// ***********************************************************\n// This example plugins/index.js can be used to load plug"
},
{
"path": "cypress/support/commands.js",
"chars": 841,
"preview": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom"
},
{
"path": "cypress/support/index.js",
"chars": 670,
"preview": "// ***********************************************************\n// This example support/index.js is processed and\n// load"
},
{
"path": "cypress.json",
"chars": 77,
"preview": "{\n \"ignoreTestFiles\": \"/**/examples/*\",\n \"env\": {\n \"port\": \"8626\"\n }\n}\n"
},
{
"path": "db.js",
"chars": 1105,
"preview": "const heroes = [\n {\n id: 'HeroAslaug',\n name: 'Aslaug',\n description: 'warrior queen',\n },\n {\n id: 'HeroB"
},
{
"path": "db.json",
"chars": 1174,
"preview": "{\n \"heroes\": [\n {\n \"id\": \"HeroAslaug\",\n \"name\": \"Aslaug\",\n \"description\": \"warrior queen\"\n },\n "
},
{
"path": "docker-compose.debug.yml",
"chars": 284,
"preview": "version: '2.1'\n\nservices:\n heroes-react:\n image: heroes-react\n build: .\n context: .\n args:\n REAC"
},
{
"path": "docker-compose.yml",
"chars": 214,
"preview": "version: '2.1'\n\nservices:\n heroes-react:\n image: heroes-react\n build:\n context: .\n args:\n REACT_"
},
{
"path": "package.json",
"chars": 1568,
"preview": "{\n \"name\": \"heroes-react\",\n \"version\": \"0.2.0\",\n \"private\": true,\n \"scripts\": {\n \"start\": \"react-scripts start\",\n"
},
{
"path": "public/index.html",
"chars": 1769,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"utf-8\">\n <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/favicon"
},
{
"path": "public/manifest.json",
"chars": 306,
"preview": "{\n \"short_name\": \"React App\",\n \"name\": \"Create React App Sample\",\n \"icons\": [\n {\n \"src\": \"favicon.ico\",\n "
},
{
"path": "routes.json",
"chars": 66,
"preview": "{\n \"/api/*\": \"/$1\",\n \"/hero\": \"/heroes\",\n \"/reset\": \"/reset\"\n}\n"
},
{
"path": "server.js",
"chars": 465,
"preview": "const express = require('express');\nconst app = express();\nconst port = process.env.PORT || 8626;\nconst publicweb = proc"
},
{
"path": "src/About.js",
"chars": 2170,
"preview": "import React from 'react';\n\nconst About = () => (\n <div className=\"content-container\">\n <div className=\"content-titl"
},
{
"path": "src/App.css",
"chars": 0,
"preview": ""
},
{
"path": "src/App.js",
"chars": 1091,
"preview": "import React, { lazy, Suspense } from 'react';\nimport 'bulma/css/bulma.css';\nimport './styles.scss';\nimport { Navigate, "
},
{
"path": "src/components/ButtonFooter.js",
"chars": 449,
"preview": "import React from 'react';\n\nconst ButtonFooter = ({\n label,\n className,\n iconClasses,\n onClick,\n dataIndex,\n dataI"
},
{
"path": "src/components/CardContent.js",
"chars": 288,
"preview": "import React from 'react';\n\nconst CardContent = ({ name, description }) => (\n <div className=\"card-content\">\n <div c"
},
{
"path": "src/components/HeaderBar.js",
"chars": 389,
"preview": "import React from 'react';\nimport HeaderBarBrand from './HeaderBarBrand';\nimport HeaderBarLinks from './HeaderBarLinks';"
},
{
"path": "src/components/HeaderBarBrand.js",
"chars": 851,
"preview": "import React from 'react';\nimport { NavLink } from 'react-router-dom';\n\nconst HeaderBarBrand = () => (\n <div className="
},
{
"path": "src/components/HeaderBarLinks.js",
"chars": 737,
"preview": "import React from 'react';\n\nconst HeaderBarLinks = () => (\n <div className=\"navbar-menu\">\n <div className=\"navbar-en"
},
{
"path": "src/components/InputDetail.js",
"chars": 433,
"preview": "import React from 'react';\n\nconst InputDetail = ({ name, value, placeholder, onChange, readOnly }) => (\n <div className"
},
{
"path": "src/components/ListHeader.js",
"chars": 720,
"preview": "import React from 'react';\nimport { NavLink } from 'react-router-dom';\n\nconst ListHeader = ({ title, handleAdd, handleRe"
},
{
"path": "src/components/Modal.js",
"chars": 484,
"preview": "import { Component } from 'react';\nimport { createPortal } from 'react-dom';\n\nconst modalRoot = document.getElementById("
},
{
"path": "src/components/ModalYesNo.js",
"chars": 766,
"preview": "import React from 'react';\n\nimport Modal from './Modal';\n\nconst ModalYesNo = ({ message, onYes, onNo }) => (\n <Modal>\n "
},
{
"path": "src/components/NavBar.js",
"chars": 637,
"preview": "import React from 'react';\nimport { NavLink } from 'react-router-dom';\n\nconst NavBar = props => (\n <nav className=\"colu"
},
{
"path": "src/components/NotFound.js",
"chars": 358,
"preview": "import React from 'react';\n\nconst NotFound = () => (\n <div className=\"content-container\">\n <div className=\"content-t"
},
{
"path": "src/components/index.js",
"chars": 427,
"preview": "import ButtonFooter from './ButtonFooter';\nimport CardContent from './CardContent';\nimport HeaderBar from './HeaderBar';"
},
{
"path": "src/heroes/HeroDetail.js",
"chars": 1932,
"preview": "import React, { useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { ButtonFoot"
},
{
"path": "src/heroes/HeroList.js",
"chars": 1484,
"preview": "import React from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { ButtonFooter, CardContent } from '."
},
{
"path": "src/heroes/Heroes.js",
"chars": 2778,
"preview": "import React, { useEffect, useState } from 'react';\nimport { Route, Routes, useNavigate } from 'react-router-dom';\n\nimpo"
},
{
"path": "src/heroes/useHeroes.js",
"chars": 951,
"preview": "import { useCallback } from 'react';\nimport { useDispatch, useSelector } from 'react-redux';\n\nimport {\n addHeroAction,\n"
},
{
"path": "src/index.css",
"chars": 380,
"preview": "body {\n margin: 0;\n padding: 0;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n "
},
{
"path": "src/index.js",
"chars": 893,
"preview": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { Provider } from 'react-redux';\nimport"
},
{
"path": "src/serviceWorker.js",
"chars": 4812,
"preview": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the ap"
},
{
"path": "src/store/action-utils.js",
"chars": 417,
"preview": "export const parseList = response => {\n if (response.status !== 200) throw Error(response.message);\n let list = respon"
},
{
"path": "src/store/config.js",
"chars": 67,
"preview": "const API = process.env.REACT_APP_API;\n\nexport { API as default };\n"
},
{
"path": "src/store/hero.actions.js",
"chars": 1124,
"preview": "export const LOAD_HERO = '[Heroes] LOAD_HERO';\nexport const LOAD_HERO_SUCCESS = '[Heroes] LOAD_HERO_SUCCESS';\nexport con"
},
{
"path": "src/store/hero.api.js",
"chars": 743,
"preview": "import axios from 'axios';\nimport { parseItem, parseList } from './action-utils';\nimport API from './config';\n\nconst cap"
},
{
"path": "src/store/hero.reducer.js",
"chars": 2390,
"preview": "import {\n SELECT_HERO,\n LOAD_HERO_SUCCESS,\n LOAD_HERO,\n LOAD_HERO_ERROR,\n UPDATE_HERO,\n UPDATE_HERO_SUCCESS,\n UPD"
},
{
"path": "src/store/hero.saga.js",
"chars": 2214,
"preview": "import { put, takeEvery, call, all } from 'redux-saga/effects';\nimport {\n LOAD_HERO,\n LOAD_HERO_SUCCESS,\n LOAD_HERO_E"
},
{
"path": "src/store/index.js",
"chars": 576,
"preview": "import { combineReducers } from 'redux';\nimport { heroesReducer, selectedHeroReducer } from './hero.reducer';\nimport { s"
},
{
"path": "src/store/villain.actions.js",
"chars": 1298,
"preview": "export const LOAD_VILLAIN = '[Villains] LOAD_VILLAIN';\nexport const LOAD_VILLAIN_SUCCESS = '[Villains] LOAD_VILLAIN_SUCC"
},
{
"path": "src/store/villain.api.js",
"chars": 786,
"preview": "import axios from 'axios';\nimport { parseItem, parseList } from './action-utils';\nimport API from './config';\n\nconst cap"
},
{
"path": "src/store/villain.reducer.js",
"chars": 2512,
"preview": "import {\n SELECT_VILLAIN,\n LOAD_VILLAIN_SUCCESS,\n LOAD_VILLAIN,\n LOAD_VILLAIN_ERROR,\n UPDATE_VILLAIN,\n UPDATE_VILL"
},
{
"path": "src/store/villain.saga.js",
"chars": 2244,
"preview": "import { put, takeEvery, call, all } from 'redux-saga/effects';\nimport {\n LOAD_VILLAIN,\n LOAD_VILLAIN_SUCCESS,\n LOAD_"
},
{
"path": "src/styles.scss",
"chars": 2580,
"preview": "$vue: #42b883;\n$vue-light: #42b883;\n$angular: #b52e31;\n$angular-light: #eb7a7c;\n$react: #00b3e6;\n$react-light: #61dafb;\n"
},
{
"path": "src/villains/VillainDetail.js",
"chars": 2047,
"preview": "import React, { useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { ButtonFoot"
},
{
"path": "src/villains/VillainList.js",
"chars": 1611,
"preview": "import React from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { ButtonFooter, CardContent } from '."
},
{
"path": "src/villains/Villains.js",
"chars": 2984,
"preview": "import React, { useEffect, useState } from 'react';\nimport { Route, Routes, useNavigate } from 'react-router-dom';\n\nimpo"
},
{
"path": "src/villains/useVillains.js",
"chars": 1034,
"preview": "import { useCallback } from 'react';\nimport { useDispatch, useSelector } from 'react-redux';\n\nimport {\n addVillainActio"
}
]
About this extraction
This page contains the full source code of the johnpapa/heroes-react GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 70 files (86.9 KB), approximately 24.1k tokens, and a symbol index with 45 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.