Updated sidebar layout (#1661)

This commit is contained in:
Luke Vella 2025-04-14 15:11:59 +01:00 committed by GitHub
parent 8c0814b92b
commit 72ca1d4c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 3268 additions and 1331 deletions

View file

@ -10,5 +10,6 @@
"editor.formatOnSave": true,
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.tsserver.log": "verbose",
"typescript.tsserver.trace": "messages"
"typescript.tsserver.trace": "messages",
"references.preferredLocation": "view"
}

39
.windsurfrules Normal file
View file

@ -0,0 +1,39 @@
1. Use yarn for package management
2. Use dayjs for date handling
3. Use tailwindcss for styling
4. Use react-query for data fetching
5. Use react-hook-form for form handling
6. Prefer implicit return values over explicit return values
7. Use zod for form validation
8. Create separate import statements for types
9. All text in the UI should be translated using either the Trans component or the useTranslation hook
10. Prefer composable components in the style of shadcn UI over large monolithic components
11. DropdownMenuItem is a flex container with a preset gap so there is no need to add margins to the children
12. The size and colour of an icon should be set by wrapping it with the <Icon> component from @rallly/ui/icon which will give it the correct colour and size.
13. Keep the props of a component as minimal as possible. Pass only the bare minimum amount of information needed to it.
14. All text in the UI should be translatable.
15. i18n keys are in camelCase.
16. Use the <Trans> component in client components from @/components/trans. Use the `defaults` prop to provide the default text. Example:
```tsx
<Trans i18nKey="menu" defaults="Menu" />
```
17. On the server use the `getTranslations` function from @/i18n/server to get the translations. Example:
```ts
const { t } = await getTranslations();
t("menu", { defaultValue: "Menu" });
```
18. shadcn-ui components should be added to packages/ui
19. Always use a composable patterns when building components
20. Use `cn()` from @rallly/ui to compose classes
21. Prefer using the React module APIs (e.g. React.useState) instead of standalone hooks (e.g. useState)
22. Do not attempt to fix typescript issues related to missing translations. This will be handled by our tooling.
23. Never manually add translations to .json files. This will be handled by our tooling.
24. Add the "use client" directive to the top of any .tsx file that requires client-side javascript
25. i18nKeys should describe the message in camelCase. Ex. "lastUpdated": "Last Updated"
26. Keep i18nKeys up to 25 characters
27. If the i18nKey is not intended to be reused, prefix it with the component name in camelCase

View file

@ -168,11 +168,9 @@
"hideScoresLabel": "Hide scores until after a participant has voted",
"continueAs": "Continue as",
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
"unlockFeatures": "Unlock all Pro features.",
"pollStatusFinalized": "Finalized",
"share": "Share",
"noParticipants": "No participants",
"logoutDescription": "Sign out of your existing session",
"events": "Events",
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
"inviteLink": "Invite Link",
@ -303,5 +301,28 @@
"needToMakeChanges": "Need to make changes?",
"billingPortalDescription": "Visit the billing portal to manage your subscription, update payment methods, or view billing history.",
"priceFree": "Free",
"signUp": "Sign Up"
"signUp": "Sign Up",
"upgradeToPro": "Upgrade to Pro",
"moreParticipants": "{count} more…",
"noDates": "No dates",
"commandMenuNoResults": "No results",
"selectedPolls": "{count} {count, plural, one {poll} other {polls}} selected",
"unselectAll": "Unselect All",
"deletePolls": "Delete Polls",
"deletePollsDescription": "Are you sure you want to delete these {count} polls? This action cannot be undone.",
"commandMenu": "Command Menu",
"commandMenuDescription": "Select a command",
"eventsPageDesc": "View and manage your scheduled events",
"homeDashboardDesc": "Manage your polls, events, and account settings",
"homeNavTitle": "Navigation",
"account": "Account",
"pollsPageDesc": "View and manage all your scheduling polls",
"signOut": "Sign Out",
"paginationItems": "Showing {startItem}{endItem} of {totalItems}",
"paginationPrevious": "Previous",
"paginationPage": "Page {currentPage} of {totalPages}",
"paginationNext": "Next",
"upgradeToProDesc": "Unlock all Pro features",
"searchPollsPlaceholder": "Search polls by title...",
"poll": "Poll"
}

View file

@ -0,0 +1,22 @@
"use client";
import { SidebarProvider } from "@rallly/ui/sidebar";
import { useLocalStorage } from "react-use";
export function AppSidebarProvider({
children,
}: {
children: React.ReactNode;
}) {
const [value, setValue] = useLocalStorage("sidebar_state", "expanded");
return (
<SidebarProvider
open={value === "expanded"}
onOpenChange={(open) => {
setValue(open ? "expanded" : "collapsed");
}}
>
{children}
</SidebarProvider>
);
}

View file

@ -0,0 +1,134 @@
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import {
Sidebar,
SidebarContent,
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 Link from "next/link";
import * as React from "react";
import { LogoLink } from "@/app/components/logo-link";
import { Trans } from "@/components/trans";
import { getUser } from "@/data/get-user";
import { getTranslation } from "@/i18n/server";
import { UpgradeButton } from "../upgrade-button";
import { NavItem } from "./nav-item";
import { NavUser } from "./nav-user";
export async function AppSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const user = await getUser();
const { t } = await getTranslation();
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<div className="flex items-center justify-between p-1">
<div className="flex items-center gap-2">
<LogoLink />
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Create</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{/* <NavItem href="/spaces">
<span className="inline-flex size-4 items-center justify-center rounded bg-violet-400 text-xs text-white">
P
</span>
<span className="flex-1">Personal</span>
</NavItem> */}
<NavItem href="/">
<HomeIcon className="size-4" />
{t("home")}
</NavItem>
<NavItem href="/polls">
<BarChart2Icon className="size-4" />
{t("polls")}
</NavItem>
<NavItem href="/events">
<CalendarIcon className="size-4" />
{t("events")}
</NavItem>
{/* <NavItem href="/teams">
<UsersIcon className="size-4" />
{t("teams", { defaultValue: "Teams" })}
</NavItem>
<NavItem href="/settings">
<SettingsIcon className="size-4" />
{t("settings", { defaultValue: "Settings" })}
</NavItem> */}
{/* <NavItem href="/links" icon={LinkIcon} label="Links" /> */}
{/* <NavItem href="/availability" icon={ClockIcon} label="Availability" /> */}
{/* <NavItem href="/integrations" icon={PuzzleIcon} label="Integrations" /> */}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
{!user.isPro ? (
<>
<div className="relative overflow-hidden rounded-xl border bg-gray-50 p-3 text-sm shadow-sm">
<SparklesIcon className="absolute -top-4 right-0 z-0 size-16 text-gray-200" />
<div className="relative z-10">
<h2 className="font-semibold">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</h2>
<p className="text-muted-foreground mb-3 mt-1 text-sm">
<Trans
i18nKey="upgradeToProDesc"
defaults="Unlock all Pro features"
/>
</p>
<UpgradeButton>
<Button size="sm" variant="primary" className="w-full">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Button>
</UpgradeButton>
</div>
</div>
<SidebarSeparator />
</>
) : null}
<NavUser
name={user.name}
image={user.image}
plan={
user.isPro ? (
<Trans i18nKey="planPro" defaults="Pro" />
) : (
<Trans i18nKey="planFree" defaults="Free" />
)
}
/>
</SidebarFooter>
</Sidebar>
);
}

View file

@ -0,0 +1,24 @@
"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

@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
export function NavUser({
name,
image,
plan,
}: {
name: string;
image?: string;
plan?: React.ReactNode;
}) {
const pathname = usePathname();
return (
<Link
href="/settings/profile"
data-state={pathname.startsWith("/settings") ? "active" : "inactive"}
className="group relative flex w-full items-center gap-3 rounded-md p-3 text-sm hover:bg-gray-200 data-[state=active]:bg-gray-200"
>
<OptimizedAvatarImage size="md" src={image} name={name} />
<div className="flex-1 truncate text-left">
<div className="font-medium">{name}</div>
<div className="text-muted-foreground mt-0.5 truncate font-normal">
{plan}
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,48 @@
import { cn } from "@rallly/ui";
export function TopBar({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"bg-background/90 sticky top-0 z-10 flex items-center gap-4 rounded-t-lg border-b px-4 py-3 backdrop-blur-md",
className,
)}
>
{children}
</div>
);
}
export function TopBarLeft({ children }: { children: React.ReactNode }) {
return <div className="flex flex-1 items-center gap-x-4">{children}</div>;
}
export function TopBarRight({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-1 items-center justify-end gap-x-4">
{children}
</div>
);
}
export function TopBarGroup({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-x-1", className)}>{children}</div>
);
}
export function TopBarSeparator() {
return <div className="bg-border h-4 w-px" />;
}

View file

