mirror of
https://github.com/Amperra-Group/xcs.git
synced 2025-08-16 13:22:14 -06:00
517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
/* eslint-disable react-hooks/rules-of-hooks */
|
|
import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
ButtonGroup,
|
|
Container,
|
|
Flex,
|
|
Heading,
|
|
Icon,
|
|
IconButton,
|
|
Input,
|
|
Skeleton,
|
|
Spacer,
|
|
Stack,
|
|
Table,
|
|
TableContainer,
|
|
Tbody,
|
|
Td,
|
|
Text,
|
|
Th,
|
|
Thead,
|
|
Tooltip,
|
|
Tr,
|
|
useColorModeValue,
|
|
useDisclosure,
|
|
useToast
|
|
} from '@chakra-ui/react';
|
|
|
|
import { Link } from '@chakra-ui/next-js';
|
|
|
|
import { MdMail, MdOutlineAddCircle, MdOutlineJoinRight } from 'react-icons/md';
|
|
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
|
|
import { useAuthContext } from '@/contexts/AuthContext';
|
|
|
|
import Layout from '@/layouts/PlatformLayout';
|
|
|
|
import CreateOrganizationDialog from '@/components/CreateOrganizationDialog';
|
|
import JoinOrganizationDialog from '@/components/JoinOrganizationDialog';
|
|
import UserInvitationsModal from '@/components/UserInvitationsModal';
|
|
import { Organization, OrganizationMember } from '@/types';
|
|
import moment from 'moment';
|
|
import { AiFillSetting } from 'react-icons/ai';
|
|
import { BiGrid, BiRefresh } from 'react-icons/bi';
|
|
import { BsListUl } from 'react-icons/bs';
|
|
|
|
const toRelativeTime = (date: string) => {
|
|
return moment(new Date(date)).fromNow();
|
|
};
|
|
const toActualTime = (date: string) => {
|
|
return moment(new Date(date)).format('MMMM Do YYYY, h:mm:ss a');
|
|
}
|
|
|
|
function GridEntry({ key, organization }: { key: Key, organization?: Organization }) {
|
|
return <>
|
|
<Tooltip placement={'top'} label={organization?.name} key={key}>
|
|
<Flex flexDir={'column'} w={{ base: '45%', md: '128px', lg: '224px' }}>
|
|
{/* icon */}
|
|
<Skeleton isLoaded={!!organization}>
|
|
<Flex
|
|
border={'1px solid'}
|
|
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
|
borderRadius={'lg'}
|
|
overflow={'hidden'}
|
|
aspectRatio={1 / 1}
|
|
>
|
|
<Avatar
|
|
as={Link}
|
|
href={`/organizations/${organization?.id}/settings`}
|
|
ignoreFallback={true}
|
|
borderRadius={'none'}
|
|
size={'lg'}
|
|
src={organization?.avatar || '/images/default-avatar-organization.png'}
|
|
cursor={'pointer'}
|
|
w={'full'}
|
|
h={'full'}
|
|
transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }}
|
|
/>
|
|
</Flex>
|
|
</Skeleton>
|
|
{/* text */}
|
|
<Skeleton isLoaded={!!organization} my={4} px={2}>
|
|
<Flex flexDir={'column'} textUnderlineOffset={4}>
|
|
<Heading
|
|
as={'h3'}
|
|
size={'md'}
|
|
fontWeight={'bold'}
|
|
noOfLines={1}
|
|
wordBreak={'break-word'}
|
|
>
|
|
<Link href={`/organizations/${organization?.id}/settings`}>
|
|
{organization?.name || "Organization"}
|
|
</Link>
|
|
</Heading>
|
|
<Text color={"gray.500"}>
|
|
by{' '}
|
|
<Link href={`/@${organization?.owner?.username}`}>
|
|
{organization?.owner?.displayName}
|
|
</Link>
|
|
</Text>
|
|
<Tooltip label={toActualTime(organization?.updatedAt as string)} cursor={'help'}>
|
|
<Flex align={'center'} color={'gray.500'} gap={1} fontSize={'md'}>
|
|
<Icon as={BiRefresh} />
|
|
<Text color={'gray.500'}>
|
|
{
|
|
toRelativeTime(organization?.updatedAt as string)
|
|
}
|
|
</Text>
|
|
</Flex>
|
|
</Tooltip>
|
|
</Flex>
|
|
</Skeleton>
|
|
</Flex>
|
|
</Tooltip>
|
|
</>
|
|
}
|
|
|
|
function TableEntry({ key, organization, skeleton }: { key: number | string, organization?: Organization, skeleton?: boolean }) {
|
|
const { push } = useRouter();
|
|
|
|
return <>
|
|
<Tr key={key}>
|
|
<Td>
|
|
<Stack flexDir={'row'} align={'center'}>
|
|
<Skeleton isLoaded={!skeleton}>
|
|
<Tooltip label={organization?.name} placement={'top'}>
|
|
<Avatar as={Link} href={`/organizations/${organization?.id}`} transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }} borderRadius={'lg'} size={'md'} src={organization?.avatar || '/images/default-avatar-organization.png'} />
|
|
</Tooltip>
|
|
</Skeleton>
|
|
|
|
<Flex flexDir={'column'} mx={2} justify={'center'}>
|
|
<Skeleton isLoaded={!skeleton}>
|
|
<Text fontWeight={'bold'}>
|
|
{!skeleton ? organization?.name : "Organization Name"}
|
|
</Text>
|
|
<Text size={'sm'} variant={'subtext'} textUnderlineOffset={4}>
|
|
Owned by {!skeleton ? <Link href={`/@${organization?.owner?.username}`}>{organization?.owner?.displayName}</Link> : "Organization Owner"}
|
|
</Text>
|
|
<Tooltip label={toActualTime(organization?.updatedAt as string)}>
|
|
<Flex align={'center'} color={'gray.500'} gap={1} w={'fit-content'}>
|
|
<Icon as={BiRefresh} />
|
|
<Text size={'sm'} textUnderlineOffset={4} cursor={'help'}>
|
|
{!skeleton ? toRelativeTime(organization?.updatedAt) : "Last Updated"}
|
|
{!skeleton && organization?.updatedBy && " by "}
|
|
{!skeleton ? <Link href={`/@${organization?.updatedBy?.username}`}>{organization?.updatedBy?.displayName}</Link> : "Organization Owner"}
|
|
</Text>
|
|
</Flex>
|
|
</Tooltip>
|
|
<Text size={'sm'} variant={'subtext'} maxW={{ base: '500px', md: '384px', lg: '500px' }} overflow={'hidden'} textOverflow={'ellipsis'}>
|
|
{!skeleton ? organization?.description : "Organization Description"}
|
|
</Text>
|
|
</Skeleton>
|
|
</Flex>
|
|
</Stack>
|
|
</Td>
|
|
<Td isNumeric>
|
|
<Skeleton isLoaded={!skeleton}>
|
|
<ButtonGroup>
|
|
<Button
|
|
onClick={() => {
|
|
push(`/organizations/${organization?.id}`);
|
|
}}
|
|
size={"sm"}
|
|
variant={"solid"}
|
|
textDecor={"unset !important"}
|
|
>
|
|
View Public Page
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
push(`/organizations/${organization?.id}/settings`);
|
|
}}
|
|
size={"sm"}
|
|
variant={"solid"}
|
|
colorScheme='black'
|
|
textDecor={"unset !important"}
|
|
leftIcon={<Icon as={AiFillSetting} />}
|
|
>
|
|
Manage
|
|
</Button>
|
|
</ButtonGroup>
|
|
</Skeleton>
|
|
</Td>
|
|
</Tr>
|
|
</>
|
|
}
|
|
export default function PlatformOrganizations() {
|
|
const { user, currentUser } = useAuthContext();
|
|
const { push, query } = useRouter();
|
|
const toast = useToast();
|
|
|
|
const [queryLoading, setQueryLoading] = useState<boolean>(true);
|
|
const [organizationsLoading, setOrganizationsLoading] = useState<boolean>(true);
|
|
|
|
const [organizations, setOrganizations] = useState<any>([]);
|
|
const [filteredOrganizations, setFilteredOrganizations] = useState<any>([]);
|
|
const [initialInviteCodeValue, setInitialInviteCodeValue] = useState<string | null>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [view, setView] = useState<'list' | 'grid' | null>(null);
|
|
|
|
// modal disclosure hooks
|
|
|
|
const {
|
|
isOpen: isCreateOrganizationModalOpen,
|
|
onOpen: onCreateOrganizationModalOpen,
|
|
onClose: onCreateOrganizationModalClose
|
|
} = useDisclosure();
|
|
|
|
const {
|
|
isOpen: isJoinOrganizationModalOpen,
|
|
onOpen: onJoinOrganizationModalOpen,
|
|
onClose: onJoinOrganizationModalClose
|
|
} = useDisclosure();
|
|
|
|
const {
|
|
isOpen: isViewInvitationsModalOpen,
|
|
onOpen: onViewInvitationsModalOpen,
|
|
onClose: onViewInvitationsModalClose
|
|
} = useDisclosure();
|
|
|
|
const refreshData = useCallback(async () => {
|
|
setOrganizationsLoading(true);
|
|
const token = await user?.getIdToken().then((token: string) => token);
|
|
await fetch('/api/v1/me/organizations', {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
}).then((res) => {
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to fetch organizations.');
|
|
}
|
|
res
|
|
.json()
|
|
.then((data) => {
|
|
setTimeout(() => {
|
|
setOrganizations(data.organizations);
|
|
setOrganizationsLoading(false);
|
|
}, 100);
|
|
})
|
|
.catch((err) => {
|
|
toast({
|
|
title: 'Error',
|
|
description: err.message,
|
|
status: 'error',
|
|
duration: 5000,
|
|
isClosable: true
|
|
});
|
|
});
|
|
}).catch((err) => {
|
|
toast({
|
|
title: 'There was an error fetching your organizations.',
|
|
description: err.message,
|
|
status: 'error',
|
|
duration: 5000,
|
|
isClosable: true
|
|
});
|
|
});
|
|
}, [toast, user]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
refreshData();
|
|
}, [user, refreshData]);
|
|
|
|
const joinOrganizationPrompt = useCallback(async (inviteCode: string) => {
|
|
setQueryLoading(true);
|
|
setInitialInviteCodeValue(inviteCode);
|
|
setQueryLoading(false);
|
|
onJoinOrganizationModalOpen();
|
|
}, [onJoinOrganizationModalOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!query) return;
|
|
if (query.invitation) {
|
|
joinOrganizationPrompt(query.invitation as string);
|
|
} else {
|
|
setTimeout(() => {
|
|
setQueryLoading(false);
|
|
}, 100);
|
|
}
|
|
}, [query.invitation, joinOrganizationPrompt, query]);
|
|
|
|
const filterOrganizations = useCallback((query: string) => {
|
|
if (!query) {
|
|
setFilteredOrganizations(organizations);
|
|
return;
|
|
}
|
|
const filtered = organizations.filter((organization: Organization) => {
|
|
return organization.name.toLowerCase().includes(query.toLowerCase()) || organization.owner?.displayName?.toLowerCase().includes(query.toLowerCase());
|
|
});
|
|
setFilteredOrganizations(filtered);
|
|
}, [organizations]);
|
|
|
|
const toRelativeTime = useMemo(() => (date: string) => {
|
|
return moment(new Date(date)).fromNow();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// load view option from local storage
|
|
const viewOption = localStorage.getItem('organizationView');
|
|
if (viewOption) {
|
|
setView(viewOption as 'list' | 'grid');
|
|
} else {
|
|
setView('list');
|
|
localStorage.setItem('organizationView', 'list');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// cache view option in local storage
|
|
if (!view) return;
|
|
localStorage.setItem('organizationView', view);
|
|
}, [view]);
|
|
|
|
useEffect(() => {
|
|
if (!organizations) return;
|
|
setFilteredOrganizations(organizations);
|
|
}, [organizations]);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>Organizations - Amperra Wyre</title>
|
|
<meta
|
|
property="og:title"
|
|
content="Manage Organizations - Amperra Wyre"
|
|
/>
|
|
<meta
|
|
property="og:site_name"
|
|
content="Amperra Wyre"
|
|
/>
|
|
<meta
|
|
property="og:url"
|
|
content="https://wyre.ryj.my.id"
|
|
/>
|
|
<meta
|
|
property="og:type"
|
|
content="website"
|
|
/>
|
|
<meta
|
|
property="og:image"
|
|
content="/images/logo-square.jpg"
|
|
/>
|
|
</Head>
|
|
<CreateOrganizationDialog
|
|
isOpen={isCreateOrganizationModalOpen}
|
|
onClose={onCreateOrganizationModalClose}
|
|
onCreate={(id) => {
|
|
push(`/organizations/${id}/settings`);
|
|
}}
|
|
/>
|
|
{!queryLoading && (
|
|
<JoinOrganizationDialog
|
|
isOpen={isJoinOrganizationModalOpen}
|
|
onClose={onJoinOrganizationModalClose}
|
|
onJoin={(id) => {
|
|
push(`/organizations/${id}/settings`);
|
|
}}
|
|
initialValue={initialInviteCodeValue || ''}
|
|
/>
|
|
)}
|
|
<UserInvitationsModal
|
|
isOpen={isViewInvitationsModalOpen}
|
|
onClose={onViewInvitationsModalClose}
|
|
onRefresh={refreshData}
|
|
/>
|
|
<Container
|
|
maxW={'full'}
|
|
p={8}
|
|
>
|
|
<Text
|
|
as={'h1'}
|
|
fontSize={'4xl'}
|
|
fontWeight={'900'}
|
|
pb={4}
|
|
>
|
|
Organizations
|
|
</Text>
|
|
<Stack
|
|
flexDir={{ base: 'column', lg: 'row' }}
|
|
display={'flex'}
|
|
justify={'flex-start'}
|
|
gap={4}
|
|
>
|
|
<Button
|
|
variant={'solid'}
|
|
leftIcon={<MdOutlineAddCircle />}
|
|
onClick={onCreateOrganizationModalOpen}
|
|
>
|
|
New Organization
|
|
</Button>
|
|
<Button
|
|
variant={'solid'}
|
|
leftIcon={<MdOutlineJoinRight />}
|
|
onClick={onJoinOrganizationModalOpen}
|
|
>
|
|
Join Organization
|
|
</Button>
|
|
<Button
|
|
variant={'solid'}
|
|
leftIcon={<MdMail />}
|
|
onClick={onViewInvitationsModalOpen}
|
|
>
|
|
View Invitations
|
|
<Flex as={'span'} fontFamily={'sans-serif'} align={'center'} justify={'center'} ml={2} px={2} borderRadius={'full'} minW={'1.5em'} h={'1.5em'} bg={useColorModeValue('blackAlpha.100', 'whiteAlpha.200')}>{" "}{currentUser?.statistics?.organizationInvitations || 0}</Flex>
|
|
</Button>
|
|
<Input
|
|
placeholder={'Search'}
|
|
variant={'filled'}
|
|
w={{ base: 'full', md: 'auto' }}
|
|
ref={searchRef}
|
|
onChange={(e) => {
|
|
filterOrganizations(e.target.value);
|
|
}}
|
|
/>
|
|
<Spacer />
|
|
<Flex w={'fit-content'} gap={4}>
|
|
<ButtonGroup>
|
|
<Tooltip label={'Refresh'} placement={'top'}>
|
|
<IconButton aria-label={'Refresh'} icon={<BiRefresh />}
|
|
onClick={refreshData}
|
|
/>
|
|
</Tooltip>
|
|
</ButtonGroup>
|
|
<ButtonGroup isAttached>
|
|
<Tooltip label={'List'} placement={'top'}>
|
|
<IconButton aria-label={'List'} variant={view === "list" ? "solid" : "unselected"} onClick={() => { setView('list') }} icon={<BsListUl />} />
|
|
</Tooltip>
|
|
<Tooltip label={'Grid'} placement={'top'}>
|
|
<IconButton aria-label={'Grid'} variant={view === "grid" ? "solid" : "unselected"} onClick={() => { setView('grid') }} icon={<BiGrid />} />
|
|
</Tooltip>
|
|
</ButtonGroup>
|
|
</Flex>
|
|
</Stack>
|
|
<Box w={"full"}>
|
|
<Flex
|
|
as={Stack}
|
|
direction={'row'}
|
|
h={'full'}
|
|
spacing={4}
|
|
overflow={'auto'}
|
|
flexWrap={'wrap'}
|
|
>
|
|
{
|
|
view === 'list' ? (
|
|
<TableContainer
|
|
py={2}
|
|
maxW={{ base: 'full' }}
|
|
overflowY={'auto'}
|
|
flexGrow={1}
|
|
px={4}
|
|
>
|
|
<Table size={{ base: 'md', md: 'md' }}>
|
|
<Thead>
|
|
<Tr>
|
|
<Th>Organization</Th>
|
|
<Th isNumeric>Actions</Th>
|
|
</Tr>
|
|
</Thead>
|
|
{/* display list of organizations */}
|
|
<Tbody>
|
|
{
|
|
organizationsLoading ? (
|
|
Array.from({ length: 6 }).map((_, i) => (
|
|
<TableEntry key={i} organization={undefined} skeleton={true} />
|
|
))
|
|
) : (filteredOrganizations.map((organization: Organization) => (
|
|
<TableEntry key={organization.id as string} organization={organization} skeleton={false} />
|
|
)))
|
|
}
|
|
</Tbody>
|
|
</Table>
|
|
</TableContainer>
|
|
) : (
|
|
<Flex py={8} gap={4} wrap={'wrap'}>
|
|
{
|
|
organizationsLoading ? (
|
|
Array.from({ length: 6 }).map((_, i) => (
|
|
<GridEntry key={i} />
|
|
))
|
|
) : (
|
|
filteredOrganizations.map((organization: Organization) => (
|
|
<GridEntry key={organization.id} organization={organization} />
|
|
))
|
|
)
|
|
}
|
|
</Flex>
|
|
)}
|
|
{
|
|
!organizationsLoading && filteredOrganizations.length === 0 && (
|
|
<Flex
|
|
flexDir={'column'}
|
|
align={'center'}
|
|
justify={'center'}
|
|
w={'full'}
|
|
h={'full'}
|
|
py={4}
|
|
>
|
|
<Text fontSize={'2xl'} fontWeight={'bold'}>No Organizations Found</Text>
|
|
<Text color={'gray.500'}>Try adjusting your search query or create a new organization.</Text>
|
|
</Flex>
|
|
)
|
|
}
|
|
</Flex>
|
|
</Box>
|
|
</Container>
|
|
</>
|
|
);
|
|
}
|
|
|
|
PlatformOrganizations.getLayout = (page: any) => <Layout>{page}</Layout>;
|