node 업그레이드-->25 이전 서버설정 cash문제 해결.
This commit is contained in:
@@ -134,7 +134,7 @@ INVION/
|
|||||||
### 1. 필수 요구사항
|
### 1. 필수 요구사항
|
||||||
|
|
||||||
- **Java**: 21
|
- **Java**: 21
|
||||||
- **Node.js**: 20.10+ (프론트엔드 빌드용)
|
- **Node.js**: **22 LTS+** (`frontend/package.json` 의 `engines.node` 에 `>=22.0.0` 강제. 프로젝트 루트에 `.nvmrc` 박혀있어 `nvm use` 로 자동 전환됨)
|
||||||
- **PostgreSQL**: 데이터베이스 서버
|
- **PostgreSQL**: 데이터베이스 서버
|
||||||
- **npm**: 10.0+
|
- **npm**: 10.0+
|
||||||
|
|
||||||
@@ -148,25 +148,33 @@ cd frontend && npm install && npm run dev
|
|||||||
cd backend-spring && ./gradlew bootRun
|
cd backend-spring && ./gradlew bootRun
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Docker 환경 실행
|
### 3. Docker 환경 실행 (dev)
|
||||||
|
|
||||||
|
frontend + backend 한 컴포즈 파일로 띄움. 코드 변경은 volume mount 로 컨테이너 안에 즉시 반영됨 (turbopack 자동 리로드).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 개발 (백엔드 + 프론트엔드)
|
# 개발 환경 한 번에 띄우기
|
||||||
docker-compose -f docker-compose.backend.win.yml up -d
|
docker compose -f docker/dev/docker-compose.invyone.yml up -d
|
||||||
docker-compose -f docker-compose.frontend.win.yml up -d
|
|
||||||
|
|
||||||
# 프로덕션 배포
|
# 내리기 / 재시작 / 로그
|
||||||
docker-compose -f docker/deploy/docker-compose.yml up -d
|
docker compose -f docker/dev/docker-compose.invyone.yml down
|
||||||
|
docker compose -f docker/dev/docker-compose.invyone.yml restart
|
||||||
|
docker compose -f docker/dev/docker-compose.invyone.yml logs -f
|
||||||
|
|
||||||
|
# 프로덕션 배포 (별도)
|
||||||
|
docker compose -f docker/deploy/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 서비스 접속
|
### 4. 서비스 접속
|
||||||
|
|
||||||
| 서비스 | URL | 설명 |
|
| 환경 | 서비스 | URL | 설명 |
|
||||||
|--------|-----|------|
|
|------|--------|-----|------|
|
||||||
| **프론트엔드** | http://localhost:9771 | Next.js UI |
|
| **로컬 dev** (`npm run dev` / `gradlew bootRun`) | 프론트엔드 | http://localhost:9771 | Next.js (turbopack) |
|
||||||
| **백엔드 API** | http://localhost:8081 | Spring Boot REST API |
|
| | 백엔드 API | http://localhost:8081 | Spring Boot |
|
||||||
|
| **도커 dev** (위 컴포즈) | 프론트엔드 | http://localhost:9772 | 컨테이너 내부 3000 → 호스트 9772 |
|
||||||
|
| | 백엔드 API | http://localhost:8083 | 컨테이너 내부 8081 → 호스트 8083 |
|
||||||
|
|
||||||
> 프론트엔드는 `next.config.mjs`의 rewrite 설정으로 `/api/*` 요청을 백엔드(8081)로 프록시합니다.
|
> 프론트엔드는 `next.config.mjs` 의 rewrites 설정으로 `/api/*` 요청을 백엔드로 프록시합니다 (도커 컴포즈에서는 컨테이너 네트워크 내부 이름 `invyone-backend-spring:8081` 로 프록시).
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
@@ -244,8 +252,11 @@ file:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# frontend/.env.local
|
# frontend/.env.local — 클라이언트(브라우저) 가 사용할 API base URL
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8081/api
|
# 반드시 상대경로 "/api" 로 둘 것. 절대 URL (예: http://localhost:8083/api) 을 박으면
|
||||||
|
# 다른 머신/도메인에서 접속할 때 클라이언트가 자기 자신을 찌르며 connection refused 발생함.
|
||||||
|
# next dev server 의 rewrites 가 "/api/*" 를 컨테이너 내부 backend 로 프록시한다.
|
||||||
|
NEXT_PUBLIC_API_URL=/api
|
||||||
```
|
```
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
@@ -259,7 +270,7 @@ docker build -t invion .
|
|||||||
|
|
||||||
멀티스테이지 빌드 과정:
|
멀티스테이지 빌드 과정:
|
||||||
1. **Stage 1** — Spring Boot 빌드 (`eclipse-temurin:21-jdk-alpine`, Gradle → bootJar)
|
1. **Stage 1** — Spring Boot 빌드 (`eclipse-temurin:21-jdk-alpine`, Gradle → bootJar)
|
||||||
2. **Stage 2** — Next.js 빌드 (`node:20.10-alpine`, npm → standalone)
|
2. **Stage 2** — Next.js 빌드 (`node:22-alpine`, npm → standalone)
|
||||||
3. **Stage 3** — 런타임 (`eclipse-temurin:21-jre-alpine` + Node.js, 두 서비스 병렬 실행)
|
3. **Stage 3** — 런타임 (`eclipse-temurin:21-jre-alpine` + Node.js, 두 서비스 병렬 실행)
|
||||||
|
|
||||||
### CI/CD 파이프라인
|
### CI/CD 파이프라인
|
||||||
@@ -274,6 +285,26 @@ Git Push → Jenkins → Kaniko 이미지 빌드 → 프라이빗 레지스트
|
|||||||
- **배포**: Helm 차트 + GitOps (이미지 태그 자동 업데이트)
|
- **배포**: Helm 차트 + GitOps (이미지 태그 자동 업데이트)
|
||||||
- **프로덕션 도메인**: Traefik 리버스 프록시 + Let's Encrypt HTTPS
|
- **프로덕션 도메인**: Traefik 리버스 프록시 + Let's Encrypt HTTPS
|
||||||
|
|
||||||
|
## 알려진 설정 정책
|
||||||
|
|
||||||
|
후임자/협업자가 "이거 왜 이렇게 짜놨지?" 헷갈리지 않도록 의도가 있는 비명시적 결정만 정리.
|
||||||
|
|
||||||
|
### `frontend/next.config.mjs` — `isDev` 분기
|
||||||
|
|
||||||
|
`output: "standalone"` 과 `experimental.webpackMemoryOptimizations` 는 **prod build 에서만 켜진다** (`NODE_ENV !== "production"` 이면 비활성화). dev 모드에서 같이 켜면 다음 두 가지 부작용이 동시에 발생함:
|
||||||
|
- `output: standalone` 이 dev 청크 manifest 경로 처리를 깨트림 → 라우트 그룹 `(main)/(auth)` 의 layout/page 청크가 `_next/static/chunks/...` 에 못 만들어짐
|
||||||
|
- `webpackMemoryOptimizations` 가 컴파일된 SSR 청크를 GC 해버려서 첫 visit 후 ENOENT 발생 → ChunkLoadError 영구화
|
||||||
|
|
||||||
|
→ 두 옵션은 절대 dev 에서 켜지 말 것. prod build 에서만 의미 있음.
|
||||||
|
|
||||||
|
### `docker/dev/frontend.Dockerfile` — turbopack 강제
|
||||||
|
|
||||||
|
`dev:docker` 스크립트에 `--turbopack` 플래그가 박혀있다. 도커에서 webpack 으로 돌리면 next 15 + app router + 라우트 그룹 `(main)/(auth)` 조합에서 layout/page 청크가 disk 에 flush 되지 않는 케이스가 발생함. 로컬 dev (`npm run dev`) 도 동일하게 turbopack 사용. 베이스 이미지는 `node:22-alpine`.
|
||||||
|
|
||||||
|
### Frontend → Backend API 호출 — 반드시 상대경로
|
||||||
|
|
||||||
|
위 "환경 변수" 섹션 참고. `NEXT_PUBLIC_API_URL` 에 절대 URL 을 박으면 안 된다. `/api` 로 두고 `next.config.mjs` 의 rewrites 가 처리하도록 위임.
|
||||||
|
|
||||||
## 코드 컨벤션
|
## 코드 컨벤션
|
||||||
|
|
||||||
### 네이밍 규칙
|
### 네이밍 규칙
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
, MENU.MENU_DESC
|
, MENU.MENU_DESC
|
||||||
, MENU.SEQ
|
, MENU.SEQ
|
||||||
, MENU.WRITER
|
, MENU.WRITER
|
||||||
, MENU.REGDATE
|
, MENU.CREATED_DATE
|
||||||
, MENU.STATUS
|
, MENU.STATUS
|
||||||
, MENU.COMPANY_CODE
|
, MENU.COMPANY_CODE
|
||||||
, MENU.LANG_KEY
|
, MENU.LANG_KEY
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
, S.MENU_DESC
|
, S.MENU_DESC
|
||||||
, S.SEQ
|
, S.SEQ
|
||||||
, S.WRITER
|
, S.WRITER
|
||||||
, S.REGDATE
|
, S.CREATED_DATE
|
||||||
, S.STATUS
|
, S.STATUS
|
||||||
, S.COMPANY_CODE
|
, S.COMPANY_CODE
|
||||||
, S.LANG_KEY
|
, S.LANG_KEY
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
, COALESCE(V.MENU_DESC, '') AS MENU_DESC
|
, COALESCE(V.MENU_DESC, '') AS MENU_DESC
|
||||||
, CAST(V.SEQ AS TEXT) AS SEQ
|
, CAST(V.SEQ AS TEXT) AS SEQ
|
||||||
, V.WRITER
|
, V.WRITER
|
||||||
, TO_CHAR(V.REGDATE, 'YYYY-MM-DD') AS REGDATE
|
, TO_CHAR(V.CREATED_DATE, 'YYYY-MM-DD') AS REGDATE
|
||||||
, V.STATUS
|
, V.STATUS
|
||||||
, V.COMPANY_CODE
|
, V.COMPANY_CODE
|
||||||
, COALESCE(V.LANG_KEY, '') AS LANG_KEY
|
, COALESCE(V.LANG_KEY, '') AS LANG_KEY
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
, MENU.MENU_DESC
|
, MENU.MENU_DESC
|
||||||
, MENU.SEQ
|
, MENU.SEQ
|
||||||
, MENU.WRITER
|
, MENU.WRITER
|
||||||
, MENU.REGDATE
|
, MENU.CREATED_DATE
|
||||||
, MENU.STATUS
|
, MENU.STATUS
|
||||||
, MENU.COMPANY_CODE
|
, MENU.COMPANY_CODE
|
||||||
, MENU.LANG_KEY
|
, MENU.LANG_KEY
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
, S.MENU_DESC
|
, S.MENU_DESC
|
||||||
, S.SEQ
|
, S.SEQ
|
||||||
, S.WRITER
|
, S.WRITER
|
||||||
, S.REGDATE
|
, S.CREATED_DATE
|
||||||
, S.STATUS
|
, S.STATUS
|
||||||
, S.COMPANY_CODE
|
, S.COMPANY_CODE
|
||||||
, S.LANG_KEY
|
, S.LANG_KEY
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
, COALESCE(V.MENU_DESC, '') AS MENU_DESC
|
, COALESCE(V.MENU_DESC, '') AS MENU_DESC
|
||||||
, CAST(V.SEQ AS TEXT) AS SEQ
|
, CAST(V.SEQ AS TEXT) AS SEQ
|
||||||
, V.WRITER
|
, V.WRITER
|
||||||
, TO_CHAR(V.REGDATE, 'YYYY-MM-DD') AS REGDATE
|
, TO_CHAR(V.CREATED_DATE, 'YYYY-MM-DD') AS REGDATE
|
||||||
, V.STATUS
|
, V.STATUS
|
||||||
, V.COMPANY_CODE
|
, V.COMPANY_CODE
|
||||||
, COALESCE(V.LANG_KEY, '') AS LANG_KEY
|
, COALESCE(V.LANG_KEY, '') AS LANG_KEY
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Node.js 20 기반 이미지 사용
|
# Node.js 22 기반 (active LTS, 2027-04 까지 — Next 15.4 turbopack 권장 환경)
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
# 작업 디렉토리 설정
|
# 작업 디렉토리 설정
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -16,5 +16,7 @@ COPY . .
|
|||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지)
|
# 개발 서버 시작 (Next 15.4 turbopack stable. webpack 으로 돌리면
|
||||||
|
# app router + 라우트 그룹 (main)/(auth) 의 layout/page 청크가 disk 에
|
||||||
|
# 안 flush 되어 ChunkLoadError 가 영구 발생함. 로컬과 동일하게 turbopack 사용)
|
||||||
CMD ["npm", "run", "dev:docker"]
|
CMD ["npm", "run", "dev:docker"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense, useEffect, useCallback } from "react";
|
import { useState, Suspense, useEffect, useCallback, useRef } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -549,32 +549,47 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
[pathname, activeTab],
|
[pathname, activeTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 접힌 사이드바에서 부모 메뉴 클릭 → 플라이아웃
|
// 플라이아웃 닫기 타이머 — hover out 후 작은 딜레이로 닫아 마우스가 버튼 ↔ 플라이아웃 이동 시 끊기지 않게 함
|
||||||
|
const flyoutCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const cancelFlyoutClose = useCallback(() => {
|
||||||
|
if (flyoutCloseTimerRef.current) {
|
||||||
|
clearTimeout(flyoutCloseTimerRef.current);
|
||||||
|
flyoutCloseTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleFlyoutClose = useCallback(() => {
|
||||||
|
cancelFlyoutClose();
|
||||||
|
flyoutCloseTimerRef.current = setTimeout(() => setFlyoutMenu(null), 150);
|
||||||
|
}, [cancelFlyoutClose]);
|
||||||
|
|
||||||
|
// 접힌 사이드바에서 부모 메뉴 hover → 플라이아웃 오픈
|
||||||
|
const handleCollapsedMenuHover = useCallback((menu: any, e: React.MouseEvent) => {
|
||||||
|
if (!sidebarCollapsed || !menu.hasChildren) return;
|
||||||
|
cancelFlyoutClose();
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setFlyoutMenu({ menu, rect });
|
||||||
|
}, [sidebarCollapsed, cancelFlyoutClose]);
|
||||||
|
|
||||||
|
// 접힌 사이드바에서 부모 메뉴 클릭 → 이미 hover 로 열려있으므로 swallow (leaf 만 동작)
|
||||||
const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => {
|
const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => {
|
||||||
if (!sidebarCollapsed || !menu.hasChildren) return false;
|
if (!sidebarCollapsed || !menu.hasChildren) return false;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
setFlyoutMenu((prev) => prev?.menu.id === menu.id ? null : { menu, rect });
|
|
||||||
return true;
|
return true;
|
||||||
}, [sidebarCollapsed]);
|
}, [sidebarCollapsed]);
|
||||||
|
|
||||||
// 플라이아웃에서 메뉴 선택
|
// 플라이아웃에서 메뉴 선택
|
||||||
const handleFlyoutSelect = useCallback((child: any) => {
|
const handleFlyoutSelect = useCallback((child: any) => {
|
||||||
|
cancelFlyoutClose();
|
||||||
setFlyoutMenu(null);
|
setFlyoutMenu(null);
|
||||||
handleMenuClick(child);
|
handleMenuClick(child);
|
||||||
}, []);
|
}, [cancelFlyoutClose]);
|
||||||
|
|
||||||
// 바깥 클릭 시 플라이아웃 닫기
|
// 언마운트 시 타이머 정리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!flyoutMenu) return;
|
return () => cancelFlyoutClose();
|
||||||
const close = (e: MouseEvent) => {
|
}, [cancelFlyoutClose]);
|
||||||
if (!(e.target as HTMLElement).closest(".v5-side-flyout") && !(e.target as HTMLElement).closest(".v5-si")) {
|
|
||||||
setFlyoutMenu(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", close);
|
|
||||||
return () => document.removeEventListener("click", close);
|
|
||||||
}, [flyoutMenu]);
|
|
||||||
|
|
||||||
// 메뉴 트리 렌더링 (v5 glassmorphism)
|
// 메뉴 트리 렌더링 (v5 glassmorphism)
|
||||||
const renderMenu = (menu: any, level: number = 0) => {
|
const renderMenu = (menu: any, level: number = 0) => {
|
||||||
@@ -582,7 +597,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
const isLeaf = !menu.hasChildren;
|
const isLeaf = !menu.hasChildren;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={menu.id} style={{ position: "relative" }}>
|
<div
|
||||||
|
key={menu.id}
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
onMouseEnter={(e) => handleCollapsedMenuHover(menu, e)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
draggable={isLeaf && !sidebarCollapsed}
|
draggable={isLeaf && !sidebarCollapsed}
|
||||||
onDragStart={(e) => handleMenuDragStart(e, menu)}
|
onDragStart={(e) => handleMenuDragStart(e, menu)}
|
||||||
@@ -607,6 +629,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
<div
|
<div
|
||||||
className="v5-side-flyout open"
|
className="v5-side-flyout open"
|
||||||
style={{ top: 0 }}
|
style={{ top: 0 }}
|
||||||
|
onMouseEnter={cancelFlyoutClose}
|
||||||
|
onMouseLeave={scheduleFlyoutClose}
|
||||||
>
|
>
|
||||||
<div className="fly-title">{menu.name}</div>
|
<div className="fly-title">{menu.name}</div>
|
||||||
{menu.children?.map((child: any) => (
|
{menu.children?.map((child: any) => (
|
||||||
@@ -622,9 +646,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어 */}
|
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */}
|
||||||
{!sidebarCollapsed && menu.hasChildren && (
|
{!sidebarCollapsed && menu.hasChildren && (
|
||||||
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
|
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
|
||||||
|
<div className="v5-submenu-inner">
|
||||||
{menu.children?.map((child: any, idx: number) => (
|
{menu.children?.map((child: any, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -639,6 +664,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771",
|
"dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771",
|
||||||
"dev:docker": "next dev -p 3000",
|
"dev:docker": "next dev --turbopack -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
|||||||
@@ -397,12 +397,15 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
|||||||
.v5-side:not(.collapsed) .v5-side-group .v5-si{
|
.v5-side:not(.collapsed) .v5-side-group .v5-si{
|
||||||
transition:height .35s .12s cubic-bezier(.16,1,.3,1),padding .35s .12s,opacity .35s .15s,margin .35s .12s;}
|
transition:height .35s .12s cubic-bezier(.16,1,.3,1),padding .35s .12s,opacity .35s .15s,margin .35s .12s;}
|
||||||
|
|
||||||
/* ===== SUBMENU EXPAND/COLLAPSE ===== */
|
/* ===== SUBMENU EXPAND/COLLAPSE =====
|
||||||
|
grid-template-rows:0fr↔1fr 트릭은 직속 child 가 단 하나일 때만 동작 (여러 개면 implicit row 가 auto 로 펼쳐짐).
|
||||||
|
그래서 .v5-submenu 안을 .v5-submenu-inner 로 감싸 항상 child 가 1개가 되도록 보장한다. */
|
||||||
.v5-submenu{display:grid;grid-template-rows:0fr;overflow:hidden;padding-left:1.5rem;
|
.v5-submenu{display:grid;grid-template-rows:0fr;overflow:hidden;padding-left:1.5rem;
|
||||||
transition:grid-template-rows .35s cubic-bezier(.4,0,.2,1),opacity .25s;}
|
transition:grid-template-rows .35s cubic-bezier(.4,0,.2,1),opacity .25s;}
|
||||||
.v5-submenu>*{overflow:hidden;}
|
.v5-submenu>*{overflow:hidden;min-height:0;}
|
||||||
.v5-submenu.expanded{grid-template-rows:1fr;opacity:1;}
|
.v5-submenu.expanded{grid-template-rows:1fr;opacity:1;}
|
||||||
.v5-submenu:not(.expanded){opacity:0;}
|
.v5-submenu:not(.expanded){opacity:0;}
|
||||||
|
.v5-submenu-inner{display:flex;flex-direction:column;gap:1px;}
|
||||||
|
|
||||||
/* Sub items stagger slide in */
|
/* Sub items stagger slide in */
|
||||||
.v5-sub-item{transform:translateX(-10px);opacity:0;
|
.v5-sub-item{transform:translateX(-10px);opacity:0;
|
||||||
|
|||||||
Reference in New Issue
Block a user