🚧 Add space components (#1819)

This commit is contained in:
Luke Vella 2025-07-15 10:36:45 +01:00 committed by GitHub
parent d93615befe
commit 56c74d5024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 615 additions and 91 deletions

View file

@ -122,7 +122,6 @@
"editDetailsDescription": "Change the details of your event.",
"finalizeDescription": "Select a final date for your event.",
"notificationsGuestTooltip": "Create an account or login to turn on notifications",
"planFree": "Free",
"dateAndTimeDescription": "Change your preferred date and time settings",
"createdTime": "Created {relativeTime}",
"permissionDeniedParticipant": "If you are not the poll creator, you should go to the Invite Page.",
@ -396,5 +395,10 @@
"actionErrorNotFound": "The resource was not found",
"actionErrorForbidden": "You are not allowed to perform this action",
"actionErrorInternalServerError": "An internal server error occurred",
"cancelEvent": "Cancel Event"
"cancelEvent": "Cancel Event",
"createSpace": "Create Space",
"createSpaceDescription": "Create a new space to organize your polls and events.",
"createSpaceLoading": "Creating space...",
"createSpaceSuccess": "Space created successfully",
"createSpaceError": "Failed to create space"
}

View file

@ -1,24 +0,0 @@
"use client";
import { SidebarMenuButton, SidebarMenuItem } from "@rallly/ui/sidebar";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavItem({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={href}>{children}</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View file

@ -24,7 +24,7 @@ export function NavUser({
<OptimizedAvatarImage size="md" src={image} name={name} />
<div className="flex-1 truncate text-left">
<div className="font-medium">{name}</div>
<div className="mt-0.5 truncate font-normal text-muted-foreground">
<div className="mt-0.5 truncate font-normal text-muted-foreground text-xs">
{plan}
</div>
</div>

View file

@ -0,0 +1,52 @@
"use client";
import { useTranslation } from "@/i18n/client";
import { Icon } from "@rallly/ui/icon";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@rallly/ui/sidebar";
import { BarChart2Icon, CalendarIcon, HomeIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const useSpaceMenuItems = () => {
const { t } = useTranslation();
return [
{
href: "/",
icon: <HomeIcon />,
label: t("home", { defaultValue: "Home" }),
},
{
href: "/polls",
icon: <BarChart2Icon />,
label: t("polls", { defaultValue: "Polls" }),
},
{
href: "/events",
icon: <CalendarIcon />,
label: t("events", { defaultValue: "Events" }),
},
];
};
export function SpaceSidebarMenu() {
const items = useSpaceMenuItems();
const pathname = usePathname();
return (
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={pathname === item.href}>
<Link href={item.href}>
<Icon>{item.icon}</Icon>
{item.label}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
);
}

View file

@ -3,7 +3,7 @@
import { SidebarProvider } from "@rallly/ui/sidebar";
import { useLocalStorage } from "react-use";
export function AppSidebarProvider({
export function SpaceSidebarProvider({
children,
}: {
children: React.ReactNode;

View file

@ -1,3 +1,12 @@
import { SpaceSidebarMenu } from "@/app/[locale]/(space)/components/sidebar/space-sidebar-menu";
import { LogoLink } from "@/app/components/logo-link";
import { getActiveSpace, requireUserAbility } from "@/auth/queries";
import { Trans } from "@/components/trans";
import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle";
import { SpaceDropdown } from "@/features/spaces/components/space-dropdown";
import { SpaceIcon } from "@/features/spaces/components/space-icon";
import { isSpacesEnabled } from "@/features/spaces/constants";
import { loadSpaces } from "@/features/spaces/queries";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import {
@ -6,33 +15,34 @@ import {
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarSeparator,
} from "@rallly/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import {
BarChart2Icon,
CalendarIcon,
HomeIcon,
PlusIcon,
SparklesIcon,
} from "lucide-react";
import { ChevronsUpDownIcon, PlusIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
import type * as React from "react";
import { LogoLink } from "@/app/components/logo-link";
import { Trans } from "@/components/trans";
import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle";
import { requireUser } from "@/auth/queries";
import type React from "react";
import { UpgradeButton } from "../upgrade-button";
import { NavItem } from "./nav-item";
import { NavUser } from "./nav-user";
export async function AppSidebar({
async function loadData() {
const [{ user }, spaces, activeSpace] = await Promise.all([
requireUserAbility(),
loadSpaces(),
getActiveSpace(),
]);
return {
user,
spaces,
activeSpace,
};
}
export async function SpaceSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const user = await requireUser();
const { user, spaces, activeSpace } = await loadData();
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
@ -58,23 +68,28 @@ export async function AppSidebar({
</Tooltip>
</div>
</div>
{isSpacesEnabled ? (
<div>
<SpaceDropdown spaces={spaces} activeSpaceId={activeSpace.id}>
<Button className="h-auto w-full rounded-lg p-1" variant="ghost">
<SpaceIcon name={activeSpace.name} />
<div className="flex-1 px-0.5 text-left">
<div>{activeSpace.name}</div>
<div className="text-muted-foreground text-xs">
{activeSpace.isPro ? "Pro" : "Free"}
</div>
</div>
<Icon>
<ChevronsUpDownIcon />
</Icon>
</Button>
</SpaceDropdown>
</div>
) : null}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<NavItem href="/">
<HomeIcon className="size-4" />
<Trans i18nKey="home" />
</NavItem>
<NavItem href="/polls">
<BarChart2Icon className="size-4" />
<Trans i18nKey="polls" />
</NavItem>
<NavItem href="/events">
<CalendarIcon className="size-4" />
<Trans i18nKey="events" />
</NavItem>
</SidebarMenu>
<SpaceSidebarMenu />
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
@ -106,11 +121,7 @@ export async function AppSidebar({
name={user.name}
image={user.image}
plan={
user.isPro ? (
<Trans i18nKey="planPro" defaults="Pro" />
) : (
<Trans i18nKey="planFree" defaults="Free" />
)
<span className="capitalize">{activeSpace.role.toLowerCase()}</span>
}
/>
</SidebarFooter>

View file

@ -8,22 +8,30 @@ import { getOnboardedUser } from "@/features/setup/queries";
import { TimezoneProvider } from "@/features/timezone/client/context";
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
import { AppSidebar } from "./components/sidebar/app-sidebar";
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
import { SpaceSidebar } from "./components/sidebar/space-sidebar";
import { SpaceSidebarProvider } from "./components/sidebar/space-sidebar-provider";
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
async function loadData() {
const [user] = await Promise.all([getOnboardedUser()]);
return {
user,
};
}
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getOnboardedUser();
const { user } = await loadData();
return (
<TimezoneProvider initialTimezone={user.timeZone}>
<AppSidebarProvider>
<SpaceSidebarProvider>
<CommandMenu />
<AppSidebar />
<SpaceSidebar />
<SidebarInset className="min-w-0">
<TopBar className="md:hidden">
<TopBarLeft>
@ -51,7 +59,7 @@ export default async function Layout({
<div className="flex flex-1 flex-col">{children}</div>
</div>
</SidebarInset>
</AppSidebarProvider>
</SpaceSidebarProvider>
</TimezoneProvider>
);
}

View file

@ -0,0 +1,138 @@
"use server";
import { requireUserAbility } from "@/auth/queries";
import { ActionError, authActionClient } from "@/features/safe-action/server";
import { subject } from "@casl/ability";
import { accessibleBy } from "@casl/prisma";
import { prisma } from "@rallly/database";
import { z } from "zod";
export const setActiveSpaceAction = authActionClient
.inputSchema(
z.object({
spaceId: z.string(),
}),
)
.metadata({
actionName: "space_set_active",
})
.action(async ({ parsedInput }) => {
const { user, ability } = await requireUserAbility();
const space = await prisma.space.findFirst({
where: {
AND: [accessibleBy(ability).Space, { id: parsedInput.spaceId }],
},
include: {
members: {
where: {
userId: user.id,
},
},
},
});
if (!space) {
throw new ActionError({
code: "NOT_FOUND",
message: "Space not found",
});
}
if (!ability.can("read", subject("Space", space))) {
throw new ActionError({
code: "FORBIDDEN",
message: "You do not have access to this space",
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
activeSpaceId: parsedInput.spaceId,
},
});
});
export const createSpaceAction = authActionClient
.metadata({
actionName: "space_create",
})
.inputSchema(
z.object({
name: z.string().min(1).max(100),
}),
)
.action(async ({ parsedInput }) => {
const { user } = await requireUserAbility();
const space = await prisma.space.create({
data: {
name: parsedInput.name,
ownerId: user.id,
members: {
create: {
userId: user.id,
role: "OWNER",
},
},
},
});
try {
await prisma.user.update({
where: {
id: user.id,
},
data: {
activeSpaceId: space.id,
},
});
} catch (error) {
console.error(error);
}
});
export const deleteSpaceAction = authActionClient
.metadata({
actionName: "space_delete",
})
.inputSchema(
z.object({
spaceId: z.string(),
}),
)
.action(async ({ parsedInput }) => {
const { ability } = await requireUserAbility();
const space = await prisma.space.findFirst({
where: {
AND: [accessibleBy(ability).Space, { id: parsedInput.spaceId }],
},
include: {
subscription: true,
},
});
if (!space) {
throw new ActionError({
code: "NOT_FOUND",
message: "Space not found",
});
}
if (ability.cannot("delete", subject("Space", space))) {
throw new ActionError({
code: "FORBIDDEN",
message: "You do not have access to this space",
});
}
await prisma.space.delete({
where: {
id: parsedInput.spaceId,
},
});
});

View file

@ -0,0 +1,24 @@
"use client";
import type { SpaceDTO } from "@/features/spaces/queries";
import React from "react";
export const SpaceContext = React.createContext<SpaceDTO | null>(null);
export const useSpace = () => {
const space = React.useContext(SpaceContext);
if (!space) {
throw new Error("useSpace must be used within a SpaceProvider");
}
return space;
};
export const SpaceProvider = ({
space,
children,
}: { space: SpaceDTO; children?: React.ReactNode }) => {
return (
<SpaceContext.Provider value={space}>{children}</SpaceContext.Provider>
);
};

View file

@ -0,0 +1,122 @@
"use client";
import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { createSpaceAction } from "@/features/spaces/actions";
import { useTranslation } from "@/i18n/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
type DialogProps,
DialogTitle,
} from "@rallly/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { toast } from "@rallly/ui/sonner";
import { useForm } from "react-hook-form";
import { z } from "zod";
const newSpaceFormSchema = z.object({
name: z.string().min(1).max(100),
});
type NewSpaceFormValues = z.infer<typeof newSpaceFormSchema>;
export function NewSpaceDialog({ children, ...props }: DialogProps) {
const { t } = useTranslation();
const form = useForm<NewSpaceFormValues>({
resolver: zodResolver(newSpaceFormSchema),
defaultValues: {
name: "",
},
});
const createSpace = useSafeAction(createSpaceAction, {
onSuccess: () => {
form.reset();
},
});
return (
<Dialog {...props}>
{children}
<Form {...form}>
<DialogContent size="xs">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="createSpace" defaults="Create Space" />
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="createSpaceDescription"
defaults="Create a new space to organize your polls and events."
/>
</DialogDescription>
</DialogHeader>
<form
onSubmit={form.handleSubmit(({ name }) => {
props.onOpenChange?.(false);
toast.promise(
createSpace.executeAsync({
name,
}),
{
loading: t("createSpaceLoading", {
defaultValue: "Creating space...",
}),
success: t("createSpaceSuccess", {
defaultValue: "Space created successfully",
}),
error: t("createSpaceError", {
defaultValue: "Failed to create space",
}),
},
);
})}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="name" defaults="Name" />
</FormLabel>
<FormControl>
<Input
data-1p-ignore="true"
placeholder="e.g. Acme Corp"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button
loading={form.formState.isSubmitting}
type="submit"
variant="primary"
>
<Trans i18nKey="createSpace" defaults="Create Space" />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Form>
</Dialog>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { ProBadge } from "@/components/pro-badge";
import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { setActiveSpaceAction } from "@/features/spaces/actions";
import { cn } from "@rallly/ui";
import { useDialog } from "@rallly/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { CirclePlusIcon, SettingsIcon } from "lucide-react";
import Link from "next/link";
import { NewSpaceDialog } from "./new-space-dialog";
import { SpaceIcon } from "./space-icon";
export function SpaceDropdown({
spaces,
activeSpaceId,
children,
}: {
spaces: {
id: string;
name: string;
isPro: boolean;
}[];
activeSpaceId: string;
children?: React.ReactNode;
}) {
const setActiveSpace = useSafeAction(setActiveSpaceAction);
const newSpaceDialog = useDialog();
const activeSpace = spaces.find((space) => space.id === activeSpaceId);
if (!activeSpace) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
className={cn({
"animate-pulse duration-500": setActiveSpace.isExecuting,
})}
asChild={Boolean(children)}
>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)]"
align="start"
>
<DropdownMenuRadioGroup
value={activeSpaceId}
onValueChange={(value) => {
setActiveSpace.execute({ spaceId: value });
}}
>
{spaces.map((space) => (
<DropdownMenuRadioItem
key={space.id}
value={space.id}
className="flex items-center gap-2"
>
<SpaceIcon size="sm" name={space.name} />
<span>{space.name}</span>
{space.isPro ? <ProBadge /> : null}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={newSpaceDialog.trigger}>
<Icon>
<CirclePlusIcon />
</Icon>
<Trans i18nKey="createSpace" defaults="Create Space" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings">
<Icon>
<SettingsIcon />
</Icon>
<Trans i18nKey="settings" defaults="Settings" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewSpaceDialog {...newSpaceDialog.dialogProps} />
</>
);
}

View file

@ -0,0 +1,17 @@
"use client";
import { cn } from "@rallly/ui";
import { Avatar, AvatarFallback, type AvatarProps } from "@rallly/ui/avatar";
type SpaceIconProps = {
name: string;
className?: string;
} & AvatarProps;
export function SpaceIcon({ name, className, size }: SpaceIconProps) {
return (
<Avatar className={cn(className)} size={size}>
<AvatarFallback seed={name}>{name[0]}</AvatarFallback>
</Avatar>
);
}

View file

@ -0,0 +1 @@
export const isSpacesEnabled = false;

View file

@ -1,19 +1,49 @@
import { requireUserAbility } from "@/auth/queries";
import { accessibleBy } from "@casl/prisma";
import { prisma } from "@rallly/database";
import { type SpaceMemberRole, prisma } from "@rallly/database";
import { redirect } from "next/navigation";
import { cache } from "react";
export const listSpaces = cache(async () => {
const { ability } = await requireUserAbility();
const spaces = await prisma.spaceMember.findMany({
where: accessibleBy(ability).SpaceMember,
include: { space: true },
export type SpaceDTO = {
id: string;
name: string;
ownerId: string;
isPro: boolean;
role: SpaceMemberRole;
};
export const loadSpaces = cache(async () => {
const { user, ability } = await requireUserAbility();
const spaces = await prisma.space.findMany({
where: accessibleBy(ability).Space,
include: {
subscription: true,
members: true,
},
});
return spaces.map((spaceMember) => ({
...spaceMember.space,
role: spaceMember.role,
}));
const availableSpaces: SpaceDTO[] = [];
for (const space of spaces) {
const role = space.members.find(
(member) => member.userId === user.id,
)?.role;
if (!role) {
console.warn(`User ${user.id} does not have access to space ${space.id}`);
continue;
}
availableSpaces.push({
id: space.id,
name: space.name,
ownerId: space.ownerId,
isPro: Boolean(space.subscription?.active),
role,
});
}
return availableSpaces;
});
export const getDefaultSpace = cache(async () => {
@ -25,19 +55,59 @@ export const getDefaultSpace = cache(async () => {
orderBy: {
createdAt: "asc",
},
include: {
subscription: true,
},
});
if (!space) {
throw new Error(`Space with owner ID ${user.id} not found`);
redirect("/setup");
}
return space;
return {
id: space.id,
name: space.name,
ownerId: space.ownerId,
isPro: Boolean(space.subscription?.active),
role: "OWNER",
} satisfies SpaceDTO;
});
export const getSpace = cache(async ({ id }: { id: string }) => {
return await prisma.space.findFirst({
const { user, ability } = await requireUserAbility();
const space = await prisma.space.findFirst({
where: {
id,
AND: [accessibleBy(ability).Space, { id }],
},
include: {
subscription: {
where: {
active: true,
},
},
members: {
where: {
userId: user.id,
},
},
},
});
if (!space) {
throw new Error(`User ${user.id} does not have access to space ${id}`);
}
const role = space.members.find((member) => member.userId === user.id)?.role;
if (!role) {
throw new Error(`User ${user.id} is not a member of space ${id}`);
}
return {
id: space.id,
name: space.name,
ownerId: space.ownerId,
isPro: Boolean(space.subscription?.active),
role,
} satisfies SpaceDTO;
});

View file

@ -8,10 +8,10 @@ import * as React from "react";
const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
variants: {
size: {
xl: "size-12 text-xl",
lg: "size-9 text-lg",
md: "size-7 text-base",
sm: "size-5 text-xs",
xl: "size-12 rounded-md text-xl",
lg: "size-12 rounded-md text-lg",
md: "size-9 rounded-md text-base",
sm: "size-5 rounded-md text-xs",
},
},
defaultVariants: {
@ -19,6 +19,8 @@ const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
},
});
export type AvatarProps = VariantProps<typeof avatarVariants>;
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &

View file

@ -13,7 +13,7 @@ const badgeVariants = cva(
destructive: "bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "bg-green-600 text-white",
secondary: "border border-primary-200 bg-primary-50 text-primary",
secondary: "bg-primary-50 text-primary",
},
size: {
sm: "h-5 min-w-5 px-1.5 text-xs",

View file

@ -31,6 +31,7 @@ const buttonVariants = cva(
size: {
default: "h-8 gap-x-2 rounded-md px-2 text-sm",
sm: "h-7 gap-x-1.5 rounded-md px-1.5 text-sm",
md: "h-9 gap-x-2 rounded-md px-2 text-sm",
lg: "h-12 gap-x-3 rounded-lg px-4 text-base",
icon: "size-7 gap-x-1.5 rounded-lg text-sm",
"icon-lg": "size-8 rounded-full",

View file

@ -526,7 +526,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 flex w-full items-center gap-2.5 overflow-hidden rounded-lg p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] focus-visible:ring-2 focus-visible:ring-gray-400 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 flex w-full items-center gap-2.5 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] focus-visible:ring-2 focus-visible:ring-gray-400 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {