[WebUI]Tailwind + nextjs(Horizantal Nav Menu) – 5

πŸ‘‰πŸ» μ—¬κΈ°μ„œλŠ” μ›Ήμ‚¬μ΄νŠΈλ₯Ό λ§Œλ“€λ•Œ 상단 메뉴 뢀뢄을 λ§Œλ“­λ‹ˆλ‹€.
Here, we will create the top menu section for the website.

πŸ‘‰πŸ» μ—¬λŸ¬κ°€μ§€ νƒ€μž…μ΄ μžˆμ§€λ§Œ μ˜€λŠ˜μ€ μˆ˜ν‰ λ„€λΉ„κ²Œμ΄μ…˜ 메뉴λ₯Ό λ§Œλ“­λ‹ˆλ‹€.(Horizontal Nav Menu)
There are various types, but today we will create a horizontal navigation menu.

πŸ‘‰πŸ» 메뉴λ₯Ό μ»΄ν¬λ„ŒνŠΈ ν˜•μ‹μœΌλ‘œ λ§Œλ“€μ—ˆκ³  tailwindcssλ₯Ό μ‚¬μš©ν–ˆμŠ΅λ‹ˆλ‹€.
I created the menu as a component and used Tailwind CSS.

πŸ‘‰πŸ» λ„ˆλ¬΄ μ½”λ“œκ°€ κΈΈμ–΄μ Έμ„œ μ—¬κΈ°μ„œλŠ” tailwindcss둜만 μ§„ν–‰ν•©λ‹ˆλ‹€.
Since the code has become too long, I will proceed using only Tailwind CSS here.

πŸ“ 디렉토리 ꡬ쑰 / Directory structure

tailwindcss-nextjs/
└── app/
    β”œβ”€β”€ layout.tsx
    β”œβ”€β”€ page.tsx (루트)
    β”œβ”€β”€ globals.css
    β”œβ”€β”€ favicon.ico
    β”‚
    β”œβ”€β”€ ui/                          ← components
    β”‚   β”œβ”€β”€ page.tsx             
    β”‚   └── _components/            
    β”‚       β”œβ”€β”€ button.tsx           ← Button
    β”‚       β”œβ”€β”€ dropdown.tsx
    β”‚       └── navigation/
    β”‚           └── HorizontalNavMenu.tsx  ← NavMenu
    β”‚
    β”œβ”€β”€ hmenu/                       ← Menu test route
    β”‚   β”œβ”€β”€ layout.tsx               ← layout
    β”‚   β”œβ”€β”€ page.tsx                 ← /hmenu
    β”‚   β”œβ”€β”€ game/ -> page.tsx        ← /hmenu/game
    β”‚   β”œβ”€β”€ music/ -> page.tsx       ← /hmenu/music
    β”‚   β”œβ”€β”€ profile/ -> page.tsx     ← /hmenu/profile
    β”‚   └── help/ -> page.tsx        ← /hmenu/help
    β”‚
    β”œβ”€β”€ button-css/ , button-tailwind/ , card-css/ ... (예제 νŽ˜μ΄μ§€λ“€/Example pages)
    └── form-css/ , form-tailwind/ ...

πŸ“ μ•„μ΄ν”Όμ£Όμ†Œν—ˆμš© / Allow IP address

βœ”οΈ next.config.ts의 μ½”λ“œλ₯Ό μ•„λž˜μ²˜λŸΌ μˆ˜μ •ν•©λ‹ˆλ‹€.
Modify the code in next.config.ts as shown below.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
 allowedDevOrigins: ['192.168.0.10', '192.168.0.*'],
};

export default nextConfig;

1)192.168.0.10은 μ—¬λŸ¬λΆ„μ˜ 아이피 μ£Όμ†Œλ₯Ό μž…λ ₯ν•˜λ©΄λ©λ‹ˆλ‹€.
You should enter your IP address where 192.168.0.10 is shown.

2)이 뢀뢄을 μˆ˜μ •ν•˜μ§€ μ•ŠμœΌλ©΄ λͺ¨λ°”일 λΈŒλΌμš°μ €μ—μ„œ http://your ip address/hmenu μ΄λ ‡κ²Œ μ‹€ν–‰ν•˜λ©΄ μžλ°”μŠ€ν¬λ¦½νŠΈκ°€ λ™μž‘ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
If you do not modify this part, the JavaScript will not function when you access http://your ip address/hmenu from a mobile browser.

3)μœ„μ˜ 아이피λ₯Ό ν—ˆμš©ν•˜κ³  μ ‘μ†ν•˜λ©΄ μ •μƒμ μœΌλ‘œ λͺ¨λ°”μΌμ—μ„œλ„ 메뉴λ₯Ό ν…ŒμŠ€νŠΈ ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
Once you allow the IP address mentioned above and connect, you will be able to test the menu on mobile devices as expected.

πŸ“ 메뉴 μ»΄ν¬λ„ŒνŠΈ / Menu Component

βœ”οΈ λ©”λ‰΄λŠ” μ»΄ν¬λ„ŒνŠΈ ν˜•νƒœλ‘œ λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.
The menu was created as a component.

