DEV Community

Cover image for Application Shell using Next.js, Material UI, Amplify Hosting - FakeTube #3
Jacek Kościesza
Jacek Kościesza

Posted on

Application Shell using Next.js, Material UI, Amplify Hosting - FakeTube #3

We are finally ready to start coding. In the last two episodes we finalised important prerequisites - preparing initial requirements and content for our YouTube clone. In this episode we will start frontend development with an Application Shell.

Application Shell provides a static, consistent and responsive user interface framework or skeleton, which has elements like navigation bars, headers, footers, sidebars and basic layout structure. App shell loads very quickly, then the dynamic content specific to the current page or section is loaded and injected into the shell. In our case it will be a static layout with components like Masthead, App Drawer with Guide, Mini Guide and Pivot Bar.

We are going to use popular technologies like Next.js, React, TypeScript and Material UI. Hosting will be provided by AWS Amplify Hosting, which supports Next.js server-side rendering (SSR) out of the box.

GitHub

GitHub will be our primary tool for a project planning and tracking, but also as a source code repository

I already created the project repository:JacekKosciesza/FakeTube, so let's clone it and change a working directory:

git clone https://github.com/JacekKosciesza/FakeTube.git
cd FakeTube/
Enter fullscreen mode Exit fullscreen mode

Next.js

Installation

The easiest way to start a Next.js project is to use create-next-app. It launches a simple wizard in the terminal. Let's name our app faketube and use default values for the other options.

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Create Next.js app

I like to have top level folders in the project like this:

  • web
  • mobile
  • cloud

so, let's rename faketube folder created by crate-next-app to web, change a working directory and run our web application (on the default http://localhost:3000)

mv faketube web
cd web/
npm run dev
Enter fullscreen mode Exit fullscreen mode

New Next.js app

GitHub: feat(appshell): next.js installation (#9)

Material UI

Installation

Now it's time to add our components library of choice - Material UI to our project. MUI documentation gives us precise instructions on how to do it.

Let's start with installing core dependencies.

npm install @mui/material @emotion/react @emotion/styled
Enter fullscreen mode Exit fullscreen mode

We will also need to install icons in order to use prebuilt SVG Material Icons.

npm install @mui/icons-material
Enter fullscreen mode Exit fullscreen mode

GitHub: feat(appshell): material ui installation (#10)

Next.js integration

There are a few integration steps we will have to do to use Material UI with Next.js.

We will be using an App Router which is a file-system based router that uses the React's latest features and is an evolution of the older Pages Router.

Dependencies

Let's start with installing the dependencies

npm install @mui/material-nextjs @emotion/cache
Enter fullscreen mode Exit fullscreen mode

Configuration

Next, let's change configuration in web/app/layout.tsx by importing the AppRouterCacheProvider component and wrapping all elements under the <body> with it

web/app/layout.tsx (diff)

+ import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import type { Metadata } from "next";
// ...

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable}`}>
+       <AppRouterCacheProvider>
          {children}
+       </AppRouterCacheProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Font optimization

If we want to take advantage of Next.js font optimization, we will have to customize a few things in the MUI theme and Next.js layout.

web/app/theme.ts

"use client";

import { createTheme } from "@mui/material/styles";

const theme = createTheme({
  typography: {
    fontFamily: "var(--font-roboto)",
  },
});

export default theme;
Enter fullscreen mode Exit fullscreen mode

web/app/layout.tsx (diff)

+import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
+import { Roboto } from 'next/font/google';
+import { ThemeProvider } from '@mui/material/styles';

+import theme from './theme';

-const geistSans = Geist({
-  variable: "--font-geist-sans",
-  subsets: ["latin"],
-});

-const geistMono = Geist_Mono({
-  variable: "--font-geist-mono",
-  subsets: ["latin"],
-});

+const roboto = Roboto({
+  weight: ['300', '400', '500', '700'],
+  subsets: ['latin'],
+  display: 'swap',
+  variable: '--font-roboto',
+});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
   return (
-    <html lang="en">
+    <html lang="en" className={roboto.variable}>
-      <body className={`${geistSans.variable} ${geistMono.variable}`}>
+      <body>
          <AppRouterCacheProvider>
+           <ThemeProvider theme={theme}>
+             <CssBaseline />
              {children}
+           </ThemeProvider>
          </AppRouterCacheProvider>
       </body>
     </html>
   );
 }
Enter fullscreen mode Exit fullscreen mode

web/app/layout.tsx

import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import { ThemeProvider } from "@mui/material/styles";

import theme from "./theme";

const roboto = Roboto({
  weight: ["300", "400", "500", "700"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-roboto",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className={roboto.variable}>
      <body>
        <AppRouterCacheProvider>
          <ThemeProvider theme={theme}>
            <CssBaseline />
            {children}
          </ThemeProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's verify if our project still builds and runs

npm run build
npm run dev
Enter fullscreen mode Exit fullscreen mode

GitHub: feat(appshell): next.js integration (#11)

Application Shell

After all those prerequisites, we can focus on a minimalistic Application Shell component. It will not add any visual difference (in that version), but will be a foundation for some enhancements.

web/app/AppShell/AppShell.tsx

import * as React from "react";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/index.ts

export * from "./AppShell";
Enter fullscreen mode Exit fullscreen mode

To use it - we will have to modify our web/app/layout.tsx, but first let's look at HTML metadata.

Metadata

Let's change web app metadata like title, description and keywords. This is how YouTube defines it.

<!DOCTYPE html>
<html>
  <head>
    <title>YouTube</title>
    <meta name="description" content="Enjoy the videos and music
you love, upload original content, and share it all with friends,
family, and the world on YouTube.">
     <meta name="keywords" content="video, sharing, camera phone, video
phone, free, upload">
     <!-- ... -->
  </head>
</html>
Enter fullscreen mode Exit fullscreen mode

We will do it in a similar way, but let's get rid of some features references, which we don't have yet.

web/app/layout.tsx (diff)

import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import { ThemeProvider } from "@mui/material/styles";

import theme from "../theme";
+import { AppShell } from "./AppShell";

const roboto = Roboto({
  weight: ["300", "400", "500", "700"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-roboto",
});

export const metadata: Metadata = {
-  title: "Create Next App",
+  title: "FakeTube",
-  description: "Generated by create next app",
+  description: "Enjoy the videos and music you love on FakeTube.",
+  keywords: "video, free",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={roboto.variable}>
      <body>
        <AppRouterCacheProvider>
          <ThemeProvider theme={theme}>
            <CssBaseline />
-           {children}
+           <AppShell>{children}</AppShell>
          </ThemeProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Home

Defaul page needs some cleanup. We will make it empty for now.

web/app/page.tsx

export default function Home() {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Breakpoints

To make our layout more compatible with the real YouTube app, we will have to slightly adjust breakpoints by defining custom ones in the MUI theme.

web/app/theme.ts (diff)

"use client";

import { createTheme } from "@mui/material/styles";

+declare module "@mui/material/styles" {
+  interface BreakpointOverrides {
+    xxl: true;
+    xxxl: true;
+  }
+}

const theme = createTheme({
  typography: {
    fontFamily: "var(--font-roboto)",
  },
+ breakpoints: {
+   values: {
+     xs: 0,
+     sm: 601,
+     md: 792,
+     lg: 1313,
+     xl: 1536,
+     xxl: 1920,
+     xxxl: 2985,
+   },
+ },
});

export default theme;
Enter fullscreen mode Exit fullscreen mode

Loading

In Next.js we can display a meaningful loading UI component with React Suspense. In our case we will need a simple indeterminate linear progress indicator.

web/app/loading.tsx

import LinearProgress from "@mui/material/LinearProgress";

export default function Loading() {
  return <LinearProgress color="error" sx={{ height: 3 }} />;
}
Enter fullscreen mode Exit fullscreen mode

Favicon

It's time to replace Next.js favicon with the FakeTube one. At the same time let's add the FakeTube logo in PNG and SVG formats.

web
├── app
│   ├── favicon.ico
|   ├── ...
├── public
│   ├── faketube.png
│   ├── faketube.svg
|   ├── ...
| ...
Enter fullscreen mode Exit fullscreen mode

FakeTube favicon

Cleanup

Let's also remove all the unused files created by create-nextjs-app.

web
├── app
│   ├── globals.css
│   ├── page.module.css
|   ├── ...
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   ├── window.svg
|   ├── ...
| ...
Enter fullscreen mode Exit fullscreen mode

Next.js Dev Tools

We don't need Next.js Dev Tools at the moment, so we will hide it. We can do it by modifying devIndicators Next.js config option.

web/next.config (diff)

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
- /* config options here */
+ devIndicators: false,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

GitHub: feat(appshell): empty appshell component (#12)

Masthead

The next step is to create a masthead, which in Material Design terms is known as an App Bar. It's at the top of the screen and will be used for branding, navigation, and actions in the future.

web/app/AppShell/Masthead/Masthead.tsx

import AppBar from "@mui/material/AppBar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";

export function Masthead() {
  return (
    <AppBar elevation={0} position="static">
      <Toolbar>
        <IconButton
          size="large"
          edge="start"
          color="inherit"
          aria-label="menu"
          sx={{ mr: 2 }}
        >
          <MenuIcon />
        </IconButton>
        <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
          FakeTube
        </Typography>
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/Masthead/index.tsx

export * from "./Masthead";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

import * as React from "react";

+import { Masthead } from "./Masthead";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
- return <>{children}</>;
+ return (
+   <>
+     <Masthead />
+     {children}
+   </>
+  );
}
Enter fullscreen mode Exit fullscreen mode

Basic blue masthead

By default it's blue, so we have to change it to look like on YouTube. We can adjust styling on the component level or on the theme level. I chose the latter to make it easier to introduce dark theme in the future.

web/app/theme.ts (diff)

// ...
const theme = createTheme({
  typography: {
    fontFamily: "var(--font-roboto)",
  },
  breakpoints: {
    values: {
      xs: 0,
      sm: 601,
      md: 792,
      lg: 1313,
      xl: 1536,
      xxl: 1920,
      xxxl: 2985,
    },
  },
+ components: {
+   MuiAppBar: {
+     styleOverrides: {
+       colorPrimary: {
+         backgroundColor: "#FFFFFF",
+         color: "#000000",
+       },
+     },
+   },
+ },
});
Enter fullscreen mode Exit fullscreen mode

Basic masthead

GitHub: feat(appshell): basic masthead component (#13)

Logo

So far we have created only basic "FakeTube" logotype (brand name), but we also need a logomark (icon or image) to create a complete brand logo. We will introduce a new logo component and update masthead to use it.

web/app/AppShell/Logo/Logo.tsx

"use client";

import * as React from "react";
import Image from "next/image";
import NextLink from "next/link";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";

export function Logo() {
  return (
    <Tooltip
      title="FakeTube Home"
      enterDelay={500}
      disableInteractive
      sx={{
        textDecoration: "none",
      }}
    >
      <NextLink href="/" style={{ textDecoration: "none" }}>
        <Stack direction="row" alignItems="center" spacing={0.2}>
          <Image src="faketube.svg" alt="" width={29} height={20} />
          <Typography
            variant="h6"
            color="textPrimary"
            sx={{
              fontWeight: (theme) => theme.typography.fontWeightBold,
              letterSpacing: "-0.075rem",
            }}
          >
            FakeTube
          </Typography>
        </Stack>
      </NextLink>
    </Tooltip>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/Logo/index.ts

export * from "./Logo";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/Masthead/Masthead.tsx (diff)

import AppBar from "@mui/material/AppBar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Toolbar from "@mui/material/Toolbar";
-import Typography from "@mui/material/Typography";

+import { Logo } from "../Logo";

export function Masthead() {
  return (
    <AppBar elevation={0} position="static">
      <Toolbar>
        <IconButton
          size="large"
          edge="start"
          color="inherit"
          aria-label="menu"
          sx={{ mr: 2 }}
        >
          <MenuIcon />
        </IconButton>
-       <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
-         FakeTube
-       </Typography>
+        <Logo />
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

Masthead with logo

GitHub: feat(appshell): logo (#14)

App Drawer

Now, let's shift focus to an App Drawer - sidebar which will have main navigation elements like Guide or Mini Guide. It will have a temporary and permanent variants and will be controlled (open/close) by a Guide Button, which is also known as a hamburger menu button.

web/app/AppShell/AppDrawer/AppDrawer.tsx

import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";

export interface Props {
  open: boolean;
  onDrawerClose: () => void;
  variant?: "temporary" | "permanent";
}

export function AppDrawer(props: Props) {
  const { open, onDrawerClose, variant } = props;

  const handleDrawerClose = () => {
    if (variant === "temporary") onDrawerClose();
  };

  return (
    <Drawer
      open={open}
      onClose={onDrawerClose}
      sx={{
        "& .MuiDrawer-paper": {
          mt: variant === "temporary" ? 0 : 8,
          borderRight: "none",
        },
      }}
      variant={variant}
    >
      <Box
        role="presentation"
        onClick={handleDrawerClose}
        onKeyDown={handleDrawerClose}
        display="flex"
        flexDirection="column"
        justifyContent="space-between"
        sx={{
          height: (theme) =>
            `calc(100% - ${theme.spacing(variant === "temporary" ? 2 : 8)})`,
        }}
      >
        <Box p={2}>TODO: Guide</Box>
      </Box>
    </Drawer>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

+"use client";

import * as React from "react";

+import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
+ const [isAppDrawerOpened, setIsAppDrawerOpened] = React.useState(false);

+ const closeAppDrawer = () => {
+   setIsAppDrawerOpened(false);
+ };
+  const toggleAppDrawer = () => {
+   setIsAppDrawerOpened(!isAppDrawerOpened);
+ };

  return (
    <>
-     <Masthead />
+     <Masthead onGuideButtonClick={toggleAppDrawer} />
+     <AppDrawer
+       open={isAppDrawerOpened}
+       onDrawerClose={closeAppDrawer}
+       variant="temporary"
+      />
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx

"use client";

import * as React from "react";

import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
  const [isAppDrawerOpened, setIsAppDrawerOpened] = React.useState(false);

  const closeAppDrawer = () => {
    setIsAppDrawerOpened(false);
  };
  const toggleAppDrawer = () => {
    setIsAppDrawerOpened(!isAppDrawerOpened);
  };

  return (
    <>
      <Masthead onGuideButtonClick={toggleAppDrawer} />
      <AppDrawer
        open={isAppDrawerOpened}
        onDrawerClose={closeAppDrawer}
        variant="temporary"
      />
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/Masthead/Masthead.tsx (diff)

import AppBar from "@mui/material/AppBar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Toolbar from "@mui/material/Toolbar";

import { Logo } from "../Logo";

+export interface Props {
+  onGuideButtonClick: () => void;
+}

-export function Masthead() {
+export function Masthead({ onGuideButtonClick }: Props) {
  return (
    <AppBar elevation={0} position="static">
      <Toolbar>
        <IconButton
          size="large"
          edge="start"
          color="inherit"
          aria-label="menu"
          sx={{ mr: 2 }}
+         onClick={onGuideButtonClick}
        >
          <MenuIcon />
        </IconButton>
        <Logo />
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

Temporary app drawer

GitHub: feat(appshell): empty app drawer component (#15)

Guide

A guide is one of the main navigation elements. It contains elements which we already have in the masthead like Guide Button and Logo, as well as unique navigation items.

web/app/AppShell/AppDrawer/Guide/Guide.tsx

import HomeIcon from "@mui/icons-material/Home";
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
import List from "@mui/material/List";
import Stack from "@mui/material/Stack";
import Toolbar from "@mui/material/Toolbar";

import { GuideButton } from "./GuideButton";
import { GuideItem } from "./GuideItem";
import { Logo } from "../../Logo";

interface Props {
  onDrawerClose: () => void;
  variant?: "temporary" | "permanent";
}

export function Guide(props: Props) {
  const { onDrawerClose, variant } = props;

  const handleDrawerClose = () => {
    if (variant === "temporary") onDrawerClose();
  };

  return (
    <Stack>
      {variant === "temporary" && (
        <Toolbar>
          <GuideButton onClick={handleDrawerClose} />
          <Logo />
        </Toolbar>
      )}
      <List sx={{ width: 240, p: 1 }}>
        <GuideItem
          label="Home"
          href="/"
          icon={<HomeOutlinedIcon />}
          activeIcon={<HomeIcon />}
          onClick={handleDrawerClose}
        />
      </List>
    </Stack>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's extract a hamburger menu button to a reusable Guide Button component and use it in both places (Guide and Masthead).

web/app/AppShell/AppDrawer/Guide/GuideButton.tsx

import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";

interface Props {
  onClick: () => void;
}

export function GuideButton(props: Props) {
  const { onClick } = props;

  return (
    <IconButton
      onClick={onClick}
      size="large"
      edge="start"
      color="inherit"
      aria-label="Guide"
      sx={{ mr: 2 }}
    >
      <MenuIcon />
    </IconButton>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppDrawer/Guide/index.ts

export * from "./Guide";
export * from "./GuideButton";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/Masthead/Masthead.tsx (diff)

import AppBar from "@mui/material/AppBar";
-import IconButton from "@mui/material/IconButton";
-import MenuIcon from "@mui/icons-material/Menu";
import Toolbar from "@mui/material/Toolbar";

import { Logo } from "../Logo";
+import { GuideButton } from "../AppDrawer/Guide";

export interface Props {
  onGuideButtonClick: () => void;
}

export function Masthead({ onGuideButtonClick }: Props) {
  return (
    <AppBar elevation={0} position="static">
      <Toolbar>        
-       <IconButton
-         size="large"
-         edge="start"
-         color="inherit"
-         aria-label="menu"
-         sx={{ mr: 2 }}
-         onClick={onGuideButtonClick}
-       >
-         <MenuIcon />
-       </IconButton>
+       <GuideButton onClick={onGuideButtonClick} />
        <Logo />
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

Guide item will also be a reusable component defined like this.

web/app/AppShell/AppDrawer/Guide/GuideItem.tsx

import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import NextLink from "next/link";

import { useActive } from "../../useActive";

interface Props {
  label: string;
  href: string;
  icon: React.ReactNode;
  activeIcon: React.ReactNode;
  onClick: () => void;
}

export function GuideItem(props: Props) {
  const { label, href, icon, activeIcon, onClick } = props;

  const active = useActive(href);

  return (
    <ListItem disablePadding>
      <ListItemButton
        component={NextLink}
        onClick={onClick}
        href={href}
        sx={{
          pl: 2,
          borderRadius: 2.5,
          backgroundColor: (theme) =>
            active ? theme.palette.action.selected : "inherit",
          fontWeight: active ? "bolder" : "normal",
        }}
      >
        <ListItemIcon
          sx={{
            color: (theme) => theme.palette.text.primary,
            minWidth: "42px",
          }}
        >
          {active ? activeIcon : icon}
        </ListItemIcon>
        <ListItemText
          primary={label}
          slotProps={{
            primary: {
              sx: {
                fontWeight: active ? "bolder" : "normal",
                fontSize: 14,
              },
            },
          }}
        />
      </ListItemButton>
    </ListItem>
  );
}
Enter fullscreen mode Exit fullscreen mode

To know if this is an active (selected) element, we created a helper useActive hook.

web/app/AppShell/useActive.tsx

import { usePathname } from "next/navigation";

export function useActive(href: string): boolean {
  const pathname = usePathname();

  let active = false;
  if (href === "/") {
    active = pathname === href;
  } else {
    active = pathname.startsWith(href);
  }

  return active;
}
Enter fullscreen mode Exit fullscreen mode

Finally let's add Guide to the Application Drawer

web/app/AppShell/AppDrawer/AppDrawer.tsx (diff)

import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";

+import { Guide } from "./Guide";

export interface Props {
  open: boolean;
  onDrawerClose: () => void;
  variant?: "temporary" | "permanent";
}

export function AppDrawer(props: Props) {
  const { open, onDrawerClose, variant } = props;

  const handleDrawerClose = () => {
    if (variant === "temporary") onDrawerClose();
  };

  return (
    <Drawer
      open={open}
      onClose={onDrawerClose}
      sx={{
        "& .MuiDrawer-paper": {
          mt: variant === "temporary" ? 0 : 8,
          borderRight: "none",
        },
      }}
      variant={variant}
    >
      <Box
        role="presentation"
        onClick={handleDrawerClose}
        onKeyDown={handleDrawerClose}
        display="flex"
        flexDirection="column"
        justifyContent="space-between"
        sx={{
          height: (theme) =>
            `calc(100% - ${theme.spacing(variant === "temporary" ? 2 : 8)})`,
        }}
      >
-       <Box p={2}>TODO: Guide</Box>
+       <Guide onDrawerClose={handleDrawerClose} variant={variant} />
      </Box>
    </Drawer>
  );
}
Enter fullscreen mode Exit fullscreen mode

Temporary app drawer with guide

Our navigation solution works well, but we only display a temporary variant of the app drawer. We want to show a permanent variant for the desktop resolution. At the same time we can prepare a display logic for other navigation types like Mini Guide and Pivot Bar. To do that, let's introduce a new hook - useNavigation.

web/app/AppShell/useNavigation.tsx

import useMediaQuery from "@mui/material/useMediaQuery";
import useTheme from "@mui/system/useTheme";

export interface Navigation {
  pivotBar?: boolean;
  miniGuide?: boolean;
  guide?: boolean;
  variant?: "temporary" | "permanent";
}

export function useNavigation(opened: boolean): Navigation {
  const theme = useTheme();
  const tablet = useMediaQuery(theme.breakpoints.between("sm", "lg"));
  const desktop = useMediaQuery(theme.breakpoints.up("lg"));

  const navigation: Navigation = {
    pivotBar: true,
    miniGuide: false,
    guide: false,
  };

  if (tablet) {
    return { guide: true, miniGuide: true, variant: "temporary" };
  }

  if (desktop) {
    return opened
      ? { guide: true, variant: "permanent" }
      : { guide: false, miniGuide: true, variant: "temporary" };
  }

  return navigation;
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

"use client";

import * as React from "react";

import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";
+import { useNavigation } from "./useNavigation";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
  const [isAppDrawerOpened, setIsAppDrawerOpened] = React.useState(false);

  const closeAppDrawer = () => {
    setIsAppDrawerOpened(false);
  };
  const toggleAppDrawer = () => {
    setIsAppDrawerOpened(!isAppDrawerOpened);
  };

+ const navigation = useNavigation(isAppDrawerOpened);

  return (
    <>
      <Masthead onGuideButtonClick={toggleAppDrawer} />
+     {navigation.guide && (
        <AppDrawer
          open={isAppDrawerOpened}
          onDrawerClose={closeAppDrawer}
-         variant="temporary"
+         variant={navigation.variant}
        />
+     )}
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Permanent app drawer with guide

GitHub: feat(appshell): guide (#4)

Mini Guide

In some cases we want to display a Mini Guide instead of a fully blown Guide. We will do that for tablet resolution or a desktop one, when the app drawer is closed.

web/app/AppShell/MiniGuide/MiniGuide.tsx

import Drawer from "@mui/material/Drawer";
import HomeIcon from "@mui/icons-material/Home";
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
import List from "@mui/material/List";

import { MiniGuideItem } from "./MiniGuideItem";

export function MiniGuide() {
  return (
    <Drawer
      open={true}
      variant="permanent"
      sx={{
        "& .MuiDrawer-paper": {
          width: (theme) => theme.spacing(9),
          mt: 8,
          pt: 0.3,
          px: 0.8,
          borderRight: "none",
        },
      }}
    >
      <List disablePadding>
        <MiniGuideItem
          label="Home"
          href="/"
          icon={<HomeOutlinedIcon />}
          activeIcon={<HomeIcon />}
        />
      </List>
    </Drawer>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/MiniGuide/MiniGuideItem.tsx

import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import NextLink from "next/link";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";

import { useActive } from "../useActive";

interface Props {
  label: string;
  href: string;
  icon: React.ReactNode;
  activeIcon: React.ReactNode;
}

export function MiniGuideItem(props: Props) {
  const { label, href, icon, activeIcon } = props;

  const active = useActive(href);

  return (
    <ListItem disablePadding>
      <ListItemButton
        component={NextLink}
        href={href}
        sx={{
          borderRadius: 2.5,
        }}
      >
        <Stack alignItems="center" pt={1}>
          {active ? activeIcon : icon}
          <Typography variant="caption" fontSize={10}>
            {label}
          </Typography>
        </Stack>
      </ListItemButton>
    </ListItem>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/MiniGuide/index.ts

export * from "./MiniGuide";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

"use client";

import * as React from "react";

import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";
+import { MiniGuide } from "./MiniGuide";
import { useNavigation } from "./useNavigation";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
  const [isAppDrawerOpened, setIsAppDrawerOpened] = React.useState(false);

  const closeAppDrawer = () => {
    setIsAppDrawerOpened(false);
  };
  const toggleAppDrawer = () => {
    setIsAppDrawerOpened(!isAppDrawerOpened);
  };

  const navigation = useNavigation(isAppDrawerOpened);

  return (
    <>
      <Masthead onGuideButtonClick={toggleAppDrawer} />
+     {navigation.miniGuide && <MiniGuide />}
      {navigation.guide && (
        <AppDrawer
          open={isAppDrawerOpened}
          onDrawerClose={closeAppDrawer}
          variant={navigation.variant}
        />
      )}
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mini guide

GitHub: feat(appshell): mini guide (#5)

Pivot Bar

Similarly, for the mobile resolution we want to display a bottom navigation, which is called a Pivot Bar.

web/app/AppShell/PivotBar/PivotBar.tsx

import BottomNavigation from "@mui/material/BottomNavigation";
import HomeIcon from "@mui/icons-material/Home";
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
import Paper from "@mui/material/Paper";

import { PivotBarItem } from "./PivotBarItem";

export function PivotBar() {
  return (
    <Paper
      sx={{
        position: "fixed",
        bottom: 0,
        left: 0,
        right: 0,
        zIndex: (theme) => theme.zIndex.appBar,
      }}
      elevation={1}
    >
      <BottomNavigation showLabels>
        <PivotBarItem
          label="Home"
          href="/"
          icon={<HomeOutlinedIcon />}
          activeIcon={<HomeIcon />}
        />
      </BottomNavigation>
    </Paper>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/PivotBar/PivotBarItem.tsx

import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import NextLink from "next/link";

import { useActive } from "../useActive";

interface Props {
  label: string;
  href: string;
  icon: React.ReactNode;
  activeIcon: React.ReactNode;
}

export function PivotBarItem(props: Props) {
  const { label, href, icon, activeIcon } = props;

  const active = useActive(href);

  return (
    <BottomNavigationAction
      component={NextLink}
      href={href}
      showLabel
      label={label}
      icon={active ? activeIcon : icon}
      sx={{
        "&.MuiBottomNavigationAction-root": {
          color: "inherit",
        },
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/PivotBar/index.ts

export * from "./PivotBar";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

"use client";

import * as React from "react";

import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";
import { MiniGuide } from "./MiniGuide";
+import { PivotBar } from "./PivotBar";
import { useNavigation } from "./useNavigation";

interface Props {
  children: React.ReactNode;
}

export function AppShell({ children }: Props) {
  const [isAppDrawerOpened, setIsAppDrawerOpened] = React.useState(false);

  const closeAppDrawer = () => {
    setIsAppDrawerOpened(false);
  };
  const toggleAppDrawer = () => {
    setIsAppDrawerOpened(!isAppDrawerOpened);
  };

  const navigation = useNavigation(isAppDrawerOpened);

  return (
    <>
      <Masthead onGuideButtonClick={toggleAppDrawer} />
+     {navigation.pivotBar && <PivotBar />}
      {navigation.miniGuide && <MiniGuide />}
      {navigation.guide && (
        <AppDrawer
          open={isAppDrawerOpened}
          onDrawerClose={closeAppDrawer}
          variant={navigation.variant}
        />
      )}
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We don't want to show the Guide Button when a Pivot Bar navigation is present. Let's change that behaviour.

web/app/AppShell/Masthead/Masthead.tsx (diff)

// ...
export interface Props {
  onGuideButtonClick: () => void;
+ showGuideButton?: boolean;
}

-export function Masthead({ onGuideButtonClick }: Props) {
+export function Masthead({ onGuideButtonClick, showGuideButton }: Props) {
  return (
    <AppBar elevation={0} position="static">
      <Toolbar>
-       <GuideButton onClick={onGuideButtonClick} />
+       {showGuideButton && <GuideButton onClick={onGuideButtonClick} />}
        <Logo />
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

  // ...
  return (
    <>
-     <Masthead onGuideButtonClick={toggleAppDrawer} />
+     <Masthead
+       onGuideButtonClick={toggleAppDrawer}
+       showGuideButton={!navigation.pivotBar}
+     />
      {navigation.pivotBar && <PivotBar />}
      {navigation.miniGuide && <MiniGuide />}
      {navigation.guide && (
        <AppDrawer
          open={isAppDrawerOpened}
          onDrawerClose={closeAppDrawer}
          variant={navigation.variant}
        />
      )}
      {children}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pivot bar

GitHub: feat(appshell): pivot bar (#6)

Page Manager

At the moment content of the page is not displayed in the right place, when Guide or Mini Guide is displayed. Let's add some placeholder to the Home Page e.g. TODO: Home Page to show that.

web/app/page.tsx (diff)

+import Typography from "@mui/material/Typography";

export default function Home() {
- return null;
+ return (
+   <Typography component="h2" variant="h4">
+     TODO: Home Page
+   </Typography>
+ );
}
Enter fullscreen mode Exit fullscreen mode

Mini guide hides page content

Guide hides page content

To fix that we will introduce a Page Manger - a component, which will adjust margin and padding based on the navigation elements displayed. We will also set the maximum width of the page to the xxxl breakpoint.

web/app/AppShell/PageManager/PageManager.tsx

import Box from "@mui/material/Box";
import Container from "@mui/material/Container";

import { Navigation } from "../useNavigation";

interface Props {
  navigation: Navigation;
  children: React.ReactNode;
}

export function PageManager({ navigation, children }: Props) {
  let ml = 0;
  let px = 0;

  if (navigation.guide && navigation.variant === "permanent") {
    ml = 30;
    px = 3;
  }

  if (navigation.miniGuide) {
    ml = 9;
    px = 3;
  }

  return (
    <Container maxWidth={"xxxl"} disableGutters>
      <Box
        sx={{
          ml,
          mt: 1,
          px,
        }}
      >
        {children}
      </Box>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/PageManager/index.ts

export * from "./PageManager";
Enter fullscreen mode Exit fullscreen mode

web/app/AppShell/AppShell.tsx (diff)

"use client";

import * as React from "react";

import { AppDrawer } from "./AppDrawer";
import { Masthead } from "./Masthead";
import { MiniGuide } from "./MiniGuide";
+import { PageManager } from "./PageManager";
import { PivotBar } from "./PivotBar";
import { useNavigation } from "./useNavigation";
// ...

// ...
  return (
    <>
      <Masthead
        onGuideButtonClick={toggleAppDrawer}
        showGuideButton={!navigation.pivotBar}
      />
      {navigation.pivotBar && <PivotBar />}
      {navigation.miniGuide && <MiniGuide />}
      {navigation.guide && (
        <AppDrawer
          open={isAppDrawerOpened}
          onDrawerClose={closeAppDrawer}
          variant={navigation.variant}
        />
      )}
-     {children}
+     <PageManager navigation={navigation}>{children}</PageManager>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Looks much better now.

Mini guide doesn't hide page content

Guide doesn't hide page content

GitHub: feat(appshell): page manager (#16)

SSR (Server-Side Rendering)

When you load or refresh our web app on a tablet or mobile resolution - there is a flash of mobile version, before useMediaQuery is executed on a client side and adjusts navigation elements.

Flash of mobile version

To fix that - we can use Server-Side Rendering for useMediaQuery, but there is a catch.

Server-side rendering and client-side media queries are fundamentally at odds. Be aware of the tradeoff. The support can only be partial.

Let's start with extracting part of the web/app/layout.tsx to a separate web/app/providers.tsx file.

"use client";

import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";

import theme from "./theme";

export function Providers({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <AppRouterCacheProvider>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        {children}
      </ThemeProvider>
    </AppRouterCacheProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/layout.tsx (diff)

-import CssBaseline from "@mui/material/CssBaseline";
-import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
-import { ThemeProvider } from "@mui/material/styles";

-import theme from "./theme";
import { AppShell } from "./AppShell";
+import { Providers } from "./providers";

const roboto = Roboto({
  weight: ["300", "400", "500", "700"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-roboto",
});

export const metadata: Metadata = {
  title: "FakeTube",
  description: "Enjoy the videos and music you love on FakeTube.",
  keywords: "video, free",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className={roboto.variable}>
      <body>
-       <AppRouterCacheProvider>
-       <ThemeProvider theme={theme}>
-       <CssBaseline />
+       <Providers>
          <AppShell>{children}</AppShell>
+       </Providers>
-       </ThemeProvider>
-       </AppRouterCacheProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

This SSR implementation will use User-Agent request header to detect device type (mobile, tablet, desktop) and based on that will provide arbitrary screen width to the useMediaQuery.

Let's install a few dependencies first.

npm install css-mediaquery ua-parser-js
npm install --save-dev @types/css-mediaquery @types/ua-parser-js
Enter fullscreen mode Exit fullscreen mode

web/app/layout.tsx (diff)

import type { Metadata } from "next";
+import { headers } from "next/headers";
import { Roboto } from "next/font/google";
+import { UAParser } from "ua-parser-js";

// ...

+const FALLBACK_DEVICE_TYPE = "mobile";

-export default function RootLayout({
+export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
+ const headersList = await headers();
+ const userAgent = headersList.get("user-agent") || undefined;
+ const uap = new UAParser(userAgent);
+ const deviceType = (await uap.getDevice().withFeatureCheck()).type || FALLBACK_DEVICE_TYPE;

  return (
    <html lang="en" className={roboto.variable}>
      <body>
-       <Providers>
+       <Providers deviceType={deviceType}>
          <AppShell>{children}</AppShell>
        </Providers>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/providers.tsx (diff)

// ...

export function Providers({
  children,
+ deviceType,
}: Readonly<{
  children: React.ReactNode;
+  deviceType: string;
}>) {
  return (
    <AppRouterCacheProvider>
-     <ThemeProvider theme={theme}>
+     <ThemeProvider theme={theme(deviceType)}>
        <CssBaseline />
        {children}
      </ThemeProvider>
    </AppRouterCacheProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/theme.ts (diff)

"use client";

+import mediaQuery from "css-mediaquery";
import { createTheme } from "@mui/material/styles";

declare module "@mui/material/styles" {
  interface BreakpointOverrides {
    xxl: true;
    xxxl: true;
  }
}

+function deviceTypeToWidth(deviceType: string) {
+  switch (deviceType) {
+    case "mobile":
+      return "600px";
+    case "tablet":
+      return "792px";
+    default:
+      return "1313px";
+  }
+}

+const ssrMatchMedia = (deviceType: string) => (query: string) => ({
+  matches: mediaQuery.match(query, {
+    width: deviceTypeToWidth(deviceType),
+  }),
+});

-const theme = createTheme({
+const theme = (deviceType: string) =>
+  createTheme({
     typography: {
       fontFamily: "var(--font-roboto)",
     },
     breakpoints: {
       values: {
         xs: 0,
         sm: 601,
         md: 792,
         lg: 1313,
         xl: 1536,
         xxl: 1920,
         xxxl: 2985,
       },
     },
     components: {
+      MuiUseMediaQuery: {
+        defaultProps: {
+          ssrMatchMedia: ssrMatchMedia(deviceType),
+        },
+      },
       MuiAppBar: {
         styleOverrides: {
           colorPrimary: {
             backgroundColor: "#FFFFFF",
             color: "#000000",
           },
         },
       },
     },
   });

export default theme;
Enter fullscreen mode Exit fullscreen mode

Unfortunately it seems that ua-parser-js doesn't always correctly identify a device type, see iPad Air and iPad Pro - type not correctly identified, so it doesn't work correctly on my Chrome browser on MacOS. We will have to definitely look at improving it. At the moment we can adjust FALLBACK_DEVICE_TYPE to change the behaviour.

GitHub: feat(appshell): server-side rendering (#17)

Amplify Hosting

The last thing to do is to deploy our web application and make it publicly accessible from the faketube.app domain.

We will use AWS Amplify service and its hosting feature.

Go to AWS Amplify and click Deploy an app button. Choose GitHub as a Git provider and click Next.

Choose JacekKosciesza/FakeTube repository and main branch. Our app is a monorepo, so let's set web as a root directory. Click Next.

Set the name of our app to FakeTube. All other settings should be auto detected and configured as a Next.js app. Click Next.

After reviewing all the settings click Save and deploy.

After a while the app should be deployed to the amplifyapp.com domain with some random subdomain.

Amplify - Create new app

To use our faketube.app domain, we have to click Add custom domain from the Overview tab.

Click Add domain, type faketube.app and click Check domain availability. We already own this domain (GoDaddy domain registrar), so Amplify will detect it and automatically select Create hosted zone on Route53 option. Let's click Configure domain button.

We will have to copy the name server names and paste them on the GoDaddy domain configuration page. After some longer time (usually minutes not hours) Amplify should automatically finish setup - create SSL configuration and activate the domain.

Amplify - Add custom domain

Now faketube.app should be listed as a custom domain and we should see our app when we visit it.

faketube.app

Top comments (2)

Collapse
 
ultron01 profile image
Petr Vurm

Dear App Developer,

I am the sole owner of the registered trademark "FakeTube," which is protected under applicable intellectual property laws. It has come to my attention that your application is using the name "FakeTube" without authorization.

This unauthorized use constitutes trademark infringement and may cause confusion among consumers, potentially damaging the reputation and goodwill associated with my brand.

I hereby demand that you:

  1. Cease and desist from all use of the "FakeTube" trademark immediately.
  2. Remove or rebrand any content, applications, or services bearing the "FakeTube" name within 7 days of this notice.

Failure to comply with these demands may result in legal action to enforce my trademark rights.

Please confirm in writing that you have taken the necessary actions to comply with this notice.

Sincerely,
Petr Vurm

Collapse
 
jacekkosciesza profile image
Jacek Kościesza

@ultron01 Thanks Petr for letting me know about this issue. I will contact you directly when I get back from vacation.