Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to app router #18

Merged
merged 5 commits into from
Dec 27, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -6,13 +6,21 @@
"plugin:react-hooks/recommended",
"airbnb",
"airbnb-typescript",
"next/core-web-vitals",
"prettier"
"next/core-web-vitals"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
// https://github.com/iamturns/eslint-config-airbnb-typescript/issues/239
// https://www.npmjs.com/package/eslint-plugin-import
"settings": {
"import/resolver": {
"typescript": true
}
},
"rules": {
"import/prefer-default-export": 0,
// https://stackoverflow.com/questions/69928061/struggling-with-typescript-react-eslint-and-simple-arrow-functions-components
"react/function-component-definition": [
2,
@@ -38,6 +46,25 @@
// Use semicolon as member delimiter for interfaces and type
"@typescript-eslint/member-delimiter-style": 2,
"@typescript-eslint/semi": 0,
"react/jsx-props-no-spreading": [
2,
{
"exceptions": [
"input",
"textarea",
"select",
"Form.Control",
"Form.Check",
"Form.Check.Input",
"Form.Select",
"Form.Switch"
]
}
],
"quotes": [
2,
"single"
],
"semi": [
2,
"never"
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -13,18 +13,21 @@ jobs:

strategy:
matrix:
node-version: [16.x, 18.x, 19.x, 20.x]
node-version: [18.x, 19.x, 20.x, 21.x]

steps:
- uses: actions/checkout@v3

- uses: pnpm/action-setup@v2
with:
version: 8

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

- run: pnpm install
- run: pnpm lint
- run: pnpm build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -35,3 +35,4 @@ yarn-error.log*
*.tsbuildinfo

/.idea
next-env.d.ts
41 changes: 0 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -69,47 +69,6 @@ To learn more about Next.js, take a look at the following resources:

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

## Next.JS Rendering

<details>
<summary>Click to expand</summary>

### Pre-rendering

By default, Next.js pre-renders every page. This means that Next.js generates HTML for each page in advance, instead of having it all done by client-side JavaScript. Pre-rendering can result in better performance and SEO.

### SSR: Server-side rendering

Next.js will pre-render this page on **each request** using the data returned by `getServerSideProps`.

https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props

### SSG: Static-site generation

Next.js will pre-render this page at **build time** using the props returned by `getStaticProps`.

* In development (next dev), getStaticProps will be called on every request.

https://nextjs.org/docs/basic-features/data-fetching/get-static-props

### CSR: Client-side rendering

If done at the page level, the data is fetched at runtime, and the content of the page is updated as the data changes. When used at the component level, the data is fetched at the time of the component mount, and the content of the component is updated as the data changes.

It is **highly recommended** to use [SWR](https://swr.vercel.app/) if you are fetching data on the client-side. It handles caching, revalidation, focus tracking, refetching on intervals, and more.

https://nextjs.org/docs/basic-features/data-fetching/client-side

### ISR: Incremental Static Regeneration

Next.js allows you to create or update static pages **after you’ve built** your site.

To use ISR, add the `revalidate` prop to `getStaticProps`.

https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration

</details>

## Installed Packages

1. [Redux](https://redux.js.org/tutorials/fundamentals/part-1-overview)
6 changes: 0 additions & 6 deletions next-env.d.ts

This file was deleted.

9 changes: 8 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')

/** @type {import('next').NextConfig} */
@@ -8,7 +9,13 @@ const nextConfig = {
includePaths: [path.join(__dirname, 'styles')],
},
images: {
domains: ['img.pokemondb.net'],
remotePatterns: [
{
protocol: 'https',
hostname: 'img.pokemondb.net',
port: '',
},
],
},
}

66 changes: 34 additions & 32 deletions package.json
Original file line number Diff line number Diff line change
@@ -9,49 +9,51 @@
"lint": "next lint"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@popperjs/core": "^2.11.8",
"@reduxjs/toolkit": "^1.9.5",
"axios": "^0.27.2",
"bootstrap": "^5.3.1",
"chart.js": "^3.9.1",
"classnames": "^2.3.2",
"cookie": "^0.5.0",
"cookies-next": "^2.1.2",
"next": "^13.4.18",
"@reduxjs/toolkit": "^2.0.1",
"axios": "^1.6.3",
"axios-retry": "^4.0.0",
"bootstrap": "^5.3.2",
"chart.js": "^4.4.1",
"classnames": "^2.4.0",
"cookie": "^0.6.0",
"cookies-next": "^4.1.0",
"next": "^14.0.4",
"nextjs-progressloader": "^1.0.0",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
"react-chartjs-2": "^4.3.1",
"react-bootstrap": "^2.9.2",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-paginate": "^8.2.0",
"react-redux": "^8.1.2",
"react-resize-detector": "^7.1.2",
"swr": "^2.2.1"
"react-redux": "^9.0.4",
"swr": "^2.2.4"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/node": "^18.17.5",
"@types/nprogress": "^0.2.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"eslint": "^8.47.0",
"@types/cookie": "^0.6.0",
"@types/node": "^20.10.5",
"@types/nprogress": "^0.2.3",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-next": "^13.4.18",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-config-next": "^14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"json-server": "^0.17.3",
"sass": "^1.66.0",
"typescript": "^5.1.6"
"json-server": "^0.17.4",
"sass": "^1.69.5",
"typescript": "^5.3.3"
}
}
1,787 changes: 819 additions & 968 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions src/app/(authentication)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Container } from 'react-bootstrap'
import React from 'react'

export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-light min-vh-100 d-flex flex-row align-items-center dark:bg-transparent">
<Container>
{children}
</Container>
</div>
)
}
111 changes: 111 additions & 0 deletions src/app/(authentication)/login/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client'

import {
Alert,
Button, Col, Form, InputGroup, Row,
} from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-regular-svg-icons'
import { faLock } from '@fortawesome/free-solid-svg-icons'
import { useRouter } from 'next/navigation'
import { SyntheticEvent, useState } from 'react'
import { deleteCookie, getCookie } from 'cookies-next'
import axios from 'axios'
import Link from 'next/link'

