mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-31 09:46:26 +02:00
✨ Updated sidebar layout (#1661)
This commit is contained in:
parent
8c0814b92b
commit
72ca1d4c38
104 changed files with 3268 additions and 1331 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -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
39
.windsurfrules
Normal 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
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
48
apps/web/src/app/[locale]/(admin)/components/top-bar.tsx
Normal file
48
apps/web/src/app/[locale]/(admin)/components/top-bar.tsx
Normal 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" />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,14 +30,10 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
|
|||
!row.original.timeZone,
|
||||
);
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<CardContent>
|
||||
<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 font-medium"
|
||||
>
|
||||
<time dateTime={start.toISOString()} className="text-sm">
|
||||
{start.format("ddd, D MMM")}
|
||||
</time>
|
||||
<time
|
||||
|
@ -56,7 +51,7 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
|
|||
background: generateGradient(row.original.id),
|
||||
}}
|
||||
></span>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
<h2 className="truncate text-sm font-medium">
|
||||
{row.original.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -69,11 +64,10 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default function Layout({ children }: { children?: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { Spinner } from "@/components/spinner";
|
||||
|
||||
export default async function Loading() {
|
||||
return <Spinner />;
|
||||
}
|
|
@ -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",
|
||||
})}
|
||||
<EventPageIcon />
|
||||
<Trans i18nKey="events" defaults="Events" />
|
||||
</PageTitle>
|
||||
</div>
|
||||
<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",
|
||||
|
|
|
@ -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}
|
||||
<Tabs
|
||||
value={searchParams.get("period") ?? "upcoming"}
|
||||
onValueChange={(value) => {
|
||||
const newParams = new URLSearchParams(searchParams?.toString());
|
||||
newParams.set("period", value);
|
||||
window.history.pushState(null, "", `?${newParams.toString()}`);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("period", value);
|
||||
const newUrl = `?${params.toString()}`;
|
||||
router.replace(newUrl);
|
||||
}}
|
||||
aria-label="Event period"
|
||||
>
|
||||
<RadioCardsItem value="upcoming">
|
||||
<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 />}
|
||||
|
|
|
@ -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",
|
||||
)}
|
||||
<AppSidebarProvider>
|
||||
<CommandMenu />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<TopBar className="sm:hidden">
|
||||
<TopBarLeft>
|
||||
<SidebarTrigger />
|
||||
</TopBarLeft>
|
||||
<TopBarRight>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<LogoLink />
|
||||
<ProBadge />
|
||||
<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>
|
||||
<Sidebar />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
<HomePageIcon />
|
||||
<Trans i18nKey="home" defaults="Home" />
|
||||
</PageTitle>
|
||||
</div>
|
||||
<PageDescription>
|
||||
<Trans
|
||||
i18nKey="homeDashboardDesc"
|
||||
defaults="Manage your polls, events, and account settings"
|
||||
/>
|
||||
</PageDescription>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<Dashboard />
|
||||
<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>
|
||||
</div>
|
||||
</Hydrate>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
49
apps/web/src/app/[locale]/(admin)/polls/actions.ts
Normal file
49
apps/web/src/app/[locale]/(admin)/polls/actions.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { Spinner } from "@/components/spinner";
|
||||
|
||||
export default async function Loading() {
|
||||
return <Spinner />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
78
apps/web/src/app/[locale]/(admin)/polls/search-input.tsx
Normal file
78
apps/web/src/app/[locale]/(admin)/polls/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
import { Spinner } from "@/components/spinner";
|
||||
|
||||
export default async function Loading() {
|
||||
return <Spinner />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>{children}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<SettingsLayout>{children}</SettingsLayout>
|
||||
</PageContent>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,25 +1,16 @@
|
|||
"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={
|
||||
|
@ -44,6 +35,5 @@ export function PreferencesPage() {
|
|||
<DateTimePreferences />
|
||||
</SettingsSection>
|
||||
</SettingsContent>
|
||||
</Settings>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { Spinner } from "@/components/spinner";
|
||||
|
||||
export default async function Loading() {
|
||||
return <Spinner />;
|
||||
}
|
|
@ -1,32 +1,23 @@
|
|||
"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" />}
|
||||
|
@ -40,9 +31,7 @@ export const ProfilePage = () => {
|
|||
<ProfileSettings />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title={
|
||||
<Trans i18nKey="profileEmailAddress" defaults="Email Address" />
|
||||
}
|
||||
title={<Trans i18nKey="profileEmailAddress" defaults="Email Address" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="profileEmailAddressDescription"
|
||||
|
@ -54,20 +43,6 @@ export const ProfilePage = () => {
|
|||
</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 />
|
||||
|
@ -92,6 +67,5 @@ export const ProfilePage = () => {
|
|||
</>
|
||||
) : null}
|
||||
</SettingsContent>
|
||||
</Settings>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
<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>
|
||||
<TabMenuItem href="/settings/billing">
|
||||
<Icon>
|
||||
<CreditCardIcon />
|
||||
</Icon>
|
||||
<Trans i18nKey="billing" />
|
||||
</TabMenuItem>
|
||||
<TabsTrigger asChild value="/settings/billing">
|
||||
<Link href="/settings/billing">
|
||||
<Trans i18nKey="billing" defaults="Billing" />
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</IfCloudHosted>
|
||||
</TabMenu>
|
||||
</TabsList>
|
||||
<TabsContent className="mt-4" value={pathname}>
|
||||
{children}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
<TimezoneProvider
|
||||
initialTimezone={session?.user?.timeZone ?? undefined}
|
||||
>
|
||||
<ConnectedDayjsProvider>
|
||||
{children}
|
||||
<TimeZoneChangeDetector />
|
||||
</ConnectedDayjsProvider>
|
||||
</TimezoneProvider>
|
||||
</UserProvider>
|
||||
</TooltipProvider>
|
||||
</PostHogProvider>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
7
apps/web/src/app/api/auth/invalid-session/route.ts
Normal file
7
apps/web/src/app/api/auth/invalid-session/route.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { signOut } from "@/next-auth";
|
||||
|
||||
export async function GET() {
|
||||
return await signOut({
|
||||
redirectTo: "/login",
|
||||
});
|
||||
}
|
152
apps/web/src/app/components/page-icons.tsx
Normal file
152
apps/web/src/app/components/page-icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
"use client";
|
||||
import { cn } from "@rallly/ui";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
52
apps/web/src/components/copy-link-button.tsx
Normal file
52
apps/web/src/components/copy-link-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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)}</>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
105
apps/web/src/components/pagination.tsx
Normal file
105
apps/web/src/components/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="z-10">
|
||||
<ul>
|
||||
{participants
|
||||
.slice(visibleCount, 10)
|
||||
.map((participant, index) => (
|
||||
{tooltipParticipants.map((participant, index) => (
|
||||
<li key={index}>{participant.name}</li>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey="moreParticipants"
|
||||
values={{ count: remainingCount }}
|
||||
defaults="{count} more…"
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</li>
|
||||
) : null}
|
||||
|
|
|
@ -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>
|
||||
|
|
62
apps/web/src/components/poll-status-icon.tsx
Normal file
62
apps/web/src/components/poll-status-icon.tsx
Normal 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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
57
apps/web/src/components/poll/scheduled-event-display.tsx
Normal file
57
apps/web/src/components/poll/scheduled-event-display.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
};
|
6
apps/web/src/components/relative-date.tsx
Normal file
6
apps/web/src/components/relative-date.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export function RelativeDate({ date }: { date: Date }) {
|
||||
return <>{dayjs(date).fromNow()}</>;
|
||||
}
|
72
apps/web/src/components/router-loading-indicator.tsx
Normal file
72
apps/web/src/components/router-loading-indicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
35
apps/web/src/components/stacked-list.tsx
Normal file
35
apps/web/src/components/stacked-list.tsx
Normal 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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
|
|
19
apps/web/src/data/get-poll-count-by-status.ts
Normal file
19
apps/web/src/data/get-poll-count-by-status.ts
Normal 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;
|
||||
}
|
101
apps/web/src/data/get-polls.ts
Normal file
101
apps/web/src/data/get-polls.ts
Normal 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,
|
||||
};
|
||||
}
|
99
apps/web/src/data/get-recently-updated-polls.ts
Normal file
99
apps/web/src/data/get-recently-updated-polls.ts
Normal 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"],
|
||||
},
|
||||
);
|
42
apps/web/src/data/get-user.ts
Normal file
42
apps/web/src/data/get-user.ts
Normal 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,
|
||||
};
|
||||
});
|
|
@ -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;
|
||||
}
|
115
apps/web/src/features/navigation/command-menu/command-menu.tsx
Normal file
115
apps/web/src/features/navigation/command-menu/command-menu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
1
apps/web/src/features/navigation/command-menu/index.ts
Normal file
1
apps/web/src/features/navigation/command-menu/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./command-menu";
|
130
apps/web/src/features/poll-selection/context.tsx
Normal file
130
apps/web/src/features/poll-selection/context.tsx
Normal 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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
3
apps/web/src/features/timezone/index.ts
Normal file
3
apps/web/src/features/timezone/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./timezone-context";
|
||||
export * from "./timezone-display";
|
||||
export * from "./timezone-utils";
|
104
apps/web/src/features/timezone/timezone-context.tsx
Normal file
104
apps/web/src/features/timezone/timezone-context.tsx
Normal 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;
|
||||
};
|
31
apps/web/src/features/timezone/timezone-display.tsx
Normal file
31
apps/web/src/features/timezone/timezone-display.tsx
Normal 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>;
|
||||
}
|
110
apps/web/src/features/timezone/timezone-utils.ts
Normal file
110
apps/web/src/features/timezone/timezone-utils.ts
Normal 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;
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -209,7 +209,7 @@ const requireUser = async () => {
|
|||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
return session?.user;
|
||||
return { userId: session.user.id };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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",
|
||||
|
|
84
packages/ui/src/action-bar.tsx
Normal file
84
packages/ui/src/action-bar.tsx
Normal 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,
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,31 +34,55 @@ 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) => (
|
||||
} & VariantProps<typeof dialogContentVariants>
|
||||
>(
|
||||
(
|
||||
{ className, children, position, 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,
|
||||
)}
|
||||
className={cn("", dialogContentVariants({ position, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
@ -72,7 +98,8 @@ const DialogContent = React.forwardRef<
|
|||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
),
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
|
|
@ -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 =
|
||||
|
|
5
packages/ui/src/hooks/use-platform.ts
Normal file
5
packages/ui/src/hooks/use-platform.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export function usePlatform() {
|
||||
return { isMac: navigator.userAgent.includes("Mac") };
|
||||
}
|
|
@ -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}
|
||||
|
|
53
packages/ui/src/page-tabs.tsx
Normal file
53
packages/ui/src/page-tabs.tsx
Normal 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 };
|
28
packages/ui/src/progress.tsx
Normal file
28
packages/ui/src/progress.tsx
Normal 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 };
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue