diff --git a/src/main/src/index.ts b/src/main/src/index.ts index a1b91f7..08a8067 100644 --- a/src/main/src/index.ts +++ b/src/main/src/index.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron'; +import { app, ipcMain, nativeTheme } from 'electron'; import { appendFile, mkdir } from 'fs/promises'; import { join } from 'path'; import './security-restrictions'; @@ -68,6 +68,10 @@ function setupAPIHandlers() { } }); + /* 시스템 테마 조회 핸들러 */ + ipcMain.handle('theme:getSystemTheme', () => { + return nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; + }); /* Notification 핸들러 설정 */ setupNotificationHandlers(); } diff --git a/src/main/src/security-restrictions.ts b/src/main/src/security-restrictions.ts index 9b89a0f..eebfe6d 100644 --- a/src/main/src/security-restrictions.ts +++ b/src/main/src/security-restrictions.ts @@ -159,4 +159,35 @@ app.on('web-contents-created', (_, contents) => { // Disable Node.js integration webPreferences.nodeIntegration = false; }); + + /** + * Prevent page reload (F5, Ctrl+R, Cmd+R) + * 개발 모드에서는 허용하고 프로덕션에서만 막기 + */ + contents.on('before-input-event', (event, input) => { + // 개발 모드에서는 새로고침 허용 + if (import.meta.env.DEV) { + return; + } + + // F5 또는 새로고침 단축키 (Ctrl+R, Cmd+R) 막기 + if ( + input.key === 'F5' || + (input.key === 'r' && (input.control || input.meta)) + ) { + event.preventDefault(); + } + }); + + /** + * Prevent programmatic reload + * 개발 모드에서는 허용하고 프로덕션에서만 막기 + */ + if (import.meta.env.PROD) { + const originalReload = contents.reload.bind(contents); + contents.reload = () => { + console.warn('Page reload is disabled in production mode'); + // 새로고침 실행하지 않음 + }; + } }); diff --git a/src/preload/exposedInMainWorld.d.ts b/src/preload/exposedInMainWorld.d.ts index 8c61ff9..a23ce86 100644 --- a/src/preload/exposedInMainWorld.d.ts +++ b/src/preload/exposedInMainWorld.d.ts @@ -1,31 +1,5 @@ interface Window { - readonly bugi: { version: number }; - /** - * Safe expose crypto API - * @example - * window.nodeCrypto.sha256sum('data') - */ - readonly nodeCrypto: { sha256sum: (data: string) => Promise }; - /** - * Expose API functionality to renderer - * @example - * window.electronAPI.getHealth() - */ - readonly electronAPI: { - getHealth: () => Promise; - getVersion: () => Promise; - generateHash: (data: string) => Promise; - generateBatchHash: (dataList: string[]) => Promise; - getPlatform: () => Promise; - writeLog: (data: string, filename?: string) => Promise; - widget: { - open: () => Promise; - close: () => Promise; - isOpen: () => Promise; - }; - notification: { - show: (title: string, body: string) => Promise; - requestPermission: () => Promise; - }; - }; + readonly bugi: BugiAPI; + readonly nodeCrypto: NodeCryptoAPI; + readonly electronAPI: ElectronAPI; } diff --git a/src/preload/src/index.ts b/src/preload/src/index.ts index 5cafaa5..007ec6d 100644 --- a/src/preload/src/index.ts +++ b/src/preload/src/index.ts @@ -4,8 +4,80 @@ import { contextBridge, ipcRenderer } from 'electron'; +// Health 응답 타입 (예시) +// TODO: 실제 메인 프로세스에서 보내는 데이터 구조에 맞게 수정 +type HealthResponse = { + status: 'ok'; + uptime: number; +}; + +// Version 응답 타입 (예시) +type VersionInfo = { + appVersion: string; + electron: string; + chrome: string; + node: string; +}; + +// 플랫폼 정보 (예시) +type PlatformInfo = { + os: NodeJS.Platform; + arch: string; +}; + +// 로그 작성 결과 (예시) +type WriteLogResult = { + success: boolean; + path?: string; + error?: string; +}; + +// 시스템 테마 타입 (예시) +type SystemTheme = 'light' | 'dark' | 'system'; + +// window.bugi 타입 +type BugiAPI = { + version: number; +}; + +// window.nodeCrypto 타입 +type NodeCryptoAPI = { + sha256sum: (data: string) => Promise; +}; + +// window.electronAPI 타입 +interface ElectronAPI { + // Health check + getHealth: () => Promise; + + // Version info + getVersion: () => Promise; + + // Hash generation + generateHash: (data: string) => Promise; + + // Batch hash generation + generateBatchHash: (dataList: string[]) => Promise; + + // Platform info + getPlatform: () => Promise; + + // Write log file + writeLog: (data: string, filename?: string) => Promise; + + widget: { + open: () => Promise; + close: () => Promise; + isOpen: () => Promise; + }; + + // 시스템 테마 조회 + getSystemTheme: () => Promise; +} + // Expose version number to renderer -contextBridge.exposeInMainWorld('bugi', { version: 0.1 }); +const bugiAPI: BugiAPI = { version: 0.1 }; +contextBridge.exposeInMainWorld('bugi', bugiAPI); /** * The "Main World" is the JavaScript context that your main renderer code runs in. @@ -28,7 +100,7 @@ contextBridge.exposeInMainWorld('bugi', { version: 0.1 }); * @example * window.nodeCrypto.sha256sum('data') */ -contextBridge.exposeInMainWorld('nodeCrypto', { +const nodeCryptoAPI: NodeCryptoAPI = { sha256sum: async (data: string) => { const encoder = new TextEncoder(); const dataBuffer = encoder.encode(data); @@ -36,47 +108,67 @@ contextBridge.exposeInMainWorld('nodeCrypto', { const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); }, -}); +}; +contextBridge.exposeInMainWorld('nodeCrypto', nodeCryptoAPI); /** * Expose API functionality to renderer * @example * window.electronAPI.getHealth() */ -contextBridge.exposeInMainWorld('electronAPI', { +const electronAPI: ElectronAPI = { // Health check - getHealth: () => ipcRenderer.invoke('api:health'), + getHealth: () => + ipcRenderer.invoke('api:health') as ReturnType, // Version info - getVersion: () => ipcRenderer.invoke('api:version'), + getVersion: () => + ipcRenderer.invoke('api:version') as ReturnType, // Hash generation - generateHash: (data: string) => ipcRenderer.invoke('api:hash', data), + generateHash: (data: string) => + ipcRenderer.invoke('api:hash', data) as ReturnType< + ElectronAPI['generateHash'] + >, // Batch hash generation generateBatchHash: (dataList: string[]) => - ipcRenderer.invoke('api:hash:batch', dataList), + ipcRenderer.invoke('api:hash:batch', dataList) as ReturnType< + ElectronAPI['generateBatchHash'] + >, // Platform info - getPlatform: () => ipcRenderer.invoke('api:platform'), + getPlatform: () => + ipcRenderer.invoke('api:platform') as ReturnType< + ElectronAPI['getPlatform'] + >, // Write log file writeLog: (data: string, filename?: string) => - ipcRenderer.invoke('api:writeLog', data, filename), + ipcRenderer.invoke('api:writeLog', data, filename) as ReturnType< + ElectronAPI['writeLog'] + >, /*electronAPI 객체에 widget 추가해서 리액트에서 접근 가능하도록 설정(리액트와 main process의 다리 역할) */ widget: { - open: () => ipcRenderer.invoke('widget:open'), - close: () => ipcRenderer.invoke('widget:close'), - isOpen: () => ipcRenderer.invoke('widget:isOpen'), + open: () => + ipcRenderer.invoke('widget:open') as ReturnType< + ElectronAPI['widget']['open'] + >, + close: () => + ipcRenderer.invoke('widget:close') as ReturnType< + ElectronAPI['widget']['close'] + >, + isOpen: () => + ipcRenderer.invoke('widget:isOpen') as ReturnType< + ElectronAPI['widget']['isOpen'] + >, }, - /* 시스템 알림 기능 */ - notification: { - show: (title: string, body: string) => - ipcRenderer.invoke('notification:show', title, body), - /* 알림 권한 요청 */ - requestPermission: () => - ipcRenderer.invoke('notification:requestPermission'), - }, -}); + // 시스템 테마 조회 + getSystemTheme: () => + ipcRenderer.invoke('theme:getSystemTheme') as ReturnType< + ElectronAPI['getSystemTheme'] + >, +}; +contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/src/assets/widget.svg b/src/renderer/src/assets/widget.svg index 9a2b6e8..c88ea3b 100644 --- a/src/renderer/src/assets/widget.svg +++ b/src/renderer/src/assets/widget.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/renderer/src/components/Timer/Timer.tsx b/src/renderer/src/components/Timer/Timer.tsx index 35178cd..0d18904 100644 --- a/src/renderer/src/components/Timer/Timer.tsx +++ b/src/renderer/src/components/Timer/Timer.tsx @@ -2,30 +2,23 @@ type CountdownValue = 0 | 1 | 2 | 3 | 4 | 5; type Props = { value: CountdownValue; // 5 -> 4 -> 3 -> 2 -> 1 -> 0 - size?: number; // 기본 100 - on?: string; // 하이라이트 색 (기본 #FFBF00) - off?: string; // 비활성 색 (기본 white) + size?: number; // 기본 64 + on?: string; // 하이라이트 색 (기본 yellow-500) + off?: string; // 비활성 색 (기본 grey-50) }; const SEG_KEYS = ['LT', 'TOP', 'LB', 'RT', 'RB'] as const; type SegmentKey = (typeof SEG_KEYS)[number]; -/** - * 각 숫자에서 노란색으로 켜질 세그먼트 매핑 - * - LT: 좌상(왼쪽 위 곡선) - * - TOP: 상단 곡선 - * - LB: 좌하(왼쪽 아래 곡선) - * - RT: 우상(오른쪽 위 곡선) - * - RB: 우하(오른쪽 아래 곡선) - * - * 원본 SVG들을 기준으로 매핑함: - * 5: 모두 흰색 - * 4: LB만 노랑 - * 3: LT, TOP, LB 노랑 - * 2: LT, LB 노랑 - * 1: LT, TOP, LB, RT 노랑 (RB만 흰색) - * 0: 전부 노랑 - */ +// Tailwind 기준 (CSS 변수 사용해 다크/라이트 테마 자동 대응) +const DEFAULT_ON = 'var(--color-yellow-500)'; +const DEFAULT_OFF = 'var(--color-grey-50)'; + +// 배경 대비 세그먼트+숫자 확대 비율 +const SEG_SCALE = 1.6; +// 세그먼트+숫자를 살짝 위로 올리기 위한 오프셋 (뷰박스 기준) +const SEG_Y_OFFSET = -4; // 값 더 줄이면 위로 더 올라감 + const activeMap: Record = { 5: [], 4: ['LB'], @@ -35,38 +28,24 @@ const activeMap: Record = { 0: ['LT', 'TOP', 'LB', 'RT', 'RB'], }; -// 각 숫자 중앙 글리프(path) — 원본 그대로 (축약/가공 없음) -const centerPathMap: Record = { - 5: { - d: 'M50.0215 62.2148C48.9974 62.2148 48.0736 62.0251 47.25 61.6455C46.4264 61.266 45.7747 60.7432 45.2949 60.0771C44.8223 59.404 44.5716 58.6413 44.543 57.7891H47.2715C47.3001 58.2044 47.4362 58.5768 47.6797 58.9062C47.9303 59.2357 48.2598 59.4935 48.668 59.6797C49.0833 59.8659 49.5345 59.959 50.0215 59.959C50.5944 59.959 51.1064 59.8301 51.5576 59.5723C52.016 59.3145 52.3704 58.96 52.6211 58.5088C52.8789 58.0505 53.0078 57.5384 53.0078 56.9727C53.0078 56.3854 52.8753 55.8626 52.6104 55.4043C52.3454 54.9388 51.9801 54.5771 51.5146 54.3193C51.0492 54.0544 50.5228 53.9219 49.9355 53.9219C48.7038 53.9004 47.8444 54.3444 47.3574 55.2539H44.7578L45.7031 46.4453H54.8984V48.7871H48.002L47.5293 52.9121H47.6797C47.9876 52.5612 48.4102 52.2783 48.9473 52.0635C49.4844 51.8415 50.0645 51.7305 50.6875 51.7305C51.64 51.7305 52.4993 51.9525 53.2656 52.3965C54.0319 52.8405 54.6299 53.4564 55.0596 54.2441C55.4964 55.0247 55.7148 55.9056 55.7148 56.8867C55.7148 57.9108 55.4714 58.8275 54.9844 59.6367C54.5046 60.446 53.8314 61.0798 52.9648 61.5381C52.1055 61.9893 51.1243 62.2148 50.0215 62.2148Z', - }, - 4: { - d: 'M43.3555 59.8359V57.3984L50.6914 46.0312H54.4883V57.3281H56.7148V59.8359H54.4883V63H51.582V59.8359H43.3555ZM46.4727 57.3281H51.6289V49.5H51.4414L46.4727 57.2109V57.3281Z', - }, - 3: { - d: 'M49.9219 63.2214C46.2891 63.2214 43.7109 61.2527 43.6406 58.3933H46.7109C46.8047 59.7527 48.1641 60.6433 49.9219 60.6433C51.7969 60.6433 53.1328 59.6121 53.1328 58.1121C53.1328 56.5886 51.8203 55.4871 49.6172 55.4871H48.0938V53.1433H49.6172C51.4219 53.1433 52.6875 52.1355 52.6641 50.6589C52.6875 49.2292 51.6094 48.2683 49.9453 48.2683C48.3516 48.2683 46.9688 49.1589 46.9219 50.5886H43.9922C44.0625 47.7292 46.6406 45.7839 49.9688 45.7839C53.4141 45.7839 55.6406 47.8933 55.6172 50.4949C55.6406 52.3933 54.375 53.7761 52.5234 54.1511V54.2917C54.9141 54.6199 56.2734 56.1433 56.25 58.2761C56.2734 61.1355 53.6016 63.2214 49.9219 63.2214Z', - }, - 2: { - d: 'M44.6758 63V60.7969L50.6758 55.0078C52.3867 53.2969 53.2773 52.2891 53.2773 50.8594C53.2773 49.2891 52.0352 48.2812 50.3711 48.2812C48.6133 48.2812 47.4883 49.3828 47.5117 51.0938H44.6055C44.582 47.8828 46.9961 45.7969 50.3945 45.7969C53.8633 45.7969 56.207 47.8594 56.207 50.7188C56.207 52.6406 55.2695 54.1875 51.9414 57.3281L48.918 60.3281V60.4453H56.4648V63H44.6758Z', - }, - 1: { - d: 'M52.7227 46.0184V62.9872H49.6523V48.995H49.5586L45.5977 51.5262V48.7372L49.793 46.0184H52.7227Z', - }, - 0: { - d: 'M50 62.2344C48.6406 62.2344 47.4727 61.8945 46.4961 61.2148C45.5273 60.5273 44.7812 59.5312 44.2578 58.2266C43.7422 56.9219 43.4844 55.3516 43.4844 53.5156C43.4844 51.6875 43.7422 50.1211 44.2578 48.8164C44.7812 47.5039 45.5312 46.5078 46.5078 45.8281C47.4844 45.1406 48.6484 44.7969 50 44.7969C51.3438 44.7969 52.5039 45.1406 53.4805 45.8281C54.4648 46.5078 55.2148 47.5039 55.7305 48.8164C56.2539 50.1211 56.5156 51.6875 56.5156 53.5156C56.5156 55.3516 56.2539 56.9219 55.7305 58.2266C55.2148 59.5312 54.4688 60.5273 53.4922 61.2148C52.5234 61.8945 51.3594 62.2344 50 62.2344ZM50 59.6797C50.7188 59.6797 51.332 59.4492 51.8398 58.9883C52.3555 58.5195 52.75 57.8281 53.0234 56.9141C53.3047 55.9922 53.4453 54.8594 53.4453 53.5156C53.4453 52.1875 53.3047 51.0625 53.0234 50.1406C52.75 49.2109 52.3555 48.5117 51.8398 48.043C51.3242 47.5664 50.7109 47.3281 50 47.3281C49.2891 47.3281 48.6758 47.5664 48.1602 48.043C47.6445 48.5117 47.2461 49.2109 46.9648 50.1406C46.6914 51.0625 46.5547 52.1875 46.5547 53.5156C46.5547 54.8594 46.6914 55.9922 46.9648 56.9141C47.2461 57.8281 47.6406 58.5195 48.1484 58.9883C48.6641 59.4492 49.2812 59.6797 50 59.6797Z', - fill: '#FFBF00', - }, +const centerPathMap: Record = { + 5: 'M50.0215 62.2148C48.9974 62.2148 48.0736 62.0251 47.25 61.6455C46.4264 61.266 45.7747 60.7432 45.2949 60.0771C44.8223 59.404 44.5716 58.6413 44.543 57.7891H47.2715C47.3001 58.2044 47.4362 58.5768 47.6797 58.9062C47.9303 59.2357 48.2598 59.4935 48.668 59.6797C49.0833 59.8659 49.5345 59.959 50.0215 59.959C50.5944 59.959 51.1064 59.8301 51.5576 59.5723C52.016 59.3145 52.3704 58.96 52.6211 58.5088C52.8789 58.0505 53.0078 57.5384 53.0078 56.9727C53.0078 56.3854 52.8753 55.8626 52.6104 55.4043C52.3454 54.9388 51.9801 54.5771 51.5146 54.3193C51.0492 54.0544 50.5228 53.9219 49.9355 53.9219C48.7038 53.9004 47.8444 54.3444 47.3574 55.2539H44.7578L45.7031 46.4453H54.8984V48.7871H48.002L47.5293 52.9121H47.6797C47.9876 52.5612 48.4102 52.2783 48.9473 52.0635C49.4844 51.8415 50.0645 51.7305 50.6875 51.7305C51.64 51.7305 52.4993 51.9525 53.2656 52.3965C54.0319 52.8405 54.6299 53.4564 55.0596 54.2441C55.4964 55.0247 55.7148 55.9056 55.7148 56.8867C55.7148 57.9108 55.4714 58.8275 54.9844 59.6367C54.5046 60.446 53.8314 61.0798 52.9648 61.5381C52.1055 61.9893 51.1243 62.2148 50.0215 62.2148Z', + 4: 'M43.3555 59.8359V57.3984L50.6914 46.0312H54.4883V57.3281H56.7148V59.8359H54.4883V63H51.582V59.8359H43.3555ZM46.4727 57.3281H51.6289V49.5H51.4414L46.4727 57.2109V57.3281Z', + 3: 'M49.9219 63.2214C46.2891 63.2214 43.7109 61.2527 43.6406 58.3933H46.7109C46.8047 59.7527 48.1641 60.6433 49.9219 60.6433C51.7969 60.6433 53.1328 59.6121 53.1328 58.1121C53.1328 56.5886 51.8203 55.4871 49.6172 55.4871H48.0938V53.1433H49.6172C51.4219 53.1433 52.6875 52.1355 52.6641 50.6589C52.6875 49.2292 51.6094 48.2683 49.9453 48.2683C48.3516 48.2683 46.9688 49.1589 46.9219 50.5886H43.9922C44.0625 47.7292 46.6406 45.7839 49.9688 45.7839C53.4141 45.7839 55.6406 47.8933 55.6172 50.4949C55.6406 52.3933 54.375 53.7761 52.5234 54.1511V54.2917C54.9141 54.6199 56.2734 56.1433 56.25 58.2761C56.2734 61.1355 53.6016 63.2214 49.9219 63.2214Z', + 2: 'M44.6758 63V60.7969L50.6758 55.0078C52.3867 53.2969 53.2773 52.2891 53.2773 50.8594C53.2773 49.2891 52.0352 48.2812 50.3711 48.2812C48.6133 48.2812 47.4883 49.3828 47.5117 51.0938H44.6055C44.582 47.8828 46.9961 45.7969 50.3945 45.7969C53.8633 45.7969 56.207 47.8594 56.207 50.7188C56.207 52.6406 55.2695 54.1875 51.9414 57.3281L48.918 60.3281V60.4453H56.4648V63H44.6758Z', + 1: 'M52.7227 46.0184V62.9872H49.6523V48.995H49.5586L45.5977 51.5262V48.7372L49.793 46.0184H52.7227Z', + 0: 'M50 62.2344C48.6406 62.2344 47.4727 61.8945 46.4961 61.2148C45.5273 60.5273 44.7812 59.5312 44.2578 58.2266C43.7422 56.9219 43.4844 55.3516 43.4844 53.5156C43.4844 51.6875 43.7422 50.1211 44.2578 48.8164C44.7812 47.5039 45.5312 46.5078 46.5078 45.8281C47.4844 45.1406 48.6484 44.7969 50 44.7969C51.3438 44.7969 52.5039 45.1406 53.4805 45.8281C54.4648 46.5078 55.2148 47.5039 55.7305 48.8164C56.2539 50.1211 56.5156 51.6875 56.5156 53.5156C56.5156 55.3516 56.2539 56.9219 55.7305 58.2266C55.2148 59.5312 54.4688 60.5273 53.4922 61.2148C52.5234 61.8945 51.3594 62.2344 50 62.2344ZM50 59.6797C50.7188 59.6797 51.332 59.4492 51.8398 58.9883C52.3555 58.5195 52.75 57.8281 53.0234 56.9141C53.3047 55.9922 53.4453 54.8594 53.4453 53.5156C53.4453 52.1875 53.3047 51.0625 53.0234 50.1406C52.75 49.2109 52.3555 48.5117 51.8398 48.043C51.3242 47.5664 50.7109 47.3281 50 47.3281C49.2891 47.3281 48.6758 47.5664 48.1602 48.043C47.6445 48.5117 47.2461 49.2109 46.9648 50.1406C46.6914 51.0625 46.5547 52.1875 46.5547 53.5156C46.5547 54.8594 46.6914 55.9922 46.9648 56.9141C47.2461 57.8281 47.6406 58.5195 48.1484 58.9883C48.6641 59.4492 49.2812 59.6797 50 59.6797Z', }; const Timer = function Timer({ value, - size = 100, - on = '#FFBF00', - off = 'white', + size = 64, + on = DEFAULT_ON, + off = DEFAULT_OFF, }: Props) { const active = new Set(activeMap[value]); const stroke = (seg: SegmentKey) => (active.has(seg) ? on : off); - const center = centerPathMap[value]; + const centerD = centerPathMap[value]; return ( - {/* 공통 5개 곡선 세그먼트 */} - - - - - - - - - - - - - - - + + {/* 흰 동그라미 배경 */} + - {/* 중앙 숫자/원 */} - - - - - {/* 공통 필터들(원본에서 이름만 단순화) */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + {/* 중앙 숫자 */} + + + + + + + + ); diff --git a/src/renderer/src/components/pose-detection/PostureStabilizer.ts b/src/renderer/src/components/pose-detection/PostureStabilizer.ts index cc2a86a..79c0835 100644 --- a/src/renderer/src/components/pose-detection/PostureStabilizer.ts +++ b/src/renderer/src/components/pose-detection/PostureStabilizer.ts @@ -49,7 +49,6 @@ export class PostureStabilizer { } // 현재 점수는 이미 버퍼에 추가되어 있으므로, 이전 점수들만으로 평균 계산 - // 버퍼의 마지막 항목이 현재 점수이므로, 그 이전 항목들의 평균을 계산 const previousScores = this.scoreBuffer.slice(0, -1); // 이전 점수가 없으면 (버퍼에 현재 점수만 있으면) 업데이트 허용 @@ -57,10 +56,11 @@ export class PostureStabilizer { return true; } - // 이전 점수들의 평균 계산 - const averageScore = - previousScores.reduce((sum, entry) => sum + entry.score, 0) / - previousScores.length; + const currentEntry = this.scoreBuffer[this.scoreBuffer.length - 1]; + const averageScore = this.calculateWeightedAverage( + previousScores, + currentEntry.timestamp, + ); // 현재 프레임과 이전 점수들의 평균의 오차 계산 const scoreDifference = Math.abs(currentScore - averageScore); @@ -89,10 +89,10 @@ export class PostureStabilizer { } { // 현재 점수를 제외한 이전 점수들의 평균 계산 const previousScores = this.scoreBuffer.slice(0, -1); + const currentEntry = this.scoreBuffer[this.scoreBuffer.length - 1]; const averageScore = previousScores.length > 0 - ? previousScores.reduce((sum, entry) => sum + entry.score, 0) / - previousScores.length + ? this.calculateWeightedAverage(previousScores, currentEntry.timestamp) : currentScore; const scoreDifference = Math.abs(currentScore - averageScore); const shouldUpdate = this.shouldUpdate(currentScore); @@ -113,4 +113,36 @@ export class PostureStabilizer { public reset(): void { this.scoreBuffer = []; } + + private calculateWeightedAverage( + entries: Array<{ score: number; timestamp: number }>, + currentTimestamp: number, + ): number { + if (entries.length === 0) { + return 0; + } + + let weightedSum = 0; + let totalWeight = 0; + + for (const entry of entries) { + const elapsed = currentTimestamp - entry.timestamp; + const weight = Math.max(0, 1 - elapsed / this.windowMs); + + if (weight <= 0) { + continue; + } + + weightedSum += entry.score * weight; + totalWeight += weight; + } + + if (totalWeight === 0) { + return ( + entries.reduce((sum, entry) => sum + entry.score, 0) / entries.length + ); + } + + return weightedSum / totalWeight; + } } diff --git a/src/renderer/src/hooks/useThemePreference.ts b/src/renderer/src/hooks/useThemePreference.ts new file mode 100644 index 0000000..b508e1b --- /dev/null +++ b/src/renderer/src/hooks/useThemePreference.ts @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; + +type UseThemePreferenceReturn = [boolean, (value: boolean) => void]; + +const THEME_STORAGE_KEY = 'theme'; + +export function useThemePreference(): UseThemePreferenceReturn { + const isApplyingSystemTheme = useRef(false); + + const [isDark, setIsDark] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + + const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + if (savedTheme === 'dark' || savedTheme === 'light') { + return savedTheme === 'dark'; + } + + return false; + }); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + + if (!savedTheme && window.electronAPI?.getSystemTheme) { + isApplyingSystemTheme.current = true; + window.electronAPI + .getSystemTheme() + .then((systemTheme: 'dark' | 'light') => { + const shouldBeDark = systemTheme === 'dark'; + setIsDark(shouldBeDark); + + if (shouldBeDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + setTimeout(() => { + isApplyingSystemTheme.current = false; + }, 0); + }) + .catch((error: unknown) => { + console.error('시스템 테마 조회 실패:', error); + isApplyingSystemTheme.current = false; + }); + } else if (savedTheme) { + if (savedTheme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + }, []); + + useEffect(() => { + if (isDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + if (!isApplyingSystemTheme.current) { + window.localStorage.setItem(THEME_STORAGE_KEY, isDark ? 'dark' : 'light'); + } + }, [isDark]); + + return [isDark, setIsDark]; +} + diff --git a/src/renderer/src/hooks/useWidget.ts b/src/renderer/src/hooks/useWidget.ts new file mode 100644 index 0000000..686133f --- /dev/null +++ b/src/renderer/src/hooks/useWidget.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; + +/** + * 위젯 창 상태를 관리하는 훅 + * 위젯 창의 열림/닫힘 상태를 추적하고 토글 기능을 제공합니다. + * + * @example + * ```tsx + * const { isWidgetOpen, toggleWidget } = useWidget(); + * + * + * ``` + */ +export const useWidget = () => { + const [isWidgetOpen, setIsWidgetOpen] = useState(false); + + // 위젯 창 상태 주기적 확인 (1초마다) + useEffect(() => { + const checkWidgetStatus = async () => { + if (window.electronAPI?.widget) { + const isOpen = await window.electronAPI.widget.isOpen(); + setIsWidgetOpen(isOpen); + } + }; + + checkWidgetStatus(); + const interval = setInterval(checkWidgetStatus, 1000); + return () => clearInterval(interval); + }, []); + + // 위젯 로그 저장 헬퍼 함수 + const logWidgetEvent = async (event: 'widget_opened' | 'widget_closed') => { + if (window.electronAPI?.writeLog) { + try { + const logData = JSON.stringify({ + event, + timestamp: new Date().toISOString(), + }); + await window.electronAPI.writeLog(logData); + } catch (error) { + console.error( + `위젯 ${event === 'widget_opened' ? '열림' : '닫힘'} 로그 저장 실패:`, + error, + ); + } + } + }; + + // 위젯 토글 함수 + const toggleWidget = async () => { + try { + if (window.electronAPI?.widget) { + if (isWidgetOpen) { + await window.electronAPI.widget.close(); + setIsWidgetOpen(false); + console.log('위젯 창이 닫혔습니다'); + await logWidgetEvent('widget_closed'); + } else { + await window.electronAPI.widget.open(); + setIsWidgetOpen(true); + console.log('위젯 창이 열렸습니다'); + await logWidgetEvent('widget_opened'); + } + } + } catch (error) { + console.error('위젯 창 토글 실패:', error); + } + }; + + return { + isWidgetOpen, + toggleWidget, + }; +}; diff --git a/src/renderer/src/layout/Header/Header.tsx b/src/renderer/src/layout/Header/Header.tsx index 4e673ee..1760b47 100644 --- a/src/renderer/src/layout/Header/Header.tsx +++ b/src/renderer/src/layout/Header/Header.tsx @@ -1,24 +1,10 @@ -import { useEffect, useState } from 'react'; import Logo from '../../assets/logo.svg?react'; import Symbol from '../../assets/symbol.svg?react'; import { ThemeToggleSwitch } from '../../components/ThemeToggleSwitch/ThemeToggleSwitch'; +import { useThemePreference } from '../../hooks/useThemePreference'; const Header = () => { - const [isDark, setIsDark] = useState(() => { - // localStorage에 값이 있으면 true/false로 변환해서 사용 - return localStorage.getItem('theme') === 'dark'; - }); - - //테마 상태가 바뀔 때마다 HTML 클래스 + localStorage 업데이트 - useEffect(() => { - if (isDark) { - document.documentElement.classList.add('dark'); - localStorage.setItem('theme', 'dark'); // 다크모드 기억 - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('theme', 'light'); // 라이트모드 기억 - } - }, [isDark]); + const [isDark, setIsDark] = useThemePreference(); return (
diff --git a/src/renderer/src/pages/Calibration/components/WebcamView.tsx b/src/renderer/src/pages/Calibration/components/WebcamView.tsx index d32dfd2..4432a54 100644 --- a/src/renderer/src/pages/Calibration/components/WebcamView.tsx +++ b/src/renderer/src/pages/Calibration/components/WebcamView.tsx @@ -45,6 +45,20 @@ const WebcamView = ({ height: 428, }); + // 초기 마운트 시 container 크기로 초기화 + useEffect(() => { + const container = containerRef.current; + if (container) { + const { clientWidth, clientHeight } = container; + if (clientWidth > 0 && clientHeight > 0) { + setVideoDimensions({ + width: clientWidth, + height: clientHeight, + }); + } + } + }, []); + const { cameraState, setShow } = useCameraStore(); const isWebcamOn = cameraState === 'show'; @@ -53,15 +67,15 @@ const WebcamView = ({ const videoConstraints = preferredDeviceId ? { - deviceId: { exact: preferredDeviceId }, - width: 1000, - height: 563, - } + deviceId: { exact: preferredDeviceId }, + width: 1000, + height: 563, + } : { - facingMode: 'user', - width: 1000, - height: 563, - }; + facingMode: 'user', + width: 1000, + height: 563, + }; const handlePoseDetected = ( landmarks: PoseLandmark[], @@ -95,6 +109,7 @@ const WebcamView = ({ } }; + // 카메라 스트림 정리 useEffect(() => { if (cameraState === 'hide' || cameraState === 'exit') { if ( @@ -109,20 +124,78 @@ const WebcamView = ({ } }, [cameraState]); + // containerRef 크기 변경 감지 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + setVideoDimensions((prev) => { + // 카메라가 켜져있을 때는 실제 비디오 크기를 우선 사용 + if (cameraState === 'show' && webcamRef.current?.video) { + const video = webcamRef.current.video; + return { + width: video.videoWidth || width, + height: video.videoHeight || height, + }; + } + // 카메라가 꺼져있을 때는 container 크기 사용 + return { + width, + height, + }; + }); + } + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [cameraState]); + + // 비디오 요소 크기 변경 감지 (카메라가 켜져있을 때) + useEffect(() => { + if (cameraState !== 'show') return; + + const video = webcamRef.current?.video; + if (!video) return; + + const handleResize = () => { + if (video.videoWidth > 0 && video.videoHeight > 0) { + setVideoDimensions({ + width: video.videoWidth, + height: video.videoHeight, + }); + } + }; + + video.addEventListener('loadedmetadata', handleResize); + video.addEventListener('resize', handleResize); + + return () => { + video.removeEventListener('loadedmetadata', handleResize); + video.removeEventListener('resize', handleResize); + }; + }, [cameraState]); + return ( -
+
{cameraState === 'show' ? (
{showPoseOverlay && detectedLandmarks.length > 0 && (
)} diff --git a/src/renderer/src/pages/Main/components/AttendacePanel.tsx b/src/renderer/src/pages/Main/components/AttendacePanel.tsx index fe20625..74ba183 100644 --- a/src/renderer/src/pages/Main/components/AttendacePanel.tsx +++ b/src/renderer/src/pages/Main/components/AttendacePanel.tsx @@ -8,6 +8,37 @@ import { ToggleSwitch } from '../../../components/ToggleSwitch/ToggleSwitch'; type CalendarProps = { year: number; month: number }; // month: 0~11 +interface CircleProps { + level: number; // 1~5 + today: boolean; +} + +const LEVEL_COLORS = [ + 'bg-yellow-500', // 1레벨 + 'bg-yellow-400', // 2레벨 + 'bg-yellow-200', // 3레벨 + 'bg-yellow-100', // 4레벨 + 'bg-yellow-50', // 5레벨 +] as const; + +const Circle = ({ level, today }: CircleProps) => { + // 혹시 level이 1~5를 벗어나면 안전하게 클램프 + const clampedLevel = Math.min(Math.max(level, 1), LEVEL_COLORS.length); + const colorClass = LEVEL_COLORS[clampedLevel - 1]; + + return ( +
+ ); +}; + const Calendar = ({ year, month }: CalendarProps) => { const days = ['일', '월', '화', '수', '목', '금', '토']; @@ -24,6 +55,32 @@ const Calendar = ({ year, month }: CalendarProps) => { ...(Array(trailing).fill(null) as (number | null)[]), ]; + // 오늘 정보 + const today = new Date(); + const todayYear = today.getFullYear(); + const todayMonth = today.getMonth(); + const todayDate = today.getDate(); + + const isSameMonth = todayYear === year && todayMonth === month; + + // 🔥 레벨/사용 여부는 실제 데이터 들어오면 여기만 갈아끼우면 됨 + const getLevelForDay = (day: number): number | null => { + // 예시: 4의 배수 날짜는 "안 사용한 날"이라고 가정해서 null 리턴 + if (day % 4 === 0) return null; + + // 그 외에는 1~5 레벨 순환 + return ((day - 1) % LEVEL_COLORS.length) + 1; + }; + + const isFutureDay = (day: number) => { + // 같은 달 기준으로 오늘 이후 + if (year > todayYear) return true; + if (year === todayYear && month > todayMonth) return true; + if (year === todayYear && month === todayMonth && day > todayDate) + return true; + return false; + }; + return (
{/* 요일 헤더 */} @@ -39,9 +96,30 @@ const Calendar = ({ year, month }: CalendarProps) => {
{calendarDays.map((day, index) => (
- {day !== null && ( -
- )} + {day !== null && + (() => { + const future = isFutureDay(day); + const isToday = isSameMonth && day === todayDate; + + if (future) { + // 👉 미래 날짜: bg-transparent border-bg-line + return ( +
+ ); + } + + const level = getLevelForDay(day); + + if (!level) { + // 👉 안 사용한 날: bg-grey-50 + return ( +
+ ); + } + + // 👉 사용한 날: 레벨 색 Circle + return ; + })()}
))}
@@ -104,7 +182,7 @@ const AttendacePanel = () => { uncheckedLabel="월간" checkedLabel="연간" checked={false} - onChange={() => {}} + onChange={() => { }} />
diff --git a/src/renderer/src/pages/Main/components/MainHeader.tsx b/src/renderer/src/pages/Main/components/MainHeader.tsx index e68b983..1d62821 100644 --- a/src/renderer/src/pages/Main/components/MainHeader.tsx +++ b/src/renderer/src/pages/Main/components/MainHeader.tsx @@ -1,13 +1,13 @@ import DashboardIcon from '@assets/dashboard.svg?react'; -import PlanIcon from '@assets/plan.svg?react'; -import NotificationIcon from '../../../assets/main/bell_icon.svg?react'; import SettingIcon from '@assets/setting.svg?react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import Logo from '../../../assets/logo.svg?react'; +import NotificationIcon from '../../../assets/main/bell_icon.svg?react'; import Symbol from '../../../assets/symbol.svg?react'; import { Button } from '../../../components/Button/Button'; import { ThemeToggleSwitch } from '../../../components/ThemeToggleSwitch/ThemeToggleSwitch'; +import { useThemePreference } from '../../../hooks/useThemePreference'; import { cn } from '../../../utils/cn'; type TabType = 'dashboard' | 'plan' | 'settings'; @@ -17,28 +17,12 @@ interface MainHeaderProps { } const MainHeader = ({ onClickNotification }: MainHeaderProps) => { - const [isDark, setIsDark] = useState(() => { - // SSR 안전 가드 - if (typeof window === 'undefined') return false; - return localStorage.getItem('theme') === 'dark'; - }); - - // 테마 상태가 바뀔 때마다 HTML 클래스 + localStorage 업데이트 - useEffect(() => { - if (isDark) { - document.documentElement.classList.add('dark'); - localStorage.setItem('theme', 'dark'); // 다크모드 기억 - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('theme', 'light'); // 라이트모드 기억 - } - }, [isDark]); + const [isDark, setIsDark] = useThemePreference(); const [activeTab, setActiveTab] = useState('dashboard'); const tabs = [ { id: 'dashboard' as TabType, label: '대시보드', icon: DashboardIcon }, - { id: 'plan' as TabType, label: '계획', icon: PlanIcon }, { id: 'settings' as TabType, label: '설정', icon: SettingIcon }, ]; diff --git a/src/renderer/src/pages/Main/components/RunningPanel.tsx b/src/renderer/src/pages/Main/components/RunningPanel.tsx index c9954a7..836a50f 100644 --- a/src/renderer/src/pages/Main/components/RunningPanel.tsx +++ b/src/renderer/src/pages/Main/components/RunningPanel.tsx @@ -5,16 +5,14 @@ import PmRiniVideo from '@assets/video/pm-rini.webm'; import RiniVideo from '@assets/video/rini.webm'; import StoneBugiVideo from '@assets/video/stone-bugi.webm'; import TireBugiVideo from '@assets/video/tire-bugi.webm'; -import WidgetIcon from '@assets/widget.svg?react'; -import { useEffect, useMemo, useState } from 'react'; -import { Button } from '../../../components/Button/Button'; + +import { useEffect, useMemo } from 'react'; import { usePostureStore } from '../../../store/usePostureStore'; import { cn } from '../../../utils/cn'; import { getScoreLevel } from '../../../utils/getScoreLevel'; const RunningPanel = () => { const score = usePostureStore((state) => state.score); - const [isWidgetOpen, setIsWidgetOpen] = useState(false); // 점수 기반 레벨 계산 const levelInfo = useMemo(() => getScoreLevel(score), [score]); @@ -81,7 +79,6 @@ const RunningPanel = () => { const checkWidgetStatus = async () => { if (window.electronAPI?.widget) { const isOpen = await window.electronAPI.widget.isOpen(); - setIsWidgetOpen(isOpen); } }; @@ -93,65 +90,11 @@ const RunningPanel = () => { }, []); // 위젯 열기/닫기 핸들러 - const handleToggleWidget = async () => { - try { - if (window.electronAPI?.widget) { - if (isWidgetOpen) { - await window.electronAPI.widget.close(); - setIsWidgetOpen(false); - console.log('위젯 창이 닫혔습니다'); - - // 위젯 닫힘 로그 저장 - if (window.electronAPI?.writeLog) { - try { - const logData = JSON.stringify({ - event: 'widget_closed', - timestamp: new Date().toISOString(), - }); - await window.electronAPI.writeLog(logData); - } catch (error) { - console.error('위젯 닫힘 로그 저장 실패:', error); - } - } - } else { - await window.electronAPI.widget.open(); - setIsWidgetOpen(true); - console.log('위젯 창이 열렸습니다'); - - // 위젯 열림 로그 저장 - if (window.electronAPI?.writeLog) { - try { - const logData = JSON.stringify({ - event: 'widget_opened', - timestamp: new Date().toISOString(), - }); - await window.electronAPI.writeLog(logData); - } catch (error) { - console.error('위젯 열림 로그 저장 실패:', error); - } - } - } - } - } catch (error) { - console.error('위젯 창 토글 실패:', error); - } - }; return (

{runningStatus}

-
- } - />
diff --git a/src/renderer/src/pages/Main/components/WebcamPanel.tsx b/src/renderer/src/pages/Main/components/WebcamPanel.tsx index 04ac4b6..279bcaf 100644 --- a/src/renderer/src/pages/Main/components/WebcamPanel.tsx +++ b/src/renderer/src/pages/Main/components/WebcamPanel.tsx @@ -1,16 +1,18 @@ -import { Button } from '../../../components'; -import ShowIcon from '@assets/show.svg?react'; import HideIcon from '@assets/hide.svg?react'; +import ShowIcon from '@assets/show.svg?react'; +import WidgetIcon from '@assets/widget.svg?react'; +import { useCreateSessionMutation } from '../../../api/session/useCreateSessionMutation'; +import { usePauseSessionMutation } from '../../../api/session/usePauseSessionMutation'; +import { useResumeSessionMutation } from '../../../api/session/useResumeSessionMutation'; +import { useStopSessionMutation } from '../../../api/session/useStopSessionMutation'; +import { Button } from '../../../components'; import { PoseLandmark, WorldLandmark, } from '../../../components/pose-detection'; -import WebcamView from '../../Calibration/components/WebcamView'; +import { useWidget } from '../../../hooks/useWidget'; import { useCameraStore } from '../../../store/useCameraStore'; -import { useCreateSessionMutation } from '../../../api/session/useCreateSessionMutation'; -import { useStopSessionMutation } from '../../../api/session/useStopSessionMutation'; -import { usePauseSessionMutation } from '../../../api/session/usePauseSessionMutation'; -import { useResumeSessionMutation } from '../../../api/session/useResumeSessionMutation'; +import WebcamView from '../../Calibration/components/WebcamView'; interface Props { onUserMediaError: (e: string | DOMException) => void; @@ -28,6 +30,7 @@ const WebcamPanel = ({ onSendMetrics, }: Props) => { const { cameraState, setShow, setHide, setExit } = useCameraStore(); + const { toggleWidget } = useWidget(); const isWebcamOn = cameraState === 'show'; const isExit = cameraState === 'exit'; @@ -39,7 +42,6 @@ const WebcamPanel = ({ usePauseSessionMutation(); const { mutate: resumeSession, isPending: isResumingSession } = useResumeSessionMutation(); - const handleStartStop = () => { if (isExit) { // 시작하기: 세션 생성 후 카메라 시작 @@ -103,8 +105,23 @@ const WebcamPanel = ({ return (
- - +
+ +
diff --git a/src/renderer/src/pages/Widget/WidgetPage.tsx b/src/renderer/src/pages/Widget/WidgetPage.tsx index 851940c..fcfd664 100644 --- a/src/renderer/src/pages/Widget/WidgetPage.tsx +++ b/src/renderer/src/pages/Widget/WidgetPage.tsx @@ -37,7 +37,7 @@ export function WidgetPage() { event: 'widget_page_loaded', timestamp: new Date().toISOString(), }); - window.electronAPI.writeLog(logData).catch((error) => { + window.electronAPI.writeLog(logData).catch((error: unknown) => { console.error('위젯 페이지 로드 로그 저장 실패:', error); }); } diff --git a/src/renderer/src/styles/colors.css b/src/renderer/src/styles/colors.css index abd38e7..e473b36 100644 --- a/src/renderer/src/styles/colors.css +++ b/src/renderer/src/styles/colors.css @@ -51,7 +51,7 @@ /* notification colors*/ --color-error: #ff351f; --color-success: #67b000; - + --color-bg-line: var(--color-grey-50); --color-point-red: #ff6647; --color-point-green: #a1b100; @@ -120,7 +120,7 @@ --color-point-red: var(--color-point-red); --color-point-green: var(--color-point-green); - + --color-bg-line: var(--color-grey-50); /* icon-color*/ --color-dot: var(--color-dot); --color-icon-stroke: var(--color-icon-stroke); @@ -211,6 +211,7 @@ ); /* 모달 컬러 */ + --color-bg-line: var(--color-grey-50); --color-surface-modal: #131312; --color-surface-modal-container: #191917; --color-modal-button: #232323; diff --git a/src/renderer/src/utils/getScoreLevel.ts b/src/renderer/src/utils/getScoreLevel.ts index 15bca49..0dd6fea 100644 --- a/src/renderer/src/utils/getScoreLevel.ts +++ b/src/renderer/src/utils/getScoreLevel.ts @@ -108,3 +108,4 @@ export function getScoreLevel(score: number): ScoreLevelInfo { export function getAllLevelDefinitions(): ScoreLevelInfo[] { return Object.values(LEVEL_DEFINITIONS); } +