diff --git a/bun.lockb b/bun.lockb index 62fb896..6ae294c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8584192..22ba757 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/SessionConsumer.tsx b/src/app/SessionConsumer.tsx index 1ffb22b..6c60c0a 100644 --- a/src/app/SessionConsumer.tsx +++ b/src/app/SessionConsumer.tsx @@ -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 ( <> diff --git a/src/app/dashboard/_components/ApplicationsList.tsx b/src/app/dashboard/_components/ApplicationsList.tsx new file mode 100644 index 0000000..5840046 --- /dev/null +++ b/src/app/dashboard/_components/ApplicationsList.tsx @@ -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 = (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 + table: TableType +}): 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' + ? ( +
+
+ { column.setFilterValue((old: [number, number]) => [value, old?.[1]]) } + } + placeholder={`Min ${ + ((column.getFacetedMinMaxValues()?.[0]) != null) + ? `(${column.getFacetedMinMaxValues()?.[0]})` + : '' + }`} + className="w-24 border shadow rounded" + /> + { column.setFilterValue((old: [number, number]) => [old?.[0], value]) } + } + placeholder={`Max ${ + ((column.getFacetedMinMaxValues()?.[1]) != null) + ? `(${column.getFacetedMinMaxValues()?.[1]})` + : '' + }`} + className="w-24 border shadow rounded" + /> +
+
+
+ ) + : ( + <> + + {sortedUniqueValues.slice(0, 5000).map((value: any) => ( + + { column.setFilterValue(value) }} + placeholder={`Search... (${column.getFacetedUniqueValues().size})`} + className="w-36 border shadow rounded" + list={column.id + 'list'} + /> +
+ + ) +} + +const columnHelper = createColumnHelper() + +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([]) + + 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 ( + <> +
+ {table.getAllLeafColumns().map((column) => ( +
+ +
+ ))} +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + [data-is-resizing]': { + backgroundColor: 'border' + } + })} + style={{ minWidth: header.getSize() }} + > +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) } + {header.column.getCanSort() && ( + + + + )} +
+
+ {header.column.getCanFilter() + ? ( +
+ +
+ ) + : null + } +
+
+ + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+
+ { table.previousPage() }} + disabled={!table.getCanPreviousPage()} + > + + + Pagina {table.getState().pagination.pageIndex + 1} de {table.getPageCount()} + { table.nextPage() }} + disabled={!table.getCanNextPage()} + > + + +
+ + ) +} + +export default ApplicationsList diff --git a/src/app/dashboard/_components/DashboardTabs.tsx b/src/app/dashboard/_components/DashboardTabs.tsx new file mode 100644 index 0000000..00903b0 --- /dev/null +++ b/src/app/dashboard/_components/DashboardTabs.tsx @@ -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(undefined) + + return ( + <> + {clanes !== undefined && ( + + + {belongToClan(ADMIN_CLAN_ID) && ( + + )} + + )} +
+ + {currentTab === undefined && ( + + Selecciona una de las opciones de arriba para comenzar. + + )} + {currentTab === 'teamApplications' && ( + + + + )} + +
+ + ) +} + +export default DashboardTabs diff --git a/src/app/dashboard/_components/TeamApplications.tsx b/src/app/dashboard/_components/TeamApplications.tsx new file mode 100644 index 0000000..2a0d547 --- /dev/null +++ b/src/app/dashboard/_components/TeamApplications.tsx @@ -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 ( + <> + Aplicaciones + + + ) +} + +export default TeamApplications diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..6b7f0c4 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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 ( + + Panel de control + + + ) +} + +export default page diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 52d82fd..2c32dd1 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -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 = () => {
- {session.status === 'idle' && typeof session.session !== 'undefined' - ? ( - <> - - - - - - - { - 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' - })) - } - }) - }} - > - - - - - ) - : ( - - - - - - ) - } +
diff --git a/src/components/layout/Header/SessionButtons.tsx b/src/components/layout/Header/SessionButtons.tsx new file mode 100644 index 0000000..6a26ab0 --- /dev/null +++ b/src/components/layout/Header/SessionButtons.tsx @@ -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 ( + <> + + + + + + + ) + } + + if (status === 'idle' && session !== undefined) { + return ( + <> + + + + + + {clanes !== undefined && clanes?.teams.some(team => team.$id === ADMIN_CLAN_ID || team.$id === MODERATOR_CLAN_ID) && ( + + + + + + )} + + { + 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')) + }) + }} + > + + + + + ) + } +} + +export default SessionButtons diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..8eb24fc --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -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, HTMLDivElement>, SkeletonVariantProps & { children?: never }> + +const Skeleton: FC = ({ children: _, className, ...props }) => { + const [skeletonRecipeArgs, allOtherSkeletonProps] = skeleton.splitVariantProps(props) + return ( +
+ ) +} + +export default Skeleton diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx new file mode 100644 index 0000000..c1dd3c8 --- /dev/null +++ b/src/components/ui/Table.tsx @@ -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, HTMLDivElement>, TableVariantProps> + +export const TableContainer: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( +
+ {children} +
+ ) +} + +type TableProps = MergeOmitting, HTMLTableElement>, TableVariantProps> + +export const Table: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( + + {children} +
+ ) +} + +type TableHeadProps = MergeOmitting, HTMLTableSectionElement>, TableVariantProps> + +export const TableHead: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( + + {children} + + ) +} + +type TableBodyProps = MergeOmitting, HTMLTableSectionElement>, TableVariantProps> + +export const TableBody: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( + + {children} + + ) +} + +type TableFootProps = MergeOmitting, HTMLTableSectionElement>, TableVariantProps> + +export const TableFoot: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( + + {children} + + ) +} + +type TableRowProps = MergeOmitting, HTMLTableRowElement>, TableVariantProps> + +export const TableRow: FC = ({ children, className, ...props }) => { + const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props) + return ( + + {children} + + ) +} + +type TableCellProps = DetailedHTMLProps, HTMLTableCellElement> + +export const TableCell: FC = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +type TableHeadCellProps = DetailedHTMLProps, HTMLTableCellElement> + +export const TableHeadCell: FC = ({ children, ...props }) => { + return ( + + {children} + + ) +} diff --git a/src/components/ui/form/DebouncedInput.tsx b/src/components/ui/form/DebouncedInput.tsx new file mode 100644 index 0000000..d706709 --- /dev/null +++ b/src/components/ui/form/DebouncedInput.tsx @@ -0,0 +1,34 @@ +import Input, { type InputProps } from '@/components/ui/form/Input' +import { useEffect, useState, type FC } from 'react' + +interface DebouncedInputProps extends Omit { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} + +const DebouncedInput: FC = ({ 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 ( + { setValue(e.target.value) }} + /> + ) +} + +export default DebouncedInput diff --git a/src/hooks/useSession.ts b/src/hooks/useSession.ts index 2d8bd59..c54a5db 100644 --- a/src/hooks/useSession.ts +++ b/src/hooks/useSession.ts @@ -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 diff --git a/src/state/sessionSlice.ts b/src/state/sessionSlice.ts index 5f63ba8..9540946 100644 --- a/src/state/sessionSlice.ts +++ b/src/state/sessionSlice.ts @@ -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) => { + return { + ...state, + clanes: action.payload + } } } }) -export const { setStatus, setSession, setCurrentUser } = sessionSlice.actions +export const { setStatus, setSession, setCurrentUser, setClanes } = sessionSlice.actions export default sessionSlice diff --git a/src/types/teamApply.ts b/src/types/teamApply.ts deleted file mode 100644 index bb50058..0000000 --- a/src/types/teamApply.ts +++ /dev/null @@ -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 diff --git a/src/utilities/date.ts b/src/utilities/date.ts new file mode 100644 index 0000000..59ada01 --- /dev/null +++ b/src/utilities/date.ts @@ -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 }) diff --git a/src/utilities/teamApplication.ts b/src/utilities/teamApplication.ts index e052992..69fc684 100644 --- a/src/utilities/teamApplication.ts +++ b/src/utilities/teamApplication.ts @@ -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 +export { type TeamApplication, type TeamApplicationList } + export const teamApplicationDataSchema: ObjectSchema = object({ name: string().required('El nombre es obligatorio'), email: string().email('Invalid email').required('El email es obligatorio'),