diff --git a/package.json b/package.json index a5729b41..9fc61975 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "lucide-react": "^0.288.0", "next": "13.5.6", "next-themes": "^0.2.1", + "node-fetch": "^3.3.2", "postcss": "8.4.31", "react": "18.2.0", "react-animate-height": "^3.2.2", diff --git a/src/app/(dashboard)/_components/recent-modules.tsx b/src/app/(dashboard)/_components/recent-modules.tsx index bd129711..fb6165d1 100644 --- a/src/app/(dashboard)/_components/recent-modules.tsx +++ b/src/app/(dashboard)/_components/recent-modules.tsx @@ -1,11 +1,10 @@ "use client"; import { type IconNames } from "@/components/icon"; -import { ScrollArea } from "@/components/scroll-area"; import { trpc } from "@/trpc/client"; import { cn } from "@/utils/cn"; import { Button } from "@nextui-org/react"; -import { type FC, Suspense, useState } from "react"; +import { Suspense, useState, type FC } from "react"; import AnimateHeight from "react-animate-height"; import { ErrorBoundary } from "react-error-boundary"; import { ModuleCard, ModuleCardSkeleton } from "./module-card"; @@ -69,21 +68,19 @@ export const RecentModules = () => { </Button> </div> <AnimateHeight height={isExpanded ? "auto" : 0}> - <ScrollArea> - <ErrorBoundary fallback={<div>Failed to load modules</div>}> - <Suspense - fallback={ - <ul className="flex gap-4 overflow-x-auto pb-4 pt-2"> - {new Array(8).fill(0).map((_, i) => ( - <ModuleCardSkeleton key={i} isLoaded={false} /> - ))} - </ul> - } - > - <RecentModulesInner /> - </Suspense> - </ErrorBoundary> - </ScrollArea> + <ErrorBoundary fallback={<div>Failed to load modules</div>}> + <Suspense + fallback={ + <ul className="flex gap-4 overflow-x-auto pb-4 pt-2"> + {new Array(8).fill(0).map((_, i) => ( + <ModuleCardSkeleton key={i} isLoaded={false} /> + ))} + </ul> + } + > + <RecentModulesInner /> + </Suspense> + </ErrorBoundary> </AnimateHeight> </div> ); diff --git a/src/app/(dashboard)/app/_components/notebook-table.tsx b/src/app/(dashboard)/app/_components/notebook-table.tsx new file mode 100644 index 00000000..51b27e2f --- /dev/null +++ b/src/app/(dashboard)/app/_components/notebook-table.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Icon } from "@/components/icon"; +import { + Chip, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "@nextui-org/react"; + +const fakeData = { + name: "Introduction to Information Security", + module: "CS759", + lastEdited: "2 Hours ago", +}; + +export const NotebookTable = () => { + return ( + <Table + isHeaderSticky + isVirtualized + fullWidth + removeWrapper + aria-label="Example static collection table" + > + <TableHeader className="border-b border-default-200"> + <TableColumn className="border-b border-default-200 bg-background dark:border-default-50"> + Name + </TableColumn> + <TableColumn className="border-b border-default-200 bg-background dark:border-default-50"> + Module + </TableColumn> + <TableColumn className="border-b border-default-200 bg-background dark:border-default-50"> + Last edited + </TableColumn> + </TableHeader> + <TableBody> + {Array.from({ length: 20 }).map((_, i) => ( + <TableRow key={i}> + <TableCell className="flex items-center gap-3"> + <Icon name="Lock" size={13} strokeWidth={2} /> {fakeData.name} + </TableCell> + <TableCell> + <Chip color="success" size="sm"> + {fakeData.module} + </Chip> + </TableCell> + <TableCell>{fakeData.lastEdited}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ); +}; diff --git a/src/app/(dashboard)/app/_components/weather.tsx b/src/app/(dashboard)/app/_components/weather.tsx new file mode 100644 index 00000000..a0a1a280 --- /dev/null +++ b/src/app/(dashboard)/app/_components/weather.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { type FC } from "react"; + +import { useCoords } from "@/hooks/useCoords"; +import { trpc } from "@/trpc/client"; +import { getFormattedWeatherDescription } from "@/utils/getFormattedWeatherDescription"; +import { Skeleton } from "@nextui-org/react"; + +export const WeatherData: FC = () => { + const coords = useCoords(); + const { data: weatherData, isLoading } = trpc.weather.getWeatherData.useQuery( + { + latitude: coords?.latitude ?? 0, + longitude: coords?.longitude ?? 0, + }, + { + enabled: !!coords, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + if (isLoading) { + return ( + <div> + <Skeleton className="mt-2 w-[258px] rounded-md"> + <div className="h-4" /> + </Skeleton> + <Skeleton className="mt-2 w-3/4 rounded-md"> + <div className="h-4" /> + </Skeleton> + </div> + ); + } + + if (!weatherData) return null; + + return ( + <span className="text-tiny text-default-500"> + You can expect a π high of {weatherData.main.temp_max.toFixed()}ΒΊ and a + π low of {weatherData.main.temp_min.toFixed()}ΒΊ with{" "} + {getFormattedWeatherDescription(weatherData.weather[0]?.description)}{" "} + today. + </span> + ); +}; diff --git a/src/app/(dashboard)/app/layout.tsx b/src/app/(dashboard)/app/layout.tsx index 46c025a4..20679ded 100644 --- a/src/app/(dashboard)/app/layout.tsx +++ b/src/app/(dashboard)/app/layout.tsx @@ -9,7 +9,7 @@ export default function AppLayout({ children }: PropsWithChildren) { return ( <TRPCReactProvider headers={headers()}> <SideMenuProvider> - <div className="relative flex min-h-screen w-screen overflow-hidden"> + <div className="relative flex h-screen w-screen overflow-hidden"> <DashboardSideMenu /> <MainDashboardWrapper>{children}</MainDashboardWrapper> diff --git a/src/app/(dashboard)/app/page.tsx b/src/app/(dashboard)/app/page.tsx index 9efbf8d0..67ef2c0c 100644 --- a/src/app/(dashboard)/app/page.tsx +++ b/src/app/(dashboard)/app/page.tsx @@ -1,8 +1,11 @@ import { TypographyH2 } from "@/components/TypographyH2"; import { TypographyP } from "@/components/TypographyP"; +import { formatDate } from "@/utils/formatDate"; import { currentUser } from "@clerk/nextjs"; import { type Metadata } from "next"; import { RecentModules } from "../_components/recent-modules"; +import { NotebookTable } from "./_components/notebook-table"; +import { WeatherData } from "./_components/weather"; export const metadata: Metadata = { title: "Dashboard Home", @@ -45,13 +48,28 @@ export default async function DashboardHome() { const message = `"${quote.content}" - ${quote.author}`; return ( - <div> - <TypographyH2>{greet}</TypographyH2> - <TypographyP className="max-w-prose opacity-75 [&:not(:first-child)]:mt-2"> - {message} - </TypographyP> + <div className="grid h-full grid-cols-[1fr_auto] gap-6 overflow-hidden pb-8"> + <div className="flex flex-col"> + <TypographyH2>{greet}</TypographyH2> + <TypographyP className="max-w-prose opacity-75 [&:not(:first-child)]:mt-2"> + {message} + </TypographyP> - <RecentModules /> + <RecentModules /> + + <h2 className="mb-2 mt-6 text-large font-semibold">Notebooks</h2> + <div className="relative flex h-full flex-col overflow-y-auto"> + <div className="flex-grow-1 flex-basis-auto absolute h-full w-full flex-shrink-0 overflow-y-scroll rounded-xl border border-default-200 dark:border-default-50"> + <NotebookTable /> + </div> + </div> + </div> + + <div className="mt-8 w-[310px] rounded-2xl border border-default-200 p-6 dark:border-default-50"> + <span className="text-tiny text-default-500">It's</span> + <h3 className="font-medium text-amber-500">{formatDate(new Date())}</h3> + <WeatherData /> + </div> </div> ); } diff --git a/src/env.mjs b/src/env.mjs index 8d3a2ed9..5639d86f 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -12,6 +12,7 @@ export const env = createEnv({ UPSTASH_REDIS_REST_URL: z.string().min(1), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), CLERK_SECRET_KEY: z.string().min(1), + OPENWEATHER_API_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), @@ -25,6 +26,7 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + OPENWEATHER_API_KEY: process.env.OPENWEATHER_API_KEY, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, }); diff --git a/src/hooks/useCoords.ts b/src/hooks/useCoords.ts new file mode 100644 index 00000000..9cdcb05b --- /dev/null +++ b/src/hooks/useCoords.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export const useCoords = () => { + const [coords, setCoords] = useState<{ + latitude: number; + longitude: number; + } | null>(null); + + useEffect(() => { + navigator.geolocation.getCurrentPosition((position) => { + setCoords(position.coords); + }); + }, []); + + return coords; +}; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index e0649c9f..dbc936a2 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,9 +1,11 @@ import { feedbackRouter } from "./routers/feedback"; import { moduleRouter } from "./routers/module"; import { waitlistRouter } from "./routers/waitlist"; +import { weatherRouter } from "./routers/weather"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ + weather: weatherRouter, waitlist: waitlistRouter, feedback: feedbackRouter, module: moduleRouter, diff --git a/src/server/api/routers/weather.ts b/src/server/api/routers/weather.ts new file mode 100644 index 00000000..4580836c --- /dev/null +++ b/src/server/api/routers/weather.ts @@ -0,0 +1,43 @@ +import { TRPCError } from "@trpc/server"; +import fetch from "node-fetch"; +import { z } from "zod"; + +import { env } from "@/env.mjs"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +type WeatherData = { + main: { + temp_max: number; + temp_min: number; + }; + weather: { + description: string; + }[]; +}; + +export const weatherRouter = createTRPCRouter({ + getWeatherData: protectedProcedure + .input( + z.object({ + latitude: z.number(), + longitude: z.number(), + }), + ) + .query(async ({ input }) => { + const { latitude, longitude } = input; + + const response = await fetch( + `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${env.OPENWEATHER_API_KEY}&units=metric`, + ); + + if (!response.ok) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch weather data", + }); + } + + return response.json() as Promise<WeatherData>; + }), +}); diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts new file mode 100644 index 00000000..692aa225 --- /dev/null +++ b/src/utils/formatDate.ts @@ -0,0 +1,42 @@ +export function formatDate(date: Date) { + const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + const day = date.getDate(); + let daySuffix = "th"; + + if (day === 1 || day === 21 || day === 31) { + daySuffix = "st"; + } else if (day === 2 || day === 22) { + daySuffix = "nd"; + } else if (day === 3 || day === 23) { + daySuffix = "rd"; + } + + const dayOfWeek = daysOfWeek[date.getDay()]; + const month = months[date.getMonth()]; + const year = date.getFullYear(); + + return `${dayOfWeek}, ${day}${daySuffix} of ${month} ${year}`; +} diff --git a/src/utils/getFormattedWeatherDescription.ts b/src/utils/getFormattedWeatherDescription.ts new file mode 100644 index 00000000..2fa4cdc7 --- /dev/null +++ b/src/utils/getFormattedWeatherDescription.ts @@ -0,0 +1,48 @@ +export const getFormattedWeatherDescription = ( + condition: string | undefined, +) => { + if (!condition) return; + + const weatherToEmoji: Record<string, string> = { + "clear sky": "βοΈ", + "few clouds": "π€οΈ", + "scattered clouds": "β ", + "broken clouds": "βοΈ", + "overcast clouds": "βοΈ", + rain: "π§οΈ", + "light rain": "π§οΈ", + "moderate rain": "π§οΈ", + "heavy intensity rain": "π§οΈ", + "very heavy rain": "π§οΈ", + "extreme rain": "π§οΈ", + "freezing rain": "π¨οΈ", + "light intensity shower rain": "π¦οΈ", + "shower rain": "π§οΈ", + "heavy intensity shower rain": "π§οΈ", + "ragged shower rain": "π§οΈ", + "light snow": "βοΈ", + snow: "βοΈ", + "heavy snow": "βοΈ", + sleet: "π¨οΈ", + "shower sleet": "π¨οΈ", + "light rain and snow": "π¨οΈ", + "rain and snow": "π¨οΈ", + "light shower snow": "π¨οΈ", + "shower snow": "π¨οΈ", + "heavy shower snow": "π¨οΈ", + mist: "π«οΈ", + smoke: "π«οΈ", + haze: "π«οΈ", + "sand/ dust whirls": "πͺοΈ", + fog: "π«οΈ", + sand: "π«οΈ", + dust: "π«οΈ", + "volcanic ash": "π«οΈ", + squalls: "π¬οΈ", + tornado: "πͺοΈ", + clear: "βοΈ", + clouds: "βοΈ", + }; + + return `${weatherToEmoji[condition.toLowerCase()] ?? ""} ${condition}`; +};