Skip to content

Latest commit

Β 

History

History
606 lines (464 loc) Β· 16 KB

File metadata and controls

606 lines (464 loc) Β· 16 KB

ν”„λ‘ νŠΈμ—”λ“œ 개발 κ°€μ΄λ“œ

Altsis ν”„λ‘ νŠΈμ—”λ“œλŠ” React 18 + TypeScript 기반의 SPA(Single Page Application)μž…λ‹ˆλ‹€. 이 λ¬Έμ„œμ—μ„œλŠ” ν”„λ‘ νŠΈμ—”λ“œ κ°œλ°œμ— ν•„μš”ν•œ 핡심 κ°œλ…κ³Ό νŒ¨ν„΄μ„ μ„€λͺ…ν•©λ‹ˆλ‹€.


λͺ©μ°¨

  1. 기술 μŠ€νƒ
  2. λΌμš°νŒ…
  3. μ»΄ν¬λ„ŒνŠΈ μž‘μ„± νŒ¨ν„΄
  4. μƒνƒœ 관리
  5. API 호좜 (useAPIv2)
  6. μŠ€νƒ€μΌλ§
  7. ν…Œλ§ˆ μ‹œμŠ€ν…œ
  8. μ£Όμš” μ»΄ν¬λ„ŒνŠΈ

1. 기술 μŠ€νƒ

기술 버전 μš©λ„
React 18.3.x UI 라이브러리
TypeScript 4.9.x 정적 νƒ€μž… 검사
React Router 6.x SPA λΌμš°νŒ…
Zustand 4.x κ²½λŸ‰ μƒνƒœ 관리 (에디터 μŠ€ν† μ–΄)
SCSS (Sass) 1.83.x CSS μ „μ²˜λ¦¬κΈ°
Axios 0.27.x HTTP μš”μ²­
Socket.IO Client 4.x μ‹€μ‹œκ°„ 톡신
Lodash 4.x μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜
react-cookie 4.x μΏ ν‚€ 관리
xlsx 0.18.x Excel 내보내기
react-markdown 8.x λ§ˆν¬λ‹€μš΄ λ Œλ”λ§

2. λΌμš°νŒ…

React Router v6 기반

ν”„λ‘ νŠΈμ—”λ“œ λΌμš°νŒ…μ€ frontend/src/routes/RouterPage.tsxμ—μ„œ μ •μ˜λ©λ‹ˆλ‹€.

URL ꡬ쑰

인증된 μ‚¬μš©μžμ˜ URL은 아카데미 ID와 학ꡐ IDκ°€ μ ‘λ‘μ‚¬λ‘œ ν¬ν•¨λ©λ‹ˆλ‹€:

/:academyId/:schoolId/[path]

μ˜ˆμ‹œ:

URL μ„€λͺ…
/login 아카데미 선택 ν™”λ©΄
/myAcademy/login νŠΉμ • 아카데미 둜그인
/register νšŒμ›κ°€μž…
/myAcademy/mySchool/ ν™ˆ νŽ˜μ΄μ§€
/myAcademy/mySchool/courses μˆ˜μ—… λͺ©λ‘
/myAcademy/mySchool/courses/design μˆ˜μ—… 섀계
/myAcademy/mySchool/courses/enroll μˆ˜κ°• μ‹ μ²­
/myAcademy/mySchool/admin/schools/list 학ꡐ 관리
/myAcademy/mySchool/boards κ²Œμ‹œνŒ
/myAcademy/mySchool/settings μ„€μ •

UrlContextSync

UrlContextSync.tsxλŠ” URL의 :academyId와 :schoolId νŒŒλΌλ―Έν„°λ₯Ό AuthContext와 λ™κΈ°ν™”ν•˜λŠ” 래퍼 μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. /:academyId/:schoolId ν•˜μœ„μ˜ λͺ¨λ“  λΌμš°νŠΈμ—μ„œ λ™μž‘ν•©λ‹ˆλ‹€.

RequireAuth 래퍼