export default function Login() {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')

const getRedirect = () => {
const redirect = getCookie('redirect')
if (redirect) {
deleteCookie('redirect')
return redirect.toString()
}

return '/'
}

const login = async (e: SyntheticEvent) => {
e.stopPropagation()
e.preventDefault()

setSubmitting(true)

try {
const res = await axios.post('api/mock/login')
if (res.status === 200) {
router.push(getRedirect())
}
} catch (err) {
if (err instanceof Error) {
setError(err.message)
}
} finally {
setSubmitting(false)
}
}

return (
<>
<Alert variant="danger" show={error !== ''} onClose={() => setError('')} dismissible>{error}</Alert>
<Form onSubmit={login}>
<InputGroup className="mb-3">
<InputGroup.Text>
<FontAwesomeIcon
icon={faUser}
fixedWidth
/>
</InputGroup.Text>
<Form.Control
name="username"
required
disabled={submitting}
placeholder="Username"
aria-label="Username"
defaultValue="Username"
/>
</InputGroup>

<InputGroup className="mb-3">
<InputGroup.Text>
<FontAwesomeIcon
icon={faLock}
fixedWidth
/>
</InputGroup.Text>
<Form.Control
type="password"
name="password"
required
disabled={submitting}
placeholder="Password"
aria-label="Password"
defaultValue="Password"
/>
</InputGroup>

<Row className="align-items-center">
<Col xs={6}>
<Button
className="px-4"
variant="primary"
type="submit"
disabled={submitting}
>
Login
</Button>
</Col>
<Col xs={6} className="text-end">
<Link className="px-0" href="#">
Forgot
password?
</Link>
</Col>
</Row>
</Form>
</>
)
}
37 changes: 37 additions & 0 deletions src/app/(authentication)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Col, Row } from 'react-bootstrap'
import Link from 'next/link'
import LoginForm from '@/app/(authentication)/login/login'

export default function Page() {
return (
<Row className="justify-content-center align-items-center px-3">
<Col lg={8}>
<Row>
<Col md={7} className="bg-white border p-5">
<div>
<h1>Login</h1>
<p className="text-black-50">Sign In to your account</p>

<LoginForm />
</div>
</Col>
<Col
md={5}
className="bg-primary text-white d-flex align-items-center justify-content-center p-5"
>
<div className="text-center">
<h2>Sign up</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
</p>
<Link className="btn btn-lg btn-outline-light mt-3" href="/register">
Register Now!
</Link>
</div>
</Col>
</Row>
</Col>
</Row>
)
}
20 changes: 20 additions & 0 deletions src/app/(authentication)/register/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client'

import { Card, Col, Row } from 'react-bootstrap'
import Register from '@/app/(authentication)/register/register'

export default function Page() {
return (
<Row className="justify-content-center">
<Col md={6}>
<Card className="mb-4 rounded-0">
<Card.Body className="p-4">
<h1>Register</h1>
<p className="text-black-50">Create your account</p>
<Register />
</Card.Body>
</Card>
</Col>
</Row>
)
}
108 changes: 108 additions & 0 deletions src/app/(authentication)/register/register.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client'

import {
Alert, Button, Form, InputGroup,
} from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEnvelope, faUser } from '@fortawesome/free-regular-svg-icons'
import { faLock } from '@fortawesome/free-solid-svg-icons'
import { useRouter } from 'next/navigation'
import { SyntheticEvent, useState } from 'react'
import { deleteCookie, getCookie } from 'cookies-next'
import axios from 'axios'

export default function Register() {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')

const getRedirect = () => {
const redirect = getCookie('redirect')
if (redirect) {
deleteCookie('redirect')
return redirect.toString()
}

return '/'
}

const register = async (e: SyntheticEvent) => {
e.stopPropagation()
e.preventDefault()

setSubmitting(true)

try {
const res = await axios.post('api/mock/login')
if (res.status === 200) {
router.push(getRedirect())
}
} catch (err) {
if (err instanceof Error) {
setError(err.message)
}
} finally {
setSubmitting(false)
}
}

return (
<>
<Alert variant="danger" show={error !== ''} onClose={() => setError('')} dismissible>{error}</Alert>
<Form onSubmit={register}>
<InputGroup className="mb-3">
<InputGroup.Text><FontAwesomeIcon icon={faUser} fixedWidth /></InputGroup.Text>
<Form.Control
name="username"
required
disabled={submitting}
placeholder="Username"
aria-label="Username"
/>
</InputGroup>

<InputGroup className="mb-3">
<InputGroup.Text>
<FontAwesomeIcon icon={faEnvelope} fixedWidth />
</InputGroup.Text>
<Form.Control
type="email"
name="email"
required
disabled={submitting}
placeholder="Email"
aria-label="Email"
/>
</InputGroup>

<InputGroup className="mb-3">
<InputGroup.Text><FontAwesomeIcon icon={faLock} fixedWidth /></InputGroup.Text>
<Form.Control
type="password"
name="password"
required
disabled={submitting}
placeholder="Password"
aria-label="Password"
/>
</InputGroup>

<InputGroup className="mb-3">
<InputGroup.Text><FontAwesomeIcon icon={faLock} fixedWidth /></InputGroup.Text>
<Form.Control
type="password"
name="password_repeat"
required
disabled={submitting}
placeholder="Repeat password"
aria-label="Repeat password"
/>
</InputGroup>

<Button type="submit" className="d-block w-100" disabled={submitting} variant="success">
Create Account
</Button>
</Form>
</>
)
}
37 changes: 37 additions & 0 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Container } from 'react-bootstrap'
import React from 'react'
import SidebarProvider from '@/app/ui/dashboard/sidebar-provider'
import SidebarOverlay from '@/app/ui/dashboard/Sidebar/SidebarOverlay'
import Sidebar from '@/app/ui/dashboard/Sidebar/Sidebar'
import SidebarNav from '@/app/ui/dashboard/Sidebar/SidebarNav'
import Header from '@/app/ui/dashboard/Header/Header'
import Footer from '@/app/ui/dashboard/Footer/Footer'

export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<SidebarOverlay />
<Sidebar>
<SidebarNav />
</Sidebar>

<div className="wrapper d-flex flex-column min-vh-100 bg-light">
<Header />

<div className="body flex-grow-1 px-sm-2 mb-4">
<Container fluid="lg">
{children}
</Container>
</div>

<Footer />
</div>

<SidebarOverlay />
</SidebarProvider>
)
}
1,377 changes: 1,377 additions & 0 deletions src/app/(dashboard)/page.tsx

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions src/app/(dashboard)/pokemons/[id]/edit/edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { Card } from 'react-bootstrap'
import PokemonForm from '@/components/Pokemon/PokemonForm'
import { Pokemon } from '@/models/pokemon'

export type Props = {
pokemon: Pokemon;
}

export default function Edit(props: Props) {
const { pokemon } = props

return (
<Card>
<Card.Header>Add new Pokémon</Card.Header>
<Card.Body>
<PokemonForm pokemon={pokemon} />
</Card.Body>
</Card>
)
}
41 changes: 41 additions & 0 deletions src/app/(dashboard)/pokemons/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Pokemon } from '@/models/pokemon'
import { notFound } from 'next/navigation'
import Edit, { Props } from '@/app/(dashboard)/pokemons/[id]/edit/edit'

