This commit is contained in:
Luke Vella 2024-09-30 08:51:05 +01:00
parent dc9940b390
commit 331c7fd9ad
No known key found for this signature in database
GPG key ID: 469CAD687F0D784C
16 changed files with 150 additions and 40 deletions

View file

@ -1,14 +1,21 @@
"use client"; "use client";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { CalendarIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GridCard, GridCardHeader } from "@/components/grid-card";
import { GroupPollCard } from "@/components/group-poll-card"; import { GroupPollCard } from "@/components/group-poll-card";
import { Subheading } from "@/components/heading"; import { Subheading } from "@/components/heading";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useLocalizeTime } from "@/utils/dayjs";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
const SectionHeading = ({ children }: React.PropsWithChildren) => {
return <div className="flex items-center justify-between">{children}</div>;
};
export default function Dashboard() { export default function Dashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -18,7 +25,7 @@ export default function Dashboard() {
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <SectionHeading>
<Subheading> <Subheading>
<Trans i18nKey="pending" defaults="Pending" /> <Trans i18nKey="pending" defaults="Pending" />
</Subheading> </Subheading>
@ -27,9 +34,22 @@ export default function Dashboard() {
<Trans i18nKey="viewAll" defaults="View All" /> <Trans i18nKey="viewAll" defaults="View All" />
</Link> </Link>
</Button> </Button>
</div> </SectionHeading>
<PendingPolls /> <PendingPolls />
</div> </div>
<div className="space-y-4">
<SectionHeading>
<Subheading>
<Trans i18nKey="upcoming" defaults="Upcoming" />
</Subheading>
<Button asChild>
<Link href="/events">
<Trans i18nKey="viewAll" defaults="View All" />
</Link>
</Button>
</SectionHeading>
<UpcomingEvents />
</div>
</div> </div>
); );
} }
@ -44,7 +64,7 @@ function PendingPolls() {
} }
return ( return (
<div className="grid gap-2 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{data.map((poll) => { {data.map((poll) => {
return ( return (
<GroupPollCard <GroupPollCard
@ -56,9 +76,49 @@ function PendingPolls() {
responseCount={poll.responseCount} responseCount={poll.responseCount}
dateStart={poll.range.start} dateStart={poll.range.start}
dateEnd={poll.range.end} dateEnd={poll.range.end}
timeZone={poll.timeZone ?? undefined}
/> />
); );
})} })}
</div> </div>
); );
} }
function UpcomingEvents() {
const { data } = trpc.dashboard.getUpcoming.useQuery(undefined, {
suspense: true,
});
const localizeTime = useLocalizeTime();
if (!data) {
return <Spinner />;
}
return (
<div className="grid gap-4 md:grid-cols-3">
{data.map((event) => {
return (
<GridCard key={event.id}>
<GridCardHeader className="flex gap-2">
<div>
<div className="bg-primary-600 text-primary-100 inline-flex items-center justify-center rounded-md p-1.5">
<CalendarIcon className="size-4" />
</div>
</div>
<div className="min-w-0">
<h3 className="truncate font-semibold">{event.title}</h3>
<time
className="text-muted-foreground whitespace-nowrap"
dateTime={event.start.toISOString()}
>
{localizeTime(event.start, !event.timeZone).format("DD MMM")}
</time>
</div>
</GridCardHeader>
</GridCard>
);
})}
</div>
);
}

View file

@ -17,10 +17,10 @@ export default async function Layout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="flex h-screen flex-col bg-gray-50 pb-16 md:pb-0"> <div className="flex h-screen flex-col pb-16 md:pb-0">
<div <div
className={cn( className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-6 overflow-y-auto p-6 lg:flex", "fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-10 overflow-y-auto p-6 lg:flex",
)} )}
> >
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
@ -40,8 +40,8 @@ export default async function Layout({
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<Button variant="ghost" asChild> <Button variant="primary" asChild>
<Link href="/new"> <Link href="/new">
<Icon> <Icon>
<PlusIcon /> <PlusIcon />

View file

@ -32,3 +32,10 @@ export default async function Page({ params }: { params: Params }) {
</div> </div>
); );
} }
export async function generateMetadata({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
return {
title: t("home"),
};
}

View file

@ -12,7 +12,6 @@ import {
HomeIcon, HomeIcon,
LifeBuoyIcon, LifeBuoyIcon,
LogInIcon, LogInIcon,
PlusIcon,
Settings2Icon, Settings2Icon,
SparklesIcon, SparklesIcon,
} from "lucide-react"; } from "lucide-react";

View file