인증이 ν•„μš”ν•œ λΌμš°νŠΈλŠ” RequireAuth μ»΄ν¬λ„ŒνŠΈλ‘œ 감싸져 μžˆμŠ΅λ‹ˆλ‹€:

// λͺ¨λ“  인증된 μ‚¬μš©μž ν—ˆμš©
<RequireAuth>
  <Home />
</RequireAuth>

// νŠΉμ • κΆŒν•œλ§Œ ν—ˆμš©
<RequireAuth auth={["admin", "manager"]}>
  <Admin />
</RequireAuth>

// owner μ „μš©
<RequireAuth auth={["owner"]}>
  <Academies />
</RequireAuth>

auth prop이 μ—†μœΌλ©΄ λ‘œκ·ΈμΈν•œ λͺ¨λ“  μ‚¬μš©μžλ₯Ό ν—ˆμš©ν•©λ‹ˆλ‹€. auth 배열이 있으면 ν•΄λ‹Ή κΆŒν•œμ„ κ°€μ§„ μ‚¬μš©μžλ§Œ μ ‘κ·Όν•  수 μžˆμŠ΅λ‹ˆλ‹€. κΆŒν•œμ΄ μ—†λŠ” 경우 "잘λͺ»λœ μ ‘κ·Όμž…λ‹ˆλ‹€." μ•Œλ¦Όκ³Ό ν•¨κ»˜ λ¦¬λ‹€μ΄λ ‰νŠΈλ©λ‹ˆλ‹€.

λ ˆκ±°μ‹œ URL ν˜Έν™˜

접두사 μ—†λŠ” λ ˆκ±°μ‹œ URL(/courses/*, /boards/* λ“±)은 LegacyRedirect μ»΄ν¬λ„ŒνŠΈλ₯Ό 톡해 μžλ™μœΌλ‘œ μƒˆ URL ν˜•μ‹(/:academyId/:schoolId/...)으둜 λ¦¬λ‹€μ΄λ ‰νŠΈλ©λ‹ˆλ‹€.


3. μ»΄ν¬λ„ŒνŠΈ μž‘μ„± νŒ¨ν„΄

SCSS λͺ¨λ“ˆ μ‚¬μš©

λͺ¨λ“  μ»΄ν¬λ„ŒνŠΈλŠ” .module.scss νŒŒμΌμ„ μ‚¬μš©ν•˜μ—¬ μŠ€νƒ€μΌ μŠ€μ½”ν”„λ₯Ό λΆ„λ¦¬ν•©λ‹ˆλ‹€:

// MyComponent.tsx
import style from "./myComponent.module.scss";

const MyComponent = () => {
  return (
    <div className={style.container}>
      <h1 className={style.title}>제λͺ©</h1>
      <p className={style.description}>μ„€λͺ…</p>
    </div>
  );
};
// myComponent.module.scss
.container {
  padding: 24px;
  background-color: var(--background-color);
  border: var(--border-default);
}

.title {
  color: var(--accent-1);
  font-size: 18px;
}

.description {
  color: var(--accent-3);
}

μ€‘μš”: 색상값은 λ°˜λ“œμ‹œ CSS λ³€μˆ˜λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. #ffffffλ‚˜ rgb(0,0,0) 같은 ν•˜λ“œμ½”λ”©μ€ κΈˆμ§€ν•©λ‹ˆλ‹€.

νŒμ—… μ»΄ν¬λ„ŒνŠΈ νŒ¨ν„΄

νŒμ—…μ€ setState prop νŒ¨ν„΄μ„ μ‚¬μš©ν•©λ‹ˆλ‹€:

// λΆ€λͺ¨ μ»΄ν¬λ„ŒνŠΈ
const [isPopupOpen, setIsPopupOpen] = useState(false);

return (
  <div>
    <button onClick={() => setIsPopupOpen(true)}>νŒμ—… μ—΄κΈ°</button>
    {isPopupOpen && (
      <MyPopup
        setState={setIsPopupOpen}   // λ‹«κΈ° μ œμ–΄
        data={someData}             // 전달 데이터
        onConfirm={handleConfirm}   // 확인 콜백
      />
    )}
  </div>
);
// MyPopup.tsx
interface IMyPopupProps {
  setState: React.Dispatch<React.SetStateAction<boolean>>;
  data: SomeType;
  onConfirm: () => void;
}

const MyPopup = ({ setState, data, onConfirm }: IMyPopupProps) => {
  return (
    <div className={style.popup}>
      <div className={style.overlay} onClick={() => setState(false)} />
      <div className={style.content}>
        {/* νŒμ—… λ‚΄μš© */}
        <button onClick={() => setState(false)}>λ‹«κΈ°</button>
        <button onClick={onConfirm}>확인</button>
      </div>
    </div>
  );
};

ν…Œμ΄λΈ” μ»΄ν¬λ„ŒνŠΈ μ‚¬μš©

데이터 ν…Œμ΄λΈ”μ€ tableV2 μ»΄ν¬λ„ŒνŠΈλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€ (ꡬ table은 λ ˆκ±°μ‹œ):

import Table from "components/tableV2/Table";

<Table
  data={dataList}
  header={[
    { text: "이름", key: "name", type: "text" },
    { text: "ν•™λ…„", key: "grade", type: "text" },
    { text: "μƒνƒœ", key: "status", type: "status" },
  ]}
  onClick={(item) => handleRowClick(item)}
/>

4. μƒνƒœ 관리

AltsisλŠ” μ—¬λŸ¬ κ³„μΈ΅μ˜ μƒνƒœ 관리λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€:

AuthContext (useAuth)

ν˜„μž¬ λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 전체 μ»¨ν…μŠ€νŠΈλ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€:

import { useAuth } from "contexts/authContext";

const MyComponent = () => {
  const {
    currentUser,          // ν˜„μž¬ μ‚¬μš©μž 정보 (TCurrentUser)
    currentSchool,        // ν˜„μž¬ 학ꡐ 정보 (TSchool)
    currentRegistration,  // ν˜„μž¬ ν•™κΈ° 등둝 정보 (TCurrentRegistration)
    currentSeason,        // ν˜„μž¬ ν•™κΈ° 정보 (TCurrentSeason)
    changeSchool,         // 학ꡐ μ „ν™˜ ν•¨μˆ˜
    changeRegistration,   // ν•™κΈ° μ „ν™˜ ν•¨μˆ˜
  } = useAuth();

  // μ‚¬μš© μ˜ˆμ‹œ
  console.log(currentUser.auth);        // "admin" | "teacher" | "student" λ“±
  console.log(currentUser.academyId);   // 아카데미 ID
  console.log(currentSchool.schoolId);  // 학ꡐ ID
  console.log(currentRegistration.role); // "teacher" | "student"
};

AuthContextλŠ” μ•± μ΅œμƒμœ„μ—μ„œ 제곡되며, 둜그인 μ‹œ μžλ™μœΌλ‘œ λ‹€μŒμ„ μˆ˜ν–‰ν•©λ‹ˆλ‹€:

  1. μ‚¬μš©μž 정보 λ‘œλ“œ (UserAPI.RMySelf())
  2. μΏ ν‚€ 기반 학ꡐ 볡원 (cookies.currentSchool)
  3. ν•™κΈ° 등둝 정보 λ‘œλ“œ
  4. ν•™κΈ° 정보 λ‘œλ“œ

ThemeContext (useTheme)

ν…Œλ§ˆ μƒνƒœλ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€:

import { useTheme } from "contexts/themeContext";

const MyComponent = () => {
  const {
    theme,           // ν˜„μž¬ ν…Œλ§ˆ λͺ¨λ“œ ("light" | "dark" | "system" | ...)
    resolvedTheme,   // μ‹€μ œ 적용된 ν…Œλ§ˆ ("light" | "dark" | "custom" λ“±)
    darkModeActive,  // 닀크 λͺ¨λ“œ ν™œμ„± μ—¬λΆ€
    customColors,    // μ»€μŠ€ν…€ ν…Œλ§ˆ 색상 객체
    setTheme,        // ν…Œλ§ˆ λ³€κ²½ ν•¨μˆ˜
    setCustomColors, // μ»€μŠ€ν…€ 색상 λ³€κ²½ ν•¨μˆ˜
  } = useTheme();
};

