feat: static applications dashboard
This commit is contained in:
+4
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user