fix: bug fixes (#397)

* Add more html page titles

* Make tables responsive

* fix react query keys

* Add tooltip to sidebar toggle

* fix: trim inputs

* fix inputs
This commit is contained in:
Philip Okugbe
2024-10-13 17:09:45 +01:00
committed by GitHub
parent fa3c8a03e1
commit 36e720920b
38 changed files with 681 additions and 612 deletions

View File

@ -4,25 +4,25 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ScrollArea,
ActionIcon, ActionIcon,
} from '@mantine/core'; } from '@mantine/core';
import { Link } from 'react-router-dom'; import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx'; import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts'; import {buildPageUrl} from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts'; import {formattedDate} from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts'; import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react'; import {IconFileDescription} from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts'; import {getSpaceUrl} from '@/lib/config.ts';
interface Props { interface Props {
spaceId?: string; spaceId?: string;
} }
export default function RecentChanges({ spaceId }: Props) {
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId); export default function RecentChanges({spaceId}: Props) {
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) { if (isLoading) {
return <PageListSkeleton />; return <PageListSkeleton/>;
} }
if (isError) { if (isError) {
@ -30,7 +30,7 @@ export default function RecentChanges({ spaceId }: Props) {
} }
return pages && pages.items.length > 0 ? ( return pages && pages.items.length > 0 ? (
<ScrollArea> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Tbody> <Table.Tbody>
{pages.items.map((page) => ( {pages.items.map((page) => (
@ -43,7 +43,7 @@ export default function RecentChanges({ spaceId }: Props) {
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}> <ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18} /> <IconFileDescription size={18}/>
</ActionIcon> </ActionIcon>
)} )}
@ -60,14 +60,14 @@ export default function RecentChanges({ spaceId }: Props) {
variant="light" variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={getSpaceUrl(page?.space.slug)}
style={{ cursor: 'pointer' }} style={{cursor: 'pointer'}}
> >
{page?.space.name} {page?.space.name}
</Badge> </Badge>
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text c="dimmed" size="xs" fw={500}> <Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@ -75,7 +75,7 @@ export default function RecentChanges({ spaceId }: Props) {
))} ))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</ScrollArea> </Table.ScrollContainer>
) : ( ) : (
<Text size="md" ta="center"> <Text size="md" ta="center">
No pages yet No pages yet

View File

@ -1,18 +1,18 @@
import { Group, Text } from "@mantine/core"; import {Group, Text, Tooltip} from "@mantine/core";
import classes from "./app-header.module.css"; import classes from "./app-header.module.css";
import React from "react"; import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx"; import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai/index"; import {useAtom} from "jotai/index";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; const links = [{link: APP_ROUTE.HOME, label: "Home"}];
export function AppHeader() { export function AppHeader() {
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
@ -35,28 +35,33 @@ export function AppHeader() {
<Group wrap="nowrap"> <Group wrap="nowrap">
{!isHomeRoute && ( {!isHomeRoute && (
<> <>
<SidebarToggle <Tooltip label="Sidebar toggle">
aria-label="sidebar toggle"
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<SidebarToggle <SidebarToggle
aria-label="sidebar toggle" aria-label="Sidebar toggle"
opened={desktopOpened} opened={mobileOpened}
onClick={toggleDesktop} onClick={toggleMobile}
visibleFrom="sm" hiddenFrom="sm"
size="sm" size="sm"
/> />
</Tooltip>
<Tooltip label="Sidebar toggle">
<SidebarToggle
aria-label="Sidebar toggle"
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</> </>
)} )}
<Text <Text
size="lg" size="lg"
fw={600} fw={600}
style={{ cursor: "pointer", userSelect: "none" }} style={{cursor: "pointer", userSelect: "none"}}
component={Link} component={Link}
to="/home" to="/home"
> >
@ -69,7 +74,7 @@ export function AppHeader() {
</Group> </Group>
<Group px={"xl"}> <Group px={"xl"}>
<TopMenu /> <TopMenu/>
</Group> </Group>
</Group> </Group>
</> </>

View File

@ -1,15 +1,9 @@
import React from "react";
import { import {
IconLayoutSidebarRightCollapse, IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand, IconLayoutSidebarRightExpand
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
ActionIcon,
BoxProps,
ElementProps,
MantineColor,
MantineSize,
} from "@mantine/core";
import React from "react";
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> { export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
size?: MantineSize | `compact-${MantineSize}` | (string & {}); size?: MantineSize | `compact-${MantineSize}` | (string & {});
@ -17,18 +11,18 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
opened?: boolean; opened?: boolean;
} }
export default function SidebarToggle({ const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
opened, ({ opened, size = "sm", ...others }, ref) => {
size = "sm", return (
...others <ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
}: SidebarToggleProps) { {opened ? (
return ( <IconLayoutSidebarRightExpand />
<ActionIcon size={size} {...others} variant="subtle" color="gray"> ) : (
{opened ? ( <IconLayoutSidebarRightCollapse />
<IconLayoutSidebarRightExpand /> )}
) : ( </ActionIcon>
<IconLayoutSidebarRightCollapse /> );
)} }
</ActionIcon> );
);
} export default SidebarToggle;

View File

@ -19,7 +19,7 @@ import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-qu
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2), name: z.string().trim().min(1),
password: z.string().min(8), password: z.string().min(8),
}); });