@ -1,13 +1,20 @@
import { cn } from "@rallly/ui";
export function GridCardFooter({ children }: React.PropsWithChildren) { export function GridCardFooter({ children }: React.PropsWithChildren) {
return <div className="relative z-10 mt-6">{children}</div>; return <div className="relative z-10 mt-6">{children}</div>;
} }
export function GridCardHeader({ children }: React.PropsWithChildren) { export function GridCardHeader({
return <div className="mb-4 flex items-center gap-2">{children}</div>; children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return <div className={cn("mb-4", className)}>{children}</div>;
} }
export const GridCard = ({ children }: { children: React.ReactNode }) => { export const GridCard = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="relative rounded-xl border bg-white p-4">{children}</div> <div className="relative rounded-lg border bg-white p-4 shadow-sm">
{children}
</div>
); );
}; };

View file

@ -23,6 +23,7 @@ import {
import { GroupPollIcon } from "@/components/group-poll-icon"; import { GroupPollIcon } from "@/components/group-poll-icon";
import { Pill, PillList } from "@/components/pill"; import { Pill, PillList } from "@/components/pill";
import { PollStatusLabel } from "@/components/poll-status"; import { PollStatusLabel } from "@/components/poll-status";
import { RandomGradientBar } from "@/components/random-gradient-bar";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useLocalizeTime } from "@/utils/dayjs"; import { useLocalizeTime } from "@/utils/dayjs";
import { getRange } from "@/utils/get-range"; import { getRange } from "@/utils/get-range";
@ -74,6 +75,7 @@ export function GroupPollCard({
responseCount, responseCount,
dateStart, dateStart,
dateEnd, dateEnd,
timeZone,
}: { }: {
pollId: string; pollId: string;
title: string; title: string;
@ -82,20 +84,18 @@ export function GroupPollCard({
dateStart: Date; dateStart: Date;
dateEnd: Date; dateEnd: Date;
status: PollStatus; status: PollStatus;
timeZone?: string;
}) { }) {
const localizeTime = useLocalizeTime(); const localizeTime = useLocalizeTime();
return ( return (
<GridCard key={pollId}> <GridCard key={pollId}>
<GridCardHeader> <GridCardHeader className="flex items-center gap-2">
<div> <div>
<GroupPollIcon size="xs" /> <GroupPollIcon size="xs" />
</div> </div>
<h3 className="truncate font-medium"> <h3 className="truncate font-medium">
<Link <Link className="hover:underline" href={`/poll/${pollId}`}>
className="hover:underline focus:text-gray-900"
href={`/poll/${pollId}`}
>
{title} {title}
</Link> </Link>
</h3> </h3>
@ -109,8 +109,8 @@ export function GroupPollCard({
<CalendarIcon /> <CalendarIcon />
</Icon> </Icon>
{getRange( {getRange(
localizeTime(dateStart).toDate(), localizeTime(dateStart, !timeZone).toDate(),
localizeTime(dateEnd).toDate(), localizeTime(dateEnd, !timeZone).toDate(),
)} )}
</Pill> </Pill>
<Pill> <Pill>

View file

@ -7,13 +7,7 @@ export function Heading({
className, className,
}: React.PropsWithChildren<{ className?: string }>) { }: React.PropsWithChildren<{ className?: string }>) {
return ( return (
<h1 <h1 className={cn("text-2xl font-semibold", heading.className, className)}>
className={cn(
"text-2xl font-semibold text-gray-900",
heading.className,
className,
)}
>
{children} {children}
</h1> </h1>
); );
@ -24,7 +18,7 @@ export function Subheading({
className, className,
}: React.PropsWithChildren<{ className?: string }>) { }: React.PropsWithChildren<{ className?: string }>) {
return ( return (
<h2 className={cn("text-lg font-semibold text-gray-900", className)}> <h2 className={cn("text-lg font-semibold", heading.className, className)}>
{children} {children}
</h2> </h2>
); );

View file

@ -50,7 +50,7 @@ const Layout = ({ children }: React.PropsWithChildren) => {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<div> <div>
<div className="sticky top-0 z-30 flex flex-col justify-between gap-x-4 gap-y-2.5 border-b bg-gray-100 p-3 sm:flex-row lg:items-center lg:px-5"> <div className="sticky top-0 z-30 flex flex-col justify-between gap-x-4 gap-y-2.5 p-3 sm:flex-row lg:items-center lg:px-5">
<div className="flex min-w-0 items-center gap-x-2.5"> <div className="flex min-w-0 items-center gap-x-2.5">
{pathname === pollLink ? ( {pathname === pollLink ? (
<Button variant="ghost" asChild> <Button variant="ghost" asChild>

View file

@ -4,7 +4,7 @@ export function PillList({ children }: React.PropsWithChildren) {
export function Pill({ children }: React.PropsWithChildren) { export function Pill({ children }: React.PropsWithChildren) {
return ( return (
<span className="text-muted-foreground inline-flex items-center gap-2 rounded-md border px-1.5 py-0.5 text-sm"> <span className="text-muted-foreground inline-flex items-center gap-2 rounded-md border bg-gray-50 px-1.5 py-0.5 text-sm">
{children} {children}
</span> </span>
); );

View file

@ -3,7 +3,7 @@ import { Trans } from "next-i18next";
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>
); );

View file

@ -1,6 +1,6 @@
import { Archivo } from "next/font/google"; import { Inter_Tight } from "next/font/google";
export const heading = Archivo({ export const heading = Inter_Tight({
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
}); });

View file

@ -7,7 +7,7 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply text-foreground bg-gray-50; @apply text-foreground bg-gray-100;
font-feature-settings: font-feature-settings:
"rlig" 1, "rlig" 1,
"calt" 1; "calt" 1;

View file

@ -29,6 +29,7 @@ export const dashboard = router({
title: true, title: true,
createdAt: true, createdAt: true,
status: true, status: true,
timeZone: true,
options: { options: {
select: { select: {
startTime: true, startTime: true,
@ -58,7 +59,48 @@ export const dashboard = router({
status: poll.status, status: poll.status,
responseCount: poll._count.participants, responseCount: poll._count.participants,
inviteLink: shortUrl(`/invite/${poll.id}`), inviteLink: shortUrl(`/invite/${poll.id}`),
timeZone: poll.timeZone,
}; };
}); });
}), }),
getUpcoming: possiblyPublicProcedure.query(async ({ ctx }) => {
const events = await prisma.event.findMany({
select: {
id: true,
title: true,
start: true,
duration: true,
poll: {
select: {
timeZone: true,
location: true,
participants: {
include: {
_count: true,
},
},
},
},
},
where: {
userId: ctx.user.id,
start: { gte: new Date() },
},
orderBy: [
{
start: "desc",
},
{
title: "asc",
},
],
take: 3,
});
return events.map(({ poll, ...event }) => ({
...event,
timeZone: poll?.timeZone || null,
location: poll?.location || null,
}));
}),
}); });

View file

@ -198,7 +198,8 @@ export const useDayjs = () => {
export const useLocalizeTime = () => { export const useLocalizeTime = () => {
const { timeZone } = useDayjs(); const { timeZone } = useDayjs();
return React.useCallback( return React.useCallback(
(date: Date) => dayjs(date).tz(timeZone), (date: Date, keepLocalTime = false) =>
keepLocalTime ? dayjs(date).utc() : dayjs(date).tz(timeZone),
[timeZone], [timeZone],
); );
}; };

View file

@ -9,6 +9,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
primary: "border-transparent bg-primary text-primary-50", primary: "border-transparent bg-primary text-primary-50",
secondary: "border-transparent bg-primary-50 text-primary",
default: "bg-gray-50 text-secondary-foreground", default: "bg-gray-50 text-secondary-foreground",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-destructive text-destructive-foreground",

View file

@ -8,26 +8,25 @@ import { cn } from "./lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
cn( cn(
"inline-flex border transition-colors font-medium disabled:pointer-events-none select-none disabled:opacity-50 items-center justify-center whitespace-nowrap border", "inline-flex border transition-colors font-medium disabled:pointer-events-none select-none disabled:opacity-50 items-center justify-center whitespace-nowrap border",
"focus-visible:ring-offset-input-background", "focus-visible:shadow-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary-400",
"focus:shadow-none",
), ),
{ {
variants: { variants: {
variant: { variant: {
primary: primary:
"border-primary-700 bg-primary disabled:bg-gray-400 disabled:border-transparent text-primary-foreground shadow-sm focus:bg-primary-500", "bg-primary disabled:bg-gray-400 disabled:border-transparent text-primary-foreground hover:bg-primary-700 active:bg-primary-800 shadow-sm",
destructive: destructive:
"bg-destructive shadow-sm text-destructive-foreground focus-visible:ring-offset-1 active:bg-destructive border-destructive hover:bg-destructive/90", "bg-destructive shadow-sm text-destructive-foreground active:bg-destructive border-destructive hover:bg-destructive/90",
default: default:
"ring-1 ring-inset ring-white/25 data-[state=open]:bg-gray-100 focus:border-gray-300 focus:bg-gray-200 hover:bg-gray-100 bg-gray-50", "data-[state=open]:bg-gray-100 hover:bg-gray-100 active:bg-gray-200 bg-gray-50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"border-transparent bg-transparent text-gray-800 hover:bg-gray-100 focus:bg-gray-200", "border-transparent bg-transparent bg-gray-50 text-gray-800 hover:bg-gray-100 active:bg-gray-200",
link: "underline-offset-4 border-transparent hover:underline text-primary", link: "underline-offset-4 border-transparent hover:underline text-primary",
}, },
size: { size: {
default: "h-9 px-2.5 pr-3 gap-x-2 text-sm rounded-md", default: "h-8 px-2 gap-x-1.5 text-sm rounded-md",
sm: "h-7 text-sm px-1.5 gap-x-1.5 rounded-md", sm: "h-7 text-sm px-1.5 gap-x-1.5 rounded-md",
lg: "h-11 text-base gap-x-3 px-4 rounded-md", lg: "h-11 text-base gap-x-3 px-4 rounded-md",
}, },