Zustand (에디터 μŠ€ν† μ–΄)

양식 μ—λ””ν„°λŠ” Zustand둜 μƒνƒœλ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€:

import { useEditorStore } from "editor/store/useEditorStore";

const EditorComponent = () => {
  const { blocks, addBlock, updateBlock, removeBlock } = useEditorStore();
  // ...
};

μΏ ν‚€ (react-cookie)

μ„Έμ…˜ μ˜μ†μ„±μ„ μœ„ν•΄ μΏ ν‚€λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€:

import { useCookies } from "react-cookie";

const [cookies, setCookie, removeCookie] = useCookies([
  "currentSchool",       // ν˜„μž¬ μ„ νƒλœ 학ꡐ ID
  "currentRegistration", // ν˜„μž¬ μ„ νƒλœ ν•™κΈ° 등둝 ID
]);

5. API 호좜 (useAPIv2)

κ°œμš”

useAPIv2 훅은 λ°±μ—”λ“œ APIλ₯Ό λ„€μž„μŠ€νŽ˜μ΄μŠ€λ³„λ‘œ κ΅¬μ‘°ν™”λœ ν•¨μˆ˜λ‘œ μ œκ³΅ν•©λ‹ˆλ‹€. λ‚΄λΆ€μ μœΌλ‘œ useDatabase ν›…μ˜ Axios 기반 CRUD 래퍼λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

import useAPIv2 from "hooks/useAPIv2";

const MyComponent = () => {
  const {
    AcademyAPI,
    SchoolAPI,
    SeasonAPI,
    SyllabusAPI,
    EnrollmentAPI,
    RegistrationAPI,
    UserAPI,
    NotificationAPI,
    BoardAPI,
    PostAPI,
    // ...
  } = useAPIv2();

  // API 호좜 μ˜ˆμ‹œ
  const loadData = async () => {
    try {
      const { syllabuses } = await SyllabusAPI.RSyllabuses({
        query: { season: seasonId },
      });
      setSyllabuses(syllabuses);
    } catch (err) {
      ALERT_ERROR(err);
    }
  };
};

CRUD λͺ…λͺ… κ·œμΉ™

λͺ¨λ“  API ν•¨μˆ˜λŠ” μΌκ΄€λœ λͺ…λͺ… κ·œμΉ™μ„ λ”°λ¦…λ‹ˆλ‹€:

접두사 HTTP λ©”μ„œλ“œ 의미 μ˜ˆμ‹œ
C POST Create (생성) CAcademy, CSeason, CSyllabus
R GET Read (쑰회) RAcademy, RSeasons, RSyllabus
U PUT Update (μˆ˜μ •) UAcademyEmail, USeasonPeriod
D DELETE Delete (μ‚­μ œ) DAcademy, DSeason

λ‹¨μˆ˜(RSeason) vs 볡수(RSeasons)둜 단건/λͺ©λ‘ 쑰회λ₯Ό κ΅¬λΆ„ν•©λ‹ˆλ‹€.

API ν•¨μˆ˜ μ‹œκ·Έλ‹ˆμ²˜ νŒ¨ν„΄

// 생성 (Create) - data ν•„μˆ˜
async function CSeason(props: {
  data: {
    school: string;
    year: string;
    term: string;
    period: { start: string; end: string };
  };
}): Promise<{ season: TSeason }>

// λͺ©λ‘ 쑰회 (Read List) - query 선택
async function RSeasons(props?: {
  query?: {
    school?: string;
  };
}): Promise<{ seasons: TSeason[] }>

// 단건 쑰회 (Read One) - params ν•„μˆ˜
async function RSeason(props: {
  params: {
    _id: string;
  };
}): Promise<{ season: TSeason }>

// μˆ˜μ • (Update) - params + data ν•„μˆ˜
async function UAcademyEmail(props: {
  params: {
    academyId: string;
  };
  data: {
    email?: string;
  };
}): Promise<{ academy: TAcademy }>

// μ‚­μ œ (Delete) - params ν•„μˆ˜
async function DSeason(props: {
  params: {
    _id: string;
  };
}): Promise<void>

μ—λŸ¬ 처리

ALERT_ERROR μœ ν‹Έλ¦¬ν‹°λ₯Ό μ‚¬μš©ν•˜μ—¬ μ‚¬μš©μžμ—κ²Œ μ—λŸ¬ λ©”μ‹œμ§€λ₯Ό ν‘œμ‹œν•©λ‹ˆλ‹€:

import { ALERT_ERROR } from "hooks/useAPIv2";

try {
  await SyllabusAPI.CSyllabus({ data: syllabusData });
} catch (err) {
  ALERT_ERROR(err);
  // λ˜λŠ” 직접 처리
  if (err.response?.status === 409) {
    // 좩돌 처리
  }
}

ALERT_ERRORλŠ” λ°±μ—”λ“œμ˜ λ©”μ‹œμ§€ μ½”λ“œλ₯Ό ν•œκ΅­μ–΄ λ©”μ‹œμ§€λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€ (_message.ts λ§€ν•‘ μ‚¬μš©).

λ‚΄λΆ€ λ™μž‘ 원리

useAPIv2 (API ν•¨μˆ˜ 제곡)
    ↓ 호좜
useDatabase (Axios CRUD 래퍼)
    ↓ HTTP μš”μ²­
    C() β†’ axios.post(`/api/{location}`)
    R() β†’ axios.get(`/api/{location}`)
    U() β†’ axios.put(`/api/{location}`)
    D() β†’ axios.delete(`/api/{location}`)
    ↓ 응닡
λ°±μ—”λ“œ Express μ„œλ²„

6. μŠ€νƒ€μΌλ§

SCSS λͺ¨λ“ˆ

λͺ¨λ“  μ»΄ν¬λ„ŒνŠΈλŠ” .module.scss νŒŒμΌμ„ μ‚¬μš©ν•˜μ—¬ μŠ€νƒ€μΌ μŠ€μ½”ν”„λ₯Ό λΆ„λ¦¬ν•©λ‹ˆλ‹€. μ΄λ ‡κ²Œ ν•˜λ©΄ 클래슀λͺ… 좩돌이 λ°©μ§€λ©λ‹ˆλ‹€.

// component.module.scss

// μ˜¬λ°”λ₯Έ μ˜ˆμ‹œ - CSS λ³€μˆ˜ μ‚¬μš©
.container {
  background-color: var(--background-color);
  color: var(--accent-1);
  border: var(--border-default);
}

// 잘λͺ»λœ μ˜ˆμ‹œ - ν•˜λ“œμ½”λ”© κΈˆμ§€
.container {
  background-color: #ffffff;  // μ‚¬μš© κΈˆμ§€
  color: black;               // μ‚¬μš© κΈˆμ§€
  border: 1px solid #e6e6e6;  // μ‚¬μš© κΈˆμ§€
}

CSS λ³€μˆ˜ (variables.scss)

frontend/src/style/variables.scss에 μ •μ˜λœ μ „μ—­ CSS λ³€μˆ˜λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€:

λ³€μˆ˜ μΉ΄ν…Œκ³ λ¦¬ μ˜ˆμ‹œ μ„€λͺ…
κΈ°λ³Έ 색상 --primary-color, --primary-text-color λΈŒλžœλ“œ 색상
λ°°κ²½ --background-color, --background-hover-color λ°°κ²½ 색상
μ»΄ν¬λ„ŒνŠΈ --component-color, --component-hover-color μ»΄ν¬λ„ŒνŠΈ λ°°κ²½
μ•…μ„ΌνŠΈ --accent-1 ~ --accent-6 흑(1)μ—μ„œ λ°±(6)κΉŒμ§€ 6단계
ν…Œλ‘λ¦¬ --border-default, --border-default-color ν…Œλ‘λ¦¬
λ²„νŠΌ --btn-color-1, --btn-text-color-1 λ²„νŠΌ 색상
μƒνƒœ --status-success, --status-error λ“± μƒνƒœ ν‘œμ‹œ 색상
μΊ”λ²„μŠ€ --canvas-color 에디터 μΊ”λ²„μŠ€ λ°°κ²½

μ „μ—­ μŠ€νƒ€μΌ

파일 μš©λ„
style/global.scss HTML 리셋, μ „μ—­ 클래슀
style/variables.scss CSS λ³€μˆ˜ μ •μ˜ (ν…Œλ§ˆλ³„ μ˜€λ²„λΌμ΄λ“œ)
style/fonts.scss 폰트 λ‘œλ”©

7. ν…Œλ§ˆ μ‹œμŠ€ν…œ

프리셋 ν…Œλ§ˆ

총 6κ°€μ§€ ν…Œλ§ˆ λͺ¨λ“œλ₯Ό μ§€μ›ν•©λ‹ˆλ‹€:

λͺ¨λ“œ μ„€λͺ…
light 라이트 λͺ¨λ“œ (κΈ°λ³Έκ°’)
dark 닀크 λͺ¨λ“œ
high-contrast κ³ λŒ€λΉ„ λͺ¨λ“œ
sepia μ„Έν”Όμ•„ λͺ¨λ“œ (눈 보호)
system μ‹œμŠ€ν…œ μ„€μ • 따름 (prefers-color-scheme)
custom μ‚¬μš©μž μ •μ˜ 색상

ν…Œλ§ˆ 적용 방식

프리셋 ν…Œλ§ˆλŠ” document.documentElement.dataset.theme μ†μ„±μœΌλ‘œ μ μš©λ©λ‹ˆλ‹€:

<!-- 라이트 λͺ¨λ“œ -->
<html data-theme="light">

<!-- 닀크 λͺ¨λ“œ -->
<html data-theme="dark">

SCSSμ—μ„œλŠ” [data-theme] μ„ νƒμžλ‘œ ν…Œλ§ˆλ³„ λ³€μˆ˜λ₯Ό μ˜€λ²„λΌμ΄λ“œν•©λ‹ˆλ‹€.

μ»€μŠ€ν…€ ν…Œλ§ˆ

μ‚¬μš©μžκ°€ 7κ°€μ§€ ν‚€ 컬러λ₯Ό μ„ νƒν•˜λ©΄ themeGenerator.tsκ°€ 50개 μ΄μƒμ˜ CSS λ³€μˆ˜λ₯Ό μžλ™ μƒμ„±ν•©λ‹ˆλ‹€:

// 7κ°€μ§€ ν‚€ 컬러
interface CustomThemeColors {
  primaryColor: string;      // κΈ°λ³Έ 색상
  backgroundColor: string;   // λ°°κ²½ 색상
  componentColor: string;    // μ»΄ν¬λ„ŒνŠΈ 색상
  textColor: string;         // ν…μŠ€νŠΈ 색상
  accentColor: string;       // κ°•μ‘° 색상
  successColor: string;      // 성곡 μƒνƒœ 색상
  errorColor: string;        // μ—λŸ¬ μƒνƒœ 색상
}

이 7κ°€μ§€ μƒ‰μƒμ—μ„œ 밝기/채도 λ³€ν˜•μ„ μžλ™μœΌλ‘œ κ³„μ‚°ν•˜μ—¬ 전체 CSS λ³€μˆ˜ μ„ΈνŠΈλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.

ν…Œλ§ˆ μ €μž₯μ†Œ

ν…Œλ§ˆ 섀정은 두 곳에 μ €μž₯λ©λ‹ˆλ‹€:

μ €μž₯μ†Œ ν‚€ μš©λ„
localStorage appTheme ν…Œλ§ˆ λͺ¨λ“œ (λΉ λ₯Έ λ‘œλ”©)
localStorage customThemeColors μ»€μŠ€ν…€ 색상 (λΉ λ₯Έ λ‘œλ”©)
λ°±μ—”λ“œ DB ThemeSetting λͺ¨λΈ κΈ°κΈ° κ°„ 동기화

ν…Œλ§ˆ API

const { ThemeSettingsAPI } = useAPIv2();

// ν…Œλ§ˆ μ„€μ • 쑰회
const { themeSettings } = await ThemeSettingsAPI.RThemeSettings();

// ν…Œλ§ˆ μ„€μ • μ—…λ°μ΄νŠΈ
await ThemeSettingsAPI.UThemeSettings({
  data: {
    theme: "dark",
    customColors: { ... },
  },
});

μ‹œμŠ€ν…œ ν…Œλ§ˆ 감지

system λͺ¨λ“œμ—μ„œλŠ” prefers-color-scheme λ―Έλ””μ–΄ 쿼리λ₯Ό κ°μ§€ν•˜μ—¬ μžλ™μœΌλ‘œ 라이트/닀크λ₯Ό μ „ν™˜ν•©λ‹ˆλ‹€:

// themeContext.tsx λ‚΄λΆ€
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", (e) => {
  // μ‹œμŠ€ν…œ ν…Œλ§ˆ λ³€κ²½ μ‹œ μžλ™ 반영
});

ν…Œλ§ˆ κ΄€λ ¨ 파일

파일 μ—­ν• 
contexts/themeContext.tsx ThemeProvider, useTheme() ν›…
utils/themeGenerator.ts 7κ°€μ§€ ν‚€ 컬러 β†’ 50+ CSS λ³€μˆ˜ 생성
style/variables.scss ν…Œλ§ˆλ³„ CSS λ³€μˆ˜ μ •μ˜
pages/settings/tab/ThemeSettings.tsx ν…Œλ§ˆ μ„€μ • UI

8. μ£Όμš” μ»΄ν¬λ„ŒνŠΈ

μΊ˜λ¦°λ” V2

components/calendarV2/에 μœ„μΉ˜ν•œ μΊ˜λ¦°λ” μ‹œμŠ€ν…œμ€ μ›”κ°„/μ£Όκ°„/일간 λ·°λ₯Ό μ§€μ›ν•©λ‹ˆλ‹€.

파일 μ—­ν• 
Calendar.tsx 메인 μΊ˜λ¦°λ” μ»΄ν¬λ„ŒνŠΈ
calendarData.ts 데이터 계측 (DateItem, EventItem, Calendar 클래슀)
MonthlyViewer.tsx μ›”κ°„ λ·°
WeeklyViewer.tsx μ£Όκ°„/일간 λ·°
EventPopup.tsx 이벀트 상세 νŒμ—…
EventFormPopup.tsx 이벀트 생성/μˆ˜μ • νŒμ—…
SettingPopup.tsx μΊ˜λ¦°λ” μ„€μ • νŒμ—…

μ΄λ²€νŠΈλŠ” sourceType으둜 좜처λ₯Ό κ΅¬λΆ„ν•©λ‹ˆλ‹€:

sourceType 좜처
manual μˆ˜λ™ μž…λ ₯
enrollment μˆ˜κ°• 정보 μžλ™ 동기화
syllabus κ°•μ˜κ³„νšμ„œ μžλ™ 동기화
memo λ©”λͺ¨ μžλ™ 동기화

양식 에디터

editor/에 μœ„μΉ˜ν•œ 블둝 기반 양식 μ—λ””ν„°λŠ” μ‹œκ°„ν‘œ, κ°•μ˜κ³„νšμ„œ λ“±μ˜ 양식을 μ„€κ³„ν•©λ‹ˆλ‹€:

디렉토리 μ—­ν• 
Editor.tsx 에디터 메인 μ»΄ν¬λ„ŒνŠΈ
blocks/ 에디터 블둝 μ»΄ν¬λ„ŒνŠΈ (ν…μŠ€νŠΈ, ν…Œμ΄λΈ”, 이미지 λ“±)
store/useEditorStore.ts Zustand 기반 에디터 μƒνƒœ 관리
parser/ 에디터 데이터 νŒŒμ‹±

λͺ©μ°¨λ‘œ λŒμ•„κ°€κΈ°