mirror of
https://github.com/ershisan99/flashcards-example-project.git
synced 2025-12-18 05:09:23 +00:00
add cards table and pagination
This commit is contained in:
47
src/components/decks/cards-table.tsx
Normal file
47
src/components/decks/cards-table.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Column, Table, TableBody, TableCell, TableHeader, TableRow } from '@/components'
|
||||
import { Card } from '@/services/decks'
|
||||
import { formatDate } from '@/utils'
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
key: 'question',
|
||||
title: 'Question',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'answer',
|
||||
title: 'Answer',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
title: 'Last Updated',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'grade',
|
||||
title: 'Grade',
|
||||
sortable: true,
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
cards: Card[] | undefined
|
||||
}
|
||||
export const CardsTable = ({ cards }: Props) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader columns={columns} />
|
||||
<TableBody>
|
||||
{cards?.map(card => (
|
||||
<TableRow key={card.id}>
|
||||
<TableCell>{card.question}</TableCell>
|
||||
<TableCell>{card.answer}</TableCell>
|
||||
<TableCell>{formatDate(card.updated)}</TableCell>
|
||||
<TableCell>{card.grade}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
Column,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@/components'
|
||||
import { Deck } from '@/services/decks'
|
||||
import { formatDate } from '@/utils'
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'cardsCount',
|
||||
title: 'Cards',
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
title: 'Last Updated',
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
title: 'Created By',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '',
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
decks: Deck[] | undefined
|
||||
}
|
||||
export const DecksTable = ({ decks }: Props) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader columns={columns} />
|
||||
<TableBody>
|
||||
{decks?.map(deck => (
|
||||
<TableRow key={deck.id}>
|
||||
<TableCell>
|
||||
<Typography variant={'body2'} as={Link} to={`/decks/${deck.id}`}>
|
||||
{deck.name}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{deck.cardsCount}</TableCell>
|
||||
<TableCell>{formatDate(deck.updated)}</TableCell>
|
||||
<TableCell>{deck.author.name}</TableCell>
|
||||
<TableCell>...</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
2
src/components/decks/index.ts
Normal file
2
src/components/decks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './decks-table.tsx'
|
||||
export * from './cards-table.tsx'
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './ui'
|
||||
export * from './auth'
|
||||
export * from './profile'
|
||||
export * from './decks'
|
||||
|
||||
1
src/components/ui/pagination/index.ts
Normal file
1
src/components/ui/pagination/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './pagination'
|
||||
75
src/components/ui/pagination/pagination.module.scss
Normal file
75
src/components/ui/pagination/pagination.module.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.root {
|
||||
display: flex;
|
||||
gap: 25px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
@mixin item {
|
||||
all: unset;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
color: var(--color-light-100);
|
||||
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.item {
|
||||
@include item;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: initial;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover:not(&:disabled) {
|
||||
background-color: var(--color-dark-500);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: var(--color-dark-900);
|
||||
background-color: var(--color-light-100);
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
@include item;
|
||||
}
|
||||
|
||||
.icon {
|
||||
.item:disabled & {
|
||||
// important because icons have style prop
|
||||
color: var(--color-action-disabled) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.selectBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
color: var(--color-light-100);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
}
|
||||
192
src/components/ui/pagination/pagination.tsx
Normal file
192
src/components/ui/pagination/pagination.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import s from './pagination.module.scss'
|
||||
import { usePagination } from './usePagination'
|
||||
|
||||
import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets'
|
||||
|
||||
type PaginationConditionals =
|
||||
| {
|
||||
perPage?: null
|
||||
perPageOptions?: never
|
||||
onPerPageChange?: never
|
||||
}
|
||||
| {
|
||||
perPage: number
|
||||
perPageOptions: number[]
|
||||
onPerPageChange: (itemPerPage: number) => void
|
||||
}
|
||||
|
||||
export type PaginationProps = {
|
||||
count: number
|
||||
page: number
|
||||
onChange: (page: number) => void
|
||||
siblings?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
onPerPageChange?: (itemPerPage: number) => void
|
||||
} & PaginationConditionals
|
||||
|
||||
const classNames = {
|
||||
root: s.root,
|
||||
container: s.container,
|
||||
selectBox: s.selectBox,
|
||||
select: s.select,
|
||||
item: s.item,
|
||||
dots: s.dots,
|
||||
icon: s.icon,
|
||||
pageButton(selected?: boolean) {
|
||||
return clsx(this.item, selected && s.selected)
|
||||
},
|
||||
}
|
||||
|
||||
export const Pagination: FC<PaginationProps> = ({
|
||||
onChange,
|
||||
count,
|
||||
page,
|
||||
perPage = null,
|
||||
perPageOptions,
|
||||
onPerPageChange,
|
||||
siblings,
|
||||
}) => {
|
||||
const {
|
||||
paginationRange,
|
||||
isLastPage,
|
||||
isFirstPage,
|
||||
handlePreviousPageClicked,
|
||||
handleNextPageClicked,
|
||||
handleMainPageClicked,
|
||||
} = usePagination({
|
||||
page,
|
||||
count,
|
||||
onChange,
|
||||
siblings,
|
||||
})
|
||||
|
||||
const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange
|
||||
|
||||
return (
|
||||
<div className={classNames.root}>
|
||||
<div className={classNames.container}>
|
||||
<PrevButton onClick={handlePreviousPageClicked} disabled={isFirstPage} />
|
||||
|
||||
<MainPaginationButtons
|
||||
currentPage={page}
|
||||
onClick={handleMainPageClicked}
|
||||
paginationRange={paginationRange}
|
||||
/>
|
||||
|
||||
<NextButton onClick={handleNextPageClicked} disabled={isLastPage} />
|
||||
</div>
|
||||
|
||||
{showPerPageSelect && (
|
||||
<PerPageSelect
|
||||
{...{
|
||||
perPage,
|
||||
perPageOptions,
|
||||
onPerPageChange,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type NavigationButtonProps = {
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type PageButtonProps = NavigationButtonProps & {
|
||||
page: number
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
const Dots: FC = () => {
|
||||
return <span className={classNames.dots}>…</span>
|
||||
}
|
||||
const PageButton: FC<PageButtonProps> = ({ onClick, disabled, selected, page }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={selected || disabled}
|
||||
className={classNames.pageButton(selected)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const PrevButton: FC<NavigationButtonProps> = ({ onClick, disabled }) => {
|
||||
return (
|
||||
<button className={classNames.item} onClick={onClick} disabled={disabled}>
|
||||
<KeyboardArrowLeft className={classNames.icon} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const NextButton: FC<NavigationButtonProps> = ({ onClick, disabled }) => {
|
||||
return (
|
||||
<button className={classNames.item} onClick={onClick} disabled={disabled}>
|
||||
<KeyboardArrowRight className={classNames.icon} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type MainPaginationButtonsProps = {
|
||||
paginationRange: (number | string)[]
|
||||
currentPage: number
|
||||
onClick: (pageNumber: number) => () => void
|
||||
}
|
||||
|
||||
const MainPaginationButtons: FC<MainPaginationButtonsProps> = ({
|
||||
paginationRange,
|
||||
currentPage,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{paginationRange.map((page: number | string, index) => {
|
||||
const isSelected = page === currentPage
|
||||
|
||||
if (typeof page !== 'number') {
|
||||
return <Dots key={index} />
|
||||
}
|
||||
|
||||
return <PageButton key={index} page={page} selected={isSelected} onClick={onClick(page)} />
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type PerPageSelectProps = {
|
||||
perPage: number
|
||||
perPageOptions: number[]
|
||||
onPerPageChange: (itemPerPage: number) => void
|
||||
}
|
||||
|
||||
export const PerPageSelect: FC<PerPageSelectProps> = ({
|
||||
perPage,
|
||||
perPageOptions,
|
||||
onPerPageChange,
|
||||
}) => {
|
||||
const selectOptions = perPageOptions.map(value => ({
|
||||
label: value,
|
||||
value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={classNames.selectBox}>
|
||||
Показать
|
||||
{/*<Select*/}
|
||||
{/* className={classNames.select}*/}
|
||||
{/* value={perPage}*/}
|
||||
{/* options={selectOptions}*/}
|
||||
{/* onChange={onPerPageChange}*/}
|
||||
{/* variant="pagination"*/}
|
||||
{/*/>*/}
|
||||
на странице
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/components/ui/pagination/usePagination.ts
Normal file
112
src/components/ui/pagination/usePagination.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
// original code: https://www.freecodecamp.org/news/build-a-custom-pagination-component-in-react/
|
||||
|
||||
const range = (start: number, end: number) => {
|
||||
let length = end - start + 1
|
||||
|
||||
/*
|
||||
Create an array of certain length and set the elements within it from
|
||||
start value to end value.
|
||||
*/
|
||||
return Array.from({ length }, (_, idx) => idx + start)
|
||||
}
|
||||
|
||||
const DOTS = '...'
|
||||
|
||||
type UsePaginationParamType = {
|
||||
count: number
|
||||
siblings?: number
|
||||
page: number
|
||||
onChange: (pageNumber: number) => void
|
||||
}
|
||||
|
||||
type PaginationRange = (number | '...')[]
|
||||
|
||||
export const usePagination = ({ count, siblings = 1, page, onChange }: UsePaginationParamType) => {
|
||||
const paginationRange = useMemo(() => {
|
||||
// Pages count is determined as siblingCount + firstPage + lastPage + page + 2*DOTS
|
||||
const totalPageNumbers = siblings + 5
|
||||
|
||||
/*
|
||||
Case 1:
|
||||
If the number of pages is less than the page numbers we want to show in our
|
||||
paginationComponent, we return the range [1..totalPageCount]
|
||||
*/
|
||||
if (totalPageNumbers >= count) {
|
||||
return range(1, count)
|
||||
}
|
||||
|
||||
/*
|
||||
Calculate left and right sibling index and make sure they are within range 1 and totalPageCount
|
||||
*/
|
||||
const leftSiblingIndex = Math.max(page - siblings, 1)
|
||||
const rightSiblingIndex = Math.min(page + siblings, count)
|
||||
|
||||
/*
|
||||
We do not show dots when there is only one page number to be inserted
|
||||
between the extremes of siblings and the page limits i.e 1 and totalPageCount.
|
||||
Hence, we are using leftSiblingIndex > 2 and rightSiblingIndex < totalPageCount - 2
|
||||
*/
|
||||
const shouldShowLeftDots = leftSiblingIndex > 2
|
||||
const shouldShowRightDots = rightSiblingIndex < count - 2
|
||||
|
||||
const firstPageIndex = 1
|
||||
const lastPageIndex = count
|
||||
|
||||
/*
|
||||
Case 2: No left dots to show, but rights dots to be shown
|
||||
*/
|
||||
if (!shouldShowLeftDots && shouldShowRightDots) {
|
||||
let leftItemCount = 3 + 2 * siblings
|
||||
let leftRange = range(1, leftItemCount)
|
||||
|
||||
return [...leftRange, DOTS, count]
|
||||
}
|
||||
|
||||
/*
|
||||
Case 3: No right dots to show, but left dots to be shown
|
||||
*/
|
||||
if (shouldShowLeftDots && !shouldShowRightDots) {
|
||||
let rightItemCount = 3 + 2 * siblings
|
||||
let rightRange = range(count - rightItemCount + 1, count)
|
||||
|
||||
return [firstPageIndex, DOTS, ...rightRange]
|
||||
}
|
||||
|
||||
/*
|
||||
Case 4: Both left and right dots to be shown
|
||||
*/
|
||||
if (shouldShowLeftDots && shouldShowRightDots) {
|
||||
let middleRange = range(leftSiblingIndex, rightSiblingIndex)
|
||||
|
||||
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]
|
||||
}
|
||||
}, [siblings, page, count]) as PaginationRange
|
||||
|
||||
const lastPage = paginationRange.at(-1)
|
||||
|
||||
const isFirstPage = page === 1
|
||||
const isLastPage = page === lastPage
|
||||
|
||||
const handleNextPageClicked = useCallback(() => {
|
||||
onChange(page + 1)
|
||||
}, [page, onChange])
|
||||
|
||||
const handlePreviousPageClicked = useCallback(() => {
|
||||
onChange(page - 1)
|
||||
}, [page, onChange])
|
||||
|
||||
function handleMainPageClicked(pageNumber: number) {
|
||||
return () => onChange(pageNumber)
|
||||
}
|
||||
|
||||
return {
|
||||
paginationRange,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
handleMainPageClicked,
|
||||
handleNextPageClicked,
|
||||
handlePreviousPageClicked,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user