mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-27 13:17:51 +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.",
|
"editDetailsDescription": "Change the details of your event.",
|
||||||
"finalizeDescription": "Select a final date for your event.",
|
"finalizeDescription": "Select a final date for your event.",
|
||||||
"notificationsGuestTooltip": "Create an account or login to turn on notifications",
|
"notificationsGuestTooltip": "Create an account or login to turn on notifications",
|
||||||
"planFree": "Free",
|
|
||||||
"dateAndTimeDescription": "Change your preferred date and time settings",
|
"dateAndTimeDescription": "Change your preferred date and time settings",
|
||||||
"createdTime": "Created {relativeTime}",
|
"createdTime": "Created {relativeTime}",
|
||||||
"permissionDeniedParticipant": "If you are not the poll creator, you should go to the Invite Page.",
|
"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",
|
"actionErrorNotFound": "The resource was not found",
|
||||||
"actionErrorForbidden": "You are not allowed to perform this action",
|
"actionErrorForbidden": "You are not allowed to perform this action",
|
||||||
"actionErrorInternalServerError": "An internal server error occurred",
|
"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} />
|
<OptimizedAvatarImage size="md" src={image} name={name} />
|
||||||
<div className="flex-1 truncate text-left">
|
<div className="flex-1 truncate text-left">
|
||||||
<div className="font-medium">{name}</div>
|
<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}
|
{plan}
|
||||||
</div>
|
</div>
|
||||||
</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 { SidebarProvider } from "@rallly/ui/sidebar";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
|
|
||||||
export function AppSidebarProvider({
|
export function SpaceSidebarProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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 { Button } from "@rallly/ui/button";
|
||||||
import { Icon } from "@rallly/ui/icon";
|
import { Icon } from "@rallly/ui/icon";
|
||||||
import {
|
import {
|
||||||
|
@ -6,33 +15,34 @@ import {
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@rallly/ui/sidebar";
|
} from "@rallly/ui/sidebar";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
||||||
import {
|
import { ChevronsUpDownIcon, PlusIcon, SparklesIcon } from "lucide-react";
|
||||||
BarChart2Icon,
|
|
||||||
CalendarIcon,
|
|
||||||
HomeIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type * as React from "react";
|
import type 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 { UpgradeButton } from "../upgrade-button";
|
import { UpgradeButton } from "../upgrade-button";
|
||||||
import { NavItem } from "./nav-item";
|
|
||||||
import { NavUser } from "./nav-user";
|
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
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar>) {
|
}: React.ComponentProps<typeof Sidebar>) {
|
||||||
const user = await requireUser();
|
const { user, spaces, activeSpace } = await loadData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" {...props}>
|
<Sidebar variant="inset" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
|
@ -58,23 +68,28 @@ export async function AppSidebar({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SpaceSidebarMenu />
|
||||||
<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>
|
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
@ -106,11 +121,7 @@ export async function AppSidebar({
|
||||||
name={user.name}
|
name={user.name}
|
||||||
image={user.image}
|
image={user.image}
|
||||||
plan={
|
plan={
|
||||||
user.isPro ? (
|
<span className="capitalize">{activeSpace.role.toLowerCase()}</span>
|
||||||
<Trans i18nKey="planPro" defaults="Pro" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="planFree" defaults="Free" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
|
@ -8,22 +8,30 @@ import { getOnboardedUser } from "@/features/setup/queries";
|
||||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
|
|
||||||
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
|
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
|
||||||
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
import { SpaceSidebar } from "./components/sidebar/space-sidebar";
|
||||||
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
import { SpaceSidebarProvider } from "./components/sidebar/space-sidebar-provider";
|
||||||
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
|
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const [user] = await Promise.all([getOnboardedUser()]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Layout({
|
export default async function Layout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const user = await getOnboardedUser();
|
const { user } = await loadData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimezoneProvider initialTimezone={user.timeZone}>
|
<TimezoneProvider initialTimezone={user.timeZone}>
|
||||||
<AppSidebarProvider>
|
<SpaceSidebarProvider>
|
||||||
<CommandMenu />
|
<CommandMenu />
|
||||||
<AppSidebar />
|
<SpaceSidebar />
|
||||||
<SidebarInset className="min-w-0">
|
<SidebarInset className="min-w-0">
|
||||||
<TopBar className="md:hidden">
|
<TopBar className="md:hidden">
|
||||||
<TopBarLeft>
|
<TopBarLeft>
|
||||||
|
@ -51,7 +59,7 @@ export default async function Layout({
|
||||||
<div className="flex flex-1 flex-col">{children}</div>
|
<div className="flex flex-1 flex-col">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</AppSidebarProvider>
|
</SpaceSidebarProvider>
|
||||||
</TimezoneProvider>
|
</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 { requireUserAbility } from "@/auth/queries";
|
||||||
import { accessibleBy } from "@casl/prisma";
|
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";
|
import { cache } from "react";
|
||||||
|
|
||||||
export const listSpaces = cache(async () => {
|
export type SpaceDTO = {
|
||||||
const { ability } = await requireUserAbility();
|
id: string;
|
||||||
const spaces = await prisma.spaceMember.findMany({
|
name: string;
|
||||||
where: accessibleBy(ability).SpaceMember,
|
ownerId: string;
|
||||||
include: { space: true },
|
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) => ({
|
const availableSpaces: SpaceDTO[] = [];
|
||||||
...spaceMember.space,
|
|
||||||
role: spaceMember.role,
|
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 () => {
|
export const getDefaultSpace = cache(async () => {
|
||||||
|
@ -25,19 +55,59 @@ export const getDefaultSpace = cache(async () => {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "asc",
|
createdAt: "asc",
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!space) {
|
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 }) => {
|
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: {
|
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", {
|
const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
xl: "size-12 text-xl",
|
xl: "size-12 rounded-md text-xl",
|
||||||
lg: "size-9 text-lg",
|
lg: "size-12 rounded-md text-lg",
|
||||||
md: "size-7 text-base",
|
md: "size-9 rounded-md text-base",
|
||||||
sm: "size-5 text-xs",
|
sm: "size-5 rounded-md text-xs",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
@ -19,6 +19,8 @@ const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type AvatarProps = VariantProps<typeof avatarVariants>;
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
React.ComponentRef<typeof AvatarPrimitive.Root>,
|
React.ComponentRef<typeof AvatarPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &
|
||||||
|
|
|
@ -13,7 +13,7 @@ const badgeVariants = cva(
|
||||||
destructive: "bg-destructive text-destructive-foreground",
|
destructive: "bg-destructive text-destructive-foreground",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
green: "bg-green-600 text-white",
|
green: "bg-green-600 text-white",
|
||||||
secondary: "border border-primary-200 bg-primary-50 text-primary",
|
secondary: "bg-primary-50 text-primary",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: "h-5 min-w-5 px-1.5 text-xs",
|
sm: "h-5 min-w-5 px-1.5 text-xs",
|
||||||
|
|
|
@ -31,6 +31,7 @@ const buttonVariants = cva(
|
||||||
size: {
|
size: {
|
||||||
default: "h-8 gap-x-2 rounded-md px-2 text-sm",
|
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",
|
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",
|
lg: "h-12 gap-x-3 rounded-lg px-4 text-base",
|
||||||
icon: "size-7 gap-x-1.5 rounded-lg text-sm",
|
icon: "size-7 gap-x-1.5 rounded-lg text-sm",
|
||||||
"icon-lg": "size-8 rounded-full",
|
"icon-lg": "size-8 rounded-full",
|
||||||
|
|
|
@ -526,7 +526,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue