diff --git a/src/components/molecules/Header.tsx b/src/components/molecules/Header.tsx index bb3924a..d8ff757 100644 --- a/src/components/molecules/Header.tsx +++ b/src/components/molecules/Header.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import type { FC } from 'react'; +import toast from 'react-hot-toast'; import DarkModeSwitch from '../atoms/DarkModeSwitch'; import Logo from '../atoms/Logo'; import UserDropdown from '../atoms/UserDropdown'; @@ -12,15 +13,19 @@ const Header: FC = () => {
- - Ideas Archive - - + Leaderboard - - Contact + + Ideas Archive + toast.success('TODO - Coming soon!')} + > + Contact +
diff --git a/src/env.mjs b/src/env.mjs index 70abbc2..312895b 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -19,7 +19,11 @@ const server = z.object({ OPENAI_KEY: z.string(), SENTRY_DSN: z.string().optional(), RECAPTCHA_SECRET_KEY: z.string(), - ENABLE_OPENAI: z.string().optional() + ENABLE_OPENAI: z.string().optional(), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + TWITTER_ID: z.string(), + TWITTER_SECRET: z.string() }); /** @@ -55,7 +59,11 @@ const processEnv = { NEXT_PUBLIC_RECAPTCHA_SITE_KEY: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, - ENABLE_OPENAI: process.env.ENABLE_OPENAI + ENABLE_OPENAI: process.env.ENABLE_OPENAI, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + TWITTER_ID: process.env.TWITTER_ID, + TWITTER_SECRET: process.env.TWITTER_SECRET }; // Don't touch the part below diff --git a/src/pages/leaderboard.tsx b/src/pages/leaderboard.tsx new file mode 100644 index 0000000..9a93c1c --- /dev/null +++ b/src/pages/leaderboard.tsx @@ -0,0 +1,66 @@ +import Hero from '@/components/molecules/Hero'; +import { APP_NAME } from '@/config/app'; +import { api } from '@/utils/api'; +import { ArrowTopRightOnSquareIcon, BookmarkIcon, StarIcon } from '@heroicons/react/24/outline'; +import { type NextPage } from 'next'; +import { NextSeo } from 'next-seo'; +import Link from 'next/link'; +const Leaderboard: NextPage = () => { + const { isLoading, data } = api.ideas.leaderboard.useQuery(); + + return ( + <> + + +

Leaderboard

+

+ Top 100 ideas generated by users on the website +

+
+
+ {isLoading &&

Loading...

} + {!isLoading && ( + + + + + + + + + + + + {data && + data.ideas.map((idea, index) => { + return ( + + + + + + + + + ); + })} + +
RankIdea + + + +
{index + 1}{idea.title}{idea.rating ?? ''}{idea.saveCount} + + + +
+ )} +
+ + ); +}; + +export default Leaderboard; diff --git a/src/server/api/routers/ideas.ts b/src/server/api/routers/ideas.ts index 90e725f..bb92346 100644 --- a/src/server/api/routers/ideas.ts +++ b/src/server/api/routers/ideas.ts @@ -101,6 +101,52 @@ export const ideasRouter = createTRPCRouter({ }; }), + leaderboard: publicProcedure.query(async ({ ctx }) => { + const ideas = await ctx.prisma.idea.findMany({ + include: { + author: true, + components: { + include: { + component: true + } + }, + ratings: true, + savedBy: true + } + }); + + // Sort by average rating and then by number of saves + + ideas.sort((a, b) => { + const aRating = (a.ratings?.reduce((acc, rating) => acc + rating.rating, 0) ?? 0) / (a.ratings.length || 1); + const bRating = (b.ratings?.reduce((acc, rating) => acc + rating.rating, 0) ?? 0) / (b.ratings.length || 1); + const aSaves = a.savedBy.length; + const bSaves = b.savedBy.length; + + if (aRating > bRating) { + return -1; + } + + if (aRating < bRating) { + return 1; + } + + if (aSaves > bSaves) { + return -1; + } + + if (aSaves < bSaves) { + return 1; + } + + return 0; + }); + + return { + ideas: ideas.slice(0, 100).map((idea) => ideaToIdeaDto(idea, false, false)) + }; + }), + getOne: publicProcedure .input( diff --git a/src/server/auth.ts b/src/server/auth.ts index f2f2377..95196de 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -7,8 +7,9 @@ import { type GetServerSidePropsContext } from 'next'; import { getServerSession, type DefaultSession, type NextAuthOptions } from 'next-auth'; import DiscordProvider from 'next-auth/providers/discord'; import GitHubProvider from 'next-auth/providers/github'; +import GoogleProvider from 'next-auth/providers/google'; +import TwitterProvider from 'next-auth/providers/twitter'; import CreditsService from './services/credits'; - /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * object and keep type safety. @@ -122,6 +123,14 @@ export const authOptions: NextAuthOptions = { DiscordProvider({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET + }), + GoogleProvider({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET + }), + TwitterProvider({ + clientId: env.TWITTER_ID, + clientSecret: env.TWITTER_SECRET }) ] }; diff --git a/src/utils/ideas.ts b/src/utils/ideas.ts index 89cc2a5..650796d 100644 --- a/src/utils/ideas.ts +++ b/src/utils/ideas.ts @@ -8,6 +8,7 @@ export const ideaToIdeaDto = ( component: Component; })[]; ratings: Rating[]; + savedBy?: User[]; }, saved?: boolean, rated?: boolean @@ -33,7 +34,8 @@ export const ideaToIdeaDto = ( }, rating: averageRating, ratedByThisUser: rated ?? false, - ratingsCount: idea.ratings?.length ?? 0 + ratingsCount: idea.ratings?.length ?? 0, + saveCount: idea.savedBy?.length ?? 0 }; }; diff --git a/src/validation/generate.ts b/src/validation/generate.ts index 6e53f2d..f97b125 100644 --- a/src/validation/generate.ts +++ b/src/validation/generate.ts @@ -25,5 +25,6 @@ export const generateOutputSchema = z.object({ saved: z.boolean(), rating: z.number().nullish(), ratingsCount: z.number(), - ratedByThisUser: z.boolean() + ratedByThisUser: z.boolean(), + saveCount: z.number() });