βœ”οΈ app/ui/_components/navigation/HoriztalNavMenu.tsx

βœ”οΈ 메뉴라벨 및 라우트
Menu Labels and Routes

const LINKS = [
  { label: 'κ²Œμž„/Game', href: '/hmenu/game' },
  { label: 'μŒμ•…/Music', href: '/hmenu/music' },
  { label: 'ν”„λ‘œν•„/Profile', href: '/hmenu/profile' },
  { label: '문의/Help', href: '/hmenu/help' },
]

1) label은 λ©”λ‰΄μ—μ„œ 보여쀄 ν…μŠ€νŠΈμ΄κ³  hrefλŠ” μ‹€μ œ 이동할 νŽ˜μ΄μ§€ 라우트 μž…λ‹ˆλ‹€.
‘label’ is the text to be displayed in the menu, and ‘href’ is the actual page route to navigate to.

βœ”οΈ ν™ˆ 디렉토리 링크,PC용 메뉴
Home directory link, menu for PC

        <a href="/hmenu" className="text-xl font-black">freelifemakers</a>
        
        {/** PC */}
        <nav className="hidden md:flex gap-6">
          {LINKS.map(l => (
            <Link 
              key={l.href} 
              href={l.href}
              className={`text-sm font-medium ${pathname === l.href ? 'text-black font-bold underline' : 'text-gray-600'}`}
            >
              {l.label}
            </Link>
          ))}
        </nav>

1)λ©”λ‰΄μ—μ„œ freelifemakersλ₯Ό ν΄λ¦­ν•˜λ©΄ ν™ˆλ””λ ‰ν† λ¦¬λ‘œ μ΄λ™ν•©λ‹ˆλ‹€.
Clicking “freelifemakers” in the menu will take you to the home directory.

2)<nav> … </nav>의 μ½”λ“œλŠ” PCμ—μ„œ λ³΄μ—¬μ§€λŠ” λ©”λ‰΄μž…λ‹ˆλ‹€.
The code within <nav> … </nav> represents the menu displayed on PCs.

3) <nav className=”hidden md:flex gap-6″>
이 μ½”λ“œλŠ” λͺ¨λ°”일에선 숨기고, PC(md 이상)μ—μ„œλ§Œ 보이도둝 μ„€μ •ν•©λ‹ˆλ‹€.
This code is set to be hidden on mobile and visible only on PC (md size and up).

4)mdλŠ” 768px md:hidden md값보닀 화면이 크면 화면에 메뉴λ₯Ό λ³΄μ—¬μ€λ‹ˆλ‹€.
The md breakpoint is 768px; md:hidden means the menu will be displayed on screen whenever the screen size is larger than this md value.

βœ”οΈ λͺ¨λ°”μΌμš© 메뉴 / Menu for mobile

    const [mobileOpen, setMobileOpen] = useState(false)
  ... ...   
  <div className="flex items-center gap-3">
          <button
            type="button"
            onPointerUp={() => setMobileOpen(v => !v)} // onClick λŒ€μ‹  onPointerUp(λͺ¨λ°”일에 졜적) / Use onPointerUp instead of onClick (optimized for mobile)
            // mobile 
            className="md:hidden w-11 h-11 flex items-center justify-center rounded-full bg-gray-100 active:bg-gray-200 touch-manipulation"
          >
            <span className="text-xl">{mobileOpen ? 'βœ•' : '☰'}</span>
          </button>
        </div>
      </div>

      {mobileOpen && (
        <div className="md:hidden absolute top-16 left-0 w-full bg-white border-t px-6 py-6 flex flex-col gap-2 shadow-xl">
          <div className="text-xs text-green-600 bg-green-50 p-2 rounded">Menu</div>
          {LINKS.map(l => (
            <Link key={l.href} href={l.href} onClick={() => setMobileOpen(false)} className="py-3 font-medium">
              {l.label}
            </Link>
          ))}
        </div>
      )}

1)md:hidden 은 md(768px)보닀 화면이 크면 메뉴λ₯Ό 숨기고 768px보닀 μž‘μ•„μ§€λ©΄ λ³΄μ—¬μ€λ‹ˆλ‹€.(λͺ¨λ°”μΌμš©)
md:hidden hides the menu when the screen is larger than md (768px) and displays it when the screen is smaller than 768px (for mobile use).

2) {mobileOpen && ( … ) } 이 뢀뢄은 λͺ¨λ°”일 용으둜 햄버거 메뉴λ₯Ό λˆ„λ₯΄λ©΄ λ‚˜νƒ€λ‚˜λŠ” λ©”λ‰΄μž…λ‹ˆλ‹€.
The {mobileOpen && ( ... )} section represents the menu that appears when the hamburger menu is clicked on mobile devices.

3)mobileOpen의 값을 true,false둜 λ°˜μ „μ‹œμΌœμ„œ 메뉴λ₯Ό open,closeν•©λ‹ˆλ‹€.
Toggling the value of mobileOpen between true and false opens or closes the menu.

