Add loading pages (#978)

This commit is contained in:
Luke Vella 2024-01-13 18:45:01 +07:00 committed by GitHub
parent a1bac0c986
commit 0a2e3e3532
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 402 additions and 253 deletions

View file

@ -0,0 +1,53 @@
import { Button } from "@rallly/ui/button";
import { PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
export default async function Layout({
params,
children,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex justify-between items-center gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<Link href="/new">
<PenBoxIcon className="w-4 text-muted-foreground h-4" />
<span className="hidden sm:inline">
<Trans t={t} i18nKey="newPoll" />
</span>
</Link>
</Button>
</div>
</PageHeader>
<PageContent>{children}</PageContent>
</PageContainer>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("polls"),
};
}

View file

@ -0,0 +1,31 @@
import { Skeleton } from "@/components/skeleton";
function Row() {
return (
<div className="flex first:pt-0 py-4 items-center gap-x-4">
<div className="grow">
<Skeleton className="w-48 h-5 mb-2" />
<Skeleton className="w-24 h-4" />
</div>
<div className="pr-8">
<Skeleton className="w-24 h-4" />
</div>
<div className="pr-8">
<Skeleton className="w-24 h-4" />
</div>
<div className="pr-8">
<Skeleton className="w-12 h-4" />
</div>
</div>
);
}
export default function Loader() {
return (
<div className="divide-y divide-gray-100">
<Row />
<Row />
<Row />
<Row />
</div>
);
}

View file

@ -1,44 +1,9 @@
import { Button } from "@rallly/ui/button";
import { PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { PollsList } from "./polls-list";
export default async function Page({ params }: { params: { locale: string } }) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex justify-between items-center gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<Link href="/new">
<PenBoxIcon className="w-4 text-muted-foreground h-4" />
<span className="hidden sm:inline">
<Trans t={t} i18nKey="newPoll" />
</span>
</Link>
</Button>
</div>
</PageHeader>
<PageContent>
<div className="space-y-6">
<PollsList />
</div>
</PageContent>
</PageContainer>
);
export default async function Page() {
return <PollsList />;
}
export async function generateMetadata({

View file

@ -15,6 +15,8 @@ import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
import { trpc } from "@/utils/trpc/client";
import Loader from "./loading";
const EmptyState = () => {
return (
<div className="py-24">
@ -186,7 +188,10 @@ export function PollsList() {
[adjustTimeZone],
);
if (!data) return null;
if (!data) {
// return a table using <Skeleton /> components
return <Loader />;
}
if (data.total === 0) return <EmptyState />;

View file

@ -54,7 +54,7 @@ export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) {
if (!success) {
throw new Error("Failed to authenticate user");
} else {
queryClient.invalidate();
await queryClient.invalidate();
const s = await session.update();
if (s?.user) {
posthog?.identify(s.user.id, {

View file

@ -0,0 +1,106 @@
"use client";
import { Button } from "@rallly/ui/button";
import { ArrowUpLeftIcon } from "lucide-react";
import Head from "next/head";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";
import { PageHeader } from "@/app/components/page-layout";
import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
import Loader from "./loading";
const Prefetch = ({ children }: React.PropsWithChildren) => {
const searchParams = useSearchParams();
const token = searchParams?.get("token") as string;
const params = useParams<{ urlId: string }>();
const urlId = params?.urlId as string;
const { data: permission } = trpc.auth.getUserPermission.useQuery(
{ token },
{
enabled: !!token,
},
);
const { data: poll, error } = trpc.polls.get.useQuery(
{ urlId },
{
retry: false,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId: urlId,
});
if (error?.data?.code === "NOT_FOUND") {
return <div>Not found</div>;
}
if (!poll || !participants) {
return <Loader />;
}
return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}>
<Head>
<title>{poll.title}</title>
</Head>
{children}
</PermissionsContext.Provider>
);
};
const GoToApp = () => {
const poll = usePoll();
const { user } = useUser();
return (
<PageHeader variant="ghost">
<div className="flex justify-between">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="manage" />
</Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div>
</PageHeader>
);
};
export function InvitePage() {
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>
<GoToApp />
<div className="lg:px-6 lg:py-5 p-3">
<div className="max-w-4xl mx-auto">
<div className="-mx-1">
<Poll />
</div>
</div>
</div>
</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
);
}

View file

@ -1,66 +0,0 @@
import { prisma } from "@rallly/database";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslation } from "@/app/i18n";
import { absoluteUrl } from "@/utils/absolute-url";
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export async function generateMetadata({
params: { urlId, locale },
}: {
params: {
urlId: string;
locale: string;
};
}) {
const poll = await prisma.poll.findUnique({
where: {
id: urlId as string,
},
select: {
id: true,
title: true,
user: {
select: {
name: true,
},
},
},
});
const { t } = await getTranslation(locale);
if (!poll) {
return notFound();
}
const { title, id, user } = poll;
const author = user?.name || t("guest");
return {
title,
metadataBase: new URL(absoluteUrl()),
openGraph: {
title,
description: `By ${author}`,
url: `/invite/${id}`,
images: [
{
url: `${absoluteUrl("/api/og-image-poll", {
title,
author,
})}`,
width: 1200,
height: 630,
alt: title,
type: "image/png",
},
],
},
} satisfies Metadata;
}

View file

@ -0,0 +1,24 @@
import {
PageContainer,
PageContent,
PageHeader,
} from "@/app/components/page-layout";
import { Skeleton } from "@/components/skeleton";
export default function Loading() {
return (
<PageContainer>
<PageHeader className="justify-end flex" variant="ghost">
<Skeleton className="w-32 h-9" />
</PageHeader>
<PageContent>
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-72 w-full" />
<Skeleton className="h-96 w-full" />
<hr />
<Skeleton className="h-64 w-full" />
</div>
</PageContent>
</PageContainer>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import { Button } from "@rallly/ui/button";
import { ArrowUpLeftIcon } from "lucide-react";
import Link from "next/link";
import { PageHeader } from "@/app/components/page-layout";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
export const Nav = () => {
const poll = usePoll();
const { user } = useUser();
return (
<PageHeader variant="ghost">
<div className="flex justify-between">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="manage" />
</Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div>
</PageHeader>
);
};

View file

@ -1,104 +1,72 @@
"use client";
import { Button } from "@rallly/ui/button";
import { ArrowUpLeftIcon } from "lucide-react";
import Head from "next/head";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";
import { prisma } from "@rallly/database";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/app/components/page-layout";
import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
const Prefetch = ({ children }: React.PropsWithChildren) => {
const searchParams = useSearchParams();
const token = searchParams?.get("token") as string;
const params = useParams<{ urlId: string }>();
const urlId = params?.urlId as string;
const { data: permission } = trpc.auth.getUserPermission.useQuery(
{ token },
{
enabled: !!token,
},
);
const { data: poll, error } = trpc.polls.get.useQuery(
{ urlId },
{
retry: false,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId: urlId,
});
if (error?.data?.code === "NOT_FOUND") {
return <div>Not found</div>;
}
if (!poll || !participants) {
return null;
}
import { InvitePage } from "@/app/[locale]/invite/[urlId]/invite-page";
import { PageContainer } from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { absoluteUrl } from "@/utils/absolute-url";
export default async function Page() {
return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}>
<Head>
<title>{poll.title}</title>
</Head>
{children}
</PermissionsContext.Provider>
);
};
const GoToApp = () => {
const poll = usePoll();
const { user } = useUser();
return (
<PageHeader variant="ghost">
<div className="flex justify-between">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="manage" />
</Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div>
</PageHeader>
);
};
export default function InvitePage() {
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>
<GoToApp />
<div className="lg:px-6 lg:py-5 p-3">
<div className="max-w-4xl mx-auto">
<div className="-mx-1">
<Poll />
</div>
</div>
</div>
</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
<PageContainer>
<InvitePage />
</PageContainer>
);
}
export async function generateMetadata({
params: { urlId, locale },
}: {
params: {
urlId: string;
locale: string;
};
}) {
const poll = await prisma.poll.findUnique({
where: {
id: urlId as string,
},
select: {
id: true,
title: true,
user: {
select: {
name: true,
},
},
},
});
const { t } = await getTranslation(locale);
if (!poll) {
return notFound();
}
const { title, id, user } = poll;
const author = user?.name || t("guest");
return {
title,
metadataBase: new URL(absoluteUrl()),
openGraph: {
title,
description: `By ${author}`,
url: `/invite/${id}`,
images: [
{
url: `${absoluteUrl("/api/og-image-poll", {
title,
author,
})}`,
width: 1200,
height: 630,
alt: title,
type: "image/png",
},
],
},
} satisfies Metadata;
}

View file

@ -0,0 +1,45 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { InfoIcon } from "lucide-react";
import { Trans } from "next-i18next";
import { LoginLink } from "@/components/login-link";
import { RegisterLink } from "@/components/register-link";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
export const GuestPollAlert = () => {
const poll = usePoll();
const { user } = useUser();
if (poll.user) {
return null;
}
if (!user.isGuest) {
return null;
}
return (
<Alert icon={InfoIcon}>
<AlertTitle className="mb-1 text-sm font-medium tracking-normal">
<Trans
i18nKey="guestPollAlertTitle"
defaults="Your administrator rights can be lost if you clear your cookies"
/>
</AlertTitle>
<AlertDescription className="text-sm">
<Trans
i18nKey="guestPollAlertDescription"
defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
components={[
<RegisterLink
className="hover:text-gray-800 underline"
key="register"
/>,
<LoginLink className="hover:text-gray-800 underline" key="login" />,
]}
/>
</AlertDescription>
</Alert>
);
};

View file

@ -1,3 +0,0 @@
export default function Loading() {
return null;
}

View file

@ -1,52 +1,10 @@
"use client";
import { cn } from "@rallly/ui";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { InfoIcon } from "lucide-react";
import { Trans } from "next-i18next";
import { LoginLink } from "@/components/login-link";
import { Poll } from "@/components/poll";
import { RegisterLink } from "@/components/register-link";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
const GuestPollAlert = () => {
const poll = usePoll();
const { user } = useUser();
import { GuestPollAlert } from "./guest-poll-alert";
if (poll.user) {
return null;
}
if (!user.isGuest) {
return null;
}
return (
<Alert icon={InfoIcon}>
<AlertTitle className="mb-1 text-sm font-medium tracking-normal">
<Trans
i18nKey="guestPollAlertTitle"
defaults="Your administrator rights can be lost if you clear your cookies"
/>
</AlertTitle>
<AlertDescription className="text-sm">
<Trans
i18nKey="guestPollAlertDescription"
defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
components={[
<RegisterLink
className="hover:text-gray-800 underline"
key="register"
/>,
<LoginLink className="hover:text-gray-800 underline" key="login" />,
]}
/>
</AlertDescription>
</Alert>
);
};
export default function Page() {
export default async function Page() {
return (
<div className={cn("max-w-4xl space-y-4 mx-auto")}>
<div className="-mx-1 space-y-3 sm:space-y-6">

View file

@ -0,0 +1,24 @@
import {
PageContainer,
PageContent,
PageHeader,
} from "@/app/components/page-layout";
import { Skeleton } from "@/components/skeleton";
export default function Loading() {
return (
<PageContainer>
<PageHeader>
<Skeleton className="w-32 h-9" />
</PageHeader>
<PageContent>
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-72 w-full" />
<Skeleton className="h-96 w-full" />
<hr />
<Skeleton className="h-64 w-full" />
</div>
</PageContent>
</PageContainer>
);
}

View file

@ -53,5 +53,5 @@ export function PageContent({
children?: React.ReactNode;
className?: string;
}) {
return <div className={cn("lg:p-6 p-4", className)}>{children}</div>;
return <div className={cn("lg:px-6 lg:py-5 p-4", className)}>{children}</div>;
}

View file

@ -23,6 +23,7 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import React from "react";
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
import { LogoutButton } from "@/app/components/logout-button";
import {
PageContainer,
@ -259,7 +260,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
const watchers = trpc.polls.getWatchers.useQuery({ pollId: urlId });
if (!poll.data || !watchers.data || !participants.data) {
return null;
return <Loader />;
}
return <>{children}</>;

View file

@ -1,3 +1,4 @@
"use client";
import { cn } from "@rallly/ui";
import Link from "next/link";
import React from "react";