View File

@ -15,8 +15,8 @@ import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css"; import classes from "@/features/auth/components/auth.module.css";
const formSchema = z.object({ const formSchema = z.object({
workspaceName: z.string().min(2).max(60), workspaceName: z.string().trim().min(3).max(50),
name: z.string().min(2).max(60), name: z.string().min(1).max(50),
email: z email: z
.string() .string()
.min(1, { message: "email is required" }) .min(1, { message: "email is required" })

View File

@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx"; import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().trim().min(2).max(50),
description: z.string().max(500), description: z.string().max(500),
}); });

View File

@ -1,69 +1,72 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import {Table, Group, Text, Anchor} from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import {useGetGroupsQuery} from "@/features/group/queries/group-query";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
export default function GroupList() { export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery(); const {data, isLoading} = useGetGroupsQuery();
return ( return (
<> <>
{data && ( {data && (
<Table highlightOnHover verticalSpacing="sm"> <Table.ScrollContainer minWidth={400}>
<Table.Thead> <Table highlightOnHover verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Group</Table.Th> <Table.Tr>
<Table.Th>Members</Table.Th> <Table.Th>Group</Table.Th>
</Table.Tr> <Table.Th>Members</Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm">
<IconGroupCircle />
<div>
<Text fz="sm" fw={500}>
{group.name}
</Text>
<Text fz="xs" c="dimmed">
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap"
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</> </>
); );

View File

@ -1,20 +1,20 @@
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core"; import {Group, Table, Text, Badge, Menu, ActionIcon} from "@mantine/core";
import { import {
useGroupMembersQuery, useGroupMembersQuery,
useRemoveGroupMemberMutation, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import {useParams} from "react-router-dom";
import React from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import {IconDots} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import {modals} from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
export default function GroupMembersList() { export default function GroupMembersList() {
const { groupId } = useParams(); const {groupId} = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId); const {data, isLoading} = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
const onRemove = async (userId: string) => { const onRemove = async (userId: string) => {
const memberToRemove = { const memberToRemove = {
@ -34,72 +34,74 @@ export default function GroupMembersList() {
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Delete", cancel: "Cancel" }, labels: {confirm: "Delete", cancel: "Cancel"},
confirmProps: { color: "red" }, confirmProps: {color: "red"},
onConfirm: () => onRemove(userId), onConfirm: () => onRemove(userId),
}); });
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>User</Table.Th> <Table.Tr>
<Table.Th>Status</Table.Th> <Table.Th>User</Table.Th>
<Table.Th></Table.Th> <Table.Th>Status</Table.Th>
</Table.Tr> <Table.Th></Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</> </>
); );

View File

@ -29,24 +29,22 @@ export function useGetGroupsQuery(
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> { export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({ return useQuery({
queryKey: ['groups', groupId], queryKey: ['group', groupId],
queryFn: () => getGroupById(groupId), queryFn: () => getGroupById(groupId),
enabled: !!groupId, enabled: !!groupId,
}); });
} }
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useCreateGroupMutation() { export function useCreateGroupMutation() {
const queryClient = useQueryClient();
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data), mutationFn: (data) => createGroup(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['groups'],
});
notifications.show({ message: 'Group created successfully' }); notifications.show({ message: 'Group created successfully' });
}, },
onError: () => { onError: () => {
@ -96,6 +94,14 @@ export function useDeleteGroupMutation() {
}); });
} }
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@ -8,9 +8,10 @@ import { computeSpaceSlug } from "@/lib";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().trim().min(2).max(50),
slug: z slug: z
.string() .string()
.trim()
.min(2) .min(2)
.max(50) .max(50)
.regex( .regex(

View File

@ -1,10 +1,10 @@
import { Modal, Tabs, rem, Group, ScrollArea } from "@mantine/core"; import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx"; import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React, { useMemo } from "react"; import React, {useMemo} from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
@ -17,11 +17,11 @@ interface SpaceSettingsModalProps {
} }
export default function SpaceSettingsModal({ export default function SpaceSettingsModal({
spaceId, spaceId,
opened, opened,
onClose, onClose,
}: SpaceSettingsModalProps) { }: SpaceSettingsModalProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId); const {data: space, isLoading} = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]); const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
@ -37,14 +37,16 @@ export default function SpaceSettingsModal({
xOffset={0} xOffset={0}
mah={400} mah={400}
> >
<Modal.Overlay /> <Modal.Overlay/>
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{overflow: "hidden"}}>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title fw={500}>{space?.name}</Modal.Title> <Modal.Title>
<Modal.CloseButton /> <Text fw={500} lineClamp={1}>{space?.name}</Text>
</Modal.Title>
<Modal.CloseButton/>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<div style={{ height: rem("600px") }}> <div style={{height: rem(600)}}>
<Tabs defaultValue="members"> <Tabs defaultValue="members">
<Tabs.List> <Tabs.List>
<Tabs.Tab fw={500} value="general"> <Tabs.Tab fw={500} value="general">
@ -55,34 +57,32 @@ export default function SpaceSettingsModal({
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<ScrollArea h="600" w="100%" scrollbarSize={5}> <Tabs.Panel value="general">
<Tabs.Panel value="general"> <SpaceDetails
<SpaceDetails spaceId={space?.id}
spaceId={space?.id} readOnly={spaceAbility.cannot(
readOnly={spaceAbility.cannot( SpaceCaslAction.Manage,
SpaceCaslAction.Manage, SpaceCaslSubject.Settings,
SpaceCaslSubject.Settings, )}
)} />
/> </Tabs.Panel>
</Tabs.Panel>
<Tabs.Panel value="members"> <Tabs.Panel value="members">
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Member, SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id} />} ) && <AddSpaceMembersModal spaceId={space?.id}/>}
</Group> </Group>
<SpaceMembersList <SpaceMembersList
spaceId={space?.id} spaceId={space?.id}
readOnly={spaceAbility.cannot( readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Member, SpaceCaslSubject.Member,
)} )}
/> />
</Tabs.Panel> </Tabs.Panel>
</ScrollArea>
</Tabs> </Tabs>
</div> </div>
</Modal.Body> </Modal.Body>

