feat: static applications dashboard

This commit is contained in:
2024-02-16 13:06:56 -06:00
parent ddeed0a6ef
commit ff0d24bbb6
17 changed files with 753 additions and 97 deletions
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -23,9 +23,12 @@
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^2.0.1",
"@tanstack/match-sorter-utils": "^8.11.8",
"@tanstack/react-table": "^8.12.0",
"appwrite": "^13.0.1",
"date-fns": "^3.3.1",
"entgamers-database": "0.0.16",
"entgamers-panda-preset": "0.1.2",
"entgamers-panda-preset": "0.1.5",
"formik": "^2.4.5",
"framer-motion": "^10.17.6",
"isomorphic-fetch": "^3.0.0",
+20 -3
View File
@@ -1,18 +1,19 @@
'use client'
import { useAppDispatch } from '@/hooks/useAppDispatch'
import { useAppSelector } from '@/hooks/useAppSelector'
import { setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
import { setClanes, setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
import { AppwriteException } from 'appwrite'
import { getClanes } from 'entgamers-database/frontend/clanes'
import { getCurrentUser, getSession } from 'entgamers-database/frontend/session'
import { useCallback, useEffect, type FC } from 'react'
const SessionConsumer: FC = () => {
const session = useAppSelector((state) => state.session)
const { status, session, user, clanes } = useAppSelector((state) => state.session)
const dispatch = useAppDispatch()
const ensureSession = useCallback(async () => {
try {
if (session.status !== 'initializing' || session.session !== undefined) return
if (status !== 'initializing' || session !== undefined) return
dispatch(setStatus('loading'))
const currentSession = await getSession('current')
const currentUser = await getCurrentUser()
@@ -36,6 +37,22 @@ const SessionConsumer: FC = () => {
})
}, [])
useEffect(() => {
if (user !== undefined && clanes === undefined) {
getClanes()
.then((clanes) => {
dispatch(setClanes(clanes))
})
.catch((error) => {
if (error instanceof AppwriteException) {
console.error(error)
}
})
} else if (user === undefined && clanes !== undefined) {
dispatch(setClanes())
}
}, [user])
return (
<>
</>
@@ -0,0 +1,326 @@
import IconButton from '@/components/ui/IconButton'
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeadCell, TableRow } from '@/components/ui/Table'
import DebouncedInput from '@/components/ui/form/DebouncedInput'
import useManageError from '@/hooks/useManageError'
import { css } from '@/styled-system/css'
import { formatDate } from '@/utilities/date'
import { type TeamApplication, type TeamApplicationList } from '@/utilities/teamApplication'
import { faChevronLeft, faChevronRight, faSort, faSortAsc, faSortDesc } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { rankItem } from '@tanstack/match-sorter-utils'
import { createColumnHelper, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type Column, type FilterFn, type Table as TableType } from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useState, type FC, type ReactNode } from 'react'
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value as string)
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
return itemRank.passed
}
function Filter ({
column,
table
}: {
column: Column<any, unknown>
table: TableType<any>
}): ReactNode {
const firstValue = table
.getPreFilteredRowModel()
.flatRows[0]?.getValue(column.id)
const columnFilterValue = column.getFilterValue()
const sortedUniqueValues = useMemo(() => typeof firstValue === 'number'
? []
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
: Array.from(column.getFacetedUniqueValues().keys()).sort()
, [column.getFacetedUniqueValues()])
return typeof firstValue === 'number'
? (
<div>
<div className="flex space-x-2">
<DebouncedInput
fullWidth
type="number"
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
value={(columnFilterValue as [number, number])?.[0] ?? ''}
onChange={value => { column.setFilterValue((old: [number, number]) => [value, old?.[1]]) }
}
placeholder={`Min ${
((column.getFacetedMinMaxValues()?.[0]) != null)
? `(${column.getFacetedMinMaxValues()?.[0]})`
: ''
}`}
className="w-24 border shadow rounded"
/>
<DebouncedInput
fullWidth
type="number"
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
value={(columnFilterValue as [number, number])?.[1] ?? ''}
onChange={value => { column.setFilterValue((old: [number, number]) => [old?.[0], value]) }
}
placeholder={`Max ${
((column.getFacetedMinMaxValues()?.[1]) != null)
? `(${column.getFacetedMinMaxValues()?.[1]})`
: ''
}`}
className="w-24 border shadow rounded"
/>
</div>
<div className="h-1" />
</div>
)
: (
<>
<datalist id={column.id + 'list'}>
{sortedUniqueValues.slice(0, 5000).map((value: any) => (
<option value={value} key={value} />
))}
</datalist>
<DebouncedInput
fullWidth
type="text"
value={(columnFilterValue ?? '') as string}
onChange={value => { column.setFilterValue(value) }}
placeholder={`Search... (${column.getFacetedUniqueValues().size})`}
className="w-36 border shadow rounded"
list={column.id + 'list'}
/>
<div className="h-1" />
</>
)
}
const columnHelper = createColumnHelper<TeamApplication>()
const columns = [
columnHelper.accessor('id', {
header: 'ID'
}),
columnHelper.accessor('status', {
header: 'Estado',
enableSorting: false
}),
columnHelper.accessor('role', {
header: 'Rol',
enableSorting: false
}),
columnHelper.accessor('name', {
header: 'Nombre'
}),
columnHelper.accessor('message', {
header: 'Mensaje',
minSize: 450
}),
columnHelper.accessor('email', {
header: 'Correo'
}),
columnHelper.accessor('discord', {
header: 'Discord'
}),
columnHelper.accessor('createdAt', {
header: 'Creado',
cell: (info) => {
return formatDate(new Date(info.getValue()))
}
}),
columnHelper.accessor('updatedAt', {
header: 'Actualizado',
cell: (info) => {
return formatDate(new Date(info.getValue()))
}
})
]
const ApplicationsList: FC = () => {
const { manageError } = useManageError()
const [applications, setApplications] = useState<TeamApplication[]>([])
const table = useReactTable({
data: applications,
columns,
filterFns: {
fuzzy: fuzzyFilter
},
initialState: {
columnVisibility: {
id: false
},
sorting: [{ id: 'createdAt', desc: true }]
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
})
const getTeamApplications = useCallback(async (controller?: AbortController) => {
const callController = controller ?? new AbortController()
const response = await fetch('/api/team-applications', { signal: callController.signal })
if (response.ok) {
const teamApplicationList: TeamApplicationList = await response.json()
setApplications(teamApplicationList.teamApplications)
}
}, [])
useEffect(() => {
const controller = new AbortController()
getTeamApplications(controller)
.catch((error) => {
if (error instanceof Error && error.name === 'AbortError') return
manageError(error, 'Error al obtener las aplicaciones', 'Error desconocido al obtener las aplicaciones', 'error')
})
return () => {
controller.abort()
}
}, [])
// TODO: Better UI Controls for: column visibility. Quantity selector.
return (
<>
<div
className={css({
display: 'flex',
gap: 'small',
marginBottom: 'small'
})}
>
{table.getAllLeafColumns().map((column) => (
<div key={column.id}>
<label htmlFor={`${column.id}-view`}>
<input
type="checkbox"
id={`${column.id}-view`}
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/> {column.columnDef.header?.toString() ?? column.id}
</label>
</div>
))}
</div>
<TableContainer>
<Table>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeadCell
key={header.id}
className={css({
position: 'relative',
'&:hover > [data-is-resizing]': {
backgroundColor: 'border'
}
})}
style={{ minWidth: header.getSize() }}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 'small'
})}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }
{header.column.getCanSort() && (
<IconButton
size="small"
onClick={header.column.getToggleSortingHandler()}
>
<FontAwesomeIcon icon={header.column.getIsSorted() === 'asc' ? faSortAsc : header.column.getIsSorted() === 'desc' ? faSortDesc : faSort} size="sm" fixedWidth />
</IconButton>
)}
</div>
<div>
{header.column.getCanFilter()
? (
<div>
<Filter column={header.column} table={table} />
</div>
)
: null
}
</div>
<div
className={css({
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: '5px',
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
'&:hover': {
backgroundColor: 'border'
},
'&[data-is-resizing=true]': {
backgroundColor: 'primary'
}
})}
style={{
transform: `translateX(${1 * (table.getState().columnSizingInfo
.deltaOffset ?? 0)}px)`
}}
data-is-resizing={header.column.getIsResizing()}
onDoubleClick={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onMouseDown={header.getResizeHandler()}
/>
</TableHeadCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<div
className={css({
display: 'flex',
gap: 'small',
justifyContent: 'space-between',
alignItems: 'center',
paddingBlock: 'medium'
})}
>
<IconButton
onClick={() => { table.previousPage() }}
disabled={!table.getCanPreviousPage()}
>
<FontAwesomeIcon icon={faChevronLeft} fixedWidth />
</IconButton>
Pagina {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
<IconButton
onClick={() => { table.nextPage() }}
disabled={!table.getCanNextPage()}
>
<FontAwesomeIcon icon={faChevronRight} fixedWidth />
</IconButton>
</div>
</>
)
}
export default ApplicationsList
@@ -0,0 +1,73 @@
'use client'
import TeamApplications from '@/app/dashboard/_components/TeamApplications'
import Button from '@/components/ui/Button'
import ButtonGroup from '@/components/ui/ButtonGroup'
import Typography from '@/components/ui/Typography'
import useSession from '@/hooks/useSession'
import { css } from '@/styled-system/css'
import { ADMIN_CLAN_ID } from 'entgamers-database/frontend/clanes/administrative'
import { AnimatePresence, motion } from 'framer-motion'
import { useState, type FC } from 'react'
type Tab = undefined | 'teamApplications'
const DashboardTabs: FC = () => {
const { clanes, belongToClan } = useSession('/login')
const [currentTab, setCurrentTab] = useState<Tab>(undefined)
return (
<>
{clanes !== undefined && (
<ButtonGroup
fullWidth
>
<Button fullWidth onClick={() => { setCurrentTab(undefined) }} disabled={currentTab === undefined}>
Panel
</Button>
{belongToClan(ADMIN_CLAN_ID) && (
<Button fullWidth onClick={() => { setCurrentTab('teamApplications') }} disabled={currentTab === 'teamApplications'}>
Aplicaciones
</Button>
)}
</ButtonGroup>
)}
<div
className={css({
overflow: 'hidden',
marginTop: 'medium'
})}
>
<AnimatePresence
initial={false}
mode='wait'
>
{currentTab === undefined && (
<motion.div
transition={{ duration: 0.15, ease: 'easeInOut' }}
initial={{ opacity: 0, x: '-100%' }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: '100%' }}
key="dashboard"
>
<Typography variant="body1">Selecciona una de las opciones de arriba para comenzar.</Typography>
</motion.div>
)}
{currentTab === 'teamApplications' && (
<motion.div
transition={{ duration: 0.15, ease: 'easeInOut' }}
initial={{ opacity: 0, x: '-100%' }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: '100%' }}
key="teamApplications"
>
<TeamApplications />
</motion.div>
)}
</AnimatePresence>
</div>
</>
)
}
export default DashboardTabs
@@ -0,0 +1,14 @@
import ApplicationsList from '@/app/dashboard/_components/ApplicationsList'
import Typography from '@/components/ui/Typography'
import { type FC } from 'react'
const TeamApplications: FC = () => {
return (
<>
<Typography variant="h2">Aplicaciones</Typography>
<ApplicationsList />
</>
)
}
export default TeamApplications
+15
View File
@@ -0,0 +1,15 @@
import Typography from '@/components/ui/Typography'
import { Container } from '@/styled-system/jsx'
import { type FC } from 'react'
import DashboardTabs from './_components/DashboardTabs'
const page: FC = () => {
return (
<Container>
<Typography variant="h1" align="center">Panel de control</Typography>
<DashboardTabs />
</Container>
)
}
export default page
+4 -74
View File
@@ -1,32 +1,20 @@
'use client'
import EntGamers from '@/assets/logos/EntGamers'
import Menu from '@/components/layout/Menu'
import IconButton from '@/components/ui/IconButton'
import Tooltip from '@/components/ui/Tooltip'
import { useAppDispatch } from '@/hooks/useAppDispatch'
import { useAppSelector } from '@/hooks/useAppSelector'
import { addAlert } from '@/state/feedbackSlice'
import { setSession } from '@/state/sessionSlice'
import { css } from '@/styled-system/css'
import { Container } from '@/styled-system/jsx'
import { iconButton } from '@/styled-system/recipes'
import { faRightFromBracket, faUser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { nanoid } from '@reduxjs/toolkit'
import { AppwriteException } from 'appwrite'
import { logout } from 'entgamers-database/frontend/session'
import NextLink from 'next/link'
import { useCallback, useEffect, useState, type FC } from 'react'
import SessionButtons from './Header/SessionButtons'
const Header: FC = () => {
const session = useAppSelector(state => state.session)
const dispatch = useAppDispatch()
const [isScrolled, setIsScrolled] = useState(typeof window !== 'undefined' ? window.scrollY > 0 : false)
const handleScroll = useCallback(() => {
if (typeof window === 'undefined') return
setIsScrolled(window.scrollY > 0)
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
window.addEventListener('scroll', handleScroll)
@@ -79,65 +67,7 @@ const Header: FC = () => {
</NextLink>
</div>
<div>
{session.status === 'idle' && typeof session.session !== 'undefined'
? (
<>
<Tooltip
title="Cuenta"
position="bottom"
>
<NextLink
href="/cuenta"
className={
iconButton()
}
>
<FontAwesomeIcon icon={faUser} fixedWidth />
</NextLink>
</Tooltip>
<Tooltip
title="Cerrar sesión"
position="bottom"
>
<IconButton
onClick={() => {
logout('current')
.then(() => {
dispatch(setSession(undefined))
})
.catch((error) => {
if (error instanceof AppwriteException) {
dispatch(addAlert({
id: nanoid(),
message: error.message,
title: 'Error mientras se cerraba sesión',
severity: 'error'
}))
}
})
}}
>
<FontAwesomeIcon icon={faRightFromBracket} fixedWidth />
</IconButton>
</Tooltip>
</>
)
: (
<Tooltip
title="Iniciar sesión"
position="bottom"
>
<NextLink
href="/login"
className={
iconButton()
}
>
<FontAwesomeIcon icon={faUser} fixedWidth />
</NextLink>
</Tooltip>
)
}
<SessionButtons />
<Menu />
</div>
</Container>
@@ -0,0 +1,102 @@
import IconButton from '@/components/ui/IconButton'
import Tooltip from '@/components/ui/Tooltip'
import { useAppDispatch } from '@/hooks/useAppDispatch'
import { useAppSelector } from '@/hooks/useAppSelector'
import useManageError from '@/hooks/useManageError'
import { setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
import { iconButton } from '@/styled-system/recipes'
import { faCogs, faRightFromBracket } from '@fortawesome/free-solid-svg-icons'
import { faUser } from '@fortawesome/free-solid-svg-icons/faUser'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { ADMIN_CLAN_ID, MODERATOR_CLAN_ID } from 'entgamers-database/frontend/clanes/administrative'
import { logout } from 'entgamers-database/frontend/session'
import NextLink from 'next/link'
import { type FC } from 'react'
const SessionButtons: FC = () => {
const { session, status, clanes } = useAppSelector(state => state.session)
const { manageError } = useManageError()
const dispatch = useAppDispatch()
if (status !== 'idle') return null
if (status === 'idle' && session === undefined) {
return (
<>
<Tooltip
title={'Iniciar sesión'}
position='bottom'
>
<NextLink
href="/login"
className={
iconButton()
}
>
<FontAwesomeIcon icon={faUser} fixedWidth />
</NextLink>
</Tooltip>
</>
)
}
if (status === 'idle' && session !== undefined) {
return (
<>
<Tooltip
title={'Mi cuenta'}
position='bottom'
>
<NextLink
href="/cuenta"
className={
iconButton()
}
>
<FontAwesomeIcon icon={faUser} fixedWidth />
</NextLink>
</Tooltip>
{clanes !== undefined && clanes?.teams.some(team => team.$id === ADMIN_CLAN_ID || team.$id === MODERATOR_CLAN_ID) && (
<Tooltip
title={'Panel de administración'}
position='bottom'
>
<NextLink
href="/dashboard"
className={
iconButton()
}
>
<FontAwesomeIcon icon={faCogs} fixedWidth />
</NextLink>
</Tooltip>
)}
<Tooltip
title={'Cerrar sesión'}
position='bottom'
>
<IconButton
onClick={() => {
dispatch(setStatus('loading'))
logout('current')
.then(() => {
dispatch(setSession(undefined))
dispatch(setCurrentUser(undefined))
})
.catch((error) => {
manageError(error, 'Error al cerrar sesión', 'Error desconocido al cerrar sesión')
})
.finally(() => {
dispatch(setStatus('idle'))
})
}}
>
<FontAwesomeIcon icon={faRightFromBracket} fixedWidth />
</IconButton>
</Tooltip>
</>
)
}
}
export default SessionButtons
+18
View File
@@ -0,0 +1,18 @@
import { cx } from '@/styled-system/css'
import { skeleton, type SkeletonVariantProps } from '@/styled-system/recipes/skeleton'
import { type MergeOmitting } from '@/types/utilities'
import { type DetailedHTMLProps, type FC, type HTMLAttributes } from 'react'
export type SkeletonProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, SkeletonVariantProps & { children?: never }>
const Skeleton: FC<SkeletonProps> = ({ children: _, className, ...props }) => {
const [skeletonRecipeArgs, allOtherSkeletonProps] = skeleton.splitVariantProps(props)
return (
<div
{...allOtherSkeletonProps}
className={cx(skeleton(skeletonRecipeArgs), className)}
/>
)
}
export default Skeleton
+112
View File
@@ -0,0 +1,112 @@
import { cx } from '@/styled-system/css'
import { table, type TableVariantProps } from '@/styled-system/recipes/table'
import { type MergeOmitting } from '@/types/utilities'
import { type DetailedHTMLProps, type FC, type HTMLAttributes } from 'react'
type TableContainerProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, TableVariantProps>
export const TableContainer: FC<TableContainerProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<div
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).container, className)}
>
{children}
</div>
)
}
type TableProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableElement>, HTMLTableElement>, TableVariantProps>
export const Table: FC<TableProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<table
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).table, className)}
>
{children}
</table>
)
}
type TableHeadProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
export const TableHead: FC<TableHeadProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<thead
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).thead, className)}
>
{children}
</thead>
)
}
type TableBodyProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
export const TableBody: FC<TableBodyProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<tbody
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).tbody, className)}
>
{children}
</tbody>
)
}
type TableFootProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
export const TableFoot: FC<TableFootProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<tfoot
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).tfoot, className)}
>
{children}
</tfoot>
)
}
type TableRowProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement>, TableVariantProps>
export const TableRow: FC<TableRowProps> = ({ children, className, ...props }) => {
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
return (
<tr
{...allOtherTableProps}
className={cx(table(tableRecipeArgs).tr, className)}
>
{children}
</tr>
)
}
type TableCellProps = DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>
export const TableCell: FC<TableCellProps> = ({ children, ...props }) => {
return (
<td
{...props}
>
{children}
</td>
)
}
type TableHeadCellProps = DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>
export const TableHeadCell: FC<TableHeadCellProps> = ({ children, ...props }) => {
return (
<th
{...props}
>
{children}
</th>
)
}
+34
View File
@@ -0,0 +1,34 @@
import Input, { type InputProps } from '@/components/ui/form/Input'
import { useEffect, useState, type FC } from 'react'
interface DebouncedInputProps extends Omit<InputProps, 'value' | 'onChange'> {
value: string | number
onChange: (value: string | number) => void
debounce?: number
}
const DebouncedInput: FC<DebouncedInputProps> = ({ value: initialValue, onChange, debounce = 500, ...props }) => {
const [value, setValue] = useState(initialValue)
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
const timeout = setTimeout(() => {
onChange(value)
}, debounce)
return () => { clearTimeout(timeout) }
}, [value])
return (
<Input
{...props}
value={value}
onChange={e => { setValue(e.target.value) }}
/>
)
}
export default DebouncedInput
+9 -4
View File
@@ -1,21 +1,26 @@
import { type SessionState } from '@/state/sessionSlice'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useCallback, useEffect } from 'react'
import { useAppSelector } from './useAppSelector'
type UseSession = (redirect?: string) => SessionState
type UseSession = (redirect?: string) => SessionState & { belongToClan: (clanId: string) => boolean }
const useSession: UseSession = (redirect?: string) => {
const { status, session, user } = useAppSelector((state) => state.session)
const { status, session, user, clanes } = useAppSelector((state) => state.session)
const router = useRouter()
const belongToClan = useCallback((clanId: string): boolean => {
if (session === undefined || clanes === undefined || clanes.total === 0) return false
return clanes.teams.some((team) => team.$id === clanId) ?? false
}, [clanes])
useEffect(() => {
if (status === 'idle' && session === undefined) {
router.push(redirect ?? '/')
}
}, [status, session])
return { status, session, user }
return { status, session, user, clanes, belongToClan }
}
export default useSession
+9 -1
View File
@@ -1,5 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { type Models } from 'appwrite'
import { type ClanList } from 'entgamers-database/frontend/clanes'
import { type UserWithPreferences } from 'entgamers-database/frontend/session'
export type SessionState =
@@ -7,6 +8,7 @@ export type SessionState =
status: 'idle' | 'loading' | 'initializing'
session?: Models.Session
user?: UserWithPreferences
clanes?: ClanList
}
const initialState: SessionState = {
@@ -34,10 +36,16 @@ const sessionSlice = createSlice({
...state,
user: action.payload
}
},
setClanes: (state, action: PayloadAction<SessionState['clanes']>) => {
return {
...state,
clanes: action.payload
}
}
}
})
export const { setStatus, setSession, setCurrentUser } = sessionSlice.actions
export const { setStatus, setSession, setCurrentUser, setClanes } = sessionSlice.actions
export default sessionSlice
-13
View File
@@ -1,13 +0,0 @@
import { type Models } from 'appwrite'
export interface TeamApplyData {
name: string
email: string
discordName: string
message: string
role: 'moderator' | 'administrator' | 'collaborator'
}
export type TeamApplyDocument = Models.Document & TeamApplyData
export type TeamApplyList = Models.DocumentList<TeamApplyDocument>
+10
View File
@@ -0,0 +1,10 @@
import { format, formatDistance, setDefaultOptions } from 'date-fns'
import { es } from 'date-fns/locale'
setDefaultOptions({ locale: es })
const DATE_FORMAT = 'yyyy-MM-dd HH:mm'
export const formatDate = (date: Date): string => format(date, DATE_FORMAT)
export const formatDistanceDate = (date: Date): string => formatDistance(date, new Date(), { locale: es })
+3 -1
View File
@@ -1,5 +1,5 @@
import { type PaginationOptions } from '@/types/api'
import { type TeamApplication } from 'entgamers-database/backend/teamApplication'
import { type TeamApplication, type TeamApplicationList } from 'entgamers-database/backend/teamApplication'
import { number, object, string, type ObjectSchema } from 'yup'
export interface TeamApplicationDynamicParams {
@@ -19,6 +19,8 @@ export interface TeamApplicationSearchParams extends PaginationOptions {
export type TeamApplicationData = Omit<TeamApplication, 'id' | 'createdAt' | 'updatedAt' >
export { type TeamApplication, type TeamApplicationList }
export const teamApplicationDataSchema: ObjectSchema<TeamApplicationData> = object({
name: string().required('El nombre es obligatorio'),
email: string().email('Invalid email').required('El email es obligatorio'),