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,
|
"editor.formatOnSave": true,
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||||
"typescript.tsserver.log": "verbose",
|
"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",
|
"hideScoresLabel": "Hide scores until after a participant has voted",
|
||||||
"continueAs": "Continue as",
|
"continueAs": "Continue as",
|
||||||
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
||||||
"unlockFeatures": "Unlock all Pro features.",
|
|
||||||
"pollStatusFinalized": "Finalized",
|
"pollStatusFinalized": "Finalized",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"noParticipants": "No participants",
|
"noParticipants": "No participants",
|
||||||
"logoutDescription": "Sign out of your existing session",
|
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
|
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
|
||||||
"inviteLink": "Invite Link",
|
"inviteLink": "Invite Link",
|
||||||
|
@ -303,5 +301,28 @@
|
||||||
"needToMakeChanges": "Need to make changes?",
|
"needToMakeChanges": "Need to make changes?",
|
||||||
"billingPortalDescription": "Visit the billing portal to manage your subscription, update payment methods, or view billing history.",
|
"billingPortalDescription": "Visit the billing portal to manage your subscription, update payment methods, or view billing history.",
|
||||||
"priceFree": "Free",
|
"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";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent } from "@rallly/ui/card";
|
|
||||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
|
||||||
|
|
||||||
const { adjustTimeZone } = useDayjs();
|
const { adjustTimeZone } = useDayjs();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="rounded-lg border">
|
||||||
<ul className="divide-y divide-gray-100">
|
<ul className="divide-y divide-gray-100">
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
const start = adjustTimeZone(
|
const start = adjustTimeZone(
|
||||||
|
@ -31,49 +30,44 @@ export function EventList({ data }: { data: ScheduledEvent[] }) {
|
||||||
!row.original.timeZone,
|
!row.original.timeZone,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<li key={row.id}>
|
<li key={row.id} className="p-4">
|
||||||
<CardContent>
|
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
|
||||||
<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">
|
||||||
<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">
|
||||||
<time
|
{start.format("ddd, D MMM")}
|
||||||
dateTime={start.toISOString()}
|
</time>
|
||||||
className="text-sm font-medium"
|
<time
|
||||||
>
|
dateTime={start.toISOString()}
|
||||||
{start.format("ddd, D MMM")}
|
className="text-muted-foreground text-sm"
|
||||||
</time>
|
>
|
||||||
<time
|
{start.format("YYYY")}
|
||||||
dateTime={start.toISOString()}
|
</time>
|
||||||
className="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{start.format("YYYY")}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<span
|
|
||||||
className="h-4 w-1 shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: generateGradient(row.original.id),
|
|
||||||
}}
|
|
||||||
></span>
|
|
||||||
<h2 className="truncate text-base font-semibold">
|
|
||||||
{row.original.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
{row.original.duration === 0 ? (
|
|
||||||
<Trans i18nKey="allDay" />
|
|
||||||
) : (
|
|
||||||
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<span
|
||||||
|
className="h-4 w-1 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: generateGradient(row.original.id),
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
<h2 className="truncate text-sm font-medium">
|
||||||
|
{row.original.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{row.original.duration === 0 ? (
|
||||||
|
<Trans i18nKey="allDay" />
|
||||||
|
) : (
|
||||||
|
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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 { UserScheduledEvents } from "@/app/[locale]/(admin)/events/user-scheduled-events";
|
||||||
import type { Params } from "@/app/[locale]/types";
|
import type { Params } from "@/app/[locale]/types";
|
||||||
|
import { EventPageIcon } from "@/app/components/page-icons";
|
||||||
import {
|
import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageContent,
|
PageContent,
|
||||||
|
PageDescription,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} from "@/app/components/page-layout";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Params }) {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
const { t } = await getTranslation(params.locale);
|
await getTranslation(params.locale);
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<div className="flex items-center gap-x-3">
|
<PageTitle>
|
||||||
<PageTitle>
|
<EventPageIcon />
|
||||||
{t("events", {
|
<Trans i18nKey="events" defaults="Events" />
|
||||||
defaultValue: "Events",
|
</PageTitle>
|
||||||
})}
|
<PageDescription>
|
||||||
</PageTitle>
|
<Trans
|
||||||
</div>
|
i18nKey="eventsPageDesc"
|
||||||
|
defaults="View and manage your scheduled events"
|
||||||
|
/>
|
||||||
|
</PageDescription>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<UserScheduledEvents />
|
<UserScheduledEvents />
|
||||||
|
@ -28,12 +34,8 @@ export default async function Page({ params }: { params: Params }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata() {
|
||||||
params,
|
const { t } = await getTranslation();
|
||||||
}: {
|
|
||||||
params: { locale: string };
|
|
||||||
}) {
|
|
||||||
const { t } = await getTranslation(params.locale);
|
|
||||||
return {
|
return {
|
||||||
title: t("events", {
|
title: t("events", {
|
||||||
defaultValue: "Events",
|
defaultValue: "Events",
|
||||||
|
|
|
@ -1,39 +1,41 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
|
import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PastEvents } from "@/app/[locale]/(admin)/events/past-events";
|
import { PastEvents } from "@/app/[locale]/(admin)/events/past-events";
|
||||||
|
import { UpcomingEvents } from "@/app/[locale]/(admin)/events/upcoming-events";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
import { UpcomingEvents } from "./upcoming-events";
|
|
||||||
|
|
||||||
const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming");
|
const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming");
|
||||||
|
|
||||||
export function UserScheduledEvents() {
|
export function UserScheduledEvents() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const period = eventPeriodSchema.parse(searchParams?.get("period"));
|
const period = eventPeriodSchema.parse(searchParams?.get("period"));
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<Tabs
|
||||||
<RadioCards
|
value={searchParams.get("period") ?? "upcoming"}
|
||||||
value={period}
|
onValueChange={(value) => {
|
||||||
onValueChange={(value) => {
|
const params = new URLSearchParams(searchParams);
|
||||||
const newParams = new URLSearchParams(searchParams?.toString());
|
params.set("period", value);
|
||||||
newParams.set("period", value);
|
const newUrl = `?${params.toString()}`;
|
||||||
window.history.pushState(null, "", `?${newParams.toString()}`);
|
router.replace(newUrl);
|
||||||
}}
|
}}
|
||||||
>
|
aria-label="Event period"
|
||||||
<RadioCardsItem value="upcoming">
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="upcoming">
|
||||||
<Trans i18nKey="upcoming" defaults="Upcoming" />
|
<Trans i18nKey="upcoming" defaults="Upcoming" />
|
||||||
</RadioCardsItem>
|
</TabsTrigger>
|
||||||
<RadioCardsItem value="past">
|
<TabsTrigger value="past">
|
||||||
<Trans i18nKey="past" defaults="Past" />
|
<Trans i18nKey="past" defaults="Past" />
|
||||||
</RadioCardsItem>
|
</TabsTrigger>
|
||||||
</RadioCards>
|
</TabsList>
|
||||||
</div>
|
</Tabs>
|
||||||
<div>
|
<div>
|
||||||
{period === "upcoming" && <UpcomingEvents />}
|
{period === "upcoming" && <UpcomingEvents />}
|
||||||
{period === "past" && <PastEvents />}
|
{period === "past" && <PastEvents />}
|
||||||
|
|
|
@ -1,42 +1,53 @@
|
||||||
import { cn } from "@rallly/ui";
|
import { ActionBar } from "@rallly/ui/action-bar";
|
||||||
import { dehydrate, Hydrate } from "@tanstack/react-query";
|
import { Button } from "@rallly/ui/button";
|
||||||
import React from "react";
|
import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
|
import { AppSidebar } from "@/app/[locale]/(admin)/components/sidebar/app-sidebar";
|
||||||
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
|
import { AppSidebarProvider } from "@/app/[locale]/(admin)/components/sidebar/app-sidebar-provider";
|
||||||
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
|
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
import { LogoLink } from "@/app/components/logo-link";
|
import { getUser } from "@/data/get-user";
|
||||||
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
|
import { CommandMenu } from "@/features/navigation/command-menu";
|
||||||
|
|
||||||
|
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
|
||||||
|
|
||||||
export default async function Layout({
|
export default async function Layout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const helpers = await createSSRHelper();
|
const user = await getUser();
|
||||||
await helpers.user.subscription.prefetch();
|
|
||||||
const dehydratedState = dehydrate(helpers.queryClient);
|
|
||||||
return (
|
return (
|
||||||
<Hydrate state={dehydratedState}>
|
<AppSidebarProvider>
|
||||||
<div className="flex flex-col pb-16 md:pb-0">
|
<CommandMenu />
|
||||||
<div
|
<AppSidebar />
|
||||||
className={cn(
|
<SidebarInset>
|
||||||
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
|
<TopBar className="sm:hidden">
|
||||||
)}
|
<TopBarLeft>
|
||||||
>
|
<SidebarTrigger />
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
</TopBarLeft>
|
||||||
<LogoLink />
|
<TopBarRight>
|
||||||
<ProBadge />
|
<Button
|
||||||
</div>
|
asChild
|
||||||
<Sidebar />
|
variant="ghost"
|
||||||
|
className="rounded-full"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<Link href="/settings/profile">
|
||||||
|
<OptimizedAvatarImage
|
||||||
|
src={user.image}
|
||||||
|
name={user.name}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TopBarRight>
|
||||||
|
</TopBar>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
|
<ActionBar />
|
||||||
<div className="max-w-5xl">{children}</div>
|
</SidebarInset>
|
||||||
</div>
|
</AppSidebarProvider>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
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 { Tile, TileGrid, TileTitle } from "@rallly/ui/tile";
|
||||||
import { HomeIcon } from "lucide-react";
|
|
||||||
import { Trans } from "react-i18next/TransWithoutContext";
|
|
||||||
|
|
||||||
import Dashboard from "@/app/[locale]/(admin)/dashboard";
|
|
||||||
import type { Params } from "@/app/[locale]/types";
|
import type { Params } from "@/app/[locale]/types";
|
||||||
|
import {
|
||||||
|
BillingPageIcon,
|
||||||
|
EventPageIcon,
|
||||||
|
HomePageIcon,
|
||||||
|
PollPageIcon,
|
||||||
|
PreferencesPageIcon,
|
||||||
|
ProfilePageIcon,
|
||||||
|
} from "@/app/components/page-icons";
|
||||||
import {
|
import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageContent,
|
PageContent,
|
||||||
|
PageDescription,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageIcon,
|
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} from "@/app/components/page-layout";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
|
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Params }) {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
const { t } = await getTranslation(params.locale);
|
await getTranslation(params.locale);
|
||||||
const helpers = await createSSRHelper();
|
|
||||||
await helpers.dashboard.info.prefetch();
|
|
||||||
return (
|
return (
|
||||||
<Hydrate state={dehydrate(helpers.queryClient)}>
|
<PageContainer>
|
||||||
<div>
|
<PageHeader>
|
||||||
<PageContainer>
|
<PageTitle>
|
||||||
<PageHeader>
|
<HomePageIcon />
|
||||||
<div className="flex items-center gap-x-3">
|
<Trans i18nKey="home" defaults="Home" />
|
||||||
<PageIcon>
|
</PageTitle>
|
||||||
<HomeIcon />
|
<PageDescription>
|
||||||
</PageIcon>
|
<Trans
|
||||||
<PageTitle>
|
i18nKey="homeDashboardDesc"
|
||||||
<Trans t={t} i18nKey="home" defaults="Home" />
|
defaults="Manage your polls, events, and account settings"
|
||||||
</PageTitle>
|
/>
|
||||||
</div>
|
</PageDescription>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent className="space-y-8">
|
||||||
<Dashboard />
|
{/* <div className="space-y-4">
|
||||||
</PageContent>
|
<h2 className="text-muted-foreground text-sm">
|
||||||
</PageContainer>
|
<Trans i18nKey="homeActionsTitle" defaults="Actions" />
|
||||||
</div>
|
</h2>
|
||||||
</Hydrate>
|
<TileGrid>
|
||||||
|
<Tile href="/new">
|
||||||
|
<CreatePageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="create" defaults="Create" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
</TileGrid>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="homeNavTitle" defaults="Navigation" />
|
||||||
|
</h2>
|
||||||
|
<TileGrid>
|
||||||
|
<Tile href="/polls">
|
||||||
|
<PollPageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="polls" defaults="Polls" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile href="/events">
|
||||||
|
<EventPageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="events" defaults="Events" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
{/* <Tile href="/members">
|
||||||
|
<MembersPageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="members" defaults="Members" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile> */}
|
||||||
|
</TileGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="account" defaults="Account" />
|
||||||
|
</h2>
|
||||||
|
<TileGrid>
|
||||||
|
<Tile href="/settings/profile">
|
||||||
|
<ProfilePageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="profile" defaults="Profile" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile href="/settings/preferences">
|
||||||
|
<PreferencesPageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile href="/settings/billing">
|
||||||
|
<BillingPageIcon />
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="billing" defaults="Billing" />
|
||||||
|
</TileTitle>
|
||||||
|
</Tile>
|
||||||
|
</TileGrid>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata() {
|
||||||
params,
|
const { t } = await getTranslation();
|
||||||
}: {
|
|
||||||
params: { locale: string };
|
|
||||||
}) {
|
|
||||||
const { t } = await getTranslation(params.locale);
|
|
||||||
return {
|
return {
|
||||||
title: t("home", {
|
title: t("home", {
|
||||||
defaultValue: "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 { PollPageIcon } from "@/app/components/page-icons";
|
||||||
import type { Params } from "@/app/[locale]/types";
|
|
||||||
import {
|
import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageContent,
|
PageContent,
|
||||||
|
PageDescription,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageIcon,
|
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} 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 { getTranslation } from "@/i18n/server";
|
||||||
|
import { requireUser } from "@/next-auth";
|
||||||
|
|
||||||
export default async function Page({
|
import { PollsTabbedView } from "./polls-tabbed-view";
|
||||||
params,
|
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;
|
userId: string;
|
||||||
children?: React.ReactNode;
|
status?: PollStatus;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
q?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = await getTranslation(params.locale);
|
const [{ total, data: polls }] = await Promise.all([
|
||||||
return (
|
getPolls({ userId, status, page, pageSize, q }),
|
||||||
<PageContainer>
|
]);
|
||||||
<PageHeader>
|
|
||||||
<div className="flex items-center gap-x-3">
|
return {
|
||||||
<PageIcon>
|
polls,
|
||||||
<BarChart2Icon />
|
total,
|
||||||
</PageIcon>
|
};
|
||||||
<PageTitle>
|
|
||||||
{t("polls", {
|
|
||||||
defaultValue: "Polls",
|
|
||||||
})}
|
|
||||||
</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<UserPolls />
|
|
||||||
</PageContent>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata() {
|
||||||
params,
|
const { t } = await getTranslation();
|
||||||
}: {
|
|
||||||
params: { locale: string };
|
|
||||||
}) {
|
|
||||||
const { t } = await getTranslation(params.locale);
|
|
||||||
return {
|
return {
|
||||||
title: t("polls", {
|
title: t("polls", {
|
||||||
defaultValue: "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,
|
SparklesIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
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 {
|
import {
|
||||||
DescriptionDetails,
|
DescriptionDetails,
|
||||||
DescriptionList,
|
DescriptionList,
|
||||||
|
@ -29,7 +33,6 @@ import {
|
||||||
} from "@/components/empty-state";
|
} from "@/components/empty-state";
|
||||||
import { FormattedDate } from "@/components/formatted-date";
|
import { FormattedDate } from "@/components/formatted-date";
|
||||||
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
||||||
import { Settings, SettingsSection } from "@/components/settings/settings";
|
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { requireUser } from "@/next-auth";
|
import { requireUser } from "@/next-auth";
|
||||||
import { isSelfHosted } from "@/utils/constants";
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
@ -39,10 +42,10 @@ import { SubscriptionPrice } from "./components/subscription-price";
|
||||||
import { SubscriptionStatus } from "./components/subscription-status";
|
import { SubscriptionStatus } from "./components/subscription-status";
|
||||||
|
|
||||||
async function getData() {
|
async function getData() {
|
||||||
const user = await requireUser();
|
const { userId } = await requireUser();
|
||||||
|
|
||||||
const data = await prisma.user.findUnique({
|
const data = await prisma.user.findUnique({
|
||||||
where: { id: user.id },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
customerId: true,
|
customerId: true,
|
||||||
subscription: {
|
subscription: {
|
||||||
|
@ -70,7 +73,7 @@ async function getData() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
throw new Error("User not found");
|
redirect("/api/auth/invalid-session");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@ -86,7 +89,7 @@ export default async function Page() {
|
||||||
const { subscription } = data;
|
const { subscription } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Settings>
|
<SettingsContent>
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={
|
title={
|
||||||
<Trans i18nKey="billingSubscriptionTitle" defaults="Subscription" />
|
<Trans i18nKey="billingSubscriptionTitle" defaults="Subscription" />
|
||||||
|
@ -302,6 +305,6 @@ export default async function Page() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</Settings>
|
</SettingsContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,7 @@
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@rallly/ui/card";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
||||||
import { InfoIcon } from "lucide-react";
|
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) => {
|
export const SettingsContent = ({ children }: React.PropsWithChildren) => {
|
||||||
return <div className="space-y-6">{children}</div>;
|
return <div className="space-y-6">{children}</div>;
|
||||||
};
|
};
|
||||||
|
@ -31,13 +12,15 @@ export const SettingsSection = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="rounded-lg border p-4">
|
||||||
<CardHeader>
|
<header className="mb-6">
|
||||||
<CardTitle>{props.title}</CardTitle>
|
<h2 className="mb-2 text-base font-semibold leading-none">
|
||||||
<CardDescription>{props.description}</CardDescription>
|
{props.title}
|
||||||
</CardHeader>
|
</h2>
|
||||||
<CardContent>{props.children}</CardContent>
|
<p className="text-muted-foreground text-sm">{props.description}</p>
|
||||||
</Card>
|
</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 React from "react";
|
||||||
|
|
||||||
|
import { SettingsPageIcon } from "@/app/components/page-icons";
|
||||||
import {
|
import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageContent,
|
PageContent,
|
||||||
|
@ -8,7 +9,8 @@ import {
|
||||||
} from "@/app/components/page-layout";
|
} from "@/app/components/page-layout";
|
||||||
import { getTranslation } from "@/i18n/server";
|
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({
|
export default async function ProfileLayout({
|
||||||
children,
|
children,
|
||||||
|
@ -20,13 +22,22 @@ export default async function ProfileLayout({
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>{t("settings")}</PageTitle>
|
<div className="flex items-start gap-4">
|
||||||
</PageHeader>
|
<div className="flex-1">
|
||||||
<PageContent className="space-y-3 sm:space-y-4">
|
<PageTitle>
|
||||||
<div className="scrollbar-none -mx-3 overflow-auto bg-gray-100 px-3 sm:mx-0 sm:px-0">
|
<SettingsPageIcon />
|
||||||
<SettingsMenu />
|
{t("settings", {
|
||||||
|
defaultValue: "Settings",
|
||||||
|
})}
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{children}</div>
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<SettingsLayout>{children}</SettingsLayout>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
|
import { RouterLoadingIndicator } from "@/components/router-loading-indicator";
|
||||||
import { Spinner } from "@/components/spinner";
|
import { Spinner } from "@/components/spinner";
|
||||||
|
|
||||||
export default async function Loading() {
|
export default async function Loading() {
|
||||||
return <Spinner />;
|
return (
|
||||||
|
<>
|
||||||
|
<RouterLoadingIndicator />
|
||||||
|
<Spinner />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,49 +1,39 @@
|
||||||
"use client";
|
"use client";
|
||||||
import Head from "next/head";
|
|
||||||
|
|
||||||
import { DateTimePreferences } from "@/components/settings/date-time-preferences";
|
import { DateTimePreferences } from "@/app/[locale]/(admin)/settings/components/date-time-preferences";
|
||||||
import { LanguagePreference } from "@/components/settings/language-preference";
|
import { LanguagePreference } from "@/app/[locale]/(admin)/settings/components/language-preference";
|
||||||
import {
|
import {
|
||||||
Settings,
|
|
||||||
SettingsContent,
|
SettingsContent,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from "@/components/settings/settings";
|
} from "@/app/[locale]/(admin)/settings/components/settings-layout";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { useTranslation } from "@/i18n/client";
|
|
||||||
|
|
||||||
export function PreferencesPage() {
|
export function PreferencesPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Settings>
|
<SettingsContent>
|
||||||
<SettingsContent>
|
<SettingsSection
|
||||||
<Head>
|
title={<Trans i18nKey="language" defaults="Language" />}
|
||||||
<title>{t("settings")}</title>
|
description={
|
||||||
</Head>
|
<Trans
|
||||||
<SettingsSection
|
i18nKey="languageDescription"
|
||||||
title={<Trans i18nKey="language" defaults="Language" />}
|
defaults="Change your preferred language"
|
||||||
description={
|
/>
|
||||||
<Trans
|
}
|
||||||
i18nKey="languageDescription"
|
>
|
||||||
defaults="Change your preferred language"
|
<LanguagePreference />
|
||||||
/>
|
</SettingsSection>
|
||||||
}
|
<hr />
|
||||||
>
|
<SettingsSection
|
||||||
<LanguagePreference />
|
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
|
||||||
</SettingsSection>
|
description={
|
||||||
<hr />
|
<Trans
|
||||||
<SettingsSection
|
i18nKey="dateAndTimeDescription"
|
||||||
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
|
defaults="Change your preferred date and time settings"
|
||||||
description={
|
/>
|
||||||
<Trans
|
}
|
||||||
i18nKey="dateAndTimeDescription"
|
>
|
||||||
defaults="Change your preferred date and time settings"
|
<DateTimePreferences />
|
||||||
/>
|
</SettingsSection>
|
||||||
}
|
</SettingsContent>
|
||||||
>
|
|
||||||
<DateTimePreferences />
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsContent>
|
|
||||||
</Settings>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { Spinner } from "@/components/spinner";
|
|
||||||
|
|
||||||
export default async function Loading() {
|
|
||||||
return <Spinner />;
|
|
||||||
}
|
|
|
@ -1,97 +1,71 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { DialogTrigger } from "@rallly/ui/dialog";
|
import { DialogTrigger } from "@rallly/ui/dialog";
|
||||||
import { LogOutIcon, TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import Head from "next/head";
|
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
Settings,
|
|
||||||
SettingsContent,
|
SettingsContent,
|
||||||
SettingsSection,
|
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 { Trans } from "@/components/trans";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { useUser } from "@/components/user-provider";
|
||||||
import { useTranslation } from "@/i18n/client";
|
|
||||||
|
|
||||||
import { ProfileEmailAddress } from "./profile-email-address";
|
import { ProfileEmailAddress } from "./profile-email-address";
|
||||||
|
|
||||||
export const ProfilePage = () => {
|
export const ProfilePage = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Settings>
|
<SettingsContent>
|
||||||
<Head>
|
<SettingsSection
|
||||||
<title>{t("profile")}</title>
|
title={<Trans i18nKey="profile" defaults="Profile" />}
|
||||||
</Head>
|
description={
|
||||||
<SettingsContent>
|
<Trans
|
||||||
<SettingsSection
|
i18nKey="profileDescription"
|
||||||
title={<Trans i18nKey="profile" defaults="Profile" />}
|
defaults="Set your public profile information"
|
||||||
description={
|
/>
|
||||||
<Trans
|
}
|
||||||
i18nKey="profileDescription"
|
>
|
||||||
defaults="Set your public profile information"
|
<ProfileSettings />
|
||||||
/>
|
</SettingsSection>
|
||||||
}
|
<SettingsSection
|
||||||
>
|
title={<Trans i18nKey="profileEmailAddress" defaults="Email Address" />}
|
||||||
<ProfileSettings />
|
description={
|
||||||
</SettingsSection>
|
<Trans
|
||||||
<SettingsSection
|
i18nKey="profileEmailAddressDescription"
|
||||||
title={
|
defaults="Your email address is used to log in to your account"
|
||||||
<Trans i18nKey="profileEmailAddress" defaults="Email Address" />
|
/>
|
||||||
}
|
}
|
||||||
description={
|
>
|
||||||
<Trans
|
<ProfileEmailAddress />
|
||||||
i18nKey="profileEmailAddressDescription"
|
</SettingsSection>
|
||||||
defaults="Your email address is used to log in to your account"
|
<hr />
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ProfileEmailAddress />
|
|
||||||
</SettingsSection>
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<SettingsSection
|
{user.email ? (
|
||||||
title={<Trans i18nKey="logout" />}
|
<>
|
||||||
description={
|
<hr />
|
||||||
<Trans
|
<SettingsSection
|
||||||
i18nKey="logoutDescription"
|
title={<Trans i18nKey="dangerZone" defaults="Danger Zone" />}
|
||||||
defaults="Sign out of your existing session"
|
description={
|
||||||
/>
|
<Trans
|
||||||
}
|
i18nKey="dangerZoneAccount"
|
||||||
>
|
defaults="Delete your account permanently. This action cannot be undone."
|
||||||
<LogoutButton>
|
/>
|
||||||
<LogOutIcon className="size-4" />
|
}
|
||||||
<Trans i18nKey="logout" defaults="Logout" />
|
>
|
||||||
</LogoutButton>
|
<DeleteAccountDialog email={user.email}>
|
||||||
</SettingsSection>
|
<DialogTrigger asChild>
|
||||||
{user.email ? (
|
<Button className="text-destructive">
|
||||||
<>
|
<TrashIcon className="size-4" />
|
||||||
<hr />
|
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
|
||||||
<SettingsSection
|
</Button>
|
||||||
title={<Trans i18nKey="dangerZone" defaults="Danger Zone" />}
|
</DialogTrigger>
|
||||||
description={
|
</DeleteAccountDialog>
|
||||||
<Trans
|
</SettingsSection>
|
||||||
i18nKey="dangerZoneAccount"
|
</>
|
||||||
defaults="Delete your account permanently. This action cannot be undone."
|
) : null}
|
||||||
/>
|
</SettingsContent>
|
||||||
}
|
|
||||||
>
|
|
||||||
<DeleteAccountDialog email={user.email}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="text-destructive">
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
</DeleteAccountDialog>
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</SettingsContent>
|
|
||||||
</Settings>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,35 +1,39 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Icon } from "@rallly/ui/icon";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
|
||||||
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
|
import Link from "next/link";
|
||||||
import { Trans } from "react-i18next";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { TabMenu, TabMenuItem } from "@/app/components/tab-menu";
|
import { Trans } from "@/components/trans";
|
||||||
import { IfCloudHosted } from "@/contexts/environment";
|
import { IfCloudHosted } from "@/contexts/environment";
|
||||||
|
|
||||||
export function SettingsMenu() {
|
export function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabMenu>
|
<Tabs value={pathname}>
|
||||||
<TabMenuItem href="/settings/profile">
|
<TabsList>
|
||||||
<Icon>
|
<TabsTrigger asChild value="/settings/profile">
|
||||||
<UserIcon />
|
<Link href="/settings/profile">
|
||||||
</Icon>
|
<Trans i18nKey="profile" defaults="Profile" />
|
||||||
<Trans i18nKey="profile" />
|
</Link>
|
||||||
</TabMenuItem>
|
</TabsTrigger>
|
||||||
<TabMenuItem href="/settings/preferences">
|
<TabsTrigger asChild value="/settings/preferences">
|
||||||
<Icon>
|
<Link href="/settings/preferences">
|
||||||
<Settings2Icon />
|
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||||
</Icon>
|
</Link>
|
||||||
<Trans i18nKey="preferences" />
|
</TabsTrigger>
|
||||||
</TabMenuItem>
|
<IfCloudHosted>
|
||||||
<IfCloudHosted>
|
<TabsTrigger asChild value="/settings/billing">
|
||||||
<TabMenuItem href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Icon>
|
<Trans i18nKey="billing" defaults="Billing" />
|
||||||
<CreditCardIcon />
|
</Link>
|
||||||
</Icon>
|
</TabsTrigger>
|
||||||
<Trans i18nKey="billing" />
|
</IfCloudHosted>
|
||||||
</TabMenuItem>
|
</TabsList>
|
||||||
</IfCloudHosted>
|
<TabsContent className="mt-4" value={pathname}>
|
||||||
</TabMenu>
|
{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 { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
||||||
import { UserProvider } from "@/components/user-provider";
|
import { UserProvider } from "@/components/user-provider";
|
||||||
|
import { TimezoneProvider } from "@/features/timezone";
|
||||||
import { I18nProvider } from "@/i18n/client";
|
import { I18nProvider } from "@/i18n/client";
|
||||||
import { auth } from "@/next-auth";
|
import { auth } from "@/next-auth";
|
||||||
import { TRPCProvider } from "@/trpc/client/provider";
|
import { TRPCProvider } from "@/trpc/client/provider";
|
||||||
|
@ -55,10 +56,14 @@ export default async function Root({
|
||||||
<PostHogPageView />
|
<PostHogPageView />
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ConnectedDayjsProvider>
|
<TimezoneProvider
|
||||||
{children}
|
initialTimezone={session?.user?.timeZone ?? undefined}
|
||||||
<TimeZoneChangeDetector />
|
>
|
||||||
</ConnectedDayjsProvider>
|
<ConnectedDayjsProvider>
|
||||||
|
{children}
|
||||||
|
<TimeZoneChangeDetector />
|
||||||
|
</ConnectedDayjsProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
|
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { Icon } from "@rallly/ui/icon";
|
import { Icon } from "@rallly/ui/icon";
|
||||||
import { XIcon } from "lucide-react";
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function CloseButton() {
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
export function BackButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -16,8 +18,9 @@ export function CloseButton() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Icon>
|
<Icon>
|
||||||
<XIcon />
|
<ArrowLeftIcon />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
<Trans i18nKey="back" defaults="Back" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -2,14 +2,15 @@ import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Trans } from "react-i18next/TransWithoutContext";
|
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 type { Params } from "@/app/[locale]/types";
|
||||||
|
import { PollPageIcon } from "@/app/components/page-icons";
|
||||||
import { CreatePoll } from "@/components/create-poll";
|
import { CreatePoll } from "@/components/create-poll";
|
||||||
import { UserDropdown } from "@/components/user-dropdown";
|
import { UserDropdown } from "@/components/user-dropdown";
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
import { getLoggedIn } from "@/next-auth";
|
import { getLoggedIn } from "@/next-auth";
|
||||||
|
|
||||||
|
import { BackButton } from "./back-button";
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Params }) {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
const { t } = await getTranslation(params.locale);
|
const { t } = await getTranslation(params.locale);
|
||||||
const isLoggedIn = await getLoggedIn();
|
const isLoggedIn = await getLoggedIn();
|
||||||
|
@ -17,16 +18,16 @@ export default async function Page({ params }: { params: Params }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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="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">
|
<div className="flex items-center gap-x-4 sm:flex-1">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 sm:justify-center">
|
<div className="flex flex-1 sm:justify-center">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<GroupPollIcon size="xs" />
|
<PollPageIcon />
|
||||||
<div className="flex items-baseline gap-x-8">
|
<div className="flex items-baseline gap-x-8">
|
||||||
<h1 className="text-sm font-semibold">
|
<h1 className="font-semibold">
|
||||||
<Trans t={t} i18nKey="groupPoll" defaults="Group Poll" />
|
<Trans t={t} i18nKey="poll" defaults="Poll" />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
|
import { Skeleton } from "@rallly/ui/skeleton";
|
||||||
|
|
||||||
export function PageContainer({
|
export function PageContainer({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: React.PropsWithChildren<{ className?: string }>) {
|
}: React.PropsWithChildren<{ className?: string }>) {
|
||||||
return <div className={cn(className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageIcon({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("hidden", className)}>
|
<div className={cn("mx-auto w-full max-w-7xl", className)}>{children}</div>
|
||||||
<Slot className="size-4">{children}</Slot>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +22,7 @@ export function PageTitle({
|
||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
className={cn(
|
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,
|
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({
|
export function PageHeader({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
@ -50,7 +53,7 @@ export function PageHeader({
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: "default" | "ghost";
|
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 }) {
|
export function PageSection({ children }: { children?: React.ReactNode }) {
|
||||||
|
@ -68,5 +71,43 @@ export function PageContent({
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
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 { cn } from "@rallly/ui";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
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",
|
short: "D MMM YYYY",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Format = keyof typeof formatMap;
|
type Format = keyof typeof formatMap | string;
|
||||||
|
|
||||||
export function FormattedDate({
|
export function FormattedDate({
|
||||||
date,
|
date,
|
||||||
|
@ -15,5 +15,8 @@ export function FormattedDate({
|
||||||
date: Date;
|
date: Date;
|
||||||
format: Format;
|
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 { Trans } from "react-i18next";
|
||||||
|
|
||||||
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
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";
|
import { usePlan } from "@/contexts/plan";
|
||||||
|
|
||||||
export type PollSettingsFormData = {
|
export type PollSettingsFormData = {
|
||||||
|
@ -42,7 +42,7 @@ const SettingTitle = ({
|
||||||
<div className="text-sm font-medium">{children}</div>
|
<div className="text-sm font-medium">{children}</div>
|
||||||
{pro ? (
|
{pro ? (
|
||||||
<div>
|
<div>
|
||||||
<ProFeatureBadge />
|
<ProBadge />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,9 +7,9 @@ import React from "react";
|
||||||
const sizeToWidth = {
|
const sizeToWidth = {
|
||||||
xs: 20,
|
xs: 20,
|
||||||
sm: 24,
|
sm: 24,
|
||||||
md: 36,
|
md: 32,
|
||||||
lg: 48,
|
lg: 48,
|
||||||
xl: 56,
|
xl: 64,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OptimizedAvatarImage({
|
export function OptimizedAvatarImage({
|
||||||
|
@ -26,7 +26,7 @@ export function OptimizedAvatarImage({
|
||||||
const [isLoaded, setLoaded] = React.useState(false);
|
const [isLoaded, setLoaded] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
className={className}
|
className={cn("rounded-full", className)}
|
||||||
style={{ width: sizeToWidth[size], height: sizeToWidth[size] }}
|
style={{ width: sizeToWidth[size], height: sizeToWidth[size] }}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
|
@ -49,14 +49,14 @@ export function OptimizedAvatarImage({
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
seed={name}
|
seed={name}
|
||||||
className={cn("shrink-0", {
|
className={cn("shrink-0", {
|
||||||
"text-xs": size === "xs",
|
"text-[10px]": size === "xs",
|
||||||
"text-sm": size === "sm",
|
"text-[12px]": size === "sm",
|
||||||
"text-md": size === "md",
|
"text-md": size === "md",
|
||||||
"text-lg": size === "lg",
|
"text-lg": size === "lg",
|
||||||
"text-xl": size === "xl",
|
"text-3xl": size === "xl",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{name[0]?.toUpperCase()}
|
{name?.[0]?.toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
) : null}
|
) : null}
|
||||||
</Avatar>
|
</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 { 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 { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
interface ParticipantAvatarBarProps {
|
interface ParticipantAvatarBarProps {
|
||||||
participants: { name: string }[];
|
participants: { name: string; image?: string }[];
|
||||||
max?: number;
|
max?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,22 +18,40 @@ export const ParticipantAvatarBar = ({
|
||||||
participants,
|
participants,
|
||||||
max = Infinity,
|
max = Infinity,
|
||||||
}: ParticipantAvatarBarProps) => {
|
}: ParticipantAvatarBarProps) => {
|
||||||
const visibleCount = participants.length > max ? max - 1 : max;
|
const totalParticipants = participants.length;
|
||||||
const hiddenCount = participants.length - visibleCount;
|
|
||||||
|
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 (
|
return (
|
||||||
<ul className="flex items-center -space-x-1">
|
<ul className="flex cursor-default items-center -space-x-1 rounded-full bg-white p-0.5">
|
||||||
{participants.slice(0, visibleCount).map((participant, index) => (
|
{visibleParticipants.map((participant, index) => (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={index}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<li className="z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
<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>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{participant.name}</TooltipContent>
|
<TooltipContent>{participant.name}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{hiddenCount > 1 ? (
|
{hiddenCount > 0 ? (
|
||||||
<li className="relative z-20 inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
<li className="relative z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
|
@ -40,15 +64,24 @@ export const ParticipantAvatarBar = ({
|
||||||
+{hiddenCount}
|
+{hiddenCount}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipPortal>
|
||||||
<ul>
|
<TooltipContent className="z-10">
|
||||||
{participants
|
<ul>
|
||||||
.slice(visibleCount, 10)
|
{tooltipParticipants.map((participant, index) => (
|
||||||
.map((participant, index) => (
|
|
||||||
<li key={index}>{participant.name}</li>
|
<li key={index}>{participant.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
{remainingCount > 0 && (
|
||||||
</TooltipContent>
|
<li>
|
||||||
|
<Trans
|
||||||
|
i18nKey="moreParticipants"
|
||||||
|
values={{ count: remainingCount }}
|
||||||
|
defaults="{count} more…"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -56,7 +56,7 @@ export function PayWallDialog({ children, ...forwardedProps }: DialogProps) {
|
||||||
className="text-center"
|
className="text-center"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Badge size="lg" variant="primary">
|
<Badge size="lg" variant="secondary">
|
||||||
<Trans i18nKey="planPro" />
|
<Trans i18nKey="planPro" />
|
||||||
</Badge>
|
</Badge>
|
||||||
</m.div>
|
</m.div>
|
||||||
|
@ -158,7 +158,11 @@ export function PayWallDialog({ children, ...forwardedProps }: DialogProps) {
|
||||||
</section>
|
</section>
|
||||||
<footer className="space-y-4">
|
<footer className="space-y-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<UpgradeButton large annual={period === "yearly"}>
|
<UpgradeButton
|
||||||
|
className="w-full"
|
||||||
|
large
|
||||||
|
annual={period === "yearly"}
|
||||||
|
>
|
||||||
<Trans i18nKey="upgrade" defaults="Upgrade" />
|
<Trans i18nKey="upgrade" defaults="Upgrade" />
|
||||||
</UpgradeButton>
|
</UpgradeButton>
|
||||||
</div>
|
</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 { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
import { PollStatusIcon } from "./poll-status-icon";
|
||||||
|
|
||||||
export const PollStatusLabel = ({
|
export const PollStatusLabel = ({
|
||||||
status,
|
status,
|
||||||
className,
|
className,
|
||||||
|
@ -14,12 +16,9 @@ export const PollStatusLabel = ({
|
||||||
case "live":
|
case "live":
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("inline-flex items-center gap-x-2 text-sm", className)}
|
||||||
"inline-flex items-center gap-x-1.5 text-sm font-medium text-pink-600",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="size-1.5 rounded-full bg-pink-600" />
|
<PollStatusIcon status={status} />
|
||||||
<Trans i18nKey="pollStatusOpen" defaults="Live" />
|
<Trans i18nKey="pollStatusOpen" defaults="Live" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -27,12 +26,11 @@ export const PollStatusLabel = ({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="size-1.5 rounded-full bg-gray-600" />
|
<PollStatusIcon status={status} />
|
||||||
|
|
||||||
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
|
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -40,12 +38,11 @@ export const PollStatusLabel = ({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="size-1.5 rounded-full bg-green-600" />
|
<PollStatusIcon status={status} />
|
||||||
|
|
||||||
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
|
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -30,7 +30,7 @@ import * as React from "react";
|
||||||
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
|
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
|
||||||
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
||||||
import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-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 { Trans } from "@/components/trans";
|
||||||
import { usePlan } from "@/contexts/plan";
|
import { usePlan } from "@/contexts/plan";
|
||||||
import { usePoll } from "@/contexts/poll";
|
import { usePoll } from "@/contexts/poll";
|
||||||
|
@ -217,7 +217,7 @@ const ManagePoll: React.FunctionComponent<{
|
||||||
<CalendarCheck2Icon />
|
<CalendarCheck2Icon />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Trans i18nKey="finishPoll" defaults="Finalize" />
|
<Trans i18nKey="finishPoll" defaults="Finalize" />
|
||||||
<ProFeatureBadge />
|
<ProBadge />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<PauseResumeToggle />
|
<PauseResumeToggle />
|
||||||
</>
|
</>
|
||||||
|
@ -245,7 +245,7 @@ const ManagePoll: React.FunctionComponent<{
|
||||||
>
|
>
|
||||||
<DropdownMenuItemIconLabel icon={CopyIcon}>
|
<DropdownMenuItemIconLabel icon={CopyIcon}>
|
||||||
<Trans i18nKey="duplicate" defaults="Duplicate" />
|
<Trans i18nKey="duplicate" defaults="Duplicate" />
|
||||||
<ProFeatureBadge />
|
<ProBadge />
|
||||||
</DropdownMenuItemIconLabel>
|
</DropdownMenuItemIconLabel>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
|
@ -18,24 +18,12 @@ export const normalizeVotes = (
|
||||||
|
|
||||||
export const useAddParticipantMutation = () => {
|
export const useAddParticipantMutation = () => {
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
const queryClient = trpc.useUtils();
|
|
||||||
return trpc.polls.participants.add.useMutation({
|
return trpc.polls.participants.add.useMutation({
|
||||||
onSuccess: async (newParticipant, input) => {
|
onSuccess: async (_, input) => {
|
||||||
const { pollId, name, email } = newParticipant;
|
|
||||||
queryClient.polls.participants.list.setData(
|
|
||||||
{ pollId },
|
|
||||||
(existingParticipants = []) => {
|
|
||||||
return [
|
|
||||||
{ ...newParticipant, votes: input.votes },
|
|
||||||
...existingParticipants,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
posthog?.capture("add participant", {
|
posthog?.capture("add participant", {
|
||||||
pollId,
|
pollId: input.pollId,
|
||||||
name,
|
name: input.name,
|
||||||
email,
|
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 }) => {
|
export const ProBadge = ({ className }: { className?: string }) => {
|
||||||
return (
|
return (
|
||||||
<Badge variant="primary" className={className}>
|
<Badge variant="secondary" className={className}>
|
||||||
<Trans i18nKey="planPro" />
|
<Trans i18nKey="planPro" />
|
||||||
</Badge>
|
</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 { useDialog } from "@rallly/ui/dialog";
|
||||||
import { Icon } from "@rallly/ui/icon";
|
import { Icon } from "@rallly/ui/icon";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { CheckIcon, GlobeIcon } from "lucide-react";
|
import { CheckIcon, ChevronsUpDownIcon, GlobeIcon } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
@ -96,6 +96,9 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
<GlobeIcon />
|
<GlobeIcon />
|
||||||
</Icon>
|
</Icon>
|
||||||
{value}
|
{value}
|
||||||
|
<Icon>
|
||||||
|
<ChevronsUpDownIcon />
|
||||||
|
</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,13 +3,9 @@ import { Trans as BaseTrans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { TxKeyPath } from "../i18n/types";
|
import type { TxKeyPath } from "../i18n/types";
|
||||||
|
|
||||||
export const Trans = (props: {
|
export const Trans = (
|
||||||
i18nKey: TxKeyPath;
|
props: React.ComponentProps<typeof BaseTrans> & { i18nKey: TxKeyPath },
|
||||||
defaults?: string;
|
) => {
|
||||||
values?: Record<string, string | number | boolean | undefined>;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
components?: Record<string, React.ReactElement> | React.ReactElement[];
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <BaseTrans ns="app" t={t} {...props} />;
|
return <BaseTrans ns="app" t={t} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
"use client";
|
||||||
import { usePostHog } from "@rallly/posthog/client";
|
import { usePostHog } from "@rallly/posthog/client";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
@ -9,7 +10,12 @@ export const UpgradeButton = ({
|
||||||
children,
|
children,
|
||||||
annual,
|
annual,
|
||||||
large,
|
large,
|
||||||
}: React.PropsWithChildren<{ annual?: boolean; large?: boolean }>) => {
|
className,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
annual?: boolean;
|
||||||
|
large?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}>) => {
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
const formRef = React.useRef<HTMLFormElement>(null);
|
const formRef = React.useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
@ -27,7 +33,7 @@ export const UpgradeButton = ({
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size={large ? "lg" : "default"}
|
size={large ? "lg" : "default"}
|
||||||
className="w-full"
|
className={className}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// 🐛 Since we have nested forms, we need to prevent the default
|
// 🐛 Since we have nested forms, we need to prevent the default
|
||||||
|
@ -39,7 +45,7 @@ export const UpgradeButton = ({
|
||||||
posthog?.capture("click upgrade button");
|
posthog?.capture("click upgrade button");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children || <Trans i18nKey="upgrade" defaults="Upgrade" />}
|
{children || <Trans i18nKey="upgradeToPro" defaults="Upgrade to Pro" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { Badge } from "@rallly/ui/badge";
|
import { Badge } from "@rallly/ui/badge";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { ProBadge } from "@/components/pro-badge";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { trpc } from "@/trpc/client";
|
import { trpc } from "@/trpc/client";
|
||||||
import { isSelfHosted } from "@/utils/constants";
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
@ -44,11 +45,7 @@ export const Plan = () => {
|
||||||
const plan = usePlan();
|
const plan = usePlan();
|
||||||
|
|
||||||
if (plan === "paid") {
|
if (plan === "paid") {
|
||||||
return (
|
return <ProBadge />;
|
||||||
<Badge variant="primary">
|
|
||||||
<Trans i18nKey="planPro" defaults="Pro" />
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 newUrl = nextUrl.clone();
|
||||||
|
|
||||||
const isLoggedIn = req.auth?.user?.email;
|
const isLoggedIn = req.auth?.user?.email;
|
||||||
|
|
||||||
// if the user is already logged in, don't let them access the login page
|
// 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 = "/";
|
newUrl.pathname = "/";
|
||||||
return NextResponse.redirect(newUrl);
|
return NextResponse.redirect(newUrl);
|
||||||
}
|
}
|
||||||
|
|
|
@ -209,7 +209,7 @@ const requireUser = async () => {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
return session?.user;
|
return { userId: session.user.id };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -757,19 +757,24 @@ export const polls = router({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await prisma.$transaction([
|
await prisma.$transaction(async () => {
|
||||||
prisma.poll.update({
|
const poll = await prisma.poll.update({
|
||||||
where: {
|
where: {
|
||||||
id: input.pollId,
|
id: input.pollId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
event: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
status: "live",
|
status: "live",
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
]);
|
|
||||||
|
if (poll.eventId) {
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: {
|
||||||
|
id: poll.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
pause: possiblyPublicProcedure
|
pause: possiblyPublicProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
|
@ -42,7 +42,7 @@ test.describe.serial(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Navigate back to the poll
|
// 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 expect(page).toHaveURL(/polls/);
|
||||||
await page.click("text=Monthly Meetup");
|
await page.click("text=Monthly Meetup");
|
||||||
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
|
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
|
||||||
|
|
|
@ -309,4 +309,4 @@ model VerificationToken {
|
||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
@@map("verification_tokens")
|
@@map("verification_tokens")
|
||||||
}
|
}
|
|
@ -25,9 +25,9 @@ module.exports = {
|
||||||
background: colors.indigo["50"],
|
background: colors.indigo["50"],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
background: colors.gray["100"],
|
background: colors.indigo["50"],
|
||||||
DEFAULT: colors.gray["100"],
|
DEFAULT: colors.indigo["50"],
|
||||||
foreground: colors.gray["700"],
|
foreground: colors.indigo["600"],
|
||||||
},
|
},
|
||||||
gray: colors.gray,
|
gray: colors.gray,
|
||||||
border: colors.gray["200"],
|
border: colors.gray["200"],
|
||||||
|
@ -70,6 +70,7 @@ module.exports = {
|
||||||
sidebar: {
|
sidebar: {
|
||||||
DEFAULT: colors.gray["100"],
|
DEFAULT: colors.gray["100"],
|
||||||
foreground: colors.gray["700"],
|
foreground: colors.gray["700"],
|
||||||
|
border: colors.gray["200"],
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: colors.gray["200"],
|
DEFAULT: colors.gray["200"],
|
||||||
foreground: colors.gray["800"],
|
foreground: colors.gray["800"],
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||||
"@radix-ui/react-label": "^2.0.1",
|
"@radix-ui/react-label": "^2.0.1",
|
||||||
"@radix-ui/react-popover": "^1.0.5",
|
"@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-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^1.2.1",
|
"@radix-ui/react-select": "^1.2.1",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary: "bg-primary text-primary-50",
|
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",
|
destructive: "bg-destructive text-destructive-foreground",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
green: "bg-green-600 text-white",
|
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",
|
"focus:ring-offset-1 border-primary bg-primary hover:bg-primary-500 disabled:bg-gray-400 disabled:border-transparent text-white shadow-sm",
|
||||||
destructive:
|
destructive:
|
||||||
"focus:ring-offset-1 bg-destructive shadow-sm text-destructive-foreground active:bg-destructive border-destructive hover:bg-destructive/90",
|
"focus:ring-offset-1 bg-destructive shadow-sm text-destructive-foreground active:bg-destructive border-destructive hover:bg-destructive/90",
|
||||||
default:
|
default: "focus:ring-offset-1 hover:bg-gray-50 bg-white",
|
||||||
"focus:ring-offset-1 hover:bg-gray-100 bg-gray-50 active:bg-gray-200",
|
|
||||||
secondary:
|
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:
|
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",
|
"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",
|
link: "underline-offset-4 border-transparent hover:underline text-primary",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
@ -32,6 +33,7 @@ const buttonVariants = cva(
|
||||||
sm: "h-8 text-sm px-2 gap-x-1.5 rounded-md",
|
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",
|
lg: "h-12 text-base gap-x-3 px-4 rounded-lg",
|
||||||
icon: "size-7 text-sm gap-x-1.5 rounded-md",
|
icon: "size-7 text-sm gap-x-1.5 rounded-md",
|
||||||
|
"icon-lg": "size-8 rounded-full",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { SearchIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Dialog, DialogContent } from "./dialog";
|
import { Dialog, DialogContent } from "./dialog";
|
||||||
|
import { usePlatform } from "./hooks/use-platform";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
|
@ -29,8 +30,13 @@ type CommandDialogProps = DialogProps;
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="shadow-huge w-full max-w-3xl overflow-hidden p-0">
|
<DialogContent
|
||||||
<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">
|
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}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
@ -68,7 +74,7 @@ const CommandList = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -95,7 +101,7 @@ const CommandGroup = React.forwardRef<
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -123,7 +129,23 @@ const CommandItem = React.forwardRef<
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -148,6 +170,17 @@ const CommandShortcut = ({
|
||||||
};
|
};
|
||||||
CommandShortcut.displayName = "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 {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
|
@ -155,7 +188,9 @@ export {
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
|
CommandItemShortcut,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
|
CommandShortcutSymbol,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
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 { XIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
@ -32,47 +34,72 @@ const DialogOverlay = React.forwardRef<
|
||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
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<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
|
|
||||||
hideCloseButton?: boolean;
|
hideCloseButton?: boolean;
|
||||||
}
|
} & VariantProps<typeof dialogContentVariants>
|
||||||
>(({ className, children, size = "md", hideCloseButton, ...props }, ref) => (
|
>(
|
||||||
<DialogPortal>
|
(
|
||||||
<DialogOverlay />
|
{ className, children, position, size = "md", hideCloseButton, ...props },
|
||||||
<DialogPrimitive.Content
|
ref,
|
||||||
ref={ref}
|
) => (
|
||||||
className={cn(
|
<DialogPortal>
|
||||||
"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",
|
<DialogOverlay />
|
||||||
{
|
<DialogPrimitive.Content
|
||||||
"sm:max-w-sm": size === "sm",
|
ref={ref}
|
||||||
"sm:max-w-md": size === "md",
|
className={cn("", dialogContentVariants({ position, size }), className)}
|
||||||
"sm:max-w-lg": size === "lg",
|
{...props}
|
||||||
"sm:max-w-xl": size === "xl",
|
>
|
||||||
"sm:max-w-2xl": size === "2xl",
|
{children}
|
||||||
"sm:max-w-3xl": size === "3xl",
|
{!hideCloseButton ? (
|
||||||
"sm:max-w-4xl": size === "4xl",
|
<DialogClose asChild className="absolute right-4 top-4">
|
||||||
"sm:max-w-5xl": size === "5xl",
|
<Button size="icon" variant="ghost">
|
||||||
},
|
<Icon>
|
||||||
className,
|
<XIcon />
|
||||||
)}
|
</Icon>
|
||||||
{...props}
|
<span className="sr-only">Close</span>
|
||||||
>
|
</Button>
|
||||||
{children}
|
</DialogClose>
|
||||||
{!hideCloseButton ? (
|
) : null}
|
||||||
<DialogClose asChild className="absolute right-4 top-4">
|
</DialogPrimitive.Content>
|
||||||
<Button size="icon" variant="ghost">
|
</DialogPortal>
|
||||||
<Icon>
|
),
|
||||||
<XIcon />
|
);
|
||||||
</Icon>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
) : null}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
));
|
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
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">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Icon variant="success">
|
<Icon>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</Icon>
|
</Icon>
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
<span className="flex items-center gap-2 text-sm">{children}</span>
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
));
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
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;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Icon({ children, size, variant }: IconProps) {
|
export function Icon({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size,
|
||||||
|
variant,
|
||||||
|
}: { className?: string } & IconProps) {
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
className={cn(
|
className={cn(
|
||||||
iconVariants({ size, variant }),
|
iconVariants({ size, variant }),
|
||||||
"group-[.bg-primary]:text-primary-50 group-[.bg-destructive]:text-destructive-foreground group shrink-0",
|
"group-[.bg-primary]:text-primary-50 group-[.bg-destructive]:text-destructive-foreground group shrink-0",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{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 };
|
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