const fetchPokemon = async (params: { id: string }): Promise<Props> => {
const idQuery = params.id

if (!idQuery) {
return notFound()
}

const id = Number(idQuery)

const pokemonURL = `${process.env.NEXT_PUBLIC_POKEMON_LIST_API_BASE_URL}pokemons/${id}` || ''

try {
const res = await fetch(pokemonURL, {
method: 'GET',
})

if (!res.ok) {
return notFound()
}

const pokemon: Pokemon = await res.json()

return {
pokemon,
}
} catch (error) {
return notFound()
}
}

export default async function Page({ params }: { params: { id: string } }) {
const { pokemon } = await fetchPokemon(params)

return (
<Edit pokemon={pokemon} />
)
}
15 changes: 15 additions & 0 deletions src/app/(dashboard)/pokemons/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'

import { Card } from 'react-bootstrap'
import PokemonForm from '@/components/Pokemon/PokemonForm'

export default function Page() {
return (
<Card>
<Card.Header>Add new Pokémon</Card.Header>
<Card.Body>
<PokemonForm />
</Card.Body>
</Card>
)
}
75 changes: 75 additions & 0 deletions src/app/(dashboard)/pokemons/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client'

import { Button, Card } from 'react-bootstrap'
import React from 'react'
import { newResource, Resource } from '@/models/resource'
import { Pokemon } from '@/models/pokemon'
import useSWRAxios, { transformResponseWrapper } from '@/hooks/useSWRAxios'
import Pagination from '@/components/Pagination/Pagination'
import PokemonList from '@/components/Pokemon/PokemonList'
import { useRouter } from 'next/navigation'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlus } from '@fortawesome/free-solid-svg-icons'

export type Props = {
props: {
pokemonResource: Resource<Pokemon>;
page: number;
perPage: number;
sort: string;
order: string;
};
}

export default function Index(props: Props) {
const {
props: {
pokemonResource,
page,
perPage,
sort,
order,
},
} = props

const router = useRouter()

const pokemonListURL = `${process.env.NEXT_PUBLIC_POKEMON_LIST_API_BASE_URL}pokemons` || ''

// swr: data -> axios: data -> resource: data
const { data: { data: resource } } = useSWRAxios<Resource<Pokemon>>({
url: pokemonListURL,
params: {
_page: page,
_limit: perPage,
_sort: sort,
_order: order,
},
transformResponse: transformResponseWrapper((d: Pokemon[], h) => {
const total = h ? parseInt(h['x-total-count'], 10) : 0
return newResource(d, total, page, perPage)
}),
}, {
data: pokemonResource,
headers: {
'x-total-count': pokemonResource.meta.total.toString(),
},
})

return (
<Card>
<Card.Header>Pokémon</Card.Header>
<Card.Body>
<div className="mb-3 text-end">
<Button variant="success" onClick={() => router.push('/pokemons/create')}>
<FontAwesomeIcon icon={faPlus} fixedWidth />
Add new
</Button>
</div>
<Pagination meta={resource.meta} />
<PokemonList pokemons={resource.data} />
<Pagination meta={resource.meta} />
</Card.Body>
</Card>
)
}
58 changes: 58 additions & 0 deletions src/app/(dashboard)/pokemons/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react'
import { newResource, Resource } from '@/models/resource'
import { Pokemon } from '@/models/pokemon'
import { SearchParams } from '@/types/next'
import Index, { Props } from '@/app/(dashboard)/pokemons/index'

const fetchPokemons = async (searchParams: SearchParams): Promise<Props['props']> => {
const pokemonListURL = `${process.env.NEXT_PUBLIC_POKEMON_LIST_API_BASE_URL}pokemons` || ''
let page = 1
if (searchParams?.page) {
page = parseInt(searchParams.page.toString(), 10)
}

let perPage = 20
if (searchParams?.per_page) {
perPage = parseInt(searchParams.per_page.toString(), 10)
}

let sort = 'id'
if (searchParams?.sort) {
sort = searchParams.sort.toString()
}

let order = 'asc'
if (searchParams?.order && typeof searchParams.order === 'string') {
order = searchParams.order
}

const url = new URL(pokemonListURL)
url.searchParams.set('_page', page.toString())
url.searchParams.set('_limit', perPage.toString())
url.searchParams.set('_sort', sort)
url.searchParams.set('_order', order)

const res = await fetch(url, {
method: 'GET',
})
const pokemons: Pokemon[] = await res.json()

const total = parseInt(res.headers.get('x-total-count') ?? '0', 10)
const pokemonResource: Resource<Pokemon> = newResource(pokemons, total, page, perPage)

return {
pokemonResource,
page,
perPage,
sort,
order,
}
}

export default async function Page({ searchParams }: { searchParams: SearchParams }) {
const props = await fetchPokemons(searchParams)

return (
<Index props={props} />
)
}
3 changes: 3 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ health: true })
}
10 changes: 10 additions & 0 deletions src/app/api/mock/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { serializeCookie } from '@/lib/cookie'

export async function POST() {
const cookie = serializeCookie('auth', { user: 'Andy' }, { path: '/' })
return Response.json({ login: true }, {
headers: {
'Set-Cookie': cookie,
},
})
}
10 changes: 10 additions & 0 deletions src/app/api/mock/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { serializeCookie } from '@/lib/cookie'

export async function POST() {
const cookie = serializeCookie('auth', {}, { path: '/', expires: new Date(Date.now()) })
return Response.json({ logout: true }, {
headers: {
'Set-Cookie': cookie,
},
})
}
27 changes: 27 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import '@/styles/globals.scss'
// Next.js allows you to import CSS directly in .js files.
// It handles optimization and all the necessary Webpack configuration to make this work.
import { config } from '@fortawesome/fontawesome-svg-core'
import '@fortawesome/fontawesome-svg-core/styles.css'
import ProgressBar from '@/components/ProgressBar/ProgressBar'

// You change this configuration value to false so that the Font Awesome core SVG library
// will not try and insert <style> elements into the <head> of the page.
// Next.js blocks this from happening anyway so you might as well not even try.
// See https://fontawesome.com/v6/docs/web/use-with/react/use-with#next-js
config.autoAddCss = false

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ProgressBar />
{children}
</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import { Breadcrumb as BSBreadcrumb } from 'react-bootstrap'

export default function Breadcrumb() {
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import { Container } from 'react-bootstrap'
export default function Footer() {
return (
<footer className="footer border-top px-sm-2 py-2">
<Container fluid className="align-items-center flex-column flex-md-row d-flex justify-content-between">
<Container fluid className="text-center align-items-center flex-column flex-md-row d-flex justify-content-between">
<div>
<a className="text-decoration-none" href="https://coreui.io">CoreUI </a>
<a className="text-decoration-none" href="https://coreui.io">
@@ -19,7 +19,7 @@ export default function Footer() {
Powered by&nbsp;
<a
className="text-decoration-none"
href="@layout/AdminLayout/AdminLayout"
href="@app/ui/dashboard/AdminLayout"
>
CoreUI UI
Components
36 changes: 36 additions & 0 deletions src/app/ui/dashboard/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Link from 'next/link'
import { Container } from 'react-bootstrap'
import HeaderSidebarToggler from '@/app/ui/dashboard/Header/HeaderSidebarToggler'
import HeaderFeaturedNav from '@/app/ui/dashboard/Header/HeaderFeaturedNav'
import HeaderNotificationNav from '@/app/ui/dashboard/Header/HeaderNotificationNav'
import HeaderProfileNav from '@/app/ui/dashboard/Header/HeaderProfileNav'
import Breadcrumb from '@/app/ui/dashboard/Breadcrumb/Breadcrumb'

export default function Header() {
return (
<header className="header sticky-top mb-4 py-2 px-sm-2 border-bottom">
<Container fluid className="header-navbar d-flex align-items-center">
<HeaderSidebarToggler />
<Link href="/" className="header-brand d-md-none">
<svg width="80" height="46">
<title>CoreUI Logo</title>
<use xlinkHref="/assets/brand/coreui.svg#full" />
</svg>
</Link>
<div className="header-nav d-none d-md-flex">
<HeaderFeaturedNav />
</div>
<div className="header-nav ms-auto">
<HeaderNotificationNav />
</div>
<div className="header-nav ms-2">
<HeaderProfileNav />
</div>
</Container>
<div className="header-divider border-top my-2 mx-sm-n2" />
<Container fluid>
<Breadcrumb />
</Container>
</header>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import Link from 'next/link'
import { Nav } from 'react-bootstrap'

Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBell, faEnvelope, IconDefinition } from '@fortawesome/free-regular-svg-icons'
import {
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import {
Badge, Dropdown, Nav, NavItem,
} from 'react-bootstrap'
@@ -18,7 +20,7 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import Link from 'next/link'
import axios from 'axios'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'

type ItemWithIconProps = {
icon: IconDefinition;
@@ -52,6 +54,7 @@ export default function HeaderProfileNav() {
<div className="avatar position-relative">
<Image
fill
sizes="32px"
className="rounded-circle"
src="/assets/img/avatars/8.jpg"
alt="user@email.com"
43 changes: 43 additions & 0 deletions src/app/ui/dashboard/Header/HeaderSidebarToggler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useContext } from 'react'
import { SidebarContext } from '@/app/ui/dashboard/sidebar-provider'
import { Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBars } from '@fortawesome/free-solid-svg-icons'

export default function HeaderSidebarToggler() {
const {
showSidebarState: [isShowSidebar, setIsShowSidebar],
showSidebarMdState: [isShowSidebarMd, setIsShowSidebarMd],
} = useContext(SidebarContext)

const toggleSidebar = () => {
setIsShowSidebar(!isShowSidebar)
}

const toggleSidebarMd = () => {
setIsShowSidebarMd(!isShowSidebarMd)
}

return (
<>
<Button
variant="link"
className="header-toggler d-md-none px-md-0 me-md-3 rounded-0 shadow-none"
type="button"
onClick={toggleSidebar}
>
<FontAwesomeIcon icon={faBars} />
</Button>
<Button
variant="link"
className="header-toggler d-none d-md-inline-block px-md-0 me-md-3 rounded-0 shadow-none"
type="button"
onClick={toggleSidebarMd}
>
<FontAwesomeIcon icon={faBars} />
</Button>
</>
)
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
'use client'

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faAngleLeft } from '@fortawesome/free-solid-svg-icons'
import React, { useEffect, useState } from 'react'
import React, { useContext, useEffect, useState } from 'react'
import classNames from 'classnames'
import { Button } from 'react-bootstrap'
import SidebarNav from './SidebarNav'
import { SidebarContext } from '@/app/ui/dashboard/sidebar-provider'

export default function Sidebar(props: { isShow: boolean; isShowMd: boolean }) {
const { isShow, isShowMd } = props
export default function Sidebar({ children }: { children: React.ReactNode }) {
const [isNarrow, setIsNarrow] = useState(false)

const {
showSidebarState: [isShowSidebar],
showSidebarMdState: [isShowSidebarMd, setIsShowSidebarMd],
} = useContext(SidebarContext)

const toggleIsNarrow = () => {
const newValue = !isNarrow
localStorage.setItem('isNarrow', newValue ? 'true' : 'false')
@@ -22,12 +28,19 @@ export default function Sidebar(props: { isShow: boolean; isShowMd: boolean }) {
}
}, [setIsNarrow])

// On first time load only
useEffect(() => {
if (localStorage.getItem('isShowSidebarMd')) {
setIsShowSidebarMd(localStorage.getItem('isShowSidebarMd') === 'true')
}
}, [setIsShowSidebarMd])

return (
<div
className={classNames('sidebar d-flex flex-column position-fixed h-100', {
'sidebar-narrow': isNarrow,
show: isShow,
'md-hide': !isShowMd,
show: isShowSidebar,
'md-hide': !isShowSidebarMd,
})}
id="sidebar"
>
@@ -51,7 +64,7 @@ export default function Sidebar(props: { isShow: boolean; isShowMd: boolean }) {
</div>

<div className="sidebar-nav flex-fill">
<SidebarNav />
{children}
</div>

<Button
@@ -66,18 +79,3 @@ export default function Sidebar(props: { isShow: boolean; isShowMd: boolean }) {
</div>
)
}

export const SidebarOverlay = (props: { isShowSidebar: boolean; toggleSidebar: () => void }) => {
const { isShowSidebar, toggleSidebar } = props

return (
<div
tabIndex={-1}
aria-hidden
className={classNames('sidebar-overlay position-fixed top-0 bg-dark w-100 h-100 opacity-50', {
'd-none': !isShowSidebar,
})}
onClick={toggleSidebar}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faAddressCard,
@@ -10,7 +12,7 @@ import {
faBug,
faCalculator,
faChartPie,
faChevronUp, faCode,
faCode,
faDroplet,
faGauge,
faLayerGroup,
@@ -19,14 +21,11 @@ import {
faPuzzlePiece,
faRightToBracket,
} from '@fortawesome/free-solid-svg-icons'
import React, {
PropsWithChildren, useContext, useEffect, useState,
} from 'react'
import {
Accordion, AccordionContext, Badge, Button, Nav, useAccordionButton,
} from 'react-bootstrap'
import classNames from 'classnames'
import React, { PropsWithChildren, useContext } from 'react'
import { Badge, Nav } from 'react-bootstrap'
import Link from 'next/link'
import SidebarNavGroup from '@/app/ui/dashboard/Sidebar/SidebarNavGroup'
import { SidebarContext } from '@/app/ui/dashboard/sidebar-provider'

type SidebarNavItemProps = {
href: string;
@@ -40,10 +39,14 @@ const SidebarNavItem = (props: SidebarNavItemProps) => {
href,
} = props

const {
showSidebarState: [, setIsShowSidebar],
} = useContext(SidebarContext)

return (
<Nav.Item>
<Link href={href} passHref legacyBehavior>
<Nav.Link className="px-3 py-2 d-flex align-items-center">
<Nav.Link className="px-3 py-2 d-flex align-items-center" onClick={() => setIsShowSidebar(false)}>
{icon ? <FontAwesomeIcon className="nav-icon ms-n3" icon={icon} />
: <span className="nav-icon ms-n3" />}
{children}
@@ -61,71 +64,6 @@ const SidebarNavTitle = (props: PropsWithChildren) => {
)
}

type SidebarNavGroupToggleProps = {
eventKey: string;
icon: IconDefinition;
setIsShow: (isShow: boolean) => void;
} & PropsWithChildren

const SidebarNavGroupToggle = (props: SidebarNavGroupToggleProps) => {
// https://react-bootstrap.github.io/components/accordion/#custom-toggle-with-expansion-awareness
const { activeEventKey } = useContext(AccordionContext)
const {
eventKey, icon, children, setIsShow,
} = props

const decoratedOnClick = useAccordionButton(eventKey)

const isCurrentEventKey = activeEventKey === eventKey

useEffect(() => {
setIsShow(activeEventKey === eventKey)
}, [activeEventKey, eventKey, setIsShow])

return (
<Button
variant="link"
type="button"
className={classNames('rounded-0 nav-link px-3 py-2 d-flex align-items-center flex-fill w-100 shadow-none', {
collapsed: !isCurrentEventKey,
})}
onClick={decoratedOnClick}
>
<FontAwesomeIcon className="nav-icon ms-n3" icon={icon} />
{children}
<div className="nav-chevron ms-auto text-end">
<FontAwesomeIcon size="xs" icon={faChevronUp} />
</div>
</Button>
)
}

type SidebarNavGroupProps = {
toggleIcon: IconDefinition;
toggleText: string;
} & PropsWithChildren

const SidebarNavGroup = (props: SidebarNavGroupProps) => {
const {
toggleIcon,
toggleText,
children,
} = props

const [isShow, setIsShow] = useState(false)

return (
<Accordion as="li" bsPrefix="nav-group" className={classNames({ show: isShow })}>
<SidebarNavGroupToggle icon={toggleIcon} eventKey="0" setIsShow={setIsShow}>{toggleText}</SidebarNavGroupToggle>
<Accordion.Collapse eventKey="0">
<ul className="nav-group-items list-unstyled">
{children}
</ul>
</Accordion.Collapse>
</Accordion>
)
}

export default function SidebarNav() {
return (
<ul className="list-unstyled">
@@ -134,11 +72,7 @@ export default function SidebarNav() {
<small className="ms-auto"><Badge bg="info" className="ms-auto">NEW</Badge></small>
</SidebarNavItem>
<SidebarNavItem icon={faCode} href="/pokemons">
Sample (SSR)
<small className="ms-auto"><Badge bg="danger" className="ms-auto">DEMO</Badge></small>
</SidebarNavItem>
<SidebarNavItem icon={faCode} href="/pokemons/client">
Sample (CSR)
Sample
<small className="ms-auto"><Badge bg="danger" className="ms-auto">DEMO</Badge></small>
</SidebarNavItem>
<SidebarNavTitle>Theme</SidebarNavTitle>
77 changes: 77 additions & 0 deletions src/app/ui/dashboard/Sidebar/SidebarNavGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client'

import { IconDefinition } from '@fortawesome/free-regular-svg-icons'
import React, {
PropsWithChildren, useContext, useEffect, useState,
} from 'react'
import {
Accordion, AccordionContext, Button, useAccordionButton,
} from 'react-bootstrap'
import classNames from 'classnames'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronUp } from '@fortawesome/free-solid-svg-icons'

type SidebarNavGroupToggleProps = {
eventKey: string;
icon: IconDefinition;
setIsShow: (isShow: boolean) => void;
} & PropsWithChildren

const SidebarNavGroupToggle = (props: SidebarNavGroupToggleProps) => {
// https://react-bootstrap.github.io/components/accordion/#custom-toggle-with-expansion-awareness
const { activeEventKey } = useContext(AccordionContext)
const {
eventKey, icon, children, setIsShow,
} = props

const decoratedOnClick = useAccordionButton(eventKey)

const isCurrentEventKey = activeEventKey === eventKey

useEffect(() => {
setIsShow(activeEventKey === eventKey)
}, [activeEventKey, eventKey, setIsShow])

return (
<Button
variant="link"
type="button"
className={classNames('rounded-0 nav-link px-3 py-2 d-flex align-items-center flex-fill w-100 shadow-none', {
collapsed: !isCurrentEventKey,
})}
onClick={decoratedOnClick}
>
<FontAwesomeIcon className="nav-icon ms-n3" icon={icon} />
{children}
<div className="nav-chevron ms-auto text-end">
<FontAwesomeIcon size="xs" icon={faChevronUp} />
</div>
</Button>
)
}

type SidebarNavGroupProps = {
toggleIcon: IconDefinition;
toggleText: string;
} & PropsWithChildren

export default function SidebarNavGroup(props: SidebarNavGroupProps) {
const {
toggleIcon,
toggleText,
children,
} = props

const [isShow, setIsShow] = useState(false)

return (
<Accordion as="li" bsPrefix="nav-group" className={classNames({ show: isShow })}>
<SidebarNavGroupToggle icon={toggleIcon} eventKey="0" setIsShow={setIsShow}>{toggleText}</SidebarNavGroupToggle>
<Accordion.Collapse eventKey="0">
<ul className="nav-group-items list-unstyled">
{children}
</ul>
</Accordion.Collapse>
</Accordion>
)
}
26 changes: 26 additions & 0 deletions src/app/ui/dashboard/Sidebar/SidebarOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import React, { useContext } from 'react'
import { SidebarContext } from '@/app/ui/dashboard/sidebar-provider'
import classNames from 'classnames'

export default function SidebarOverlay() {
const {
showSidebarState: [isShowSidebar, setIsShowSidebar],
} = useContext(SidebarContext)

const hideSidebar = () => {
setIsShowSidebar(false)
}

return (
<div
tabIndex={-1}
aria-hidden
className={classNames('sidebar-overlay position-fixed top-0 bg-dark w-100 h-100 opacity-50', {
'd-none': !isShowSidebar,
})}
onClick={hideSidebar}
/>
)
}
32 changes: 32 additions & 0 deletions src/app/ui/dashboard/sidebar-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import {
createContext, Dispatch, SetStateAction, useMemo, useState,
} from 'react'

type SidebarContextType = {
showSidebarState: [boolean, Dispatch<SetStateAction<boolean>>];
showSidebarMdState: [boolean, Dispatch<SetStateAction<boolean>>];
}

export const SidebarContext = createContext<SidebarContextType>({
showSidebarState: [false, () => {}],
showSidebarMdState: [false, () => {}],
})

export default function SidebarProvider({ children }: {
children: React.ReactNode;
}) {
// Show status for xs screen
const [isShowSidebar, setIsShowSidebar] = useState(false)

// Show status for md screen and above
const [isShowSidebarMd, setIsShowSidebarMd] = useState(true)

const value: SidebarContextType = useMemo(() => ({
showSidebarState: [isShowSidebar, setIsShowSidebar],
showSidebarMdState: [isShowSidebarMd, setIsShowSidebarMd],
}), [isShowSidebar, isShowSidebarMd])

return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
}
8 changes: 8 additions & 0 deletions src/components/Form/FormError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Form } from 'react-bootstrap'
import React from 'react'

export default function FormError(props: { message?: string }) {
const { message } = props

return message && <Form.Control.Feedback type="invalid">{message}</Form.Control.Feedback>
}
2 changes: 2 additions & 0 deletions src/components/Image/ImageFallback.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

import Image, { ImageProps } from 'next/image'
import { useEffect, useState } from 'react'

2 changes: 0 additions & 2 deletions src/components/Image/index.ts

This file was deleted.

17 changes: 9 additions & 8 deletions src/components/Pagination/Paginate.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client'

import ReactPaginate from 'react-paginate'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'

type Props = {
currentPage: number;
@@ -12,6 +14,8 @@ export default function Paginate(props: Props) {
const { currentPage, lastPage, setPage } = props
const [pageIndex, setPageIndex] = useState(currentPage - 1)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()

useEffect(() => {
setPageIndex(currentPage - 1)
@@ -44,13 +48,10 @@ export default function Paginate(props: Props) {
setPage(page)
}

router.push({
pathname: router.pathname,
query: {
...router.query,
page,
},
})
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set('page', page.toString())

router.push(`${pathname}?${newSearchParams}`)
}}
/>
</div>
15 changes: 10 additions & 5 deletions src/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react'
import { Resource } from '@models/resource'
import Summary from './Summary'
import RowsPerPage from './RowsPerPage'
import Paginate from './Paginate'
import { Resource } from '@/models/resource'
import Paginate from '@/components/Pagination/Paginate'
import RowsPerPage from '@/components/Pagination/RowsPerPage'
import Summary from '@/components/Pagination/Summary'

type Props = {
meta: Resource<unknown>['meta'];
@@ -13,7 +13,12 @@ type Props = {
export default function Pagination(props: Props) {
const {
meta: {
from, to, total, per_page: perPage, last_page: lastPage, current_page: currentPage,
from,
to,
total,
per_page: perPage,
last_page: lastPage,
current_page: currentPage,
},
setPerPage,
setPage,
19 changes: 10 additions & 9 deletions src/components/Pagination/RowsPerPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client'

import { Form } from 'react-bootstrap'
import React from 'react'
import { useRouter } from 'next/router'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'

type Props = {
perPage: number;
@@ -10,6 +12,8 @@ type Props = {
export default function RowsPerPage(props: Props) {
const { perPage, setPerPage } = props
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()

return (
<div className="col-auto ms-sm-auto mb-3">
@@ -24,14 +28,11 @@ export default function RowsPerPage(props: Props) {
setPerPage(parseInt(event.target.value, 10))
}

router.push({
pathname: router.pathname,
query: {
...router.query,
page: 1, // Go back to first page
per_page: event.target.value,
},
})
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set('page', '1') // Go back to first page
newSearchParams.set('per_page', event.target.value)

router.push(`${pathname}?${newSearchParams}`)
}}
>
<option value={20}>20</option>
2 changes: 0 additions & 2 deletions src/components/Pagination/index.ts

This file was deleted.

337 changes: 337 additions & 0 deletions src/components/Pokemon/PokemonForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
'use client'

import {
Alert, Button, Col, Form, Row,
} from 'react-bootstrap'
import { Controller, SubmitHandler, useForm } from 'react-hook-form'
import React, { useState } from 'react'
import classNames from 'classnames'
import Image from 'next/image'
import {
Pokemon,
PokemonEggGroup,
pokemonEggGroups,
PokemonType,
pokemonTypes,
} from '@/models/pokemon'
import FormError from '@/components/Form/FormError'
import PokemonTypeLabel from '@/components/Pokemon/PokemonTypeLabel'

type Inputs = {
name: string;
types: PokemonType[];
eggGroups: PokemonEggGroup[];
hp: number | null;
attack: number | null;
defense: number | null;
special_attack: number | null;
special_defense: number | null;
speed: number | null;
}

type Props = {
pokemon?: Pokemon;
}

export default function PokemonForm(props: Props) {
const { pokemon } = props

const defaultValues = (): Inputs => {
if (pokemon) {
return {
name: pokemon.name,
types: pokemon.types,
eggGroups: pokemon.egg_groups,
hp: pokemon.hp,
attack: pokemon.attack,
defense: pokemon.defense,
special_attack: pokemon.special_attack,
special_defense: pokemon.special_defense,
speed: pokemon.speed,
}
}

return {
name: '',
types: [],
eggGroups: [],
hp: null,
attack: null,
defense: null,
special_attack: null,
special_defense: null,
speed: null,
}
}

const {
register,
control,
handleSubmit,
setValue,
formState: { errors },
reset,
} = useForm<Inputs>({
defaultValues: defaultValues(),
})

const [submitting, setSubmitting] = useState(false)
const [notificationMessage, setNotificationMessage] = useState('')

const onSubmit: SubmitHandler<Inputs> = async (data) => {
setSubmitting(true)

// Change to your real submit here
const fakeSubmit = () => new Promise((resolve) => {
setTimeout(() => {
resolve(data)
}, 1500)
})

const res = await fakeSubmit()

setSubmitting(false)
window.scrollTo(0, 0)

if (res) {
setNotificationMessage('Record saved successfully.')
return
}

setNotificationMessage('Unexpected error occurred, please try again.')
}

return (
<Form
noValidate
onSubmit={handleSubmit(onSubmit)}
>
<Alert variant="success" show={notificationMessage !== ''} onClose={() => setNotificationMessage('')} dismissible>
{notificationMessage}
</Alert>

{pokemon && (
<div
className="position-relative mx-auto"
style={{
width: '150px',
height: '150px',
}}
>
<Image
fill
style={{ objectFit: 'contain' }}
alt={pokemon.pokemondb_identifier}
sizes="5vw"
src={`https://img.pokemondb.net/sprites/home/normal/2x/${pokemon.pokemondb_identifier}.jpg`}
/>
</div>
)}

<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control
type="text"
{...register('name', { required: 'This field is required' })}
isInvalid={!!errors.name}
/>
<FormError message={errors.name?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Types</Form.Label>
<div className={classNames({ 'is-invalid': !!errors.types })}>
<Row>
{pokemonTypes.map((type) => (
<Col xs={6} sm={4} md={3} lg={2} key={type}>
<Form.Check id={`type-${type}`}>
<Form.Check.Input
type="checkbox"
// eslint-disable-next-line react/jsx-props-no-spreading
{...register('types', { required: 'This field is required' })}
value={type}
/>
<Form.Check.Label>
<span className="position-relative" style={{ top: '-.1rem' }}>
<PokemonTypeLabel type={type} />
</span>
</Form.Check.Label>
</Form.Check>
</Col>
))}
</Row>
</div>
<FormError message={errors.types?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Egg groups</Form.Label>
<div className={classNames({ 'is-invalid': !!errors.eggGroups })}>
<Row>
{pokemonEggGroups.map((eggGroup) => (
<Col xs={6} sm={4} md={3} lg={2} key={eggGroup}>
<Form.Check
id={`eg-${eggGroup}`}
type="checkbox"
{...register('eggGroups', { required: 'This field is required' })}
value={eggGroup}
label={eggGroup}
/>
</Col>
))}
</Row>
</div>
<FormError message={errors.eggGroups?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Hp</Form.Label>
<Form.Control
className="w-auto"
type="number"
{...register('hp', {
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
valueAsNumber: true,
})}
isInvalid={!!errors.hp}
/>
<FormError message={errors.hp?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Attack</Form.Label>
<Form.Control
className="w-auto"
type="number"
{...register('attack', {
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
valueAsNumber: true,
})}
isInvalid={!!errors.attack}
/>
<FormError message={errors.attack?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Defense</Form.Label>
<Form.Control
className="w-auto"
type="number"
{...register('defense', {
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
valueAsNumber: true,
})}
isInvalid={!!errors.defense}
/>
<FormError message={errors.defense?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Special attack</Form.Label>
<Form.Control
className="w-auto"
type="number"
{...register('special_attack', {
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
valueAsNumber: true,
})}
isInvalid={!!errors.special_attack}
/>
<FormError message={errors.special_attack?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Special defense</Form.Label>
<Form.Control
className="w-auto"
type="number"
{...register('special_defense', {
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
valueAsNumber: true,
})}
isInvalid={!!errors.special_defense}
/>
<FormError message={errors.special_defense?.message} />
</Form.Group>

<Form.Group className="mb-3">
<Form.Label>Speed</Form.Label>
<Controller
control={control}
name="speed"
rules={{
required: 'This field is required',
min: {
value: 0,
message: 'This input must be at least 0',
},
max: {
value: 255,
message: 'This input must be at most 255',
},
}}
render={({ field }) => (
<Form.Control
className="w-auto"
type="number"
{...field}
isInvalid={!!errors.speed}
value={field.value ?? ''}
onChange={(e) => {
if (e.target.value === '') {
setValue('speed', null)
return
}
setValue('speed', Number(e.target.value))
}}
/>
)}
/>
<FormError message={errors.speed?.message} />
</Form.Group>

<Button className="me-3" type="submit" variant="success" disabled={submitting}>Submit</Button>
<Button type="button" variant="secondary" onClick={() => reset()}>Reset</Button>
</Form>
)
}
55 changes: 10 additions & 45 deletions src/components/Pokemon/PokemonList.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,14 @@
'use client'

import { Dropdown, Table } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons'
import React from 'react'
import Image from 'next/image'
import { Pokemon } from '@models/pokemon'
import { THSort } from '@components/TableSort'

const typeColorMap: Record<string, string> = {
normal: '#aa9',
fighting: '#b54',
flying: '#89f',
poison: '#a59',
ground: '#db5',
rock: '#ba6',
bug: '#ab2',
ghost: '#66b',
steel: '#aab',
fire: '#f42',
water: '#39f',
grass: '#7c5',
electric: '#fc3',
psychic: '#f59',
ice: '#6cf',
dragon: '#76e',
dark: '#754',
fairy: '#e9e',
unknown: '#aa9',
shadow: '#aa9',
}

type TypeLabelProps = {
type: string;
}

const TypeLabel = ({ type }: TypeLabelProps) => (
<span
className="text-white d-inline-block text-uppercase text-center rounded-1 shadow-sm me-2"
style={{
backgroundColor: typeColorMap[type],
textShadow: '1px 1px 2px rgb(0 0 0 / 70%)',
fontSize: '.7rem',
width: '70px',
}}
>
{type}
</span>
)
import Link from 'next/link'
import { Pokemon } from '@/models/pokemon'
import THSort from '@/components/TableSort/THSort'
import PokemonTypeLabel from '@/components/Pokemon/PokemonTypeLabel'

type Props = {
pokemons: Pokemon[];
@@ -90,7 +53,7 @@ export default function PokemonList(props: Props) {
</td>
<td>{pokemon.name}</td>
<td>
{pokemon.types.map((type) => <TypeLabel key={type} type={type} />)}
{pokemon.types.map((type) => <span key={type} className="me-2"><PokemonTypeLabel type={type} /></span>)}
</td>
<td className="text-center" style={{ whiteSpace: 'pre' }}>{pokemon.egg_groups.join('\n')}</td>
<td className="text-end">{pokemon.hp}</td>
@@ -113,7 +76,9 @@ export default function PokemonList(props: Props) {

<Dropdown.Menu>
<Dropdown.Item href="#/action-1">Info</Dropdown.Item>
<Dropdown.Item href="#/action-2">Edit</Dropdown.Item>
<Link href={`pokemons/${pokemon.id}/edit`} passHref legacyBehavior>
<Dropdown.Item>Edit</Dropdown.Item>
</Link>
<Dropdown.Item
className="text-danger"
href="#/action-3"
44 changes: 44 additions & 0 deletions src/components/Pokemon/PokemonTypeLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react'

const typeColorMap: Record<string, string> = {
normal: '#aa9',
fighting: '#b54',
flying: '#89f',
poison: '#a59',
ground: '#db5',
rock: '#ba6',
bug: '#ab2',
ghost: '#66b',
steel: '#aab',
fire: '#f42',
water: '#39f',
grass: '#7c5',
electric: '#fc3',
psychic: '#f59',
ice: '#6cf',
dragon: '#76e',
dark: '#754',
fairy: '#e9e',
unknown: '#aa9',
shadow: '#aa9',
}

type Props = {
type: string;
}

export default function PokemonTypeLabel({ type }: Props) {
return (
<span
className="text-white d-inline-block text-uppercase text-center rounded-1 shadow-sm"
style={{
backgroundColor: typeColorMap[type],
textShadow: '1px 1px 2px rgb(0 0 0 / 70%)',
fontSize: '.7rem',
width: '70px',
}}
>
{type}
</span>
)
}
2 changes: 0 additions & 2 deletions src/components/Pokemon/index.ts

This file was deleted.

13 changes: 3 additions & 10 deletions src/components/ProgressBar/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import NProgress from 'nprogress'
import { Router } from 'next/router'
'use client'

Router.events.on('routeChangeStart', () => {
NProgress.start()
})

Router.events.on('routeChangeComplete', () => {
NProgress.done(true)
})
import { ProgressLoader } from 'nextjs-progressloader'

export default function ProgressBar() {
return null
return <ProgressLoader />
}
2 changes: 0 additions & 2 deletions src/components/ProgressBar/index.ts

This file was deleted.

22 changes: 12 additions & 10 deletions src/components/TableSort/THSort.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client'

import React, { PropsWithChildren, useEffect, useState } from 'react'
import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'
import { useRouter } from 'next/router'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'

type Props = {
@@ -15,7 +17,10 @@ export default function THSort(props: Props) {
} = props
const [icon, setIcon] = useState(faSort)
const router = useRouter()
const { query: { sort, order } } = router
const pathname = usePathname()
const searchParams = useSearchParams()
const sort = searchParams.get('sort')
const order = searchParams.get('order')

const onClick = () => {
if (setOrder) {
@@ -26,14 +31,11 @@ export default function THSort(props: Props) {
setSort(name)
}

router.push({
pathname: router.pathname,
query: {
...router.query,
sort: name,
order: order === 'asc' ? 'desc' : 'asc',
},
})
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set('sort', name)
newSearchParams.set('order', order === 'asc' ? 'desc' : 'asc')

router.push(`${pathname}?${newSearchParams}`)
}

useEffect(() => {
2 changes: 0 additions & 2 deletions src/components/TableSort/index.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/hooks/index.ts

This file was deleted.

6 changes: 4 additions & 2 deletions src/hooks/useSWRAxios.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import useSWR from 'swr'
import axios, {
AxiosError,
AxiosError, AxiosHeaders,
AxiosRequestConfig,
AxiosResponse,
AxiosResponseTransformer,
@@ -26,7 +26,9 @@ export default function useSWRAxios<T>(
headers: {},
status: 200,
statusText: 'Initial',
config: {},
config: {
headers: new AxiosHeaders(),
},
}

const fallbackData: AxiosResponse<T> = { ...initFallbackData, ...axiosFallbackData }
71 changes: 0 additions & 71 deletions src/layout/AdminLayout/AdminLayout.tsx

This file was deleted.

59 changes: 0 additions & 59 deletions src/layout/AdminLayout/Header/Header.tsx

This file was deleted.

2 changes: 0 additions & 2 deletions src/layout/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { CookieSerializeOptions, serialize } from 'cookie'

// eslint-disable-next-line import/prefer-default-export
export function serializeCookie(
name: string,
value: unknown,
3 changes: 0 additions & 3 deletions src/lib/index.ts

This file was deleted.

37 changes: 0 additions & 37 deletions src/lib/redirectIfAuthenticated.ts

This file was deleted.

39 changes: 0 additions & 39 deletions src/lib/withAuth.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
@@ -41,7 +41,6 @@ export default function middleware(request: NextRequest) {
if ([
'/',
'/pokemons',
'/pokemons/client',
].includes(request.nextUrl.pathname)) {
return authenticated(request)
}
49 changes: 47 additions & 2 deletions src/models/pokemon.ts
Original file line number Diff line number Diff line change
@@ -3,8 +3,8 @@ export interface Pokemon {
identifier: string;
pokemondb_identifier: string;
name: string;
types: string[];
egg_groups: string[];
types: PokemonType[];
egg_groups: PokemonEggGroup[];
hp: number;
attack: number;
defense: number;
@@ -13,3 +13,48 @@ export interface Pokemon {
speed: number;
total: number;
}

export const pokemonTypes = [
'normal',
'fighting',
'flying',
'poison',
'ground',
'rock',
'bug',
'ghost',
'steel',
'fire',
'water',
'grass',
'electric',
'psychic',
'ice',
'dragon',
'dark',
'fairy',
'unknown',
'shadow',
] as const

export type PokemonType = typeof pokemonTypes[number]

export const pokemonEggGroups = [
'Monster',
'Water 1',
'Bug',
'Flying',
'Field',
'Fairy',
'Grass',
'Human-Like',
'Water 3',
'Mineral',
'Amorphous',
'Water 2',
'Ditto',
'Dragon',
'Undiscovered',
] as const

export type PokemonEggGroup = typeof pokemonEggGroups[number]
29 changes: 0 additions & 29 deletions src/pages/_app.tsx

This file was deleted.

11 changes: 0 additions & 11 deletions src/pages/api/health.ts

This file was deleted.

9 changes: 0 additions & 9 deletions src/pages/api/mock/login.ts

This file was deleted.

8 changes: 0 additions & 8 deletions src/pages/api/mock/logout.ts

This file was deleted.

1,365 changes: 0 additions & 1,365 deletions src/pages/index.tsx

This file was deleted.

127 changes: 0 additions & 127 deletions src/pages/login.tsx

This file was deleted.

110 changes: 0 additions & 110 deletions src/pages/pokemons/client.tsx

This file was deleted.

106 changes: 0 additions & 106 deletions src/pages/pokemons/index.tsx

This file was deleted.

113 changes: 0 additions & 113 deletions src/pages/register.tsx

This file was deleted.

4 changes: 4 additions & 0 deletions src/styles/layout/_sidebar.scss
Original file line number Diff line number Diff line change
@@ -149,4 +149,8 @@ $sidebar-overlay-z-index: 1025;

.sidebar-overlay {
z-index: $sidebar-overlay-z-index;

@include media-breakpoint-up(md) {
display: none;
}
}
3 changes: 3 additions & 0 deletions src/types/next.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SearchParams = {
[key: string]: string | string[] | undefined;
}
43 changes: 3 additions & 40 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -20,45 +20,7 @@
"incremental": true,
"baseUrl": ".",
"paths": {
"@components": [
"src/components"
],
"@components/*": [
"src/components/*"
],
"@hooks": [
"src/hooks"
],
"@hooks/*": [
"src/hooks/*"
],
"@layout": [
"src/layout"
],
"@layout/*": [
"src/layout/*"
],
"@lib": [
"src/lib"
],
"@lib/*": [
"src/lib/*"
],
"@models": [
"src/models"
],
"@models/*": [
"src/models/*"
],
"@pages": [
"src/pages"
],
"@store": [
"src/store"
],
"@styles/*": [
"src/styles/*"
]
"@/*": ["src/*"]
},
"plugins": [
{
@@ -70,7 +32,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
"next.config.js"
],
"exclude": [
"node_modules"