mirror of
https://github.com/khairul169/vaulterm.git
synced 2026-09-15 17:03:30 +07:00
feat: team page
This commit is contained in:
@@ -55,6 +55,18 @@ export default function Layout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="team"
|
||||
options={
|
||||
{
|
||||
title: "Team",
|
||||
hidden: !teamId,
|
||||
drawerIcon: ({ size, color }) => (
|
||||
<Icons name="account-group" size={size} color={color} />
|
||||
),
|
||||
} as DrawerNavigationOptions
|
||||
}
|
||||
/>
|
||||
</Drawer>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import TeamPage from "@/pages/team/page";
|
||||
|
||||
export default TeamPage;
|
||||
@@ -56,27 +56,33 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
<XStack gap="$1" p="$2" alignItems="center">
|
||||
<XStack gap="$1" alignItems="center" minWidth={48}>
|
||||
<Icons name="desktop-tower" size={16} />
|
||||
<Text fontSize="$2">{Math.round(cpu)}%</Text>
|
||||
<Text fontSize="$2" aria-label="CPU">
|
||||
{Math.round(cpu)}%
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="memory" size={16} />
|
||||
<Text fontSize="$2">
|
||||
<Text fontSize="$2" aria-label="Memory">
|
||||
{memory.used} MB / {memory.total} MB (
|
||||
{Math.round((memory.used / memory.total) * 100) || 0}%)
|
||||
</Text>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="harddisk" size={16} />
|
||||
<Text fontSize="$2">
|
||||
<Text fontSize="$2" aria-label="Disk">
|
||||
{disk.used} / {disk.total} ({disk.percent})
|
||||
</Text>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="download" size={16} />
|
||||
<Text fontSize="$2">{network.rx} MB</Text>
|
||||
<Text fontSize="$2" aria-label="Network Received">
|
||||
{network.rx} MB
|
||||
</Text>
|
||||
<Icons name="upload" size={16} />
|
||||
<Text fontSize="$2">{network.tx} MB</Text>
|
||||
<Text fontSize="$2" aria-label="Network Sent">
|
||||
{network.tx} MB
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import MenuButton from "../ui/menu-button";
|
||||
import Icons from "../ui/icons";
|
||||
import { logout, setTeam, useTeamId } from "@/stores/auth";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import TeamForm, { teamFormModal } from "@/pages/team/components/team-form";
|
||||
|
||||
const UserMenuButton = () => {
|
||||
const user = useUser();
|
||||
@@ -20,43 +21,46 @@ const UserMenuButton = () => {
|
||||
const team = user?.teams?.find((t: any) => t.id === teamId);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
size="$1"
|
||||
placement="bottom-end"
|
||||
width={213}
|
||||
trigger={
|
||||
<Button
|
||||
bg="$colorTransparent"
|
||||
justifyContent="flex-start"
|
||||
p={0}
|
||||
gap="$1"
|
||||
>
|
||||
<Avatar circular size="$3">
|
||||
<Avatar.Fallback bg="$blue4" />
|
||||
</Avatar>
|
||||
<View flex={1} style={{ textAlign: "left" }}>
|
||||
<Text numberOfLines={1}>{user?.name}</Text>
|
||||
<Text numberOfLines={1} fontWeight="600" mt="$1.5">
|
||||
{team ? `${team.icon} ${team.name}` : "Personal"}
|
||||
</Text>
|
||||
</View>
|
||||
<Icons name="chevron-down" size={16} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TeamsMenu />
|
||||
<MenuButton.Item
|
||||
onPress={() => console.log("logout")}
|
||||
icon={<Icons name="account" size={16} />}
|
||||
title="Account"
|
||||
/>
|
||||
<Separator w="100%" />
|
||||
<MenuButton.Item
|
||||
onPress={() => logout()}
|
||||
icon={<Icons name="logout" size={16} />}
|
||||
title="Logout"
|
||||
/>
|
||||
</MenuButton>
|
||||
<>
|
||||
<MenuButton
|
||||
size="$1"
|
||||
placement="bottom-end"
|
||||
width={213}
|
||||
trigger={
|
||||
<Button
|
||||
bg="$colorTransparent"
|
||||
justifyContent="flex-start"
|
||||
p={0}
|
||||
gap="$1"
|
||||
>
|
||||
<Avatar circular size="$3">
|
||||
<Avatar.Fallback bg="$blue4" />
|
||||
</Avatar>
|
||||
<View flex={1} style={{ textAlign: "left" }}>
|
||||
<Text numberOfLines={1}>{user?.name}</Text>
|
||||
<Text numberOfLines={1} fontWeight="600" mt="$1.5">
|
||||
{team ? `${team.icon} ${team.name}` : "Personal"}
|
||||
</Text>
|
||||
</View>
|
||||
<Icons name="chevron-down" size={16} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TeamsMenu />
|
||||
<MenuButton.Item
|
||||
onPress={() => console.log("logout")}
|
||||
icon={<Icons name="account" size={16} />}
|
||||
title="Account"
|
||||
/>
|
||||
<Separator w="100%" />
|
||||
<MenuButton.Item
|
||||
onPress={() => logout()}
|
||||
icon={<Icons name="logout" size={16} />}
|
||||
title="Logout"
|
||||
/>
|
||||
</MenuButton>
|
||||
<TeamForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,6 +111,7 @@ const TeamsMenu = () => {
|
||||
<MenuButton.Item
|
||||
icon={<Icons name="plus" size={16} />}
|
||||
title="Create Team"
|
||||
onPress={() => teamFormModal.onOpen({ icon: "🍃", name: "" })}
|
||||
/>
|
||||
</MenuButton>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ const MenuButtonFrame = ({
|
||||
...props
|
||||
}: MenuButtonProps) => {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover size="$1" {...props}>
|
||||
<Popover.Trigger asChild={asChild}>{trigger}</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
|
||||
@@ -9,6 +9,7 @@ type ModalProps = {
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
width?: number | string;
|
||||
maxHeight?: number | string;
|
||||
};
|
||||
|
||||
const Modal = ({
|
||||
@@ -17,6 +18,7 @@ const Modal = ({
|
||||
title,
|
||||
description,
|
||||
width = 512,
|
||||
maxHeight = 600,
|
||||
}: ModalProps) => {
|
||||
const { open, onOpenChange } = disclosure.use();
|
||||
|
||||
@@ -64,7 +66,7 @@ const Modal = ({
|
||||
width="90%"
|
||||
maxWidth={width}
|
||||
height="90%"
|
||||
maxHeight={600}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<View p="$4">
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { getCurrentServer } from "@/stores/app";
|
||||
import authStore from "@/stores/auth";
|
||||
import authStore, { logout } from "@/stores/auth";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
const api = ofetch.create({
|
||||
@@ -23,7 +23,7 @@ const api = ofetch.create({
|
||||
},
|
||||
onResponseError: (error) => {
|
||||
if (error.response.status === 401 && !!authStore.getState().token) {
|
||||
authStore.setState({ token: null });
|
||||
logout();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { View, Text, Spinner } from "tamagui";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { useNavigation } from "expo-router";
|
||||
import SearchInput from "@/components/ui/search-input";
|
||||
import { useTermSession } from "@/stores/terminal-sessions";
|
||||
|
||||
@@ -6,22 +6,21 @@ import HostForm, { hostFormModal } from "./components/form";
|
||||
import Icons from "@/components/ui/icons";
|
||||
import { initialValues } from "./schema/form";
|
||||
import KeyForm from "../keychains/components/form";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useTeamId } from "@/stores/auth";
|
||||
|
||||
export default function HostsPage() {
|
||||
const teamId = useTeamId();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer.Screen
|
||||
options={{
|
||||
headerRight: () => (
|
||||
<Button
|
||||
bg="$colorTransparent"
|
||||
icon={<Icons name="plus" size={24} />}
|
||||
onPress={() => hostFormModal.onOpen(initialValues)}
|
||||
$gtSm={{ mr: "$3" }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
),
|
||||
headerRight:
|
||||
!teamId || user?.teamCanWrite(teamId)
|
||||
? () => <AddButton />
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -31,3 +30,14 @@ export default function HostsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const AddButton = () => (
|
||||
<Button
|
||||
bg="$colorTransparent"
|
||||
icon={<Icons name="plus" size={24} />}
|
||||
onPress={() => hostFormModal.onOpen(initialValues)}
|
||||
$gtSm={{ mr: "$3" }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import MenuButton from "@/components/ui/menu-button";
|
||||
import { Button } from "tamagui";
|
||||
import Icons from "@/components/ui/icons";
|
||||
import { teamFormModal } from "./team-form";
|
||||
|
||||
type Props = {
|
||||
team: any;
|
||||
};
|
||||
|
||||
export default function HeaderActions({ team }: Props) {
|
||||
return (
|
||||
<MenuButton
|
||||
placement="bottom-end"
|
||||
width={200}
|
||||
trigger={
|
||||
<Button
|
||||
circular
|
||||
bg="$colorTransparent"
|
||||
mr="$2"
|
||||
icon={<Icons name="dots-vertical" size={20} />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuButton.Item
|
||||
icon={<Icons name="pencil" size={16} />}
|
||||
onPress={() => teamFormModal.onOpen(team)}
|
||||
>
|
||||
Update Team
|
||||
</MenuButton.Item>
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Icons from "@/components/ui/icons";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import { useZForm } from "@/hooks/useZForm";
|
||||
import { createDisclosure } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { ScrollView, XStack } from "tamagui";
|
||||
import { InputField } from "@/components/ui/input";
|
||||
import FormField from "@/components/ui/form";
|
||||
import { useInviteMutation } from "../hooks/query";
|
||||
import { ErrorAlert } from "@/components/ui/alert";
|
||||
import Button from "@/components/ui/button";
|
||||
import {
|
||||
InviteSchema,
|
||||
inviteSchema,
|
||||
teamMemberRoles,
|
||||
} from "../schema/team-form";
|
||||
import { SelectField } from "@/components/ui/select";
|
||||
|
||||
export const inviteFormModal = createDisclosure<InviteSchema>();
|
||||
|
||||
const InviteForm = () => {
|
||||
const { data } = inviteFormModal.use();
|
||||
const form = useZForm(inviteSchema, data);
|
||||
const invite = useInviteMutation(data?.teamId || "");
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
invite.mutate(values, {
|
||||
onSuccess: () => {
|
||||
inviteFormModal.onClose();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
disclosure={inviteFormModal}
|
||||
title="Invite"
|
||||
description="Add new team member."
|
||||
maxHeight={360}
|
||||
>
|
||||
<ErrorAlert mx="$4" mb="$4" error={invite.error} />
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||
<FormField label="Username/Email">
|
||||
<InputField
|
||||
f={1}
|
||||
form={form}
|
||||
name="username"
|
||||
placeholder="john.doe"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Role">
|
||||
<SelectField
|
||||
items={teamMemberRoles}
|
||||
form={form}
|
||||
name="role"
|
||||
placeholder="Select Role..."
|
||||
/>
|
||||
</FormField>
|
||||
</ScrollView>
|
||||
|
||||
<XStack p="$4" gap="$4">
|
||||
<Button
|
||||
flex={1}
|
||||
onPress={inviteFormModal.onClose}
|
||||
bg="$colorTransparent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
icon={<Icons name="account-plus" size={18} />}
|
||||
onPress={onSubmit}
|
||||
isLoading={invite.isPending}
|
||||
>
|
||||
Invite User
|
||||
</Button>
|
||||
</XStack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteForm;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Avatar, Button, ListItem, View, YGroup } from "tamagui";
|
||||
import MenuButton from "@/components/ui/menu-button";
|
||||
import Icons from "@/components/ui/icons";
|
||||
import SearchInput from "@/components/ui/search-input";
|
||||
|
||||
type Props = {
|
||||
members?: any[];
|
||||
allowWrite?: boolean;
|
||||
};
|
||||
|
||||
const MemberList = ({ members, allowWrite }: Props) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const memberList = useMemo(() => {
|
||||
let items = members || [];
|
||||
|
||||
if (search) {
|
||||
items = items.filter((item: any) => {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
item.user?.name.toLowerCase().includes(q) ||
|
||||
item.user?.username.toLowerCase().includes(q) ||
|
||||
item.user?.email.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [members, search]);
|
||||
|
||||
return (
|
||||
<View mt="$4">
|
||||
<SearchInput
|
||||
placeholder="Search member.."
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
|
||||
<YGroup bordered mt="$4">
|
||||
{memberList?.map((member: any) => (
|
||||
<YGroup.Item key={member.userId}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
title={member.user?.name}
|
||||
subTitle={member.role}
|
||||
pr="$2"
|
||||
icon={
|
||||
<Avatar size="$3" circular>
|
||||
<Avatar.Fallback bg="$blue5" />
|
||||
</Avatar>
|
||||
}
|
||||
iconAfter={
|
||||
allowWrite ? <MemberActionButton member={member} /> : undefined
|
||||
}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type MemberActionButtonProps = {
|
||||
member: any;
|
||||
};
|
||||
|
||||
const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
||||
<MenuButton
|
||||
size="$1"
|
||||
placement="bottom-end"
|
||||
trigger={
|
||||
<Button
|
||||
icon={<Icons name="dots-vertical" size={20} />}
|
||||
circular
|
||||
bg="$colorTransparent"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuButton.Item icon={<Icons name="account-key" size={16} />}>
|
||||
Change Role
|
||||
</MenuButton.Item>
|
||||
<MenuButton.Item color="$red10" icon={<Icons name="trash-can" size={16} />}>
|
||||
Remove Member
|
||||
</MenuButton.Item>
|
||||
</MenuButton>
|
||||
);
|
||||
|
||||
export default MemberList;
|
||||
@@ -0,0 +1,73 @@
|
||||
import Icons from "@/components/ui/icons";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import { useZForm } from "@/hooks/useZForm";
|
||||
import { createDisclosure } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { ScrollView, XStack } from "tamagui";
|
||||
import { InputField } from "@/components/ui/input";
|
||||
import FormField from "@/components/ui/form";
|
||||
import { useSaveTeam } from "../hooks/query";
|
||||
import { ErrorAlert } from "@/components/ui/alert";
|
||||
import Button from "@/components/ui/button";
|
||||
import { TeamFormSchema, teamFormSchema } from "../schema/team-form";
|
||||
|
||||
export const teamFormModal = createDisclosure<TeamFormSchema>();
|
||||
|
||||
const TeamForm = () => {
|
||||
const { data } = teamFormModal.use();
|
||||
const form = useZForm(teamFormSchema, data);
|
||||
const isEditing = data?.id != null;
|
||||
|
||||
const saveMutation = useSaveTeam();
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
saveMutation.mutate(values, {
|
||||
onSuccess: () => {
|
||||
teamFormModal.onClose();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
disclosure={teamFormModal}
|
||||
title="Team"
|
||||
description={`${isEditing ? "Edit" : "Add new"} team.`}
|
||||
maxHeight={320}
|
||||
>
|
||||
<ErrorAlert mx="$4" mb="$4" error={saveMutation.error} />
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||
<FormField label="Name">
|
||||
<InputField
|
||||
f={1}
|
||||
form={form}
|
||||
name="name"
|
||||
placeholder="Team Name..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Icon">
|
||||
<InputField form={form} name="icon" placeholder="Icon" />
|
||||
</FormField>
|
||||
</ScrollView>
|
||||
|
||||
<XStack p="$4" gap="$4">
|
||||
<Button flex={1} onPress={teamFormModal.onClose} bg="$colorTransparent">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
icon={<Icons name="content-save" size={18} />}
|
||||
onPress={onSubmit}
|
||||
isLoading={saveMutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</XStack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamForm;
|
||||
@@ -0,0 +1,53 @@
|
||||
import api from "@/lib/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { InviteSchema, TeamFormSchema } from "../schema/team-form";
|
||||
import queryClient from "@/lib/queryClient";
|
||||
import { setTeam, useTeamId } from "@/stores/auth";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export const useTeams = () => {
|
||||
return useQuery({
|
||||
queryKey: ["teams"],
|
||||
queryFn: () => api("/teams"),
|
||||
select: (i) => i.rows,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeam = () => {
|
||||
const teamId = useTeamId();
|
||||
return useQuery({
|
||||
queryKey: ["teams", teamId],
|
||||
queryFn: () => api(`/teams/${teamId}`),
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveTeam = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (body: TeamFormSchema) => {
|
||||
return body.id
|
||||
? api(`/teams/${body.id}`, { method: "PUT", body })
|
||||
: api(`/teams`, { method: "POST", body });
|
||||
},
|
||||
onError: (e) => console.error(e),
|
||||
onSuccess: (res, body) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams"] });
|
||||
|
||||
if (!body.id && res.id) {
|
||||
setTeam(res.id);
|
||||
router.push("/team");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInviteMutation = (teamId: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body: InviteSchema) => {
|
||||
return api(`/teams/${teamId}/invite`, { method: "POST", body });
|
||||
},
|
||||
onError: (e) => console.error(e),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
ListItem,
|
||||
YGroup,
|
||||
Button,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
XStack,
|
||||
} from "tamagui";
|
||||
import React from "react";
|
||||
import { useTeam } from "./hooks/query";
|
||||
import Drawer from "expo-router/drawer";
|
||||
import { useTeamId } from "@/stores/auth";
|
||||
import { Redirect } from "expo-router";
|
||||
import HeaderActions from "./components/header-actions";
|
||||
import Icons from "@/components/ui/icons";
|
||||
import tamaguiConfig from "@/tamagui.config";
|
||||
import MemberList from "./components/member-list";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import InviteForm, { inviteFormModal } from "./components/invite-form";
|
||||
|
||||
export default function TeamPage() {
|
||||
const teamId = useTeamId();
|
||||
const { isPending, data } = useTeam();
|
||||
const user = useUser();
|
||||
|
||||
if (!teamId || (!isPending && !data)) {
|
||||
return <Redirect href="/" />;
|
||||
}
|
||||
|
||||
const canWrite = user?.teamCanWrite(teamId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer.Screen
|
||||
options={{
|
||||
headerTitle: data ? `${data.icon} ${data.name}` : undefined,
|
||||
headerRight: () => <HeaderActions team={data} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: "$4",
|
||||
maxWidth: tamaguiConfig.media.xs.maxWidth,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="flex-end">
|
||||
<Text fontSize="$8" f={1} mb="$2">
|
||||
Team Members
|
||||
</Text>
|
||||
{canWrite && (
|
||||
<Button
|
||||
icon={<Icons name="account-plus" size={16} />}
|
||||
onPress={() =>
|
||||
inviteFormModal.onOpen({ teamId, username: "", role: "member" })
|
||||
}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)}
|
||||
</XStack>
|
||||
<Text>
|
||||
{canWrite
|
||||
? "Manage or view team members here"
|
||||
: "View your team members here"}
|
||||
</Text>
|
||||
|
||||
<MemberList members={data?.members} allowWrite={canWrite} />
|
||||
|
||||
<InviteForm />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import { z } from "zod";
|
||||
|
||||
export const teamFormSchema = z.object({
|
||||
id: z.string().ulid().nullish(),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
icon: z.string().emoji("Icon is not valid."),
|
||||
});
|
||||
|
||||
export type TeamFormSchema = z.infer<typeof teamFormSchema>;
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
teamId: z.string().ulid(),
|
||||
username: z.string().min(1, { message: "Username/email is required" }),
|
||||
role: z.enum(["owner", "admin", "member"], {
|
||||
errorMap: () => ({ message: "Role is required" }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const teamMemberRoles: SelectItem[] = [
|
||||
{ label: "Owner", value: "owner" },
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "Member", value: "member" },
|
||||
];
|
||||
|
||||
export type InviteSchema = z.infer<typeof inviteSchema>;
|
||||
Reference in New Issue
Block a user