음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -35,7 +35,14 @@ const VARIANT_FOR_EXPRESSION: Record<AvatarExpression, RasterVariant> = {
};
const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]);
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]);
const PERSONA_GENERATED_ART_SETS = [
"p4-live2d-generated",
"p5-live2d-generated",
"p6-live2d-generated",
"p7-live2d-generated",
];
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb", "seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
const PSB_CRYING_ART_SETS = new Set(["seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
return VARIANT_FOR_EXPRESSION[expression];
@ -100,6 +107,13 @@ function mouthForVariant(variant: RasterVariant): string {
return "mouth-neutral";
}
function blushOpacity(artSet: string, variant: RasterVariant): number {
if (artSet === "seoyeon-live2d-psd-v2") {
return variant === "warm" ? 0.62 : 0.34;
}
return variant === "warm" ? 1 : 0.72;
}
function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } {
switch (variant) {
case "sad":
@ -255,6 +269,7 @@ export function RasterBust({
if (PSB_GROUP_ART_SETS.has(artSet)) {
const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`;
const cryingArtSet = PSB_CRYING_ART_SETS.has(artSet);
const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0;
const wideOpen = clamp01((speakingOpen - 0.34) / 0.5);
const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0;
@ -275,6 +290,10 @@ export function RasterBust({
const lowerLidY = eyePose.lowerY - closedEyes * 2.4;
const lashY = eyePose.lashY + closedEyes * 2.1;
const expressionMouth = psbMouthForVariant(variant);
const useSadCryingParts = cryingArtSet && variant === "sad";
const browLeftPart = useSadCryingParts ? "brow-sad-left" : "brow-left";
const browRightPart = useSadCryingParts ? "brow-sad-right" : "brow-right";
const tearOpacity = useSadCryingParts ? clamp01(0.82 + openEyes * 0.18 - closedEyes * 0.35) : 0;
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
@ -329,8 +348,8 @@ export function RasterBust({
{renderLayer("ear-left", "vg-raster__layer--ear", 5)}
{renderLayer("ear-right", "vg-raster__layer--ear", 5)}
{renderLayer("face-base", "vg-raster__layer--face", 6)}
{renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
{renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
{renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })}
{renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })}
{generatedEyeVariant ? (
<>
{renderLayer(`eyegen-${generatedEyeVariant}-eye-left`, "vg-raster__layer--eyes", 8, {
@ -419,13 +438,13 @@ export function RasterBust({
transform: layerTransform(`translateY(${lowerLidY.toFixed(2)}px)`),
transformOrigin: "60.8% 32.4%",
})}
{renderLayer("brow-left", "vg-raster__layer--brow", 14, {
{renderLayer(browLeftPart, "vg-raster__layer--brow", 14, {
transform: layerTransform(
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.leftRotate - speakingOpen * 0.35 - browFloat * 0.08).toFixed(2)}deg)`,
),
transformOrigin: "40% 27%",
})}
{renderLayer("brow-right", "vg-raster__layer--brow", 14, {
{renderLayer(browRightPart, "vg-raster__layer--brow", 14, {
transform: layerTransform(
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.rightRotate + speakingOpen * 0.35 + browFloat * 0.08).toFixed(2)}deg)`,
),
@ -433,6 +452,20 @@ export function RasterBust({
})}
</>
)}
{cryingArtSet ? (
<>
{renderLayer("tear-left", "vg-raster__layer--tear", 15, {
opacity: tearOpacity,
filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)",
transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`),
})}
{renderLayer("tear-right", "vg-raster__layer--tear", 15, {
opacity: tearOpacity,
filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)",
transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`),
})}
</>
) : null}
{renderLayer("nose", "vg-raster__layer--nose", 15)}
{renderLayer("hair-side-left-1", "vg-raster__layer--hair", 16, {
transform: layerTransform(

View file

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { Topbar } from "./Topbar";
import { Sidebar } from "./Sidebar";
import { useAuth } from "../../lib/auth";
import { designRoleOf, useAuth, type Role } from "../../lib/auth";
export interface AppShellProps {
children: ReactNode;
@ -11,8 +11,12 @@ export interface AppShellProps {
hideNav?: boolean;
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
bleed?: boolean;
/** 운영형 대시보드/저작 화면용 넓은 작업폭 */
wide?: boolean;
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
hideTopbar?: boolean;
/** 현재 화면 컨텍스트에 맞춘 네비/크롬 역할 */
navRole?: Role;
}
/**
@ -20,16 +24,33 @@ export interface AppShellProps {
* body[data-role] AuthProvider ( ).
* ( ) (RequireAuth) .
*/
export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
const { user } = useAuth();
const showNav = !hideNav && !!user;
const shellRole = navRole ?? user?.role;
const mainClassName = [
"vg-main",
bleed ? "vg-main--bleed" : "",
wide ? "vg-main--wide" : "",
]
.filter(Boolean)
.join(" ");
useEffect(() => {
const body = document.body;
if (shellRole) body.setAttribute("data-role", designRoleOf(shellRole));
return () => {
if (user) body.setAttribute("data-role", designRoleOf(user.role));
else body.removeAttribute("data-role");
};
}, [shellRole, user]);
return (
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? <Sidebar role={user.role} /> : null}
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>
{showNav ? <Sidebar role={shellRole ?? user.role} /> : null}
<main className={mainClassName}>
<div className="vg-main__inner">{children}</div>
</main>
</div>

View file

@ -16,15 +16,22 @@ export interface NavItem {
*/
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
learner: [
{ to: "/learn", label: "홈", icon: "home", end: true },
{ to: "/learn", label: "대시보드", icon: "home", end: true },
{ to: "/learn/practice", label: "학습", icon: "session" },
{ to: "/learn/history", label: "기록", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
teacher: [
{ to: "/teach", label: "콘솔", icon: "users", end: true },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
admin: [
{ to: "/admin", label: "운영", icon: "shield", end: true },
{ to: "/admin/users", label: "사용자", icon: "users" },
{ to: "/admin/access", label: "권한", icon: "settings" },
{ to: "/admin/tickets", label: "티켓", icon: "review" },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
};

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon";
import { useAuth, roleLabel } from "../../lib/auth";
import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
@ -45,9 +45,15 @@ export interface TopbarProps {
export function Topbar({ contextLabel }: TopbarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [dark, toggleTheme] = useTheme();
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
const switchRoles: Role[] = user?.superAdmin
? ["learner", "teacher", "admin"]
: user?.adminAccess
? ["admin"]
: [];
const onLogout = async () => {
await logout();
@ -69,6 +75,32 @@ export function Topbar({ contextLabel }: TopbarProps) {
<span className="vg-topbar__spacer" />
<div className="vg-topbar__actions">
{user && switchRoles.length > 0 ? (
<div className="vg-topbar__switch" aria-label="공간 전환">
{switchRoles
.filter((role) => canAccessRole(user, role))
.map((role) => {
const to = roleHomePath(role);
const active = location.pathname === to || location.pathname.startsWith(`${to}/`);
return (
<Link
key={role}
className={"vg-topbar__switch-link" + (active ? " is-active" : "")}
to={to}
aria-label={roleLabel(role)}
title={roleLabel(role)}
>
<Icon
name={role === "admin" ? "shield" : role === "teacher" ? "users" : "home"}
size={16}
/>
<span>{role === "admin" ? "관리자" : role === "teacher" ? "교수자" : "학습자"}</span>
</Link>
);
})}
</div>
) : null}
<button
type="button"
className="vg-iconbtn"
@ -83,7 +115,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
<>
<span className="vg-topbar__user">
<span className="vg-topbar__avatar" aria-hidden="true">
{initials(user.name)}
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user.name)}
</span>
<span className="vg-topbar__uname">{user.name}</span>
</span>

View file

@ -14,6 +14,7 @@
/* ── 톱바 ── */
.vg-topbar {
min-width: 0;
height: var(--topbar-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--hair);
@ -66,10 +67,42 @@
flex: 1;
}
.vg-topbar__actions {
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.vg-topbar__switch {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.vg-topbar__switch-link {
min-width: 0;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
border-radius: calc(var(--radius) - 2px);
color: var(--text-muted);
font-size: 12px;
font-weight: 650;
text-decoration: none;
white-space: nowrap;
}
.vg-topbar__switch-link:hover,
.vg-topbar__switch-link.is-active {
background: var(--bg-surface);
color: var(--text-strong);
text-decoration: none;
}
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
body[data-role="admin"] .vg-topbar {
@ -88,6 +121,18 @@ body[data-role="admin"] .vg-topbar__brand svg {
body[data-role="admin"] .vg-topbar__user {
background: rgba(255, 255, 255, 0.08);
}
body[data-role="admin"] .vg-topbar__switch {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
}
body[data-role="admin"] .vg-topbar__switch-link {
color: rgba(238, 244, 242, 0.72);
}
body[data-role="admin"] .vg-topbar__switch-link:hover,
body[data-role="admin"] .vg-topbar__switch-link.is-active {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
body[data-role="admin"] .vg-topbar__avatar {
background: rgba(126, 184, 173, 0.18);
color: #bfe0d9;
@ -126,6 +171,8 @@ body[data-role="admin"] .vg-iconbtn:hover {
/* 사용자 칩 */
.vg-topbar__user {
min-width: 0;
max-width: min(320px, 34vw);
display: inline-flex;
align-items: center;
gap: 9px;
@ -145,8 +192,19 @@ body[data-role="admin"] .vg-iconbtn:hover {
font-size: 11px;
font-weight: 700;
flex: none;
overflow: hidden;
}
.vg-topbar__avatar img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.vg-topbar__uname {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
@ -327,6 +385,9 @@ body[data-role="instructor"] .vg-nav__foot {
max-width: var(--maxw);
margin: 0 auto;
}
.vg-main--wide .vg-main__inner {
max-width: min(1480px, 100%);
}
/* 풀-블리드(세션 화면 등): 패딩/최대폭 없이 셸만 */
.vg-main--bleed {
padding: 0;
@ -411,6 +472,21 @@ body[data-role="instructor"] .vg-nav__foot {
.vg-topbar__uname {
display: none;
}
.vg-topbar {
padding: 0 var(--sp-4);
gap: var(--sp-3);
}
.vg-topbar__user {
max-width: 42px;
padding-right: 6px;
}
.vg-topbar__switch-link span {
display: none;
}
.vg-topbar__switch-link {
width: 28px;
padding: 0;
}
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
@ -448,3 +524,152 @@ body[data-role="instructor"] .vg-nav__foot {
padding: 0;
}
}
/* Unified role chrome
역할별 제품처럼 보이던 톱바/사이드바 변형을 하나의 크롬으로 통합한다.
역할 차이는 tokens.css의 accent만 사용하고, 형태·밀도·배경 체계는 동일하게 유지한다. */
body[data-role="admin"] .vg-topbar,
body[data-role="instructor"] .vg-topbar {
background: var(--bg-surface);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
body[data-role="admin"] .vg-topbar__wm,
body[data-role="admin"] .vg-topbar__role,
body[data-role="admin"] .vg-topbar__uname,
body[data-role="admin"] .vg-iconbtn,
body[data-role="instructor"] .vg-topbar__wm,
body[data-role="instructor"] .vg-topbar__role,
body[data-role="instructor"] .vg-topbar__uname,
body[data-role="instructor"] .vg-iconbtn {
color: var(--text-strong);
}
body[data-role="admin"] .vg-topbar__wm .v,
body[data-role="instructor"] .vg-topbar__wm .v,
body[data-role="admin"] .vg-topbar__mark,
body[data-role="instructor"] .vg-topbar__mark,
body[data-role="admin"] .vg-topbar__brand svg,
body[data-role="instructor"] .vg-topbar__brand svg {
color: var(--accent);
}
body[data-role="admin"] .vg-topbar__role,
body[data-role="instructor"] .vg-topbar__role {
border-left-color: var(--hair);
}
body[data-role="admin"] .vg-topbar__user,
body[data-role="instructor"] .vg-topbar__user {
background: var(--bg-surface-2);
}
body[data-role="admin"] .vg-topbar__avatar,
body[data-role="instructor"] .vg-topbar__avatar {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-role="admin"] .vg-iconbtn:hover,
body[data-role="instructor"] .vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w);
background-image: linear-gradient(var(--hair), var(--hair));
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
background: var(--bg-surface);
color: var(--text-strong);
padding: var(--sp-5) var(--sp-3);
}
body[data-role="admin"] .vg-nav__label,
body[data-role="admin"] .vg-nav__ethic,
body[data-role="instructor"] .vg-nav__label,
body[data-role="instructor"] .vg-nav__ethic {
color: var(--text-muted);
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
flex-direction: row;
justify-content: flex-start;
gap: 11px;
min-height: 0;
padding: 9px 12px;
color: var(--text-body);
font-size: 14px;
text-align: left;
}
body[data-role="admin"] .vg-nav__item:hover,
body[data-role="instructor"] .vg-nav__item:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-role="admin"] .vg-nav__item .vg-nav__ic,
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
color: var(--text-muted);
}
body[data-role="admin"] .vg-nav__item.is-active,
body[data-role="instructor"] .vg-nav__item.is-active {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic,
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
color: var(--accent);
}
body[data-role="admin"] .vg-nav__foot,
body[data-role="instructor"] .vg-nav__foot {
border-top-color: var(--hair);
}
@media (max-width: 1024px) {
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w-collapsed);
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
padding: var(--sp-4) var(--sp-2);
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
justify-content: center;
padding: 11px;
}
}
@media (max-width: 720px) {
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
display: block;
background-image: none;
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
position: sticky;
top: var(--topbar-h);
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
border-bottom: 1px solid var(--hair);
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
min-width: 112px;
height: 44px;
justify-content: center;
padding: 0 14px;
gap: 8px;
white-space: nowrap;
}
body[data-role="admin"] .vg-nav__item span:not(.vg-nav__ic),
body[data-role="instructor"] .vg-nav__item span:not(.vg-nav__ic) {
display: inline;
font-size: 13px;
}
}

View file

@ -18,6 +18,7 @@ export type IconName =
| "chevron-right"
| "chevron-left"
| "check"
| "share"
| "mic"
| "mic-off"
| "pause"
@ -93,6 +94,14 @@ const PATHS: Record<IconName, ReactNode> = {
"chevron-right": <polyline points="9 6 15 12 9 18" />,
"chevron-left": <polyline points="15 6 9 12 15 18" />,
check: <polyline points="20 6 9 17 4 12" />,
share: (
<>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.6 10.5 15.4 6.5M8.6 13.5l6.8 4" />
</>
),
mic: (
<>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />