feat: favorites (#2103)

* feat: favorites and templates(ee)

* rename migrations

* fix sidebar

* cleanup tabs

* fix

* turn off templates

* cleanup

* uuid validation
This commit is contained in:
Philip Okugbe
2026-04-12 22:06:25 +01:00
committed by GitHub
parent 57efb91bd3
commit d42091ccb1
90 changed files with 4557 additions and 187 deletions
@@ -56,6 +56,7 @@ export function SpaceSidebar() {
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -81,11 +82,13 @@ export function SpaceSidebar() {
marginBottom: 3,
}}
>
<SwitchSpace
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
<Group gap={4} wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
<SwitchSpace
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</Group>
</div>
<div className={classes.section}>
@@ -7,9 +7,26 @@
}
.cardSection {
position: relative;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.starButton {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 150ms ease;
}
.starButton[data-favorited="true"] {
opacity: 1;
}
.card:hover .starButton {
opacity: 1;
}
.title {
font-family:
Greycliff CF,
@@ -12,12 +12,15 @@ import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import StarButton from "@/features/favorite/components/star-button";
import { useFavoriteIds } from "@/features/favorite/queries/favorite-query";
export default function SpaceGrid() {
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
const spaceFavoriteIds = useFavoriteIds("space");
const cards = data?.items.slice(0, 9).map((space, index) => (
const cards = data?.items.slice(0, 6).map((space, index) => (
<Card
key={space.id}
p="xs"
@@ -28,7 +31,11 @@ export default function SpaceGrid() {
className={classes.card}
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<Card.Section className={classes.cardSection} h={40}>
<div className={classes.starButton} data-favorited={spaceFavoriteIds.has(space.id)}>
<StarButton type="space" spaceId={space.id} size={16} />
</div>
</Card.Section>
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
@@ -59,7 +66,7 @@ export default function SpaceGrid() {
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
{data?.items && data.items.length > 9 && (
{data?.items && data.items.length > 6 && (
<Group justify="flex-end" mt="lg">
<Button
component={Link}
@@ -1,23 +1,44 @@
import { Text, Tabs, Space } from "@mantine/core";
import { IconClockHour3 } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes.tsx";
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes";
import FavoritesPages from "@/features/home/components/favorites-pages";
import CreatedByMe from "@/features/home/components/created-by-me";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
export default function SpaceHomeTabs() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const [activeTab, setActiveTab] = useAtom(homeTabAtom);
return (
<Tabs defaultValue="recent">
<Tabs.List>
<Tabs
color="dark"
value={activeTab}
onChange={(value) => {
if (value) setActiveTab(value);
}}
>
<Tabs.List style={{ flexWrap: "nowrap", overflowX: "auto" }}>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
{t("Recently updated")}
</Text>
</Tabs.Tab>
<Tabs.Tab value="favorites" leftSection={<IconStar size={18} />}>
<Text size="sm" fw={500}>
{t("Favorites")}
</Text>
</Tabs.Tab>
<Tabs.Tab value="created" leftSection={<IconUser size={18} />}>
<Text size="sm" fw={500}>
{t("Created by me")}
</Text>
</Tabs.Tab>
</Tabs.List>
<Space my="md" />
@@ -25,6 +46,12 @@ export default function SpaceHomeTabs() {
<Tabs.Panel value="recent">
{space?.id && <RecentChanges spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="favorites">
<FavoritesPages />
</Tabs.Panel>
<Tabs.Panel value="created">
{space?.id && <CreatedByMe spaceId={space.id} />}
</Tabs.Panel>
</Tabs>
);
}
@@ -9,6 +9,7 @@ import {
Anchor,
} from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react";
import StarButton from "@/features/favorite/components/star-button";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import React, { useState } from "react";
@@ -117,6 +118,7 @@ export default function AllSpacesList({
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<StarButton type="space" spaceId={space.id} size={16} />
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
@@ -0,0 +1,90 @@
import { Text, SimpleGrid, Card, rem, Group, Box, Button } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { getSpaceUrl } from "@/lib/config";
import { prefetchSpace } from "@/features/space/queries/space-query";
import StarButton from "@/features/favorite/components/star-button";
import { IconChevronDown } from "@tabler/icons-react";
import spaceClasses from "../space-grid.module.css";
const INITIAL_COUNT = 8;
export default function FavoriteSpacesGrid() {
const { t } = useTranslation();
const { data } = useFavoritesQuery("space");
const [expanded, setExpanded] = useState(false);
const allSpaces = (data?.pages.flatMap((p) => p.items) ?? [])
.filter((fav) => fav.space)
.sort((a, b) => a.space!.name.localeCompare(b.space!.name));
if (allSpaces.length === 0) return null;
const visibleSpaces = expanded
? allSpaces
: allSpaces.slice(0, INITIAL_COUNT);
return (
<Box mb="xl">
<Text size="sm" fw={500} mb="md">
{t("Favorite spaces")}
</Text>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 4 }}>
{visibleSpaces.map((fav) => (
<Card
key={fav.id}
p="xs"
radius="md"
component={Link}
to={getSpaceUrl(fav.space!.slug)}
onMouseEnter={() =>
prefetchSpace(fav.space!.slug, fav.space!.id)
}
className={spaceClasses.card}
withBorder
>
<Card.Section className={spaceClasses.cardSection} h={40}>
<div className={spaceClasses.starButton} data-favorited="true">
<StarButton
type="space"
spaceId={fav.space!.id}
size={16}
/>
</div>
</Card.Section>
<CustomAvatar
name={fav.space!.name}
avatarUrl={fav.space!.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size="md"
mt={rem(-20)}
/>
<Text fz="md" fw={500} mt="xs" className={spaceClasses.title}>
{fav.space!.name}
</Text>
</Card>
))}
</SimpleGrid>
{!expanded && allSpaces.length > INITIAL_COUNT && (
<Group justify="center" mt="sm">
<Button
variant="subtle"
size="xs"
rightSection={<IconChevronDown size={14} />}
onClick={() => setExpanded(true)}
>
{t("Show more")}
</Button>
</Group>
)}
</Box>
);
}
@@ -69,9 +69,10 @@ export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
if (spaceId) {
// this endpoint only accepts uuid for now
queryClient.prefetchQuery({
queryClient.prefetchInfiniteQuery({
queryKey: ["recent-changes", spaceId],
queryFn: () => getRecentChanges(spaceId),
queryFn: () => getRecentChanges({ spaceId }),
initialPageParam: undefined,
});
}
};