View File

@ -1,13 +1,13 @@
import { Table, Group, Text, Avatar } from "@mantine/core"; import {Table, Group, Text, Avatar} from "@mantine/core";
import React, { useState } from "react"; import React, {useState} from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import {useDisclosure} from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import {formatMemberCount} from "@/lib";
export default function SpaceList() { export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery(); const {data, isLoading} = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false); const [opened, {open, close}] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => { const handleClick = (spaceId: string) => {
@ -18,44 +18,48 @@ export default function SpaceList() {
return ( return (
<> <>
{data && ( {data && (
<Table highlightOnHover verticalSpacing="sm"> <Table.ScrollContainer minWidth={400}>
<Table.Thead> <Table highlightOnHover verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Space</Table.Th> <Table.Tr>
<Table.Th>Members</Table.Th> <Table.Th>Space</Table.Th>
</Table.Tr> <Table.Th>Members</Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm">
<Avatar
color="initials"
variant="filled"
name={space.name}
/>
<div>
<Text fz="sm" fw={500}>
{space.name}
</Text>
<Text fz="xs" c="dimmed">
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{formatMemberCount(space.memberCount)}</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{cursor: "pointer"}}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Avatar
color="initials"
variant="filled"
name={space.name}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" style={{whiteSpace: 'nowrap'}}>{formatMemberCount(space.memberCount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
{selectedSpaceId && ( {selectedSpaceId && (

View File

@ -1,32 +1,34 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core"; import {Group, Table, Text, Menu, ActionIcon} from "@mantine/core";
import React from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import {IconDots} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import {modals} from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { import {
useChangeSpaceMemberRoleMutation, useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation, useRemoveSpaceMemberMutation,
useSpaceMembersQuery, useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts"; } from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts"; import {IRemoveSpaceMember} from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx"; import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { import {
getSpaceRoleLabel, getSpaceRoleLabel,
spaceRoleData, spaceRoleData,
} from "@/features/space/types/space-role-data.ts"; } from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib"; import {formatMemberCount} from "@/lib";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
interface SpaceMembersProps { interface SpaceMembersProps {
spaceId: string; spaceId: string;
readOnly?: boolean; readOnly?: boolean;
} }
export default function SpaceMembersList({ export default function SpaceMembersList({
spaceId, spaceId,
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId); const {data, isLoading} = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation(); const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation(); const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -85,99 +87,101 @@ export default function SpaceMembersList({
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Remove", cancel: "Cancel" }, labels: {confirm: "Remove", cancel: "Cancel"},
confirmProps: { color: "red" }, confirmProps: {color: "red"},
onConfirm: () => onRemove(memberId, type), onConfirm: () => onRemove(memberId, type),
}); });
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing={8}> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing={8}>
<Table.Tr> <Table.Thead>
<Table.Th>Member</Table.Th> <Table.Tr>
<Table.Th>Role</Table.Th> <Table.Th>Member</Table.Th>
<Table.Th></Table.Th> <Table.Th>Role</Table.Th>
</Table.Tr> <Table.Th></Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() =>
openRemoveModal(member.id, member.type)
}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle/>}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() =>
openRemoveModal(member.id, member.type)
}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</> </>
); );

View File

@ -36,7 +36,7 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> { export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ['spaces', spaceId], queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -65,7 +65,7 @@ export function useGetSpaceBySlugQuery(
spaceId: string spaceId: string
): UseQueryResult<ISpace, Error> { ): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ['spaces', spaceId], queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -111,7 +111,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) { if (variables.slug) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ['spaces', variables.slug], queryKey: ['space', variables.slug],
exact: true, exact: true,
}); });
} }

View File

@ -1,62 +1,64 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core"; import {Group, Table, Avatar, Text, Alert} from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts"; import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts";
import React from "react"; import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts"; import {getUserRoleLabel} from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx"; import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react"; import {IconInfoCircle} from "@tabler/icons-react";
import { formattedDate } from "@/lib/time.ts"; import {formattedDate, timeAgo} from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceInvitesTable() { export default function WorkspaceInvitesTable() {
const { data, isLoading } = useWorkspaceInvitationsQuery({ const {data, isLoading} = useWorkspaceInvitationsQuery({
limit: 100, limit: 100,
}); });
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
return ( return (
<> <>
<Alert variant="light" color="blue" icon={<IconInfoCircle />}> <Alert variant="light" color="blue" icon={<IconInfoCircle/>}>
Invited members who are yet to accept their invitation will appear here. Invited members who are yet to accept their invitation will appear here.
</Alert> </Alert>
{data && ( {data && (
<> <>
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Email</Table.Th> <Table.Tr>
<Table.Th>Role</Table.Th> <Table.Th>Email</Table.Th>
<Table.Th>Date</Table.Th> <Table.Th>Role</Table.Th>
</Table.Tr> <Table.Th>Date</Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar name={invitation.email} color="initials" />
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{formattedDate(invitation.createdAt)}</Table.Td>
<Table.Td>
{isAdmin && (
<InviteActionMenu invitationId={invitation.id} />
)}
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar name={invitation.email} color="initials"/>
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
<Table.Td>
{isAdmin && (
<InviteActionMenu invitationId={invitation.id}/>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</> </>
)} )}
</> </>

View File

@ -1,9 +1,9 @@
import { Group, Table, Text, Badge } from "@mantine/core"; import {Group, Table, Text, Badge} from "@mantine/core";
import { import {
useChangeMemberRoleMutation, useChangeMemberRoleMutation,
useWorkspaceMembersQuery, useWorkspaceMembersQuery,
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import React from "react"; import React from "react";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx"; import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { import {
@ -11,14 +11,14 @@ import {
userRoleData, userRoleData,
} from "@/features/workspace/types/user-role-data.ts"; } from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts"; import {UserRole} from "@/lib/types.ts";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 }); const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const changeMemberRoleMutation = useChangeMemberRoleMutation(); const changeMemberRoleMutation = useChangeMemberRoleMutation();
const { isAdmin, isOwner } = useUserRole(); const {isAdmin, isOwner} = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER); const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async ( const handleRoleChange = async (
userId: string, userId: string,
@ -40,50 +40,52 @@ export default function WorkspaceMembersTable() {
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>User</Table.Th> <Table.Tr>
<Table.Th>Status</Table.Th> <Table.Th>User</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>Status</Table.Th>
</Table.Tr> <Table.Th>Role</Table.Th>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
disabled={!isAdmin}
/>
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
disabled={!isAdmin}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</> </>
); );

View File

@ -6,6 +6,10 @@ declare global {
} }
} }
export function getAppName(): string{
return 'Docmost';
}
export function getAppUrl(): string { export function getAppUrl(): string {
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL; //let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;

View File

@ -1,11 +1,12 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form"; import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
export default function ForgotPassword() { export default function ForgotPassword() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Forgot Password - Docmost</title> <title>Forgot Password - {getAppName()}</title>
</Helmet> </Helmet>
<ForgotPasswordForm /> <ForgotPasswordForm />
</> </>

View File

@ -1,12 +1,13 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx"; import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
export default function InviteSignup() { export default function InviteSignup() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Invitation Signup - Docmost</title> <title>Invitation Signuo - {getAppName()}</title>
</Helmet> </Helmet>
<InviteSignUpForm /> <InviteSignUpForm />
</> </>
); );

View File

@ -1,11 +1,12 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import {getAppName} from "@/lib/config.ts";
export default function LoginPage() { export default function LoginPage() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Login - Docmost</title> <title>Login - {getAppName()}</title>
</Helmet> </Helmet>
<LoginForm /> <LoginForm />
</> </>

View File

@ -4,6 +4,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query"; import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core"; import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import {getAppName} from "@/lib/config.ts";
export default function PasswordReset() { export default function PasswordReset() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -21,7 +22,7 @@ export default function PasswordReset() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Password Reset - Docmost</title> <title>Password Reset - {getAppName()}</title>
</Helmet> </Helmet>
<Container my={40}> <Container my={40}>
<Text size="lg" ta="center"> <Text size="lg" ta="center">
@ -45,7 +46,7 @@ export default function PasswordReset() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Password Reset - Docmost</title> <title>Password Reset - {getAppName()}</title>
</Helmet> </Helmet>
<PasswordResetForm resetToken={resetToken} /> <PasswordResetForm resetToken={resetToken} />
</> </>

View File

@ -3,6 +3,7 @@ import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-f
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {getAppName} from "@/lib/config.ts";
export default function SetupWorkspace() { export default function SetupWorkspace() {
const { const {
@ -32,7 +33,7 @@ export default function SetupWorkspace() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Setup Workspace - Docmost</title> <title>Setup Workspace - {getAppName()}</title>
</Helmet> </Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>

View File

@ -1,15 +1,22 @@
import { Container, Space } from "@mantine/core"; import {Container, Space} from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs"; import HomeTabs from "@/features/home/components/home-tabs";
import SpaceGrid from "@/features/space/components/space-grid.tsx"; import SpaceGrid from "@/features/space/components/space-grid.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Home() { export default function Home() {
return ( return (
<Container size={"800"} pt="xl"> <>
<SpaceGrid /> <Helmet>
<title>Home - {getAppName()}</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid/>
<Space h="xl" /> <Space h="xl"/>
<HomeTabs /> <HomeTabs/>
</Container> </Container>
); </>
);
} }

View File

@ -1,15 +1,20 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx"; import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx"; import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import { Divider } from "@mantine/core"; import {Divider} from "@mantine/core";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function AccountPreferences() { export default function AccountPreferences() {
return ( return (
<> <>
<SettingsTitle title="Preferences" /> <Helmet>
<AccountTheme /> <title>Preferences - {getAppName()}</title>
<Divider my={"md"} /> </Helmet>
<PageWidthPref /> <SettingsTitle title="Preferences"/>
</> <AccountTheme/>
); <Divider my={"md"}/>
<PageWidthPref/>
</>
);
} }

View File

@ -4,10 +4,15 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core"; import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar"; import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function AccountSettings() { export default function AccountSettings() {
return ( return (
<> <>
<Helmet>
<title>My Profile - {getAppName()}</title>
</Helmet>
<SettingsTitle title="My Profile" /> <SettingsTitle title="My Profile" />
<AccountAvatar /> <AccountAvatar />

View File

@ -1,13 +1,18 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members"; import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details"; import GroupDetails from "@/features/group/components/group-details";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function GroupInfo() { export default function GroupInfo() {
return ( return (
<> <>
<SettingsTitle title="Manage Group" /> <Helmet>
<GroupDetails /> <title>Manage Group - {getAppName()}</title>
<GroupMembersList /> </Helmet>
</> <SettingsTitle title="Manage Group"/>
); <GroupDetails/>
<GroupMembersList/>
</>
);
} }

View File

@ -3,12 +3,17 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal"; import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Groups() { export default function Groups() {
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
return ( return (
<> <>
<Helmet>
<title>Groups - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Groups" /> <SettingsTitle title="Groups" />
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">

View File

@ -1,21 +1,26 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx"; import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core"; import {Group} from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx"; import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Spaces() { export default function Spaces() {
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
return ( return (
<> <>
<SettingsTitle title="Spaces" /> <Helmet>
<title>Spaces - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Spaces"/>
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />} {isAdmin && <CreateSpaceModal/>}
</Group> </Group>
<SpaceList /> <SpaceList/>
</> </>
); );
} }

View File

@ -1,62 +1,67 @@
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal"; import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import { Group, SegmentedControl, Space, Text } from "@mantine/core"; import {Group, SegmentedControl, Space, Text} from "@mantine/core";
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table"; import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import {useNavigate, useSearchParams} from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx"; import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function WorkspaceMembers() { export default function WorkspaceMembers() {
const [segmentValue, setSegmentValue] = useState("members"); const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const currentTab = searchParams.get("tab"); const currentTab = searchParams.get("tab");
if (currentTab === "invites") { if (currentTab === "invites") {
setSegmentValue(currentTab); setSegmentValue(currentTab);
} }
}, [searchParams.get("tab")]); }, [searchParams.get("tab")]);
const handleSegmentChange = (value: string) => { const handleSegmentChange = (value: string) => {
setSegmentValue(value); setSegmentValue(value);
if (value === "invites") { if (value === "invites") {
navigate(`?tab=${value}`); navigate(`?tab=${value}`);
} else { } else {
navigate(""); navigate("");
} }
}; };
return ( return (
<> <>
<SettingsTitle title="Members" /> <Helmet>
<title>Members - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Members"/>
{/* <WorkspaceInviteSection /> */} {/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */} {/* <Divider my="lg" /> */}
<Group justify="space-between"> <Group justify="space-between">
<SegmentedControl <SegmentedControl
value={segmentValue} value={segmentValue}
onChange={handleSegmentChange} onChange={handleSegmentChange}
data={[ data={[
{ label: "Members", value: "members" }, {label: "Members", value: "members"},
{ label: "Pending", value: "invites" }, {label: "Pending", value: "invites"},
]} ]}
withItemsBorders={false} withItemsBorders={false}
/> />
{isAdmin && <WorkspaceInviteModal />} {isAdmin && <WorkspaceInviteModal/>}
</Group> </Group>
<Space h="lg" /> <Space h="lg"/>
{segmentValue === "invites" ? ( {segmentValue === "invites" ? (
<WorkspaceInvitesTable /> <WorkspaceInvitesTable/>
) : ( ) : (
<WorkspaceMembersTable /> <WorkspaceMembersTable/>
)} )}
</> </>
); );
} }

View File

@ -1,11 +1,16 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function WorkspaceSettings() { export default function WorkspaceSettings() {
return ( return (
<> <>
<SettingsTitle title="General" /> <Helmet>
<WorkspaceNameForm /> <title>Workspace Settings - {getAppName()}</title>
</> </Helmet>
); <SettingsTitle title="General"/>
<WorkspaceNameForm/>
</>
);
} }

View File

@ -1,15 +1,22 @@
import { Container } from "@mantine/core"; import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import { useParams } from "react-router-dom"; import {useParams} from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function SpaceHome() { export default function SpaceHome() {
const { spaceSlug } = useParams(); const {spaceSlug} = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const {data: space} = useGetSpaceBySlugQuery(spaceSlug);
return ( return (
<Container size={"800"} pt="xl"> <>
{space && <SpaceHomeTabs />} <Helmet>
</Container> <title>{space?.name || 'Overview'} - {getAppName()}</title>
); </Helmet>
<Container size={"800"} pt="xl">
{space && <SpaceHomeTabs/>}
</Container>
</>
);
} }

View File

@ -1,23 +0,0 @@
import { Title, Text, Stack } from '@mantine/core';
import { ThemeToggle } from '@/components/theme-toggle';
export function Welcome() {
return (
<Stack>
<Title ta="center" mt={100}>
<Text
inherit
variant="gradient"
component="span"
gradient={{ from: 'pink', to: 'yellow' }}
>
Welcome
</Text>
</Title>
<Text ta="center" size="lg" maw={580} mx="auto" mt="xl">
Welcome to something new and interesting.
</Text>
<ThemeToggle />
</Stack>
);
}

View File

@ -1,15 +1,18 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { CreateUserDto } from './create-user.dto'; import { CreateUserDto } from './create-user.dto';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateAdminUserDto extends CreateUserDto { export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty() @IsNotEmpty()
@MinLength(3) @MinLength(1)
@MaxLength(35) @MaxLength(50)
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@IsNotEmpty() @IsNotEmpty()
@MinLength(4) @MinLength(3)
@MaxLength(35) @MaxLength(50)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
workspaceName: string; workspaceName: string;
} }

View File

@ -6,12 +6,14 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateUserDto { export class CreateUserDto {
@IsOptional() @IsOptional()
@MinLength(2) @MinLength(1)
@MaxLength(60) @MaxLength(50)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,4 +1,4 @@
import { IsString, MinLength } from 'class-validator'; import { IsString } from 'class-validator';
export class VerifyUserTokenDto { export class VerifyUserTokenDto {
@IsString() @IsString()

View File

@ -7,11 +7,13 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto { export class CreateGroupDto {
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(50)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@IsOptional() @IsOptional()

View File

@ -5,11 +5,13 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto { export class CreateSpaceDto {
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(50)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@IsOptional() @IsOptional()

View File

@ -1,15 +1,17 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import {IsAlphanumeric, IsOptional, IsString, MaxLength, MinLength} from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateWorkspaceDto { export class CreateWorkspaceDto {
@MinLength(4) @MinLength(4)
@MaxLength(64) @MaxLength(64)
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@IsOptional() @IsOptional()
@MinLength(4) @MinLength(4)
@MaxLength(30) @MaxLength(30)
@IsString() @IsAlphanumeric()
hostname?: string; hostname?: string;
@IsOptional() @IsOptional()