Compare commits
11 Commits
ff67e5cf5a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d7659812 | |||
| e5c4d325bf | |||
| fb0861f211 | |||
| a6fcff98b4 | |||
| bbadaaea36 | |||
| bd921528f4 | |||
| e6882b158a | |||
| c7d55b5463 | |||
| 5463395b04 | |||
| b9219da6a4 | |||
| a213699c02 |
@@ -20,7 +20,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
|
|||||||
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
|
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
|
||||||
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
|
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
|
||||||
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
|
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
|
||||||
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `stage-chocoadmin` — **never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
|
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage` — **never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
|
||||||
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
|
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
|
||||||
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
|
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
|
||||||
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
|
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
|
||||||
@@ -35,7 +35,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
|
|||||||
- API routes must call `auth()` and return 401 if no session.
|
- API routes must call `auth()` and return 401 if no session.
|
||||||
- Session `maxAge` is 8 hours — do not lengthen without review.
|
- Session `maxAge` is 8 hours — do not lengthen without review.
|
||||||
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
|
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
|
||||||
- Session cookie name `${APP_ENV}-chocoadmin.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly.
|
- Session cookie name `chocoadmin-${APP_ENV}.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly.
|
||||||
- Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop.
|
- Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop.
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
@@ -74,6 +74,7 @@ Always record a log entry after a successful DB change. Use these `Type` values:
|
|||||||
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
|
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
|
||||||
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
|
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
|
||||||
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
|
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
|
||||||
|
| `reset_maestro_password` | Maestro password reset to 123456 by admin | `admin reset to 123456` |
|
||||||
|
|
||||||
`Remark` column is `char(100)` — keep values under 100 characters.
|
`Remark` column is `char(100)` — keep values under 100 characters.
|
||||||
|
|
||||||
@@ -88,6 +89,11 @@ Always record a log entry after a successful DB change. Use these `Type` values:
|
|||||||
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
||||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
||||||
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
|
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
|
||||||
|
- Phase 15:
|
||||||
|
- Password reset button (`암호 초기화`) added to `MaestroEditForm.tsx` — calls `POST /api/maestros/[id]/reset-password`, shows `window.confirm` with maestro name, displays inline success/error message.
|
||||||
|
- `resetMaestroPassword(maestroID)` added to `lib/maestros.ts` — runs `UPDATE maestro SET Password = PASSWORD('123456')` inside a `$transaction` with a `reset_maestro_password` log entry.
|
||||||
|
- `app/api/maestros/[id]/reset-password/route.ts` — `POST` handler wrapped with `withApiHandler`.
|
||||||
|
- Deployment shell scripts added: `scripts/deploy-stage.sh` and `scripts/deploy-production.sh` — each runs git pull, ensures proxy-network, `docker compose up -d --build --remove-orphans`, and `docker exec npm nginx -s reload`. Production script requires `yes` confirmation.
|
||||||
- Phase 13 (security/correctness fixes):
|
- Phase 13 (security/correctness fixes):
|
||||||
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
|
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
|
||||||
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
|
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
|
||||||
@@ -102,6 +108,13 @@ Always record a log entry after a successful DB change. Use these `Type` values:
|
|||||||
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
|
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
|
||||||
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
|
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
|
||||||
|
|
||||||
|
- Phase 16: 반응형 웹 디자인 (Responsive Web Design)
|
||||||
|
- `app/(admin)/nav-config.ts` — navItems 배열 공유 설정 파일 (`layout.tsx`·`MobileNav.tsx` 공용)
|
||||||
|
- `app/(admin)/MobileNav.tsx` — 모바일 슬라이드인 사이드바 클라이언트 컴포넌트. 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), X 버튼, ESC 키, pathname 변경 시 자동 닫힘, body 스크롤 잠금 포함. CSS `translate-x` 트랜지션으로 애니메이션.
|
||||||
|
- `app/(admin)/layout.tsx` — 헤더에 `<MobileNav />` 통합. 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지.
|
||||||
|
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — `<section>`에 `overflow-hidden` 추가. SSR props로 표가 초기 렌더에 포함되어 `min-w-[560px]`가 섹션을 밀어내던 문제 수정. 헤더를 `flex-col sm:flex-row`로 변경해 모바일에서 액션 버튼 접근성 확보.
|
||||||
|
- 페이지네이션 `<nav>` 5개 파일(`extension-requests/page.tsx`, `upgrade-requests/page.tsx`, `maestros/page.tsx`, `StudentsSection.tsx`, `LogsSection.tsx`) — `self-end sm:self-auto` 추가로 모바일에서 오른쪽 정렬.
|
||||||
|
|
||||||
## Planned phases (not yet implemented)
|
## Planned phases (not yet implemented)
|
||||||
|
|
||||||
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
|
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
|
||||||
|
|||||||
@@ -19,14 +19,19 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스
|
|||||||
|
|
||||||
## 구현된 기능
|
## 구현된 기능
|
||||||
|
|
||||||
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환)
|
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환, 로그인 실패 5회 → 15분 잠금)
|
||||||
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
|
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
|
||||||
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
|
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
|
||||||
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
|
- 마에스트로 정보 수정 — 이름, 이메일, 상태, 계정유형, 만료일, 엔터코드 허용 여부 (`PATCH /api/maestros/[id]`)
|
||||||
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
|
- 마에스트로 암호 초기화 — 관리자가 `123456`으로 초기화 (트랜잭션 + `maestro_log`)
|
||||||
|
- 수강생 목록 — 서버사이드 페이지네이션 (`GET /api/maestros/[id]/students`)
|
||||||
|
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log` + 이메일 발송)
|
||||||
|
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log` + 이메일 발송)
|
||||||
|
- 관리자 주도 연장/업그레이드 신청 등록 — 마에스트로 상세 화면에서 직접 등록 (이메일 미발송)
|
||||||
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
||||||
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
|
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
|
||||||
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
|
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
|
||||||
|
- 이메일 자동 발송 — 연장/업그레이드 승인 후 Nodemailer로 발송, 실패 시 UI 경고 표시
|
||||||
|
|
||||||
## 개발 환경 설정
|
## 개발 환경 설정
|
||||||
|
|
||||||
@@ -92,10 +97,15 @@ pnpm dev
|
|||||||
## 배포 (Synology NAS Docker)
|
## 배포 (Synology NAS Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 이미지 빌드 및 컨테이너 시작
|
# 스테이지 배포
|
||||||
docker compose up -d --build
|
bash scripts/deploy-stage.sh
|
||||||
|
|
||||||
|
# 운영 배포 (yes 입력 확인 필요)
|
||||||
|
bash scripts/deploy-production.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
각 스크립트는 `git pull` → `proxy-network` 확인 → `docker compose up -d --build --remove-orphans` → `docker exec npm nginx -s reload` 순으로 실행된다.
|
||||||
|
|
||||||
`docker-compose.yml`의 `environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
|
`docker-compose.yml`의 `environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
@@ -105,10 +115,18 @@ chocoadmin/
|
|||||||
├── app/
|
├── app/
|
||||||
│ ├── (auth)/login/ # 관리자 로그인
|
│ ├── (auth)/login/ # 관리자 로그인
|
||||||
│ ├── (admin)/ # 인증 필요 화면
|
│ ├── (admin)/ # 인증 필요 화면
|
||||||
|
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
|
||||||
|
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
|
||||||
│ │ ├── maestros/ # 마에스트로 목록·상세
|
│ │ ├── maestros/ # 마에스트로 목록·상세
|
||||||
│ │ ├── extension-requests/
|
│ │ ├── extension-requests/
|
||||||
│ │ └── upgrade-requests/
|
│ │ └── upgrade-requests/
|
||||||
│ └── api/ # API routes
|
│ └── api/ # API routes
|
||||||
|
│ └── maestros/[id]/
|
||||||
|
│ ├── route.ts # PATCH — 마에스트로 정보 수정
|
||||||
|
│ ├── students/route.ts # GET — 수강생 목록 (페이지네이션)
|
||||||
|
│ ├── extension-requests/ # POST — 관리자 주도 연장 신청
|
||||||
|
│ ├── upgrade-requests/ # POST — 관리자 주도 업그레이드 신청
|
||||||
|
│ └── reset-password/ # POST — 암호 초기화
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── data-table/ # TanStack Table 기반 테이블
|
│ ├── data-table/ # TanStack Table 기반 테이블
|
||||||
│ ├── forms/
|
│ ├── forms/
|
||||||
@@ -118,10 +136,14 @@ chocoadmin/
|
|||||||
│ ├── logger.ts # 공통 로거 (debug/info/warn/error)
|
│ ├── logger.ts # 공통 로거 (debug/info/warn/error)
|
||||||
│ ├── errors.ts # ApiError 공통 에러 클래스
|
│ ├── errors.ts # ApiError 공통 에러 클래스
|
||||||
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
|
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
|
||||||
|
│ ├── mail.ts # Nodemailer 이메일 발송
|
||||||
│ ├── constants.ts # 상태값 상수
|
│ ├── constants.ts # 상태값 상수
|
||||||
│ ├── maestros.ts # 마에스트로 DB 쿼리
|
│ ├── maestros.ts # 마에스트로 DB 쿼리
|
||||||
│ ├── extension-requests.ts
|
│ ├── extension-requests.ts
|
||||||
│ └── upgrade-requests.ts
|
│ └── upgrade-requests.ts
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy-stage.sh # 스테이지 배포 스크립트
|
||||||
|
│ └── deploy-production.sh # 운영 배포 스크립트
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ └── schema.prisma # DB introspection 결과
|
│ └── schema.prisma # DB introspection 결과
|
||||||
├── docs/
|
├── docs/
|
||||||
@@ -138,4 +160,3 @@ chocoadmin/
|
|||||||
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
|
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
|
||||||
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
|
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
|
||||||
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
|
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
|
||||||
- [`docs/phase/phase9.md`](docs/phase/phase9.md) — 로그 정책 및 Docker 로그 운영 절차
|
|
||||||
|
|||||||
@@ -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>
|
</p>
|
||||||
<nav
|
<nav
|
||||||
aria-label="연장 신청 목록 페이지"
|
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 ? (
|
{!hasFirstPage ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+6
-16
@@ -1,24 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import { LogOut } from "lucide-react";
|
||||||
ArrowUpCircle,
|
|
||||||
ClipboardList,
|
|
||||||
GraduationCap,
|
|
||||||
LayoutDashboard,
|
|
||||||
LogOut,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { logoutAction } from "./actions";
|
import { logoutAction } from "./actions";
|
||||||
|
import { MobileNav } from "./MobileNav";
|
||||||
const navItems = [
|
import { navItems } from "./nav-config";
|
||||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
|
||||||
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
|
|
||||||
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
|
|
||||||
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@@ -58,8 +47,9 @@ export default async function AdminLayout({
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="md:pl-64">
|
<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">
|
<header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-b bg-background px-5">
|
||||||
<div>
|
<MobileNav />
|
||||||
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{session.user.name}</p>
|
<p className="text-sm font-medium">{session.user.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
관리자 ID {session.user.adminID}
|
관리자 ID {session.user.adminID}
|
||||||
|
|||||||
@@ -63,15 +63,15 @@ export function ExtensionRequestsSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
<div>
|
||||||
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||||
<Button
|
<Button
|
||||||
disabled={isDisabled || !isPaidAccount}
|
disabled={isDisabled || !isPaidAccount}
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ function LogPagination({
|
|||||||
{showNav ? (
|
{showNav ? (
|
||||||
<nav
|
<nav
|
||||||
aria-label="처리 로그 페이지"
|
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 ? (
|
{!hasFirstPage ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ function StudentPagination({
|
|||||||
</label>
|
</label>
|
||||||
{showNav ? <nav
|
{showNav ? <nav
|
||||||
aria-label="학생 목록 페이지"
|
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 ? (
|
{!hasFirstPage ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -88,16 +88,16 @@ export function UpgradeRequestsSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
<div>
|
||||||
<h2 className="text-base font-semibold">업그레이드 신청 이력</h2>
|
<h2 className="text-base font-semibold">업그레이드 신청 이력</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export default async function MaestrosPage({
|
|||||||
</form>
|
</form>
|
||||||
<nav
|
<nav
|
||||||
aria-label="마에스트로 목록 페이지"
|
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 ? (
|
{!hasFirstPage ? (
|
||||||
<Link
|
<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>
|
</p>
|
||||||
<nav
|
<nav
|
||||||
aria-label="업그레이드 신청 목록 페이지"
|
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 ? (
|
{!hasFirstPage ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 97 KiB |
@@ -70,7 +70,7 @@ declare module "next-auth/jwt" {
|
|||||||
|
|
||||||
const appEnv = process.env.APP_ENV ?? "local";
|
const appEnv = process.env.APP_ENV ?? "local";
|
||||||
const isSecure = process.env.NODE_ENV === "production";
|
const isSecure = process.env.NODE_ENV === "production";
|
||||||
const sessionCookieName = `${appEnv}-chocoadmin.session-token`;
|
const sessionCookieName = `chocoadmin-${appEnv}.session-token`;
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
handlers: { GET, POST },
|
handlers: { GET, POST },
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
|
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
|
||||||
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
|
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
|
||||||
stage-chocoadmin:
|
chocoadmin-stage:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: chocoadmin-stage:latest
|
image: chocoadmin-stage:latest
|
||||||
container_name: stage-chocoadmin
|
container_name: chocoadmin-stage
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 stage(chocoadmin-stage)와 겹치면 안 된다.
|
||||||
|
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
|
||||||
chocoadmin:
|
chocoadmin:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -8,7 +10,7 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env.production}
|
- .env.production
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
|
|||||||
+10
-10
@@ -16,8 +16,8 @@ Stage example:
|
|||||||
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
|
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
|
||||||
AUTH_SECRET="replace-with-stage-secret"
|
AUTH_SECRET="replace-with-stage-secret"
|
||||||
NEXTAUTH_SECRET="replace-with-stage-secret"
|
NEXTAUTH_SECRET="replace-with-stage-secret"
|
||||||
AUTH_URL="https://stage-chocoadmin.jinaju.com"
|
AUTH_URL="https://chocoadmin-stage.jisangs.com"
|
||||||
NEXTAUTH_URL="https://stage-chocoadmin.jinaju.com"
|
NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
Production file path:
|
Production file path:
|
||||||
@@ -38,7 +38,7 @@ NEXTAUTH_URL="https://chocoadmin.jinaju.com"
|
|||||||
|
|
||||||
`AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
|
`AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
|
||||||
|
|
||||||
`APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`${APP_ENV}-chocoadmin.session-token`), which keeps production and stage cookies separate even if they share a browser.
|
`APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`chocoadmin-${APP_ENV}.session-token`), which keeps production and stage cookies separate even if they share a browser.
|
||||||
|
|
||||||
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
|
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ openssl rand -base64 32
|
|||||||
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
|
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /volume1/docker/service/jinaju/stage-chocoadmin
|
cd /volume1/docker/service/jinaju/chocoadmin-stage
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Stage uses `docker-compose.stage.yml`, the `stage-chocoadmin` container, and the external Docker network `proxy-network`.
|
Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
|
||||||
|
|
||||||
Verify or create the network:
|
Verify or create the network:
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ docker exec npm nginx -s reload
|
|||||||
Nginx Proxy Manager settings:
|
Nginx Proxy Manager settings:
|
||||||
|
|
||||||
- Scheme: `http`
|
- Scheme: `http`
|
||||||
- Forward Hostname / IP: `stage-chocoadmin`
|
- Forward Hostname / IP: `chocoadmin-stage`
|
||||||
- Forward Port: `3000`
|
- Forward Port: `3000`
|
||||||
- Websockets Support: enabled
|
- Websockets Support: enabled
|
||||||
- SSL: enabled
|
- SSL: enabled
|
||||||
@@ -95,7 +95,7 @@ Nginx Proxy Manager settings:
|
|||||||
Stage URL:
|
Stage URL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://stage-chocoadmin.jinaju.com
|
https://chocoadmin-stage.jisangs.com
|
||||||
```
|
```
|
||||||
|
|
||||||
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
|
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
|
||||||
@@ -181,7 +181,7 @@ Middleware redirects to `/login`, login page redirects back to `/` — infinite
|
|||||||
|
|
||||||
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
|
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
|
||||||
2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request.
|
2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request.
|
||||||
3. **Check cookie name consistency** — `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `${APP_ENV}-chocoadmin.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session.
|
3. **Check cookie name consistency** — `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `chocoadmin-${APP_ENV}.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session.
|
||||||
4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP.
|
4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP.
|
||||||
5. **Clear browser cookies** for the domain, then test in a private window.
|
5. **Clear browser cookies** for the domain, then test in a private window.
|
||||||
6. **Recreate the container** after changing `.env.*`.
|
6. **Recreate the container** after changing `.env.*`.
|
||||||
@@ -198,9 +198,9 @@ Check the network:
|
|||||||
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
|
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: `chocoadmin` and `stage-chocoadmin` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias.
|
Expected: `chocoadmin` and `chocoadmin-stage` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias.
|
||||||
|
|
||||||
Fix: ensure `docker-compose.stage.yml` has `services: stage-chocoadmin:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM.
|
Fix: ensure `docker-compose.stage.yml` has `services: chocoadmin-stage:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM.
|
||||||
|
|
||||||
### Failed to find Server Action after redeployment
|
### Failed to find Server Action after redeployment
|
||||||
|
|
||||||
|
|||||||
@@ -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가 모바일에서 오른쪽 정렬 확인
|
||||||
|
- [ ] 데스크탑에서 기존 레이아웃 회귀 없음 확인
|
||||||
@@ -70,7 +70,7 @@ pnpm db:check
|
|||||||
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
|
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -I https://stage-chocoadmin.jinaju.com/maestros
|
curl -I https://chocoadmin-stage.jisangs.com/maestros
|
||||||
```
|
```
|
||||||
|
|
||||||
기대 결과:
|
기대 결과:
|
||||||
@@ -81,7 +81,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
|
|||||||
|
|
||||||
브라우저에서 확인한다.
|
브라우저에서 확인한다.
|
||||||
|
|
||||||
- `https://stage-chocoadmin.jinaju.com/login`
|
- `https://chocoadmin-stage.jisangs.com/login`
|
||||||
- `/maestros`
|
- `/maestros`
|
||||||
- `/extension-requests`
|
- `/extension-requests`
|
||||||
- `/upgrade-requests`
|
- `/upgrade-requests`
|
||||||
@@ -119,7 +119,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
|
|||||||
NAS에서 실행한다.
|
NAS에서 실행한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /volume1/docker/service/jinaju/stage-chocoadmin
|
cd /volume1/docker/service/jisangs/chocoadmin-stage
|
||||||
docker compose -f docker-compose.stage.yml restart
|
docker compose -f docker-compose.stage.yml restart
|
||||||
docker compose -f docker-compose.stage.yml ps
|
docker compose -f docker-compose.stage.yml ps
|
||||||
docker compose -f docker-compose.stage.yml logs --tail=100
|
docker compose -f docker-compose.stage.yml logs --tail=100
|
||||||
@@ -127,7 +127,7 @@ docker compose -f docker-compose.stage.yml logs --tail=100
|
|||||||
|
|
||||||
기대 결과:
|
기대 결과:
|
||||||
|
|
||||||
- `stage-chocoadmin` 컨테이너가 `Up` 상태다.
|
- `chocoadmin-stage` 컨테이너가 `Up` 상태다.
|
||||||
- 로그에 Next.js ready 메시지가 출력된다.
|
- 로그에 Next.js ready 메시지가 출력된다.
|
||||||
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
|
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ chocoadmin/
|
|||||||
│ │ └── page.tsx # 관리자 로그인
|
│ │ └── page.tsx # 관리자 로그인
|
||||||
│ ├── (admin)/
|
│ ├── (admin)/
|
||||||
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
|
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
|
||||||
|
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
|
||||||
|
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
|
||||||
│ │ ├── maestros/
|
│ │ ├── maestros/
|
||||||
│ │ │ ├── page.tsx # 마에스트로 전체 목록
|
│ │ │ ├── page.tsx # 마에스트로 전체 목록
|
||||||
│ │ │ └── [maestroId]/
|
│ │ │ └── [maestroId]/
|
||||||
@@ -179,7 +181,8 @@ chocoadmin/
|
|||||||
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
|
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
|
||||||
│ │ ├── students/route.ts # GET: 학생 목록
|
│ │ ├── students/route.ts # GET: 학생 목록
|
||||||
│ │ ├── extension-requests/route.ts # POST: 연장 신청 등록 (관리자 직접)
|
│ │ ├── extension-requests/route.ts # POST: 연장 신청 등록 (관리자 직접)
|
||||||
│ │ └── upgrade-requests/route.ts # POST: 업그레이드 신청 등록 (관리자 직접)
|
│ │ ├── upgrade-requests/route.ts # POST: 업그레이드 신청 등록 (관리자 직접)
|
||||||
|
│ │ └── reset-password/route.ts # POST: 비밀번호 123456 초기화
|
||||||
│ ├── extension-requests/
|
│ ├── extension-requests/
|
||||||
│ │ └── [id]/route.ts # PATCH: 승인/취소
|
│ │ └── [id]/route.ts # PATCH: 승인/취소
|
||||||
│ └── upgrade-requests/
|
│ └── upgrade-requests/
|
||||||
@@ -541,9 +544,24 @@ chocoadmin/
|
|||||||
|
|
||||||
- [x] 모든 항목 완료 (P0-1 ~ P2-8)
|
- [x] 모든 항목 완료 (P0-1 ~ P2-8)
|
||||||
|
|
||||||
### Phase 14. Production/Stage 배포 환경 안정화
|
### Phase 14. 비밀번호 초기화 및 배포 스크립트
|
||||||
|
|
||||||
> Production 첫 배포 과정에서 발견된 이슈들을 해결했다.
|
> 마에스트로 계정 비밀번호 초기화 기능을 추가한다.
|
||||||
|
> 완료 날짜: 2026-07-05
|
||||||
|
|
||||||
|
- [x] 비밀번호 초기화 API 구현 (`POST /api/maestros/[id]/reset-password`)
|
||||||
|
- [x] `resetMaestroPassword(maestroID)` 추가 (`lib/maestros.ts`)
|
||||||
|
- [x] `$transaction` 내에서 `UPDATE maestro SET Password = PASSWORD('123456')` 실행
|
||||||
|
- [x] `reset_maestro_password` 타입으로 `maestro_log` 기록
|
||||||
|
- [x] `withApiHandler`로 인증·에러 처리 공통화
|
||||||
|
- [x] 비밀번호 초기화 버튼 UI (`MaestroEditForm.tsx`)
|
||||||
|
- [x] `[암호 초기화]` 버튼을 `[저장]` 버튼 우측에 추가 (`variant="outline"`, `KeyRound` 아이콘)
|
||||||
|
- [x] `window.confirm`으로 `{마에스트로명} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?` 확인
|
||||||
|
- [x] 성공 시 기존 메시지 영역에 `암호 초기화 완료` 표시
|
||||||
|
|
||||||
|
### Phase 15. Production/Stage 배포 환경 안정화
|
||||||
|
|
||||||
|
> Production 첫 배포 과정에서 발견된 이슈들을 해결하고 NAS 배포 자동화 스크립트를 추가한다.
|
||||||
> 완료 날짜: 2026-07-05
|
> 완료 날짜: 2026-07-05
|
||||||
|
|
||||||
- [x] Production 배포 경로 생성 및 git pull 방식 배포 설정
|
- [x] Production 배포 경로 생성 및 git pull 방식 배포 설정
|
||||||
@@ -552,17 +570,41 @@ chocoadmin/
|
|||||||
- [x] **포트 충돌 수정** (`docker-compose.yml`)
|
- [x] **포트 충돌 수정** (`docker-compose.yml`)
|
||||||
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"` → `expose: "3000"` 으로 변경
|
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"` → `expose: "3000"` 으로 변경
|
||||||
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
|
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
|
||||||
- `services: chocoadmin:` → `services: stage-chocoadmin:` 으로 변경
|
- `services: chocoadmin:` → `services: chocoadmin-stage:` 으로 변경
|
||||||
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
|
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
|
||||||
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
|
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
|
||||||
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
|
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
|
||||||
- `auth.ts`: `cookies.sessionToken.name = "${APP_ENV}-chocoadmin.session-token"` 추가
|
- `auth.ts`: `cookies.sessionToken.name = "chocoadmin-${APP_ENV}.session-token"` 추가
|
||||||
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
|
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
|
||||||
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
|
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
|
||||||
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
|
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
|
||||||
- 인라인 `"use server"` 클로저는 빌드마다 새 ID 부여 → 재배포 후 `Failed to find Server Action`
|
- 인라인 `"use server"` 클로저는 빌드마다 새 ID 부여 → 재배포 후 `Failed to find Server Action`
|
||||||
- 코드베이스 전체 인라인 서버 액션 검토 및 동일 패턴 수정 완료
|
- 코드베이스 전체 인라인 서버 액션 검토 및 동일 패턴 수정 완료
|
||||||
- [x] Production/Stage 동시 운영 정상 확인
|
- [x] Production/Stage 동시 운영 정상 확인
|
||||||
|
- [x] 배포 자동화 스크립트 추가 (`scripts/`)
|
||||||
|
- [x] `scripts/deploy-stage.sh` — git pull → proxy-network 확인 → `docker compose -f docker-compose.stage.yml up -d --build --remove-orphans` → NPM reload
|
||||||
|
- [x] `scripts/deploy-production.sh` — `yes` 입력 확인 후 동일 5단계 실행 (`docker-compose.yml` 사용)
|
||||||
|
- [x] 각 스크립트는 NAS 해당 디렉토리에서 직접 실행
|
||||||
|
|
||||||
|
### Phase 16. 반응형 웹 디자인
|
||||||
|
|
||||||
|
> 모바일 화면(360~430px)에서도 관리자 패널을 정상 사용할 수 있도록 사이드바 내비게이션, 표 overflow, 페이지네이션 정렬을 개선한다.
|
||||||
|
> 완료 날짜: 2026-07-07
|
||||||
|
|
||||||
|
- [x] 모바일 사이드바
|
||||||
|
- [x] `app/(admin)/nav-config.ts` 생성 — navItems 배열을 `layout.tsx`와 `MobileNav.tsx`가 공유
|
||||||
|
- [x] `app/(admin)/MobileNav.tsx` 생성 — 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), 슬라이드인 패널(X 버튼), ESC 키 지원, pathname 변경 시 자동 닫힘, body 스크롤 잠금, CSS `translate-x` 트랜지션 애니메이션
|
||||||
|
- [x] `app/(admin)/layout.tsx` 수정 — 헤더에 `<MobileNav />` 통합; 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지
|
||||||
|
- [x] 표 overflow 수정 (`ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx`)
|
||||||
|
- 원인: 두 섹션은 서버 props로 데이터를 받아 SSR 초기 렌더에 `min-w-[560px]` 표가 포함됨 → `section`에 `overflow` 미설정(`visible` 기본값)이어서 표가 섹션을 밀어냄 → `overflow-x-auto`가 확장된 너비를 그대로 가져가 스크롤 미발생. `StudentsSection`·`LogsSection`은 클라이언트 fetch 방식이라 SSR 초기 렌더에 표가 없어 동일 패턴으로도 정상 동작.
|
||||||
|
- [x] `<section>`에 `overflow-hidden` 추가 — BFC 확립으로 섹션 너비를 뷰포트 기준으로 고정
|
||||||
|
- [x] 섹션 헤더를 `flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between`으로 변경 — 모바일에서 세로 스택, 액션 버튼 접근성 확보
|
||||||
|
- [x] 페이지네이션 오른쪽 정렬 — `<nav>`에 `self-end sm:self-auto` 추가 (모바일 `flex-col`에서 오른쪽 정렬, 데스크탑 `flex-row`에서 기존 동작 유지)
|
||||||
|
- [x] `app/(admin)/extension-requests/page.tsx`
|
||||||
|
- [x] `app/(admin)/upgrade-requests/page.tsx`
|
||||||
|
- [x] `app/(admin)/maestros/page.tsx`
|
||||||
|
- [x] `app/(admin)/maestros/[maestroId]/StudentsSection.tsx`
|
||||||
|
- [x] `app/(admin)/maestros/[maestroId]/LogsSection.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function usesSecureAuthCookies() {
|
|||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname, search } = request.nextUrl;
|
const { pathname, search } = request.nextUrl;
|
||||||
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
||||||
const cookieName = `${process.env.APP_ENV ?? "local"}-chocoadmin.session-token`;
|
const cookieName = `chocoadmin-${process.env.APP_ENV ?? "local"}.session-token`;
|
||||||
const token = await getToken({
|
const token = await getToken({
|
||||||
req: request,
|
req: request,
|
||||||
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
|
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
|
||||||
|
|||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
DEPLOY_DIR="/volume1/docker/service/jinaju/chocoadmin"
|
||||||
|
SERVICE="chocoadmin"
|
||||||
|
URL="https://chocoadmin.jinaju.com"
|
||||||
|
|
||||||
|
echo -e "${RED}==============================${NC}"
|
||||||
|
echo -e "${RED} chocoadmin 운영 배포${NC}"
|
||||||
|
echo -e "${RED}==============================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}운영 서버에 배포합니다. 계속하시겠습니까? (yes 입력)${NC}"
|
||||||
|
read -r CONFIRM
|
||||||
|
if [ "$CONFIRM" != "yes" ]; then
|
||||||
|
echo "배포를 취소했습니다."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}"
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
echo " $(pwd)"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[2/5] git pull${NC}"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[3/5] proxy-network 확인${NC}"
|
||||||
|
if docker network inspect proxy-network >/dev/null 2>&1; then
|
||||||
|
echo " proxy-network 존재"
|
||||||
|
else
|
||||||
|
docker network create proxy-network
|
||||||
|
echo " proxy-network 생성 완료"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[4/5] 빌드 및 컨테이너 시작${NC}"
|
||||||
|
docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[5/5] NPM nginx 리로드${NC}"
|
||||||
|
docker exec npm nginx -s reload
|
||||||
|
echo " 리로드 완료"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}컨테이너 상태${NC}"
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}배포 완료${NC}"
|
||||||
|
echo " $URL"
|
||||||
|
echo ""
|
||||||
|
echo "로그: docker compose -f $DEPLOY_DIR/docker-compose.yml logs -f $SERVICE"
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
DEPLOY_DIR="/volume1/docker/service/jisangs/chocoadmin-stage"
|
||||||
|
COMPOSE_FILE="docker-compose.stage.yml"
|
||||||
|
SERVICE="chocoadmin-stage"
|
||||||
|
URL="https://chocoadmin-stage.jisangs.com"
|
||||||
|
|
||||||
|
echo -e "${BLUE}==============================${NC}"
|
||||||
|
echo -e "${BLUE} chocoadmin-stage 배포${NC}"
|
||||||
|
echo -e "${BLUE}==============================${NC}"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}"
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
echo " $(pwd)"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[2/5] git pull${NC}"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[3/5] proxy-network 확인${NC}"
|
||||||
|
if docker network inspect proxy-network >/dev/null 2>&1; then
|
||||||
|
echo " proxy-network 존재"
|
||||||
|
else
|
||||||
|
docker network create proxy-network
|
||||||
|
echo " proxy-network 생성 완료"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[4/5] 빌드 및 컨테이너 시작${NC}"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[5/5] NPM nginx 리로드${NC}"
|
||||||
|
docker exec npm nginx -s reload
|
||||||
|
echo " 리로드 완료"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}컨테이너 상태${NC}"
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}배포 완료${NC}"
|
||||||
|
echo " $URL"
|
||||||
|
echo ""
|
||||||
|
echo "로그: docker compose -f $DEPLOY_DIR/$COMPOSE_FILE logs -f $SERVICE"
|
||||||
Reference in New Issue
Block a user