From 5c76e4c19d47fcc686167f94ca8718c90df4196b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Pyykk=C3=B6?= Date: Fri, 19 May 2023 16:09:51 +0300 Subject: [PATCH] Refactor new layout: responsive course card (#1188) * make the whole course card one grid with named areas * refactor coursecard to flexbox and container queries * fix study module padding; use isomorphic layout effect * add tooltip with other languages; some skeletons; fix hydration issue --- frontend/components/BorderedSection.tsx | 3 +- frontend/components/Breadcrumbs.tsx | 4 +- frontend/components/CourseImage.tsx | 2 +- .../Common/Fields/ControlledCheckbox.tsx | 5 +- .../Common/Fields/ControlledTextField.tsx | 4 +- .../Course/CourseInstanceLanguageSelector.tsx | 4 +- .../Dashboard/StudyModules/ModuleCard.tsx | 4 +- .../Users/Summary/Exercise/common.tsx | 3 +- frontend/components/Footer.tsx | 6 +- .../NewLayout/Common/Card/index.tsx | 9 +- .../NewLayout/Courses/CourseCard.tsx | 665 +++++++++++------- .../NewLayout/Courses/CourseGrid.tsx | 66 +- .../NewLayout/Courses/TagSelectButtons.tsx | 77 +- .../components/NewLayout/Courses/Tags.tsx | 163 +++++ .../components/NewLayout/Courses/common.tsx | 29 + .../NewLayout/Modules/StudyModuleHero.tsx | 10 +- .../NewLayout/Modules/StudyModuleListItem.tsx | 67 +- .../Navigation/DesktopNavigationMenu.tsx | 15 +- frontend/components/OutboundLink.tsx | 2 +- frontend/components/Tooltip.tsx | 96 ++- frontend/next.config.js | 2 +- frontend/pages/_app.tsx | 14 +- frontend/src/newTheme/index.tsx | 3 +- frontend/src/theme/index.tsx | 1 + frontend/translations/common/en.json | 3 +- frontend/translations/common/fi.json | 3 +- frontend/translations/common/se.json | 3 +- frontend/types/mui.d.ts | 1 + 28 files changed, 876 insertions(+), 388 deletions(-) create mode 100644 frontend/components/NewLayout/Courses/Tags.tsx create mode 100644 frontend/components/NewLayout/Courses/common.tsx diff --git a/frontend/components/BorderedSection.tsx b/frontend/components/BorderedSection.tsx index 282425c0e..a14a1c3cf 100644 --- a/frontend/components/BorderedSection.tsx +++ b/frontend/components/BorderedSection.tsx @@ -1,5 +1,6 @@ import React, { PropsWithChildren } from "react" +import { PropsOf } from "@emotion/react" import { styled } from "@mui/material/styles" const BorderedSectionBase = styled("div")` @@ -52,8 +53,6 @@ const BorderedSectionBase = styled("div")` } ` -type PropsOf = T extends React.ComponentType ? P : never - function BorderedSection({ title, children, diff --git a/frontend/components/Breadcrumbs.tsx b/frontend/components/Breadcrumbs.tsx index 671b6c300..159e52a88 100644 --- a/frontend/components/Breadcrumbs.tsx +++ b/frontend/components/Breadcrumbs.tsx @@ -78,11 +78,11 @@ const BreadcrumbLinkBase = css` ` const BreadcrumbLink = styled(Link)` - ${BreadcrumbLinkBase} + ${BreadcrumbLinkBase.styles} ` const BreadcrumbNonLink = styled("div")` - ${BreadcrumbLinkBase} + ${BreadcrumbLinkBase.styles} ` const BreadcrumbComponent: React.FunctionComponent = ({ diff --git a/frontend/components/CourseImage.tsx b/frontend/components/CourseImage.tsx index 23a1c35d9..6dbdfd396 100644 --- a/frontend/components/CourseImage.tsx +++ b/frontend/components/CourseImage.tsx @@ -16,7 +16,7 @@ const ImageComponentBase = css` ` const PlaceholderComponent = styled("div")` - ${ImageComponentBase} + ${ImageComponentBase.styles} background-color: #F0F0F0; display: flex; justify-content: center; diff --git a/frontend/components/Dashboard/Editor/Common/Fields/ControlledCheckbox.tsx b/frontend/components/Dashboard/Editor/Common/Fields/ControlledCheckbox.tsx index c75a56f07..a0457a3c9 100644 --- a/frontend/components/Dashboard/Editor/Common/Fields/ControlledCheckbox.tsx +++ b/frontend/components/Dashboard/Editor/Common/Fields/ControlledCheckbox.tsx @@ -6,7 +6,7 @@ import { Checkbox, FormControlLabel } from "@mui/material" import { styled } from "@mui/material/styles" import { ControlledFieldProps } from "." -import { InfoTooltipWithLabel } from "/components/Tooltip" +import { InfoTooltip } from "/components/Tooltip" import { useAnchor } from "/hooks/useAnchors" const AlignedSpan = styled("span")` @@ -14,6 +14,7 @@ const AlignedSpan = styled("span")` align-items: flex-end; gap: 0.5rem; ` + interface ControlledCheckboxProps< TFieldValues extends FieldValues = FieldValues, > extends ControlledFieldProps { @@ -26,7 +27,7 @@ const ControlledCheckboxLabel = React.memo( return ( {label} - {tip && } + {tip && } ) }, diff --git a/frontend/components/Dashboard/Editor/Common/Fields/ControlledTextField.tsx b/frontend/components/Dashboard/Editor/Common/Fields/ControlledTextField.tsx index 4e2bd744d..9875d91a7 100644 --- a/frontend/components/Dashboard/Editor/Common/Fields/ControlledTextField.tsx +++ b/frontend/components/Dashboard/Editor/Common/Fields/ControlledTextField.tsx @@ -15,7 +15,7 @@ import { ControlledFieldProps } from "." import { pulseAnimation } from ".." import { useCourseEditorData } from "../../Course/CourseEditorDataContext" import RevertButton from "/components/RevertButton" -import { InfoTooltipWithLabel } from "/components/Tooltip" +import { InfoTooltip } from "/components/Tooltip" import { useAnchor } from "/hooks/useAnchors" const TextFieldContainer = styled("div")` @@ -88,7 +88,7 @@ function ControlledTextFieldComponent< onRevert={onRevert} /> )} - {tip && } + {tip && } ) : null, }), diff --git a/frontend/components/Dashboard/Editor/Course/CourseInstanceLanguageSelector.tsx b/frontend/components/Dashboard/Editor/Course/CourseInstanceLanguageSelector.tsx index 7ec3e3300..b1b8f9dc4 100644 --- a/frontend/components/Dashboard/Editor/Course/CourseInstanceLanguageSelector.tsx +++ b/frontend/components/Dashboard/Editor/Course/CourseInstanceLanguageSelector.tsx @@ -14,7 +14,7 @@ import { useEventCallback } from "@mui/material/utils" import { useCourseEditorData } from "./CourseEditorDataContext" import { CourseFormValues } from "./types" import RevertButton from "/components/RevertButton" -import { InfoTooltipWithLabel } from "/components/Tooltip" +import { InfoTooltip } from "/components/Tooltip" import { useAnchor } from "/hooks/useAnchors" import { useTranslator } from "/hooks/useTranslator" import CommonTranslations from "/translations/common" @@ -134,7 +134,7 @@ function CourseInstanceLanguageSelector( } onRevert={onRevert} /> - diff --git a/frontend/components/Dashboard/StudyModules/ModuleCard.tsx b/frontend/components/Dashboard/StudyModules/ModuleCard.tsx index 803a57e46..a49848b0b 100644 --- a/frontend/components/Dashboard/StudyModules/ModuleCard.tsx +++ b/frontend/components/Dashboard/StudyModules/ModuleCard.tsx @@ -40,12 +40,12 @@ const ImageBackgroundBase = css` ` const ImageBackground = styled(LoaderImage)` - ${ImageBackgroundBase}; + ${ImageBackgroundBase.styles}; object-fit: cover; ` const ImageBackgroundSkeleton = styled("span")` - ${ImageBackgroundBase} + ${ImageBackgroundBase.styles} ` const IconBackground = styled("span")` diff --git a/frontend/components/Dashboard/Users/Summary/Exercise/common.tsx b/frontend/components/Dashboard/Users/Summary/Exercise/common.tsx index 7e9927d57..509a658ba 100644 --- a/frontend/components/Dashboard/Users/Summary/Exercise/common.tsx +++ b/frontend/components/Dashboard/Users/Summary/Exercise/common.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren, useMemo } from "react" import { merge } from "lodash" import { MaterialReactTableProps, MRT_ColumnDef } from "material-react-table" +import { PropsOf } from "@emotion/react" import CheckIcon from "@fortawesome/fontawesome-free/svgs/solid/check.svg?icon" import XMarkIcon from "@fortawesome/fontawesome-free/svgs/solid/xmark.svg?icon" import HelpIcon from "@mui/icons-material/HelpOutlineOutlined" @@ -82,8 +83,6 @@ export const NarrowCellBase = styled("div")` padding-right: 1rem; ` -type PropsOf = T extends React.ComponentType ? P : never - const TooltipWrapper = styled("div")` margin-left: auto; padding: 0 0.5rem; diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx index f6d827467..5b27e784c 100644 --- a/frontend/components/Footer.tsx +++ b/frontend/components/Footer.tsx @@ -19,15 +19,15 @@ const IconBaseStyle = css` ` const TwitterIcon = styled(Twitter)` - ${IconBaseStyle}; + ${IconBaseStyle.styles}; ` const FacebookIcon = styled(Facebook)` - ${IconBaseStyle}; + ${IconBaseStyle.styles}; ` const YoutubeIcon = styled(Youtube)` - ${IconBaseStyle}; + ${IconBaseStyle.styles}; ` const FooterBar = styled("footer")` diff --git a/frontend/components/NewLayout/Common/Card/index.tsx b/frontend/components/NewLayout/Common/Card/index.tsx index 252613841..319ca7938 100644 --- a/frontend/components/NewLayout/Common/Card/index.tsx +++ b/frontend/components/NewLayout/Common/Card/index.tsx @@ -1,8 +1,7 @@ import Image from "next/image" import { Typography, TypographyProps } from "@mui/material" -import { css } from "@mui/material/styles" -import { styled } from "@mui/material/styles" +import { css, styled } from "@mui/material/styles" export const CardWrapper = styled("div")` border-radius: 4px; @@ -87,7 +86,7 @@ export const CardHeaderBackground = styled("span", { typeof prop !== "string" || !["color", "image", "hue", "brightness"].includes(prop), })` - ${CommonHeaderBackground}; + ${CommonHeaderBackground.styles}; background-size: cover; ${({ color, image }) => { if (!color && !image) { @@ -114,7 +113,7 @@ export const CardImageHeaderBackground = styled(Image, { shouldForwardProp: (prop) => typeof prop !== "string" || !["color", "hue", "brightness"].includes(prop), })>` - ${CommonHeaderBackground}; + ${CommonHeaderBackground.styles}; width: 100%; height: 100%; object-fit: cover; @@ -129,6 +128,6 @@ CardImageHeaderBackground.defaultProps = { } export const CardHeaderBackgroundSkeleton = styled("span")` - ${CommonHeaderBackground}; + ${CommonHeaderBackground.styles}; background-color: #aaa; ` diff --git a/frontend/components/NewLayout/Courses/CourseCard.tsx b/frontend/components/NewLayout/Courses/CourseCard.tsx index ea2e3539c..6343fe715 100644 --- a/frontend/components/NewLayout/Courses/CourseCard.tsx +++ b/frontend/components/NewLayout/Courses/CourseCard.tsx @@ -1,96 +1,108 @@ +import React, { useMemo } from "react" + import Image from "next/image" -import CircleIcon from "@mui/icons-material/Circle" -import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined" import HelpIcon from "@mui/icons-material/Help" -import { Button, Skeleton, Tooltip, Typography } from "@mui/material" +import { Skeleton, Typography } from "@mui/material" import { css, styled } from "@mui/material/styles" import { CardTitle } from "../Common/Card" +import { allowedLanguages, colorSchemes, sortByLanguage } from "./common" +import { + DifficultyTag, + DifficultyTags, + LanguageTag, + LanguageTags, + ModuleTag, + ModuleTags, +} from "./Tags" import OutboundLink from "/components/OutboundLink" +import { CardSubtitle } from "/components/Text/headers" +import Tooltip from "/components/Tooltip" import { useTranslator } from "/hooks/useTranslator" import moocLogo from "/public/images/new/logos/moocfi_white.svg" //import sponsorLogo from "/public/images/new/components/courses/f-secure_logo.png" -import newTheme from "/src/newTheme" import CommonTranslations from "/translations/common" import { formatDateTime } from "/util/dataFormatFunctions" -import { CourseFieldsFragment } from "/graphql/generated" - -const colorSchemes: Record = { - "Cyber Security Base": newTheme.palette.blue.dark2!, - Ohjelmointi: newTheme.palette.green.dark2!, - "Pilvipohjaiset websovellukset": newTheme.palette.crimson.dark2!, - "Tekoäly ja data": newTheme.palette.purple.dark2!, - other: newTheme.palette.gray.dark1!, - difficulty: newTheme.palette.blue.dark1!, - module: newTheme.palette.purple.dark1!, - language: newTheme.palette.green.dark1!, -} +import { CourseFieldsFragment, TagCoreFieldsFragment } from "/graphql/generated" const ContainerBase = css` display: grid; - grid-template-rows: 1fr 4fr; + grid-template-rows: 80px auto; grid-template-columns: 1fr; box-sizing: border-box; box-shadow: 3px 3px 4px rgba(88, 89, 91, 0.25); border-radius: 0.5rem; - max-height: 400px; max-width: 800px; + margin: 1rem auto; + width: 100%; ` const Container = styled("li", { - shouldForwardProp: (prop) => prop !== "module", -})<{ module?: string }>` - ${ContainerBase}; - background-color: ${(props) => - props.module ? colorSchemes[props.module] : colorSchemes["other"]}; -` + shouldForwardProp: (prop) => prop !== "studyModule", +})<{ studyModule?: string }>( + ({ studyModule }) => ` + ${ContainerBase.styles} + background-color: ${ + studyModule ? colorSchemes[studyModule] : colorSchemes["other"] + }; + height: 100%; + container-type: inline-size; +`, +) const SkeletonContainer = styled("li")` - ${ContainerBase}; + ${ContainerBase.styles}; width: 100%; background-color: #eee; ` const TitleContainer = styled("div")` position: relative; - min-height: 80px; + max-height: 80px; height: 80px; - padding: 1rem 2.5rem 1rem 1.5rem; + justify-content: center; display: flex; + flex-direction: column; ` const ContentContainer = styled("div")` - display: grid; - padding: 0.5rem 1.5rem 0.1rem 1.5rem; - grid-template-columns: 2fr 1fr; + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 1rem; background: rgba(255, 255, 255, 1); overflow: hidden; z-index: 1; border-radius: 0 0 0.5rem 0.5rem; + gap: 0.5rem; + height: 100%; + justify-content: space-between; ` -const LeftContentContainer = styled("div")` - display: grid; - grid-template-rows: 3fr 1fr 1fr; - justify-content: left; -` - -const RightContentContainer = styled("div")` - display: grid; - grid-template-rows: 1fr 1fr 1fr 1fr; - justify-content: right; -` - -const Title = styled(CardTitle)` +const Title = styled(CardTitle)( + ({ theme }) => ` font-weight: bold; color: white; text-align: left; border-radius: 0.2rem; - align-self: center; width: 70%; -` as typeof CardTitle + padding-left: 1.5rem; + + ${theme.breakpoints.down("sm")} { + width: 80%; + font-size: 90%; + } +`, +) as typeof CardTitle + +// @ts-ignore: not used +const TitleSchedule = styled(CardSubtitle)` + color: white; + margin: 0; + padding-left: 1.5rem; +` as typeof CardSubtitle /* const SponsorContainer = styled("div")` display: flex; @@ -110,89 +122,147 @@ const Title = styled(CardTitle)` ` */ const Description = styled("div")` - padding: 1rem 0; + padding: 0 0.5rem 1.5rem 0; + margin: 0 0 auto; + flex-grow: 1; + display: flex; + min-width: 200px; + min-height: 100px; ` -const Schedule = styled("div")`` - -const Details = styled("div")` +const MainContent = styled("div")` display: flex; flex-direction: column; - align-items: flex-end; - padding: 1rem; + margin: auto; + height: 50%; + flex-grow: 2; + flex-shrink: 1; + width: 400px; ` -const CourseLength = styled("div")` +const Schedule = styled(Typography)` + grid-area: schedule; display: flex; - align-items: center; + align-items: flex-start; + flex-grow: 1; + flex-shrink: 1; + flex-basis: 30%; + margin: 0; + + @container (max-width: calc(560px + 4rem)) { + justify-content: flex-end; + flex-grow: 0; + } +` as typeof Typography + +const CourseDetails = styled("div")` + grid-area: details; + display: flex; + flex-shrink: 1; + flex-grow: 0; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + justify-content: flex-end; + align-content: flex-start; + overflow: hidden; + flex-basis: 160px; + + @container (min-width: calc(560px + 4rem)) { + & > div { + padding: 0 1rem; + margin-right: -1rem; + position: relative; + z-index: 0; + overflow: hidden; + :before { + content: "|"; + position: absolute; + right: calc(0.25rem + 2px); + bottom: 0; + } + } + } + + @container (max-width: calc(560px + 4rem)) { + justify-content: flex-start; + flex-basis: max-content; + + & > div { + padding: 0 1rem 0 1rem; + margin-left: -1rem; + margin-right: 0rem; + position: relative; + z-index: 0; + overflow: hidden; + :before { + content: "|"; + position: absolute; + left: calc(0.5rem - 2px); + bottom: 0; + } + } + } ` -const StyledTooltip = styled(Tooltip)` - max-height: 1rem; +const ResponsiveTags = styled("div")` + display: flex; + flex-direction: row; + justify-content: flex-end; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + gap: 0.5rem; + flex-grow: 1; + flex-shrink: 1; + flex-basis: 50%; ` -const Link = styled(OutboundLink)` - justify-self: right; - margin: 1rem; -` as typeof OutboundLink - -const Tags = styled("div")`` - -const LanguageTags = styled(Tags)` +const CourseLength = styled("div")` display: flex; align-items: center; - justify-content: center; + margin-right: 1rem; ` -const DifficultyTags = styled(Tags)` +const Organizer = styled(Typography)` display: flex; align-items: center; - justify-content: center; -` - -const ModuleTags = styled(Tags)`` + margin: 0; + text-align: right; +` as typeof Typography -const Tag = styled(Button)` - border-radius: 2rem; - background-color: ${colorSchemes["other"]} !important; - border-color: ${colorSchemes["other"]} !important; - color: #fff !important; - font-weight: bold; - margin: 0 0.1rem; -` - -const LanguageTag = styled(Tag)` - background-color: ${colorSchemes["language"]} !important; - border-color: ${colorSchemes["language"]} !important; - border-radius: 3rem; - padding: 0.5rem; - min-width: 40px; - max-height: 40px; -` - -const DifficultyTag = styled(Tag)` - background-color: ${colorSchemes["difficulty"]} !important; - border-color: ${colorSchemes["difficulty"]} !important; -` - -const DifficultyTagContainer = styled("div")` - display: inline-block; - text-align: center; -` - -const ModuleTag = styled(Tag)` - background-color: ${colorSchemes["module"]} !important; - border-color: ${colorSchemes["module"]} !important; +const StyledTooltip = styled(Tooltip)` + max-height: 1rem; + margin-right: -0.25rem; + + &:hover { + cursor: help; + } +` as typeof Tooltip + +const StyledHelpIcon = styled(HelpIcon)` + margin-left: 0.25rem; + font-size: inherit; + transition: all 0.1s ease-in-out; + + &:hover { + cursor: help; + scale: 1.2; + } ` -const CircleContainer = styled("div")`` - -const StyledCircleIcon = styled(CircleIcon)` - max-width: 15px; -` +const Link = styled(OutboundLink)` + justify-self: right; + margin-bottom: 0; +` as typeof OutboundLink -const StyledCircleOutlinedIcon = styled(CircleOutlinedIcon)` - max-width: 15px; +const LinkArea = styled("div")` + display: flex; + justify-content: flex-end; + align-items: flex-end; + grid-area: link; + margin-left: auto; + height: fit-content; ` const CardHeaderImage = styled(Image)` @@ -204,186 +274,255 @@ const CardHeaderImage = styled(Image)` width: 35%; height: auto; z-index: 0; + overflow: hidden; ` -const MoocfiLogo = styled(CardHeaderImage)`` +const MoocfiLogo = styled(CardHeaderImage)` + z-index: 0; +` const prettifyDate = (date: string) => date.split("T").shift()?.split("-").reverse().join(".") -const allowedLanguages = ["en", "fi", "se", "other_language"] - -interface CourseCardProps { - course: CourseFieldsFragment - tags?: string[] +interface CourseCardLayoutProps { + title: string | React.ReactNode + description: string | React.ReactNode + schedule: string | React.ReactNode + details: string | React.ReactNode + organizer?: string | React.ReactNode + moduleTags?: React.ReactNode + languageTags?: React.ReactNode + difficultyTags?: React.ReactNode + link: React.ReactNode } -function CourseCard({ course }: CourseCardProps) { - const t = useTranslator(CommonTranslations) - +function CourseCardLayout({ + title, + description, + schedule, + details, + organizer, + moduleTags, + languageTags, + difficultyTags, + link, +}: CourseCardLayoutProps) { return ( - + <> - {course?.name} - + + {title} + + {/*typeof schedule === "string" && */} + - - + + - {course?.description} + {description} - - {course.status == "Upcoming" ? ( -

- {t("Upcoming")}{" "} - {course.start_date && prettifyDate(course.start_date)} -

- ) : course?.status == "Ended" ? ( -

- {t("Ended")}{" "} - {course.end_date && - Date.parse(course.end_date) < Date.now() && - formatDateTime(course.end_date)} -

- ) : ( -

- {t("Active")}{" "} - {course.end_date ? ( - <> - {formatDateTime(course.start_date)} -{" "} - {formatDateTime(course.end_date)} - - ) : ( - <>— {t("unscheduled")} - )} -

- )} -
- - {course?.tags - ?.filter((t) => t.types?.includes("module")) - .map((tag) => ( - - {tag.name} - - ))} - -
- -
- {course.ects && ( + + + {details} + + {organizer} + + {" "} + {typeof schedule === "string" ? ( + + ) : ( + schedule + )} + + {languageTags} + {difficultyTags} + + {moduleTags} + {link} + + + ) +} + +const tagHasName = ( + tag: TagCoreFieldsFragment, +): tag is TagCoreFieldsFragment & { name: string } => + typeof tag.name === "string" + +interface CourseCardProps { + course: CourseFieldsFragment + tags?: string[] +} + +const CourseCard = React.forwardRef( + ({ course, ...props }, ref) => { + const t = useTranslator(CommonTranslations) + + const schedule = useMemo(() => { + const { status, start_date, end_date } = course + if (status == "Upcoming") { + return `${t("Upcoming")}${ + start_date ? " " + prettifyDate(start_date) : "" + }` + } else if (status == "Ended") { + return `${t("Ended")}${ + end_date && Date.parse(end_date) < Date.now() + ? " " + formatDateTime(end_date) + : "" + }` + } else { + return `${t("Active")} ${ + end_date + ? formatDateTime(start_date) + + " - " + + formatDateTime(end_date) + : "— " + t("unscheduled") + }` + } + }, [course, t]) + + const moduleTags = useMemo( + () => ( + <> + {course.tags + ?.filter((tag) => tag.types?.includes("module")) + .filter(tagHasName) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tag) => ( + + ))} + + ), + [course.tags], + ) + + const languageTags = useMemo(() => { + const langTags = course.tags?.filter((tag) => + tag.types?.includes("language"), + ) + const allowed = langTags + ?.filter((tag) => allowedLanguages.includes(tag.id)) + .sort(sortByLanguage) + const otherLanguages = langTags?.filter( + (tag) => !allowedLanguages.includes(tag.id), + ) + + return ( + <> + {allowed.map((tag) => ( + + ))} + + ) + }, [course.tags]) + + const difficultyTags = useMemo( + () => + course.tags + ?.filter((t) => t.types?.includes("difficulty")) + .filter(tagHasName) + .map((tag) => ( + + )), + [course.tags], + ) + + return ( + + - - {course.ects} op | ~ + + {course.ects} op | ~ {Math.round((parseInt(course.ects) * 27) / 5) * 5}h - + - )} - {/* TODO: add information regarding university/organization to course */} - Helsingin yliopisto -
- - {course?.tags - ?.filter((t) => t.types?.includes("language")) - .filter((t) => allowedLanguages.includes(t.id)) - .map((tag) => ( - - {tag.name?.toUpperCase()} - - ))} - - - {course?.tags - ?.filter((t) => t.types?.includes("difficulty")) - .map((tag) => ( - - - {tag.name} - - {tag.id === "beginner" ? ( - - - - - - ) : tag.id === "intermediate" ? ( - - - - - - ) : ( - - - - - - )} - - ))} - - {t("showCourse")} -
- {/* + ) + } + /* TODO: add information regarding university/organization to course */ + organizer="Helsingin yliopisto" + languageTags={languageTags} + difficultyTags={difficultyTags} + link={{t("showCourse")}} + /* - */} -
-
- ) -} + */ + /> + + ) + }, +) export const CourseCardSkeleton = () => ( - - - <Skeleton width={100 + Math.random() * 100} /> - - - - - - - - - - -
- - - -
- - - - {/* */} - - -
+ } + description={ + <> + + + + + } + schedule={} + details={} + organizer={} + moduleTags={} + languageTags={ + + } + difficultyTags={} + link={} + />
) diff --git a/frontend/components/NewLayout/Courses/CourseGrid.tsx b/frontend/components/NewLayout/Courses/CourseGrid.tsx index 52dfa4e46..7cfb5a631 100644 --- a/frontend/components/NewLayout/Courses/CourseGrid.tsx +++ b/frontend/components/NewLayout/Courses/CourseGrid.tsx @@ -16,6 +16,7 @@ import { } from "@mui/material" import { styled, Theme } from "@mui/material/styles" +import { allowedLanguages, sortByLanguage } from "./common" import CourseCard, { CourseCardSkeleton } from "./CourseCard" import BorderedSection from "/components/BorderedSection" import { useTranslator } from "/hooks/useTranslator" @@ -30,8 +31,6 @@ import { TagCoreFieldsFragment, } from "/graphql/generated" -const allowedLanguages = ["en", "fi", "se", "other_language"] - /* Coming in a later PR for better mobile view const Container = styled.div` display: grid; @@ -74,18 +73,19 @@ const Container = styled("div")( `, ) -const CardContainer = styled("ul")( +const CardsContainer = styled("ul")( ({ theme }) => ` list-style: none; padding: 0; display: grid; - grid-gap: 2rem; + grid-gap: 1.5rem; grid-template-columns: 1fr 1fr; margin-top: 0; - justify-self: center; + width: 100%; ${theme.breakpoints.down("lg")} { grid-template-columns: 1fr; + grid-gap: 2rem; } `, ) @@ -138,13 +138,7 @@ const ResetFiltersButton = styled(Button, { ` const DynamicTagSelectButtons = dynamic(() => import("./TagSelectButtons"), { - loading: () => ( - <> - - - - - ), + loading: () => , }) const DynamicTagSelectDropdowns = dynamic( @@ -205,7 +199,6 @@ function CourseGrid() { }, ) - // @ts-ignore: tagsLoading not used for now const { loading: tagsLoading, data: tagsData } = useQuery( CourseCatalogueTagsDocument, { @@ -220,23 +213,27 @@ function CourseGrid() { CourseStatus.Upcoming, ]) - const tags = useMemo( - () => - (tagsData?.tags ?? []).reduce((acc, curr) => { - curr?.types?.forEach((t) => { - if ( - t === "language" && - curr.id && - !allowedLanguages.includes(curr.id) - ) { - return acc - } - acc[t] = (acc[t] ?? []).concat(curr) - }) - return acc - }, {} as Record>), - [tagsData], - ) + const tags = useMemo(() => { + const res = (tagsData?.tags ?? []).reduce((acc, curr) => { + curr?.types?.forEach((t) => { + if ( + t === "language" && + curr.id && + !allowedLanguages.includes(curr.id) + ) { + return acc + } + acc[t] = (acc[t] ?? []).concat(curr) + }) + return acc + }, {} as Record>) + + if (res["language"]) { + res["language"] = res["language"].sort(sortByLanguage) + } + + return res + }, [tagsData]) // TODO: set tags on what tags are found from courses in db? or just do a hard-coded list of tags? const handleStatusChange = (status: string) => { @@ -312,6 +309,7 @@ function CourseGrid() { activeTags={activeTags} setActiveTags={setActiveTags} selectAllTags={() => setActiveTags([...(tagsData?.tags ?? [])])} + loading={tagsLoading} /> {(["Active", "Upcoming", "Ended"] as const).map((status) => ( @@ -340,21 +338,21 @@ function CourseGrid() { {coursesLoading ? ( - + - + ) : ( - + {filteredCourses.sort(compareCourses).map((course) => ( ))} {filteredCourses.length === 0 && (
  • no courses
  • )} -
    + )} ) diff --git a/frontend/components/NewLayout/Courses/TagSelectButtons.tsx b/frontend/components/NewLayout/Courses/TagSelectButtons.tsx index 6f4c76537..36dfae7df 100644 --- a/frontend/components/NewLayout/Courses/TagSelectButtons.tsx +++ b/frontend/components/NewLayout/Courses/TagSelectButtons.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from "react" -import { Button, Chip } from "@mui/material" +import { Button, Chip, Skeleton } from "@mui/material" import { styled } from "@mui/material/styles" import { useTranslator } from "/hooks/useTranslator" @@ -17,6 +17,18 @@ const TagsContainer = styled("div")` gap: 0.5rem; ` +const TagListContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "category", +})<{ category?: string }>(({ category }) => + category ? `grid-area: ${category}Tags;` : "", +) + +const SelectAllContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "category", +})<{ category?: string }>(({ category }) => + category ? `grid-area: ${category}SelectAll;` : "", +) + const TagChip = styled(Chip, { shouldForwardProp: (prop) => prop !== "variant", })( @@ -51,6 +63,41 @@ const TagChip = styled(Chip, { `, ) +const TagSkeletonContainer = styled("div")` + display: grid; + grid-template-areas: + "skeleton1Tags skeleton1SelectAll" + "skeleton2Tags skeleton2SelectAll" + "skeleton3Tags skeleton3SelectAll"; + gap: 0.5rem; +` + +const TagsSkeleton = ({ + category, + widths, +}: { + category: string + widths: Array +}) => ( + <> + + {widths.map((width) => ( + + ))} + + + + + +) + const SelectAllButton = styled(Button)( ({ theme, variant }) => ` margin: 0 1rem auto auto; @@ -87,6 +134,7 @@ const SelectAllButton = styled(Button)( ) interface TagSelectButtonsProps { + loading?: boolean tags: Record> activeTags: Array setActiveTags: ( @@ -102,11 +150,12 @@ const TagSelectButtons = ({ activeTags, setActiveTags, selectAllTags, + loading, }: TagSelectButtonsProps) => { const t = useTranslator(CommonTranslations) const handleClick = useCallback( - (tag: TagCoreFieldsFragment) => { + (tag: TagCoreFieldsFragment) => () => { if (activeTags.includes(tag)) { setActiveTags(activeTags.filter((t) => t !== tag)) } else { @@ -117,7 +166,7 @@ const TagSelectButtons = ({ ) const handleSelectAllClick = useCallback( - (category: string) => { + (category: string) => () => { if (category in tags) { if (tags[category].every((tag) => activeTags.includes(tag))) { setActiveTags( @@ -134,23 +183,33 @@ const TagSelectButtons = ({ [activeTags], ) + if (loading) { + return ( + + + + + + ) + } + return ( {Object.keys(tags).map((category) => ( -
    + {tags[category].map((tag) => ( handleClick(tag)} + onClick={handleClick(tag)} size="small" label={tag.name} /> ))} -
    -
    + + handleSelectAllClick(category)} + onClick={handleSelectAllClick(category)} size="small" > {t("selectAll")} -
    +
    ))}
    diff --git a/frontend/components/NewLayout/Courses/Tags.tsx b/frontend/components/NewLayout/Courses/Tags.tsx new file mode 100644 index 000000000..d9f567538 --- /dev/null +++ b/frontend/components/NewLayout/Courses/Tags.tsx @@ -0,0 +1,163 @@ +import { PropsOf } from "@emotion/react" +import CircleIcon from "@mui/icons-material/Circle" +import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined" +import { Chip } from "@mui/material" +import { styled } from "@mui/material/styles" + +import { colorSchemes } from "./common" +import { InfoTooltip } from "/components/Tooltip" +import { useTranslator } from "/hooks/useTranslator" +import CommonTranslations from "/translations/common" + +import { TagCoreFieldsFragment } from "/graphql/generated" + +const Tag = styled(Chip)` + border-radius: 2rem; + background-color: ${colorSchemes["other"]} !important; + border-color: ${colorSchemes["other"]} !important; + color: #fff !important; + font-weight: bold; + text-transform: uppercase; +` + +const Tags = styled("div")` + display: flex; + flex-shrink: 1; + margin-bottom: auto; + padding: 0; + gap: 0.2rem; + justify-content: flex-end; +` + +export const LanguageTags = styled(Tags)` + display: flex; + margin: 0 0 auto; + flex-shrink: 1; +` + +export const DifficultyTags = styled(Tags)` + display: flex; + margin: 0 0 auto; + flex-shrink: 1; + justify-content: flex-start; +` + +export const ModuleTags = styled(Tags)` + display: flex; + justify-content: flex-start; + align-items: flex-start; + flex-flow: wrap; + margin: 0 auto; + flex-grow: 2; + flex-shrink: 0; + flex-basis: 50%; +` + +const LanguageTagBase = styled(Tag)` + background-color: ${colorSchemes["language"]} !important; + border-color: ${colorSchemes["language"]} !important; + border-radius: 3rem; + min-width: 40px; + max-height: 40px; + + .MuiChip-label { + text-transform: uppercase; + } +` + +const TagWithTooltip = styled("div")` + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; +` + +export const LanguageTag = ({ + otherLanguages, + ...props +}: PropsOf & { otherLanguages?: Array }) => { + const t = useTranslator(CommonTranslations) + + if (!otherLanguages) { + return + } + + return ( + + {props.label} + language.name) + .sort() + .join(", ")} + outlined={false} + IconProps={{ + hoverColor: "#eee", + iconColor: "white", + fontSize: "small", + }} + /> + + } + /> + ) +} + +const DifficultyTagBase = styled(Tag)` + background-color: ${colorSchemes["difficulty"]} !important; + border-color: ${colorSchemes["difficulty"]} !important; +` + +const DifficultyTagContainer = styled("div")` + display: flex; + flex-direction: column; + text-align: center; +` + +export const DifficultyTag = ({ + difficulty, + ...props +}: PropsOf & { difficulty: string }) => ( + + + + + {difficulty !== "beginner" ? ( + + ) : ( + + )} + {difficulty === "advanced" ? ( + + ) : ( + + )} + + +) + +export const ModuleTag = styled(Tag)` + background-color: ${colorSchemes["module"]} !important; + border-color: ${colorSchemes["module"]} !important; +` + +const CircleContainer = styled("div")( + ({ theme }) => ` + ${theme.breakpoints.down("sm")} { + display: none; + } +`, +) + +const StyledCircleIcon = styled(CircleIcon)` + max-width: 15px; +` + +const StyledCircleOutlinedIcon = styled(CircleOutlinedIcon)` + max-width: 15px; +` diff --git a/frontend/components/NewLayout/Courses/common.tsx b/frontend/components/NewLayout/Courses/common.tsx new file mode 100644 index 000000000..096c28e9c --- /dev/null +++ b/frontend/components/NewLayout/Courses/common.tsx @@ -0,0 +1,29 @@ +import newTheme from "/src/newTheme" + +import { TagCoreFieldsFragment } from "/graphql/generated" + +export const allowedLanguages = ["fi", "en", "se", "other_language"] + +export const sortByLanguage = ( + a: TagCoreFieldsFragment, + b: TagCoreFieldsFragment, +) => { + if (allowedLanguages.indexOf(a.id) === -1) { + return 1 + } + if (allowedLanguages.indexOf(b.id) === -1) { + return -1 + } + return allowedLanguages.indexOf(a.id) - allowedLanguages.indexOf(b.id) +} + +export const colorSchemes: Record = { + "Cyber Security Base": newTheme.palette.blue.dark2!, + Ohjelmointi: newTheme.palette.green.dark2!, + "Pilvipohjaiset websovellukset": newTheme.palette.crimson.dark2!, + "Tekoäly ja data": newTheme.palette.purple.dark2!, + other: newTheme.palette.gray.dark1!, + difficulty: newTheme.palette.blue.dark1!, + module: newTheme.palette.purple.dark1!, + language: newTheme.palette.green.dark1!, +} diff --git a/frontend/components/NewLayout/Modules/StudyModuleHero.tsx b/frontend/components/NewLayout/Modules/StudyModuleHero.tsx index 7ce8c1ef4..08e7efc05 100644 --- a/frontend/components/NewLayout/Modules/StudyModuleHero.tsx +++ b/frontend/components/NewLayout/Modules/StudyModuleHero.tsx @@ -4,8 +4,16 @@ import { styled } from "@mui/material/styles" const Header = styled(Typography)` display: flex; justify-content: center; + text-align: center; ` export function StudyModuleHero() { - return
    Opintokokonaisuudet
    + return ( +
    + ) } diff --git a/frontend/components/NewLayout/Modules/StudyModuleListItem.tsx b/frontend/components/NewLayout/Modules/StudyModuleListItem.tsx index d3746e65c..b28b7c419 100644 --- a/frontend/components/NewLayout/Modules/StudyModuleListItem.tsx +++ b/frontend/components/NewLayout/Modules/StudyModuleListItem.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef } from "react" import { Skeleton, Typography } from "@mui/material" import { css, styled } from "@mui/material/styles" @@ -6,6 +6,7 @@ import { css, styled } from "@mui/material/styles" import { CorrectedAnchor } from "../Common" import { CardWrapper } from "../Common/Card" import CourseCard, { CourseCardSkeleton } from "../Courses/CourseCard" +import useIsomorphicLayoutEffect from "/hooks/useIsomorphicLayoutEffect" import backgroundPattern from "/public/images/new/background/backgroundPattern.svg" import { StudyModuleFieldsWithCoursesFragment } from "/graphql/generated" @@ -51,12 +52,15 @@ const ModuleCardBody = styled("ul")` 1, var(--cols, 3) ); /* Ideal number of columns is 3 by default; at least one! */ - --_gap: var(--gap, 2.5rem); /* Space between each logo */ - --_min: var(--min, 420px); /* Logos must be at least this wide */ - --_max: var(--max, 100%); /* Logos cannot be wider than this size */ - - list-style-position: inside; - padding: 1rem; + --_gap: var(--gap, 1.5rem); /* space between each card */ + --_min: var( + --min, + min(360px, calc(100vw - 3rem)) + ); /* card must be at least this wide */ + --_max: var(--max, 100%); /* cards cannot be wider than this size */ + + list-style: none; + padding: 2rem 1rem; display: grid; grid-template-columns: repeat( auto-fill, @@ -68,10 +72,11 @@ const ModuleCardBody = styled("ul")` 1fr ) ); - grid-template-rows: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-rows: repeat(auto-fill, 1fr); background-color: transparent; grid-gap: var(--_gap); grid-auto-flow: row; + width: 100%; ` const ModuleCardDescription = styled("div")` @@ -93,7 +98,7 @@ const ImageBackgroundBase = css` right: 0; top: 0; bottom: 0; - background-size: cover; + background-size: 200%; background-position: center 40%; z-index: -5; ` @@ -101,12 +106,12 @@ const ImageBackgroundBase = css` const ImageBackground = styled("span", { shouldForwardProp: (prop) => prop !== "src", })<{ src: string }>` - ${ImageBackgroundBase}; + ${ImageBackgroundBase.styles}; background-image: url(${(props) => props.src}); ` const SkeletonBackground = styled("span")` - ${ImageBackgroundBase}; + ${ImageBackgroundBase.styles}; background-color: #eee; ` @@ -114,13 +119,17 @@ const CenteredHeader = styled(Typography)` margin-bottom: 2rem; ` as typeof Typography -const ModuleCourseCard = styled(CourseCard)`` +/*const ModuleCourseCard = styled(CourseCard)( + ({ theme }) => ` +`, +)*/ export function ListItem({ studyModule, backgroundColor, }: StudyModuleListItemProps) { const descriptionRef = useRef() + const moduleCardRef = useRef() const courses = useMemo( () => studyModule.courses?.filter((course) => course.description) ?? [], [studyModule], @@ -128,21 +137,32 @@ export function ListItem({ const setDescriptionHeight = useCallback(() => { const description = descriptionRef.current + const moduleCard = moduleCardRef.current - if (!description) { + if (!description || !moduleCard) { return } - if (description.clientHeight > 320) { - const span = Math.round(description.scrollHeight / 320) // the max size of row should be in a var + let cardHeight = 0 + moduleCard.childNodes?.forEach((child) => { + if (child instanceof HTMLElement) { + cardHeight += child.clientHeight + } + }) + const currentSpan = Number( + description.style.getPropertyValue("--hero-span"), + ) + if (description.clientHeight > cardHeight && currentSpan < 2) { + const span = Math.ceil(description.scrollHeight / cardHeight) // the max size of row should be in a var description.style.cssText = `--hero-span: ${span};` } - }, [descriptionRef.current]) + }, [descriptionRef.current, moduleCardRef.current]) useEffect(() => { if (!window) { return () => void 0 } + window.addEventListener("resize", setDescriptionHeight) return () => { @@ -150,7 +170,7 @@ export function ListItem({ } }, []) - useLayoutEffect(setDescriptionHeight, [studyModule.description]) + useIsomorphicLayoutEffect(setDescriptionHeight, [studyModule.description]) // TODO: the anchor link may have to be shifted by the amount of the header again return ( @@ -165,11 +185,20 @@ export function ListItem({ {studyModule.description} + {studyModule.description} - {courses?.map((course) => ( - + {courses?.map((course, index) => ( + { + if (index === 0) { + moduleCardRef.current = ref + } + }} + course={course} + key={course.id} + /> ))} diff --git a/frontend/components/NewLayout/Navigation/DesktopNavigationMenu.tsx b/frontend/components/NewLayout/Navigation/DesktopNavigationMenu.tsx index 72fecbefa..23299f46b 100644 --- a/frontend/components/NewLayout/Navigation/DesktopNavigationMenu.tsx +++ b/frontend/components/NewLayout/Navigation/DesktopNavigationMenu.tsx @@ -12,7 +12,7 @@ import { SvgIconProps, useMediaQuery, } from "@mui/material" -import { styled } from "@mui/material/styles" +import { styled, Theme } from "@mui/material/styles" import { NavigationLinks } from "./NavigationLinks" import LanguageSwitch from "/components/NewLayout/Header/LanguageSwitch" @@ -90,12 +90,17 @@ const MenuButton = React.memo( const UserOptionsMenu = () => { const client = useApolloClient() - const { pathname, locale } = useRouter() + const { pathname } = useRouter() const { loggedIn, logInOrOut, currentUser } = useLoginStateContext() const t = useTranslator(CommonTranslations) - const isNarrow = useMediaQuery("(max-width: 899px)", { noSsr: true }) + const isNarrow = useMediaQuery((theme: Theme) => + theme.breakpoints.down("desktop"), + ) const userDisplayName = useMemo(() => { + if (isNarrow) { + return null + } const name = currentUser?.full_name const initials = ( (currentUser?.first_name?.[0] ?? "") + (currentUser?.last_name?.[0] ?? "") @@ -109,7 +114,7 @@ const UserOptionsMenu = () => { } return name - }, [currentUser, locale, t]) + }, [currentUser, t, isNarrow]) const onLogOut = useCallback( () => signOut(client, logInOrOut), @@ -125,7 +130,7 @@ const UserOptionsMenu = () => { narrow={isNarrow} title={t("myProfile")} > - {isNarrow ? null : userDisplayName} + {userDisplayName} ( -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: "#f5f5f9", - color: "rgba(0, 0, 0, 0.87)", - maxWidth: 300, - fontSize: theme.typography.pxToRem(12), - border: "1px solid #dadde9", - cursor: "pointer", - }, -})) as typeof MUITooltip - -const InfoIcon = styled(InfoOutlinedIcon)` +))( + ({ theme }) => ` + & .${tooltipClasses.tooltip} { + background-color: #f5f5f9; + color: rgba(0, 0, 0, 0.87); + max-width: 300px; + font-size: ${theme.typography.pxToRem(12)}; + border: 1px solid #dadde9; + cursor: pointer; + } +`, +) as typeof MUITooltip + +const IconStyle = css` + --icon-color: #a0a0ff; + --icon-hover-color: #6060ff; + cursor: help; - color: #a0a0ff; - transition: color 0.2s; + transition: all 0.2s ease-in-out; + color: var(--icon-color); :hover { - color: #6060ff; + color: var(--icon-hover-color); + scale: 1.2; } ` -const InfoTooltip = styled(Tooltip)` +const InfoTooltipBase = styled(Tooltip)` :hover { cursor: help; } ` +const InfoOutlined = styled(InfoOutlinedIcon, { + shouldForwardProp: (prop) => prop !== "iconClor" && prop !== "hoverColor", +})<{ iconColor?: string; hoverColor?: string }>( + ({ iconColor, hoverColor }) => ` + ${IconStyle.styles} + ${iconColor ? `--icon-color: ${iconColor};` : ""} + ${hoverColor ? `--icon-hover-color: ${hoverColor};` : ""} +`, +) + +const Info = styled(InfoIcon, { + shouldForwardProp: (prop) => prop !== "iconColor" && prop !== "hoverColor", +})<{ iconColor?: string; hoverColor?: string }>( + ({ iconColor, hoverColor }) => ` + ${IconStyle.styles} + ${iconColor ? `--icon-color: ${iconColor};` : ""} + ${hoverColor ? `--icon-hover-color: ${hoverColor};` : ""} +`, +) + const TooltipWrapper = styled("span")` display: flex; ` -interface InfoTooltipWithLabelProps { - label: string +interface InfoTooltipProps { + label?: string + labelProps?: TypographyProps & BoxProps + titleProps?: TypographyProps & BoxProps + outlined?: boolean + IconProps?: PropsOf } -export const InfoTooltipWithLabel = ({ +export const InfoTooltip = ({ label, + outlined = true, + IconProps, + labelProps, + titleProps, ...props -}: InfoTooltipWithLabelProps & Omit) => ( +}: InfoTooltipProps & Omit) => ( - - {label} - {props.title} + {label && ( + + {label} + + )} + + {props.title} + } > - - + {outlined ? : } + ) diff --git a/frontend/next.config.js b/frontend/next.config.js index 23d74f992..73d779769 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -199,7 +199,7 @@ const nextConfiguration = (_phase) => ({ "react-dom$": "react-dom/profiling", } - if (options.isServer) { + if (options.isServer && isProduction) { config.devtool = "source-map" } diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index c2b4c5990..48187ea42 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -3,6 +3,7 @@ import { useInsertionEffect, useMemo } from "react" import { DefaultSeo } from "next-seo" import type { AppContext, AppProps, NextWebVitalsMetric } from "next/app" import Head from "next/head" +import Script from "next/script" import { CssBaseline } from "@mui/material" import { ThemeProvider } from "@mui/material/styles" @@ -23,7 +24,7 @@ import { UserDetailedFieldsFragment } from "/graphql/generated" const { withAppEmotionCache, augmentDocumentWithEmotionCache } = createEmotionSsr({ - key: "emotion-css", + key: "moocfi", }) export { augmentDocumentWithEmotionCache } @@ -63,6 +64,11 @@ export function MyApp({ Component, pageProps, deviceType }: MyAppProps) { [signedIn, admin, currentUser], ) + // test for container query support + const supportsContainerQueries = + typeof document !== "undefined" && + "container" in document.documentElement.style + return ( <> @@ -71,6 +77,12 @@ export function MyApp({ Component, pageProps, deviceType }: MyAppProps) { content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" /> + {!supportsContainerQueries && ( +