mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-27 21:27:54 +02:00
🚧 Add space components (#1819)
This commit is contained in:
parent
d93615befe
commit
56c74d5024
18 changed files with 615 additions and 91 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
138
apps/web/src/features/spaces/actions.ts
Normal file
138
apps/web/src/features/spaces/actions.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
24
apps/web/src/features/spaces/client.tsx
Normal file
24
apps/web/src/features/spaces/client.tsx
Normal 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>
|
||||
);
|
||||
};
|
122
apps/web/src/features/spaces/components/new-space-dialog.tsx
Normal file
122
apps/web/src/features/spaces/components/new-space-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
98
apps/web/src/features/spaces/components/space-dropdown.tsx
Normal file
98
apps/web/src/features/spaces/components/space-dropdown.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
17
apps/web/src/features/spaces/components/space-icon.tsx
Normal file
17
apps/web/src/features/spaces/components/space-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
apps/web/src/features/spaces/constants.ts
Normal file
1
apps/web/src/features/spaces/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const isSpacesEnabled = false;
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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> &
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue