-
이메일 인증
-
+
이메일 인증
+
본인 인증 메일을 귀하의
{' ' + email}
diff --git a/src/renderer/src/pages/SignUp/components/ResendEmailHeroSection.tsx b/src/renderer/src/pages/SignUp/components/ResendEmailHeroSection.tsx
index 6c52b1c..9b3ab51 100644
--- a/src/renderer/src/pages/SignUp/components/ResendEmailHeroSection.tsx
+++ b/src/renderer/src/pages/SignUp/components/ResendEmailHeroSection.tsx
@@ -1,8 +1,10 @@
export default function ResendEmailHerosection() {
return (
-
인증 링크를 메일로 전송했습니다
-
+
+ 인증 링크를 메일로 전송했습니다
+
+
이메일로 전송 받은 인증 링크를 확인해주세요.
링크는 발송 시점으로부터 24시간 동안 유효합니다.
diff --git a/src/renderer/src/pages/SignUp/components/SignUpform.tsx b/src/renderer/src/pages/SignUp/components/SignUpform.tsx
index 2393d06..20b979e 100644
--- a/src/renderer/src/pages/SignUp/components/SignUpform.tsx
+++ b/src/renderer/src/pages/SignUp/components/SignUpform.tsx
@@ -94,7 +94,7 @@ const SignUpForm = () => {
htmlFor="email"
className="text-body-lg-semibold hbp:text-headline-2xl-semibold text-grey-600"
>
- 이메일 *
+ 이메일 *
{
})}
className={`hbp:text-body-xl-regular aspect-[338/60] flex-1 ${
errors.email
- ? '!border-red-500'
+ ? '!border-error'
: duplicateSuccess === true
- ? '!border-green-500'
+ ? '!border-success'
: duplicateSuccess === false
- ? '!border-red-500'
+ ? '!border-error'
: ''
}`}
/>
@@ -130,10 +130,10 @@ const SignUpForm = () => {
{(errors.email || duplicateMessage) && (
{errors.email || duplicateSuccess === false ? (
@@ -154,7 +154,7 @@ const SignUpForm = () => {
htmlFor="password"
className="text-body-lg-semibold hbp:text-headline-2xl-semibold text-grey-600"
>
- 비밀번호
*
+ 비밀번호
*
영문, 숫자, 특수문자를 조합하여 8-16글자로 입력해주세요.
@@ -162,6 +162,7 @@ const SignUpForm = () => {
{/* 비밀번호 재입력 섹션 */}
@@ -174,8 +175,8 @@ const SignUpForm = () => {
? '' // 아무 입력 없으면 기본
: formValues.password === formValues.confirmPassword &&
!errors.password
- ? '!border-green-500' // 입력 있음 + 비밀번호 조건 통과 + 일치시 초록색
- : '!border-red-500' // 그 외는 모두 빨간색
+ ? '!border-success' // 입력 있음 + 비밀번호 조건 통과 + 일치시 초록색
+ : '!border-error' // 그 외는 모두 빨간색
}
/>
@@ -188,14 +189,14 @@ const SignUpForm = () => {
formValues.password !== formValues.confirmPassword ||
!!errors.password;
- const colorClass = isError ? 'text-red-500' : 'text-green-500';
+ const colorClass = isError ? 'text-error' : 'text-success';
const Icon = isError ? FailIcon : SuccessIcon;
const message = isError
? errors.password?.message || '비밀번호가 일치하지 않습니다.'
: '비밀번호가 일치합니다.';
return (
-
+
@@ -210,7 +211,7 @@ const SignUpForm = () => {
htmlFor="name"
className="text-body-lg-semibold hbp:text-headline-2xl-semibold text-grey-600"
>
- 이름
*
+ 이름
*
최대 10글자 이내로 작성해주세요.
@@ -220,13 +221,13 @@ const SignUpForm = () => {
type="text"
placeholder="이름을 입력해주세요."
{...register('name')}
- className={`hbp:text-body-xl-regular ${errors.name ? '!border-red-500' : formValues.name ? '!border-green-500' : ''}`}
+ className={`hbp:text-body-xl-regular ${errors.name ? '!border-error' : formValues.name ? '!border-success' : ''}`}
/>
{(formValues.name || !!errors.name) && (
{errors.name ?
:
}
diff --git a/src/renderer/src/pages/Widget/WidgetPage.tsx b/src/renderer/src/pages/Widget/WidgetPage.tsx
new file mode 100644
index 0000000..fcfd664
--- /dev/null
+++ b/src/renderer/src/pages/Widget/WidgetPage.tsx
@@ -0,0 +1,92 @@
+/* 위젯 창에 표시될 페이지 - 반응형 */
+
+import { useEffect, useState } from 'react';
+import { WidgetTitleBar } from '../../components/WidgetTitleBar/WidgetTitleBar';
+import { usePostureStore } from '../../store/usePostureStore';
+import { MediumWidgetContent } from './components/MediumWidgetContent';
+import { MiniWidgetContent } from './components/MiniWidgetContent';
+import { usePostureSyncWithLocalStorage } from './hooks/usePostureSyncWithLocalStorage';
+import { useThemeSync } from './hooks/useThemeSync';
+
+type WidgetSize = 'mini' | 'medium';
+type PostureState = 'turtle' | 'giraffe';
+
+/* 레이아웃 전환 기준점 */
+const BREAKPOINT = {
+ height: 62,
+} as const;
+
+export function WidgetPage() {
+ const [widgetSize, setWidgetSize] = useState
('medium');
+ const currentPostureClass = usePostureStore((state) => state.postureClass);
+
+ /* usePostureStore에서 실시간 자세 상태 가져오기 */
+
+ /* 실시간 자세 상태 동기화 */
+ usePostureSyncWithLocalStorage();
+
+ /* 위젯 라이트/다크 모드 */
+ useThemeSync();
+
+ /* 위젯 페이지 로드 시 로그 */
+ useEffect(() => {
+ console.log('위젯 페이지가 로드되었습니다');
+
+ if (window.electronAPI?.writeLog) {
+ const logData = JSON.stringify({
+ event: 'widget_page_loaded',
+ timestamp: new Date().toISOString(),
+ });
+ window.electronAPI.writeLog(logData).catch((error: unknown) => {
+ console.error('위젯 페이지 로드 로그 저장 실패:', error);
+ });
+ }
+ }, []);
+
+ /* 위젯 resize 이벤트 */
+ useEffect(() => {
+ /* resize 디바운스 타이머 ID 저장용 변수 */
+ let resizeTimeout: number;
+
+ /* 창 크기 변경 감지 핸들러 */
+ const handleResize = () => {
+ const isMedium = innerHeight > BREAKPOINT.height;
+ /* breakpoint를 넘으면 medium, 아니면 mini */
+ setWidgetSize(isMedium ? 'medium' : 'mini');
+ };
+
+ /* 디바운스 래퍼 함수 */
+ const handleResizeDebounced = () => {
+ clearTimeout(resizeTimeout);
+ resizeTimeout = window.setTimeout(() => {
+ handleResize();
+ }, 10);
+ };
+
+ handleResize();
+ window.addEventListener('resize', handleResizeDebounced);
+
+ return () => {
+ window.removeEventListener('resize', handleResizeDebounced);
+ clearTimeout(resizeTimeout);
+ };
+ }, []);
+
+ const isMini = widgetSize === 'mini';
+
+ return (
+
+
+ {/* 커스텀 타이틀바 */}
+
+
+ {/* 위젯 내용 - 창 크기에 따라 자동 전환 */}
+ {isMini ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/renderer/src/pages/Widget/components/MediumWidgetContent.tsx b/src/renderer/src/pages/Widget/components/MediumWidgetContent.tsx
new file mode 100644
index 0000000..c5e9957
--- /dev/null
+++ b/src/renderer/src/pages/Widget/components/MediumWidgetContent.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useState } from 'react';
+import MediumGiraffe from '../../../assets/widget/medium_giraffe.svg?react';
+import MediumTurtle from '../../../assets/widget/medium_turtle.svg?react';
+import messages from '../data.json';
+
+/* 실시간 자세 판별 */
+type PostureState = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+interface Message {
+ level: number;
+ mainTitle: string;
+ subTitles: string[];
+}
+
+interface MediumWidgetContentProps {
+ posture: PostureState;
+}
+
+/* 미디엄 위젯 레이아웃 */
+export function MediumWidgetContent({ posture }: MediumWidgetContentProps) {
+ const [mainTitle, setMainTitle] = useState('자세를 측정하고 있어요');
+ const [subTitle, setSubTitle] = useState('잠시만 기다려주세요...');
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ const messageData = messages.find((m: Message) => m.level === posture);
+
+ if (messageData) {
+ setMainTitle(messageData.mainTitle);
+ const { subTitles } = messageData;
+ const randomIndex = Math.floor(Math.random() * subTitles.length);
+ setSubTitle(subTitles[randomIndex]);
+ } else {
+ // posture가 0이거나 유효하지 않은 경우 기본 메시지 설정
+ setMainTitle('자세를 측정하고 있어요');
+ setSubTitle('잠시만 기다려주세요...');
+ }
+ }, [posture]);
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ const isGiraffe = [1, 2, 3].includes(posture);
+ const gradient = isGiraffe
+ ? 'linear-gradient(180deg, var(--color-olive-green) 0.18%, var(--color-success) 99.7%)'
+ : 'linear-gradient(180deg, var(--color-coral-red) 0%, var(--color-error) 100%)';
+
+ /* 게이지 비율: 등급별 차등 적용 */
+ let gaugeWidth: string;
+ switch (posture) {
+ case 1:
+ case 6:
+ gaugeWidth = '100%';
+ break;
+ case 2:
+ case 5:
+ gaugeWidth = '75%';
+ break;
+ case 3:
+ case 4:
+ gaugeWidth = '50%';
+ break;
+ default: // posture 0
+ gaugeWidth = '25%';
+ break;
+ }
+
+ return (
+
+ {/* 캐릭터 영역 */}
+
+ {isGiraffe ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 상세 정보 영역 */}
+
+ {/* 진행 바 */}
+
+
+ {/* 메시지 */}
+
+
{mainTitle}
+
{subTitle}
+
+
+
+ );
+}
diff --git a/src/renderer/src/pages/Widget/components/MiniWidgetContent.tsx b/src/renderer/src/pages/Widget/components/MiniWidgetContent.tsx
new file mode 100644
index 0000000..77cf641
--- /dev/null
+++ b/src/renderer/src/pages/Widget/components/MiniWidgetContent.tsx
@@ -0,0 +1,54 @@
+import MiniGiraffe from '../../../assets/widget/mini_giraffe.svg?react';
+import MiniTurtle from '../../../assets/widget/mini_turtle.svg?react';
+
+/* 실시간 자세 판별 */
+type PostureState = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+interface MiniWidgetContentProps {
+ posture: PostureState;
+}
+
+/* 미니 위젯 레이아웃 - 최대 50px 높이에 맞게 가로 배치 */
+export function MiniWidgetContent({ posture }: MiniWidgetContentProps) {
+ const isGiraffe = [1, 2, 3].includes(posture);
+ const gradient = isGiraffe
+ ? 'linear-gradient(180deg, var(--color-olive-green) 0.18%, var(--color-success) 99.7%)'
+ : 'linear-gradient(180deg, var(--color-coral-red) 0%, var(--color-error) 100%)';
+
+ /* 게이지 비율: 등급별 차등 적용 */
+ let gaugeWidth: string;
+ switch (posture) {
+ case 1:
+ case 6:
+ gaugeWidth = '100%';
+ break;
+ case 2:
+ case 5:
+ gaugeWidth = '75%';
+ break;
+ case 3:
+ case 4:
+ gaugeWidth = '50%';
+ break;
+ default: // posture 0
+ gaugeWidth = '25%';
+ break;
+ }
+
+ return (
+
+ {/* 캐릭터 이미지 영역 - 작게 */}
+
+
+ {isGiraffe ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/renderer/src/pages/Widget/data.json b/src/renderer/src/pages/Widget/data.json
new file mode 100644
index 0000000..c27f307
--- /dev/null
+++ b/src/renderer/src/pages/Widget/data.json
@@ -0,0 +1,63 @@
+[
+ {
+ "level": 6,
+ "mainTitle": "앗! 지금은 거북이 상태예요",
+ "subTitles": [
+ "초등학교 3학년 아이를 얹고 있어요",
+ "시멘트 한 포대를 얹고 있어요",
+ "대형견 셰퍼드를 얹고 있어요",
+ "미래 병원비 3,000만원을 적립 중입니다",
+ "생산성 최악! 지금 당장 일어나세요!"
+ ]
+ },
+ {
+ "level": 5,
+ "mainTitle": "앗! 지금은 거북이 상태예요",
+ "subTitles": [
+ "골든 리트리버 한 마리를 얹고 있어요",
+ "42인치 TV를 얹고 있어요",
+ "자동차 타이어 1개를 얹고 있어요",
+ "목 디스크가 다가오고있어요",
+ "지금 느끼는 어깨 통증, 자세 때문이에요"
+ ]
+ },
+ {
+ "level": 4,
+ "mainTitle": "앗! 지금은 거북이 상태예요",
+ "subTitles": [
+ "7살 아이 한 명을 얹고 있어요",
+ "2L 생수병 9개 묶음을 얹고 있어요",
+ "비행기 기내용 캐리어를 얹고 있어요",
+ "본격적인 거북목 증후군 시작!",
+ "뇌로 가는 산소가 부족해지고 있어요"
+ ]
+ },
+ {
+ "level": 3,
+ "mainTitle": "좋아요, 기린 상태 유지중!",
+ "subTitles": [
+ "아직은 목 근육 긴장 가능성이 있어요",
+ "턱을 살짝만 더 당겨볼까요?",
+ "이 정도면 잘하고 있어요!"
+ ]
+ },
+ {
+ "level": 2,
+ "mainTitle": "좋아요, 기린 상태 유지중!",
+ "subTitles": [
+ "집중력 최고 상태예요",
+ "목과 어깨가 편안한 상태예요",
+ "생산성이 상승하고있어요"
+ ]
+ },
+ {
+ "level": 1,
+ "mainTitle": "좋아요, 기린 상태 유지중!",
+ "subTitles": [
+ "완벽해요! 최고의 퍼포먼스를 내고있어요",
+ "최고의 자세! 이 상태를 유지하세요",
+ "척추가 교과서처럼 정렬되었어요",
+ "이 자세, 몸이 기억하게 해주세요"
+ ]
+ }
+]
diff --git a/src/renderer/src/pages/Widget/hooks/usePostureSyncWithLocalStorage.ts b/src/renderer/src/pages/Widget/hooks/usePostureSyncWithLocalStorage.ts
new file mode 100644
index 0000000..a8609a5
--- /dev/null
+++ b/src/renderer/src/pages/Widget/hooks/usePostureSyncWithLocalStorage.ts
@@ -0,0 +1,43 @@
+import { useEffect } from 'react';
+import { usePostureStore } from '../../../store/usePostureStore';
+
+/* 메인 창의 실시간 자세 상태 위젯 창에 실시간 동기화
+ localStorage의 storage 이벤트를 통해 창 간 통신 */
+
+export function usePostureSyncWithLocalStorage() {
+ const postureClass = usePostureStore((state) => state.postureClass);
+ const setStatus = usePostureStore((state) => state.setStatus);
+
+ useEffect(() => {
+ const handleStorageChange = (e: StorageEvent) => {
+ /* posture-state-storage만 처리 (다른 localStorage 변경은 무시) */
+ if (e.key !== 'posture-state-storage' || !e.newValue) return;
+
+ try {
+ const storageData = JSON.parse(e.newValue);
+ const { postureClass: newPostureClass, score: newScore } =
+ storageData.state;
+
+ console.log('[위젯] 메인 창에서 자세 변경 감지:', {
+ from: { postureClass },
+ to: { postureClass: newPostureClass, score: newScore },
+ });
+
+ setStatus(newPostureClass, newScore);
+ } catch (error) {
+ console.error('[위젯] localStorage 파싱 오류:', error);
+ }
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ return () => window.removeEventListener('storage', handleStorageChange);
+ }, [postureClass, setStatus]);
+
+ /* 디버깅용 로그 */
+ useEffect(() => {
+ console.log('[위젯] 자세 상태 업데이트:', {
+ postureClass,
+ timestamp: new Date().toLocaleTimeString(),
+ });
+ }, [postureClass]);
+}
diff --git a/src/renderer/src/pages/Widget/hooks/useThemeSync.ts b/src/renderer/src/pages/Widget/hooks/useThemeSync.ts
new file mode 100644
index 0000000..6043db7
--- /dev/null
+++ b/src/renderer/src/pages/Widget/hooks/useThemeSync.ts
@@ -0,0 +1,28 @@
+import { useEffect } from 'react';
+
+/* 메인 창의 테마 위젯 창에 동시에 반영 */
+export function useThemeSync() {
+ useEffect(() => {
+ const applyTheme = () => {
+ const isDark = localStorage.getItem('theme') === 'dark';
+ if (isDark) {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ };
+
+ // 초기 테마 적용
+ applyTheme();
+
+ /*메인 창에서 테마 변경 시 자동 반영 */
+ const handleStorageChange = (e: StorageEvent) => {
+ if (e.key === 'theme') {
+ applyTheme();
+ }
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ return () => window.removeEventListener('storage', handleStorageChange);
+ }, []);
+}
diff --git a/src/renderer/src/routers/index.tsx b/src/renderer/src/routers/index.tsx
index 51a726b..7a47ac8 100644
--- a/src/renderer/src/routers/index.tsx
+++ b/src/renderer/src/routers/index.tsx
@@ -1,4 +1,5 @@
-import { createBrowserRouter } from 'react-router-dom';
+import { createBrowserRouter, redirect } from 'react-router-dom';
+import api from '../api/api';
import Layout from '../layout/Layout';
import CalibrationPage from '../pages/Calibration/CalibrationPage';
import LoginPage from '../pages/Login/LoginPage';
@@ -6,30 +7,76 @@ import MainPage from '../pages/Main/MainPage';
import OnboardingCompletionPage from '../pages/Onboarding/OnboardingCompletionPage';
import OnboardingPage from '../pages/Onboarding/OnboardingPage';
import EmailVerificationPage from '../pages/SignUp/EmailVerificationPage';
+import EmailVerificationCallbackPage from '../pages/SignUp/EmailVerificationCallbackPage';
import ResendVerificationPage from '../pages/SignUp/ResendVerificationPage';
import SignUpPage from '../pages/SignUp/SignUpPage';
+import { WidgetPage } from '../pages/Widget/WidgetPage';
+import OnboardinInitPage from '../pages/Onboarding/OnboardingInitPage';
+
+// 인증이 필요한 페이지용 loader
+const requireAuthLoader = async () => {
+ const accessToken = localStorage.getItem('accessToken');
+ if (!accessToken) {
+ return redirect('/');
+ }
+
+ try {
+ await api.get('/users/me');
+ return null;
+ } catch (error) {
+ localStorage.clear();
+ return redirect('/');
+ }
+};
+
+// 로그인 페이지용 loader (토큰이 있으면 메인으로 리다이렉트)
+const loginPageLoader = async () => {
+ const accessToken = localStorage.getItem('accessToken');
+ if (!accessToken) {
+ return null;
+ }
+
+ try {
+ await api.get('/users/me');
+ return redirect('/main');
+ } catch (error) {
+ localStorage.clear();
+ return null;
+ }
+};
export const router = createBrowserRouter([
{
- element: ,
- path: '/',
- children: [{ path: '', element: }],
- },
- {
- element: ,
path: '/main',
- children: [{ path: '', element: }],
+ loader: requireAuthLoader,
+ element: ,
},
{
element: ,
path: '/auth',
children: [
- { path: 'login', element: },
+ {
+ path: 'login',
+ loader: loginPageLoader,
+ element: ,
+ },
{ path: 'signup', element: },
{ path: 'verify', element: },
+ { path: 'verify-callback', element: },
{ path: 'resend', element: },
],
},
+ {
+ element: ,
+ path: '/',
+ children: [
+ {
+ path: '',
+ loader: loginPageLoader,
+ element: ,
+ },
+ ],
+ },
{
element: ,
path: '/onboarding',
@@ -37,6 +84,11 @@ export const router = createBrowserRouter([
{ path: '', element: },
{ path: 'calibration', element: },
{ path: 'completion', element: },
+ { path: 'init', element: },
],
},
+ {
+ path: '/widget',
+ children: [{ path: '', element: }],
+ },
]);
diff --git a/src/renderer/src/store/useCameraStore.ts b/src/renderer/src/store/useCameraStore.ts
new file mode 100644
index 0000000..d287f2e
--- /dev/null
+++ b/src/renderer/src/store/useCameraStore.ts
@@ -0,0 +1,26 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+
+type CameraState = 'show' | 'hide' | 'exit';
+
+interface CameraStore {
+ cameraState: CameraState;
+ setShow: () => void;
+ setHide: () => void;
+ setExit: () => void;
+}
+
+export const useCameraStore = create()(
+ persist(
+ (set) => ({
+ cameraState: 'hide',
+ setShow: () => set({ cameraState: 'show' }),
+ setHide: () => set({ cameraState: 'hide' }),
+ setExit: () => set({ cameraState: 'exit' }),
+ }),
+ {
+ name: 'camera-state-storage',
+ storage: createJSONStorage(() => localStorage),
+ },
+ ),
+);
diff --git a/src/renderer/src/store/useNotificationStore.ts b/src/renderer/src/store/useNotificationStore.ts
new file mode 100644
index 0000000..5380d81
--- /dev/null
+++ b/src/renderer/src/store/useNotificationStore.ts
@@ -0,0 +1,67 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+
+export interface NotificationSettings {
+ isAllow: boolean;
+ stretching: {
+ isEnabled: boolean;
+ interval: number; // 분 단위
+ };
+ turtleNeck: {
+ isEnabled: boolean;
+ interval: number; // 분 단위
+ };
+}
+
+interface NotificationStore extends NotificationSettings {
+ setIsAllow: (isAllow: boolean) => void;
+ setStretchingEnabled: (isEnabled: boolean) => void;
+ setStretchingInterval: (interval: number) => void;
+ setTurtleNeckEnabled: (isEnabled: boolean) => void;
+ setTurtleNeckInterval: (interval: number) => void;
+ setSettings: (settings: NotificationSettings) => void;
+}
+
+export const useNotificationStore = create()(
+ persist(
+ (set) => ({
+ isAllow: false,
+ stretching: {
+ isEnabled: false,
+ interval: 30,
+ },
+ turtleNeck: {
+ isEnabled: false,
+ interval: 10,
+ },
+
+ setIsAllow: (isAllow) => set({ isAllow }),
+
+ setStretchingEnabled: (isEnabled) =>
+ set((state) => ({
+ stretching: { ...state.stretching, isEnabled },
+ })),
+
+ setStretchingInterval: (interval) =>
+ set((state) => ({
+ stretching: { ...state.stretching, interval },
+ })),
+
+ setTurtleNeckEnabled: (isEnabled) =>
+ set((state) => ({
+ turtleNeck: { ...state.turtleNeck, isEnabled },
+ })),
+
+ setTurtleNeckInterval: (interval) =>
+ set((state) => ({
+ turtleNeck: { ...state.turtleNeck, interval },
+ })),
+
+ setSettings: (settings) => set(settings),
+ }),
+ {
+ name: 'notification-settings-storage',
+ storage: createJSONStorage(() => sessionStorage),
+ },
+ ),
+);
diff --git a/src/renderer/src/store/usePostureStore.ts b/src/renderer/src/store/usePostureStore.ts
new file mode 100644
index 0000000..643f7a6
--- /dev/null
+++ b/src/renderer/src/store/usePostureStore.ts
@@ -0,0 +1,23 @@
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+
+interface PostureState {
+ postureClass: 1 | 2 | 3 | 4 | 5 | 6 | 0;
+ score: number;
+ setStatus: (postureClass: 1 | 2 | 3 | 4 | 5 | 6 | 0, score?: number) => void;
+}
+
+/* 자세 상태 저장소 localstorage 동기화 추가 */
+export const usePostureStore = create()(
+ persist(
+ (set) => ({
+ postureClass: 0,
+ score: 0,
+ setStatus: (postureClass, score = 0) => set({ postureClass, score }),
+ }),
+ {
+ name: 'posture-state-storage', // localStorage 키
+ storage: createJSONStorage(() => localStorage),
+ },
+ ),
+);
diff --git a/src/renderer/src/styles/base.css b/src/renderer/src/styles/base.css
index 867b991..30c68bc 100644
--- a/src/renderer/src/styles/base.css
+++ b/src/renderer/src/styles/base.css
@@ -27,3 +27,31 @@
font-family: 'Pretendard', system-ui, sans-serif;
}
}
+
+/* 커스텀 스크롤바 스타일 */
+@layer utilities {
+ /* 웹킷 브라우저용 (Chrome, Safari, Edge) */
+ .custom-scrollbar::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ .custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ border-radius: 999px;
+ }
+
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: var(--color-grey-50);
+ border-radius: 999px;
+ }
+
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: var(--color-grey-100);
+ }
+
+ /* Firefox용 */
+ .custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-grey-50) transparent;
+ }
+}
diff --git a/src/renderer/src/styles/breakpoint.css b/src/renderer/src/styles/breakpoint.css
index d844589..aa7940f 100644
--- a/src/renderer/src/styles/breakpoint.css
+++ b/src/renderer/src/styles/breakpoint.css
@@ -8,16 +8,24 @@
--breakpoint-2xl: 96rem; /* 1536px - 초대형 */
/* 커스텀 Breakpoints */
- --breakpoint-hbp: 57.5rem; /* 920px - 하이브리드 */
+ --breakpoint-minimum: 128rem; /* 2048px - 하이퍼 대형 */
+ --breakpoint-mini: 18rem;
+ --breakpoint-hbp: 160rem; /* 2560px - 하이브리드 */
--breakpoint-mobile: 30rem; /* 480px - 모바일 최적화 */
--breakpoint-tablet: 56rem; /* 896px - 태블릿 최적화 */
+
+ --breakpoint-minimum: 1280px;
+ --breakpoint-labtop: 1440px;
+ --breakpoint-desktop: 1920px;
}
/* 미디어 쿼리 헬퍼 */
@custom-media --mobile (max-width: 30rem);
@custom-media --tablet (min-width: 30rem) and (max-width: 56rem);
-@custom-media --desktop (min-width: 56rem);
@custom-media --large (min-width: 80rem);
+@custom-media --minimum (min-width: 1280px);
+@custom-media --labtop (min-width: 1440px);
+@custom-media --desktop (min-width: 1920px);
/* 컨테이너 최대 너비 */
.container {
diff --git a/src/renderer/src/styles/colors.css b/src/renderer/src/styles/colors.css
index 96e1f74..e473b36 100644
--- a/src/renderer/src/styles/colors.css
+++ b/src/renderer/src/styles/colors.css
@@ -16,13 +16,15 @@
--color-yellow-100: #ffebb0;
--color-yellow-200: #ffe28a;
--color-yellow-300: #ffd454;
- --color-yellow-400: #ffcc33;
+ --color-yellow-400: #ffcb31;
--color-yellow-500: #ffbf00;
--color-yellow-600: #e8ae00;
--color-yellow-700: #d29a00;
--color-yellow-800: #bd8700;
--color-yellow-900: #a67100;
+ --color-sementic-brand-primary: #ffbf00;
+
/* Grey 컬러 팔레트 */
--color-grey-0: #ffffff;
--color-grey-25: #f9f8f7;
@@ -45,8 +47,43 @@
#ffbf00 50%,
#ffae00 100%
);
- --color-error: #ff3232;
- --color-success: #00bf29;
+
+ /* notification colors*/
+ --color-error: #ff351f;
+ --color-success: #67b000;
+ --color-bg-line: var(--color-grey-50);
+ --color-point-red: #ff6647;
+ --color-point-green: #a1b100;
+
+ --color-dot: #fff;
+ --color-icon-stroke: #a8a7a4;
+ --color-check-stroke: #ffffff;
+ --color-check-fill: #c6c5c3;
+ --color-logo-fill: #3c3b3a;
+ --color-sun-stroke: #191917;
+ --color-moon-stroke: #6a6966;
+
+ /* widget-color*/
+ --color-olive-green: #a1b100;
+ --color-coral-red: #ff6647;
+
+ --color-widget-gradient: linear-gradient(
+ 180deg,
+ var(--color-olive-green) 0.18%,
+ var(--color-success) 99.7%
+ );
+ --color-widget-red: linear-gradient(
+ 180deg,
+ var(--color-coral-red) 0%,
+ var(--color-error) 100%
+ );
+
+ /* 모달 컬러 */
+ --color-surface-modal: #ffffff;
+ --color-surface-modal-container: #f9f8f7;
+ --color-modal-button: #f9f8f7;
+ --color-modal-disabled: #e3e1df;
+ --color-global-yellow-100: #ffebb0;
}
@theme inline {
@@ -76,21 +113,59 @@
--color-grey-900: var(--color-grey-900);
--color-grey-950: var(--color-grey-950);
--color-grey-1000: var(--color-grey-1000);
+
+ /* notification colors*/
+ --color-error: var(--color-error);
+ --color-success: var(--color-success);
+
+ --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);
+ --color-check-stroke: var(--color-check-stroke);
+ --color-check-fill: var(--color-check-fill);
+ --color-logo-fill: var(--color-logo-fill);
+ --color-sun-stroke: var(--color-sun-stroke);
+ --color-moon-stroke: var(--color-moon-stroke);
+
+ /* widget-color*/
+ --color-olive-green: var(--color-olive-green);
+ --color-coral-red: var(--color-coral-red);
+
+ /* main page color */
+ --color-average-score: linear-gradient(
+ 130deg,
+ var(--color-yellow-500) -9.87%,
+ var(--color-yellow-400) 25.52%,
+ var(--color-yellow-200) 97.31%
+ );
+
+ /* 모달 컬러 */
+ --color-surface-modal: var(--color-surface-modal);
+ --color-surface-modal-container: var(--color-surface-modal-container);
+ --color-modal-button: var(--color-modal-button);
+ --color-modal-disabled: var(--color-modal-disabled);
+ --color-sementic-brand-primary: var(--color-sementic-brand-primary);
+ --color-global-yellow-100: var(--color-global-yellow-100);
}
/* Dark mode colors */
.dark {
/* Dark mode Yellow (더 어두운 톤) */
- --color-yellow-50: #624d0b;
- --color-yellow-100: #806310;
- --color-yellow-200: #9d7b13;
- --color-yellow-300: #c59a18;
- --color-yellow-400: #e0ae1c;
- --color-yellow-500: #e7bd3e;
- --color-yellow-600: #e9c34f;
- --color-yellow-700: #edcd73;
- --color-yellow-800: #f2da9b;
- --color-yellow-900: #f7e5c0;
+ --color-yellow-50: #493704;
+ --color-yellow-100: #795706;
+ --color-yellow-200: #a57909;
+ --color-yellow-300: #cb980b;
+ --color-yellow-400: #e9b20c;
+ --color-yellow-500: #f5c73d;
+ --color-yellow-600: #f7d364;
+ --color-yellow-700: #f9da86;
+ --color-yellow-800: #fae19e;
+ --color-yellow-900: #fbe7bb;
+
+ --color-sementic-brand-primary: #e9b20c;
/* Dark mode Grey (역전된 스케일) */
--color-grey-0: #191917;
@@ -107,4 +182,39 @@
--color-grey-900: #f9f8f7;
--color-grey-950: #fcfcfc;
--color-grey-1000: #ffffff;
+
+ /* Dark mode notification colors*/
+ --color-error: #c92504;
+ --color-success: #839400;
+
+ --color-point-red: #f15232;
+ --color-point-green: #839400;
+
+ /* icon-color*/
+ --color-icon-stroke: #6a6966;
+ --color-check-stroke: #191917;
+ --color-check-fill: #3c3b3a;
+ --color-logo-fill: #c6c5c3;
+ --color-sun-stroke: #6a6966;
+ --color-moon-stroke: #191917;
+
+ /* widget-color*/
+ --color-olive-green: #477d00;
+ --color-coral-red: #f15232;
+
+ /* main page color */
+ --color-average-score: linear-gradient(
+ 130deg,
+ var(--color-yellow-500) -9.87%,
+ var(--color-yellow-400) 25.52%,
+ var(--color-yellow-200) 97.31%
+ );
+
+ /* 모달 컬러 */
+ --color-bg-line: var(--color-grey-50);
+ --color-surface-modal: #131312;
+ --color-surface-modal-container: #191917;
+ --color-modal-button: #232323;
+ --color-modal-disabled: #2c2c2c;
+ --color-global-yellow-100: rgba(73, 55, 4, 0.5);
}
diff --git a/src/renderer/src/styles/fonts.css b/src/renderer/src/styles/fonts.css
index 41b5857..21cf68a 100644
--- a/src/renderer/src/styles/fonts.css
+++ b/src/renderer/src/styles/fonts.css
@@ -1,8 +1,7 @@
/* 폰트 관련 스타일 */
@font-face {
font-family: 'Pretendard';
- src: url('../../../../assets/fonts/PretendardVariable.woff2')
- format('woff2-variations');
+ src: url('/fonts/PretendardVariable.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
diff --git a/src/renderer/src/styles/globals.css b/src/renderer/src/styles/globals.css
index 2f553f9..c806a2a 100644
--- a/src/renderer/src/styles/globals.css
+++ b/src/renderer/src/styles/globals.css
@@ -6,3 +6,48 @@
@import './breakpoint.css';
@custom-variant dark (&:where(.dark, .dark *));
+
+/* 슬라이드 애니메이션 */
+@keyframes slide-next {
+ from {
+ opacity: 0;
+ transform: translateX(30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slide-prev {
+ from {
+ opacity: 0;
+ transform: translateX(-30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.animate-slide-next {
+ animation: slide-next 0.3s ease-out;
+}
+
+.animate-slide-prev {
+ animation: slide-prev 0.3s ease-out;
+}
+
+/* 페이드인 애니메이션 */
+@keyframes fade-in {
+ from {
+ opacity: 0.6;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.animate-fade-in {
+ animation: fade-in 0.4s ease-out;
+}
diff --git a/src/renderer/src/styles/typography.css b/src/renderer/src/styles/typography.css
index 988c293..2663dc1 100644
--- a/src/renderer/src/styles/typography.css
+++ b/src/renderer/src/styles/typography.css
@@ -24,6 +24,11 @@
font-weight: 600;
}
+@utility text-headline-3xl-bold {
+ font-size: 24px;
+ font-weight: 700;
+}
+
@utility text-headline-2xl-regular {
font-size: 22px;
font-weight: 400;
@@ -98,3 +103,23 @@
font-size: 14px;
font-weight: 600;
}
+
+@utility text-caption-xs-meidum {
+ font-size: 12px;
+ font-weight: 500;
+}
+
+@utility text-caption-xs-regular {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+@utility text-caption-2xs-regular {
+ font-size: 10px;
+ font-weight: 400;
+}
+
+@utility text-caption-2xs-medium {
+ font-size: 10px;
+ font-weight: 500;
+}
diff --git a/src/renderer/src/types/dashboard/averageScore.ts b/src/renderer/src/types/dashboard/averageScore.ts
new file mode 100644
index 0000000..4a0c67c
--- /dev/null
+++ b/src/renderer/src/types/dashboard/averageScore.ts
@@ -0,0 +1,11 @@
+export interface AverageScoreData {
+ score: number;
+}
+
+export interface AverageScoreResponse {
+ timestamp: string;
+ success: boolean;
+ data: AverageScoreData;
+ code: string;
+ message: string | null;
+}
diff --git a/src/renderer/src/types/dashboard/level.ts b/src/renderer/src/types/dashboard/level.ts
new file mode 100644
index 0000000..9c4e91e
--- /dev/null
+++ b/src/renderer/src/types/dashboard/level.ts
@@ -0,0 +1,13 @@
+export interface LevelData {
+ level: number;
+ current: number;
+ required: number;
+}
+
+export interface LevelResponse {
+ timestamp: string;
+ success: boolean;
+ data: LevelData;
+ code: string;
+ message: string | null;
+}
diff --git a/src/renderer/src/types/dashboard/postureGraph.ts b/src/renderer/src/types/dashboard/postureGraph.ts
new file mode 100644
index 0000000..b793259
--- /dev/null
+++ b/src/renderer/src/types/dashboard/postureGraph.ts
@@ -0,0 +1,11 @@
+export interface PostureGraphData {
+ points: Record;
+}
+
+export interface PostureGraphResponse {
+ timestamp: string;
+ success: boolean;
+ data: PostureGraphData;
+ code: string;
+ message: string | null;
+}
diff --git a/src/renderer/src/types/main/session.ts b/src/renderer/src/types/main/session.ts
new file mode 100644
index 0000000..eab36d8
--- /dev/null
+++ b/src/renderer/src/types/main/session.ts
@@ -0,0 +1,54 @@
+/* 세션 생성 API 타입 */
+export interface CreateSessionResponse {
+ timestamp: string;
+ success: boolean;
+ data: {
+ sessionId: string;
+ };
+ code: string;
+ message: string;
+}
+
+/* 세션 중단/일시정지 공통 응답 타입 */
+export interface SessionActionResponse {
+ timestamp: string;
+ success: boolean;
+ code: string;
+ message: string;
+}
+
+/* 세션 메트릭 데이터 타입 */
+export interface MetricData {
+ score: number;
+ timestamp: string;
+}
+
+/* 세션 메트릭 저장 요청 타입 */
+export interface SaveMetricsRequest {
+ sessionId: string;
+ metrics: MetricData[];
+}
+
+/* 세션 메트릭 저장 응답 타입 */
+export interface SaveMetricsResponse {
+ timestamp: string;
+ success: boolean;
+ code: string;
+ message: string;
+}
+
+/* 세션 리포트 데이터 타입 */
+export interface SessionReportData {
+ totalSeconds: number; // 전체 세션 시간 (초)
+ goodSeconds: number; // 좋은 자세 유지 시간 (초)
+ score: number; // 세션 점수
+}
+
+/* 세션 리포트 응답 타입 */
+export interface SessionReportResponse {
+ timestamp: string;
+ success: boolean;
+ data: SessionReportData;
+ code: string;
+ message: string;
+}
diff --git a/src/renderer/src/types/vite-env.d.ts b/src/renderer/src/types/vite-env.d.ts
index 11f02fe..b1f45c7 100644
--- a/src/renderer/src/types/vite-env.d.ts
+++ b/src/renderer/src/types/vite-env.d.ts
@@ -1 +1,2 @@
///
+///
diff --git a/src/renderer/src/utils/getColor.ts b/src/renderer/src/utils/getColor.ts
new file mode 100644
index 0000000..7633417
--- /dev/null
+++ b/src/renderer/src/utils/getColor.ts
@@ -0,0 +1,8 @@
+// CSS 변수에서 색상 가져오기
+export const getColor = (cssVar: string, fallback: string) => {
+ return (
+ getComputedStyle(document.documentElement)
+ .getPropertyValue(cssVar)
+ .trim() || fallback
+ );
+};
diff --git a/src/renderer/src/utils/getScoreLevel.ts b/src/renderer/src/utils/getScoreLevel.ts
new file mode 100644
index 0000000..0dd6fea
--- /dev/null
+++ b/src/renderer/src/utils/getScoreLevel.ts
@@ -0,0 +1,111 @@
+/**
+ * 점수를 6단계 레벨로 분류합니다.
+ *
+ * @param score - 분류할 점수
+ * @returns 레벨 (1-6)과 레벨 정보
+ */
+export type ScoreLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+export interface ScoreLevelInfo {
+ level: ScoreLevel;
+ label: string;
+ name: string; // 레벨 이름 (angel-rini, pm-rini, rini, bugi, stone-bugi, tire-bugi)
+ percentile: string;
+ minScore: number;
+ maxScore: number | null; // null이면 무한대
+}
+
+const LEVEL_DEFINITIONS: Record = {
+ 1: {
+ level: 1,
+ label: 'L1',
+ name: 'angel-rini',
+ percentile: '하위 5%',
+ minScore: Number.NEGATIVE_INFINITY,
+ maxScore: -7.0,
+ },
+ 2: {
+ level: 2,
+ label: 'L2',
+ name: 'pm-rini',
+ percentile: '25%',
+ minScore: -7.0,
+ maxScore: -3.6,
+ },
+ 3: {
+ level: 3,
+ label: 'L3',
+ name: 'rini',
+ percentile: '45% (고정)',
+ minScore: -3.6,
+ maxScore: 1.2,
+ },
+ 4: {
+ level: 4,
+ label: 'L4',
+ name: 'bugi',
+ percentile: '70%',
+ minScore: 1.2,
+ maxScore: 6.0,
+ },
+ 5: {
+ level: 5,
+ label: 'L5',
+ name: 'stone-bugi',
+ percentile: '95%',
+ minScore: 6.0,
+ maxScore: 12.5,
+ },
+ 6: {
+ level: 6,
+ label: 'L6',
+ name: 'tire-bugi',
+ percentile: '상위 5%',
+ minScore: 12.5,
+ maxScore: Number.POSITIVE_INFINITY,
+ },
+};
+
+/**
+ * 점수를 기반으로 레벨을 반환합니다.
+ *
+ * @param score - 분류할 점수
+ * @returns 레벨 정보
+ */
+export function getScoreLevel(score: number): ScoreLevelInfo {
+ // L1: score ≤ -7.0
+ if (score <= -7.0) {
+ return LEVEL_DEFINITIONS[1];
+ }
+
+ // L2: -7.0 < score ≤ -3.6
+ if (score > -7.0 && score <= -3.6) {
+ return LEVEL_DEFINITIONS[2];
+ }
+
+ // L3: -3.6 < score ≤ 1.2
+ if (score > -3.6 && score <= 1.2) {
+ return LEVEL_DEFINITIONS[3];
+ }
+
+ // L4: 1.2 < score ≤ 6.0
+ if (score > 1.2 && score <= 6.0) {
+ return LEVEL_DEFINITIONS[4];
+ }
+
+ // L5: 6.0 < score ≤ 12.5
+ if (score > 6.0 && score <= 12.5) {
+ return LEVEL_DEFINITIONS[5];
+ }
+
+ // L6: score > 12.5
+ return LEVEL_DEFINITIONS[6];
+}
+
+/**
+ * 모든 레벨 정의를 반환합니다.
+ */
+export function getAllLevelDefinitions(): ScoreLevelInfo[] {
+ return Object.values(LEVEL_DEFINITIONS);
+}
+
diff --git a/src/renderer/tsconfig.json b/src/renderer/tsconfig.json
index 13e9a25..6d8b6fe 100644
--- a/src/renderer/tsconfig.json
+++ b/src/renderer/tsconfig.json
@@ -13,6 +13,7 @@
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
+ "resolveJsonModule": true,
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"target": "ES6",
@@ -21,14 +22,17 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
- "@ui/*": ["./src/packages/ui/components/*"],
- "@assets/*": ["./src/assets/*"]
+ "@ui/*": ["./src/components/*"],
+ "@assets/*": ["./src/assets/*"],
+ "@api/*": ["./src/api/*"],
+ "@utils/*": ["./src/utils/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
- "../preload/exposedInMainWorld.d.ts"
+ "../preload/exposedInMainWorld.d.ts",
+ "src/types/*.d.ts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "dist"]
}
diff --git a/vite.config.mts b/vite.config.mts
index cdc49de..39b3464 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -10,15 +10,19 @@ const __dirname = path.dirname(__filename);
// https://vitejs.dev/config/
export default defineConfig({
- base: './',
+ base: '/',
root: 'src/renderer',
- plugins: [react(), tailwindcss(), svgr()],
+ plugins: [svgr(), react(), tailwindcss()],
resolve: {
alias: {
'@ui/': path.resolve(__dirname, 'src/renderer/src/components') + '/',
'@ui': path.resolve(__dirname, 'src/renderer/src/components'),
'@assets/': path.resolve(__dirname, 'src/renderer/src/assets') + '/',
'@assets': path.resolve(__dirname, 'src/renderer/src/assets'),
+ '@api/': path.resolve(__dirname, 'src/renderer/src/api') + '/',
+ '@api': path.resolve(__dirname, 'src/renderer/src/api'),
+ '@utils/': path.resolve(__dirname, 'src/renderer/src/utils') + '/',
+ '@utils': path.resolve(__dirname, 'src/renderer/src/utils'),
ui: path.resolve(__dirname, 'src/renderer/src/components'),
},
},
@@ -27,7 +31,7 @@ export default defineConfig({
host: 'localhost',
},
build: {
- outDir: '../dist/renderer',
+ outDir: path.resolve(__dirname, 'dist/renderer'),
sourcemap: true,
emptyOutDir: true,
},