123
This commit is contained in:
@@ -31,8 +31,7 @@ public class SecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/multilang/batch").permitAll()
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
AND MENU.STATUS = 'active'
|
||||
</if>
|
||||
<if test="menu_type != null">
|
||||
AND MENU.MENU_TYPE = CAST(#{menu_type} AS NUMERIC)
|
||||
AND MENU.MENU_TYPE = #{menu_type}
|
||||
</if>
|
||||
<choose>
|
||||
<when test='!is_management_screen and menu_type != null and menu_type == "0"'>
|
||||
|
||||
@@ -4,32 +4,32 @@ services:
|
||||
build:
|
||||
context: ../../backend-spring
|
||||
dockerfile: ../docker/dev/backend-spring.Dockerfile
|
||||
container_name: pms-backend-mac
|
||||
container_name: pms-backend-mac-v2
|
||||
ports:
|
||||
- "8081:8081"
|
||||
- "8082:8082"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=dev
|
||||
- SERVER_PORT=8081
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://39.117.244.52:11132/testvex
|
||||
- SERVER_PORT=8082
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://211.115.91.141:11134/test_dev
|
||||
- SPRING_DATASOURCE_USERNAME=postgres
|
||||
- SPRING_DATASOURCE_PASSWORD=ph0909!!
|
||||
- SPRING_DATASOURCE_PASSWORD=vexplor0909!!
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
- JWT_EXPIRATION=86400000
|
||||
- FILE_UPLOAD_DIR=./uploads
|
||||
volumes:
|
||||
- ../../backend-spring:/app
|
||||
networks:
|
||||
- pms-network
|
||||
- test-vex-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
networks:
|
||||
pms-network:
|
||||
test-vex-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -4,22 +4,23 @@ services:
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: ../docker/dev/frontend.Dockerfile
|
||||
container_name: pms-frontend-mac
|
||||
container_name: pms-frontend-mac-v2
|
||||
ports:
|
||||
- "9771:3000"
|
||||
- "9772:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8081/api
|
||||
- SERVER_API_URL=http://pms-backend-mac:8081
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8082/api
|
||||
- SERVER_API_URL=http://pms-backend-mac-v2:8082
|
||||
- NODE_OPTIONS=--max-old-space-size=8192
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
- WATCHPACK_POLLING=true
|
||||
volumes:
|
||||
- ../../frontend:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
networks:
|
||||
- pms-network
|
||||
- test-vex-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
pms-network:
|
||||
test-vex-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Node.js 18 기반 이미지 사용
|
||||
FROM node:18-alpine
|
||||
# Node.js 20 기반 이미지 사용
|
||||
FROM node:20-alpine
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/* ===== INVION v5 Login — Cosmic Command Center ===== */
|
||||
/* Scoped under .inv-login to avoid global conflicts */
|
||||
|
||||
.inv-login {
|
||||
--bg:#fafaff; --bg-subtle:#f3f2fa; --surface:rgba(255,255,255,0.55); --surface-solid:#ffffff;
|
||||
--surface-hover:rgba(255,255,255,0.7); --text:#0f0e1a; --text-sec:#6b6a80; --text-muted:#9998ad;
|
||||
--primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(108,92,231,0.25);
|
||||
--cyan:#00cec9; --cyan-glow:rgba(0,206,201,0.2); --pink:#fd79a8; --pink-glow:rgba(253,121,168,0.15);
|
||||
--red:#ff4757; --green:#00b894; --amber:#fdcb6e;
|
||||
--border:rgba(108,92,231,0.12); --border-subtle:rgba(0,0,0,0.05);
|
||||
--glass:rgba(255,255,255,0.45); --glass-strong:rgba(255,255,255,0.65);
|
||||
--glass-border:rgba(108,92,231,0.12);
|
||||
--glow-sm:0 0 20px rgba(108,92,231,0.12); --glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
|
||||
position:fixed;inset:0;z-index:1;
|
||||
font-family:'Inter',system-ui,sans-serif;
|
||||
background:var(--bg);color:var(--text);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
overflow:hidden;
|
||||
transition:background .5s,color .4s;
|
||||
}
|
||||
|
||||
.inv-login.dark {
|
||||
--bg:#06050e; --bg-subtle:#0c0b18; --surface:rgba(17,16,42,0.5); --surface-solid:#11102a;
|
||||
--surface-hover:rgba(25,24,64,0.6); --text:#eae8f4; --text-sec:#8d8ba8; --text-muted:#5a587a;
|
||||
--primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(162,155,254,0.25);
|
||||
--cyan:#55efc4; --cyan-glow:rgba(85,239,196,0.15); --pink:#fd79a8; --red:#ff6b6b;
|
||||
--green:#55efc4; --amber:#ffeaa7;
|
||||
--border:rgba(162,155,254,0.1); --border-subtle:rgba(255,255,255,0.04);
|
||||
--glass:rgba(17,16,42,0.45); --glass-strong:rgba(17,16,42,0.65);
|
||||
--glass-border:rgba(162,155,254,0.12);
|
||||
--glow-sm:0 0 20px rgba(162,155,254,0.1); --glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.inv-login .cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
|
||||
.inv-login .star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
|
||||
animation:inv-twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
|
||||
.inv-login .star.c{width:3px;height:3px;background:var(--sc);}
|
||||
@keyframes inv-twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
|
||||
|
||||
.inv-login:not(.dark) .star{display:none;}
|
||||
.inv-login:not(.dark) .shooting-star{display:none;}
|
||||
.inv-login:not(.dark) .particle{display:none;}
|
||||
|
||||
.inv-login .neb{position:absolute;border-radius:50%;filter:blur(140px);animation:inv-drift 16s ease-in-out infinite alternate;}
|
||||
.inv-login .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.inv-login .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.inv-login .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.inv-login .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes inv-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
.inv-login:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
.inv-login:not(.dark) .neb{filter:blur(100px);}
|
||||
.inv-login:not(.dark) .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;border-radius:50%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
|
||||
.inv-login:not(.dark) .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);animation-duration:20s;}
|
||||
.inv-login:not(.dark) .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
|
||||
.inv-login:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
|
||||
.inv-login .shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
transform:rotate(35deg);animation:inv-shoot 5s ease-in-out infinite;opacity:0;}
|
||||
.inv-login .shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
|
||||
@keyframes inv-shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
|
||||
|
||||
.inv-login .particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--primary));
|
||||
border-radius:50%;opacity:0;animation:inv-floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
|
||||
@keyframes inv-floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
|
||||
|
||||
/* ===== WARP CANVAS ===== */
|
||||
.inv-login .warp-canvas{position:fixed;inset:0;z-index:2;pointer-events:none;opacity:0;}
|
||||
.inv-login .warp-canvas.active{opacity:1;}
|
||||
|
||||
/* ===== THEME TOGGLE ===== */
|
||||
.inv-login .pill{display:flex;background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--glass-border);
|
||||
border-radius:999px;padding:2px;position:absolute;top:1.5rem;right:1.5rem;z-index:20;}
|
||||
.inv-login .pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.inv-login .pill button.on{background:var(--primary);color:white;box-shadow:var(--glow-sm);}
|
||||
|
||||
/* ===== THEME FADE ===== */
|
||||
.inv-login .theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
|
||||
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
|
||||
.inv-login .theme-fade.in{opacity:1;}
|
||||
|
||||
/* ===== LOGIN CARD ===== */
|
||||
.inv-login .login-card{
|
||||
width:400px;backdrop-filter:blur(30px) saturate(1.5);-webkit-backdrop-filter:blur(30px) saturate(1.5);
|
||||
border-radius:28px;padding:2.5rem 2.5rem 2rem;background:var(--glass-strong);
|
||||
border:1px solid var(--glass-border);
|
||||
box-shadow:0 12px 48px rgba(0,0,0,0.08),inset 0 0 0 1px rgba(255,255,255,0.18);
|
||||
animation:inv-cardIn 1s cubic-bezier(.16,1,.3,1) .3s both;position:relative;z-index:5;
|
||||
}
|
||||
.inv-login:not(.dark) .login-card{background:rgba(255,255,255,0.82);}
|
||||
.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(162,155,254,0.06);}
|
||||
@keyframes inv-cardIn{from{opacity:0;transform:translateY(40px) scale(.96)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Orbital decoration */
|
||||
.inv-login .login-orbit{width:80px;height:80px;margin:0 auto 1rem;position:relative;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .4s both;}
|
||||
.inv-login .orbit-core{position:absolute;inset:22px;border-radius:50%;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15);
|
||||
animation:inv-corePulse 3s ease-in-out infinite;}
|
||||
@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15)}
|
||||
50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(0,206,201,0.25)}}
|
||||
.inv-login .orbit-ring{position:absolute;inset:0;border-radius:50%;border:1.5px solid transparent;
|
||||
border-top-color:var(--primary);border-right-color:var(--cyan);
|
||||
animation:inv-orbitSpin 4s linear infinite;opacity:.6;}
|
||||
.inv-login .orbit-ring-2{position:absolute;inset:6px;border-radius:50%;border:1px solid transparent;
|
||||
border-bottom-color:var(--pink);border-left-color:var(--primary-light);
|
||||
animation:inv-orbitSpin 6s linear infinite reverse;opacity:.4;}
|
||||
@keyframes inv-orbitSpin{to{transform:rotate(360deg)}}
|
||||
.inv-login .orbit-dot{position:absolute;width:6px;height:6px;border-radius:50%;background:var(--cyan);
|
||||
top:-1px;left:50%;margin-left:-3px;box-shadow:0 0 10px var(--cyan-glow);
|
||||
animation:inv-orbitSpin 4s linear infinite;transform-origin:3px 41px;}
|
||||
|
||||
/* Fail shake */
|
||||
@keyframes inv-denied{
|
||||
0%{transform:scale(1.1);box-shadow:0 0 60px rgba(255,71,87,0.4),0 0 0 2px rgba(255,71,87,0.5),inset 0 0 30px rgba(255,71,87,0.05)}
|
||||
18%{transform:scale(1) translateX(-6px)}
|
||||
36%{transform:scale(1) translateX(5px)}
|
||||
52%{transform:scale(1) translateX(-4px)}
|
||||
66%{transform:scale(1) translateX(3px)}
|
||||
78%{transform:scale(1) translateX(-1px)}
|
||||
100%{transform:scale(1) translateX(0);box-shadow:none}
|
||||
}
|
||||
.inv-login .login-card.denied{animation:inv-denied .55s cubic-bezier(.36,.07,.19,.97) both;}
|
||||
|
||||
/* Logo */
|
||||
.inv-login .logo{text-align:center;margin-bottom:.15rem;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .5s both;}
|
||||
.inv-login .logo h1{font-size:1.8rem;font-weight:900;letter-spacing:-.04em;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan) 60%,var(--pink));
|
||||
background-size:200% 200%;-webkit-background-clip:text;-webkit-text-fill-color:transparent;
|
||||
background-clip:text;animation:inv-gshift 4s ease-in-out infinite;}
|
||||
@keyframes inv-logoIn{from{opacity:0;transform:translateY(-20px);filter:blur(10px)}to{opacity:1;transform:none;filter:none}}
|
||||
@keyframes inv-gshift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||||
|
||||
.inv-login .login-sub{text-align:center;font-size:.78rem;color:var(--text-muted);margin-bottom:1.8rem;animation:inv-fadeIn .8s .7s both;}
|
||||
@keyframes inv-fadeIn{from{opacity:0}to{opacity:1}}
|
||||
|
||||
/* Form groups */
|
||||
.inv-login .fg{margin-bottom:1rem;animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) both;}
|
||||
.inv-login .fg:nth-child(1){animation-delay:.8s}
|
||||
.inv-login .fg:nth-child(2){animation-delay:.9s}
|
||||
@keyframes inv-fgIn{from{opacity:0;transform:translateX(-20px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Input with icon */
|
||||
.inv-login .fi-wrap{position:relative;display:flex;align-items:center;}
|
||||
.inv-login .fi-icon{position:absolute;left:14px;color:var(--text-muted);pointer-events:none;transition:color .3s;flex-shrink:0;}
|
||||
.inv-login .fi-wrap:focus-within .fi-icon{color:var(--primary);}
|
||||
.inv-login .fi{width:100%;height:48px;padding:0 1rem 0 2.6rem;border:1.5px solid var(--border);border-radius:14px;
|
||||
background:var(--surface);color:var(--text);font-size:.875rem;font-family:inherit;outline:none;
|
||||
backdrop-filter:blur(8px);transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.inv-login:not(.dark) input.fi,
|
||||
.inv-login:not(.dark) input.fi:focus,
|
||||
.inv-login:not(.dark) input.fi:active,
|
||||
.inv-login:not(.dark) input.fi:hover{
|
||||
background-color:#ffffff !important;
|
||||
backdrop-filter:none !important;
|
||||
-webkit-backdrop-filter:none !important;
|
||||
border-color:rgba(108,92,231,0.18) !important;
|
||||
color:#0f0e1a !important;
|
||||
}
|
||||
.inv-login:not(.dark) input.fi:-webkit-autofill,
|
||||
.inv-login:not(.dark) input.fi:-webkit-autofill:focus{
|
||||
-webkit-box-shadow:0 0 0 1000px #ffffff inset !important;
|
||||
-webkit-text-fill-color:#0f0e1a !important;
|
||||
background-color:#ffffff !important;
|
||||
}
|
||||
.inv-login .fi::placeholder{color:var(--text-muted);font-weight:400;}
|
||||
.inv-login .fi:focus{border-color:var(--primary);box-shadow:0 0 0 4px var(--primary-glow),var(--glow-sm);}
|
||||
.inv-login .fi.error{border-color:rgba(255,71,87,0.4) !important;box-shadow:0 0 12px rgba(255,71,87,0.12) !important;}
|
||||
.inv-login .pw-w .fi{padding-right:2.8rem;}
|
||||
.inv-login .pw-w{position:relative;display:flex;align-items:center;}
|
||||
.inv-login .pw-b{position:absolute;right:14px;top:50%;transform:translateY(-50%);background:none;border:none;
|
||||
color:var(--text-muted);cursor:pointer;padding:4px;transition:color .2s;}
|
||||
.inv-login .pw-b:hover{color:var(--primary);}
|
||||
|
||||
/* Divider */
|
||||
.inv-login .login-divider{display:flex;align-items:center;gap:.75rem;margin:1.25rem 0;animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) 1s both;}
|
||||
.inv-login .login-divider::before,.inv-login .login-divider::after{content:'';flex:1;height:1px;background:var(--border);}
|
||||
.inv-login .login-divider span{font-size:.6rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;}
|
||||
|
||||
/* Error message */
|
||||
.inv-login .err-msg{font-size:.72rem;color:var(--red);text-align:center;margin-top:.75rem;
|
||||
opacity:0;transform:translateY(-5px);transition:opacity .4s,transform .4s;}
|
||||
.inv-login .err-msg.show{opacity:1;transform:translateY(0);}
|
||||
|
||||
/* Launch button */
|
||||
.inv-login .lbtn{width:100%;height:50px;border:none;border-radius:14px;font-size:.9rem;font-weight:700;
|
||||
font-family:inherit;color:white;cursor:pointer;position:relative;overflow:hidden;
|
||||
display:flex;align-items:center;justify-content:center;gap:.5rem;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
box-shadow:0 4px 20px var(--primary-glow);transition:all .4s cubic-bezier(.4,0,.2,1);
|
||||
animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) 1.1s both;}
|
||||
.inv-login .lbtn:hover{transform:translateY(-2px);box-shadow:var(--glow-lg),0 8px 30px var(--primary-glow);}
|
||||
.inv-login .lbtn:active{transform:translateY(0) scale(.98);}
|
||||
.inv-login .lbtn:disabled{opacity:.7;cursor:not-allowed;transform:none;}
|
||||
.inv-login .lbtn::before{content:'';position:absolute;top:0;left:-100%;width:100%;height:100%;
|
||||
background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);animation:inv-shimmer 3s ease-in-out infinite;}
|
||||
@keyframes inv-shimmer{0%{left:-100%}50%{left:100%}100%{left:100%}}
|
||||
|
||||
/* Ripple */
|
||||
.inv-login .rip{position:absolute;border-radius:50%;background:rgba(255,255,255,.4);transform:scale(0);animation:inv-ripX .6s ease-out;pointer-events:none;}
|
||||
@keyframes inv-ripX{to{transform:scale(4);opacity:0}}
|
||||
|
||||
/* Spinner */
|
||||
.inv-login .spinner{display:inline-block;width:20px;height:20px;border:2.5px solid rgba(255,255,255,.3);
|
||||
border-top-color:white;border-radius:50%;animation:inv-spin .6s linear infinite;}
|
||||
@keyframes inv-spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* Footer */
|
||||
.inv-login .login-ft{text-align:center;margin-top:1.25rem;font-size:.65rem;color:var(--text-muted);animation:inv-fadeIn .8s 1.2s both;letter-spacing:.02em;}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useLogin } from "@/hooks/useLogin";
|
||||
import { LoginHeader } from "@/components/auth/LoginHeader";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { LoginFooter } from "@/components/auth/LoginFooter";
|
||||
import { User, Lock, Eye, EyeOff, ArrowRight } from "lucide-react";
|
||||
import "./login.css";
|
||||
|
||||
export default function LoginPage() {
|
||||
const {
|
||||
@@ -11,31 +11,156 @@ export default function LoginPage() {
|
||||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
isPopMode,
|
||||
handleInputChange,
|
||||
handleLogin,
|
||||
togglePasswordVisibility,
|
||||
togglePopMode,
|
||||
} = useLogin();
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const cosmosRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const fadeRef = useRef<HTMLDivElement>(null);
|
||||
const errRef = useRef<HTMLDivElement>(null);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
// ===== Cosmic background — stars + particles =====
|
||||
useEffect(() => {
|
||||
const co = cosmosRef.current;
|
||||
if (!co) return;
|
||||
const cs = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const s = document.createElement("div");
|
||||
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
|
||||
if (s.classList.contains("c")) s.style.setProperty("--sc", cs[(Math.random() * 3) | 0]);
|
||||
s.style.left = Math.random() * 100 + "%";
|
||||
s.style.top = Math.random() * 100 + "%";
|
||||
s.style.setProperty("--d", (2 + Math.random() * 5) + "s");
|
||||
s.style.setProperty("--dl", Math.random() * 5 + "s");
|
||||
s.style.setProperty("--mo", (0.3 + Math.random() * 0.7) + "");
|
||||
co.appendChild(s);
|
||||
}
|
||||
const pc = ["var(--primary)", "var(--cyan)", "var(--pink)"];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = document.createElement("div");
|
||||
p.className = "particle";
|
||||
p.style.left = Math.random() * 100 + "%";
|
||||
p.style.setProperty("--sz", (2 + Math.random() * 4) + "px");
|
||||
p.style.setProperty("--pc", pc[(Math.random() * 3) | 0]);
|
||||
p.style.setProperty("--fd", (7 + Math.random() * 12) + "s");
|
||||
p.style.setProperty("--fdl", Math.random() * 10 + "s");
|
||||
co.appendChild(p);
|
||||
}
|
||||
return () => {
|
||||
co.querySelectorAll(".star, .particle").forEach((el) => el.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== Theme toggle =====
|
||||
const setTheme = useCallback((t: "light" | "dark") => {
|
||||
const root = rootRef.current;
|
||||
const fade = fadeRef.current;
|
||||
if (!root || !fade || (window as any)._themeSwitching) return;
|
||||
const cur = root.classList.contains("dark") ? "dark" : "light";
|
||||
if (cur === t) return;
|
||||
(window as any)._themeSwitching = true;
|
||||
fade.style.background =
|
||||
t === "dark"
|
||||
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
|
||||
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
|
||||
fade.classList.add("in");
|
||||
setTimeout(() => {
|
||||
root.classList.toggle("dark", t === "dark");
|
||||
setIsDark(t === "dark");
|
||||
setTimeout(() => {
|
||||
fade.classList.remove("in");
|
||||
(window as any)._themeSwitching = false;
|
||||
}, 50);
|
||||
}, 420);
|
||||
}, []);
|
||||
|
||||
// ===== Show error with denied animation =====
|
||||
useEffect(() => {
|
||||
if (!error || !cardRef.current || !errRef.current) return;
|
||||
const card = cardRef.current;
|
||||
const errEl = errRef.current;
|
||||
card.classList.remove("denied");
|
||||
void card.offsetWidth;
|
||||
card.classList.add("denied");
|
||||
errEl.classList.add("show");
|
||||
const t1 = setTimeout(() => card.classList.remove("denied"), 600);
|
||||
const t2 = setTimeout(() => errEl.classList.remove("show"), 3000);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [error]);
|
||||
|
||||
// ===== Ripple on button =====
|
||||
const handleRipple = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const btn = e.currentTarget;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const d = document.createElement("div");
|
||||
d.className = "rip";
|
||||
const sz = Math.max(r.width, r.height) * 2;
|
||||
d.style.width = d.style.height = sz + "px";
|
||||
d.style.left = e.clientX - r.left - sz / 2 + "px";
|
||||
d.style.top = e.clientY - r.top - sz / 2 + "px";
|
||||
btn.appendChild(d);
|
||||
setTimeout(() => d.remove(), 600);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<LoginHeader />
|
||||
<div ref={rootRef} className="inv-login">
|
||||
<div ref={fadeRef} className="theme-fade" />
|
||||
|
||||
<LoginForm
|
||||
formData={formData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
showPassword={showPassword}
|
||||
isPopMode={isPopMode}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleLogin}
|
||||
onTogglePassword={togglePasswordVisibility}
|
||||
onTogglePop={togglePopMode}
|
||||
/>
|
||||
<div ref={cosmosRef} className="cosmos">
|
||||
<div className="neb neb-1" />
|
||||
<div className="neb neb-2" />
|
||||
<div className="neb neb-3" />
|
||||
<div className="neb neb-4" />
|
||||
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
|
||||
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
|
||||
</div>
|
||||
|
||||
<LoginFooter />
|
||||
<div className="pill">
|
||||
<button className={!isDark ? "on" : ""} onClick={() => setTheme("light")}>Light</button>
|
||||
<button className={isDark ? "on" : ""} onClick={() => setTheme("dark")}>Dark</button>
|
||||
</div>
|
||||
|
||||
<div ref={cardRef} className="login-card">
|
||||
<div className="login-orbit">
|
||||
<div className="orbit-ring" />
|
||||
<div className="orbit-ring-2" />
|
||||
<div className="orbit-dot" />
|
||||
<div className="orbit-core" />
|
||||
</div>
|
||||
|
||||
<div className="logo"><h1>INVION</h1></div>
|
||||
<div className="login-sub">Cosmic Command Center</div>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="fg">
|
||||
<div className="fi-wrap">
|
||||
<User className="fi-icon" width={16} height={16} />
|
||||
<input className="fi" name="user_id" placeholder="User ID" value={formData.user_id} onChange={handleInputChange} disabled={isLoading} autoComplete="username" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="fg">
|
||||
<div className="fi-wrap pw-w">
|
||||
<Lock className="fi-icon" width={16} height={16} />
|
||||
<input className={`fi${error ? " error" : ""}`} name="password" type={showPassword ? "text" : "password"} placeholder="Password" value={formData.password} onChange={handleInputChange} disabled={isLoading} autoComplete="current-password" />
|
||||
<button type="button" className="pw-b" onClick={togglePasswordVisibility} disabled={isLoading}>
|
||||
{showPassword ? <EyeOff width={16} height={16} /> : <Eye width={16} height={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-divider"><span>ready to launch</span></div>
|
||||
|
||||
<button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}>
|
||||
{isLoading ? <span className="spinner" /> : <><span>Launch</span><ArrowRight width={16} height={16} /></>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div ref={errRef} className="err-msg">{error || "아이디 또는 비밀번호를 확인해주세요"}</div>
|
||||
<div className="login-ft">© 2026 INVION</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -377,7 +377,11 @@ export default function TableManagementPage() {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
|
||||
if (response.data.success) {
|
||||
setConstraints(response.data.data);
|
||||
const data = response.data.data;
|
||||
setConstraints({
|
||||
primaryKey: data.primaryKey ?? { name: "", columns: [] },
|
||||
indexes: data.indexes ?? [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제약조건 로드 실패:", error);
|
||||
@@ -950,8 +954,8 @@ export default function TableManagementPage() {
|
||||
const filteredTables = useMemo(() => {
|
||||
const filtered = tables.filter(
|
||||
(table) =>
|
||||
table.table_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.display_name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
(table.table_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
||||
return filtered.sort((a, b) => {
|
||||
@@ -1001,7 +1005,7 @@ export default function TableManagementPage() {
|
||||
// PK 체크박스 변경 핸들러
|
||||
const handlePkToggle = useCallback(
|
||||
(columnName: string, checked: boolean) => {
|
||||
const currentPkCols = [...constraints.primaryKey.columns];
|
||||
const currentPkCols = [...(constraints.primaryKey?.columns ?? [])];
|
||||
let newPkCols: string[];
|
||||
if (checked) {
|
||||
newPkCols = [...currentPkCols, columnName];
|
||||
@@ -1012,7 +1016,7 @@ export default function TableManagementPage() {
|
||||
setPendingPkColumns(newPkCols);
|
||||
setPkDialogOpen(true);
|
||||
},
|
||||
[constraints.primaryKey.columns],
|
||||
[constraints.primaryKey?.columns],
|
||||
);
|
||||
|
||||
// PK 변경 확인
|
||||
@@ -1071,7 +1075,7 @@ export default function TableManagementPage() {
|
||||
// 컬럼별 인덱스 상태 헬퍼
|
||||
const getColumnIndexState = useCallback(
|
||||
(columnName: string) => {
|
||||
const isPk = constraints.primaryKey.columns.includes(columnName);
|
||||
const isPk = (constraints.primaryKey?.columns ?? []).includes(columnName);
|
||||
const hasIndex = constraints.indexes.some(
|
||||
(idx) => !idx.is_unique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ===== V5 Cosmic Layout System ===== */
|
||||
@import "../styles/v5-layout.css";
|
||||
|
||||
/* ===== Dark Mode Variant ===== */
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import { TabBar } from "./TabBar";
|
||||
import { TabContent } from "./TabContent";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { CosmicBackground } from "./CosmicBackground";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -235,10 +237,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const { user, logout, refreshUserData } = useAuth();
|
||||
const { user_menus: userMenus, admin_menus: adminMenus, loading, refreshMenus } = useMenu();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [tabsCollapsed, setTabsCollapsed] = useState(false);
|
||||
const [flyoutMenu, setFlyoutMenu] = useState<{ menu: any; rect: DOMRect } | null>(null);
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||
|
||||
const setNextTheme = useCallback((t: string) => {
|
||||
if (theme === t) return;
|
||||
const fade = document.getElementById("v5-theme-fade");
|
||||
if (fade) {
|
||||
fade.style.background = t === "dark"
|
||||
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
|
||||
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
|
||||
fade.classList.add("in");
|
||||
setTimeout(() => {
|
||||
rawSetTheme(t);
|
||||
setTimeout(() => fade.classList.remove("in"), 50);
|
||||
}, 420);
|
||||
} else {
|
||||
rawSetTheme(t);
|
||||
}
|
||||
}, [theme, rawSetTheme]);
|
||||
|
||||
// URL 직접 접근 시 탭 자동 열기
|
||||
useEffect(() => {
|
||||
@@ -441,9 +464,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
|
||||
};
|
||||
|
||||
const handleModeSwitch = () => {
|
||||
setTabMode(isAdminMode ? "user" : "admin");
|
||||
};
|
||||
const handleModeSwitch = useCallback(() => {
|
||||
const modeFade = document.getElementById("v5-mode-fade");
|
||||
const sidebar = document.querySelector(".v5-side") as HTMLElement;
|
||||
const tabBar = document.querySelector(".v5-tabs") as HTMLElement;
|
||||
|
||||
// Phase 1: overlay flash + slide out
|
||||
modeFade?.classList.add("in");
|
||||
sidebar?.classList.add("slide-out");
|
||||
tabBar?.classList.add("fade-out");
|
||||
|
||||
setTimeout(() => {
|
||||
// Phase 2: swap mode
|
||||
setTabMode(isAdminMode ? "user" : "admin");
|
||||
sidebar?.classList.remove("slide-out");
|
||||
sidebar?.classList.add("slide-in");
|
||||
tabBar?.classList.remove("fade-out");
|
||||
tabBar?.classList.add("fade-in");
|
||||
|
||||
// Phase 3: cleanup
|
||||
setTimeout(() => {
|
||||
modeFade?.classList.remove("in");
|
||||
sidebar?.classList.remove("slide-in");
|
||||
tabBar?.classList.remove("fade-in");
|
||||
}, 400);
|
||||
}, 300);
|
||||
}, [isAdminMode, setTabMode]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -512,58 +558,92 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
[pathname, activeTab],
|
||||
);
|
||||
|
||||
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||
// 접힌 사이드바에서 부모 메뉴 클릭 → 플라이아웃
|
||||
const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => {
|
||||
if (!sidebarCollapsed || !menu.hasChildren) return false;
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setFlyoutMenu((prev) => prev?.menu.id === menu.id ? null : { menu, rect });
|
||||
return true;
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// 플라이아웃에서 메뉴 선택
|
||||
const handleFlyoutSelect = useCallback((child: any) => {
|
||||
setFlyoutMenu(null);
|
||||
handleMenuClick(child);
|
||||
}, []);
|
||||
|
||||
// 바깥 클릭 시 플라이아웃 닫기
|
||||
useEffect(() => {
|
||||
if (!flyoutMenu) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
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)
|
||||
const renderMenu = (menu: any, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(menu.id);
|
||||
const isLeaf = !menu.hasChildren;
|
||||
|
||||
return (
|
||||
<div key={menu.id}>
|
||||
<div key={menu.id} style={{ position: "relative" }}>
|
||||
<div
|
||||
draggable={isLeaf}
|
||||
draggable={isLeaf && !sidebarCollapsed}
|
||||
onDragStart={(e) => handleMenuDragStart(e, menu)}
|
||||
className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${
|
||||
isMenuActive(menu)
|
||||
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
|
||||
: isExpanded
|
||||
? "bg-accent/60 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
} ${level > 0 ? "ml-6" : ""}`}
|
||||
onClick={() => handleMenuClick(menu)}
|
||||
className={`v5-si ${isMenuActive(menu) ? "on" : ""} ${level > 0 ? "ml-6" : ""}`}
|
||||
title={menu.name}
|
||||
onClick={(e) => {
|
||||
if (handleCollapsedMenuClick(menu, e)) return;
|
||||
handleMenuClick(menu);
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{menu.icon}
|
||||
<span className="ml-3 truncate" title={menu.name}>
|
||||
{menu.name}
|
||||
<span className="ic">{menu.icon}</span>
|
||||
<span className="truncate">{menu.name}</span>
|
||||
{menu.hasChildren && !sidebarCollapsed && (
|
||||
<span className="ml-auto">
|
||||
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</div>
|
||||
{menu.hasChildren && (
|
||||
<div className="ml-auto">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menu.hasChildren && isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5 pl-9">
|
||||
{/* 플라이아웃 (접힌 상태에서만) */}
|
||||
{sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && (
|
||||
<div
|
||||
className="v5-side-flyout open"
|
||||
style={{ top: 0 }}
|
||||
>
|
||||
<div className="fly-title">{menu.name}</div>
|
||||
{menu.children?.map((child: any) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`fly-item ${isMenuActive(child) ? "on" : ""}`}
|
||||
onClick={() => handleFlyoutSelect(child)}
|
||||
>
|
||||
<span className="ic">{child.icon}</span>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 펼친 상태의 하위 메뉴 */}
|
||||
{!sidebarCollapsed && menu.hasChildren && isExpanded && (
|
||||
<div className="v5-si-child" style={{ paddingLeft: "1.5rem", display: "flex", flexDirection: "column", gap: "1px" }}>
|
||||
{menu.children?.map((child: any) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => handleMenuDragStart(e, child)}
|
||||
className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${
|
||||
isMenuActive(child)
|
||||
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
className={`v5-si ${isMenuActive(child) ? "on" : ""}`}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{child.icon}
|
||||
<span className="ml-3 truncate" title={child.name}>
|
||||
{child.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ic">{child.icon}</span>
|
||||
<span className="truncate" title={child.name}>{child.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -613,246 +693,182 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Admin permission check
|
||||
const isAdmin =
|
||||
(user as ExtendedUserInfo)?.isAdmin ||
|
||||
(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.user_type === "COMPANY_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.user_type === "ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.user_type === "admin";
|
||||
|
||||
// Breadcrumb from active tab
|
||||
const breadcrumbText = activeTab?.title || "대시보드";
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
{/* 모바일 헤더 */}
|
||||
{isMobile && (
|
||||
<header className="border-border bg-background/95 fixed top-0 right-0 left-0 z-50 flex h-14 items-center justify-between border-b px-4 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<Logo />
|
||||
<>
|
||||
{/* Cosmic background */}
|
||||
<CosmicBackground />
|
||||
|
||||
{/* Theme fade overlay */}
|
||||
<div className="v5-theme-fade" id="v5-theme-fade" />
|
||||
|
||||
{/* Mode transition overlay */}
|
||||
<div className="v5-mode-fade" id="v5-mode-fade" />
|
||||
|
||||
{/* V5 Shell */}
|
||||
<div className={`v5-shell ${isAdminMode ? "v5-admin-mode" : ""}`}>
|
||||
{/* ===== Glass Header ===== */}
|
||||
<header className="v5-hdr">
|
||||
<div className="v5-hdr-l">
|
||||
{/* Mobile hamburger */}
|
||||
<button className="v5-mobile-toggle" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
<div className="v5-hdr-logo">INVION</div>
|
||||
<div className="v5-hdr-bc">
|
||||
{isAdminMode ? "관리자" : "홈"} › <b>{breadcrumbText}</b>
|
||||
</div>
|
||||
<div className="v5-admin-badge">
|
||||
<div className="badge-dot" />
|
||||
관리자 모드
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="hover:bg-accent flex items-center gap-2 rounded-lg px-2 py-1 transition-colors">
|
||||
<div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
|
||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.user_name || "User"}
|
||||
className="aspect-square h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
|
||||
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">{user.user_name || "사용자"}</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{user.dept_name || user.email || user.user_id}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={openProfileModal}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1 py-0.5">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* 메인 컨테이너 */}
|
||||
<div className={`flex flex-1 ${isMobile ? "pt-14" : ""}`}>
|
||||
{sidebarOpen && isMobile && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/40 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<aside
|
||||
className={`${
|
||||
isMobile
|
||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]"
|
||||
: "relative z-auto h-screen translate-x-0"
|
||||
} border-sidebar-border bg-sidebar flex w-[260px] flex-col border-r transition-transform duration-300 sm:w-[220px] lg:w-[240px]`}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="border-border flex h-14 items-center justify-between border-b px-4">
|
||||
<Logo />
|
||||
<div className="v5-hdr-r">
|
||||
{/* Theme pill */}
|
||||
<div className="v5-pill">
|
||||
<button className={theme !== "dark" ? "on" : ""} onClick={() => setNextTheme("light")}>Light</button>
|
||||
<button className={theme === "dark" ? "on" : ""} onClick={() => setNextTheme("dark")}>Dark</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && (
|
||||
<div className="border-border bg-muted/50 mx-3 mt-3 rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="text-primary h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-muted-foreground text-[10px]">현재 관리 회사</p>
|
||||
<p className="truncate text-sm font-semibold" title={currentCompanyName || "로딩 중..."}>
|
||||
{currentCompanyName || "로딩 중..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.user_type === "COMPANY_ADMIN" ||
|
||||
(user as ExtendedUserInfo)?.user_type === "admin") && (
|
||||
<div className="border-border space-y-2 border-b p-3">
|
||||
<Button
|
||||
onClick={handleModeSwitch}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 hover:cursor-pointer ${
|
||||
isAdminMode
|
||||
? "border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
||||
: "border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 border"
|
||||
}`}
|
||||
{/* Mini tab icon (visible when tabs collapsed) */}
|
||||
{tabsCollapsed && (
|
||||
<button
|
||||
className="v5-tab-mini visible"
|
||||
onClick={() => setTabsCollapsed(false)}
|
||||
title="탭 펼치기"
|
||||
>
|
||||
{isAdminMode ? (
|
||||
<>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
사용자 메뉴로 전환
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="h-4 w-4" />
|
||||
관리자 메뉴로 전환
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="9"/></svg>
|
||||
<span className="tab-count">{currentTabs.length}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("🔴 회사 선택 버튼 클릭!");
|
||||
setShowCompanySwitcher(true);
|
||||
}}
|
||||
className="border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors duration-150 hover:cursor-pointer"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
회사 선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Bell / Notifications */}
|
||||
<button className="v5-bell">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<div className="v5-bell-dot" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="space-y-0.5 px-3">
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="bg-muted h-8 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
uiMenus.map((menu) => renderMenu(menu))
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Admin toggle (gear ↔ home) */}
|
||||
{isAdmin && (
|
||||
<button className="v5-admin-btn" onClick={handleModeSwitch} title={isAdminMode ? "홈으로" : "관리자"}>
|
||||
<Settings size={14} className="ic-gear" />
|
||||
<Home size={14} className="ic-home" />
|
||||
<span className="v5-admin-label">{isAdminMode ? "홈으로" : "관리자"}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-border border-t px-3 py-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-muted/30 border-t p-3">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="hover:bg-accent flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors">
|
||||
<div className="relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full">
|
||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.user_name || "User"}
|
||||
className="aspect-square h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
|
||||
{/* Avatar dropdown */}
|
||||
<div className="v5-avatar-w">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="v5-avatar">
|
||||
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="v5-avatar-dd-content w-56" align="end">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gradient-to-br from-[var(--v5-primary)] to-[var(--v5-cyan)] flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white">
|
||||
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">{user.user_name || "사용자"}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.dept_name || user.email || user.user_id}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.user_name || "User"}
|
||||
className="aspect-square h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-base font-semibold">
|
||||
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">{user.user_name || "사용자"}</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">{user.email || user.user_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{user.user_name || "사용자"} ({user.user_id || ""})
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none font-semibold">{user.email || ""}</p>
|
||||
<p className="text-muted-foreground text-xs leading-none font-semibold">
|
||||
{user.dept_name && user.position_name
|
||||
? `${user.dept_name}, ${user.position_name}`
|
||||
: user.dept_name || user.position_name || "부서 정보 없음"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={openProfileModal}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={openProfileModal}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>내 정보</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-500 focus:text-red-500">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
|
||||
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-background ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
<TabBar />
|
||||
<TabContent />
|
||||
</main>
|
||||
{/* ===== Tab Bar ===== */}
|
||||
<TabBar collapsed={tabsCollapsed} onToggleCollapse={() => setTabsCollapsed(!tabsCollapsed)} />
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && isMobile && (
|
||||
<div className="v5-side-overlay open" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ===== Body (sidebar + content) ===== */}
|
||||
<div className="v5-body">
|
||||
{/* Sidebar */}
|
||||
<aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""}`}
|
||||
style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined}
|
||||
>
|
||||
{/* SUPER_ADMIN company info */}
|
||||
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && !sidebarCollapsed && (
|
||||
<div style={{ padding: ".5rem .6rem", marginBottom: ".25rem" }}>
|
||||
<div className="flex items-center gap-2 rounded-lg p-2" style={{ background: "var(--v5-surface)", border: "1px solid var(--v5-glass-border)", borderRadius: "10px", fontSize: ".7rem" }}>
|
||||
<Building2 size={14} style={{ color: "var(--v5-primary)", flexShrink: 0 }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p style={{ fontSize: ".55rem", color: "var(--v5-text-muted)" }}>현재 관리 회사</p>
|
||||
<p className="truncate font-semibold" style={{ fontSize: ".72rem", color: "var(--v5-text)" }}>{currentCompanyName || "로딩 중..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: "0" }}>
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: "1px" }}>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2 p-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-8 rounded" style={{ background: "var(--v5-surface)" }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
uiMenus.map((menu) => renderMenu(menu))
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sidebar toggle */}
|
||||
{!isMobile && (
|
||||
<button className="v5-side-toggle" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: sidebarCollapsed ? "rotate(180deg)" : "none", transition: "transform .3s" }}>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<span>접기</span>
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Content area */}
|
||||
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TabContent />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileModal
|
||||
@@ -898,7 +914,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function CosmicBackground() {
|
||||
const cosmosRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const co = cosmosRef.current;
|
||||
if (!co) return;
|
||||
|
||||
// Stars
|
||||
const starColors = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const s = document.createElement("div");
|
||||
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
|
||||
if (s.classList.contains("c"))
|
||||
s.style.setProperty("--sc", starColors[(Math.random() * 3) | 0]);
|
||||
s.style.left = Math.random() * 100 + "%";
|
||||
s.style.top = Math.random() * 100 + "%";
|
||||
s.style.setProperty("--d", 2 + Math.random() * 5 + "s");
|
||||
s.style.setProperty("--dl", Math.random() * 5 + "s");
|
||||
s.style.setProperty("--mo", (0.3 + Math.random() * 0.7).toString());
|
||||
co.appendChild(s);
|
||||
}
|
||||
|
||||
// Particles
|
||||
const particleColors = ["var(--v5-primary)", "var(--v5-cyan)", "var(--v5-pink)"];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = document.createElement("div");
|
||||
p.className = "particle";
|
||||
p.style.left = Math.random() * 100 + "%";
|
||||
p.style.setProperty("--sz", 2 + Math.random() * 4 + "px");
|
||||
p.style.setProperty("--pc", particleColors[(Math.random() * 3) | 0]);
|
||||
p.style.setProperty("--fd", 7 + Math.random() * 12 + "s");
|
||||
p.style.setProperty("--fdl", Math.random() * 10 + "s");
|
||||
co.appendChild(p);
|
||||
}
|
||||
|
||||
return () => {
|
||||
co.querySelectorAll(".star, .particle").forEach((el) => el.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={cosmosRef} className="v5-cosmos">
|
||||
<div className="neb neb-1" />
|
||||
<div className="neb neb-2" />
|
||||
<div className="neb neb-3" />
|
||||
<div className="neb neb-4" />
|
||||
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
|
||||
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
|
||||
import { X, RotateCw, ChevronDown } from "lucide-react";
|
||||
import { X, RotateCw, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import {
|
||||
@@ -41,7 +41,12 @@ interface DropGhost {
|
||||
tabCountAtCreation: number;
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
interface TabBarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function TabBar({ collapsed = false, onToggleCollapse }: TabBarProps) {
|
||||
const tabs = useTabStore(selectTabs);
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
const {
|
||||
@@ -491,10 +496,8 @@ export function TabBar() {
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
className={cn(
|
||||
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
|
||||
isActive
|
||||
? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold"
|
||||
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
|
||||
"v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none",
|
||||
isActive && "on",
|
||||
)}
|
||||
style={{
|
||||
width: TAB_WIDTH,
|
||||
@@ -505,7 +508,7 @@ export function TabBar() {
|
||||
}}
|
||||
title={tab.title}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
{isActive && (
|
||||
@@ -524,10 +527,7 @@ export function TabBar() {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
|
||||
!isActive && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
className="v5-tab-x"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
@@ -542,13 +542,16 @@ export function TabBar() {
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
||||
className={`v5-tabs relative flex shrink-0 items-stretch gap-[1px] overflow-hidden ${collapsed ? "collapsed" : ""}`}
|
||||
onDragOver={handleBarDragOver}
|
||||
onDragLeave={handleBarDragLeave}
|
||||
onDrop={handleBarDrop}
|
||||
>
|
||||
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
||||
<div className="pointer-events-none absolute inset-0 z-5" />
|
||||
{onToggleCollapse && (
|
||||
<button className="v5-tab-toggle" onClick={onToggleCollapse} title={collapsed ? "탭 펼치기" : "탭 접기"}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||
|
||||
{hasOverflow && (
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>INVION - Layout v5</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#fafaff; --bg-subtle:#f3f2fa; --surface:rgba(255,255,255,0.55); --surface-solid:#ffffff;
|
||||
--surface-hover:rgba(255,255,255,0.7); --text:#0f0e1a; --text-sec:#6b6a80; --text-muted:#9998ad;
|
||||
--primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(108,92,231,0.25);
|
||||
--cyan:#00cec9; --cyan-glow:rgba(0,206,201,0.2); --pink:#fd79a8; --pink-glow:rgba(253,121,168,0.15);
|
||||
--red:#ff4757; --green:#00b894; --amber:#fdcb6e;
|
||||
--border:rgba(108,92,231,0.12); --border-subtle:rgba(0,0,0,0.05);
|
||||
--glass:rgba(255,255,255,0.45); --glass-strong:rgba(255,255,255,0.65);
|
||||
--glass-border:rgba(108,92,231,0.12);
|
||||
--glow-sm:0 0 20px rgba(108,92,231,0.12); --glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--sidebar-w:220px;
|
||||
}
|
||||
.dark {
|
||||
--bg:#06050e; --bg-subtle:#0c0b18; --surface:rgba(17,16,42,0.5); --surface-solid:#11102a;
|
||||
--surface-hover:rgba(25,24,64,0.6); --text:#eae8f4; --text-sec:#8d8ba8; --text-muted:#5a587a;
|
||||
--primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(162,155,254,0.25);
|
||||
--cyan:#55efc4; --cyan-glow:rgba(85,239,196,0.15); --pink:#fd79a8; --red:#ff6b6b;
|
||||
--green:#55efc4; --amber:#ffeaa7;
|
||||
--border:rgba(162,155,254,0.1); --border-subtle:rgba(255,255,255,0.04);
|
||||
--glass:rgba(17,16,42,0.45); --glass-strong:rgba(17,16,42,0.65);
|
||||
--glass-border:rgba(162,155,254,0.12);
|
||||
--glow-sm:0 0 20px rgba(162,155,254,0.1); --glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
html,body{height:100%;overflow:hidden;}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);transition:background .5s,color .4s;}
|
||||
::-webkit-scrollbar{width:5px;} ::-webkit-scrollbar-track{background:transparent;}
|
||||
::-webkit-scrollbar-thumb{background:rgba(108,92,231,0.2);border-radius:3px;}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
.star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
|
||||
animation:twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
|
||||
.star.c{width:3px;height:3px;background:var(--sc);}
|
||||
@keyframes twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
|
||||
html:not(.dark) .star{display:none;}
|
||||
html:not(.dark) .shooting-star{display:none;}
|
||||
html:not(.dark) .particle{display:none;}
|
||||
|
||||
.neb{position:absolute;border-radius:50%;filter:blur(140px);animation:drift 16s ease-in-out infinite alternate;}
|
||||
.neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
html:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
html:not(.dark) .neb{filter:blur(100px);}
|
||||
html:not(.dark) .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;border-radius:50%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
|
||||
html:not(.dark) .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);animation-duration:20s;}
|
||||
html:not(.dark) .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
|
||||
html:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
|
||||
.shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
transform:rotate(35deg);animation:shoot 5s ease-in-out infinite;opacity:0;}
|
||||
.shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
|
||||
@keyframes shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
|
||||
|
||||
.particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--primary));
|
||||
border-radius:50%;opacity:0;animation:floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
|
||||
@keyframes floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
|
||||
|
||||
/* ===== LAYOUT SHELL ===== */
|
||||
.shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;}
|
||||
|
||||
/* --- Header --- */
|
||||
.hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
.hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
|
||||
.hdr-bc{font-size:.72rem;color:var(--text-muted);}
|
||||
.hdr-bc b{color:var(--text);font-weight:600;}
|
||||
.hdr-r{display:flex;align-items:center;gap:.65rem;}
|
||||
|
||||
/* Theme pill */
|
||||
.pill{display:flex;background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--glass-border);
|
||||
border-radius:999px;padding:2px;}
|
||||
.pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.pill button.on{background:var(--primary);color:white;box-shadow:var(--glow-sm);}
|
||||
|
||||
/* Bell */
|
||||
.bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s;}
|
||||
.bell:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--red);border-radius:50%;animation:pdot 2s infinite;}
|
||||
@keyframes pdot{0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,.4)}50%{box-shadow:0 0 0 5px rgba(255,71,87,0)}}
|
||||
|
||||
/* Admin button */
|
||||
.admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;}
|
||||
.admin-btn:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);transform:scale(1.1);}
|
||||
.admin-btn .admin-label{position:absolute;top:110%;left:50%;transform:translateX(-50%);
|
||||
font-size:.52rem;font-weight:600;color:var(--primary);white-space:nowrap;
|
||||
opacity:0;transition:opacity .2s,color .2s;pointer-events:none;}
|
||||
.admin-btn:hover .admin-label{opacity:1;}
|
||||
.admin-mode .admin-btn .admin-label{color:var(--cyan);}
|
||||
|
||||
/* Avatar */
|
||||
.avatar-w{position:relative;}
|
||||
.avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:700;color:white;
|
||||
cursor:pointer;transition:transform .2s,box-shadow .3s;}
|
||||
.avatar:hover{transform:scale(1.1);box-shadow:var(--glow-sm);}
|
||||
|
||||
/* Avatar dropdown */
|
||||
.avatar-dd{position:absolute;top:calc(100% + 10px);right:0;width:220px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:16px;padding:.5rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .avatar-dd{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
.avatar-dd.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.avatar-dd .av-profile{display:flex;align-items:center;gap:.6rem;padding:.55rem .6rem;
|
||||
border-bottom:1px solid var(--border-subtle);margin-bottom:.35rem;}
|
||||
.avatar-dd .av-avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;color:white;flex-shrink:0;}
|
||||
.avatar-dd .av-name{font-size:.78rem;font-weight:700;color:var(--text);}
|
||||
.avatar-dd .av-email{font-size:.6rem;color:var(--text-muted);margin-top:.1rem;}
|
||||
.avatar-dd .av-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;
|
||||
border-radius:10px;font-size:.72rem;font-weight:500;color:var(--text-sec);
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.avatar-dd .av-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.avatar-dd .av-item .av-ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
|
||||
.avatar-dd .av-item:hover .av-ic{opacity:1;}
|
||||
.avatar-dd .av-divider{height:1px;background:var(--border-subtle);margin:.3rem .6rem;}
|
||||
.avatar-dd .av-item.danger{color:var(--red);}
|
||||
.avatar-dd .av-item.danger:hover{background:rgba(255,71,87,.08);}
|
||||
|
||||
/* Notification panel */
|
||||
.noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;overflow:hidden;}
|
||||
.dark .noti-panel{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
.noti-panel.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.noti-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .85rem;
|
||||
border-bottom:1px solid var(--border-subtle);}
|
||||
.noti-head .noti-title{font-size:.78rem;font-weight:700;color:var(--text);}
|
||||
.noti-head .noti-clear{font-size:.58rem;font-weight:600;color:var(--primary);cursor:pointer;
|
||||
border:none;background:none;font-family:inherit;transition:color .2s;}
|
||||
.noti-head .noti-clear:hover{color:var(--cyan);}
|
||||
.noti-list{overflow-y:auto;max-height:330px;padding:.35rem;}
|
||||
.noti-item{display:flex;gap:.55rem;padding:.55rem .5rem;border-radius:10px;cursor:pointer;transition:all .15s;}
|
||||
.noti-item:hover{background:var(--surface-hover);}
|
||||
.noti-item.unread{background:linear-gradient(135deg,rgba(108,92,231,.05),rgba(108,92,231,.02));}
|
||||
.dark .noti-item.unread{background:linear-gradient(135deg,rgba(162,155,254,.06),rgba(162,155,254,.02));}
|
||||
.noti-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem;}
|
||||
.noti-dot.info{background:var(--primary);}
|
||||
.noti-dot.warn{background:var(--amber);}
|
||||
.noti-dot.err{background:var(--red);box-shadow:0 0 6px rgba(255,71,87,.4);}
|
||||
.noti-dot.ok{background:var(--green);}
|
||||
.noti-body{flex:1;min-width:0;}
|
||||
.noti-msg{font-size:.7rem;font-weight:500;color:var(--text);line-height:1.35;}
|
||||
.noti-msg b{font-weight:600;}
|
||||
.noti-time{font-size:.55rem;color:var(--text-muted);margin-top:.15rem;}
|
||||
.noti-empty{text-align:center;padding:2rem;color:var(--text-muted);font-size:.75rem;}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
/* Mobile menu toggle */
|
||||
.mobile-toggle{display:none;width:36px;height:36px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
|
||||
.mobile-toggle:hover{border-color:var(--primary);color:var(--primary);}
|
||||
|
||||
/* Mobile overlay */
|
||||
.side-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:25;
|
||||
opacity:0;transition:opacity .3s;pointer-events:none;}
|
||||
.side-overlay.open{opacity:1;pointer-events:auto;}
|
||||
|
||||
@media(max-width:768px){
|
||||
.mobile-toggle{display:flex;}
|
||||
.side{position:fixed;left:0;top:0;bottom:0;z-index:30;
|
||||
transform:translateX(-100%);transition:transform .35s cubic-bezier(.4,0,.2,1);
|
||||
width:260px;padding-top:60px;}
|
||||
.side.mobile-open{transform:none;}
|
||||
.side-overlay{display:block;}
|
||||
.hdr-bc{display:none;}
|
||||
.admin-badge{display:none !important;}
|
||||
.hdr-logo{font-size:.9rem;}
|
||||
.tabs{padding:0 .3rem;}
|
||||
.tab{padding:0 .6rem;font-size:.65rem;}
|
||||
.tab-toggle{display:none;}
|
||||
.content{padding:.75rem;}
|
||||
.admin-label{display:none !important;}
|
||||
.pill{display:none;}
|
||||
}
|
||||
@media(min-width:769px) and (max-width:1024px){
|
||||
:root{--sidebar-w:180px;}
|
||||
.si{font-size:.72rem;padding:.45rem .6rem;}
|
||||
.content{padding:1rem;}
|
||||
}
|
||||
|
||||
/* --- Tabs --- */
|
||||
.tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
background:var(--glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--glass-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
.tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||
color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
||||
.tab:hover{color:var(--text-sec);background:var(--surface-hover);}
|
||||
.tab.on{color:var(--primary);font-weight:600;border-bottom-color:var(--primary);background:var(--surface);}
|
||||
.tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--text-muted);
|
||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||
.tab:hover .tab-x{opacity:1;}.tab-x:hover{background:rgba(255,71,87,.15);color:var(--red);}
|
||||
|
||||
/* --- Body (sidebar + content) --- */
|
||||
.body{display:flex;flex:1;overflow:hidden;position:relative;z-index:5;}
|
||||
|
||||
/* --- Sidebar --- */
|
||||
.side{width:var(--sidebar-w);background:var(--glass);backdrop-filter:blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.3);border-right:1px solid var(--glass-border);
|
||||
padding:.85rem .6rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;}
|
||||
.side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--text-muted);padding:1rem .65rem .35rem;}
|
||||
.side-sec:first-child{padding-top:.25rem;}
|
||||
.si{padding:.5rem .7rem;border-radius:10px;font-size:.77rem;color:var(--text-sec);cursor:pointer;
|
||||
transition:all .25s cubic-bezier(.4,0,.2,1);font-weight:450;display:flex;align-items:center;gap:.6rem;
|
||||
position:relative;overflow:hidden;height:auto;}
|
||||
.si .ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.65;flex-shrink:0;}
|
||||
.si:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.si.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.05));
|
||||
color:var(--primary);font-weight:600;border:1px solid rgba(108,92,231,.15);box-shadow:var(--glow-sm);}
|
||||
.si.on .ic{opacity:1;}
|
||||
.dark .si.on{background:linear-gradient(135deg,rgba(162,155,254,.14),rgba(162,155,254,.05));border-color:rgba(162,155,254,.15);}
|
||||
.si::before{content:'';position:absolute;left:0;top:0;width:3px;height:100%;background:var(--primary);
|
||||
border-radius:0 2px 2px 0;transform:scaleY(0);transition:transform .2s cubic-bezier(.4,0,.2,1);}
|
||||
.si.on::before{transform:scaleY(1);}
|
||||
.side-anim .si{animation:slideR .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes slideR{from{opacity:0;transform:translateX(-16px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* ===== SIDEBAR COLLAPSE ===== */
|
||||
/* Toggle button at bottom of sidebar */
|
||||
.side-toggle{margin-top:auto;padding:.5rem .7rem;border-radius:10px;border:none;
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;gap:.6rem;font-size:.7rem;font-weight:500;font-family:inherit;
|
||||
transition:all .25s;flex-shrink:0;}
|
||||
.side-toggle:hover{background:var(--surface-hover);color:var(--primary);}
|
||||
.side-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
|
||||
|
||||
/* Collapsed sidebar */
|
||||
.side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
|
||||
.side.collapsed .si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;}
|
||||
.side.collapsed .si span:not(.ic){width:0;overflow:hidden;opacity:0;transition:width .25s,opacity .2s;}
|
||||
.side.collapsed .si .ic{opacity:.7;margin:0;transition:opacity .2s;}
|
||||
.side.collapsed .si.on .ic{opacity:1;}
|
||||
.side.collapsed .si:hover{transform:none;}
|
||||
.side.collapsed .side-sec{height:0;overflow:hidden;padding:0;margin:0;opacity:0;transition:all .25s;}
|
||||
|
||||
/* Expand: text & section animate back */
|
||||
.side:not(.collapsed) .si span:not(.ic){opacity:1;transition:opacity .3s .15s;}
|
||||
.side:not(.collapsed) .side-sec{opacity:1;transition:opacity .3s .1s,height .3s,padding .3s;}
|
||||
|
||||
/* Collapsed: show category groups */
|
||||
.side-group{display:contents;}
|
||||
.side.collapsed .side-group{display:flex;flex-direction:column;gap:1px;position:relative;}
|
||||
|
||||
/* Category header in collapsed mode */
|
||||
.side-cat{height:0;overflow:hidden;opacity:0;padding:0;margin:0;pointer-events:none;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
border-radius:10px;cursor:pointer;position:relative;color:var(--text-muted);
|
||||
transition:height .25s,opacity .2s,padding .25s,margin .25s;}
|
||||
.side.collapsed .side-cat{height:auto;overflow:visible;opacity:1;pointer-events:auto;
|
||||
padding:.55rem;margin-top:.4rem;transition:height .3s .05s,opacity .3s .1s,padding .3s .05s,margin .3s .05s;}
|
||||
.side.collapsed .side-cat:first-child{margin-top:0;}
|
||||
.side.collapsed .side-cat:hover{background:var(--surface-hover);color:var(--primary);}
|
||||
.side.collapsed .side-cat.open{background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));
|
||||
color:var(--primary);}
|
||||
.dark .side.collapsed .side-cat.open{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(162,155,254,.04));}
|
||||
.side-cat .cat-label{font-size:.48rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||
margin-top:.15rem;text-align:center;line-height:1;}
|
||||
/* Cat appear animation when collapsing */
|
||||
.side.collapsed .side-cat{animation:catIn .3s cubic-bezier(.16,1,.3,1) both;}
|
||||
.side.collapsed .side-group:nth-child(1) .side-cat{animation-delay:.05s;}
|
||||
.side.collapsed .side-group:nth-child(2) .side-cat{animation-delay:.1s;}
|
||||
.side.collapsed .side-group:nth-child(3) .side-cat{animation-delay:.15s;}
|
||||
@keyframes catIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Hide menu items in collapsed mode (shown in flyout) */
|
||||
.side.collapsed .side-group .si{height:0;padding:0;margin:0;overflow:hidden;opacity:0;
|
||||
transition:height .25s,padding .25s,opacity .15s,margin .25s;}
|
||||
.side.collapsed .side-group .side-sec{height:0;padding:0;margin:0;overflow:hidden;opacity:0;}
|
||||
/* Expand: items animate back */
|
||||
.side:not(.collapsed) .side-group .si{transition:height .3s .1s,padding .3s .1s,opacity .3s .15s,margin .3s .1s;}
|
||||
|
||||
/* Flyout panel */
|
||||
.side-flyout{position:absolute;left:calc(100% + 8px);top:0;width:170px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.15),var(--glow-sm);
|
||||
opacity:0;transform:translateX(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .side-flyout{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
.side-flyout.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--text-muted);
|
||||
text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;}
|
||||
.side-flyout .fly-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;
|
||||
border-radius:10px;font-size:.72rem;font-weight:500;color:var(--text-sec);
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.side-flyout .fly-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.side-flyout .fly-item.on{color:var(--primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
.side-flyout .fly-item .ic{width:14px;height:14px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
|
||||
.side-flyout .fly-item.on .ic{opacity:1;}
|
||||
|
||||
/* Collapsed toggle — just show icon */
|
||||
.side.collapsed .side-toggle span{width:0;overflow:hidden;opacity:0;transition:width .2s,opacity .15s;}
|
||||
.side.collapsed .side-toggle{justify-content:center;padding:.55rem;}
|
||||
.side.collapsed .side-toggle svg{transform:rotate(180deg);}
|
||||
.side:not(.collapsed) .side-toggle span{opacity:1;transition:opacity .3s .2s;}
|
||||
|
||||
/* --- Content placeholder --- */
|
||||
.content{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem;}
|
||||
|
||||
/* Placeholder card */
|
||||
.placeholder{
|
||||
flex:1;display:flex;align-items:center;justify-content:center;
|
||||
border:2px dashed var(--border);border-radius:16px;
|
||||
background:var(--glass);backdrop-filter:blur(12px);
|
||||
color:var(--text-muted);font-size:.85rem;font-weight:500;
|
||||
min-height:300px;
|
||||
}
|
||||
.placeholder .ph-inner{text-align:center;}
|
||||
.placeholder .ph-icon{font-size:2.5rem;margin-bottom:.75rem;opacity:.3;}
|
||||
.placeholder .ph-title{font-size:.9rem;font-weight:700;color:var(--text-sec);margin-bottom:.3rem;}
|
||||
.placeholder .ph-desc{font-size:.7rem;color:var(--text-muted);}
|
||||
|
||||
/* ===== ADMIN MODE ===== */
|
||||
/* Mode transition overlay */
|
||||
.mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
|
||||
background:radial-gradient(ellipse at center,var(--primary-glow),transparent 70%);
|
||||
transition:opacity .5s cubic-bezier(.4,0,.2,1);}
|
||||
.mode-fade.in{opacity:1;}
|
||||
|
||||
/* Shell transition */
|
||||
.shell{transition:opacity .3s,transform .3s;}
|
||||
|
||||
/* Admin mode header accent */
|
||||
.admin-mode .hdr{border-bottom-color:var(--primary);}
|
||||
.admin-mode .hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--primary),var(--cyan));animation:glowLine .6s ease-out both;}
|
||||
@keyframes glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}}
|
||||
|
||||
/* Admin badge in header */
|
||||
.admin-badge{display:none;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.2);font-size:.58rem;font-weight:700;color:var(--primary);
|
||||
animation:badgeIn .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
.dark .admin-badge{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(85,239,196,.08));
|
||||
border-color:rgba(162,155,254,.2);color:var(--primary-light);}
|
||||
.admin-mode .admin-badge{display:flex;}
|
||||
@keyframes badgeIn{from{opacity:0;transform:scale(.8) translateX(-10px)}to{opacity:1;transform:none}}
|
||||
.admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--cyan);
|
||||
box-shadow:0 0 8px var(--cyan-glow);animation:bdPulse 2s infinite;}
|
||||
@keyframes bdPulse{0%,100%{box-shadow:0 0 4px var(--cyan-glow)}50%{box-shadow:0 0 12px var(--cyan-glow)}}
|
||||
|
||||
/* Admin button icon swap */
|
||||
.admin-btn .ic-gear{display:block;}
|
||||
.admin-btn .ic-home{display:none;}
|
||||
.admin-mode .admin-btn .ic-gear{display:none;}
|
||||
.admin-mode .admin-btn .ic-home{display:block;}
|
||||
.admin-mode .admin-btn{border-color:var(--cyan);color:var(--cyan);background:rgba(0,206,201,.08);
|
||||
box-shadow:0 0 15px var(--cyan-glow);}
|
||||
.admin-mode .admin-btn:hover{color:var(--cyan);border-color:var(--cyan);}
|
||||
|
||||
/* Admin sidebar — different accent */
|
||||
.admin-side .si.on{background:linear-gradient(135deg,rgba(0,206,201,.12),rgba(0,206,201,.05));
|
||||
color:var(--cyan);border-color:rgba(0,206,201,.2);}
|
||||
.admin-side .si.on .ic{opacity:1;}
|
||||
.admin-side .si::before{background:var(--cyan);}
|
||||
.dark .admin-side .si.on{background:linear-gradient(135deg,rgba(85,239,196,.12),rgba(85,239,196,.05));
|
||||
border-color:rgba(85,239,196,.15);}
|
||||
.admin-side .si:hover{color:var(--text);}
|
||||
|
||||
/* Sidebar swap animation — merge with collapse transition */
|
||||
.side{transition:width .4s cubic-bezier(.4,0,.2,1),padding .4s,transform .35s cubic-bezier(.16,1,.3,1),opacity .25s;}
|
||||
.side.slide-out{transform:translateX(-100%);opacity:0;}
|
||||
.side.slide-in{animation:sideSlideIn .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes sideSlideIn{from{transform:translateX(-30px);opacity:0}to{transform:none;opacity:1}}
|
||||
|
||||
/* Tabs swap animation */
|
||||
.tabs{transition:opacity .2s;}
|
||||
.tabs.fade-out{opacity:0;}
|
||||
.tabs.fade-in{animation:tabsFadeIn .3s ease-out both;}
|
||||
@keyframes tabsFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* ===== TAB COLLAPSE ===== */
|
||||
/* Toggle button — sits at left edge of tab bar area */
|
||||
.tab-toggle{width:28px;height:28px;border-radius:8px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;flex-shrink:0;margin-right:.35rem;}
|
||||
.tab-toggle:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.tab-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
|
||||
.tabs-collapsed .tab-toggle svg{transform:rotate(180deg);}
|
||||
|
||||
/* Collapsed state — hide tab items, shrink bar */
|
||||
.tabs.tabs-collapsed{height:0;padding:0;border:none;overflow:hidden;transition:height .3s cubic-bezier(.4,0,.2,1),padding .3s,border-width .3s;}
|
||||
.tabs:not(.tabs-collapsed){transition:height .3s cubic-bezier(.16,1,.3,1),padding .3s;}
|
||||
|
||||
/* Mini tab icon — appears in header when tabs collapsed */
|
||||
.tab-mini{position:relative;display:none;width:32px;height:32px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .25s;}
|
||||
.tab-mini:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.tab-mini.visible{display:flex;animation:miniIn .3s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes miniIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
||||
.tab-mini .tab-count{position:absolute;top:3px;right:3px;width:14px;height:14px;border-radius:50%;
|
||||
background:var(--primary);color:white;font-size:.5rem;font-weight:700;
|
||||
display:flex;align-items:center;justify-content:center;line-height:1;}
|
||||
|
||||
/* Tab dropdown — opens from mini icon */
|
||||
.tab-dropdown{position:absolute;top:calc(100% + 8px);right:0;width:220px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .tab-dropdown{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
.tab-dropdown.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
|
||||
.tab-dropdown .td-item{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.45rem .65rem;border-radius:10px;font-size:.72rem;font-weight:500;
|
||||
color:var(--text-sec);cursor:pointer;transition:all .15s;gap:.4rem;}
|
||||
.tab-dropdown .td-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.tab-dropdown .td-item.on{color:var(--primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
.tab-dropdown .td-item .td-close{width:16px;height:16px;border-radius:4px;border:none;
|
||||
background:transparent;color:var(--text-muted);cursor:pointer;display:flex;
|
||||
align-items:center;justify-content:center;font-size:.55rem;opacity:0;transition:all .15s;flex-shrink:0;}
|
||||
.tab-dropdown .td-item:hover .td-close{opacity:1;}
|
||||
.tab-dropdown .td-close:hover{background:rgba(255,71,87,.12);color:var(--red);}
|
||||
|
||||
.tab-dropdown .td-head{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.3rem .65rem .5rem;border-bottom:1px solid var(--border-subtle);margin-bottom:.25rem;}
|
||||
.tab-dropdown .td-title{font-size:.6rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;}
|
||||
.tab-dropdown .td-expand{font-size:.55rem;font-weight:600;color:var(--primary);cursor:pointer;border:none;background:none;font-family:inherit;transition:color .2s;}
|
||||
.tab-dropdown .td-expand:hover{color:var(--cyan);}
|
||||
|
||||
/* --- Theme fade overlay --- */
|
||||
.theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
|
||||
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
|
||||
.theme-fade.in{opacity:1;}
|
||||
|
||||
/* --- Preview tag --- */
|
||||
.preview-tag{position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:10000;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));color:white;font-size:.55rem;
|
||||
font-weight:700;padding:.2rem 1.2rem;border-radius:0 0 10px 10px;letter-spacing:.06em;
|
||||
box-shadow:0 4px 15px var(--primary-glow);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-tag">INVION v5 — LAYOUT SHELL</div>
|
||||
<div class="theme-fade" id="theme-fade"></div>
|
||||
<div class="mode-fade" id="mode-fade"></div>
|
||||
|
||||
<!-- Cosmic background -->
|
||||
<div class="cosmos" id="cosmos">
|
||||
<div class="neb neb-1"></div><div class="neb neb-2"></div><div class="neb neb-3"></div><div class="neb neb-4"></div>
|
||||
<div class="shooting-star" style="top:12%;left:70%"></div>
|
||||
<div class="shooting-star" style="top:35%;left:55%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Shell -->
|
||||
<div class="shell">
|
||||
<!-- Header -->
|
||||
<header class="hdr">
|
||||
<div class="hdr-l">
|
||||
<button class="mobile-toggle" onclick="toggleMobileSide()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="hdr-logo">INVION</div>
|
||||
<div class="hdr-bc" id="breadcrumb">홈 › <b>대시보드</b></div>
|
||||
<div class="admin-badge"><div class="badge-dot"></div>관리자 모드</div>
|
||||
</div>
|
||||
<div class="hdr-r">
|
||||
<div class="pill">
|
||||
<button class="on" onclick="setTheme('light')">Light</button>
|
||||
<button onclick="setTheme('dark')">Dark</button>
|
||||
</div>
|
||||
<div class="tab-mini" id="tab-mini" onclick="toggleTabDropdown()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="9"/></svg>
|
||||
<div class="tab-count" id="tab-count">4</div>
|
||||
<div class="tab-dropdown" id="tab-dropdown"></div>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button class="bell" onclick="toggleNoti()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<div class="bell-dot"></div>
|
||||
</button>
|
||||
<div class="noti-panel" id="noti-panel">
|
||||
<div class="noti-head">
|
||||
<span class="noti-title">알림</span>
|
||||
<button class="noti-clear" onclick="document.getElementById('noti-panel').classList.remove('open')">모두 읽음</button>
|
||||
</div>
|
||||
<div class="noti-list">
|
||||
<div class="noti-item unread"><div class="noti-dot err"></div><div class="noti-body"><div class="noti-msg"><b>DTG-078</b> 통신 오류 발생</div><div class="noti-time">2분 전</div></div></div>
|
||||
<div class="noti-item unread"><div class="noti-dot warn"></div><div class="noti-body"><div class="noti-msg"><b>3월 정산</b> 보고서 검토 요청</div><div class="noti-time">14분 전</div></div></div>
|
||||
<div class="noti-item unread"><div class="noti-dot info"></div><div class="noti-body"><div class="noti-msg"><b>김대리</b>가 DTG-003 설치 완료</div><div class="noti-time">30분 전</div></div></div>
|
||||
<div class="noti-item"><div class="noti-dot ok"></div><div class="noti-body"><div class="noti-msg">시스템 자동 백업 완료 <b>(2.3GB)</b></div><div class="noti-time">1시간 전</div></div></div>
|
||||
<div class="noti-item"><div class="noti-dot info"></div><div class="noti-body"><div class="noti-msg"><b>최부장</b>이 결재 3건 승인</div><div class="noti-time">2시간 전</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="admin-btn" id="admin-toggle" title="관리자" onclick="switchMode(document.querySelector('.shell').classList.contains('admin-mode')?'main':'admin')">
|
||||
<svg class="ic-gear" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<svg class="ic-home" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
<span class="admin-label">관리자</span>
|
||||
</button>
|
||||
<div class="avatar-w">
|
||||
<div class="avatar" onclick="toggleAvatarDD()">P</div>
|
||||
<div class="avatar-dd" id="avatar-dd">
|
||||
<div class="av-profile">
|
||||
<div class="av-avatar">P</div>
|
||||
<div><div class="av-name">Park님</div><div class="av-email">park@invion.com</div></div>
|
||||
</div>
|
||||
<div class="av-item"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>내 정보</div>
|
||||
<div class="av-item"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>비밀번호 변경</div>
|
||||
<div class="av-item"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>환경설정</div>
|
||||
<div class="av-divider"></div>
|
||||
<div class="av-item danger"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>로그아웃</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Tabs -->
|
||||
<div class="tabs" id="main-tabs">
|
||||
<button class="tab-toggle" onclick="collapseTab()" title="탭 접기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="18 15 12 9 6 15"/></svg></button>
|
||||
<div class="tab on"><span>대시보드</span><button class="tab-x">×</button></div>
|
||||
<div class="tab"><span>DTG 관리</span><button class="tab-x">×</button></div>
|
||||
<div class="tab"><span>사용자 관리</span><button class="tab-x">×</button></div>
|
||||
<div class="tab"><span>정산</span><button class="tab-x">×</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tabs (hidden by default) -->
|
||||
<div class="tabs" id="admin-tabs" style="display:none">
|
||||
<div class="tab on"><span>메뉴 관리</span><button class="tab-x">×</button></div>
|
||||
<div class="tab"><span>사용자 관리</span><button class="tab-x">×</button></div>
|
||||
<div class="tab"><span>테이블 관리</span><button class="tab-x">×</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
<div class="side-overlay" id="side-overlay" onclick="closeMobileSide()"></div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="body">
|
||||
<!-- Sidebar -->
|
||||
<nav class="side side-anim" id="side">
|
||||
<!-- 관리 그룹 -->
|
||||
<div class="side-group" data-cat="관리">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
<div class="cat-label">관리</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">관리</div>
|
||||
<div class="si on" data-name="대시보드"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span><span>대시보드</span></div>
|
||||
<div class="si" data-name="DTG 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span>DTG 관리</span></div>
|
||||
<div class="si" data-name="정산"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></span><span>정산</span></div>
|
||||
<div class="si" data-name="물류"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg></span><span>물류</span></div>
|
||||
</div>
|
||||
<!-- 설정 그룹 -->
|
||||
<div class="side-group" data-cat="설정">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<div class="cat-label">설정</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">설정</div>
|
||||
<div class="si" data-name="메뉴 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span><span>메뉴 관리</span></div>
|
||||
<div class="si" data-name="사용자 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></span><span>사용자 관리</span></div>
|
||||
<div class="si" data-name="화면 빌더"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span><span>화면 빌더</span></div>
|
||||
</div>
|
||||
<!-- 분석 그룹 -->
|
||||
<div class="side-group" data-cat="분석">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
<div class="cat-label">분석</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">분석</div>
|
||||
<div class="si" data-name="리포트"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span><span>리포트</span></div>
|
||||
<div class="si" data-name="AI 어시스턴트"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></span><span>AI 어시스턴트</span></div>
|
||||
</div>
|
||||
<!-- Toggle button -->
|
||||
<button class="side-toggle" onclick="toggleSidebar()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<span>접기</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Admin Sidebar (hidden by default) -->
|
||||
<nav class="side admin-side side-anim" id="admin-side" style="display:none">
|
||||
<!-- 시스템 그룹 -->
|
||||
<div class="side-group" data-cat="시스템">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<div class="cat-label">시스템</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">시스템</div>
|
||||
<div class="si on" data-name="메뉴 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span><span>메뉴 관리</span></div>
|
||||
<div class="si" data-name="사용자 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span><span>사용자 관리</span></div>
|
||||
<div class="si" data-name="권한 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span><span>권한 관리</span></div>
|
||||
</div>
|
||||
<!-- 데이터 그룹 -->
|
||||
<div class="side-group" data-cat="데이터">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
<div class="cat-label">데이터</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">데이터</div>
|
||||
<div class="si" data-name="테이블 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg></span><span>테이블 관리</span></div>
|
||||
<div class="si" data-name="코드 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></span><span>코드 관리</span></div>
|
||||
<div class="si" data-name="카테고리 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span><span>카테고리 관리</span></div>
|
||||
</div>
|
||||
<!-- 화면 그룹 -->
|
||||
<div class="side-group" data-cat="화면">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
<div class="cat-label">화면</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">화면</div>
|
||||
<div class="si" data-name="화면 빌더"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span><span>화면 빌더</span></div>
|
||||
<div class="si" data-name="화면 그룹"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span>화면 그룹</span></div>
|
||||
</div>
|
||||
<!-- 운영 그룹 -->
|
||||
<div class="side-group" data-cat="운영">
|
||||
<div class="side-cat" onclick="toggleFlyout(this)">
|
||||
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<div class="cat-label">운영</div></div>
|
||||
<div class="side-flyout"></div>
|
||||
</div>
|
||||
<div class="side-sec">운영</div>
|
||||
<div class="si" data-name="배치 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span><span>배치 관리</span></div>
|
||||
<div class="si" data-name="감사 로그"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></span><span>감사 로그</span></div>
|
||||
</div>
|
||||
<!-- Toggle button -->
|
||||
<button class="side-toggle" onclick="toggleAdminSidebar()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<span>접기</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Content area — placeholder -->
|
||||
<main class="content">
|
||||
<div class="placeholder">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-icon">⚙</div>
|
||||
<div class="ph-title">콘텐츠 영역</div>
|
||||
<div class="ph-desc">이 영역에 대시보드, 관리 페이지 등 실제 콘텐츠가 렌더링됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Stars + particles
|
||||
(()=>{
|
||||
const co=document.getElementById('cosmos'),cs=['rgba(162,155,254,.8)','rgba(85,239,196,.7)','rgba(253,121,168,.7)'];
|
||||
for(let i=0;i<150;i++){const s=document.createElement('div');s.className='star'+(Math.random()>.83?' c':'');
|
||||
if(s.classList.contains('c'))s.style.setProperty('--sc',cs[Math.random()*3|0]);
|
||||
s.style.left=Math.random()*100+'%';s.style.top=Math.random()*100+'%';
|
||||
s.style.setProperty('--d',(2+Math.random()*5)+'s');s.style.setProperty('--dl',Math.random()*5+'s');
|
||||
s.style.setProperty('--mo',(0.3+Math.random()*.7)+'');co.appendChild(s);}
|
||||
const pc=['var(--primary)','var(--cyan)','var(--pink)'];
|
||||
for(let i=0;i<20;i++){const p=document.createElement('div');p.className='particle';
|
||||
p.style.left=Math.random()*100+'%';p.style.setProperty('--sz',(2+Math.random()*4)+'px');
|
||||
p.style.setProperty('--pc',pc[Math.random()*3|0]);p.style.setProperty('--fd',(7+Math.random()*12)+'s');
|
||||
p.style.setProperty('--fdl',Math.random()*10+'s');co.appendChild(p);}
|
||||
})();
|
||||
|
||||
// Theme toggle
|
||||
function setTheme(t){
|
||||
if(window._themeSwitching)return;
|
||||
const cur=document.documentElement.classList.contains('dark')?'dark':'light';
|
||||
if(cur===t)return;
|
||||
window._themeSwitching=true;
|
||||
const fade=document.getElementById('theme-fade');
|
||||
fade.style.background=t==='dark'
|
||||
?'radial-gradient(ellipse at center,#0c0b18,#06050e)'
|
||||
:'radial-gradient(ellipse at center,#f3f2fa,#fafaff)';
|
||||
fade.classList.add('in');
|
||||
setTimeout(()=>{
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
document.querySelectorAll('.pill button').forEach(b=>b.classList.toggle('on',(t==='dark'&&b.textContent==='Dark')||(t==='light'&&b.textContent==='Light')));
|
||||
setTimeout(()=>{fade.classList.remove('in');window._themeSwitching=false;},50);
|
||||
},420);
|
||||
}
|
||||
|
||||
// ===== Mode switch (main ↔ admin) =====
|
||||
function switchMode(mode){
|
||||
if(window._modeSwitching)return;
|
||||
const shell=document.querySelector('.shell');
|
||||
const isAdmin=shell.classList.contains('admin-mode');
|
||||
if((mode==='admin'&&isAdmin)||(mode==='main'&&!isAdmin))return;
|
||||
window._modeSwitching=true;
|
||||
|
||||
const mainSide=document.getElementById('side');
|
||||
const adminSide=document.getElementById('admin-side');
|
||||
const mainTabs=document.getElementById('main-tabs');
|
||||
const adminTabs=document.getElementById('admin-tabs');
|
||||
const bc=document.getElementById('breadcrumb');
|
||||
const modeFade=document.getElementById('mode-fade');
|
||||
|
||||
// Phase 1: Fade overlay + slide out current sidebar
|
||||
modeFade.classList.add('in');
|
||||
const curSide=mode==='admin'?mainSide:adminSide;
|
||||
const newSide=mode==='admin'?adminSide:mainSide;
|
||||
const curTabs=mode==='admin'?mainTabs:adminTabs;
|
||||
const newTabs=mode==='admin'?adminTabs:mainTabs;
|
||||
|
||||
curSide.classList.add('slide-out');
|
||||
curTabs.classList.add('fade-out');
|
||||
|
||||
setTimeout(()=>{
|
||||
// Phase 2: Swap visibility
|
||||
curSide.style.display='none';
|
||||
curSide.classList.remove('slide-out');
|
||||
newSide.style.display='';
|
||||
newSide.classList.add('slide-in');
|
||||
|
||||
curTabs.style.display='none';
|
||||
curTabs.classList.remove('fade-out');
|
||||
newTabs.style.display='';
|
||||
newTabs.classList.add('fade-in');
|
||||
|
||||
// Toggle admin mode class
|
||||
shell.classList.toggle('admin-mode',mode==='admin');
|
||||
bc.innerHTML=mode==='admin'?'관리자 › <b>메뉴 관리</b>':'홈 › <b>대시보드</b>';
|
||||
document.querySelector('.admin-label').textContent=mode==='admin'?'홈으로':'관리자';
|
||||
|
||||
// Re-apply sidebar animation delays
|
||||
newSide.querySelectorAll('.si').forEach((n,i)=>{
|
||||
n.style.animationDelay=(.03+i*.03)+'s';
|
||||
n.classList.remove('slide-in');void n.offsetWidth;
|
||||
});
|
||||
|
||||
// Phase 3: Fade out overlay
|
||||
setTimeout(()=>{
|
||||
modeFade.classList.remove('in');
|
||||
newSide.classList.remove('slide-in');
|
||||
newTabs.classList.remove('fade-in');
|
||||
window._modeSwitching=false;
|
||||
},300);
|
||||
},300);
|
||||
}
|
||||
|
||||
// Sidebar click (delegated for both main + admin)
|
||||
document.addEventListener('click',function(e){
|
||||
const si=e.target.closest('.si');
|
||||
if(!si)return;
|
||||
const side=si.closest('.side');
|
||||
if(!side||side.classList.contains('collapsed'))return;
|
||||
side.querySelectorAll('.si').forEach(i=>i.classList.remove('on'));
|
||||
si.classList.add('on');
|
||||
const name=si.dataset.name||si.textContent.trim();
|
||||
const bc=document.getElementById('breadcrumb');
|
||||
const isAdmin=document.querySelector('.shell').classList.contains('admin-mode');
|
||||
bc.innerHTML=(isAdmin?'관리자':'홈')+' › <b>'+name+'</b>';
|
||||
});
|
||||
|
||||
// Tab click + close (delegated)
|
||||
document.addEventListener('click',function(e){
|
||||
const tabX=e.target.closest('.tab-x');
|
||||
if(tabX){
|
||||
e.stopPropagation();const tab=tabX.closest('.tab');if(!tab)return;
|
||||
const wasOn=tab.classList.contains('on');const parent=tab.parentElement;tab.remove();
|
||||
if(wasOn){const first=parent.querySelector('.tab');if(first)first.classList.add('on');}
|
||||
return;
|
||||
}
|
||||
const tab=e.target.closest('.tab');
|
||||
if(!tab)return;
|
||||
const parent=tab.parentElement;
|
||||
parent.querySelectorAll('.tab').forEach(i=>i.classList.remove('on'));tab.classList.add('on');
|
||||
});
|
||||
|
||||
// ===== Tab collapse =====
|
||||
function collapseTab(){
|
||||
const tabs=document.getElementById('main-tabs');
|
||||
const mini=document.getElementById('tab-mini');
|
||||
tabs.classList.add('tabs-collapsed');
|
||||
mini.classList.add('visible');
|
||||
updateTabCount();
|
||||
}
|
||||
function expandTab(){
|
||||
const tabs=document.getElementById('main-tabs');
|
||||
const mini=document.getElementById('tab-mini');
|
||||
const dd=document.getElementById('tab-dropdown');
|
||||
tabs.classList.remove('tabs-collapsed');
|
||||
mini.classList.remove('visible');
|
||||
dd.classList.remove('open');
|
||||
}
|
||||
function toggleTabDropdown(){
|
||||
const dd=document.getElementById('tab-dropdown');
|
||||
if(dd.classList.contains('open')){dd.classList.remove('open');return;}
|
||||
// Build dropdown content from current tabs
|
||||
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
|
||||
let html='<div class="td-head"><span class="td-title">열린 탭 ('+tabs.length+')</span><button class="td-expand" onclick="expandTab()">펼치기</button></div>';
|
||||
tabs.forEach((t,i)=>{
|
||||
const name=t.querySelector('span')?.textContent||'';
|
||||
const isOn=t.classList.contains('on');
|
||||
html+='<div class="td-item'+(isOn?' on':'')+'" data-idx="'+i+'" onclick="selectTabFromDD(this)"><span>'+name+'</span><button class="td-close" onclick="event.stopPropagation();closeTabFromDD('+i+')">×</button></div>';
|
||||
});
|
||||
dd.innerHTML=html;
|
||||
dd.classList.add('open');
|
||||
}
|
||||
function selectTabFromDD(el){
|
||||
const idx=parseInt(el.dataset.idx);
|
||||
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
|
||||
tabs.forEach(t=>t.classList.remove('on'));
|
||||
if(tabs[idx])tabs[idx].classList.add('on');
|
||||
document.getElementById('tab-dropdown').classList.remove('open');
|
||||
// Update breadcrumb
|
||||
const name=tabs[idx]?.querySelector('span')?.textContent||'';
|
||||
document.getElementById('breadcrumb').innerHTML='홈 › <b>'+name+'</b>';
|
||||
}
|
||||
function closeTabFromDD(idx){
|
||||
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
|
||||
const tab=tabs[idx];if(!tab)return;
|
||||
const wasOn=tab.classList.contains('on');tab.remove();
|
||||
if(wasOn){const first=document.querySelector('#main-tabs .tab');if(first)first.classList.add('on');}
|
||||
updateTabCount();
|
||||
toggleTabDropdown();// Refresh dropdown
|
||||
}
|
||||
function updateTabCount(){
|
||||
const count=document.getElementById('main-tabs').querySelectorAll('.tab').length;
|
||||
document.getElementById('tab-count').textContent=count;
|
||||
}
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click',function(e){
|
||||
if(!e.target.closest('.tab-mini')){
|
||||
const dd=document.getElementById('tab-dropdown');
|
||||
if(dd)dd.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Avatar dropdown =====
|
||||
function toggleAvatarDD(){
|
||||
const dd=document.getElementById('avatar-dd');
|
||||
closeAllPanels('avatar-dd');
|
||||
dd.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ===== Notification panel =====
|
||||
function toggleNoti(){
|
||||
const panel=document.getElementById('noti-panel');
|
||||
closeAllPanels('noti-panel');
|
||||
panel.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ===== Close all floating panels =====
|
||||
function closeAllPanels(except){
|
||||
['avatar-dd','noti-panel','tab-dropdown'].forEach(id=>{
|
||||
if(id!==except){const el=document.getElementById(id);if(el)el.classList.remove('open');}
|
||||
});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
if(!e.target.closest('.avatar-w')&&!e.target.closest('.bell')&&!e.target.closest('.noti-panel')
|
||||
&&!e.target.closest('.tab-mini')){
|
||||
closeAllPanels();
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Mobile sidebar =====
|
||||
function toggleMobileSide(){
|
||||
const side=document.getElementById('side');
|
||||
const overlay=document.getElementById('side-overlay');
|
||||
side.classList.toggle('mobile-open');
|
||||
overlay.classList.toggle('open');
|
||||
}
|
||||
function closeMobileSide(){
|
||||
document.getElementById('side').classList.remove('mobile-open');
|
||||
document.getElementById('side-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
// ===== Sidebar collapse =====
|
||||
function toggleSidebar(){
|
||||
const side=document.getElementById('side');
|
||||
closeFlyouts();
|
||||
side.classList.toggle('collapsed');
|
||||
}
|
||||
function toggleAdminSidebar(){
|
||||
const side=document.getElementById('admin-side');
|
||||
closeFlyouts();
|
||||
side.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
function toggleFlyout(catEl){
|
||||
const side=document.getElementById('side');
|
||||
if(!side.classList.contains('collapsed'))return;
|
||||
const flyout=catEl.querySelector('.side-flyout');
|
||||
const wasOpen=flyout.classList.contains('open');
|
||||
closeFlyouts();
|
||||
if(wasOpen)return;
|
||||
|
||||
// Build flyout items from the group's .si items
|
||||
const group=catEl.closest('.side-group');
|
||||
const catName=group.dataset.cat;
|
||||
const items=group.querySelectorAll('.si');
|
||||
let html='<div class="fly-title">'+catName+'</div>';
|
||||
items.forEach(si=>{
|
||||
const name=si.dataset.name||si.textContent.trim();
|
||||
const isOn=si.classList.contains('on');
|
||||
const iconHtml=si.querySelector('.ic')?.innerHTML||'';
|
||||
html+='<div class="fly-item'+(isOn?' on':'')+'" data-name="'+name+'" onclick="selectFromFlyout(this)"><span class="ic">'+iconHtml+'</span><span>'+name+'</span></div>';
|
||||
});
|
||||
flyout.innerHTML=html;
|
||||
catEl.classList.add('open');
|
||||
flyout.style.top='0';
|
||||
requestAnimationFrame(()=>flyout.classList.add('open'));
|
||||
}
|
||||
|
||||
function selectFromFlyout(flyItem){
|
||||
const name=flyItem.dataset.name;
|
||||
const side=flyItem.closest('.side');
|
||||
const sideId=side?side.id:'side';
|
||||
// Update active state
|
||||
side.querySelectorAll('.si').forEach(si=>si.classList.remove('on'));
|
||||
const target=side.querySelector('.si[data-name="'+name+'"]');
|
||||
if(target)target.classList.add('on');
|
||||
// Update breadcrumb
|
||||
const isAdmin=document.querySelector('.shell').classList.contains('admin-mode');
|
||||
document.getElementById('breadcrumb').innerHTML=(isAdmin?'관리자':'홈')+' › <b>'+name+'</b>';
|
||||
closeFlyouts();
|
||||
}
|
||||
|
||||
function closeFlyouts(){
|
||||
document.querySelectorAll('.side-flyout').forEach(f=>f.classList.remove('open'));
|
||||
document.querySelectorAll('.side-cat').forEach(c=>c.classList.remove('open'));
|
||||
}
|
||||
|
||||
// Close flyouts on outside click
|
||||
document.addEventListener('click',function(e){
|
||||
if(!e.target.closest('.side-cat')&&!e.target.closest('.side-flyout')){
|
||||
closeFlyouts();
|
||||
}
|
||||
});
|
||||
|
||||
// Sidebar animate delays
|
||||
document.querySelectorAll('#side .si').forEach((n,i)=>{n.style.animationDelay=(.05+i*.04)+'s';});
|
||||
document.querySelectorAll('#admin-side .si').forEach((n,i)=>{n.style.animationDelay=(.05+i*.04)+'s';});
|
||||
document.querySelectorAll('#admin-side .side-cat').forEach((n,i)=>{n.style.animationDelay=(.05+i*.05)+'s';});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
/* ===================================================================
|
||||
INVION v5 — Cosmic Glassmorphism Layout System
|
||||
All variables use --v5- prefix to avoid shadcn/Tailwind collision.
|
||||
=================================================================== */
|
||||
|
||||
/* ===== V5 CSS Variables ===== */
|
||||
:root {
|
||||
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
|
||||
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
--v5-surface-hover:rgba(255,255,255,0.7);
|
||||
--v5-text:#0f0e1a; --v5-text-sec:#6b6a80; --v5-text-muted:#9998ad;
|
||||
--v5-primary:#6c5ce7; --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(108,92,231,0.25);
|
||||
--v5-cyan:#00cec9; --v5-cyan-glow:rgba(0,206,201,0.2);
|
||||
--v5-pink:#fd79a8; --v5-pink-glow:rgba(253,121,168,0.15);
|
||||
--v5-red:#ff4757; --v5-green:#00b894; --v5-amber:#fdcb6e;
|
||||
--v5-border:rgba(108,92,231,0.12); --v5-border-subtle:rgba(0,0,0,0.05);
|
||||
--v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65);
|
||||
--v5-glass-border:rgba(108,92,231,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(108,92,231,0.12);
|
||||
--v5-glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--v5-sidebar-w:220px;
|
||||
}
|
||||
.dark {
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:rgba(25,24,64,0.6);
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-primary:#a29bfe; --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(162,155,254,0.25);
|
||||
--v5-cyan:#55efc4; --v5-cyan-glow:rgba(85,239,196,0.15);
|
||||
--v5-pink:#fd79a8; --v5-red:#ff6b6b; --v5-green:#55efc4; --v5-amber:#ffeaa7;
|
||||
--v5-border:rgba(162,155,254,0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(17,16,42,0.45); --v5-glass-strong:rgba(17,16,42,0.65);
|
||||
--v5-glass-border:rgba(162,155,254,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(162,155,254,0.1);
|
||||
--v5-glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.v5-cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
.v5-cosmos .star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
|
||||
animation:v5-twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
|
||||
.v5-cosmos .star.c{width:3px;height:3px;background:var(--sc);}
|
||||
@keyframes v5-twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
|
||||
html:not(.dark) .v5-cosmos .star{display:none;}
|
||||
html:not(.dark) .v5-cosmos .shooting-star{display:none;}
|
||||
html:not(.dark) .v5-cosmos .particle{display:none;}
|
||||
|
||||
.v5-cosmos .neb{position:absolute;border-radius:50%;filter:blur(140px);animation:v5-drift 16s ease-in-out infinite alternate;}
|
||||
.v5-cosmos .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--v5-primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.v5-cosmos .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--v5-cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.v5-cosmos .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--v5-pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.v5-cosmos .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes v5-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
html:not(.dark) .v5-cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
html:not(.dark) .v5-cosmos .neb{filter:blur(100px);}
|
||||
html:not(.dark) .v5-cosmos .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;border-radius:50%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
|
||||
html:not(.dark) .v5-cosmos .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);animation-duration:20s;}
|
||||
html:not(.dark) .v5-cosmos .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
|
||||
html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
|
||||
.v5-cosmos .shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
transform:rotate(35deg);animation:v5-shoot 5s ease-in-out infinite;opacity:0;}
|
||||
.v5-cosmos .shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
|
||||
@keyframes v5-shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
|
||||
|
||||
.v5-cosmos .particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--v5-primary));
|
||||
border-radius:50%;opacity:0;animation:v5-floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
|
||||
@keyframes v5-floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
|
||||
|
||||
/* ===== LAYOUT SHELL ===== */
|
||||
.v5-shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;}
|
||||
|
||||
/* ===== GLASS HEADER ===== */
|
||||
.v5-hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
.v5-hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
|
||||
.v5-hdr-bc{font-size:.72rem;color:var(--v5-text-muted);}
|
||||
.v5-hdr-bc b{color:var(--v5-text);font-weight:600;}
|
||||
.v5-hdr-r{display:flex;align-items:center;gap:.65rem;}
|
||||
|
||||
/* Theme pill */
|
||||
.v5-pill{display:flex;background:var(--v5-surface);backdrop-filter:blur(8px);border:1px solid var(--v5-glass-border);
|
||||
border-radius:999px;padding:2px;}
|
||||
.v5-pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--v5-text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-pill button.on{background:var(--v5-primary);color:white;box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
/* Bell */
|
||||
.v5-bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s;}
|
||||
.v5-bell:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--v5-red);border-radius:50%;animation:v5-pdot 2s infinite;}
|
||||
@keyframes v5-pdot{0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,.4)}50%{box-shadow:0 0 0 5px rgba(255,71,87,0)}}
|
||||
|
||||
/* Admin button */
|
||||
.v5-admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;}
|
||||
.v5-admin-btn:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);transform:scale(1.1);}
|
||||
.v5-admin-btn .v5-admin-label{position:absolute;top:110%;left:50%;transform:translateX(-50%);
|
||||
font-size:.52rem;font-weight:600;color:var(--v5-primary);white-space:nowrap;
|
||||
opacity:0;transition:opacity .2s,color .2s;pointer-events:none;}
|
||||
.v5-admin-btn:hover .v5-admin-label{opacity:1;}
|
||||
.v5-admin-btn .ic-gear{display:block;}
|
||||
.v5-admin-btn .ic-home{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-home{display:block;}
|
||||
.v5-admin-mode .v5-admin-btn{border-color:var(--v5-cyan);color:var(--v5-cyan);background:rgba(0,206,201,.08);box-shadow:0 0 15px var(--v5-cyan-glow);}
|
||||
.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);border-color:var(--v5-cyan);}
|
||||
.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);}
|
||||
|
||||
/* Avatar */
|
||||
.v5-avatar-w{position:relative;}
|
||||
.v5-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:700;color:white;
|
||||
cursor:pointer;transition:transform .2s,box-shadow .3s;}
|
||||
.v5-avatar:hover{transform:scale(1.1);box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
/* Avatar dropdown */
|
||||
.v5-avatar-dd{position:absolute;top:calc(100% + 10px);right:0;width:220px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:16px;padding:.5rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .v5-avatar-dd{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-avatar-dd.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-avatar-dd .av-profile{display:flex;align-items:center;gap:.6rem;padding:.55rem .6rem;
|
||||
border-bottom:1px solid var(--v5-border-subtle);margin-bottom:.35rem;}
|
||||
.v5-avatar-dd .av-avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;color:white;flex-shrink:0;}
|
||||
.v5-avatar-dd .av-name{font-size:.78rem;font-weight:700;color:var(--v5-text);}
|
||||
.v5-avatar-dd .av-email{font-size:.6rem;color:var(--v5-text-muted);margin-top:.1rem;}
|
||||
.v5-avatar-dd .av-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;
|
||||
border-radius:10px;font-size:.72rem;font-weight:500;color:var(--v5-text-sec);
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.v5-avatar-dd .av-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-avatar-dd .av-item .av-ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
|
||||
.v5-avatar-dd .av-item:hover .av-ic{opacity:1;}
|
||||
.v5-avatar-dd .av-divider{height:1px;background:var(--v5-border-subtle);margin:.3rem .6rem;}
|
||||
.v5-avatar-dd .av-item.danger{color:var(--v5-red);}
|
||||
.v5-avatar-dd .av-item.danger:hover{background:rgba(255,71,87,.08);}
|
||||
|
||||
/* Notification panel */
|
||||
.v5-noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;overflow:hidden;}
|
||||
.dark .v5-noti-panel{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-noti-panel.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-noti-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .85rem;
|
||||
border-bottom:1px solid var(--v5-border-subtle);}
|
||||
.v5-noti-head .noti-title{font-size:.78rem;font-weight:700;color:var(--v5-text);}
|
||||
.v5-noti-head .noti-clear{font-size:.58rem;font-weight:600;color:var(--v5-primary);cursor:pointer;
|
||||
border:none;background:none;font-family:inherit;transition:color .2s;}
|
||||
.v5-noti-head .noti-clear:hover{color:var(--v5-cyan);}
|
||||
.v5-noti-list{overflow-y:auto;max-height:330px;padding:.35rem;}
|
||||
.v5-noti-item{display:flex;gap:.55rem;padding:.55rem .5rem;border-radius:10px;cursor:pointer;transition:all .15s;}
|
||||
.v5-noti-item:hover{background:var(--v5-surface-hover);}
|
||||
.v5-noti-item.unread{background:linear-gradient(135deg,rgba(108,92,231,.05),rgba(108,92,231,.02));}
|
||||
.dark .v5-noti-item.unread{background:linear-gradient(135deg,rgba(162,155,254,.06),rgba(162,155,254,.02));}
|
||||
.v5-noti-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem;}
|
||||
.v5-noti-dot.info{background:var(--v5-primary);}
|
||||
.v5-noti-dot.warn{background:var(--v5-amber);}
|
||||
.v5-noti-dot.err{background:var(--v5-red);box-shadow:0 0 6px rgba(255,71,87,.4);}
|
||||
.v5-noti-dot.ok{background:var(--v5-green);}
|
||||
.v5-noti-body{flex:1;min-width:0;}
|
||||
.v5-noti-msg{font-size:.7rem;font-weight:500;color:var(--v5-text);line-height:1.35;}
|
||||
.v5-noti-msg b{font-weight:600;}
|
||||
.v5-noti-time{font-size:.55rem;color:var(--v5-text-muted);margin-top:.15rem;}
|
||||
|
||||
/* Admin mode header accent */
|
||||
.v5-admin-mode .v5-hdr{border-bottom-color:var(--v5-primary);}
|
||||
.v5-admin-mode .v5-hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--v5-primary),var(--v5-cyan));animation:v5-glowLine .6s ease-out both;}
|
||||
@keyframes v5-glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}}
|
||||
|
||||
/* Admin badge */
|
||||
.v5-admin-badge{display:none;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);
|
||||
animation:v5-badgeIn .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(85,239,196,.08));
|
||||
border-color:rgba(162,155,254,.2);color:var(--v5-primary-light);}
|
||||
.v5-admin-mode .v5-admin-badge{display:flex;}
|
||||
@keyframes v5-badgeIn{from{opacity:0;transform:scale(.8) translateX(-10px)}to{opacity:1;transform:none}}
|
||||
.v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-cyan);
|
||||
box-shadow:0 0 8px var(--v5-cyan-glow);animation:v5-bdPulse 2s infinite;}
|
||||
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-cyan-glow)}50%{box-shadow:0 0 12px var(--v5-cyan-glow)}}
|
||||
|
||||
/* ===== GLASS TABS ===== */
|
||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
background:var(--v5-glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
||||
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
||||
.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:var(--v5-primary);background:var(--v5-surface);}
|
||||
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
|
||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||
.v5-tab:hover .v5-tab-x{opacity:1;}
|
||||
.v5-tab-x:hover{background:rgba(255,71,87,.15);color:var(--v5-red);}
|
||||
|
||||
/* Tab collapse */
|
||||
.v5-tab-toggle{width:28px;height:28px;border-radius:8px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;flex-shrink:0;margin-right:.35rem;}
|
||||
.v5-tab-toggle:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-tab-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-tabs.collapsed .v5-tab-toggle svg{transform:rotate(180deg);}
|
||||
.v5-tabs.collapsed{height:0;padding:0;border:none;overflow:hidden;transition:height .3s cubic-bezier(.4,0,.2,1),padding .3s,border-width .3s;}
|
||||
.v5-tabs:not(.collapsed){transition:height .3s cubic-bezier(.16,1,.3,1),padding .3s;}
|
||||
|
||||
/* Tab mini icon */
|
||||
.v5-tab-mini{position:relative;display:none;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .25s;}
|
||||
.v5-tab-mini:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-tab-mini.visible{display:flex;animation:v5-miniIn .3s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes v5-miniIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
||||
.v5-tab-mini .tab-count{position:absolute;top:3px;right:3px;width:14px;height:14px;border-radius:50%;
|
||||
background:var(--v5-primary);color:white;font-size:.5rem;font-weight:700;
|
||||
display:flex;align-items:center;justify-content:center;line-height:1;}
|
||||
|
||||
/* Tab dropdown */
|
||||
.v5-tab-dropdown{position:absolute;top:calc(100% + 8px);right:0;width:220px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .v5-tab-dropdown{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-tab-dropdown.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-tab-dropdown .td-item{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.45rem .65rem;border-radius:10px;font-size:.72rem;font-weight:500;
|
||||
color:var(--v5-text-sec);cursor:pointer;transition:all .15s;gap:.4rem;}
|
||||
.v5-tab-dropdown .td-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-tab-dropdown .td-item.on{color:var(--v5-primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
.v5-tab-dropdown .td-close{width:16px;height:16px;border-radius:4px;border:none;
|
||||
background:transparent;color:var(--v5-text-muted);cursor:pointer;display:flex;
|
||||
align-items:center;justify-content:center;font-size:.55rem;opacity:0;transition:all .15s;flex-shrink:0;}
|
||||
.v5-tab-dropdown .td-item:hover .td-close{opacity:1;}
|
||||
.v5-tab-dropdown .td-close:hover{background:rgba(255,71,87,.12);color:var(--v5-red);}
|
||||
.v5-tab-dropdown .td-head{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.3rem .65rem .5rem;border-bottom:1px solid var(--v5-border-subtle);margin-bottom:.25rem;}
|
||||
.v5-tab-dropdown .td-title{font-size:.6rem;font-weight:700;color:var(--v5-text-muted);text-transform:uppercase;letter-spacing:.08em;}
|
||||
.v5-tab-dropdown .td-expand{font-size:.55rem;font-weight:600;color:var(--v5-primary);cursor:pointer;
|
||||
border:none;background:none;font-family:inherit;transition:color .2s;}
|
||||
.v5-tab-dropdown .td-expand:hover{color:var(--v5-cyan);}
|
||||
|
||||
/* ===== GLASS SIDEBAR ===== */
|
||||
.v5-body{display:flex;flex:1;overflow:hidden;position:relative;z-index:5;}
|
||||
.v5-side{width:var(--v5-sidebar-w);background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.3);border-right:1px solid var(--v5-glass-border);
|
||||
padding:.85rem .6rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;
|
||||
transition:width .4s cubic-bezier(.4,0,.2,1),padding .4s,transform .35s cubic-bezier(.16,1,.3,1),opacity .25s;}
|
||||
|
||||
.v5-side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--v5-text-muted);padding:1rem .65rem .35rem;
|
||||
transition:opacity .3s,height .3s,padding .3s;}
|
||||
.v5-side-sec:first-child{padding-top:.25rem;}
|
||||
|
||||
.v5-si{padding:.5rem .7rem;border-radius:10px;font-size:.77rem;color:var(--v5-text-sec);cursor:pointer;
|
||||
transition:all .25s cubic-bezier(.4,0,.2,1);font-weight:450;display:flex;align-items:center;gap:.6rem;
|
||||
position:relative;overflow:hidden;height:auto;}
|
||||
.v5-si .ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.65;flex-shrink:0;}
|
||||
.v5-si:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-si.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.05));
|
||||
color:var(--v5-primary);font-weight:600;border:1px solid rgba(108,92,231,.15);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-si.on .ic{opacity:1;}
|
||||
.dark .v5-si.on{background:linear-gradient(135deg,rgba(162,155,254,.14),rgba(162,155,254,.05));border-color:rgba(162,155,254,.15);}
|
||||
.v5-si::before{content:'';position:absolute;left:0;top:0;width:3px;height:100%;background:var(--v5-primary);
|
||||
border-radius:0 2px 2px 0;transform:scaleY(0);transition:transform .2s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-si.on::before{transform:scaleY(1);}
|
||||
|
||||
/* Sidebar enter animation */
|
||||
.v5-side-anim .v5-si{animation:v5-slideR .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes v5-slideR{from{opacity:0;transform:translateX(-16px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Sidebar toggle */
|
||||
.v5-side-toggle{margin-top:auto;padding:.5rem .7rem;border-radius:10px;border:none;
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;gap:.6rem;font-size:.7rem;font-weight:500;font-family:inherit;
|
||||
transition:all .25s;flex-shrink:0;}
|
||||
.v5-side-toggle:hover{background:var(--v5-surface-hover);color:var(--v5-primary);}
|
||||
.v5-side-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
|
||||
|
||||
/* ===== SIDEBAR COLLAPSE ===== */
|
||||
.v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
|
||||
.v5-side.collapsed .v5-si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;}
|
||||
.v5-side.collapsed .v5-si span:not(.ic){width:0;overflow:hidden;opacity:0;transition:width .25s,opacity .2s;position:absolute;}
|
||||
.v5-side.collapsed .v5-si{position:relative;}
|
||||
.v5-side.collapsed .v5-si:hover::after{content:attr(title);position:absolute;left:calc(100% + 10px);top:50%;transform:translateY(-50%);
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(12px);border:1px solid var(--v5-glass-border);
|
||||
padding:.3rem .6rem;border-radius:8px;font-size:.68rem;font-weight:500;color:var(--v5-text);
|
||||
white-space:nowrap;z-index:100;box-shadow:0 4px 15px rgba(0,0,0,.1);pointer-events:none;}
|
||||
.v5-side.collapsed .v5-si .ic{opacity:.7;margin:0;transition:opacity .2s;}
|
||||
.v5-side.collapsed .v5-si.on .ic{opacity:1;}
|
||||
.v5-side.collapsed .v5-si:hover{transform:none;}
|
||||
.v5-side.collapsed .v5-side-sec{height:0;overflow:hidden;padding:0;margin:0;opacity:0;transition:all .25s;}
|
||||
.v5-side:not(.collapsed) .v5-si span:not(.ic){opacity:1;transition:opacity .3s .15s;}
|
||||
.v5-side:not(.collapsed) .v5-side-sec{opacity:1;transition:opacity .3s .1s,height .3s,padding .3s;}
|
||||
|
||||
/* Category groups */
|
||||
.v5-side-group{display:contents;}
|
||||
.v5-side.collapsed .v5-side-group{display:flex;flex-direction:column;gap:1px;position:relative;}
|
||||
|
||||
/* Category header (collapsed only) */
|
||||
.v5-side-cat{height:0;overflow:hidden;opacity:0;padding:0;margin:0;pointer-events:none;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
border-radius:10px;cursor:pointer;position:relative;color:var(--v5-text-muted);
|
||||
transition:height .25s,opacity .2s,padding .25s,margin .25s;}
|
||||
.v5-side.collapsed .v5-side-cat{height:auto;overflow:visible;opacity:1;pointer-events:auto;
|
||||
padding:.55rem;margin-top:.4rem;transition:height .3s .05s,opacity .3s .1s,padding .3s .05s,margin .3s .05s;}
|
||||
.v5-side.collapsed .v5-side-cat:first-child{margin-top:0;}
|
||||
.v5-side.collapsed .v5-side-cat:hover{background:var(--v5-surface-hover);color:var(--v5-primary);}
|
||||
.v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));color:var(--v5-primary);}
|
||||
.dark .v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(162,155,254,.04));}
|
||||
.v5-side-cat .cat-label{font-size:.48rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||
margin-top:.15rem;text-align:center;line-height:1;}
|
||||
.v5-side.collapsed .v5-side-cat{animation:v5-catIn .3s cubic-bezier(.16,1,.3,1) both;}
|
||||
.v5-side.collapsed .v5-side-group:nth-child(1) .v5-side-cat{animation-delay:.05s;}
|
||||
.v5-side.collapsed .v5-side-group:nth-child(2) .v5-side-cat{animation-delay:.1s;}
|
||||
.v5-side.collapsed .v5-side-group:nth-child(3) .v5-side-cat{animation-delay:.15s;}
|
||||
.v5-side.collapsed .v5-side-group:nth-child(4) .v5-side-cat{animation-delay:.2s;}
|
||||
@keyframes v5-catIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Hide items in collapsed (shown in flyout) */
|
||||
.v5-side.collapsed .v5-side-group .v5-si{height:0;padding:0;margin:0;overflow:hidden;opacity:0;
|
||||
transition:height .25s,padding .25s,opacity .15s,margin .25s;}
|
||||
.v5-side.collapsed .v5-side-group .v5-side-sec{height:0;padding:0;margin:0;overflow:hidden;opacity:0;}
|
||||
.v5-side:not(.collapsed) .v5-side-group .v5-si{transition:height .3s .1s,padding .3s .1s,opacity .3s .15s,margin .3s .1s;}
|
||||
|
||||
/* Hide child menus when collapsed */
|
||||
.v5-side.collapsed .v5-si-child{height:0;padding:0;margin:0;overflow:hidden;opacity:0;pointer-events:none;}
|
||||
/* Hide tooltip when flyout is open */
|
||||
.v5-si:has(> .v5-side-flyout.open):hover::after{display:none !important;}
|
||||
|
||||
/* Collapsed toggle */
|
||||
.v5-side.collapsed .v5-side-toggle span{width:0;overflow:hidden;opacity:0;transition:width .2s,opacity .15s;}
|
||||
.v5-side.collapsed .v5-side-toggle{justify-content:center;padding:.55rem;}
|
||||
.v5-side.collapsed .v5-side-toggle svg{transform:rotate(180deg);}
|
||||
.v5-side:not(.collapsed) .v5-side-toggle span{opacity:1;transition:opacity .3s .2s;}
|
||||
|
||||
/* Flyout panel */
|
||||
.v5-side-flyout{position:absolute;left:calc(100% + 8px);top:0;width:170px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.15),var(--v5-glow-sm);
|
||||
opacity:0;transform:translateX(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .v5-side-flyout{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-side-flyout.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--v5-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;}
|
||||
.v5-side-flyout .fly-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;
|
||||
border-radius:10px;font-size:.72rem;font-weight:500;color:var(--v5-text-sec);
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.v5-side-flyout .fly-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-side-flyout .fly-item.on{color:var(--v5-primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
.v5-side-flyout .fly-item .ic{width:14px;height:14px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
|
||||
.v5-side-flyout .fly-item.on .ic{opacity:1;}
|
||||
|
||||
/* Admin sidebar accent */
|
||||
.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(0,206,201,.12),rgba(0,206,201,.05));
|
||||
color:var(--v5-cyan);border-color:rgba(0,206,201,.2);}
|
||||
.v5-admin-side .v5-si.on .ic{opacity:1;}
|
||||
.v5-admin-side .v5-si::before{background:var(--v5-cyan);}
|
||||
.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(85,239,196,.12),rgba(85,239,196,.05));
|
||||
border-color:rgba(85,239,196,.15);}
|
||||
|
||||
/* ===== MODE TRANSITION ===== */
|
||||
.v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
|
||||
background:radial-gradient(ellipse at center,var(--v5-primary-glow),transparent 70%);
|
||||
transition:opacity .5s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-mode-fade.in{opacity:1;}
|
||||
|
||||
.v5-side.slide-out{transform:translateX(-100%);opacity:0;}
|
||||
.v5-side.slide-in{animation:v5-sideSlideIn .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
@keyframes v5-sideSlideIn{from{transform:translateX(-30px);opacity:0}to{transform:none;opacity:1}}
|
||||
.v5-tabs.fade-out{opacity:0;}
|
||||
.v5-tabs.fade-in{animation:v5-tabsFadeIn .3s ease-out both;}
|
||||
@keyframes v5-tabsFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* ===== THEME TRANSITION ===== */
|
||||
.v5-theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
|
||||
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-theme-fade.in{opacity:1;}
|
||||
|
||||
/* ===== CONTENT PLACEHOLDER ===== */
|
||||
.v5-content{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem;}
|
||||
.v5-placeholder{
|
||||
flex:1;display:flex;align-items:center;justify-content:center;
|
||||
border:2px dashed var(--v5-border);border-radius:16px;
|
||||
background:var(--v5-glass);backdrop-filter:blur(12px);
|
||||
color:var(--v5-text-muted);font-size:.85rem;font-weight:500;min-height:300px;}
|
||||
|
||||
/* ===== V5 DROPDOWN GLASS OVERRIDES ===== */
|
||||
.v5-hdr-r [data-radix-popper-content-wrapper]{z-index:100 !important;}
|
||||
.v5-hdr-r [role="menu"],
|
||||
.v5-avatar-dd-content{
|
||||
background:var(--v5-glass-strong) !important;backdrop-filter:blur(20px) saturate(1.4) !important;
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.4) !important;
|
||||
border:1px solid var(--v5-glass-border) !important;border-radius:14px !important;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm) !important;
|
||||
animation:v5-ddIn .25s cubic-bezier(.16,1,.3,1) both !important;
|
||||
padding:.4rem !important;}
|
||||
.dark .v5-hdr-r [role="menu"],
|
||||
.dark .v5-avatar-dd-content{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md) !important;}
|
||||
@keyframes v5-ddIn{from{opacity:0;transform:translateY(-8px) scale(.95)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Avatar dropdown items glass style */
|
||||
.v5-avatar-dd-content [role="menuitem"]{border-radius:8px !important;transition:all .15s !important;font-size:.75rem !important;}
|
||||
.v5-avatar-dd-content [role="menuitem"]:hover{background:var(--v5-surface-hover) !important;transform:translateX(2px);}
|
||||
|
||||
/* ===== MOBILE RESPONSIVE ===== */
|
||||
.v5-mobile-toggle{display:none;width:36px;height:36px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
|
||||
.v5-mobile-toggle:hover{border-color:var(--v5-primary);color:var(--v5-primary);}
|
||||
|
||||
.v5-side-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:25;
|
||||
opacity:0;transition:opacity .3s;pointer-events:none;}
|
||||
.v5-side-overlay.open{opacity:1;pointer-events:auto;}
|
||||
|
||||
@media(max-width:768px){
|
||||
.v5-mobile-toggle{display:flex;}
|
||||
.v5-side{position:fixed;left:0;top:0;bottom:0;z-index:30;
|
||||
transform:translateX(-100%);transition:transform .35s cubic-bezier(.4,0,.2,1);
|
||||
width:260px;padding-top:60px;}
|
||||
.v5-side.mobile-open{transform:none;}
|
||||
.v5-side-overlay{display:block;}
|
||||
.v5-hdr-bc{display:none;}
|
||||
.v5-admin-badge{display:none !important;}
|
||||
.v5-hdr-logo{font-size:.9rem;}
|
||||
.v5-tabs{padding:0 .3rem;}
|
||||
.v5-tab{padding:0 .6rem;font-size:.65rem;}
|
||||
.v5-tab-toggle{display:none;}
|
||||
.v5-content{padding:.75rem;}
|
||||
.v5-admin-label{display:none !important;}
|
||||
.v5-pill{display:none;}
|
||||
}
|
||||
@media(min-width:769px) and (max-width:1024px){
|
||||
:root{--v5-sidebar-w:180px;}
|
||||
.v5-si{font-size:.72rem;padding:.45rem .6rem;}
|
||||
.v5-content{padding:1rem;}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ echo ""
|
||||
echo "============================================"
|
||||
echo "0. 기존 컨테이너 정리 중..."
|
||||
echo "============================================"
|
||||
docker rm -f pms-backend-mac pms-frontend-mac 2>/dev/null || echo "기존 컨테이너가 없습니다."
|
||||
docker network rm pms-network 2>/dev/null || echo "기존 네트워크가 없습니다."
|
||||
docker network create pms-network 2>/dev/null || echo "네트워크를 생성했습니다."
|
||||
docker rm -f pms-backend-mac-v2 pms-frontend-mac-v2 2>/dev/null || echo "기존 컨테이너가 없습니다."
|
||||
docker network rm test-vex-network 2>/dev/null || echo "기존 네트워크가 없습니다."
|
||||
docker network create test-vex-network 2>/dev/null || echo "네트워크를 생성했습니다."
|
||||
echo ""
|
||||
|
||||
# 병렬 빌드 시작
|
||||
@@ -31,7 +31,7 @@ echo "============================================"
|
||||
# 백엔드 빌드 (백그라운드)
|
||||
echo "백엔드(Spring Boot) 빌드 시작..."
|
||||
(
|
||||
docker-compose -f docker/dev/docker-compose.backend.mac.yml build
|
||||
docker compose -f docker/dev/docker-compose.backend.mac.yml build
|
||||
echo "백엔드 빌드 완료"
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
@@ -39,7 +39,7 @@ BACKEND_PID=$!
|
||||
# 프론트엔드 빌드 (백그라운드)
|
||||
echo "프론트엔드 빌드 시작..."
|
||||
(
|
||||
docker-compose -f docker/dev/docker-compose.frontend.mac.yml build
|
||||
docker compose -f docker/dev/docker-compose.frontend.mac.yml build
|
||||
echo "프론트엔드 빌드 완료"
|
||||
) &
|
||||
FRONTEND_PID=$!
|
||||
@@ -62,17 +62,17 @@ echo "============================================"
|
||||
SERVICE_START=$(date +%s)
|
||||
|
||||
# 기존 컨테이너 정리
|
||||
docker-compose -f docker/dev/docker-compose.backend.mac.yml down -v 2>/dev/null
|
||||
docker-compose -f docker/dev/docker-compose.frontend.mac.yml down -v 2>/dev/null
|
||||
docker compose -f docker/dev/docker-compose.backend.mac.yml down -v 2>/dev/null
|
||||
docker compose -f docker/dev/docker-compose.frontend.mac.yml down -v 2>/dev/null
|
||||
|
||||
# 백엔드 시작 (백그라운드)
|
||||
echo "백엔드(Spring Boot) 서비스 시작..."
|
||||
docker-compose -f docker/dev/docker-compose.backend.mac.yml up -d &
|
||||
docker compose -f docker/dev/docker-compose.backend.mac.yml up -d &
|
||||
BACKEND_START_PID=$!
|
||||
|
||||
# 프론트엔드 시작 (백그라운드)
|
||||
echo "프론트엔드 서비스 시작..."
|
||||
docker-compose -f docker/dev/docker-compose.frontend.mac.yml up -d &
|
||||
docker compose -f docker/dev/docker-compose.frontend.mac.yml up -d &
|
||||
FRONTEND_START_PID=$!
|
||||
|
||||
# 서비스 시작 완료 대기
|
||||
@@ -92,21 +92,21 @@ echo "============================================"
|
||||
echo "모든 서비스가 시작되었습니다!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "[DATABASE] PostgreSQL: http://39.117.244.52:11132"
|
||||
echo "[BACKEND] Spring Boot API: http://localhost:8081/api"
|
||||
echo "[FRONTEND] Next.js: http://localhost:9771"
|
||||
echo "[DATABASE] PostgreSQL: http://211.115.91.141:11134 (test_dev)"
|
||||
echo "[BACKEND] Spring Boot API: http://localhost:8082/api"
|
||||
echo "[FRONTEND] Next.js: http://localhost:9772"
|
||||
echo ""
|
||||
echo "서비스 상태 확인:"
|
||||
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml ps"
|
||||
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml ps"
|
||||
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml ps"
|
||||
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml ps"
|
||||
echo ""
|
||||
echo "로그 확인:"
|
||||
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml logs -f"
|
||||
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml logs -f"
|
||||
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml logs -f"
|
||||
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml logs -f"
|
||||
echo ""
|
||||
echo "서비스 중지:"
|
||||
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml down"
|
||||
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml down"
|
||||
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml down"
|
||||
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml down"
|
||||
echo ""
|
||||
echo "============================================"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user