aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/teams
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/teams')
-rw-r--r--src/app/(main)/teams/TeamAddForm.tsx39
-rw-r--r--src/app/(main)/teams/TeamJoinForm.tsx40
-rw-r--r--src/app/(main)/teams/TeamLeaveButton.tsx41
-rw-r--r--src/app/(main)/teams/TeamLeaveForm.tsx48
-rw-r--r--src/app/(main)/teams/TeamProvider.tsx21
-rw-r--r--src/app/(main)/teams/TeamsAddButton.tsx33
-rw-r--r--src/app/(main)/teams/TeamsDataTable.tsx27
-rw-r--r--src/app/(main)/teams/TeamsHeader.tsx26
-rw-r--r--src/app/(main)/teams/TeamsJoinButton.tsx31
-rw-r--r--src/app/(main)/teams/TeamsPage.tsx19
-rw-r--r--src/app/(main)/teams/TeamsTable.tsx29
-rw-r--r--src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx40
-rw-r--r--src/app/(main)/teams/[teamId]/TeamEditForm.tsx89
-rw-r--r--src/app/(main)/teams/[teamId]/TeamManage.tsx32
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx46
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx62
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx60
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx19
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMembersTable.tsx55
-rw-r--r--src/app/(main)/teams/[teamId]/TeamSettings.tsx49
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx25
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx19
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx50
-rw-r--r--src/app/(main)/teams/page.tsx10
24 files changed, 910 insertions, 0 deletions
diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx
new file mode 100644
index 0000000..c95259f
--- /dev/null
+++ b/src/app/(main)/teams/TeamAddForm.tsx
@@ -0,0 +1,39 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField name="name" label={formatMessage(labels.name)}>
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/TeamJoinForm.tsx b/src/app/(main)/teams/TeamJoinForm.tsx
new file mode 100644
index 0000000..6978078
--- /dev/null
+++ b/src/app/(main)/teams/TeamJoinForm.tsx
@@ -0,0 +1,40 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+export function TeamJoinForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { mutateAsync, error, touch } = useUpdateQuery('/teams/join');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ touch('teams:members');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField
+ label={formatMessage(labels.accessCode)}
+ name="accessCode"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton variant="primary">{formatMessage(labels.join)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/TeamLeaveButton.tsx b/src/app/(main)/teams/TeamLeaveButton.tsx
new file mode 100644
index 0000000..2cca76f
--- /dev/null
+++ b/src/app/(main)/teams/TeamLeaveButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { useLoginQuery, useMessages, useModified } from '@/components/hooks';
+import { LogOut } from '@/components/icons';
+import { TeamLeaveForm } from './TeamLeaveForm';
+
+export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName: string }) {
+ const { formatMessage, labels } = useMessages();
+ const router = useRouter();
+ const { user } = useLoginQuery();
+ const { touch } = useModified();
+
+ const handleLeave = async () => {
+ touch('teams');
+ router.push('/settings/teams');
+ };
+
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <LogOut />
+ </Icon>
+ <Text>{formatMessage(labels.leave)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.leaveTeam)} style={{ width: 400 }}>
+ {({ close }) => (
+ <TeamLeaveForm
+ teamId={teamId}
+ userId={user.id}
+ teamName={teamName}
+ onSave={handleLeave}
+ onClose={close}
+ />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamLeaveForm.tsx b/src/app/(main)/teams/TeamLeaveForm.tsx
new file mode 100644
index 0000000..b3dcaf5
--- /dev/null
+++ b/src/app/(main)/teams/TeamLeaveForm.tsx
@@ -0,0 +1,48 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+
+export function TeamLeaveForm({
+ teamId,
+ userId,
+ teamName,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ userId: string;
+ teamName: string;
+ onSave: () => void;
+ onClose: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('teams:members');
+ onSave();
+ onClose();
+ },
+ });
+ };
+
+ return (
+ <ConfirmationForm
+ buttonLabel={formatMessage(labels.leave)}
+ message={
+ <FormattedMessage
+ {...messages.confirmLeave}
+ values={{
+ target: <b>{teamName}</b>,
+ }}
+ />
+ }
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ />
+ );
+}
diff --git a/src/app/(main)/teams/TeamProvider.tsx b/src/app/(main)/teams/TeamProvider.tsx
new file mode 100644
index 0000000..cea4161
--- /dev/null
+++ b/src/app/(main)/teams/TeamProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useTeamQuery } from '@/components/hooks/queries/useTeamQuery';
+import type { Team } from '@/generated/prisma/client';
+
+export const TeamContext = createContext<Team>(null);
+
+export function TeamProvider({ teamId, children }: { teamId?: string; children: ReactNode }) {
+ const { data: team, isLoading, isFetching } = useTeamQuery(teamId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!team) {
+ return null;
+ }
+
+ return <TeamContext.Provider value={team}>{children}</TeamContext.Provider>;
+}
diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx
new file mode 100644
index 0000000..578a273
--- /dev/null
+++ b/src/app/(main)/teams/TeamsAddButton.tsx
@@ -0,0 +1,33 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { messages } from '@/components/messages';
+import { TeamAddForm } from './TeamAddForm';
+
+export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
+ const { formatMessage, labels } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = async () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ onSave?.();
+ };
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.createTeam)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsDataTable.tsx b/src/app/(main)/teams/TeamsDataTable.tsx
new file mode 100644
index 0000000..cdce7b9
--- /dev/null
+++ b/src/app/(main)/teams/TeamsDataTable.tsx
@@ -0,0 +1,27 @@
+import Link from 'next/link';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useLoginQuery, useNavigation, useUserTeamsQuery } from '@/components/hooks';
+import { TeamsTable } from './TeamsTable';
+
+export function TeamsDataTable() {
+ const { user } = useLoginQuery();
+ const query = useUserTeamsQuery(user.id);
+ const { pathname } = useNavigation();
+ const isSettings = pathname.includes('/settings');
+
+ const renderLink = (row: any) => {
+ return (
+ <Link key={row.id} href={`${isSettings ? '/settings' : ''}/teams/${row.id}`}>
+ {row.name}
+ </Link>
+ );
+ };
+
+ return (
+ <DataGrid query={query}>
+ {({ data }) => {
+ return <TeamsTable data={data} renderLink={renderLink} />;
+ }}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsHeader.tsx b/src/app/(main)/teams/TeamsHeader.tsx
new file mode 100644
index 0000000..579ba59
--- /dev/null
+++ b/src/app/(main)/teams/TeamsHeader.tsx
@@ -0,0 +1,26 @@
+import { Row } from '@umami/react-zen';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useLoginQuery, useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { TeamsAddButton } from './TeamsAddButton';
+import { TeamsJoinButton } from './TeamsJoinButton';
+
+export function TeamsHeader({
+ allowCreate = true,
+ allowJoin = true,
+}: {
+ allowCreate?: boolean;
+ allowJoin?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+
+ return (
+ <PageHeader title={formatMessage(labels.teams)}>
+ <Row gap="3">
+ {allowJoin && <TeamsJoinButton />}
+ {allowCreate && user.role !== ROLES.viewOnly && <TeamsAddButton />}
+ </Row>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsJoinButton.tsx b/src/app/(main)/teams/TeamsJoinButton.tsx
new file mode 100644
index 0000000..017211e
--- /dev/null
+++ b/src/app/(main)/teams/TeamsJoinButton.tsx
@@ -0,0 +1,31 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { UserPlus } from '@/components/icons';
+import { TeamJoinForm } from './TeamJoinForm';
+
+export function TeamsJoinButton() {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleJoin = () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ };
+
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <UserPlus />
+ </Icon>
+ <Text>{formatMessage(labels.joinTeam)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.joinTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamJoinForm onSave={handleJoin} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsPage.tsx b/src/app/(main)/teams/TeamsPage.tsx
new file mode 100644
index 0000000..5b11bcf
--- /dev/null
+++ b/src/app/(main)/teams/TeamsPage.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable';
+import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+
+export function TeamsPage() {
+ return (
+ <PageBody>
+ <Column gap="6">
+ <TeamsHeader />
+ <Panel>
+ <TeamsDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsTable.tsx b/src/app/(main)/teams/TeamsTable.tsx
new file mode 100644
index 0000000..754f0b2
--- /dev/null
+++ b/src/app/(main)/teams/TeamsTable.tsx
@@ -0,0 +1,29 @@
+import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export interface TeamsTableProps extends DataTableProps {
+ renderLink?: (row: any) => ReactNode;
+}
+
+export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {renderLink}
+ </DataColumn>
+ <DataColumn id="owner" label={formatMessage(labels.owner)}>
+ {(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
+ </DataColumn>
+ <DataColumn id="members" label={formatMessage(labels.members)} align="end">
+ {(row: any) => row?._count?.members}
+ </DataColumn>
+ <DataColumn id="websites" label={formatMessage(labels.websites)} align="end">
+ {(row: any) => row?._count?.websites}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx
new file mode 100644
index 0000000..7adc9b3
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx
@@ -0,0 +1,40 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'DELETE';
+
+export function TeamDeleteForm({
+ teamId,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { labels, formatMessage, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch } = useDeleteQuery(`/teams/${teamId}`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('teams');
+ touch(`teams:${teamId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx
new file mode 100644
index 0000000..74e038f
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx
@@ -0,0 +1,89 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ IconLabel,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useTeam, useUpdateQuery } from '@/components/hooks';
+import { RefreshCw } from '@/components/icons';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => `team_${getRandomChars(16)}`;
+
+export function TeamEditForm({
+ teamId,
+ allowEdit,
+ showAccessCode,
+ onSave,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+ showAccessCode?: boolean;
+ onSave?: () => void;
+}) {
+ const team = useTeam();
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/teams/${teamId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ touch(`teams:${teamId}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ ...team }}>
+ {({ setValue }) => {
+ return (
+ <>
+ <FormField name="id" label={formatMessage(labels.teamId)}>
+ <TextField isReadOnly allowCopy />
+ </FormField>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField isReadOnly={!allowEdit} />
+ </FormField>
+ {showAccessCode && (
+ <Row alignItems="flex-end" gap>
+ <FormField
+ name="accessCode"
+ label={formatMessage(labels.accessCode)}
+ style={{ flex: 1 }}
+ >
+ <TextField isReadOnly allowCopy />
+ </FormField>
+ {allowEdit && (
+ <Button
+ onPress={() => setValue('accessCode', generateId(), { shouldDirty: true })}
+ >
+ <IconLabel icon={<RefreshCw />} label={formatMessage(labels.regenerate)} />
+ </Button>
+ )}
+ </Row>
+ )}
+ {allowEdit && (
+ <FormButtons justifyContent="flex-end">
+ <FormSubmitButton variant="primary" isPending={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ )}
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamManage.tsx b/src/app/(main)/teams/[teamId]/TeamManage.tsx
new file mode 100644
index 0000000..88cbad9
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamManage.tsx
@@ -0,0 +1,32 @@
+import { Button, Dialog, DialogTrigger, Modal } from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { ActionForm } from '@/components/common/ActionForm';
+import { useMessages, useModified } from '@/components/hooks';
+import { TeamDeleteForm } from './TeamDeleteForm';
+
+export function TeamManage({ teamId }: { teamId: string }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const router = useRouter();
+ const { touch } = useModified();
+
+ const handleLeave = async () => {
+ touch('teams');
+ router.push('/settings/teams');
+ };
+
+ return (
+ <ActionForm
+ label={formatMessage(labels.deleteTeam)}
+ description={formatMessage(messages.deleteTeamWarning)}
+ >
+ <DialogTrigger>
+ <Button variant="danger">{formatMessage(labels.delete)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamDeleteForm teamId={teamId} onSave={handleLeave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx
new file mode 100644
index 0000000..f75b6d1
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx
@@ -0,0 +1,46 @@
+import { useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { TeamMemberEditForm } from './TeamMemberEditForm';
+
+export function TeamMemberEditButton({
+ teamId,
+ userId,
+ role,
+ onSave,
+}: {
+ teamId: string;
+ userId: string;
+ role: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = () => {
+ touch('teams:members');
+ toast(formatMessage(messages.saved));
+ onSave?.();
+ };
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.editMember)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ <TeamMemberEditForm
+ teamId={teamId}
+ userId={userId}
+ role={role}
+ onSave={handleSave}
+ onClose={close}
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx
new file mode 100644
index 0000000..4826746
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx
@@ -0,0 +1,62 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ Select,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function TeamMemberEditForm({
+ teamId,
+ userId,
+ role,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ userId: string;
+ role: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users/${userId}`);
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave();
+ onClose();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ role }}>
+ <FormField
+ name="role"
+ rules={{ required: formatMessage(labels.required) }}
+ label={formatMessage(labels.role)}
+ >
+ <Select>
+ <ListItem id={ROLES.teamManager}>{formatMessage(labels.manager)}</ListItem>
+ <ListItem id={ROLES.teamMember}>{formatMessage(labels.member)}</ListItem>
+ <ListItem id={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</ListItem>
+ </Select>
+ </FormField>
+
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" isDisabled={false}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx
new file mode 100644
index 0000000..4d3e8e9
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function TeamMemberRemoveButton({
+ teamId,
+ userId,
+ userName,
+ onSave,
+}: {
+ teamId: string;
+ userId: string;
+ userName: string;
+ disabled?: boolean;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('teams:members');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{userName}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.remove)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx
new file mode 100644
index 0000000..52c0fe3
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx
@@ -0,0 +1,19 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useTeamMembersQuery } from '@/components/hooks';
+import { TeamMembersTable } from './TeamMembersTable';
+
+export function TeamMembersDataTable({
+ teamId,
+ allowEdit = false,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+}) {
+ const queryResult = useTeamMembersQuery(teamId);
+
+ return (
+ <DataGrid query={queryResult} allowSearch>
+ {({ data }) => <TeamMembersTable data={data} teamId={teamId} allowEdit={allowEdit} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx
new file mode 100644
index 0000000..8414908
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx
@@ -0,0 +1,55 @@
+import { DataColumn, DataTable, Row } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { TeamMemberEditButton } from './TeamMemberEditButton';
+import { TeamMemberRemoveButton } from './TeamMemberRemoveButton';
+
+export function TeamMembersTable({
+ data = [],
+ teamId,
+ allowEdit = false,
+}: {
+ data: any[];
+ teamId: string;
+ allowEdit: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const roles = {
+ [ROLES.teamOwner]: formatMessage(labels.teamOwner),
+ [ROLES.teamManager]: formatMessage(labels.teamManager),
+ [ROLES.teamMember]: formatMessage(labels.teamMember),
+ [ROLES.teamViewOnly]: formatMessage(labels.viewOnly),
+ };
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="username" label={formatMessage(labels.username)}>
+ {(row: any) => row?.user?.username}
+ </DataColumn>
+ <DataColumn id="role" label={formatMessage(labels.role)}>
+ {(row: any) => roles[row?.role]}
+ </DataColumn>
+ {allowEdit && (
+ <DataColumn id="action" align="end">
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" maxHeight="20px">
+ <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} />
+ <TeamMemberRemoveButton
+ teamId={teamId}
+ userId={row?.user?.id}
+ userName={row?.user?.username}
+ />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
new file mode 100644
index 0000000..3ddbe00
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
@@ -0,0 +1,49 @@
+import { Column } from '@umami/react-zen';
+import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks';
+import { Users } from '@/components/icons';
+import { ROLES } from '@/lib/constants';
+import { TeamEditForm } from './TeamEditForm';
+import { TeamManage } from './TeamManage';
+import { TeamMembersDataTable } from './TeamMembersDataTable';
+
+export function TeamSettings({ teamId }: { teamId: string }) {
+ const team: any = useTeam();
+ const { user } = useLoginQuery();
+ const { pathname } = useNavigation();
+
+ const isAdmin = pathname.includes('/admin');
+
+ const isTeamOwner =
+ !!team?.members?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) &&
+ user.role !== ROLES.viewOnly;
+
+ const canEdit =
+ user.isAdmin ||
+ (!!team?.members?.find(
+ ({ userId, role }) =>
+ (role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id,
+ ) &&
+ user.role !== ROLES.viewOnly);
+
+ return (
+ <Column gap="6">
+ <PageHeader title={team?.name} icon={<Users />}>
+ {!isTeamOwner && !isAdmin && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
+ </PageHeader>
+ <Panel>
+ <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
+ </Panel>
+ <Panel>
+ <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
+ </Panel>
+ {isTeamOwner && (
+ <Panel>
+ <TeamManage teamId={teamId} />
+ </Panel>
+ )}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx
new file mode 100644
index 0000000..f2b4ece
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx
@@ -0,0 +1,25 @@
+import { Icon, LoadingButton, Text } from '@umami/react-zen';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { X } from '@/components/icons';
+
+export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync } = useDeleteQuery(`/teams/${teamId}/websites/${websiteId}`);
+
+ const handleRemoveTeamMember = async () => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ onSave();
+ },
+ });
+ };
+
+ return (
+ <LoadingButton variant="quiet" onClick={() => handleRemoveTeamMember()}>
+ <Icon>
+ <X />
+ </Icon>
+ <Text>{formatMessage(labels.remove)}</Text>
+ </LoadingButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx
new file mode 100644
index 0000000..6a2e4f4
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx
@@ -0,0 +1,19 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useTeamWebsitesQuery } from '@/components/hooks';
+import { TeamWebsitesTable } from './TeamWebsitesTable';
+
+export function TeamWebsitesDataTable({
+ teamId,
+ allowEdit = false,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+}) {
+ const queryResult = useTeamWebsitesQuery(teamId);
+
+ return (
+ <DataGrid query={queryResult} allowSearch>
+ {({ data }) => <TeamWebsitesTable data={data} teamId={teamId} allowEdit={allowEdit} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx
new file mode 100644
index 0000000..10f5654
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx
@@ -0,0 +1,50 @@
+import { DataColumn, DataTable, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
+import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function TeamWebsitesTable({
+ teamId,
+ data = [],
+ allowEdit,
+}: {
+ teamId: string;
+ data: any[];
+ allowEdit: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>}
+ </DataColumn>
+ <DataColumn id="domain" label={formatMessage(labels.domain)} />
+ <DataColumn id="createdBy" label={formatMessage(labels.createdBy)}>
+ {(row: any) => row?.createUser?.username}
+ </DataColumn>
+ {allowEdit && (
+ <DataColumn id="action" align="end">
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center">
+ <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} />
+ <TeamMemberRemoveButton
+ teamId={teamId}
+ userId={row?.user?.id}
+ userName={row?.user?.username}
+ />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/page.tsx b/src/app/(main)/teams/page.tsx
new file mode 100644
index 0000000..7344f15
--- /dev/null
+++ b/src/app/(main)/teams/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { TeamsPage } from './TeamsPage';
+
+export default function () {
+ return <TeamsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Teams',
+};