반응형 웹 디자인 적용
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { navItems } from "./nav-config";
|
||||
|
||||
export function MobileNav() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
document.body.style.overflow = "";
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label="메뉴 열기"
|
||||
className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
||||
onClick={() => setIsOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Menu aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* 배경 오버레이 — 탭하면 닫힘 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"fixed inset-0 z-20 bg-black/50 transition-opacity duration-200 md:hidden",
|
||||
isOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 슬라이드인 패널 */}
|
||||
<aside
|
||||
aria-hidden={!isOpen}
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-30 w-64 border-r bg-background transition-transform duration-200 md:hidden",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between border-b px-5">
|
||||
<Link className="text-lg font-semibold" href="/">
|
||||
chocoadmin
|
||||
</Link>
|
||||
<button
|
||||
aria-label="메뉴 닫기"
|
||||
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setIsOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="space-y-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export default async function ExtensionRequestsPage({
|
||||
</p>
|
||||
<nav
|
||||
aria-label="연장 신청 목록 페이지"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
|
||||
+6
-16
@@ -1,24 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { logoutAction } from "./actions";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
||||
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
|
||||
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
|
||||
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
|
||||
];
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { navItems } from "./nav-config";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -58,8 +47,9 @@ export default async function AdminLayout({
|
||||
</aside>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
|
||||
<div>
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-b bg-background px-5">
|
||||
<MobileNav />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
관리자 ID {session.user.adminID}
|
||||
|
||||
@@ -63,15 +63,15 @@ export function ExtensionRequestsSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button
|
||||
disabled={isDisabled || !isPaidAccount}
|
||||
onClick={() => void handleCreate()}
|
||||
|
||||
@@ -219,7 +219,7 @@ function LogPagination({
|
||||
{showNav ? (
|
||||
<nav
|
||||
aria-label="처리 로그 페이지"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Button
|
||||
|
||||
@@ -246,7 +246,7 @@ function StudentPagination({
|
||||
</label>
|
||||
{showNav ? <nav
|
||||
aria-label="학생 목록 페이지"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Button
|
||||
|
||||
@@ -88,16 +88,16 @@ export function UpgradeRequestsSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">업그레이드 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className={selectClass}
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -191,7 +191,7 @@ export default async function MaestrosPage({
|
||||
</form>
|
||||
<nav
|
||||
aria-label="마에스트로 목록 페이지"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
|
||||
export const navItems = [
|
||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
||||
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
|
||||
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
|
||||
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
|
||||
];
|
||||
@@ -135,7 +135,7 @@ export default async function UpgradeRequestsPage({
|
||||
</p>
|
||||
<nav
|
||||
aria-label="업그레이드 신청 목록 페이지"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# Phase 16: 반응형 웹 디자인 (Responsive Web Design)
|
||||
|
||||
## 목표
|
||||
|
||||
모바일 화면(360~430px 기준)에서도 관리자 패널을 정상적으로 사용할 수 있도록 세 가지 영역을 개선한다.
|
||||
|
||||
1. 사이드바 — 모바일 햄버거 메뉴 + 슬라이드인 패널
|
||||
2. 표 — 섹션 경계 내에서 가로 스크롤되도록 헤더 레이아웃 수정
|
||||
3. 페이지네이션 — 모바일에서도 오른쪽 정렬 유지
|
||||
|
||||
---
|
||||
|
||||
## 1. 모바일 사이드바
|
||||
|
||||
### 현재 상태
|
||||
|
||||
`app/(admin)/layout.tsx`의 `<aside>`는 `hidden md:block`으로 모바일에서 완전히 숨겨진다. 헤더에 햄버거 버튼이 없어 모바일에서 내비게이션 자체에 접근할 수 없다.
|
||||
|
||||
`layout.tsx`는 서버 컴포넌트(`await auth()` 사용)이므로 `useState`를 직접 쓸 수 없다.
|
||||
|
||||
### 구현 방식
|
||||
|
||||
**추천 닫기 UI: 배경 오버레이(backdrop) + 사이드바 내 X 버튼 조합**
|
||||
|
||||
- 오버레이: 사이드바 외부 어디든 탭하면 닫힘 → 모바일 터치 UX에 가장 자연스러움
|
||||
- X 버튼: 사이드바 상단에 명시적으로 표시 → 처음 쓰는 사용자도 직관적으로 이해
|
||||
- ESC 키: `useEffect`로 `keydown` 리스너 추가 → 접근성 향상
|
||||
|
||||
두 가지 닫기 수단을 모두 제공해 사용자가 선호하는 방식으로 닫을 수 있다.
|
||||
|
||||
### 생성할 파일
|
||||
|
||||
#### `app/(admin)/MobileNav.tsx` (신규, `"use client"`)
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
// useState(isOpen), useEffect(ESC 키)
|
||||
// 렌더링:
|
||||
// 1. <button> 햄버거 (md:hidden — 데스크탑에서 숨김)
|
||||
// 2. isOpen일 때: fixed inset-0 z-20 bg-black/50 backdrop (클릭 시 닫힘)
|
||||
// 3. 슬라이드인 패널: fixed inset-y-0 left-0 z-30 w-64
|
||||
// - 상단: "chocoadmin" 로고 + X 버튼 (오른쪽)
|
||||
// - 내비게이션 링크 목록 (navItems 배열)
|
||||
// - 링크 클릭 시 자동 닫힘 (useRouter 또는 onClick)
|
||||
// 4. 애니메이션: translate-x-0 ↔ -translate-x-full, transition-transform duration-200
|
||||
```
|
||||
|
||||
패널이 열릴 때 `document.body`에 `overflow: hidden`을 걸어 배경 스크롤을 막는다.
|
||||
|
||||
### 수정할 파일
|
||||
|
||||
#### `app/(admin)/layout.tsx`
|
||||
|
||||
```diff
|
||||
+ import { MobileNav } from "./MobileNav";
|
||||
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
|
||||
+ <MobileNav navItems={navItems} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
...
|
||||
</div>
|
||||
<form action={logoutAction}>...</form>
|
||||
</header>
|
||||
```
|
||||
|
||||
- 데스크탑 `<aside>`는 그대로 유지 (`hidden md:block`)
|
||||
- `MobileNav`가 렌더링하는 햄버거 버튼은 `md:hidden`이므로 데스크탑에서는 보이지 않음
|
||||
- 헤더 왼쪽: 햄버거(모바일) | 사용자 정보(기존) → `justify-between`으로 양 끝 배치 유지
|
||||
|
||||
---
|
||||
|
||||
## 2. 표 — 섹션 경계 내 가로 스크롤
|
||||
|
||||
### 원인 분석
|
||||
|
||||
#### 왜 StudentsSection / LogsSection은 정상 동작하는가
|
||||
|
||||
두 섹션 모두 `"use client"` 컴포넌트로, `useEffect` 안에서 데이터를 직접 fetch한다.
|
||||
|
||||
- SSR 시점: `data === null` → 로딩 중... `<div class="h-24 ...">` 렌더
|
||||
- 섹션 너비가 뷰포트 기준으로 확정된 뒤, 클라이언트에서 데이터를 받아 표를 삽입
|
||||
- 표가 나타날 때 이미 섹션 너비가 확정되어 있으므로 `overflow-x-auto`가 스크롤을 정상 처리
|
||||
|
||||
#### 왜 ExtensionRequestsSection / UpgradeRequestsSection은 섹션이 늘어나는가
|
||||
|
||||
두 섹션도 `"use client"`이지만, **데이터를 서버에서 props로 받아** SSR 초기 렌더에 표가 포함된다.
|
||||
|
||||
- SSR 시점: `extensionRequests` / `upgradeRequests` props가 이미 채워진 상태로 렌더
|
||||
- 초기 레이아웃 계산 시점에 `min-w-[560px]` 표가 DOM에 존재
|
||||
- `section` 요소에 `overflow` 설정이 없어(`overflow: visible` 기본값) 표의 너비가 섹션을 밀어냄
|
||||
- 섹션 자체가 560px+ 로 확장 → `div.overflow-x-auto`도 그 너비를 그대로 가져가므로 스크롤이 생기지 않음
|
||||
|
||||
#### 핵심 수정: `section`에 `overflow-hidden` 추가
|
||||
|
||||
`section` 요소에 `overflow: hidden`을 적용하면 BFC(Block Formatting Context)가 확립되어 어떤 자식도 섹션 너비를 밀어낼 수 없다. 섹션은 항상 뷰포트 기준 너비로 고정되고, 표는 `div.overflow-x-auto` 안에서 가로 스크롤된다.
|
||||
|
||||
```diff
|
||||
- <section className="rounded-lg border bg-background p-5">
|
||||
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
```
|
||||
|
||||
`overflow: hidden`은 섹션의 **가로 팽창만** 막는다. 섹션의 `height`는 `auto`이므로 세로로는 내용 크기에 맞게 늘어나며 내용이 잘리지 않는다. 또한 `div.overflow-x-auto`의 스크롤바는 섹션 내부에 위치하므로 정상 표시된다.
|
||||
|
||||
#### 추가 수정: 섹션 헤더 레이아웃 (UX 목적)
|
||||
|
||||
`overflow-hidden`으로 표 팽창은 막을 수 있지만, 헤더의 버튼/셀렉트 영역이 모바일 뷰포트보다 넓을 경우 `overflow: hidden`에 의해 잘려 보이지 않는다. 버튼 접근성을 위해 헤더도 모바일에서 세로 스택으로 변경한다.
|
||||
|
||||
### 수정할 파일
|
||||
|
||||
#### `app/(admin)/maestros/[maestroId]/ExtensionRequestsSection.tsx`
|
||||
|
||||
```diff
|
||||
- <section className="rounded-lg border bg-background p-5">
|
||||
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
|
||||
- <div className="flex items-start justify-between gap-2">
|
||||
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 ...>연장 신청 이력</h2>
|
||||
<p ...>전체 N건 중 최근 20건</p>
|
||||
</div>
|
||||
- <div className="flex flex-col items-end gap-1">
|
||||
+ <div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button ...>연장</Button>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### `app/(admin)/maestros/[maestroId]/UpgradeRequestsSection.tsx`
|
||||
|
||||
```diff
|
||||
- <section className="rounded-lg border bg-background p-5">
|
||||
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
|
||||
- <div className="flex items-start justify-between gap-2">
|
||||
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 ...>업그레이드 신청 이력</h2>
|
||||
<p ...>전체 N건 중 최근 20건</p>
|
||||
</div>
|
||||
- <div className="flex flex-col items-end gap-1">
|
||||
+ <div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
- <div className="flex items-center gap-2">
|
||||
+ <div className="flex flex-wrap items-center gap-2">
|
||||
<select ...>...</select>
|
||||
<Button ...>업그레이드</Button>
|
||||
</div>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### StudentsSection / LogsSection
|
||||
|
||||
수정 불필요. 클라이언트 fetch 방식으로 인해 SSR 초기 렌더에 표가 없어 섹션 너비가 먼저 확정되므로 현재 구조 그대로 정상 동작한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 페이지네이션 오른쪽 정렬
|
||||
|
||||
### 현재 상태
|
||||
|
||||
모든 페이지네이션 컨테이너:
|
||||
```
|
||||
flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between
|
||||
```
|
||||
|
||||
모바일(flex-col)에서 nav(페이지 버튼)는 페이지 크기 선택 아래에 위치하며 왼쪽 정렬된다.
|
||||
|
||||
### 수정 방법
|
||||
|
||||
`<nav>` 요소에 `self-end` 추가, 데스크탑에서는 `sm:self-auto`로 복원:
|
||||
|
||||
```diff
|
||||
<nav
|
||||
aria-label="..."
|
||||
- className="flex flex-wrap items-center gap-1.5"
|
||||
+ className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
```
|
||||
|
||||
- 모바일(flex-col): `align-self: flex-end` → nav가 오른쪽으로 붙음
|
||||
- 데스크탑(flex-row + justify-between): `sm:self-auto` → 기존 `items-center` 적용, 수직 중앙 정렬 유지
|
||||
|
||||
페이지 크기 선택 요소("페이지당 N개 표시")는 변경하지 않아 왼쪽에 그대로 유지.
|
||||
|
||||
### 수정 대상 파일 및 위치
|
||||
|
||||
| 파일 | nav aria-label |
|
||||
|---|---|
|
||||
| `app/(admin)/extension-requests/page.tsx` | "연장 신청 목록 페이지" |
|
||||
| `app/(admin)/upgrade-requests/page.tsx` | "업그레이드 신청 목록 페이지" |
|
||||
| `app/(admin)/maestros/page.tsx` | "마에스트로 목록 페이지" |
|
||||
| `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` | "학생 목록 페이지" |
|
||||
| `app/(admin)/maestros/[maestroId]/LogsSection.tsx` | "처리 로그 페이지" |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. `MobileNav.tsx` 생성 → `layout.tsx` 수정 (사이드바)
|
||||
2. `ExtensionRequestsSection.tsx`: `section`에 `overflow-hidden` 추가 + 헤더 flex-col 수정
|
||||
3. `UpgradeRequestsSection.tsx`: `section`에 `overflow-hidden` 추가 + 헤더 flex-col 수정
|
||||
4. 페이지네이션 nav 5개 파일 수정
|
||||
|
||||
## 검증 포인트
|
||||
|
||||
- [ ] 모바일(375px)에서 햄버거 버튼 표시 확인
|
||||
- [ ] 사이드바 슬라이드인/아웃 애니메이션 확인
|
||||
- [ ] 오버레이 탭 → 사이드바 닫힘 확인
|
||||
- [ ] X 버튼 탭 → 사이드바 닫힘 확인
|
||||
- [ ] 데스크탑(≥768px)에서 햄버거 버튼 미표시 확인
|
||||
- [ ] `ExtensionRequestsSection` 섹션이 모바일 뷰포트 너비 안에 머무르는지 확인
|
||||
- [ ] `ExtensionRequestsSection` 표가 섹션 내부에서 가로 스크롤되는지 확인
|
||||
- [ ] `ExtensionRequestsSection` 헤더 버튼이 모바일에서 잘리지 않고 표시되는지 확인
|
||||
- [ ] `UpgradeRequestsSection` 섹션이 모바일 뷰포트 너비 안에 머무르는지 확인
|
||||
- [ ] `UpgradeRequestsSection` 표가 섹션 내부에서 가로 스크롤되는지 확인
|
||||
- [ ] `UpgradeRequestsSection` 요금제 셀렉트 + 버튼이 모바일에서 접근 가능한지 확인
|
||||
- [ ] 모든 페이지네이션 nav가 모바일에서 오른쪽 정렬 확인
|
||||
- [ ] 데스크탑에서 기존 레이아웃 회귀 없음 확인
|
||||
Reference in New Issue
Block a user