Files
invyone/frontend/components/layout/SettingsModal.tsx
T
gbpark e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select()
  · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스
  · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK)
- 경고시스템/다중경고 버튼을 음성 인식과 동일 톤
  · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트)
  · 정적 (반짝 펄스 애니메이션 제거)
- client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합)
- ui.js 다중 경고 시연 wiring + scada 작업 노트 2건
- 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:39:43 +09:00

201 lines
6.8 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { Check, Moon, Sun, X, PanelLeftClose, PanelLeft, AlignJustify, AlignHorizontalJustifyCenter } from "lucide-react";
import { useColorTheme, COLOR_THEMES, type ColorTheme } from "@/hooks/useColorTheme";
import { animatedThemeChange } from "@/lib/themeTransition";
import { animatedColorChange } from "@/lib/colorTransition";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 트리거 버튼 ref — 외부 클릭 판정 시 anchor 자체는 외부로 보지 않기 위함. */
anchorRef?: React.RefObject<HTMLElement | null>;
sidebarCollapsed?: boolean;
onSidebarCollapsedChange?: (collapsed: boolean) => void;
navOrientation?: "vertical" | "horizontal";
onNavOrientationChange?: (next: "vertical" | "horizontal") => void;
}
/**
* Tweaks panel — 디자인시스템 `Tweaks` (app.jsx) 포팅.
* 중앙 모달 → 헤더 SlidersHorizontal 버튼 anchor popover (2026-04-28 재배치).
* 키워드 "설정" 유지 (파일명/컴포넌트명). 외부 API 는 props.open/onOpenChange 기존과 동일.
*/
export function SettingsModal({
open,
onOpenChange,
anchorRef,
sidebarCollapsed,
onSidebarCollapsedChange,
navOrientation,
onNavOrientationChange,
}: SettingsModalProps) {
const { color, setColor } = useColorTheme();
const { theme, setTheme } = useTheme();
const isDark = theme === "dark";
const panelRef = useRef<HTMLDivElement>(null);
// Escape 로 닫기
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// 패널 바깥 클릭 시 닫기 — anchor 버튼 클릭은 onClick 으로 토글되므로 무시.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (anchorRef?.current?.contains(target)) return;
onOpenChange(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, onOpenChange, anchorRef]);
const handleModeClick = (next: "light" | "dark", e: React.MouseEvent) => {
if (next === theme) return;
animatedThemeChange(next, setTheme, { x: e.clientX, y: e.clientY });
};
const handleColorClick = (id: ColorTheme, e: React.MouseEvent<HTMLButtonElement>) => {
if (id === color) return;
const rect = e.currentTarget.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const meta = COLOR_THEMES.find((c) => c.id === id);
const swatchColor = (isDark ? meta?.dark : meta?.light) ?? "#6c5ce7";
animatedColorChange(id, () => setColor(id), { x: cx, y: cy, color: swatchColor });
};
return (
<div
ref={panelRef}
className={`v5-tweaks-panel${open ? " on" : ""}`}
role="dialog"
aria-label="Tweaks"
aria-hidden={!open}
>
<div className="v5-tweaks-head">
<span>Tweaks</span>
<button
className="v5-hdr-icon"
onClick={() => onOpenChange(false)}
aria-label="Tweaks 닫기"
style={{ width: 24, height: 24, borderRadius: 8 }}
>
<X size={13} />
</button>
</div>
{/* === 테마 컬러 === */}
<div className="v5-tweaks-row">
<label> </label>
<div className="v5-tweaks-swatches">
{COLOR_THEMES.map((c) => {
const swatch = isDark ? c.dark : c.light;
const isActive = color === c.id;
return (
<button
key={c.id}
type="button"
className={`v5-tweaks-swatch${isActive ? " on" : ""}`}
onClick={(e) => handleColorClick(c.id as ColorTheme, e)}
title={c.label}
aria-label={c.label}
style={{ background: swatch }}
>
{isActive && <Check size={11} strokeWidth={3.5} />}
</button>
);
})}
</div>
</div>
{/* === 모드 === */}
<div className="v5-tweaks-row">
<label></label>
<div className="v5-tweaks-seg">
<button
type="button"
className={`v5-btn ${!isDark ? "primary" : "secondary"} sm`}
onClick={(e) => handleModeClick("light", e)}
>
<Sun size={12} />
</button>
<button
type="button"
className={`v5-btn ${isDark ? "primary" : "secondary"} sm`}
onClick={(e) => handleModeClick("dark", e)}
>
<Moon size={12} />
</button>
</div>
</div>
{/* === 메뉴 방향 === */}
{onNavOrientationChange && (
<div className="v5-tweaks-row">
<label> </label>
<div className="v5-tweaks-seg">
<button
type="button"
className={`v5-btn ${navOrientation === "vertical" ? "primary" : "secondary"} sm`}
onClick={() => onNavOrientationChange("vertical")}
>
<AlignJustify size={12} />
</button>
<button
type="button"
className={`v5-btn ${navOrientation === "horizontal" ? "primary" : "secondary"} sm`}
onClick={() => onNavOrientationChange("horizontal")}
>
<AlignHorizontalJustifyCenter size={12} />
</button>
</div>
</div>
)}
{/* === 사이드바 (세로일 때만 의미있음) === */}
{onSidebarCollapsedChange && navOrientation !== "horizontal" && (
<div className="v5-tweaks-row">
<label></label>
<div className="v5-tweaks-seg">
<button
type="button"
className={`v5-btn ${!sidebarCollapsed ? "primary" : "secondary"} sm`}
onClick={() => onSidebarCollapsedChange(false)}
>
<PanelLeft size={12} />
</button>
<button
type="button"
className={`v5-btn ${sidebarCollapsed ? "primary" : "secondary"} sm`}
onClick={() => onSidebarCollapsedChange(true)}
>
<PanelLeftClose size={12} />
</button>
</div>
</div>
)}
<div className="v5-tweaks-foot">
</div>
</div>
);
}