Created Layout & Responsive Header Component

This commit is contained in:
aftabrehan
2024-02-01 20:28:42 +05:00
parent 9ac97c7f9a
commit bc7cad7e3b
21 changed files with 413 additions and 30 deletions

View File

@@ -6,20 +6,22 @@ Built using Next.js and Tailwind CSS.
1. Clone the repository:
```
```bash
git clone https://github.com/aftabrehan/zin-tools.git
# or
gh repo clone aftabrehan/zin-tools
```
2. Install the required dependencies:
```bash
npm
npm install
# or
yarn
yarn install
# or
pnpm
pnpm install
# or
bun
bun install
```
3. Run the development server:
@@ -36,7 +38,5 @@ bun dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
> [!NOTE]
> This project is created solely for test/portfolio purposes, and you are free to use it as you see fit.

View File

@@ -1,5 +1,9 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import type { Metadata } from 'next'
import { Header } from '@/components/header'
import { Footer } from '@/components/footer'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
@@ -16,7 +20,17 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<Header />
<main className="w-full min-h-[calc(100vh-144px)] mt-36">
<div className="w-full max-w-7xl mx-auto h-full">{children}</div>
</main>
<div
id="modal"
className="flex items-center justify-center w-screen h-screen fixed top-0 left-[50%] translate-x-[-50%] z-1000 overflow-y-hidden pointer-events-none"
/>
<Footer />
</body>
</html>
)
}

View File

@@ -1,7 +1,7 @@
const NotFoundPage = () => (
<main className="w-full h-full">
<p>Not Found Page</p>
</main>
<div className="w-full h-full">
<p>404 Page</p>
</div>
)
export default NotFoundPage

View File

@@ -1,7 +1,7 @@
const Home = () => (
<main className="w-full h-full">
<p>My Clean App</p>
</main>
<div className="w-full h-full">
<p>Home Page</p>
</div>
)
export default Home

42
components/MobileNav.tsx Normal file
View File

@@ -0,0 +1,42 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { Modal } from '@/components//modal/modal'
import { NavDropdown } from '@/components/nav-dropdown'
import { ALL_NAV_ITEMS } from '@/constants/nav-items'
export const MobileNav = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button
className="w-12 h-12 flex items-center justify-center rounded-md"
onClick={() => setIsOpen(!isOpen)}
style={{ background: isOpen ? '#000' : '#f4f4f5' }}
>
<Image
alt=""
src={isOpen ? '/cross.svg' : '/hamburger.svg'}
width={24}
height={24}
/>
</button>
<Modal isOpen={isOpen} close={() => setIsOpen(false)}>
<div className="w-full h-[calc(100vh-144px)] mt-36 flex sm:hidden bg-white pointer-events-auto border-t border-zinc-100 border animation-slide-from-right">
<nav className="w-full">
<ul className="w-full flex flex-col items-end justify-center gap-2 p-4">
{ALL_NAV_ITEMS.map((navItem, i) => (
<NavDropdown key={i} {...navItem} />
))}
</ul>
</nav>
</div>
</Modal>
</>
)
}

30
components/button.tsx Normal file
View File

@@ -0,0 +1,30 @@
import clsx from 'clsx'
interface buttonProps {
label: string
onClick?: () => void
variant?: 'primary' | 'secondary'
}
export const Button = ({
label,
onClick,
variant = 'primary',
}: buttonProps) => {
const cls = {
primary: 'bg-primary text-white',
secondary: 'text-black hover:bg-zinc-100',
}[variant]
return (
<button
onClick={onClick}
className={clsx(
'h-12 px-5 flex items-center justify-center text-base font-medium text-nowrap rounded-full hover:opacity-75 transition-all duration-200',
cls
)}
>
{label}
</button>
)
}

6
components/footer.tsx Normal file
View File

@@ -0,0 +1,6 @@
export const Footer = () => (
// TODO: remove dummy height min-h-[600px]
<footer className="w-full min-h-[600px] bg-gray-100">
<div className="w-full max-w-7xl mx-auto">Footer</div>
</footer>
)

39
components/header.tsx Normal file
View File

@@ -0,0 +1,39 @@
import Link from 'next/link'
import Image from 'next/image'
import { Searchbar } from '@/components/search-bar'
import { Button } from '@/components/button'
import { Navbar } from '@/components/navbar'
import { MobileNav } from '@/components/MobileNav'
export const Header = () => (
<header className="w-full h-36 fixed top-0 shadow-header px-4 pt-6 bg-white">
<div className="w-full max-w-7xl mx-auto flex flex-col items-center justify-center gap-3 sm:gap-4 md:gap-6">
<div className="w-full flex items-center justify-between">
<Link href="/" className="overflow-hidden">
<Image
src="/logo.svg"
alt="ZinTools Logo"
width={138}
height={38}
className="w-[120px] sm:w-[130px] md:w-[138px] h-auto"
/>
</Link>
<Searchbar className="hidden sm:flex" />
<div className="flex items-center justify-center gap-2">
<Button label="Login" variant="secondary" />
<Button label="Sign up" />
</div>
</div>
<Navbar className="hidden sm:block" />
<div className="w-full flex sm:hidden gap-x-2">
<Searchbar />
<MobileNav />
</div>
</div>
</header>
)

View File

@@ -0,0 +1,19 @@
import { useRef, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
interface modalPortal {
children: React.ReactNode
selector?: string
}
export const ModalPortal = ({ children, selector = 'modal' }: modalPortal) => {
const ref = useRef<HTMLDivElement | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
ref.current = document.querySelector(selector)
setMounted(true)
}, [selector])
return mounted && ref.current ? createPortal(children, ref.current) : null
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
import clsx from 'clsx'
import { ModalPortal } from '@/components/modal/modal-portal'
import { useClickOutside } from '@/hooks/useClickOutside'
interface modalProps {
isOpen: boolean
close: () => void
children: React.ReactNode
closeOnClickAway?: boolean
className?: string
}
export const Modal = ({
isOpen,
close,
children,
closeOnClickAway = false,
className,
}: modalProps) => {
const contentRef = useRef(null)
useClickOutside({
onClick: () => closeOnClickAway && close(),
ref: contentRef,
})
useEffect(() => {
if (!isOpen) return
}, [isOpen])
return isOpen ? (
<ModalPortal selector="#modal">
<div ref={contentRef} className={clsx('w-full h-full', className)}>
{children}
</div>
</ModalPortal>
) : null
}

View File

@@ -0,0 +1,50 @@
import Link from 'next/link'
import clsx from 'clsx'
interface navDropdownProps {
title: string
link: string
options: Array<{ label: string; link: string }>
position?: 'left' | 'right' | 'center'
}
export const NavDropdown = ({
title,
link,
options,
position = 'center',
}: navDropdownProps) => {
const positionCls = {
left: 'left-0',
right: 'right-0',
center: '',
}[position]
return (
<li className="group relative flex items-center justify-center">
<Link
href={link}
className="relative text-base sm:text-sm font-medium whitespace-nowrap px-3 py-3.5 after:absolute after:bottom-0 after:left-0 after:content-none::after after:w-full after:h-[2px] after:bg-primary after:hidden group-hover:after:block"
>
{title}
</Link>
<div
className={clsx(
'hidden sm:group-hover:flex absolute top-12 flex-col min-w-[220px] gap-2 p-4 bg-white rounded-b-md shadow-md border border-zinc-1',
positionCls
)}
>
{options.map(({ label, link: opLink }, i) => (
<Link
key={i}
href={opLink}
className="w-full p-2 hover:bg-zinc-100 rounded-md transition-colors duration-200 text-sm"
>
{label}
</Link>
))}
</div>
</li>
)
}

45
components/navbar.tsx Normal file
View File

@@ -0,0 +1,45 @@
import clsx from 'clsx'
import { NavDropdown } from '@/components/nav-dropdown'
import { ALL_NAV_ITEMS, LESS_NAV_ITEMS } from '@/constants/nav-items'
interface navbarProps {
className?: string
}
export const Navbar = ({ className }: navbarProps) => (
<nav className={clsx('w-full max-w-6xl', className)}>
<ul className="w-full hidden lg:flex items-center justify-between gap-1">
{ALL_NAV_ITEMS.map((navItem, i) => (
<NavDropdown
key={i}
position={
i === 0
? 'left'
: i === ALL_NAV_ITEMS.length - 1
? 'right'
: 'center'
}
{...navItem}
/>
))}
</ul>
<ul className="w-full flex lg:hidden items-center justify-between gap-1">
{LESS_NAV_ITEMS.map((navItem, i) => (
<NavDropdown
key={i}
position={
i === 0
? 'left'
: i === LESS_NAV_ITEMS.length - 1
? 'right'
: 'center'
}
{...navItem}
/>
))}
</ul>
</nav>
)

33
components/search-bar.tsx Normal file
View File

@@ -0,0 +1,33 @@
import Image from 'next/image'
import clsx from 'clsx'
interface searchbarProps {
className?: string
}
export const Searchbar = ({ className }: searchbarProps) => (
<div
className={clsx(
'flex-1 sm:max-w-[280px] md:max-w-[400px] lg:max-w-[500px] flex items-center justify-between gap-x-4 md:gap-x-6 px-4 sm:px-6 py-1.5 bg-zinc-100 rounded-3xl',
className
)}
>
<input
type="search"
placeholder="Search for Movies, TV Shows, Themes & Cast"
className="flex flex-1 outline-none bg-transparent text-black text-base"
/>
<div className="flex items-center justify-end gap-x-2 md:gap-x-6">
<div className="w-[1px] h-9 bg-gray-400/50 cursor-default" />
<div className="w-8 h-8 flex items-center justify-center hover:bg-gray-200 rounded-full cursor-pointer">
<Image
alt=""
src="/search.svg"
width={16}
height={16}
className="select-none"
/>
</div>
</div>
</div>
)

28
constants/nav-items.ts Normal file
View File

@@ -0,0 +1,28 @@
const placeholderOptions = [
{ label: 'Stock Fotage', link: '/' },
{ label: 'Motion Graphics', link: '/' },
{ label: 'All Stock Videos', link: '/' },
]
const commonProps = { link: '/', options: placeholderOptions }
export const ALL_NAV_ITEMS = [
{ title: 'Stock Video', ...commonProps },
{ title: 'Video Templates', ...commonProps },
{ title: 'Music', ...commonProps },
{ title: 'Sound Effects', ...commonProps },
{ title: 'Graphic Templates', ...commonProps },
{ title: 'Presentation Templates', ...commonProps },
{ title: 'Graphics', ...commonProps },
{ title: 'Photos', ...commonProps },
{ title: 'More', ...commonProps },
]
export const LESS_NAV_ITEMS = [
{ title: 'Stock Video', ...commonProps },
{ title: 'Video Templates', ...commonProps },
{ title: 'Music', ...commonProps },
{ title: 'Sound Effects', ...commonProps },
{ title: 'Graphic Templates', ...commonProps },
{ title: 'More', ...commonProps },
]

16
hooks/useClickOutside.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react'
interface funcProps {
onClick: () => void
ref: { current: HTMLDivElement } | { current: null }
}
export const useClickOutside = ({ onClick, ref }: funcProps) => {
useEffect(() => {
const handleClickOutside = (e: any) =>
ref.current && !ref.current.contains(e.target) && onClick()
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClick, ref])
}

View File

@@ -9,19 +9,20 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"next": "14.1.0"
"react-dom": "^18"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.1.0"
"typescript": "^5"
}
}

3
public/cross.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.30563 18.2187C3.89812 18.6263 3.89812 19.2869 4.30563 19.6944C4.71313 20.1019 5.37383 20.1019 5.78133 19.6944L4.30563 18.2187ZM12.7378 12.7378C13.1453 12.3303 13.1453 11.6697 12.7378 11.2622C12.3303 10.8547 11.6697 10.8547 11.2622 11.2622L12.7378 12.7378ZM11.2622 11.2622C10.8547 11.6697 10.8547 12.3303 11.2622 12.7378C11.6697 13.1453 12.3303 13.1453 12.7378 12.7378L11.2622 11.2622ZM19.6944 5.78133C20.1019 5.37383 20.1019 4.71313 19.6944 4.30563C19.2869 3.89812 18.6263 3.89812 18.2187 4.30563L19.6944 5.78133ZM12.7378 11.2622C12.3303 10.8547 11.6697 10.8547 11.2622 11.2622C10.8547 11.6697 10.8547 12.3303 11.2622 12.7378L12.7378 11.2622ZM18.2187 19.6944C18.6263 20.1019 19.2869 20.1019 19.6944 19.6944C20.1019 19.2869 20.1019 18.6263 19.6944 18.2187L18.2187 19.6944ZM11.2622 12.7378C11.6697 13.1453 12.3303 13.1453 12.7378 12.7378C13.1453 12.3303 13.1453 11.6697 12.7378 11.2622L11.2622 12.7378ZM5.78133 4.30563C5.37383 3.89812 4.71313 3.89812 4.30563 4.30563C3.89812 4.71313 3.89812 5.37383 4.30563 5.78133L5.78133 4.30563ZM5.78133 19.6944L12.7378 12.7378L11.2622 11.2622L4.30563 18.2187L5.78133 19.6944ZM12.7378 12.7378L19.6944 5.78133L18.2187 4.30563L11.2622 11.2622L12.7378 12.7378ZM11.2622 12.7378L18.2187 19.6944L19.6944 18.2187L12.7378 11.2622L11.2622 12.7378ZM12.7378 11.2622L5.78133 4.30563L4.30563 5.78133L11.2622 12.7378L12.7378 11.2622Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

5
public/hamburger.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 5H4" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M20 12H4" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M20 19H4" stroke="black" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

3
public/search.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.7834 14.6461L11.3595 10.2222C12.3467 8.94813 12.8117 7.34591 12.6598 5.7413C12.5079 4.13668 11.7506 2.65014 10.5419 1.58393C9.33315 0.517707 7.76372 -0.0481467 6.1527 0.00141407C4.54168 0.0509749 3.01 0.71223 1.8691 1.85073C0.728193 2.98923 0.0637091 4.51951 0.0107508 6.13042C-0.0422074 7.74134 0.520335 9.31196 1.584 10.5229C2.64767 11.7339 4.13261 12.4943 5.7369 12.6496C7.34118 12.8049 8.94439 12.3433 10.2205 11.3588L14.6444 15.7835C14.7968 15.9266 14.9989 16.0048 15.2079 16.0015C15.4169 15.9982 15.6165 15.9137 15.7643 15.7659C15.9121 15.6181 15.9966 15.4186 15.9998 15.2096C16.0031 15.0006 15.9249 14.7985 15.7818 14.6461H15.7834ZM3.01238 9.69351C2.35138 9.03269 1.90119 8.1907 1.71875 7.27402C1.53631 6.35733 1.62981 5.40713 1.98743 4.54359C2.34505 3.68005 2.95072 2.94195 3.72785 2.42266C4.50497 1.90336 5.41864 1.62618 6.35331 1.62618C7.28797 1.62618 8.20164 1.90336 8.97877 2.42266C9.75589 2.94195 10.3616 3.68005 10.7192 4.54359C11.0768 5.40713 11.1703 6.35733 10.9879 7.27402C10.8054 8.1907 10.3552 9.03269 9.69424 9.69351C9.25757 10.1359 8.73738 10.4872 8.16386 10.7269C7.59035 10.9667 6.97492 11.0902 6.35331 11.0902C5.73169 11.0902 5.11627 10.9667 4.54275 10.7269C3.96923 10.4872 3.44905 10.1359 3.01238 9.69351Z" fill="#9DA3AE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,20 +1,21 @@
import type { Config } from "tailwindcss";
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
colors: {
primary: '#616DF4',
},
boxShadow: {
header: '0px 4px 12px 0px rgba(189, 189, 189, 0.25)',
},
},
},
plugins: [],
};
export default config;
}
export default config

View File

@@ -591,6 +591,11 @@ client-only@0.0.1:
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
clsx@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb"
integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@@ -2242,6 +2247,7 @@ streamsearch@^1.1.0:
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -2302,6 +2308,7 @@ string.prototype.trimstart@^1.0.7:
es-abstract "^1.22.1"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==