4)메뉴 μ—΄λ¦Ό,λ‹«νž˜ λ‘œμ§μ€ μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.
The logic for opening and closing the menu is as follows.

1. λ³€μˆ˜μ„€μ • / Variable setting
const [mobileOpen, setMobileOpen] = useState(false)

2.메뉴 μ—΄λ¦Ό,λ‹«νž˜ λ²„νŠΌ / Menu open/close button
<button type="button" onPointerUp={() => setMobileOpen(v => !v)} > ... </button>

3.mobileOPen의 μƒνƒœμ— 따라 보여쀄 메뉴, 링크 ν΄λ¦­ν•˜λ©΄ 메뉴 λ‹«κΈ°
Display the menu based on the state of `mobileOpen`; close the menu when a link is clicked.

{mobileOpen && ( ... ...

<Link key={l.href} href={l.href} onClick={() => setMobileOpen(false)} 
      className="py-3 font-medium">{l.label}
</Link>
 )

πŸ“ 메뉴 적용 / Apply Menu

βœ”οΈ Layout.tsxνŒŒμΌμ—μ„œ 전체 νŽ˜μ΄μ§€μ— 메뉴가 적용될 수 μžˆλ„λ‘ ν•©λ‹ˆλ‹€.
In the Layout.tsx file, ensure that the menu is applied to the entire page.

import { NavMenu } from '../ui/_components/navigation/HorizontalNavMenu'

export default function HmenuLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <NavMenu />
      <main className="min-h-screen bg-gray-50">
        <div className="mx-auto max-w-6xl px-6 py-20">
          {children}
        </div>
      </main>
    </>
  )
}

πŸ“ λ©”μΈνŽ˜μ΄μ§€ / Main Page

βœ”οΈ 메뉴λ₯Ό Layout.tsx에 μ μš©ν–ˆκΈ°λ•Œλ¬Έμ— μ—¬κΈ°μ„œλŠ” 메뉴λ₯Ό λ”°λ‘œ μ μš©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
Since the menu has been applied in Layout.tsx, it is not applied separately here.

'use client'
import { Button } from '../ui/_components/button'

export default function MainPage() {
  return (
    <>
      <main className="min-h-screen bg-gray-50">
        <div className="mx-auto max-w-6xl px-6 py-20">
          <h1 className="text-4xl font-bold">HorizontalNavMenu</h1>
          <p className="text-gray-500 mt-2">κΈ°λ³Έ λ„€λΉ„κ²Œμ΄μ…˜ λ©”λ‰΄μž…λ‹ˆλ‹€.</p>
          <p className="text-gray-500 mt-2">This is the default navigation menu.</p>
          <div className="flex gap-x-4 m-4">
            <div>
            <Button onClick={() => alert('button test')} >Button test</Button>
            </div>
            <div>
            <Button onClick={() => console.log('Game Start')}>Game STart</Button>
            </div>
            </div>
        </div>
      </main>
    </>
  )
}

πŸ“ μ„œλΈŒνŽ˜μ΄μ§€ / Subpage

βœ”οΈ λ©”λ‰΄μ˜ μ„œλ²„νŽ˜μ΄μ§€λŠ” μ½”λ“œκ°€ λͺ¨λ‘ λ™μΌν•©λ‹ˆλ‹€.
The server pages for the menu all contain identical code.

βœ”οΈ νŽ˜μ΄μ§€λ₯Ό κ΅¬λ³„ν•˜κΈ°μœ„ν•œ ν…μŠ€νŠΈλ§Œ λ‹€λ¦…λ‹ˆλ‹€. μ•„λž˜λŠ” /game 라우트의 νŽ˜μ΄μ§€ μ½”λ“œμž…λ‹ˆλ‹€.
Only the text used to distinguish the pages differs. Below is the page code for the /game route.

'use client'

export default function MainPage() {
  return (
    <>
      <h1 className="text-4xl font-bold">κ²Œμž„ νŽ˜μ΄μ§€</h1>
      <p className="text-gray-500 mt-2">Game Page</p>
    </>
  )
}

πŸ“ λ²„νŠΌ μ»΄ν¬λ„ŒνŠΈ μˆ˜μ • / Modify button component

βœ”οΈ 기쑴의 λ²„νŠΌ λ‘œμ§μ€ onClickλ“±μ˜ νŒŒλΌλ©”ν„°(Props)λ₯Ό 받을 수 μ—†μ–΄μ„œ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€.
I modified the existing button logic because it could not accept parameters (props) such as onClick.

'use client'
type ButtonProps = {
  children: React.ReactNode
  onClick?: () => void
} & React.ButtonHTMLAttributes<HTMLButtonElement>

export function Button({ children, onClick, ...props }: ButtonProps) {
  return (
    <button 
      onClick={onClick}
      {...props}
      className="px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-bold 
      active:bg-blue-700 touch-manipulation">
      {children}
    </button>
  )
}

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ PC

1) λ©”μΈνŽ˜μ΄μ§€ / Main Page

Main Page

2) μ„œλΈŒ νŽ˜μ΄μ§€ / Subpage

Game Page
Music Page

βœ”οΈ Mobile

Leave a Reply