e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
- 음성 인식 (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>
201 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|