Altsis νλ‘ νΈμλλ React 18 + TypeScript κΈ°λ°μ SPA(Single Page Application)μ λλ€. μ΄ λ¬Έμμμλ νλ‘ νΈμλ κ°λ°μ νμν ν΅μ¬ κ°λ κ³Ό ν¨ν΄μ μ€λͺ ν©λλ€.
- κΈ°μ μ€ν
- λΌμ°ν
- μ»΄ν¬λνΈ μμ± ν¨ν΄
- μν κ΄λ¦¬
- API νΈμΆ (useAPIv2)
- μ€νμΌλ§
- ν λ§ μμ€ν
- μ£Όμ μ»΄ν¬λνΈ
| κΈ°μ | λ²μ | μ©λ |
|---|---|---|
| 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 | λ§ν¬λ€μ΄ λ λλ§ |
νλ‘ νΈμλ λΌμ°ν
μ frontend/src/routes/RouterPage.tsxμμ μ μλ©λλ€.
μΈμ¦λ μ¬μ©μμ 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.tsxλ URLμ :academyIdμ :schoolId νλΌλ―Έν°λ₯Ό AuthContextμ λκΈ°ννλ λνΌ μ»΄ν¬λνΈμ
λλ€. /:academyId/:schoolId νμμ λͺ¨λ λΌμ°νΈμμ λμν©λλ€.
μΈμ¦μ΄ νμν λΌμ°νΈλ RequireAuth μ»΄ν¬λνΈλ‘ κ°μΈμ Έ μμ΅λλ€:
// λͺ¨λ μΈμ¦λ μ¬μ©μ νμ©
<RequireAuth>
<Home />
</RequireAuth>
// νΉμ κΆνλ§ νμ©
<RequireAuth auth={["admin", "manager"]}>
<Admin />
</RequireAuth>
// owner μ μ©
<RequireAuth auth={["owner"]}>
<Academies />
</RequireAuth>auth propμ΄ μμΌλ©΄ λ‘κ·ΈμΈν λͺ¨λ μ¬μ©μλ₯Ό νμ©ν©λλ€. auth λ°°μ΄μ΄ μμΌλ©΄ ν΄λΉ κΆνμ κ°μ§ μ¬μ©μλ§ μ κ·Όν μ μμ΅λλ€. κΆνμ΄ μλ κ²½μ° "μλͺ»λ μ κ·Όμ
λλ€." μλ¦Όκ³Ό ν¨κ» 리λ€μ΄λ νΈλ©λλ€.
μ λμ¬ μλ λ κ±°μ URL(/courses/*, /boards/* λ±)μ LegacyRedirect μ»΄ν¬λνΈλ₯Ό ν΅ν΄ μλμΌλ‘ μ URL νμ(/:academyId/:schoolId/...)μΌλ‘ 리λ€μ΄λ νΈλ©λλ€.
λͺ¨λ μ»΄ν¬λνΈλ .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)}
/>Altsisλ μ¬λ¬ κ³μΈ΅μ μν κ΄λ¦¬λ₯Ό μ¬μ©ν©λλ€:
νμ¬ λ‘κ·ΈμΈν μ¬μ©μμ μ 체 컨ν μ€νΈλ₯Ό μ 곡ν©λλ€:
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λ μ± μ΅μμμμ μ 곡λλ©°, λ‘κ·ΈμΈ μ μλμΌλ‘ λ€μμ μνν©λλ€:
- μ¬μ©μ μ 보 λ‘λ (
UserAPI.RMySelf()) - μΏ ν€ κΈ°λ° νκ΅ λ³΅μ (
cookies.currentSchool) - νκΈ° λ±λ‘ μ 보 λ‘λ
- νκΈ° μ 보 λ‘λ
ν λ§ μνλ₯Ό κ΄λ¦¬ν©λλ€:
import { useTheme } from "contexts/themeContext";
const MyComponent = () => {
const {
theme, // νμ¬ ν
λ§ λͺ¨λ ("light" | "dark" | "system" | ...)
resolvedTheme, // μ€μ μ μ©λ ν
λ§ ("light" | "dark" | "custom" λ±)
darkModeActive, // λ€ν¬ λͺ¨λ νμ± μ¬λΆ
customColors, // 컀μ€ν
ν
λ§ μμ κ°μ²΄
setTheme, // ν
λ§ λ³κ²½ ν¨μ
setCustomColors, // 컀μ€ν
μμ λ³κ²½ ν¨μ
} = useTheme();
};μμ μλν°λ Zustandλ‘ μνλ₯Ό κ΄λ¦¬ν©λλ€:
import { useEditorStore } from "editor/store/useEditorStore";
const EditorComponent = () => {
const { blocks, addBlock, updateBlock, removeBlock } = useEditorStore();
// ...
};μΈμ μμμ±μ μν΄ μΏ ν€λ₯Ό μ¬μ©ν©λλ€:
import { useCookies } from "react-cookie";
const [cookies, setCookie, removeCookie] = useCookies([
"currentSchool", // νμ¬ μ νλ νκ΅ ID
"currentRegistration", // νμ¬ μ νλ νκΈ° λ±λ‘ ID
]);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);
}
};
};λͺ¨λ 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)λ‘ λ¨κ±΄/λͺ©λ‘ μ‘°νλ₯Ό ꡬλΆν©λλ€.
// μμ± (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 μλ²
λͺ¨λ μ»΄ν¬λνΈλ .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; // μ¬μ© κΈμ§
}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 |
ν°νΈ λ‘λ© |
μ΄ 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 λͺ¨λΈ |
κΈ°κΈ° κ° λκΈ°ν |
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 |
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/ |
μλν° λ°μ΄ν° νμ± |