feat: home space list (#1400)

This commit is contained in:
Philip Okugbe
2025-07-25 00:21:40 +01:00
committed by GitHub
parent 662460252f
commit b30bf61dc4
10 changed files with 255 additions and 8 deletions

View File

@ -403,6 +403,7 @@
"Replace (Enter)": "Replace (Enter)", "Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all", "Replace all": "Replace all",
"View all spaces": "View all spaces"
"Error": "Error", "Error": "Error",
"Failed to disable MFA": "Failed to disable MFA", "Failed to disable MFA": "Failed to disable MFA",
"Disable two-factor authentication": "Disable two-factor authentication", "Disable two-factor authentication": "Disable two-factor authentication",

View File

@ -31,6 +31,7 @@ import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin"; import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
@ -77,6 +78,7 @@ export default function App() {
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} /> <Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route <Route
path={"/s/:spaceSlug/p/:pageSlug"} path={"/s/:spaceSlug/p/:pageSlug"}

View File

@ -27,6 +27,8 @@ export function AppHeader() {
const { isTrial, trialDaysLeft } = useTrial(); const { isTrial, trialDaysLeft } = useTrial();
const isHomeRoute = location.pathname.startsWith("/home"); const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const hideSidebar = isHomeRoute || isSpacesRoute;
const items = links.map((link) => ( const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}> <Link key={link.label} to={link.link} className={classes.link}>
@ -38,7 +40,7 @@ export function AppHeader() {
<> <>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}> <Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group wrap="nowrap"> <Group wrap="nowrap">
{!isHomeRoute && ( {!hideSidebar && (
<> <>
<Tooltip label={t("Sidebar toggle")}> <Tooltip label={t("Sidebar toggle")}>
<SidebarToggle <SidebarToggle

View File

@ -73,13 +73,15 @@ export default function GlobalAppShell({
const isSettingsRoute = location.pathname.startsWith("/settings"); const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/"); const isSpaceRoute = location.pathname.startsWith("/s/");
const isHomeRoute = location.pathname.startsWith("/home"); const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isPageRoute = location.pathname.includes("/p/"); const isPageRoute = location.pathname.includes("/p/");
const hideSidebar = isHomeRoute || isSpacesRoute;
return ( return (
<AppShell <AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={ navbar={
!isHomeRoute && { !hideSidebar && {
width: isSpaceRoute ? sidebarWidth : 300, width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm", breakpoint: "sm",
collapsed: { collapsed: {
@ -100,7 +102,7 @@ export default function GlobalAppShell({
<AppShell.Header px="md" className={classes.header}> <AppShell.Header px="md" className={classes.header}>
<AppHeader /> <AppHeader />
</AppShell.Header> </AppShell.Header>
{!isHomeRoute && ( {!hideSidebar && (
<AppShell.Navbar <AppShell.Navbar
className={classes.navbar} className={classes.navbar}
withBorder={false} withBorder={false}

View File

@ -1,4 +1,4 @@
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core"; import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { import {
prefetchSpace, prefetchSpace,
@ -9,10 +9,11 @@ import { Link } from "react-router-dom";
import classes from "./space-grid.module.css"; import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
export default function SpaceGrid() { export default function SpaceGrid() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ page: 1 }); const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 9 });
const cards = data?.items.map((space, index) => ( const cards = data?.items.map((space, index) => (
<Card <Card
@ -46,11 +47,25 @@ export default function SpaceGrid() {
return ( return (
<> <>
<Text fz="sm" fw={500} mb={"md"}> <Group justify="space-between" align="center" mb="md">
{t("Spaces you belong to")} <Text fz="sm" fw={500}>
</Text> {t("Spaces you belong to")}
</Text>
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid> <SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
<Group justify="flex-end" mt="lg">
<Button
component={Link}
to="/spaces"
variant="subtle"
rightSection={<IconArrowRight size={16} />}
size="sm"
>
{t("View all spaces")}
</Button>
</Group>
</> </>
); );
} }

View File

@ -0,0 +1,10 @@
.spaceLink {
text-decoration: none;
color: inherit;
display: flex;
width: 100%;
&:hover {
text-decoration: none;
}
}

View File

@ -0,0 +1,160 @@
import {
Table,
Text,
Group,
ActionIcon,
Box,
Space,
Menu,
Avatar,
Anchor,
} from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { getSpaceUrl } from "@/lib/config";
import { prefetchSpace } from "@/features/space/queries/space-query";
import { SearchInput } from "@/components/common/search-input";
import Paginate from "@/components/common/paginate";
import NoTableResults from "@/components/common/no-table-results";
import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
interface AllSpacesListProps {
spaces: any[];
onSearch: (query: string) => void;
page: number;
hasPrevPage?: boolean;
hasNextPage?: boolean;
onPageChange: (page: number) => void;
}
export default function AllSpacesList({
spaces,
onSearch,
page,
hasPrevPage,
hasNextPage,
onPageChange,
}: AllSpacesListProps) {
const { t } = useTranslation();
const [settingsOpened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const handleOpenSettings = (spaceId: string) => {
setSelectedSpaceId(spaceId);
openSettings();
};
return (
<Box>
<SearchInput onSearch={onSearch} />
<Space h="md" />
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Space")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
<Table.Th w={100}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{spaces.length > 0 ? (
spaces.map((space) => (
<Table.Tr key={space.id}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={getSpaceUrl(space.slug)}
>
<Group
gap="sm"
wrap="nowrap"
className={classes.spaceLink}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
>
<Avatar
color="initials"
variant="filled"
name={space.name}
size={40}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</Text>
{space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
)}
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{formatMemberCount(space.memberCount, t)}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconSettings size={16} />}
onClick={() => handleOpenSettings(space.id)}
>
{t("Space settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={3} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{spaces.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={hasPrevPage}
hasNextPage={hasNextPage}
onPageChange={onPageChange}
/>
)}
{selectedSpaceId && (
<SpaceSettingsModal
spaceId={selectedSpaceId}
opened={settingsOpened}
onClose={closeSettings}
/>
)}
</Box>
);
}

View File

@ -0,0 +1 @@
export { default as AllSpacesList } from "./all-spaces-list";

View File

@ -1,5 +1,6 @@
const APP_ROUTE = { const APP_ROUTE = {
HOME: "/home", HOME: "/home",
SPACES: "/spaces",
AUTH: { AUTH: {
LOGIN: "/login", LOGIN: "/login",
SIGNUP: "/signup", SIGNUP: "/signup",

View File

@ -0,0 +1,53 @@
import { Container, Title, Text, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import CreateSpaceModal from "@/features/space/components/create-space-modal";
import { AllSpacesList } from "@/features/space/components/spaces-page";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role";
export default function Spaces() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useGetSpacesQuery({
page,
limit: 30,
query: search,
});
return (
<>
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<Container size={"800"} pt="xl">
<Group justify="space-between" mb="xl">
<Title order={3}>{t("Spaces")}</Title>
{isAdmin && <CreateSpaceModal />}
</Group>
<Box>
<Text size="sm" c="dimmed" mb="md">
{t("Spaces you belong to")}
</Text>
<AllSpacesList
spaces={data?.items || []}
onSearch={handleSearch}
page={page}
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onPageChange={setPage}
/>
</Box>
</Container>
</>
);
}