@ -0,0 +1,13 @@
"use client";
import { DialogTrigger } from "@rallly/ui/dialog";
import { PayWallDialog } from "@/components/pay-wall-dialog";
export function UpgradeButton({ children }: React.PropsWithChildren) {
return (
<PayWallDialog>
<DialogTrigger asChild>{children}</DialogTrigger>
</PayWallDialog>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@rallly/ui/card";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import dayjs from "dayjs";
@ -18,7 +17,7 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
const { adjustTimeZone } = useDayjs();
return (
<Card>
<div className="rounded-lg border">
<ul className="divide-y divide-gray-100">
{table.getRowModel().rows.map((row) => {
const start = adjustTimeZone(
@ -31,49 +30,44 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
!row.original.timeZone,
);
return (
<li key={row.id}>
<CardContent>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
<div className="flex shrink-0 justify-between gap-1 sm:w-24 sm:flex-col sm:text-right">
<time
dateTime={start.toISOString()}
className="text-sm font-medium"
>
{start.format("ddd, D MMM")}
</time>
<time
dateTime={start.toISOString()}
className="text-muted-foreground text-sm"
>
{start.format("YYYY")}
</time>
</div>
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span
className="h-4 w-1 shrink-0 rounded-full"
style={{
background: generateGradient(row.original.id),
}}
></span>
<h2 className="truncate text-base font-semibold">
{row.original.title}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{row.original.duration === 0 ? (
<Trans i18nKey="allDay" />
) : (
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
)}
</p>
</div>
<li key={row.id} className="p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
<div className="flex shrink-0 justify-between gap-1 sm:w-24 sm:flex-col sm:text-right">
<time dateTime={start.toISOString()} className="text-sm">
{start.format("ddd, D MMM")}
</time>
<time
dateTime={start.toISOString()}
className="text-muted-foreground text-sm"
>
{start.format("YYYY")}
</time>
</div>
</CardContent>
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span
className="h-4 w-1 shrink-0 rounded-full"
style={{
background: generateGradient(row.original.id),
}}
></span>
<h2 className="truncate text-sm font-medium">
{row.original.title}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{row.original.duration === 0 ? (
<Trans i18nKey="allDay" />
) : (
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
)}
</p>
</div>
</div>
</li>
);
})}
</ul>
</Card>
</div>
);
}

View file

@ -1,3 +0,0 @@
export default function Layout({ children }: { children?: React.ReactNode }) {
return <div>{children}</div>;
}

View file

@ -1,5 +0,0 @@
import { Spinner } from "@/components/spinner";
export default async function Loading() {
return <Spinner />;
}

View file

@ -1,25 +1,31 @@
import { UserScheduledEvents } from "@/app/[locale]/(admin)/events/user-scheduled-events";
import type { Params } from "@/app/[locale]/types";
import { EventPageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageDescription,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { Trans } from "@/components/trans";
import { getTranslation } from "@/i18n/server";
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageTitle>
{t("events", {
defaultValue: "Events",
})}
</PageTitle>
</div>
<PageTitle>
<EventPageIcon />
<Trans i18nKey="events" defaults="Events" />
</PageTitle>
<PageDescription>
<Trans
i18nKey="eventsPageDesc"
defaults="View and manage your scheduled events"
/>
</PageDescription>
</PageHeader>
<PageContent>
<UserScheduledEvents />
@ -28,12 +34,8 @@ export default async function Page({ params }: { params: Params }) {
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
export async function generateMetadata() {
const { t } = await getTranslation();
return {
title: t("events", {
defaultValue: "Events",

View file

@ -1,39 +1,41 @@
"use client";
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
import { useSearchParams } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PastEvents } from "@/app/[locale]/(admin)/events/past-events";
import { UpcomingEvents } from "@/app/[locale]/(admin)/events/upcoming-events";
import { Trans } from "@/components/trans";
import { UpcomingEvents } from "./upcoming-events";
const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming");
export function UserScheduledEvents() {
const searchParams = useSearchParams();
const period = eventPeriodSchema.parse(searchParams?.get("period"));
const router = useRouter();
return (
<div className="space-y-4">
<div>
<RadioCards
value={period}
onValueChange={(value) => {
const newParams = new URLSearchParams(searchParams?.toString());
newParams.set("period", value);
window.history.pushState(null, "", `?${newParams.toString()}`);
}}
>
<RadioCardsItem value="upcoming">
<Tabs
value={searchParams.get("period") ?? "upcoming"}
onValueChange={(value) => {
const params = new URLSearchParams(searchParams);
params.set("period", value);
const newUrl = `?${params.toString()}`;
router.replace(newUrl);
}}
aria-label="Event period"
>
<TabsList>
<TabsTrigger value="upcoming">
<Trans i18nKey="upcoming" defaults="Upcoming" />
</RadioCardsItem>
<RadioCardsItem value="past">
</TabsTrigger>
<TabsTrigger value="past">
<Trans i18nKey="past" defaults="Past" />
</RadioCardsItem>
</RadioCards>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<div>
{period === "upcoming" && <UpcomingEvents />}
{period === "past" && <PastEvents />}

View file

@ -1,42 +1,53 @@
import { cn } from "@rallly/ui";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import React from "react";
import { ActionBar } from "@rallly/ui/action-bar";
import { Button } from "@rallly/ui/button";
import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar";
import Link from "next/link";
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
import { AppSidebar } from "@/app/[locale]/(admin)/components/sidebar/app-sidebar";
import { AppSidebarProvider } from "@/app/[locale]/(admin)/components/sidebar/app-sidebar-provider";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { getUser } from "@/data/get-user";
import { CommandMenu } from "@/features/navigation/command-menu";
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const helpers = await createSSRHelper();
await helpers.user.subscription.prefetch();
const dehydratedState = dehydrate(helpers.queryClient);
const user = await getUser();
return (
<Hydrate state={dehydratedState}>
<div className="flex flex-col pb-16 md:pb-0">
<div
className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
)}
>
<div className="flex w-full items-center justify-between gap-4">
<LogoLink />
<ProBadge />
</div>
<Sidebar />
<AppSidebarProvider>
<CommandMenu />
<AppSidebar />
<SidebarInset>
<TopBar className="sm:hidden">
<TopBarLeft>
<SidebarTrigger />
</TopBarLeft>
<TopBarRight>
<Button
asChild
variant="ghost"
className="rounded-full"
size="icon"
>
<Link href="/settings/profile">
<OptimizedAvatarImage
src={user.image}
name={user.name}
size="xs"
/>
</Link>
</Button>
</TopBarRight>
</TopBar>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
</div>
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
<div className="max-w-5xl">{children}</div>
</div>
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
<MobileNavigation />
</div>
</div>
</Hydrate>
<ActionBar />
</SidebarInset>
</AppSidebarProvider>
);
}

View file

@ -1,5 +1,11 @@
import { Spinner } from "@/components/spinner";
import { PageSkeleton } from "@/app/components/page-layout";
import { RouterLoadingIndicator } from "@/components/router-loading-indicator";
export default async function Loading() {
return <Spinner />;
return (
<>
<RouterLoadingIndicator />
<PageSkeleton />
</>
);
}

View file

@ -1,42 +0,0 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { ArrowLeftIcon, MenuIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Trans } from "@/components/trans";
export function BackButton() {
const router = useRouter();
return (
<Button
variant="ghost"
onClick={() => {
router.back();
}}
>
<ArrowLeftIcon className="text-muted-foreground size-4" />
<span className="hidden sm:block">
<Trans i18nKey="back" defaults="Back" />
</span>
</Button>
);
}
export function MobileMenuButton({ open }: { open?: boolean }) {
if (open) {
return <BackButton />;
}
return (
<Button asChild variant="ghost">
<Link prefetch={true} href="/menu">
<Icon>
<MenuIcon />
</Icon>
</Link>
</Button>
);
}

View file

@ -1,27 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import type { Params } from "@/app/[locale]/types";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/i18n/server";
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<PageTitle>
<Trans t={t} i18nKey="menu" defaults="Menu" />
</PageTitle>
</PageHeader>
<PageContent className="px-2">
<Sidebar />
</PageContent>
</PageContainer>
);
}

View file

@ -1,52 +1,118 @@
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { HomeIcon } from "lucide-react";
import { Trans } from "react-i18next/TransWithoutContext";
import { Tile, TileGrid, TileTitle } from "@rallly/ui/tile";
import Dashboard from "@/app/[locale]/(admin)/dashboard";
import type { Params } from "@/app/[locale]/types";
import {
BillingPageIcon,
EventPageIcon,
HomePageIcon,
PollPageIcon,
PreferencesPageIcon,
ProfilePageIcon,
} from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageDescription,
PageHeader,
PageIcon,
PageTitle,
} from "@/app/components/page-layout";
import { Trans } from "@/components/trans";
import { getTranslation } from "@/i18n/server";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
const helpers = await createSSRHelper();
await helpers.dashboard.info.prefetch();
await getTranslation(params.locale);
return (
<Hydrate state={dehydrate(helpers.queryClient)}>
<div>
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageIcon>
<HomeIcon />
</PageIcon>
<PageTitle>
<Trans t={t} i18nKey="home" defaults="Home" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
<Dashboard />
</PageContent>
</PageContainer>
</div>
</Hydrate>
<PageContainer>
<PageHeader>
<PageTitle>
<HomePageIcon />
<Trans i18nKey="home" defaults="Home" />
</PageTitle>
<PageDescription>
<Trans
i18nKey="homeDashboardDesc"
defaults="Manage your polls, events, and account settings"
/>
</PageDescription>
</PageHeader>
<PageContent className="space-y-8">
{/* <div className="space-y-4">
<h2 className="text-muted-foreground text-sm">
<Trans i18nKey="homeActionsTitle" defaults="Actions" />
</h2>
<TileGrid>
<Tile href="/new">
<CreatePageIcon />
<TileTitle>
<Trans i18nKey="create" defaults="Create" />
</TileTitle>
</Tile>
</TileGrid>
</div> */}
<div className="space-y-4">
<h2 className="text-muted-foreground text-sm">
<Trans i18nKey="homeNavTitle" defaults="Navigation" />
</h2>
<TileGrid>
<Tile href="/polls">
<PollPageIcon />
<TileTitle>
<Trans i18nKey="polls" defaults="Polls" />
</TileTitle>
</Tile>
<Tile href="/events">
<EventPageIcon />
<TileTitle>
<Trans i18nKey="events" defaults="Events" />
</TileTitle>
</Tile>
{/* <Tile href="/members">
<MembersPageIcon />
<TileTitle>
<Trans i18nKey="members" defaults="Members" />
</TileTitle>
</Tile> */}
</TileGrid>
</div>
<div className="space-y-4">
<h2 className="text-muted-foreground text-sm">
<Trans i18nKey="account" defaults="Account" />
</h2>
<TileGrid>
<Tile href="/settings/profile">
<ProfilePageIcon />
<TileTitle>
<Trans i18nKey="profile" defaults="Profile" />
</TileTitle>
</Tile>
<Tile href="/settings/preferences">
<PreferencesPageIcon />
<TileTitle>
<Trans i18nKey="preferences" defaults="Preferences" />
</TileTitle>
</Tile>
<Tile href="/settings/billing">
<BillingPageIcon />
<TileTitle>
<Trans i18nKey="billing" defaults="Billing" />
</TileTitle>
</Tile>
</TileGrid>
</div>
</PageContent>
</PageContainer>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
export async function generateMetadata() {
const { t } = await getTranslation();
return {
title: t("home", {
defaultValue: "Home",

View file

@ -0,0 +1,49 @@
"use server";
import { prisma } from "@rallly/database";
import { revalidatePath } from "next/cache";
import { auth } from "@/next-auth";
/**
* Deletes multiple polls by their IDs
* Only allows deletion of polls owned by the current user
*/
export async function deletePolls(pollIds: string[]) {
try {
// Get the current user session
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
// Delete polls that belong to the current user
const result = await prisma.poll.updateMany({
where: {
id: {
in: pollIds,
},
userId: session.user.id,
},
data: {
deleted: true,
deletedAt: new Date(),
},
});
// Revalidate the polls page to reflect the changes
revalidatePath("/polls");
return {
success: true,
count: result.count,
};
} catch (error) {
console.error("Failed to delete polls:", error);
return {
success: false,
error: "Failed to delete polls",
};
}
}

View file

@ -1,5 +0,0 @@
import { Spinner } from "@/components/spinner";
export default async function Loading() {
return <Spinner />;
}

View file

@ -1,53 +1,230 @@
import { BarChart2Icon } from "lucide-react";
import type { PollStatus } from "@rallly/database";
import { Button } from "@rallly/ui/button";
import { shortUrl } from "@rallly/utils/absolute-url";
import { InboxIcon } from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import { UserPolls } from "@/app/[locale]/(admin)/polls/user-polls";
import type { Params } from "@/app/[locale]/types";
import { PollPageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageDescription,
PageHeader,
PageIcon,
PageTitle,
} from "@/app/components/page-layout";
import { CopyLinkButton } from "@/components/copy-link-button";
import {
EmptyState,
EmptyStateDescription,
EmptyStateFooter,
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { Pagination } from "@/components/pagination";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { PollStatusIcon } from "@/components/poll-status-icon";
import {
StackedList,
StackedListItem,
StackedListItemContent,
} from "@/components/stacked-list";
import { Trans } from "@/components/trans";
import { getPolls } from "@/data/get-polls";
import { getTranslation } from "@/i18n/server";
import { requireUser } from "@/next-auth";
export default async function Page({
params,
import { PollsTabbedView } from "./polls-tabbed-view";
import { SearchInput } from "./search-input";
const DEFAULT_PAGE_SIZE = 20;
const pageSchema = z
.string()
.nullish()
.transform((val) => {
if (!val) return 1;
const parsed = parseInt(val, 10);
return isNaN(parsed) || parsed < 1 ? 1 : parsed;
});
const querySchema = z
.string()
.nullish()
.transform((val) => val?.trim() || undefined);
const statusSchema = z
.enum(["live", "paused", "finalized"])
.nullish()
.transform((val) => val || "live");
const pageSizeSchema = z
.string()
.nullish()
.transform((val) => {
if (!val) return DEFAULT_PAGE_SIZE;
const parsed = parseInt(val, 10);
return isNaN(parsed) || parsed < 1
? DEFAULT_PAGE_SIZE
: Math.min(parsed, 100);
});
// Combined schema for type inference
async function loadData({
userId,
status = "live",
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
q,
}: {
params: Params;
children?: React.ReactNode;
userId: string;
status?: PollStatus;
page?: number;
pageSize?: number;
q?: string;
}) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageIcon>
<BarChart2Icon />
</PageIcon>
<PageTitle>
{t("polls", {
defaultValue: "Polls",
})}
</PageTitle>
</div>
</PageHeader>
<PageContent>
<UserPolls />
</PageContent>
</PageContainer>
);
const [{ total, data: polls }] = await Promise.all([
getPolls({ userId, status, page, pageSize, q }),
]);
return {
polls,
total,
};
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
export async function generateMetadata() {
const { t } = await getTranslation();
return {
title: t("polls", {
defaultValue: "Polls",
}),
};
}
function PollsEmptyState() {
return (
<EmptyState className="p-8">
<EmptyStateIcon>
<InboxIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noPolls" defaults="No polls found" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans
i18nKey="noPollsDescription"
defaults="Try adjusting your search or create a new poll"
/>
</EmptyStateDescription>
<EmptyStateFooter>
<Button variant="primary" asChild>
<Link href="/new">
<Trans i18nKey="createPoll" defaults="Create Poll" />
</Link>
</Button>
</EmptyStateFooter>
</EmptyState>
);
}
export default async function Page({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const { userId } = await requireUser();
const parsedStatus = statusSchema.parse(searchParams.status);
const parsedPage = pageSchema.parse(searchParams.page);
const parsedPageSize = pageSizeSchema.parse(searchParams.pageSize);
const parsedQuery = querySchema.parse(searchParams.q);
const { polls, total } = await loadData({
userId,
status: parsedStatus,
page: parsedPage,
pageSize: parsedPageSize,
q: parsedQuery,
});
const totalPages = Math.ceil(total / parsedPageSize);
return (
<PageContainer>
<div className="flex gap-4">
<PageHeader className="flex-1">
<PageTitle>
<PollPageIcon />
<Trans i18nKey="polls" defaults="Polls" />
</PageTitle>
<PageDescription>
<Trans
i18nKey="pollsPageDesc"
defaults="View and manage all your scheduling polls"
/>
</PageDescription>
</PageHeader>
<div className="flex items-start gap-2">
<Button size="sm" asChild>
<Link href="/new">
<Trans i18nKey="create" defaults="Create" />
</Link>
</Button>
</div>
</div>
<PageContent className="space-y-4">
<PollsTabbedView>
<div className="space-y-4">
<SearchInput />
{polls.length === 0 ? (
<PollsEmptyState />
) : (
<>
<StackedList className="overflow-hidden">
{polls.map((poll) => (
<StackedListItem
className="relative hover:bg-gray-50"
key={poll.id}
>
<div className="flex items-center gap-4">
<StackedListItemContent className="relative flex min-w-0 flex-1 items-center gap-2">
<PollStatusIcon status={poll.status} />
<Link
className="focus:ring-ring truncate text-sm font-medium hover:underline focus-visible:ring-2"
href={`/poll/${poll.id}`}
>
<span className="absolute inset-0" />
{poll.title}
</Link>
</StackedListItemContent>
<StackedListItemContent className="z-10 hidden items-center justify-end gap-4 sm:flex">
<ParticipantAvatarBar
participants={poll.participants}
max={5}
/>
<CopyLinkButton
href={shortUrl(`/invite/${poll.id}`)}
/>
</StackedListItemContent>
</div>
</StackedListItem>
))}
</StackedList>
{totalPages > 1 ? (
<Pagination
currentPage={parsedPage}
totalPages={totalPages}
totalItems={total}
pageSize={parsedPageSize}
className="mt-4"
/>
) : null}
</>
)}
</div>
</PollsTabbedView>
</PageContent>
</PageContainer>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { Trans } from "@/components/trans";
export function PollsTabbedView({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const name = "status";
const router = useRouter();
const handleTabChange = React.useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams);
params.set(name, value);
params.delete("page");
const newUrl = `?${params.toString()}`;
router.replace(newUrl, { scroll: false });
},
[name, router, searchParams],
);
const value = searchParams.get(name) ?? "live";
return (
<Tabs value={value} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="live">
<Trans i18nKey="pollStatusOpen" defaults="Live" />
</TabsTrigger>
<TabsTrigger value="paused">
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</TabsTrigger>
<TabsTrigger value="finalized">
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
</TabsTrigger>
</TabsList>
<TabsContent tabIndex={-1} value={value} key={value}>
{children}
</TabsContent>
</Tabs>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { Icon } from "@rallly/ui/icon";
import { Input } from "@rallly/ui/input";
import debounce from "lodash/debounce";
import { SearchIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { useTranslation } from "@/i18n/client";
export function SearchInput() {
const { t } = useTranslation();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
// Create a ref for the input element to maintain focus
const inputRef = React.useRef<HTMLInputElement>(null);
// Get current search value from URL
const currentSearchValue = searchParams.get("q") || "";
// Track input value in state
const [inputValue, setInputValue] = React.useState(currentSearchValue);
// Create a debounced function to update the URL
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateUrl = React.useCallback(
debounce((value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
params.delete("page");
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 500),
[pathname, router, searchParams],
);
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
debouncedUpdateUrl(newValue);
};
return (
<form
className="relative w-72"
onSubmit={(e) => {
e.preventDefault();
debouncedUpdateUrl.flush();
}}
>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
<Icon>
<SearchIcon />
</Icon>
</div>
<Input
ref={inputRef}
type="search"
autoFocus={searchParams.get("q") !== null}
placeholder={t("searchPollsPlaceholder", {
defaultValue: "Search polls by title...",
})}
className="pl-8"
value={inputValue}
onChange={handleChange}
/>
</form>
);
}

View file

@ -1,255 +0,0 @@
"use client";
import type { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import React from "react";
import useCopyToClipboard from "react-use/lib/useCopyToClipboard";
import { z } from "zod";
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { PollStatusBadge } from "@/components/poll-status";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { VisibilityTrigger } from "@/components/visibility-trigger";
import { trpc } from "@/trpc/client";
function PollCount({ count }: { count?: number }) {
return <span className="font-semibold">{count || 0}</span>;
}
function FilteredPolls({ status }: { status: PollStatus }) {
const { data, fetchNextPage, hasNextPage } =
trpc.polls.infiniteList.useInfiniteQuery(
{
status,
limit: 30,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
if (!data) {
return <Spinner />;
}
return (
<div className="space-y-6">
<ol className="space-y-4">
{data.pages.map((page, i) => (
<li key={i}>
<PollsListView data={page.polls} />
</li>
))}
</ol>
{hasNextPage ? (
<VisibilityTrigger onVisible={fetchNextPage} className="mt-6">
<Spinner />
</VisibilityTrigger>
) : null}
</div>
);
}
function PollStatusMenu({
status,
onStatusChange,
}: {
status?: PollStatus;
onStatusChange?: (status: PollStatus) => void;
}) {
const { data: countByStatus, isFetching } =
trpc.polls.getCountByStatus.useQuery();
if (!countByStatus) {
return null;
}
return (
<RadioCards value={status} onValueChange={onStatusChange}>
<RadioCardsItem className="flex items-center gap-2.5" value="live">
<Trans i18nKey="pollStatusOpen" />
<PollCount count={countByStatus.live} />
</RadioCardsItem>
<RadioCardsItem className="flex items-center gap-2.5" value="paused">
<Trans i18nKey="pollStatusPaused" />
<PollCount count={countByStatus.paused} />
</RadioCardsItem>
<RadioCardsItem className="flex items-center gap-2.5" value="finalized">
<Trans i18nKey="pollStatusFinalized" />
<PollCount count={countByStatus.finalized} />
</RadioCardsItem>
{isFetching && <Spinner />}
</RadioCards>
);
}
function useQueryParam(name: string) {
const searchParams = useSearchParams();
return [
searchParams?.get(name),
function (value: string) {
const newParams = new URLSearchParams(searchParams?.toString());
newParams.set(name, value);
window.history.replaceState(null, "", `?${newParams.toString()}`);
},
] as const;
}
const pollStatusSchema = z.enum(["live", "paused", "finalized"]).catch("live");
const pollStatusQueryKey = "status";
export function UserPolls() {
const [pollStatus, setPollStatus] = useQueryParam(pollStatusQueryKey);
const parsedPollStatus = pollStatusSchema.parse(pollStatus);
return (
<div className="space-y-4">
<PollStatusMenu
status={parsedPollStatus}
onStatusChange={setPollStatus}
/>
<FilteredPolls status={parsedPollStatus} />
</div>
);
}
function CopyLinkButton({ pollId }: { pollId: string }) {
const [, copy] = useCopyToClipboard();
const [didCopy, setDidCopy] = React.useState(false);
return (
<Button
type="button"
disabled={didCopy}
onClick={(e) => {
e.stopPropagation();
copy(`${window.location.origin}/invite/${pollId}`);
setDidCopy(true);
setTimeout(() => {
setDidCopy(false);
}, 1000);
}}
className="relative z-20 w-full"
>
{didCopy ? (
<>
<CheckIcon className="size-4" />
<Trans i18nKey="copied" defaults="Copied" />
</>
) : (
<>
<LinkIcon className="size-4" />
<Trans i18nKey="copyLink" defaults="Copy Link" />
</>
)}
</Button>
);
}
function ParticipantCount({ count }: { count: number }) {
return (
<div className="inline-flex items-center gap-x-1 text-sm font-medium">
<Icon>
<UserIcon />
</Icon>
<span>{count}</span>
</div>
);
}
function PollsListView({
data,
}: {
data: {
id: string;
status: PollStatus;
title: string;
createdAt: Date;
user: {
id: string;
name: string;
} | null;
guestId?: string | null;
participants: {
id: string;
name: string;
}[];
}[];
}) {
const table = useReactTable({
columns: [],
data,
getCoreRowModel: getCoreRowModel(),
});
if (data?.length === 0) {
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarPlusIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noPolls" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans i18nKey="noPollsDescription" />
</EmptyStateDescription>
</EmptyState>
);
}
return (
<div className="grid gap-3 sm:gap-4 md:grid-cols-2">
{table.getRowModel().rows.map((row) => (
<div
className={cn(
"group relative space-y-4 overflow-hidden rounded-lg border bg-white p-4 focus-within:bg-gray-50",
)}
key={row.id}
>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div>
<GroupPollIcon size="xs" />
</div>
<h2 className="truncate text-base font-medium group-hover:underline">
<Link
href={`/poll/${row.original.id}`}
className="absolute inset-0 z-10"
/>
{row.original.title}
</h2>
</div>
<div className="flex items-center gap-2">
<Badge size="lg">
<PollStatusBadge status={row.original.status} />
</Badge>
<Badge size="lg">
<ParticipantCount count={row.original.participants.length} />
</Badge>
</div>
</div>
<div className="flex items-end justify-between">
<CopyLinkButton pollId={row.original.id} />
</div>
</div>
))}
</div>
);
}

View file

@ -1,20 +0,0 @@
"use client";
import { Badge } from "@rallly/ui/badge";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
export function ProBadge() {
const { user } = useUser();
if (user.tier !== "pro") {
return null;
}
return (
<Badge variant="primary">
<Trans i18nKey="planPro" />
</Badge>
);
}

View file

@ -0,0 +1,82 @@
import type { PollStatus } from "@rallly/database";
import { Icon } from "@rallly/ui/icon";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { ScheduledEventDisplay } from "@/components/poll/scheduled-event-display";
import { PollStatusIcon } from "@/components/poll-status-icon";
import { RelativeDate } from "@/components/relative-date";
import { Trans } from "@/components/trans";
type Poll = {
id: string;
title: string;
status: PollStatus;
createdAt: Date;
updatedAt: Date;
participants: {
id: string;
name: string;
image?: string;
}[];
dateOptions: {
first?: Date;
last?: Date;
count: number;
duration: number | number[];
};
event?: {
start: Date;
duration: number;
};
};
type PollCardProps = {
poll: Poll;
};
export const PollCard = ({ poll }: PollCardProps) => {
return (
<Link
href={`/poll/${poll.id}`}
className="group flex h-full flex-col rounded-lg border border-gray-200 bg-white p-4 hover:bg-gray-50"
>
<div className="mb-2 flex items-center justify-between">
<PollStatusIcon status={poll.status} />
<div className="text-muted-foreground text-xs">
<RelativeDate date={poll.updatedAt} />
</div>
</div>
<h3 className="mb-4 line-clamp-2 text-base font-medium text-gray-900 group-hover:underline">
{poll.title}
</h3>
<div className="mt-auto space-y-2">
<ScheduledEventDisplay
event={poll.event}
dateOptions={poll.dateOptions}
/>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Icon>
<UsersIcon />
</Icon>
<span>
{poll.participants.length}{" "}
<Trans i18nKey="participants" defaults="participants" />
</span>
</div>
</div>
</Link>
);
};
export const RecentlyUpdatedPollsGrid = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{children}
</div>
);
};

View file

@ -1,5 +0,0 @@
import { Spinner } from "@/components/spinner";
export default async function Loading() {
return <Spinner />;
}

View file

@ -13,8 +13,12 @@ import {
SparklesIcon,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import {
SettingsContent,
SettingsSection,
} from "@/app/[locale]/(admin)/settings/components/settings-layout";
import {
DescriptionDetails,
DescriptionList,
@ -29,7 +33,6 @@ import {
} from "@/components/empty-state";
import { FormattedDate } from "@/components/formatted-date";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { Settings, SettingsSection } from "@/components/settings/settings";
import { Trans } from "@/components/trans";
import { requireUser } from "@/next-auth";
import { isSelfHosted } from "@/utils/constants";
@ -39,10 +42,10 @@ import { SubscriptionPrice } from "./components/subscription-price";
import { SubscriptionStatus } from "./components/subscription-status";
async function getData() {
const user = await requireUser();
const { userId } = await requireUser();
const data = await prisma.user.findUnique({
where: { id: user.id },
where: { id: userId },
select: {
customerId: true,
subscription: {
@ -70,7 +73,7 @@ async function getData() {
});
if (!data) {
throw new Error("User not found");
redirect("/api/auth/invalid-session");
}
return data;
@ -86,7 +89,7 @@ export default async function Page() {
const { subscription } = data;
return (
<Settings>
<SettingsContent>
<SettingsSection
title={
<Trans i18nKey="billingSubscriptionTitle" defaults="Subscription" />
@ -302,6 +305,6 @@ export default async function Page() {
</Button>
</div>
</SettingsSection>
</Settings>
</SettingsContent>
);
}

View file

@ -1,26 +1,7 @@
import { cn } from "@rallly/ui";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { InfoIcon } from "lucide-react";
export const Settings = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-6">{children}</div>;
};
export const SettingsHeader = ({ children }: React.PropsWithChildren) => {
return (
<div className="mb-4 text-lg font-semibold md:mb-8">
<h2>{children}</h2>
</div>
);
};
export const SettingsContent = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-6">{children}</div>;
};
@ -31,13 +12,15 @@ export const SettingsSection = (props: {
children: React.ReactNode;
}) => {
return (
<Card>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</CardHeader>
<CardContent>{props.children}</CardContent>
</Card>
<section className="rounded-lg border p-4">
<header className="mb-6">
<h2 className="mb-2 text-base font-semibold leading-none">
{props.title}
</h2>
<p className="text-muted-foreground text-sm">{props.description}</p>
</header>
<div>{props.children}</div>
</section>
);
};

View file

@ -0,0 +1,25 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { LogOutIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { Trans } from "@/components/trans";
export const SignOutButton = () => {
return (
<Button
variant="ghost"
size="sm"
onClick={() => {
signOut();
}}
>
<Icon>
<LogOutIcon />
</Icon>
<Trans i18nKey="signOut" defaults="Sign Out" />
</Button>
);
};

View file

@ -1,5 +1,6 @@
import React from "react";
import { SettingsPageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
@ -8,7 +9,8 @@ import {
} from "@/app/components/page-layout";
import { getTranslation } from "@/i18n/server";
import { SettingsMenu } from "./settings-menu";
import { SignOutButton } from "./components/sign-out-button";
import { SettingsLayout } from "./settings-menu";
export default async function ProfileLayout({
children,
@ -20,13 +22,22 @@ export default async function ProfileLayout({
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("settings")}</PageTitle>
</PageHeader>
<PageContent className="space-y-3 sm:space-y-4">
<div className="scrollbar-none -mx-3 overflow-auto bg-gray-100 px-3 sm:mx-0 sm:px-0">
<SettingsMenu />
<div className="flex items-start gap-4">
<div className="flex-1">
<PageTitle>
<SettingsPageIcon />
{t("settings", {
defaultValue: "Settings",
})}
</PageTitle>
</div>
<div className="flex items-center gap-2">
<SignOutButton />
</div>
</div>
<div>{children}</div>
</PageHeader>
<PageContent>
<SettingsLayout>{children}</SettingsLayout>
</PageContent>
</PageContainer>
);

View file

@ -1,5 +1,11 @@
import { RouterLoadingIndicator } from "@/components/router-loading-indicator";
import { Spinner } from "@/components/spinner";
export default async function Loading() {
return <Spinner />;
return (
<>
<RouterLoadingIndicator />
<Spinner />
</>
);
}

View file

@ -1,49 +1,39 @@
"use client";
import Head from "next/head";
import { DateTimePreferences } from "@/components/settings/date-time-preferences";
import { LanguagePreference } from "@/components/settings/language-preference";
import { DateTimePreferences } from "@/app/[locale]/(admin)/settings/components/date-time-preferences";
import { LanguagePreference } from "@/app/[locale]/(admin)/settings/components/language-preference";
import {
Settings,
SettingsContent,
SettingsSection,
} from "@/components/settings/settings";
} from "@/app/[locale]/(admin)/settings/components/settings-layout";
import { Trans } from "@/components/trans";
import { useTranslation } from "@/i18n/client";
export function PreferencesPage() {
const { t } = useTranslation();
return (
<Settings>
<SettingsContent>
<Head>
<title>{t("settings")}</title>
</Head>
<SettingsSection
title={<Trans i18nKey="language" defaults="Language" />}
description={
<Trans
i18nKey="languageDescription"
defaults="Change your preferred language"
/>
}
>
<LanguagePreference />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={
<Trans
i18nKey="dateAndTimeDescription"
defaults="Change your preferred date and time settings"
/>
}
>
<DateTimePreferences />
</SettingsSection>
</SettingsContent>
</Settings>
<SettingsContent>
<SettingsSection
title={<Trans i18nKey="language" defaults="Language" />}
description={
<Trans
i18nKey="languageDescription"
defaults="Change your preferred language"
/>
}
>
<LanguagePreference />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={
<Trans
i18nKey="dateAndTimeDescription"
defaults="Change your preferred date and time settings"
/>
}
>
<DateTimePreferences />
</SettingsSection>
</SettingsContent>
);
}

View file

@ -1,5 +0,0 @@
import { Spinner } from "@/components/spinner";
export default async function Loading() {
return <Spinner />;
}

View file

@ -1,97 +1,71 @@
"use client";
import { Button } from "@rallly/ui/button";
import { DialogTrigger } from "@rallly/ui/dialog";
import { LogOutIcon, TrashIcon } from "lucide-react";
import Head from "next/head";
import { TrashIcon } from "lucide-react";
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
import { ProfileSettings } from "@/app/[locale]/(admin)/settings/profile/profile-settings";
import { LogoutButton } from "@/app/components/logout-button";
import {
Settings,
SettingsContent,
SettingsSection,
} from "@/components/settings/settings";
} from "@/app/[locale]/(admin)/settings/components/settings-layout";
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
import { ProfileSettings } from "@/app/[locale]/(admin)/settings/profile/profile-settings";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { useTranslation } from "@/i18n/client";
import { ProfileEmailAddress } from "./profile-email-address";
export const ProfilePage = () => {
const { t } = useTranslation();
const { user } = useUser();
return (
<Settings>
<Head>
<title>{t("profile")}</title>
</Head>
<SettingsContent>
<SettingsSection
title={<Trans i18nKey="profile" defaults="Profile" />}
description={
<Trans
i18nKey="profileDescription"
defaults="Set your public profile information"
/>
}
>
<ProfileSettings />
</SettingsSection>
<SettingsSection
title={
<Trans i18nKey="profileEmailAddress" defaults="Email Address" />
}
description={
<Trans
i18nKey="profileEmailAddressDescription"
defaults="Your email address is used to log in to your account"
/>
}
>
<ProfileEmailAddress />
</SettingsSection>
<hr />
<SettingsContent>
<SettingsSection
title={<Trans i18nKey="profile" defaults="Profile" />}
description={
<Trans
i18nKey="profileDescription"
defaults="Set your public profile information"
/>
}
>
<ProfileSettings />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="profileEmailAddress" defaults="Email Address" />}
description={
<Trans
i18nKey="profileEmailAddressDescription"
defaults="Your email address is used to log in to your account"
/>
}
>
<ProfileEmailAddress />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="logout" />}
description={
<Trans
i18nKey="logoutDescription"
defaults="Sign out of your existing session"
/>
}
>
<LogoutButton>
<LogOutIcon className="size-4" />
<Trans i18nKey="logout" defaults="Logout" />
</LogoutButton>
</SettingsSection>
{user.email ? (
<>
<hr />
<SettingsSection
title={<Trans i18nKey="dangerZone" defaults="Danger Zone" />}
description={
<Trans
i18nKey="dangerZoneAccount"
defaults="Delete your account permanently. This action cannot be undone."
/>
}
>
<DeleteAccountDialog email={user.email}>
<DialogTrigger asChild>
<Button className="text-destructive">
<TrashIcon className="size-4" />
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
</Button>
</DialogTrigger>
</DeleteAccountDialog>
</SettingsSection>
</>
) : null}
</SettingsContent>
</Settings>
{user.email ? (
<>
<hr />
<SettingsSection
title={<Trans i18nKey="dangerZone" defaults="Danger Zone" />}
description={
<Trans
i18nKey="dangerZoneAccount"
defaults="Delete your account permanently. This action cannot be undone."
/>
}
>
<DeleteAccountDialog email={user.email}>
<DialogTrigger asChild>
<Button className="text-destructive">
<TrashIcon className="size-4" />
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
</Button>
</DialogTrigger>
</DeleteAccountDialog>
</SettingsSection>
</>
) : null}
</SettingsContent>
);
};

View file

@ -1,35 +1,39 @@
"use client";
import { Icon } from "@rallly/ui/icon";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import { Trans } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { TabMenu, TabMenuItem } from "@/app/components/tab-menu";
import { Trans } from "@/components/trans";
import { IfCloudHosted } from "@/contexts/environment";
export function SettingsMenu() {
export function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<TabMenu>
<TabMenuItem href="/settings/profile">
<Icon>
<UserIcon />
</Icon>
<Trans i18nKey="profile" />
</TabMenuItem>
<TabMenuItem href="/settings/preferences">
<Icon>
<Settings2Icon />
</Icon>
<Trans i18nKey="preferences" />
</TabMenuItem>
<IfCloudHosted>
<TabMenuItem href="/settings/billing">
<Icon>
<CreditCardIcon />
</Icon>
<Trans i18nKey="billing" />
</TabMenuItem>
</IfCloudHosted>
</TabMenu>
<Tabs value={pathname}>
<TabsList>
<TabsTrigger asChild value="/settings/profile">
<Link href="/settings/profile">
<Trans i18nKey="profile" defaults="Profile" />
</Link>
</TabsTrigger>
<TabsTrigger asChild value="/settings/preferences">
<Link href="/settings/preferences">
<Trans i18nKey="preferences" defaults="Preferences" />
</Link>
</TabsTrigger>
<IfCloudHosted>
<TabsTrigger asChild value="/settings/billing">
<Link href="/settings/billing">
<Trans i18nKey="billing" defaults="Billing" />
</Link>
</TabsTrigger>
</IfCloudHosted>
</TabsList>
<TabsContent className="mt-4" value={pathname}>
{children}
</TabsContent>
</Tabs>
);
}

View file

@ -1,199 +0,0 @@
"use client";
import { usePostHog } from "@rallly/posthog/client";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { DialogTrigger } from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import {
ArrowUpRightIcon,
BarChart2Icon,
CalendarIcon,
ChevronRightIcon,
HomeIcon,
LifeBuoyIcon,
LogInIcon,
PlusIcon,
Settings2Icon,
SparklesIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { Trans } from "@/components/trans";
import { IfGuest, useUser } from "@/components/user-provider";
import { IfFreeUser } from "@/contexts/plan";
import type { IconComponent } from "@/types";
function NavItem({
href,
children,
target,
icon: Icon,
current,
}: {
href: string;
target?: string;
icon: IconComponent;
children: React.ReactNode;
current?: boolean;
}) {
return (
<Link
href={href}
target={target}
className={cn(
current
? "text-foreground bg-gray-200"
: "text-muted-foreground border-transparent hover:bg-gray-200 focus:bg-gray-300",
"group flex items-center gap-x-3 rounded-md px-3 py-2 text-sm font-semibold leading-6",
)}
>
<Icon className={cn("size-5 shrink-0")} aria-hidden="true" />
{children}
</Link>
);
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useUser();
const posthog = usePostHog();
return (
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
<li>
<NavItem current={pathname === "/"} href="/" icon={HomeIcon}>
<Trans i18nKey="home" defaults="Home" />
</NavItem>
</li>
<li>
<NavItem
current={pathname?.startsWith("/polls")}
href="/polls"
icon={BarChart2Icon}
>
<Trans i18nKey="polls" defaults="Polls" />
</NavItem>
</li>
<li>
<NavItem
current={pathname?.startsWith("/events")}
href="/events"
icon={CalendarIcon}
>
<Trans i18nKey="events" defaults="Events" />
</NavItem>
</li>
</ul>
</li>
<li className="-mx-2 space-y-1">
<Button variant="primary" className="w-full rounded-full" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
<Trans i18nKey="create" defaults="create" />
</Link>
</Button>
</li>
<li className="mt-auto">
<ul role="list" className="-mx-2 space-y-1">
<IfFreeUser>
<li>
<PayWallDialog>
<DialogTrigger
className="relative mb-4 flex w-full items-center gap-4 overflow-hidden rounded-md border bg-gray-50/50 px-4 py-3 text-left ring-gray-200 hover:bg-gray-50 focus-visible:border-gray-300"
onClick={() =>
posthog?.capture("trigger paywall", { from: "sidebar" })
}
>
<SparklesIcon className="pointer-events-none absolute -left-2 -top-2 size-20 text-gray-500/10" />
<div>
<div className="inline-flex size-12 items-center justify-center rounded-md duration-500">
<Badge variant="primary">
<Trans i18nKey="planPro" />
</Badge>
</div>
</div>
<div>
<div className="mb-0.5 text-sm font-bold">
<Trans i18nKey="upgrade" />
</div>
<div className="text-sm leading-relaxed text-gray-500">
<Trans
i18nKey="unlockFeatures"
defaults="Unlock all Pro features."
/>
</div>
</div>
</DialogTrigger>
</PayWallDialog>
</li>
</IfFreeUser>
<IfGuest>
<li>
<NavItem href="/login" icon={LogInIcon}>
<Trans i18nKey="login" />
</NavItem>
</li>
</IfGuest>
<li>
<NavItem
target="_blank"
href="https://support.rallly.co"
icon={LifeBuoyIcon}
>
<Trans i18nKey="support" />
<Icon>
<ArrowUpRightIcon />
</Icon>
</NavItem>
</li>
<li>
<NavItem
href="/settings/preferences"
current={pathname === "/settings/preferences"}
icon={Settings2Icon}
>
<Trans i18nKey="preferences" />
</NavItem>
</li>
</ul>
<hr className="my-2" />
<ul role="list" className="-mx-2 space-y-1">
<li>
<Button
asChild
variant="ghost"
className="group h-auto w-full justify-start py-3"
>
<Link href="/settings/profile">
<div>
<OptimizedAvatarImage
src={user.image ?? undefined}
name={user.name}
size="md"
/>
</div>
<span className="ml-1 grid grow">
<span className="font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-sm">
{user.email}
</span>
</span>
<ChevronRightIcon className="text-muted-foreground size-4 opacity-0 group-hover:opacity-100" />
</Link>
</Button>
</li>
</ul>
</li>
</ul>
</nav>
);
}

View file

@ -13,6 +13,7 @@ import React from "react";
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
import { UserProvider } from "@/components/user-provider";
import { TimezoneProvider } from "@/features/timezone";
import { I18nProvider } from "@/i18n/client";
import { auth } from "@/next-auth";
import { TRPCProvider } from "@/trpc/client/provider";
@ -55,10 +56,14 @@ export default async function Root({
<PostHogPageView />
<TooltipProvider>
<UserProvider>
<ConnectedDayjsProvider>
{children}
<TimeZoneChangeDetector />
</ConnectedDayjsProvider>
<TimezoneProvider
initialTimezone={session?.user?.timeZone ?? undefined}
>
<ConnectedDayjsProvider>
{children}
<TimeZoneChangeDetector />
</ConnectedDayjsProvider>
</TimezoneProvider>
</UserProvider>
</TooltipProvider>
</PostHogProvider>

View file

@ -2,10 +2,12 @@
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { XIcon } from "lucide-react";
import { ArrowLeftIcon } from "lucide-react";
import { useRouter } from "next/navigation";
export function CloseButton() {
import { Trans } from "@/components/trans";
export function BackButton() {
const router = useRouter();
return (
@ -16,8 +18,9 @@ export function CloseButton() {
variant="ghost"
>
<Icon>
<XIcon />
<ArrowLeftIcon />
</Icon>
<Trans i18nKey="back" defaults="Back" />
</Button>
);
}

View file

@ -2,14 +2,15 @@ import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
import { BackButton } from "@/app/[locale]/(admin)/menu/menu-button";
import type { Params } from "@/app/[locale]/types";
import { PollPageIcon } from "@/app/components/page-icons";
import { CreatePoll } from "@/components/create-poll";
import { UserDropdown } from "@/components/user-dropdown";
import { getTranslation } from "@/i18n/server";
import { getLoggedIn } from "@/next-auth";
import { BackButton } from "./back-button";
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
const isLoggedIn = await getLoggedIn();
@ -17,16 +18,16 @@ export default async function Page({ params }: { params: Params }) {
return (
<div>
<div className="sticky top-0 z-20 border-b bg-gray-100/90 p-3 backdrop-blur-md sm:grid-cols-3">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-x-2 sm:px-6">
<div className="mx-auto flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-4 sm:flex-1">
<BackButton />
</div>
<div className="flex flex-1 sm:justify-center">
<div className="flex items-center gap-x-2">
<GroupPollIcon size="xs" />
<PollPageIcon />
<div className="flex items-baseline gap-x-8">
<h1 className="text-sm font-semibold">
<Trans t={t} i18nKey="groupPoll" defaults="Group Poll" />
<h1 className="font-semibold">
<Trans t={t} i18nKey="poll" defaults="Poll" />
</h1>
</div>
</div>

View file

@ -0,0 +1,7 @@
import { signOut } from "@/next-auth";
export async function GET() {
return await signOut({
redirectTo: "/login",
});
}

View file

@ -0,0 +1,152 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import {
BarChart2Icon,
CalendarIcon,
CreditCardIcon,
EyeIcon,
HomeIcon,
PlusIcon,
Settings2Icon,
SettingsIcon,
UserCogIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import React from "react";
const pageIconVariants = cva(
"inline-flex size-7 items-center justify-center rounded-lg",
{
variants: {
color: {
darkGray: "bg-gray-700 text-white",
indigo: "bg-indigo-500 text-white",
gray: "bg-gray-200 text-gray-600",
lime: "bg-lime-500 text-white",
blue: "bg-blue-500 text-white",
rose: "bg-rose-500 text-white",
purple: "bg-purple-500 text-white",
},
size: {
sm: "size-5",
md: "size-7",
},
},
defaultVariants: {
color: "gray",
size: "md",
},
},
);
type PageIconVariantProps = VariantProps<typeof pageIconVariants>;
export function PageIcon({
children,
color,
size,
}: {
children: React.ReactNode;
} & PageIconVariantProps) {
return (
<span className={pageIconVariants({ color, size })}>
<Slot className="size-4">{children}</Slot>
</span>
);
}
export function SettingsPageIcon() {
return (
<PageIcon color="gray" size="md">
<SettingsIcon />
</PageIcon>
);
}
export function AccountPageIcon() {
return (
<PageIcon color="gray" size="md">
<UserCogIcon />
</PageIcon>
);
}
export function SpacesPageIcon() {
return (
<PageIcon color="lime" size="md">
<EyeIcon />
</PageIcon>
);
}
export function MembersPageIcon() {
return (
<PageIcon color="indigo" size="md">
<UsersIcon />
</PageIcon>
);
}
export function TeamsPageIcon() {
return (
<PageIcon color="indigo" size="md">
<UsersIcon />
</PageIcon>
);
}
export function HomePageIcon() {
return (
<PageIcon color="darkGray" size="md">
<HomeIcon />
</PageIcon>
);
}
export function CreatePageIcon() {
return (
<PageIcon color="gray" size="md">
<PlusIcon />
</PageIcon>
);
}
export function PollPageIcon() {
return (
<PageIcon color="purple" size="md">
<BarChart2Icon />
</PageIcon>
);
}
export function EventPageIcon() {
return (
<PageIcon color="rose" size="md">
<CalendarIcon />
</PageIcon>
);
}
export function ProfilePageIcon() {
return (
<PageIcon color="gray" size="md">
<UserIcon />
</PageIcon>
);
}
export function PreferencesPageIcon() {
return (
<PageIcon color="gray" size="md">
<Settings2Icon />
</PageIcon>
);
}
export function BillingPageIcon() {
return (
<PageIcon color="gray" size="md">
<CreditCardIcon />
</PageIcon>
);
}

View file

@ -1,25 +1,14 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@rallly/ui";
import { Skeleton } from "@rallly/ui/skeleton";
export function PageContainer({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return <div className={cn(className)}>{children}</div>;
}
export function PageIcon({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("hidden", className)}>
<Slot className="size-4">{children}</Slot>
</div>
<div className={cn("mx-auto w-full max-w-7xl", className)}>{children}</div>
);
}
@ -33,7 +22,7 @@ export function PageTitle({
return (
<h1
className={cn(
"inline-flex items-center truncate text-xl font-bold tracking-tight text-gray-700",
"text-foreground flex gap-3 truncate text-xl font-bold tracking-tight",
className,
)}
>
@ -42,6 +31,20 @@ export function PageTitle({
);
}
export function PageDescription({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<p className={cn("text-muted-foreground mt-4 text-sm", className)}>
{children}
</p>
);
}
export function PageHeader({
children,
className,
@ -50,7 +53,7 @@ export function PageHeader({
className?: string;
variant?: "default" | "ghost";
}) {
return <div className={cn("mb-6", className)}>{children}</div>;
return <div className={cn(className)}>{children}</div>;
}
export function PageSection({ children }: { children?: React.ReactNode }) {
@ -68,5 +71,43 @@ export function PageContent({
children?: React.ReactNode;
className?: string;
}) {
return <div className={cn("md:grow", className)}>{children}</div>;
return (
<div className={cn("mt-4 md:grow lg:mt-6", className)}>{children}</div>
);
}
export function PageSkeleton() {
return (
<PageContainer>
<PageHeader>
<PageTitle>
<Skeleton className="size-8" />
<Skeleton className="h-8 w-32" />
</PageTitle>
<PageDescription>
<Skeleton className="h-4 w-64" />
</PageDescription>
</PageHeader>
<PageContent>
<div className="space-y-8">
<Skeleton className="h-8 w-1/2" />
<div className="space-y-4">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-1/2" />
</div>
<div className="space-y-4">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-1/2" />
</div>
<div className="space-y-4">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-1/2" />
</div>
</div>
</PageContent>
</PageContainer>
);
}

View file

@ -1,34 +0,0 @@
"use client";
import { cn } from "@rallly/ui";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
export function TabMenuItem({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<li>
<Link
className={cn(
"flex h-9 min-w-0 grow items-center gap-x-2.5 rounded-md px-2.5 text-sm font-medium",
pathname === href
? "text-foreground bg-gray-200"
: "hover:text-foreground focus:text-foreground border-transparent text-gray-500 focus:bg-gray-200",
)}
href={href}
>
{children}
</Link>
</li>
);
}
export function TabMenu({ children }: { children: React.ReactNode }) {
return <ul className="flex gap-2.5">{children}</ul>;
}

View file

@ -1,3 +1,4 @@
"use client";
import { cn } from "@rallly/ui";
import {
Dialog,

View file

@ -0,0 +1,52 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState } from "react";
import { useCopyToClipboard } from "react-use";
import { useTranslation } from "@/i18n/client";
export function CopyLinkButton({
href,
className,
}: {
href: string;
className?: string;
}) {
const [, copy] = useCopyToClipboard();
const [didCopy, setDidCopy] = useState(false);
const { t } = useTranslation();
return (
<Tooltip open={didCopy ? true : undefined}>
<TooltipTrigger asChild>
<Button
className={className}
variant="ghost"
size="icon"
onClick={() => {
copy(href);
setDidCopy(true);
setTimeout(() => setDidCopy(false), 1000);
}}
>
<Icon>
<CopyIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
{didCopy ? (
<div className="flex items-center gap-2">
<CheckIcon className="size-4 text-green-400" />
{t("copied", { defaultValue: "Copied" })}
</div>
) : (
<p>{t("copyLink", { defaultValue: "Copy link" })}</p>
)}
</TooltipContent>
</Tooltip>
);
}

View file

@ -6,7 +6,7 @@ const formatMap = {
short: "D MMM YYYY",
};
type Format = keyof typeof formatMap;
type Format = keyof typeof formatMap | string;
export function FormattedDate({
date,
@ -15,5 +15,8 @@ export function FormattedDate({
date: Date;
format: Format;
}) {
return <>{dayjs(date).format(formatMap[format])}</>;
// If format is a key in formatMap, use the predefined format, otherwise use the format string directly
const formatString =
format in formatMap ? formatMap[format as keyof typeof formatMap] : format;
return <>{dayjs(date).format(formatString)}</>;
}

View file

@ -16,7 +16,7 @@ import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { ProBadge } from "@/components/pro-badge";
import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = {
@ -42,7 +42,7 @@ const SettingTitle = ({
<div className="text-sm font-medium">{children}</div>
{pro ? (
<div>
<ProFeatureBadge />
<ProBadge />
</div>
) : null}
</div>

View file

@ -7,9 +7,9 @@ import React from "react";
const sizeToWidth = {
xs: 20,
sm: 24,
md: 36,
md: 32,
lg: 48,
xl: 56,
xl: 64,
};
export function OptimizedAvatarImage({
@ -26,7 +26,7 @@ export function OptimizedAvatarImage({
const [isLoaded, setLoaded] = React.useState(false);
return (
<Avatar
className={className}
className={cn("rounded-full", className)}
style={{ width: sizeToWidth[size], height: sizeToWidth[size] }}
>
{src ? (
@ -49,14 +49,14 @@ export function OptimizedAvatarImage({
<AvatarFallback
seed={name}
className={cn("shrink-0", {
"text-xs": size === "xs",
"text-sm": size === "sm",
"text-[10px]": size === "xs",
"text-[12px]": size === "sm",
"text-md": size === "md",
"text-lg": size === "lg",
"text-xl": size === "xl",
"text-3xl": size === "xl",
})}
>
{name[0]?.toUpperCase()}
{name?.[0]?.toUpperCase()}
</AvatarFallback>
) : null}
</Avatar>

View file

@ -0,0 +1,105 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { Trans } from "@/components/trans";
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
className?: string;
}
export function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
className,
}: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const navigateToPage = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
};
const handlePreviousPage = () => {
if (currentPage > 1) {
navigateToPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
navigateToPage(currentPage + 1);
}
};
return (
<div className={cn("flex items-center justify-between", className)}>
<div className="text-sm text-gray-500">
<Trans
i18nKey="paginationItems"
defaults="Showing {startItem}{endItem} of {totalItems}"
values={{
startItem,
endItem,
totalItems,
}}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handlePreviousPage}
disabled={currentPage === 1}
>
<Icon>
<ChevronLeftIcon />
</Icon>
<span className="sr-only">
<Trans i18nKey="paginationPrevious" defaults="Previous" />
</span>
</Button>
<div className="text-sm">
<Trans
i18nKey="paginationPage"
defaults="Page {currentPage} of {totalPages}"
values={{
currentPage,
totalPages,
}}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleNextPage}
disabled={currentPage === totalPages}
>
<span className="sr-only">
<Trans i18nKey="paginationNext" defaults="Next" />
</span>
<Icon>
<ChevronRightIcon />
</Icon>
</Button>
</div>
</div>
);
}

View file

@ -1,10 +1,16 @@
import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@rallly/ui/tooltip";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { Trans } from "@/components/trans";
interface ParticipantAvatarBarProps {
participants: { name: string }[];
participants: { name: string; image?: string }[];
max?: number;
}
@ -12,22 +18,40 @@ export const ParticipantAvatarBar = ({
participants,
max = Infinity,
}: ParticipantAvatarBarProps) => {
const visibleCount = participants.length > max ? max - 1 : max;
const hiddenCount = participants.length - visibleCount;
const totalParticipants = participants.length;
const visibleCount = totalParticipants <= max ? totalParticipants : max - 1;
const visibleParticipants = participants.slice(0, visibleCount);
const tooltipParticipants = participants.slice(
visibleCount,
visibleCount + 10,
);
const remainingCount =
totalParticipants - visibleCount - tooltipParticipants.length;
const hiddenCount = totalParticipants - visibleCount;
return (
<ul className="flex items-center -space-x-1">
{participants.slice(0, visibleCount).map((participant, index) => (
<ul className="flex cursor-default items-center -space-x-1 rounded-full bg-white p-0.5">
{visibleParticipants.map((participant, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<li className="z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
<OptimizedAvatarImage name={participant.name} size="xs" />
<OptimizedAvatarImage
name={participant.name}
src={participant.image}
size="xs"
/>
</li>
</TooltipTrigger>
<TooltipContent>{participant.name}</TooltipContent>
</Tooltip>
))}
{hiddenCount > 1 ? (
<li className="relative z-20 inline-flex items-center justify-center rounded-full ring-2 ring-white">
{hiddenCount > 0 ? (
<li className="relative z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
<Tooltip>
<TooltipTrigger asChild>
<span
@ -40,15 +64,24 @@ export const ParticipantAvatarBar = ({
+{hiddenCount}
</span>
</TooltipTrigger>
<TooltipContent>
<ul>
{participants
.slice(visibleCount, 10)
.map((participant, index) => (
<TooltipPortal>
<TooltipContent className="z-10">
<ul>
{tooltipParticipants.map((participant, index) => (
<li key={index}>{participant.name}</li>
))}
</ul>
</TooltipContent>
{remainingCount > 0 && (
<li>
<Trans
i18nKey="moreParticipants"
values={{ count: remainingCount }}
defaults="{count} more…"
/>
</li>
)}
</ul>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</li>
) : null}

View file

@ -56,7 +56,7 @@ export function PayWallDialog({ children, ...forwardedProps }: DialogProps) {
className="text-center"
aria-hidden="true"
>
<Badge size="lg" variant="primary">
<Badge size="lg" variant="secondary">
<Trans i18nKey="planPro" />
</Badge>
</m.div>
@ -158,7 +158,11 @@ export function PayWallDialog({ children, ...forwardedProps }: DialogProps) {
</section>
<footer className="space-y-4">
<div className="grid gap-2">
<UpgradeButton large annual={period === "yearly"}>
<UpgradeButton
className="w-full"
large
annual={period === "yearly"}
>
<Trans i18nKey="upgrade" defaults="Upgrade" />
</UpgradeButton>
</div>

View file

@ -0,0 +1,62 @@
import type { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@rallly/ui/tooltip";
import { CircleCheckIcon, CirclePauseIcon, CirclePlayIcon } from "lucide-react";
import { Trans } from "@/components/trans";
interface PollStatusIconProps {
status: PollStatus;
className?: string;
showTooltip?: boolean;
}
export function PollStatusIcon({
status,
className,
showTooltip = true,
}: PollStatusIconProps) {
const icon = (() => {
switch (status) {
case "live":
return <CirclePlayIcon className="size-4 text-amber-500" />;
case "paused":
return <CirclePauseIcon className="size-4 text-gray-400" />;
case "finalized":
return <CircleCheckIcon className="size-4 text-green-500" />;
}
})();
const label = (() => {
switch (status) {
case "live":
return <Trans i18nKey="pollStatusOpen" defaults="Live" />;
case "paused":
return <Trans i18nKey="pollStatusPaused" defaults="Paused" />;
case "finalized":
return <Trans i18nKey="pollStatusFinalized" defaults="Finalized" />;
}
})();
if (showTooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex", className)}>{icon}</span>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <span className={cn("inline-flex", className)}>{icon}</span>;
}

View file

@ -3,6 +3,8 @@ import { cn } from "@rallly/ui";
import { Trans } from "@/components/trans";
import { PollStatusIcon } from "./poll-status-icon";
export const PollStatusLabel = ({
status,
className,
@ -14,12 +16,9 @@ export const PollStatusLabel = ({
case "live":
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 text-sm font-medium text-pink-600",
className,
)}
className={cn("inline-flex items-center gap-x-2 text-sm", className)}
>
<span className="size-1.5 rounded-full bg-pink-600" />
<PollStatusIcon status={status} />
<Trans i18nKey="pollStatusOpen" defaults="Live" />
</span>
);
@ -27,12 +26,11 @@ export const PollStatusLabel = ({
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-gray-500",
"inline-flex items-center gap-x-2 rounded-full text-sm",
className,
)}
>
<span className="size-1.5 rounded-full bg-gray-600" />
<PollStatusIcon status={status} />
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</span>
);
@ -40,12 +38,11 @@ export const PollStatusLabel = ({
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-green-600",
"inline-flex items-center gap-x-2 rounded-full text-sm",
className,
)}
>
<span className="size-1.5 rounded-full bg-green-600" />
<PollStatusIcon status={status} />
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
</span>
);

View file

@ -30,7 +30,7 @@ import * as React from "react";
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog";
import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { ProBadge } from "@/components/pro-badge";
import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan";
import { usePoll } from "@/contexts/poll";
@ -217,7 +217,7 @@ const ManagePoll: React.FunctionComponent<{
<CalendarCheck2Icon />
</Icon>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProFeatureBadge />
<ProBadge />
</DropdownMenuItem>
<PauseResumeToggle />
</>
@ -245,7 +245,7 @@ const ManagePoll: React.FunctionComponent<{
>
<DropdownMenuItemIconLabel icon={CopyIcon}>
<Trans i18nKey="duplicate" defaults="Duplicate" />
<ProFeatureBadge />
<ProBadge />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
<DropdownMenuSeparator />

View file

@ -18,24 +18,12 @@ export const normalizeVotes = (
export const useAddParticipantMutation = () => {
const posthog = usePostHog();
const queryClient = trpc.useUtils();
return trpc.polls.participants.add.useMutation({
onSuccess: async (newParticipant, input) => {
const { pollId, name, email } = newParticipant;
queryClient.polls.participants.list.setData(
{ pollId },
(existingParticipants = []) => {
return [
{ ...newParticipant, votes: input.votes },
...existingParticipants,
];
},
);
onSuccess: async (_, input) => {
posthog?.capture("add participant", {
pollId,
name,
email,
pollId: input.pollId,
name: input.name,
email: input.email,
});
},
});

View file

@ -0,0 +1,57 @@
"use client";
import dayjs from "dayjs";
import { Trans } from "@/components/trans";
import { DateDisplay, DateTimeDisplay } from "@/features/timezone";
interface ScheduledEventDisplayProps {
event?: {
start: Date;
duration: number;
};
dateOptions?: {
first?: Date;
last?: Date;
count: number;
};
}
export const ScheduledEventDisplay = ({
event,
dateOptions,
}: ScheduledEventDisplayProps) => {
if (event) {
return (
<div className="flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">
{event.duration > 0 ? (
<DateTimeDisplay date={dayjs(event.start)} />
) : (
<DateDisplay date={dayjs(event.start)} />
)}
</span>
</div>
);
}
if (!dateOptions?.first || !dateOptions?.last || dateOptions.count === 0) {
return (
<span className="text-muted-foreground">
<Trans i18nKey="noDates" defaults="No dates" />
</span>
);
}
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
<Trans
i18nKey="optionCount"
defaults="{count, plural, one {# option} other {# options}}"
values={{ count: dateOptions.count }}
/>
</span>
</div>
);
};

View file

@ -4,7 +4,7 @@ import { Trans } from "@/components/trans";
export const ProBadge = ({ className }: { className?: string }) => {
return (
<Badge variant="primary" className={className}>
<Badge variant="secondary" className={className}>
<Trans i18nKey="planPro" />
</Badge>
);

View file

@ -1,12 +0,0 @@
"use client";
import { usePlan } from "@/contexts/plan";
import { ProBadge } from "./pro-badge";
export const ProFeatureBadge = ({ className }: { className?: string }) => {
const plan = usePlan();
if (plan === "free") {
return <ProBadge className={className} />;
}
return null;
};

View file

@ -0,0 +1,6 @@
"use client";
import dayjs from "dayjs";
export function RelativeDate({ date }: { date: Date }) {
return <>{dayjs(date).fromNow()}</>;
}

View file

@ -0,0 +1,72 @@
"use client";
import { cn } from "@rallly/ui";
import { Progress } from "@rallly/ui/progress";
import * as React from "react";
export const RouterLoadingIndicator = () => {
const [isLoading, setIsLoading] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const progressInterval = React.useRef<NodeJS.Timeout | null>(null);
const currentProgress = React.useRef(0);
// Track route changes
React.useEffect(() => {
const startLoading = () => {
setIsLoading(true);
setProgress(0);
currentProgress.current = 0;
// Use a smaller step for slower progress initially
let step = 0.5;
progressInterval.current = setInterval(() => {
currentProgress.current += step;
const calculatedProgress = Math.round(
(Math.atan(currentProgress.current) / (Math.PI / 2)) * 100,
);
setProgress(calculatedProgress);
if (calculatedProgress >= 70) {
step = 0.1;
}
}, 100);
};
const stopLoading = () => {
setProgress(100);
// Clear the interval
if (progressInterval.current) {
clearInterval(progressInterval.current);
progressInterval.current = null;
}
// Reset after animation completes
setTimeout(() => {
setIsLoading(false);
setProgress(0);
}, 500);
};
startLoading();
// When route changes complete, stop loading
return () => {
stopLoading();
};
}, []);
return (
<div
className={cn(
"fixed left-0 right-0 top-0 z-50 w-full",
isLoading ? "opacity-100" : "opacity-0",
)}
>
<Progress value={progress} className="h-1 rounded-none" />
</div>
);
};

View file

@ -0,0 +1,35 @@
import { cn } from "@rallly/ui";
export function StackedList({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("divide-y rounded-lg border", className)}>
{children}
</div>
);
}
export function StackedListItem({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("p-1", className)}>{children}</div>;
}
export function StackedListItemContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("p-3", className)}>{children}</div>;
}

View file

@ -1,183 +0,0 @@
"use client";
import { cn } from "@rallly/ui";
import Link from "next/link";
import React, { createContext, useContext, useState } from "react";
const TableContext = createContext<{
bleed: boolean;
dense: boolean;
grid: boolean;
striped: boolean;
}>({
bleed: false,
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
className,
children,
...props
}: {
bleed?: boolean;
dense?: boolean;
grid?: boolean;
striped?: boolean;
} & React.ComponentPropsWithoutRef<"div">) {
return (
<TableContext.Provider
value={
{ bleed, dense, grid, striped } as React.ContextType<
typeof TableContext
>
}
>
<div className="flow-root">
<div
{...props}
className={cn(
className,
"-mx-(--gutter) overflow-x-auto whitespace-nowrap",
)}
>
<div
className={cn(
"inline-block min-w-full align-middle",
!bleed && "sm:px-(--gutter)",
)}
>
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">
{children}
</table>
</div>
</div>
</div>
</TableContext.Provider>
);
}
export function TableHead({
className,
...props
}: React.ComponentPropsWithoutRef<"thead">) {
return (
<thead
{...props}
className={cn(className, "text-zinc-500 dark:text-zinc-400")}
/>
);
}
export function TableBody(props: React.ComponentPropsWithoutRef<"tbody">) {
return <tbody {...props} />;
}
const TableRowContext = createContext<{
href?: string;
target?: string;
title?: string;
}>({
href: undefined,
target: undefined,
title: undefined,
});
export function TableRow({
href,
target,
title,
className,
...props
}: {
href?: string;
target?: string;
title?: string;
} & React.ComponentPropsWithoutRef<"tr">) {
const { striped } = useContext(TableContext);
return (
<TableRowContext.Provider
value={
{ href, target, title } as React.ContextType<typeof TableRowContext>
}
>
<tr
{...props}
className={cn(
className,
href &&
"has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/[2.5%]",
striped && "even:bg-zinc-950/[2.5%] dark:even:bg-white/[2.5%]",
href && striped && "hover:bg-zinc-950/5 dark:hover:bg-white/5",
href &&
!striped &&
"hover:bg-zinc-950/[2.5%] dark:hover:bg-white/[2.5%]",
)}
/>
</TableRowContext.Provider>
);
}
export function TableHeader({
className,
...props
}: React.ComponentPropsWithoutRef<"th">) {
const { bleed, grid } = useContext(TableContext);
return (
<th
{...props}
className={cn(
className,
"first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) border-b border-b-zinc-950/10 px-4 py-2 font-medium dark:border-b-white/10",
grid &&
"border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5",
!bleed && "sm:first:pl-1 sm:last:pr-1",
)}
/>
);
}
export function TableCell({
className,
children,
...props
}: React.ComponentPropsWithoutRef<"td">) {
const { bleed, dense, grid, striped } = useContext(TableContext);
const { href, target, title } = useContext(TableRowContext);
const [cellRef, setCellRef] = useState<HTMLElement | null>(null);
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={cn(
className,
"first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) relative px-4",
!striped && "border-b border-zinc-950/5 dark:border-white/5",
grid &&
"border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5",
dense ? "py-2.5" : "py-4",
!bleed && "sm:first:pl-1 sm:last:pr-1",
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="focus:outline-hidden absolute inset-0"
/>
)}
{children}
</td>
);
}

View file

@ -15,7 +15,7 @@ import {
import { useDialog } from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import dayjs from "dayjs";
import { CheckIcon, GlobeIcon } from "lucide-react";
import { CheckIcon, ChevronsUpDownIcon, GlobeIcon } from "lucide-react";
import React from "react";
import { Trans } from "@/components/trans";
@ -96,6 +96,9 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
<GlobeIcon />
</Icon>
{value}
<Icon>
<ChevronsUpDownIcon />
</Icon>
</Button>
</>
);

View file

@ -3,13 +3,9 @@ import { Trans as BaseTrans, useTranslation } from "react-i18next";
import type { TxKeyPath } from "../i18n/types";
export const Trans = (props: {
i18nKey: TxKeyPath;
defaults?: string;
values?: Record<string, string | number | boolean | undefined>;
children?: React.ReactNode;
components?: Record<string, React.ReactElement> | React.ReactElement[];
}) => {
export const Trans = (
props: React.ComponentProps<typeof BaseTrans> & { i18nKey: TxKeyPath },
) => {
const { t } = useTranslation();
return <BaseTrans ns="app" t={t} {...props} />;
};

View file

@ -1,3 +1,4 @@
"use client";
import { usePostHog } from "@rallly/posthog/client";
import { Button } from "@rallly/ui/button";
import Link from "next/link";
@ -9,7 +10,12 @@ export const UpgradeButton = ({
children,
annual,
large,
}: React.PropsWithChildren<{ annual?: boolean; large?: boolean }>) => {
className,
}: React.PropsWithChildren<{
annual?: boolean;
large?: boolean;
className?: string;
}>) => {
const posthog = usePostHog();
const formRef = React.useRef<HTMLFormElement>(null);
@ -27,7 +33,7 @@ export const UpgradeButton = ({
/>
<Button
size={large ? "lg" : "default"}
className="w-full"
className={className}
variant="primary"
onClick={(e) => {
// 🐛 Since we have nested forms, we need to prevent the default
@ -39,7 +45,7 @@ export const UpgradeButton = ({
posthog?.capture("click upgrade button");
}}
>
{children || <Trans i18nKey="upgrade" defaults="Upgrade" />}
{children || <Trans i18nKey="upgradeToPro" defaults="Upgrade to Pro" />}
</Button>
</form>
);

View file

@ -2,6 +2,7 @@
import { Badge } from "@rallly/ui/badge";
import React from "react";
import { ProBadge } from "@/components/pro-badge";
import { Trans } from "@/components/trans";
import { trpc } from "@/trpc/client";
import { isSelfHosted } from "@/utils/constants";
@ -44,11 +45,7 @@ export const Plan = () => {
const plan = usePlan();
if (plan === "paid") {
return (
<Badge variant="primary">
<Trans i18nKey="planPro" defaults="Pro" />
</Badge>
);
return <ProBadge />;
}
return (

View file

@ -0,0 +1,19 @@
import { prisma } from "@rallly/database";
export async function getPollCountByStatus(userId: string) {
const res = await prisma.poll.groupBy({
by: ["status"],
where: { userId, deleted: false },
_count: true,
});
const counts = res.reduce(
(acc, item) => {
acc[item.status] = item._count;
return acc;
},
{ live: 0, paused: 0, finalized: 0 },
);
return counts;
}

View file

@ -0,0 +1,101 @@
import type { PollStatus, Prisma } from "@rallly/database";
import { prisma } from "@rallly/database";
type PollFilters = {
userId: string;
status?: PollStatus;
page?: number;
pageSize?: number;
q?: string;
};
export async function getPolls({
userId,
status,
page = 1,
pageSize = 10,
q,
}: PollFilters) {
// Build the where clause based on filters
const where: Prisma.PollWhereInput = {
userId,
status,
deleted: false,
};
// Add search filter if provided
if (q) {
where.title = {
contains: q,
mode: "insensitive",
};
}
const [total, data] = await Promise.all([
prisma.poll.count({ where }),
prisma.poll.findMany({
where,
select: {
id: true,
title: true,
status: true,
createdAt: true,
updatedAt: true,
user: {
select: {
id: true,
name: true,
image: true,
},
},
event: {
select: {
start: true,
duration: true,
},
},
participants: {
where: {
deleted: false,
},
select: {
id: true,
name: true,
user: {
select: {
image: true,
},
},
},
orderBy: {
createdAt: "asc",
},
},
},
orderBy: {
updatedAt: "desc",
},
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const hasNextPage = page * pageSize < total;
return {
total,
data: data.map((poll) => {
return {
...poll,
user: poll.user,
participants: poll.participants.map((participant) => ({
id: participant.id,
name: participant.name,
image: participant.user?.image ?? undefined,
})),
event: poll.event ?? undefined,
};
}),
hasNextPage,
};
}

View file

@ -0,0 +1,99 @@
import type { Prisma } from "@rallly/database";
import { prisma } from "@rallly/database";
import { unstable_cache } from "next/cache";
type PollFilters = {
userId: string;
limit?: number;
};
export const getRecentlyUpdatedPolls = async ({
userId,
limit = 3,
}: PollFilters) => {
// Build the where clause based on filters
const where: Prisma.PollWhereInput = {
userId,
deleted: false,
};
const data = await prisma.poll.findMany({
where,
select: {
id: true,
title: true,
status: true,
createdAt: true,
updatedAt: true,
event: {
select: {
start: true,
duration: true,
},
},
participants: {
where: {
deleted: false,
},
select: {
id: true,
name: true,
user: {
select: {
image: true,
},
},
},
orderBy: {
createdAt: "desc",
},
},
options: {
select: {
id: true,
startTime: true,
duration: true,
},
},
},
orderBy: {
updatedAt: "desc",
},
take: limit,
});
return data.map((poll) => {
const { options, ...rest } = poll;
const durations = new Set<number>();
for (const option of options) {
durations.add(option.duration);
}
return {
...rest,
participants: poll.participants.map((participant) => ({
id: participant.id,
name: participant.name,
image: participant.user?.image ?? undefined,
})),
dateOptions: {
first: options[0]?.startTime,
last: options[options.length - 1]?.startTime,
count: options.length,
duration:
durations.size === 1
? (durations.values().next().value as number)
: Array.from(durations),
},
event: poll.event ?? undefined,
};
});
};
export const getCachedRecentlyUpdatedPolls = unstable_cache(
getRecentlyUpdatedPolls,
undefined,
{
revalidate: 60 * 5,
tags: ["polls"],
},
);

View file

@ -0,0 +1,42 @@
import { prisma } from "@rallly/database";
import { redirect } from "next/navigation";
import { cache } from "react";
import { requireUser } from "@/next-auth";
export const getUser = cache(async () => {
const { userId } = await requireUser();
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
image: true,
locale: true,
timeZone: true,
subscription: {
select: {
active: true,
},
},
},
});
if (!user) {
redirect("/api/auth/invalid-session");
}
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image ?? undefined,
locale: user.locale ?? undefined,
timeZone: user.timeZone ?? undefined,
isPro: user.subscription?.active ?? false,
};
});

View file

@ -0,0 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
function cmdKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey) {
return e.key;
}
return false;
}
export function CommandGlobalShortcut({ trigger }: { trigger: () => void }) {
const router = useRouter();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (cmdKey(e)) {
case "k":
e.preventDefault();
trigger();
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [router, trigger]);
// This component doesn't render anything
return null;
}

View file

@ -0,0 +1,115 @@
"use client";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@rallly/ui/command";
import { DialogDescription, DialogTitle, useDialog } from "@rallly/ui/dialog";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import {
BillingPageIcon,
EventPageIcon,
HomePageIcon,
PageIcon,
PollPageIcon,
PreferencesPageIcon,
ProfilePageIcon,
} from "@/app/components/page-icons";
import { Trans } from "@/components/trans";
import { CommandGlobalShortcut } from "./command-global-shortcut";
export function CommandMenu() {
const router = useRouter();
const { trigger, dialogProps, dismiss } = useDialog();
const handleSelect = (route: string) => {
router.push(route);
dismiss();
};
return (
<>
<CommandGlobalShortcut trigger={trigger} />
{/* <Button variant="ghost" onClick={trigger}>
<Icon>
<SearchIcon />
</Icon>
<Trans i18nKey="search" defaults="Search" />
<CommandShortcutSymbol symbol="K" />
</Button> */}
<CommandDialog {...dialogProps}>
<DialogTitle className="sr-only">
<Trans i18nKey="commandMenu" defaults="Command Menu" />
</DialogTitle>
<DialogDescription className="sr-only">
<Trans i18nKey="commandMenuDescription" defaults="Select a command" />
</DialogDescription>
<CommandInput
autoFocus={true}
placeholder="Type a command or search..."
/>
<CommandList className="max-h-max">
<CommandEmpty>
<span>
<Trans i18nKey="commandMenuNoResults" defaults="No results" />
</span>
</CommandEmpty>
<CommandGroup heading={<Trans i18nKey="polls" defaults="Actions" />}>
<CommandItem onSelect={() => handleSelect("/new")}>
<PageIcon>
<PlusIcon />
</PageIcon>
<Trans i18nKey="create" defaults="Create" />
</CommandItem>
</CommandGroup>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => handleSelect("/")}>
<HomePageIcon />
<Trans i18nKey="home" defaults="Home" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/polls")}>
<PollPageIcon />
<Trans i18nKey="polls" defaults="Polls" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/events")}>
<EventPageIcon />
<Trans i18nKey="events" defaults="Events" />
</CommandItem>
{/* <CommandItem onSelect={() => handleSelect("/teams")}>
<TeamsPageIcon />
<Trans i18nKey="teams" defaults="Teams" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/spaces")}>
<SpacesPageIcon />
<Trans i18nKey="spaces" defaults="Spaces" />
</CommandItem> */}
</CommandGroup>
<CommandGroup
heading={<Trans i18nKey="account" defaults="Account" />}
>
<CommandItem onSelect={() => handleSelect("/settings/profile")}>
<ProfilePageIcon />
<Trans i18nKey="profile" defaults="Profile" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/preferences")}>
<PreferencesPageIcon />
<Trans i18nKey="preferences" defaults="Preferences" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/billing")}>
<BillingPageIcon />
<Trans i18nKey="billing" defaults="Billing" />
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}

View file

@ -0,0 +1 @@
export * from "./command-menu";

View file

@ -0,0 +1,130 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useCallback, useMemo, useState } from "react";
import { useRequiredContext } from "@/components/use-required-context";
import { PollSelectionActionBar } from "./poll-selection-action-bar";
type RowSelectionState = Record<string, boolean>;
type PollSelectionContextType = {
selectedPolls: RowSelectionState;
setSelectedPolls: (selection: RowSelectionState) => void;
selectPolls: (pollIds: string[]) => void;
unselectPolls: (pollIds: string[]) => void;
togglePollSelection: (pollId: string) => void;
clearSelection: () => void;
isSelected: (pollId: string) => boolean;
getSelectedPollIds: () => string[];
selectedCount: number;
};
const PollSelectionContext = createContext<PollSelectionContextType | null>(
null,
);
type PollSelectionProviderProps = {
children: ReactNode;
};
export const PollSelectionProvider = ({
children,
}: PollSelectionProviderProps) => {
const [selectedPolls, setSelectedPolls] = useState<RowSelectionState>({});
const selectPolls = useCallback((pollIds: string[]) => {
setSelectedPolls((prev) => {
const newSelection = { ...prev };
pollIds.forEach((id) => {
newSelection[id] = true;
});
return newSelection;
});
}, []);
const unselectPolls = useCallback(
(pollIds: string[]) =>
setSelectedPolls((prev) => {
const newSelection = { ...prev };
pollIds.forEach((id) => {
delete newSelection[id];
});
return newSelection;
}),
[],
);
const togglePollSelection = useCallback(
(pollId: string) =>
setSelectedPolls((prev) => {
const newSelection = { ...prev };
if (newSelection[pollId]) {
delete newSelection[pollId];
} else {
newSelection[pollId] = true;
}
return newSelection;
}),
[],
);
const clearSelection = useCallback(() => setSelectedPolls({}), []);
const isSelected = useCallback(
(pollId: string) => Boolean(selectedPolls[pollId]),
[selectedPolls],
);
const getSelectedPollIds = useCallback(
() => Object.keys(selectedPolls),
[selectedPolls],
);
const selectedCount = useMemo(
() => Object.keys(selectedPolls).length,
[selectedPolls],
);
const value = useMemo(
() => ({
selectedPolls,
setSelectedPolls,
selectPolls,
unselectPolls,
togglePollSelection,
clearSelection,
isSelected,
getSelectedPollIds,
selectedCount,
}),
[
selectedPolls,
setSelectedPolls,
selectPolls,
unselectPolls,
togglePollSelection,
clearSelection,
isSelected,
getSelectedPollIds,
selectedCount,
],
);
return (
<PollSelectionContext.Provider value={value}>
{children}
<PollSelectionActionBar />
</PollSelectionContext.Provider>
);
};
export const usePollSelection = () => {
const context = useRequiredContext(
PollSelectionContext,
"usePollSelection must be used within a PollSelectionProvider",
);
return context;
};

View file

@ -0,0 +1,142 @@
"use client";
import {
ActionBarContainer,
ActionBarContent,
ActionBarGroup,
ActionBarPortal,
} from "@rallly/ui/action-bar";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@rallly/ui/dialog";
import { AnimatePresence, motion } from "framer-motion";
import { TrashIcon } from "lucide-react";
import * as React from "react";
import { deletePolls } from "@/app/[locale]/(admin)/polls/actions";
import { Trans } from "@/components/trans";
import { usePollSelection } from "./context";
const MActionBar = motion(ActionBarContainer);
export function PollSelectionActionBar() {
const { selectedCount, clearSelection, getSelectedPollIds } =
usePollSelection();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const handleDelete = async () => {
const selectedPollIds = getSelectedPollIds();
if (selectedPollIds.length === 0) {
return;
}
setIsDeleting(true);
try {
const result = await deletePolls(selectedPollIds);
if (result.success) {
setIsDeleteDialogOpen(false);
clearSelection();
} else {
// Handle error case
console.error("Failed to delete polls:", result.error);
}
} finally {
setIsDeleting(false);
}
};
return (
<ActionBarPortal>
<AnimatePresence>
{selectedCount > 0 && (
<MActionBar
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.5,
}}
>
<ActionBarContent>
<span className="text-sm font-medium">
<Trans
i18nKey="selectedPolls"
defaults="{count} {count, plural, one {poll} other {polls}} selected"
values={{ count: selectedCount }}
/>
</span>
</ActionBarContent>
<ActionBarGroup>
<Button
variant="actionBar"
onClick={clearSelection}
className="text-action-bar-foreground"
>
<Trans i18nKey="unselectAll" defaults="Unselect All" />
</Button>
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
<TrashIcon className="size-4" />
<Trans i18nKey="delete" defaults="Delete" />
</Button>
</ActionBarGroup>
</MActionBar>
)}
</AnimatePresence>
{/* Delete Polls Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="deletePolls" defaults="Delete Polls" />
</DialogTitle>
</DialogHeader>
<p className="text-sm">
{selectedCount === 1 ? (
<Trans
i18nKey="deletePollDescription"
defaults="Are you sure you want to delete this poll? This action cannot be undone."
/>
) : (
<Trans
i18nKey="deletePollsDescription"
defaults="Are you sure you want to delete these {count} polls? This action cannot be undone."
values={{ count: selectedCount }}
/>
)}
</p>
<DialogFooter>
<Button
onClick={() => {
setIsDeleteDialogOpen(false);
}}
>
<Trans i18nKey="cancel" defaults="Cancel" />
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
<Trans i18nKey="delete" defaults="Delete" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ActionBarPortal>
);
}

View file

@ -0,0 +1,3 @@
export * from "./timezone-context";
export * from "./timezone-display";
export * from "./timezone-utils";

View file

@ -0,0 +1,104 @@
"use client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { createContext, useContext, useEffect, useState } from "react";
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
// Default to browser timezone if not specified
const getBrowserTimezone = () => {
if (typeof window !== "undefined") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return "UTC"; // Default to UTC for server-side rendering
};
type TimezoneContextType = {
timezone: string;
setTimezone: (timezone: string) => void;
formatDate: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatTime: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatDateTime: (
date: string | Date | dayjs.Dayjs,
format?: string,
) => string;
};
const TimezoneContext = createContext<TimezoneContextType | undefined>(
undefined,
);
export const TimezoneProvider = ({
initialTimezone,
children,
}: {
initialTimezone?: string;
children: React.ReactNode;
}) => {
// Initialize with browser timezone, but allow user preference to override
const [timezone, setTimezone] = useState<string>(() => {
if (initialTimezone) {
return initialTimezone;
}
// Try to get from localStorage first (user preference)
if (typeof window !== "undefined") {
const savedTimezone = localStorage.getItem("userTimezone");
if (savedTimezone) {
return savedTimezone;
}
}
return getBrowserTimezone();
});
// Save timezone preference to localStorage when it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("userTimezone", timezone);
}
}, [timezone]);
// Format functions that automatically use the current timezone
const formatDate = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD",
) => {
return dayjs(date).tz(timezone).format(format);
};
const formatTime = (date: string | Date | dayjs.Dayjs, format = "HH:mm") => {
return dayjs(date).tz(timezone).format(format);
};
const formatDateTime = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD HH:mm",
) => {
return dayjs(date).tz(timezone).format(format);
};
const value = {
timezone,
setTimezone,
formatDate,
formatTime,
formatDateTime,
};
return (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
);
};
export const useTimezone = () => {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error("useTimezone must be used within a TimezoneProvider");
}
return context;
};

View file

@ -0,0 +1,31 @@
"use client";
import type dayjs from "dayjs";
import { useTimezone } from "./timezone-context";
type DateDisplayProps = {
date: string | Date | dayjs.Dayjs;
format?: string;
};
export function DateDisplay({ date, format = "LL" }: DateDisplayProps) {
const { formatDate } = useTimezone();
return <span>{formatDate(date, format)}</span>;
}
export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) {
const { formatTime } = useTimezone();
return <span>{formatTime(date, format)}</span>;
}
export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) {
const { formatDateTime } = useTimezone();
return <span>{formatDateTime(date, format)}</span>;
}
// Component to display the current timezone
export function CurrentTimezone() {
const { timezone } = useTimezone();
return <span>{timezone}</span>;
}

View file

@ -0,0 +1,110 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* Get the browser's timezone
*/
export const getBrowserTimezone = (): string => {
if (typeof window !== "undefined") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return "UTC";
};
/**
* Get a list of all available timezones
*/
export const getAllTimezones = (): string[] => {
// This is a simplified list - in a real implementation, you might want to use a more comprehensive list
return [
"UTC",
"Africa/Cairo",
"Africa/Johannesburg",
"Africa/Lagos",
"America/Anchorage",
"America/Bogota",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Phoenix",
"America/Sao_Paulo",
"America/Toronto",
"Asia/Bangkok",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Jakarta",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Berlin",
"Europe/Istanbul",
"Europe/London",
"Europe/Madrid",
"Europe/Moscow",
"Europe/Paris",
"Europe/Rome",
"Pacific/Auckland",
"Pacific/Honolulu",
];
};
/**
* Convert a date from one timezone to another
*/
export const convertTimezone = (
date: string | Date | dayjs.Dayjs,
fromTimezone: string,
toTimezone: string,
): dayjs.Dayjs => {
return dayjs(date).tz(fromTimezone).tz(toTimezone);
};
/**
* Format a date for display with timezone
*/
export const formatWithTimezone = (
date: string | Date | dayjs.Dayjs,
timezone: string,
format: string,
): string => {
return dayjs(date).tz(timezone).format(format);
};
/**
* Get the timezone offset as a string (e.g., "UTC+1:00")
*/
export const getTimezoneOffset = (timezone: string): string => {
const offset = dayjs().tz(timezone).format("Z");
return `UTC${offset}`;
};
/**
* Group timezones by offset
*/
export const groupTimezonesByOffset = (): Record<string, string[]> => {
const timezones = getAllTimezones();
const grouped: Record<string, string[]> = {};
timezones.forEach((tz) => {
const offset = dayjs().tz(tz).format("Z");
if (!grouped[offset]) {
grouped[offset] = [];
}
grouped[offset].push(tz);
});
return grouped;
};

View file

@ -12,9 +12,12 @@ export const middleware = withAuth(async (req) => {
const newUrl = nextUrl.clone();
const isLoggedIn = req.auth?.user?.email;
// if the user is already logged in, don't let them access the login page
if (/^\/(login)/.test(newUrl.pathname) && isLoggedIn) {
if (
/^\/(login)/.test(newUrl.pathname) &&
isLoggedIn &&
!newUrl.searchParams.get("invalidSession")
) {
newUrl.pathname = "/";
return NextResponse.redirect(newUrl);
}

View file

@ -209,7 +209,7 @@ const requireUser = async () => {
if (!session?.user) {
redirect("/login");
}
return session?.user;
return { userId: session.user.id };
};
/**

View file

@ -757,19 +757,24 @@ export const polls = router({
}),
)
.mutation(async ({ input }) => {
await prisma.$transaction([
prisma.poll.update({
await prisma.$transaction(async () => {
const poll = await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
event: {
delete: true,
},
status: "live",
},
}),
]);
});
if (poll.eventId) {
await prisma.event.delete({
where: {
id: poll.eventId,
},
});
}
});
}),
pause: possiblyPublicProcedure
.input(

View file

@ -42,7 +42,7 @@ test.describe.serial(() => {
});
// Step 4: Navigate back to the poll
await page.getByRole("link", { name: "Live" }).click();
await page.getByRole("main").getByRole("link", { name: "Polls" }).click();
await expect(page).toHaveURL(/polls/);
await page.click("text=Monthly Meetup");
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");

View file

@ -25,9 +25,9 @@ module.exports = {
background: colors.indigo["50"],
},
secondary: {
background: colors.gray["100"],
DEFAULT: colors.gray["100"],
foreground: colors.gray["700"],
background: colors.indigo["50"],
DEFAULT: colors.indigo["50"],
foreground: colors.indigo["600"],
},
gray: colors.gray,
border: colors.gray["200"],
@ -70,6 +70,7 @@ module.exports = {
sidebar: {
DEFAULT: colors.gray["100"],
foreground: colors.gray["700"],
border: colors.gray["200"],
accent: {
DEFAULT: colors.gray["200"],
foreground: colors.gray["800"],

View file

@ -24,6 +24,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.1.2",

View file

@ -0,0 +1,84 @@
import * as Portal from "@radix-ui/react-portal";
import * as React from "react";
import { cn } from "./lib/utils";
const ACTION_BAR_PORTAL_ID = "action-bar-portal";
const ActionBar = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"pointer-events-none sticky bottom-8 flex justify-center pb-5",
className,
)}
id={ACTION_BAR_PORTAL_ID}
{...props}
/>
));
ActionBar.displayName = "ActionBar";
const ActionBarPortal = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<Portal.Root
container={document.getElementById(ACTION_BAR_PORTAL_ID)}
ref={ref}
className={className}
{...props}
/>
));
ActionBarPortal.displayName = "ActionBarPortal";
const ActionBarContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"bg-action-bar text-action-bar-foreground pointer-events-auto z-50 mx-auto inline-flex w-full max-w-2xl items-center gap-4 rounded-xl p-2 shadow-lg",
className,
)}
{...props}
/>
);
});
ActionBarContainer.displayName = "ActionBarContainer";
const ActionBarContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center px-2.5", className)}
{...props}
/>
));
ActionBarContent.displayName = "ActionBarContent";
const ActionBarGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center gap-2", className)}
{...props}
/>
));
ActionBarGroup.displayName = "ActionBarGroup";
export {
ActionBar,
ActionBarContainer,
ActionBarContent,
ActionBarGroup,
ActionBarPortal,
};

View file

@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: {
variant: {
primary: "bg-primary text-primary-50",
default: "bg-gray-50 border text-secondary-foreground",
default: "bg-gray-50 border text-muted-foreground",
destructive: "bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "bg-green-600 text-white",

View file

@ -19,12 +19,13 @@ const buttonVariants = cva(
"focus:ring-offset-1 border-primary bg-primary hover:bg-primary-500 disabled:bg-gray-400 disabled:border-transparent text-white shadow-sm",
destructive:
"focus:ring-offset-1 bg-destructive shadow-sm text-destructive-foreground active:bg-destructive border-destructive hover:bg-destructive/90",
default:
"focus:ring-offset-1 hover:bg-gray-100 bg-gray-50 active:bg-gray-200",
default: "focus:ring-offset-1 hover:bg-gray-50 bg-white",
secondary:
"focus:ring-offset-1 bg-secondary text-secondary-foreground hover:bg-secondary/80",
"focus:ring-offset-1 border-secondary bg-secondary hover:bg-secondary/80 text-secondary-foreground",
ghost:
"border-transparent bg-transparent data-[state=open]:bg-gray-500/20 text-gray-800 hover:bg-gray-500/10 active:bg-gray-500/20",
actionBar:
"border-transparent bg-transparent data-[state=open]:bg-gray-500/20 text-gray-800 hover:bg-gray-700 active:bg-gray-700/50",
link: "underline-offset-4 border-transparent hover:underline text-primary",
},
size: {
@ -32,6 +33,7 @@ const buttonVariants = cva(
sm: "h-8 text-sm px-2 gap-x-1.5 rounded-md",
lg: "h-12 text-base gap-x-3 px-4 rounded-lg",
icon: "size-7 text-sm gap-x-1.5 rounded-md",
"icon-lg": "size-8 rounded-full",
},
},
defaultVariants: {

View file

@ -6,6 +6,7 @@ import { SearchIcon } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "./dialog";
import { usePlatform } from "./hooks/use-platform";
import { Icon } from "./icon";
import { cn } from "./lib/utils";
@ -29,8 +30,13 @@ type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="shadow-huge w-full max-w-3xl overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<DialogContent
hideCloseButton={true}
size="xl"
position="top"
className="shadow-huge p-0"
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:p-2 [&_[cmdk-item]_svg]:size-4">
{children}
</Command>
</DialogContent>
@ -68,7 +74,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn("h-[320px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
@ -95,7 +101,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-gray-500",
"overflow-hidden p-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:text-gray-500",
className,
)}
{...props}
@ -123,7 +129,23 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex h-9 cursor-default select-none items-center rounded-md px-2 text-sm outline-none aria-selected:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center gap-2 rounded-xl p-2 text-sm outline-none aria-selected:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandItemShortcut = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex h-12 cursor-pointer select-none items-center gap-3 rounded-md px-3 font-medium outline-none aria-selected:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
@ -148,6 +170,17 @@ const CommandShortcut = ({
};
CommandShortcut.displayName = "CommandShortcut";
// Renders the Command (⌘) symbol on macs and Ctrl on windows
const CommandShortcutSymbol = ({ symbol }: { symbol: string }) => {
const { isMac } = usePlatform();
return (
<CommandShortcut>
{isMac ? "⌘" : "Ctrl"}+{symbol}
</CommandShortcut>
);
};
CommandShortcutSymbol.displayName = "CommandShortcutSymbol";
export {
Command,
CommandDialog,
@ -155,7 +188,9 @@ export {
CommandGroup,
CommandInput,
CommandItem,
CommandItemShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
CommandShortcutSymbol,
};

View file

@ -1,6 +1,8 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { XIcon } from "lucide-react";
import * as React from "react";
@ -32,47 +34,72 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const dialogContentVariants = cva(
cn(
//style
"bg-background sm:rounded-lg sm:border shadow-lg p-6 gap-4",
// position
"fixed z-50 grid w-full top-0 left-1/2 -translate-x-1/2",
// animation
"duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=closed]:slide-out-to-left-1/2",
),
{
variants: {
position: {
top: "sm:top-48 data-[state=closed]:slide-out-to-top-[10%] data-[state=open]:slide-in-from-top-[10%]",
center:
"sm:top-[50%] sm:translate-y-[-50%] data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]",
},
size: {
sm: "sm:max-w-sm",
md: "sm:max-w-md",
lg: "sm:max-w-lg",
xl: "sm:max-w-xl",
"2xl": "sm:max-w-2xl",
"3xl": "sm:max-w-3xl",
"4xl": "sm:max-w-4xl",
"5xl": "sm:max-w-5xl",
},
},
defaultVariants: {
position: "center",
size: "md",
},
},
);
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
hideCloseButton?: boolean;
}
>(({ className, children, size = "md", hideCloseButton, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-0 z-50 grid w-full max-w-full translate-x-[-50%] gap-4 p-6 shadow-lg duration-200 sm:top-[50%] sm:translate-y-[-50%] sm:rounded-lg sm:border",
{
"sm:max-w-sm": size === "sm",
"sm:max-w-md": size === "md",
"sm:max-w-lg": size === "lg",
"sm:max-w-xl": size === "xl",
"sm:max-w-2xl": size === "2xl",
"sm:max-w-3xl": size === "3xl",
"sm:max-w-4xl": size === "4xl",
"sm:max-w-5xl": size === "5xl",
},
className,
)}
{...props}
>
{children}
{!hideCloseButton ? (
<DialogClose asChild className="absolute right-4 top-4">
<Button size="icon" variant="ghost">
<Icon>
<XIcon />
</Icon>
<span className="sr-only">Close</span>
</Button>
</DialogClose>
) : null}
</DialogPrimitive.Content>
</DialogPortal>
));
} & VariantProps<typeof dialogContentVariants>
>(
(
{ className, children, position, size = "md", hideCloseButton, ...props },
ref,
) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn("", dialogContentVariants({ position, size }), className)}
{...props}
>
{children}
{!hideCloseButton ? (
<DialogClose asChild className="absolute right-4 top-4">
<Button size="icon" variant="ghost">
<Icon>
<XIcon />
</Icon>
<span className="sr-only">Close</span>
</Button>
</DialogClose>
) : null}
</DialogPrimitive.Content>
</DialogPortal>
),
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View file

@ -120,12 +120,12 @@ const DropdownMenuCheckboxItem = React.forwardRef<
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Icon variant="success">
<Icon>
<CheckIcon />
</Icon>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<span className="flex items-center gap-2 text-sm">{children}</span>
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =

View file

@ -0,0 +1,5 @@
"use client";
export function usePlatform() {
return { isMac: navigator.userAgent.includes("Mac") };
}

View file

@ -30,12 +30,18 @@ export interface IconProps extends VariantProps<typeof iconVariants> {
children?: React.ReactNode;
}
export function Icon({ children, size, variant }: IconProps) {
export function Icon({
children,
className,
size,
variant,
}: { className?: string } & IconProps) {
return (
<Slot
className={cn(
iconVariants({ size, variant }),
"group-[.bg-primary]:text-primary-50 group-[.bg-destructive]:text-destructive-foreground group shrink-0",
className,
)}
>
{children}

View file

@ -0,0 +1,53 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "./lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("-mb-px flex space-x-4 border-b border-gray-200", className)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex h-9 items-center whitespace-nowrap rounded-none border-b-2 px-1 pb-1 pt-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:border-indigo-500 data-[state=inactive]:border-transparent data-[state=active]:text-indigo-600 data-[state=inactive]:text-gray-500 data-[state=inactive]:hover:border-gray-300 data-[state=inactive]:hover:text-gray-700",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View file

@ -0,0 +1,28 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "./lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"bg-secondary relative h-1 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View file

@ -30,7 +30,7 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_MOBILE = "16rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@ -380,7 +380,7 @@ const SidebarHeader = React.forwardRef<
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2", className)}
className={cn("flex flex-col gap-2 px-2 py-3", className)}
{...props}
/>
);

Some files were not shown because too many files have changed in this diff Show more