docs: Sync documentation from private repository
This commit is contained in:
parent
4832f932e9
commit
cfa489c494
767
API_SPEC.md
767
API_SPEC.md
@ -547,6 +547,8 @@ patternAnalyses/{contentHash}
|
|||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
name: string; // 팀 이름
|
name: string; // 팀 이름
|
||||||
|
grade?: number; // 학년 (1~6) — D17: 팀 생성 시 학년 고정
|
||||||
|
curriculumId?: string; // 커리큘럼 ID (예: "g3-c1") — D16: 팀 생성 시 8주 코스 고정
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -621,10 +623,13 @@ patternAnalyses/{contentHash}
|
|||||||
teamId: string;
|
teamId: string;
|
||||||
data: {
|
data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
grade?: number; // 학년 (1~6) — D17
|
||||||
|
curriculumId?: string; // 커리큘럼 ID (예: "g3-c1") — D16; grade와 일치해야 함
|
||||||
securityMode?: "simple" | "normal" | "open";
|
securityMode?: "simple" | "normal" | "open";
|
||||||
requirePin?: boolean;
|
requirePin?: boolean;
|
||||||
allowAnonymousJoin?: boolean;
|
allowAnonymousJoin?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
// 수업 일정은 여기 없다 — 날짜는 회차 문서가 든다 (「TeamSession API (회차)」 참조)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -700,17 +705,22 @@ patternAnalyses/{contentHash}
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8. POST `/team/add-member` - 팀에 멤버 추가
|
### 8. POST `/team/add-member` - 공개 팀 self-join
|
||||||
실제 URL: `POST /api/team/add-member`
|
실제 URL: `POST /api/team/add-member`
|
||||||
|
|
||||||
**인증**: 필수
|
**인증**: 필수 (본인 참여 전용 — body의 `uid`는 인증된 사용자와 일치해야 하며, 생략 가능)
|
||||||
|
|
||||||
|
**제약**:
|
||||||
|
- 공개 팀(`isPublic: true`)만 참여 가능 — 비공개 팀은 초대 링크로만
|
||||||
|
- 비활성 팀(`isActive: false`) 참여 불가
|
||||||
|
- 이미 멤버/소유자면 멱등 성공
|
||||||
|
|
||||||
**Request**:
|
**Request**:
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
teamId: string;
|
teamId: string;
|
||||||
uid: string;
|
uid?: string; // 선택 — 전달 시 본인 uid여야 함 (타인 추가 불가)
|
||||||
nickname?: string; // 🆕 닉네임 (선택적)
|
nickname?: string; // 닉네임 (선택적)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -859,6 +869,405 @@ await teamManager.removeMember(teamId, currentUser.uid);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🆕 19. GET `/team/:teamId/attendance` - 팀 출결관리 탭 데이터 조회
|
||||||
|
|
||||||
|
실제 URL: `GET /api/team/:teamId/attendance`
|
||||||
|
|
||||||
|
**인증**: 필수 (팀 소유자만)
|
||||||
|
|
||||||
|
**설명**: 출석의 유일한 진실은 `teamSessions/{sessionId}/checkins` 서브컬렉션이다 (`src/lib/server/teamSession.ts`). 이 라우트는 회차+체크인 데이터를 응답 DTO 형태로 집계만 한다 (`src/lib/server/attendance.ts`).
|
||||||
|
|
||||||
|
**"최근 수업"은 관측된 회차만이다.** 개설 시점에 8주치 회차를 미리 만들어 두므로 그냥 date DESC로 뽑으면 목록 앞쪽이 통째로 아직 하지 않은 미래 회차다. 그래서 `getObservedSessions`가 `in_progress`·`done`만 본다. 관측된 회차가 하나도 없으면 다음 예정 회차로 "언제 첫 수업인가"에 답하고, 그것마저 없을 때만 `hasSchedule: false`다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hasSchedule: boolean;
|
||||||
|
classTime?: string; // "HH:mm"
|
||||||
|
membersTotal: number; // 팀 소유자 제외 인원수
|
||||||
|
date?: string; // "YYYY-MM-DD" (오늘 또는 가장 최근 수업일, KST)
|
||||||
|
present: number;
|
||||||
|
late: number;
|
||||||
|
rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 출석(지각 포함)한 팀원만
|
||||||
|
history: Array<{ date: string; membersTotal: number; present: number; late: number }>; // 최근 수업일 최대 5개 (최신순)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**백엔드 로직**:
|
||||||
|
1. `getTeam(teamId)` 조회 후 소유자 권한 체크
|
||||||
|
2. `getObservedSessions(teamId, 5)` — `in_progress`·`done` 회차를 date 내림차순 최대 5개
|
||||||
|
3. 하나도 없으면 `getNextScheduledSession(teamId)`로 다음 예정 회차의 시각만 실어 보낸다
|
||||||
|
4. 가장 최근 회차가 오늘의 출결, 전체 목록이 이력 — `buildAttendanceSummary`로 회차+체크인을 응답 DTO로 집계
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트에서 1분간 캐싱 (`teamManager.getTeamAttendance(teamId)`)
|
||||||
|
|
||||||
|
**Firestore 인덱스**: `teamSessions(teamId, status, date DESC)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 20. POST `/team/:teamId/session/checkin` - 학생 체크인
|
||||||
|
|
||||||
|
실제 URL: `POST /api/team/:teamId/session/checkin`
|
||||||
|
|
||||||
|
**인증**: 필수 (팀 멤버만, `uid in team.members`)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
sessionId: string; // GET /session/current 이 돌려준 회차 ID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**설명**: 대기실 도착 시 호출하는 체크인. 멱등 — 재호출·URL 직접 접근 시에도 최초 체크인 레코드를 그대로 반환한다.
|
||||||
|
|
||||||
|
**이 라우트에는 오프너가 없다.** 회차를 여는 것은 `GET /session/current`이고, 클라이언트는 거기서 받은 ID로 여기 온다. 여기서 한 번 더 열면 같은 전이 규칙이 두 경로에 복제되고, 복제된 규칙이 어긋나는 것이 이 모델을 다시 만든 원래 병이다. 그래서 이 라우트는 **검증만** 한다.
|
||||||
|
|
||||||
|
**서버는 클라이언트가 보낸 `sessionId`를 믿지 않는다.** 그 ID는 쉽게 낡는다 — 교사가 휴강으로 날짜를 옮기면 학생이 들고 있던 ID는 어제 회차를 가리킨 채 **여전히 유효한 문서**다. 그대로 두면 출석이 조용히 다른 회차에 기록된다. 그래서 세 가지를 다시 확인하고, 하나라도 어긋나면 **409**다:
|
||||||
|
|
||||||
|
| 검증 | 어긋났을 때 |
|
||||||
|
|---|---|
|
||||||
|
| 회차의 `teamId`가 경로의 팀과 같은가 | 409 "이 반의 회차가 아닙니다." — 경로 권한만 보고 통과시키면 남의 반 출석부에 이름이 남는다 |
|
||||||
|
| `status === "in_progress"`인가 | 409 "아직 시작하지 않았거나 이미 끝난 수업입니다." — 문서 존재는 열림을 뜻하지 않는다 |
|
||||||
|
| 회차 날짜가 오늘(KST)인가 (`assertSessionIsToday`) | 409 "오늘의 수업이 아닙니다. 화면을 새로고침해 주세요." |
|
||||||
|
|
||||||
|
셋 다 `console.warn`으로 흔적을 남긴다 — 조용한 오기록보다 시끄러운 거절이 낫다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
session: TeamSession;
|
||||||
|
myCheckIn: SessionCheckIn; // { uid, arrivedAt, isLate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러**: 400(`sessionId` 누락), 403(팀 멤버 아님·비활성 팀), 404(팀/회차 없음), 409(위 3종)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.checkIn(teamId, sessionId)` — 성공 시 입장 카드 캐시를 즉시 무효화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 21. GET `/team/:teamId/session/current` - 수업 대기실 데이터 조회
|
||||||
|
|
||||||
|
실제 URL: `GET /api/team/:teamId/session/current`
|
||||||
|
|
||||||
|
**인증**: 필수 (팀 멤버만)
|
||||||
|
|
||||||
|
**설명**: 대기실 화면용 데이터. **의도적 GET write-through** — `getInProgressSession`이 전이를 수행하므로 이 조회 자체가 오프너다. 크론이 없어 수업을 실제로 여는 것은 이런 요청 경로뿐이고, 학생 체크인은 여기서 받은 ID로 이뤄지므로 이 라우트가 대기실의 오프너 단일 지점이다.
|
||||||
|
|
||||||
|
⚠️ `session: null`은 "오늘 회차 문서가 없다"가 **아니라** "지금 진행 중인 회차가 없다"다. 개설 시점에 8주치 회차를 만들어 두므로 문서 존재는 아무것도 말해 주지 않는다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
session: TeamSession | null;
|
||||||
|
checkIns: Array<SessionCheckIn & { name: string }>; // 팀 닉네임 포함
|
||||||
|
myCheckIn: SessionCheckIn | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러**: 403(팀 멤버 아님), 404(팀 없음), 409(진행 중 회차가 둘 이상 — 하나를 조용히 고르면 출석·글이 틀린 회차에 영구 기록되므로 고르지 않고 거부한다)
|
||||||
|
|
||||||
|
**캐싱**: 없음 (근실시간성 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TeamSession API (회차)
|
||||||
|
|
||||||
|
수업 1회가 문서 1건이다(`teamSessions/{randomId}`). 그 문서가 날짜·시각·주차·글감·글작성 단계·진행 상태·출석을 전부 들기 때문에 "오늘 무슨 수업인가"는 계산이 아니라 조회 한 줄이고, 교사 표와 학생 알림장이 같은 행을 읽는다. 데이터 모델은 `DATA_MODELS.md`의 「TeamSession」 절을 참조.
|
||||||
|
|
||||||
|
구 배정 API(`/team/:teamId/assignment` 4종)와 구 세션 API(`POST /team/:teamId/session`)는 이 API로 대체되어 **제거되었다**. `assignments`·`classSessions` 컬렉션도 함께 삭제됐다.
|
||||||
|
|
||||||
|
**세 가지를 먼저 알아야 한다:**
|
||||||
|
|
||||||
|
1. **전이에 크론이 없다.** 회차를 실제로 여닫는 것은 조회 요청뿐이다 — `GET /team/:teamId/sessions`, `GET /team/:teamId/session/current`, `GET /student/sessions`, `GET /teacher/attendance`가 응답 직전에 전이를 수행한다(의도적 GET write-through). 부수 효과가 지저분해 보인다고 떼면 수업이 영영 열리지 않는다. 반대로 `GET /teacher/sessions`는 **일부러 전이를 하지 않는다** — 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리면 안 되기 때문이다.
|
||||||
|
2. **회차 개수는 팀 개설 시점에만 정해진다.** 주 N회는 사후에 바꿀 수 없고, 어떤 요청 타입에도 `weeklyCount` 필드가 없다. 늘리거나 줄이는 것은 단건 추가·삭제 경로의 일이다.
|
||||||
|
3. **에러 코드는 공통이다.** 서버 레이어의 `TeamSessionError`를 라우트가 그대로 매핑한다 — `NOT_FOUND`→404, `VALIDATION_ERROR`→400, `CONFLICT`→409.
|
||||||
|
|
||||||
|
**Firestore 인덱스**: `teamSessions(teamId, date)`, `(teamId, status)`, `(teamId, status, date ASC)`, `(teamId, status, date DESC)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. GET `/team/:teamId/sessions` - 팀 회차 전체 조회
|
||||||
|
|
||||||
|
실제 URL: `GET /api/team/:teamId/sessions`
|
||||||
|
|
||||||
|
**인증**: 필수 (팀 멤버 — 소유자 또는 `uid in team.members`)
|
||||||
|
|
||||||
|
**설명**: 날짜 오름차순 전체 목록. **이 GET은 쓰기를 한다** — 응답 전에 `transitionTeamSessions`를 부르므로 담겨 오는 `status`는 "지금" 기준으로 갱신된 값이다. 전이가 실패해도 조회는 성공한다(저장된 `status`로 그릴 수 있는 화면은 전부 그려져야 한다). 다만 실패는 반드시 `console.error`로 남는다 — 크론이 없어 이 로그가 유일한 관측 수단이다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: { sessions: TeamSession[] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러**: 403(팀 멤버 아님), 404(팀 없음)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.getTeamSessions(teamId)` (캐싱 30초, 조작 후 즉시 무효화)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. POST `/team/:teamId/sessions` - 회차 생성 (배치·단건 겸용)
|
||||||
|
|
||||||
|
실제 URL: `POST /api/team/:teamId/sessions`
|
||||||
|
|
||||||
|
**인증**: 필수 (**팀 소유자 = 교사만**)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
sessions: Array<{
|
||||||
|
date: string; // "YYYY-MM-DD"
|
||||||
|
time: string; // "HH:mm"
|
||||||
|
weekId: string;
|
||||||
|
topicId: string; // 필수 — 빈 문자열 불가
|
||||||
|
writingStages?: WritingStage[]; // 생략 시 서버 기본값
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**배열 하나가 배치 생성과 단건 추가를 겸한다.** 팀 개설은 `주 N회 × 8주`개를 한 번에 보내고, 회차 끼워넣기는 길이 1인 배열을 보낸다. 쓰기가 **트랜잭션 1회**라 부분 성공이 없다 — 24건을 24요청으로 쪼개면 중간 실패 시 절반만 있는 팀이 남는다.
|
||||||
|
|
||||||
|
입력 검증은 라우트와 서버 레이어 양쪽에 있다. 라우트가 한 번 더 보는 이유는 **24건 중 몇 번째가 잘못됐는지는 라우트만 알려 줄 수 있기** 때문이다(`sessions[7].topicId가 필요합니다.`).
|
||||||
|
|
||||||
|
**Response** (201):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: { sessions: TeamSession[] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러**: 400(형식·글감 누락), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(같은 날짜 회차가 이미 있음 — 하루 1회차)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.createSessions(teamId, inputs)` / `createSession(teamId, input)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. PUT `/team/:teamId/sessions/:sessionId` - 회차 수정
|
||||||
|
|
||||||
|
실제 URL: `PUT /api/team/:teamId/sessions/:sessionId`
|
||||||
|
|
||||||
|
**인증**: 필수 (**팀 소유자 = 교사만**)
|
||||||
|
|
||||||
|
**Request** (넘긴 필드만 바뀐다. 전부 생략하면 400):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
date?: string;
|
||||||
|
time?: string;
|
||||||
|
topicId?: string; // 비울 수 없다
|
||||||
|
writingStages?: WritingStage[]; // 1개 이상
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
회차가 독립 문서라 **인접 회차를 건드리지 않고** 한 건만 고칠 수 있다. 구 모델은 일정이 팀 문서 안의 배열이라 3회차 날짜를 옮기려면 배열 전체를 다시 썼다. 회차 ID가 불변이므로 날짜를 옮겨도 그 회차를 가리키던 글·출석은 끊기지 않는다.
|
||||||
|
|
||||||
|
**Response**: `{ success: true, data: { session: TeamSession } }`
|
||||||
|
|
||||||
|
**에러**: 400(형식·수정 항목 없음), 403(소유자 아님·비활성 팀), 404(팀/회차 없음 — 다른 팀의 회차 ID도 404), 409(옮긴 날짜에 이미 회차가 있음)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.updateSession(teamId, sessionId, patch)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. PATCH `/team/:teamId/sessions/:sessionId` - 수동 열기 / 종료
|
||||||
|
|
||||||
|
실제 URL: `PATCH /api/team/:teamId/sessions/:sessionId`
|
||||||
|
|
||||||
|
**인증**: 필수 (**팀 소유자 = 교사만**)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{ action: "open" | "close" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUT과 나눈 이유**: 열기는 `order` 부여 + 같은 팀의 다른 진행 회차 종료를 한 트랜잭션에서 하는 **상태 전이**이지 필드 수정이 아니다. 한 라우트에 섞으면 "날짜만 고치려던 요청이 수업을 여는" 사고가 가능해진다.
|
||||||
|
|
||||||
|
- `open`: 확정 회차 번호(`order`)를 부여하고, 같은 팀에 진행 중인 회차가 있으면 함께 닫는다(`closedBy: "auto:open"`). 팀당 `in_progress`는 항상 최대 1개다.
|
||||||
|
- `close`: `closedAt`·`closedBy`를 남기고 `done`으로 내린다.
|
||||||
|
|
||||||
|
**Response**: `{ success: true, data: { session: TeamSession } }`
|
||||||
|
|
||||||
|
**에러**: 400(action 누락/오타), 403(소유자 아님·비활성 팀), 404(팀/회차 없음), 409(전이 제약 위반)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.openSession(teamId, sessionId)` / `closeSession(teamId, sessionId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. DELETE `/team/:teamId/sessions/:sessionId` - 회차 삭제
|
||||||
|
|
||||||
|
실제 URL: `DELETE /api/team/:teamId/sessions/:sessionId`
|
||||||
|
|
||||||
|
**인증**: 필수 (**팀 소유자 = 교사만**)
|
||||||
|
|
||||||
|
🔒 **`scheduled` 회차만 지울 수 있다.** 진행·완료 회차를 지우면 `checkins` 서브컬렉션이 부모 없는 고아로 남아 어떤 조회에도 걸리지 않은 채 살아 있다 — 그건 삭제가 아니라 조용한 기록 파기다. 없는 회차는 멱등 성공이다.
|
||||||
|
|
||||||
|
**Response**: `{ success: true, data: { deleted: true } }`
|
||||||
|
|
||||||
|
**에러**: 403(소유자 아님·비활성 팀), 404(팀 없음 · 다른 팀의 회차 ID), 409(`scheduled`가 아님)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.deleteSession(teamId, sessionId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. POST `/team/:teamId/sessions/regenerate` - 일정 규칙 일괄 재생성
|
||||||
|
|
||||||
|
실제 URL: `POST /api/team/:teamId/sessions/regenerate`
|
||||||
|
|
||||||
|
**인증**: 필수 (**팀 소유자 = 교사만**)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{ dates: Array<{ date: string; time: string }> } // 날짜 오름차순
|
||||||
|
```
|
||||||
|
|
||||||
|
**삭제-재생성이 아니라 UPDATE다.** 문서 ID가 유지되어야 그 회차를 가리키는 출석·글이 끊기지 않는다. 그래서 **개수는 바꿀 수 없고**, `dates.length`가 예정 회차 수와 다르면 400이다.
|
||||||
|
|
||||||
|
`scheduled` 회차의 날짜만 갈아끼운다 — 진행·완료 회차는 날짜도 문서 ID도 바뀌지 않는다. 중복 검사는 **진행·완료 회차의 날짜를 포함한 전체 집합**에 건다. 예정분만 보고 검사하면 새 날짜가 이미 끝난 회차의 날짜와 겹쳐 하루 2회차가 생긴다.
|
||||||
|
|
||||||
|
**Response**: `{ success: true, data: { sessions: TeamSession[] } }`
|
||||||
|
|
||||||
|
**에러**: 400(형식·같은 날짜 중복·개수 불일치), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(전체 집합 기준 날짜 충돌)
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.regenerateSessions(teamId, dates)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teacher API
|
||||||
|
|
||||||
|
### 🆕 1. GET `/teacher/stats` - 선생님 홈 통계 (미확인/미평가 과제 수)
|
||||||
|
실제 URL: `GET /api/teacher/stats`
|
||||||
|
|
||||||
|
**인증**: 필수 (교사 역할, `withTeacherAuth`)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
unevaluatedCount: number; // 미평가 과제 수
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**미평가 정의**: 내 팀(소유한 팀 + 참여한 팀)에 속한 `status === "published"` 글 중, 내(선생님)가 작성한 댓글이 하나도 없는 글 (`comments` 컬렉션에서 `userId == 나`, `isDeleted == false` 기준으로 판단. 학생 댓글은 평가로 카운트하지 않음)
|
||||||
|
|
||||||
|
**백엔드 로직**:
|
||||||
|
1. `getAllUserTeams(uid)` - 내 팀 목록 조회
|
||||||
|
2. `comments` 컬렉션에서 내가 작성한 댓글의 `writingId` 집합 조회
|
||||||
|
3. `writings` 컬렉션에서 내 팀들의 `published` 글 조회 (`teamId in [...]`, 10개 청크 분할)
|
||||||
|
4. 댓글 집합에 없는 글 개수 = `unevaluatedCount`
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트에서 2분간 캐싱
|
||||||
|
|
||||||
|
**Firestore 인덱스**: `comments(userId, isDeleted)`, `writings(teamId, status)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 2. GET `/teacher/attendance` - 선생님 홈 출석현황 패널
|
||||||
|
|
||||||
|
실제 URL: `GET /api/teacher/attendance`
|
||||||
|
|
||||||
|
**인증**: 필수 (교사 역할, `withTeacherAuth`)
|
||||||
|
|
||||||
|
**설명**: 출석의 유일한 진실은 `teamSessions/{sessionId}/checkins` 서브컬렉션이다. 대상 팀은 **오늘 날짜의 회차 문서가 있는 팀**이다.
|
||||||
|
|
||||||
|
🔴 **여기서 불변식이 반전됐다.** 구 모델에서 "오늘 날짜의 세션 문서가 있다"는 곧 "수업이 열렸다"였다 — 문서는 수업이 열릴 때 비로소 만들어졌기 때문이다. 신 모델은 개설 시점에 8주치 회차를 미리 만들므로 문서 존재는 아무것도 말해 주지 않고, 열림 여부는 `status`로만 판정한다. 시간표 역산(`isScheduledOn`/`timeForDate`)도 사라졌다 — 회차 문서가 날짜·시각을 직접 든다.
|
||||||
|
|
||||||
|
**의도적 GET write-through**: `getTodaySessionsForTeams`가 시각이 도래한 회차의 전이를 함께 수행하므로, 아무도 접속하지 않은 수업도 교사가 홈을 열어보는 시점에 정확히 열린다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
teams: Array<{
|
||||||
|
teamId: string;
|
||||||
|
name: string;
|
||||||
|
classTime: string; // "HH:mm"
|
||||||
|
membersTotal: number; // 팀 소유자 제외 인원수
|
||||||
|
attendance: { present: number; late: number } | null; // null = 현재(KST) < classTime, 수업 전 대기
|
||||||
|
rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 상위 5명
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**백엔드 로직**:
|
||||||
|
1. `getAllUserTeams(uid)` - 내 팀 목록 조회
|
||||||
|
2. `getTodaySessionsForTeams`로 오늘 날짜의 회차를 청크 단위 단일 쿼리로 조회 — 여기 걸린 팀만 대상. **오늘 날짜 토큰은 이 함수 안에만 있다**(라우트에 날짜 비교를 두지 않는다)
|
||||||
|
3. 회차가 `scheduled`면 아직 시각 전이므로 `attendance: null`("수업 전 대기")
|
||||||
|
4. 아니면 `buildAttendanceSummary`로 체크인 데이터를 응답 DTO로 집계 (팀원의 최초 도착 시각 = 출석 시각, `baselineAt + graceMinutes` 초과 시 지각)
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트에서 1분간 캐싱 (`teamManager.getTodayAttendance()`)
|
||||||
|
|
||||||
|
**Firestore 인덱스**: `teamSessions(teamId, date)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 3. GET `/teacher/sessions` - 내 팀 전체의 회차 조회 (교사 목록 화면)
|
||||||
|
|
||||||
|
실제 URL: `GET /api/teacher/sessions`
|
||||||
|
|
||||||
|
**인증**: 필수 (교사 역할, `withTeacherAuth`)
|
||||||
|
|
||||||
|
**설명**: 팀 카드의 요일 칩, 팀 리포트 표의 수업일정·시간·진행 회차, 학습 스케줄 캘린더 — 셋 다 **여러 팀의 일정을 한 화면에** 그린다. 팀마다 `/team/:id/sessions`를 부르면 그대로 N+1이라 조회를 여기 하나로 모았다.
|
||||||
|
|
||||||
|
🔴 **이 GET은 전이를 하지 않는다 — 오프너가 아니다.** 여기까지 전이를 걸면 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리고, 그 비용은 팀 수만큼의 트랜잭션이 된다. 이 응답은 **그리기 위한 스냅샷**이다.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: { sessions: TeamSession[] } // 내 모든 팀, 날짜 오름차순
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manager**: `teamSessionManager.getMyTeamSessions()` (캐싱 30초)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Student API
|
||||||
|
|
||||||
|
### 🆕 1. GET `/student/sessions` - 학생 홈 입장 카드용 회차 알림 조회
|
||||||
|
|
||||||
|
실제 URL: `GET /api/student/sessions`
|
||||||
|
|
||||||
|
**인증**: 필수 (`withAuth`)
|
||||||
|
|
||||||
|
**설명**: 내 모든 팀에 대해 `getTeamSessionNotificationsForUser(uid)`(알림 파생의 단일 지점)를 호출한다. 각 팀은 회차 전이를 거쳐 lazy 판정(오픈 포함)되므로, 크론이 없는 지금 이 조회가 그 팀의 유일한 관찰자일 수 있다(의도적 GET write-through).
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
notifications: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
teamId: string;
|
||||||
|
targetUid: string;
|
||||||
|
deepLink: string; // getClassSessionHref(teamId) — 단일 지점에서 생성
|
||||||
|
channel: "inapp"; // 현재는 인앱 카드만 소비, 추후 채널 추가 시 이음새
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트에서 30초 캐싱 (`teamSessionManager.getEnterableSessions()`, 체크인 후 즉시 무효화). ⚠️ 이 매니저에서 TTL은 성능 손잡이가 아니라 **전환 지연 손잡이**다 — 크론이 없어 조회가 곧 전이이므로, TTL을 늘리면 "수업이 늦게 열린다"가 같이 늘어난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## User API
|
## User API
|
||||||
|
|
||||||
**중요**: User vs FirestoreUser 구분
|
**중요**: User vs FirestoreUser 구분
|
||||||
@ -1265,6 +1674,85 @@ const credits = convertKRWToCredits(refundKRW); // 100원 = 10 크레딧
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Notice API
|
||||||
|
|
||||||
|
### 1. GET `/notice` - 공지사항 목록 조회
|
||||||
|
실제 URL: `GET /api/notice`
|
||||||
|
|
||||||
|
**인증**: 필수 (일반 사용자)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
notices: Notice[]; // 고정 공지 우선, 이후 최신순
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. POST `/notice` - 공지사항 생성
|
||||||
|
실제 URL: `POST /api/notice`
|
||||||
|
|
||||||
|
**인증**: 필수 (관리자 전용, Custom Claim `isAdmin === true`)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
notice: Notice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. PUT `/notice/:noticeId` - 공지사항 수정
|
||||||
|
실제 URL: `PUT /api/notice/:noticeId`
|
||||||
|
|
||||||
|
**인증**: 필수 (관리자 전용)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
notice: Notice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DELETE `/notice/:noticeId` - 공지사항 삭제
|
||||||
|
실제 URL: `DELETE /api/notice/:noticeId`
|
||||||
|
|
||||||
|
**인증**: 필수 (관리자 전용)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Topic API
|
## Topic API
|
||||||
|
|
||||||
### 1. POST `/topic/available` - 사용 가능한 주제 목록
|
### 1. POST `/topic/available` - 사용 가능한 주제 목록
|
||||||
@ -1300,6 +1788,35 @@ const credits = convertKRWToCredits(refundKRW); // 100원 = 10 크레딧
|
|||||||
|
|
||||||
**참고**: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다.
|
**참고**: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다.
|
||||||
|
|
||||||
|
**참고**: 이 엔드포인트는 PERSONAL + TEAM 글감만 반환합니다. 시스템 글감(SYSTEM)은 포함되지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1-B. GET `/topic?weekId=` - 주차별 시스템 글감 목록
|
||||||
|
실제 URL: `GET /api/topic?weekId={weekId}`
|
||||||
|
|
||||||
|
**인증**: 불필요 (시스템 콘텐츠)
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `weekId`: string (필수 — 예: "g3-c1-w01")
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
topics: Topic[]; // ownerType === SYSTEM, topic.weekId === weekId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manager 메서드**:
|
||||||
|
- `topicManager.getByWeek(weekId)`
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
||||||
|
|
||||||
|
**참고**: 시스템 글감(128개/학년)은 이 엔드포인트를 통해서만 접근 가능합니다. `weekId` 없이 호출하면 400 에러를 반환합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. GET `/topic/:id` - 주제 조회
|
### 2. GET `/topic/:id` - 주제 조회
|
||||||
@ -1767,264 +2284,104 @@ export async function createTeam(data: CreateTeamRequest): Promise<ApiResponse<C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Curriculum & Lesson API
|
## Curriculum & Week API
|
||||||
|
|
||||||
|
데이터 계층: **Curriculum(4개/학년) → CurriculumWeek(8개/커리큘럼, 32개/학년) → Topic(4개/주차, 128개/학년)**
|
||||||
|
|
||||||
|
- `Curriculum` 컬렉션: `id`(예: "g3-c1"), `grade`, `track`("C1"~"C4"), `name`, `description`, `totalWeeks`, `isActive`
|
||||||
|
- `CurriculumWeek` 컬렉션: `id`(예: "g3-c1-w01"), `curriculumId`, `grade`, `week`(1~8), `name`, `weekGoal`, `competency`, `learningGoal`, `writingActivity`, `keyExpressions`, `output`, `teacherPoint`, `topicCriteria`, `drawingDirection`, `isActive`
|
||||||
|
- 시스템 데이터는 읽기 전용(인증 불필요). 관리자 쓰기 엔드포인트는 미구현.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### GET /api/curriculum
|
### GET /api/curriculum
|
||||||
|
|
||||||
시스템 또는 팀 커리큘럼 목록 조회
|
커리큘럼 목록 조회 (학년별 필터 선택적)
|
||||||
|
|
||||||
**Query Parameters**:
|
**Query Parameters**:
|
||||||
- `type`: "system" | "team" (선택)
|
- `grade`: number (선택 — 학년 필터)
|
||||||
- `teamId`: 팀 ID (type=team 시 필수)
|
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
curriculums: Curriculum[]
|
data: { curricula: Curriculum[] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manager 메서드**:
|
**Manager 메서드**:
|
||||||
- `curriculumManager.getSystemCurriculums()`
|
- `curriculumManager.getByGrade(grade?)` — grade 없으면 전체 반환
|
||||||
- `curriculumManager.getTeamCurriculums(teamId)`
|
|
||||||
- `curriculumManager.getAvailableCurriculums(teamId?)`
|
|
||||||
|
|
||||||
**캐싱**: 클라이언트 캐싱 (TTL 10분)
|
**인증**: 불필요 (시스템 콘텐츠)
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트 캐싱 (TTL 5분)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### GET /api/curriculum/[curriculumId]
|
### GET /api/curriculum/[curriculumId]
|
||||||
|
|
||||||
커리큘럼 상세 조회
|
커리큘럼 단건 조회
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
- `includeLessons`: boolean (레슨 포함 여부)
|
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
curriculum: Curriculum,
|
data: { curriculum: Curriculum }
|
||||||
lessons?: Lesson[]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manager 메서드**:
|
**Manager 메서드**:
|
||||||
- `curriculumManager.getCurriculumById(curriculumId, includeLessons)`
|
- `curriculumManager.getCurriculum(curriculumId)`
|
||||||
|
|
||||||
**캐싱**: 클라이언트 캐싱 (TTL 10분)
|
**인증**: 불필요 (시스템 콘텐츠)
|
||||||
|
|
||||||
|
**캐싱**: 클라이언트 캐싱 (TTL 5분)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/curriculum
|
### GET /api/curriculum/[curriculumId]/week
|
||||||
|
|
||||||
커리큘럼 생성
|
커리큘럼의 주차 목록 조회 (8개)
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
ownerType?: "system" | "team";
|
|
||||||
teamId?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
curriculum: Curriculum
|
data: { weeks: CurriculumWeek[] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**서버 로직**:
|
||||||
|
- 커리큘럼이 존재하지 않으면 404
|
||||||
|
- `curriculumId`로 필터링, `week` 오름차순 정렬
|
||||||
|
|
||||||
**Manager 메서드**:
|
**Manager 메서드**:
|
||||||
- `curriculumManager.createCurriculum(data)`
|
- `curriculumManager.getWeeks(curriculumId)`
|
||||||
|
|
||||||
**권한**: 인증 필요, 팀 소유자 (ownerType=team 시)
|
**인증**: 불필요 (시스템 콘텐츠)
|
||||||
|
|
||||||
**캐시 무효화**: `curriculums:*` 패턴
|
**캐싱**: 클라이언트 캐싱 (TTL 5분)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PUT /api/curriculum/[curriculumId]
|
### GET /api/curriculum/[curriculumId]/week/[weekId]
|
||||||
|
|
||||||
커리큘럼 수정
|
주차 단건 조회
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
items?: Array<{type: string; id: string; title?: string}>;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
curriculum: Curriculum
|
data: { week: CurriculumWeek }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**서버 로직**:
|
||||||
|
- `week.curriculumId !== curriculumId`면 404 (크로스 커리큘럼 가드)
|
||||||
|
|
||||||
**Manager 메서드**:
|
**Manager 메서드**:
|
||||||
- `curriculumManager.updateCurriculum(curriculumId, updates)`
|
- `curriculumManager.getWeek(curriculumId, weekId)`
|
||||||
|
|
||||||
**권한**: 인증 필요, 커리큘럼 소유자
|
**인증**: 불필요 (시스템 콘텐츠)
|
||||||
|
|
||||||
**캐시 무효화**: `curriculum:${curriculumId}:*`, `curriculums:*` 패턴
|
**캐싱**: 클라이언트 캐싱 (TTL 5분)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/curriculum/[curriculumId]
|
|
||||||
|
|
||||||
커리큘럼 삭제
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `curriculumManager.deleteCurriculum(curriculumId)`
|
|
||||||
|
|
||||||
**권한**: 인증 필요, 커리큘럼 소유자
|
|
||||||
|
|
||||||
**캐시 무효화**: `curriculum:${curriculumId}:*`, `curriculums:*` 패턴
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/lesson/[lessonId]
|
|
||||||
|
|
||||||
레슨 상세 조회
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
lesson: Lesson
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `lessonManager.getLessonById(lessonId)`
|
|
||||||
|
|
||||||
**캐싱**: 클라이언트 캐싱 (TTL 10분)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/lesson/curriculum/[curriculumId]
|
|
||||||
|
|
||||||
커리큘럼의 레슨 목록 조회
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
lessons: Lesson[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `lessonManager.getLessonsByCurriculum(curriculumId)`
|
|
||||||
|
|
||||||
**캐싱**: 클라이언트 캐싱 (TTL 10분)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /api/lesson
|
|
||||||
|
|
||||||
레슨 생성
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
ownerType?: "system" | "team";
|
|
||||||
teamId?: string;
|
|
||||||
level?: number;
|
|
||||||
category: string;
|
|
||||||
curriculumId?: string;
|
|
||||||
orderIndex?: number;
|
|
||||||
contents?: LessonContent[];
|
|
||||||
reward?: {experience: number; stickers?: string[]};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
lesson: Lesson
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `lessonManager.createLesson(data)`
|
|
||||||
|
|
||||||
**권한**: 인증 필요, 팀 소유자 (ownerType=team 시)
|
|
||||||
|
|
||||||
**캐시 무효화**: `lessons:*` 패턴
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PUT /api/lesson/[lessonId]
|
|
||||||
|
|
||||||
레슨 수정
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
level?: number;
|
|
||||||
category?: string;
|
|
||||||
orderIndex?: number;
|
|
||||||
contents?: LessonContent[];
|
|
||||||
reward?: {experience: number; stickers?: string[]};
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
lesson: Lesson
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `lessonManager.updateLesson(lessonId, updates)`
|
|
||||||
|
|
||||||
**권한**: 인증 필요, 레슨 소유자
|
|
||||||
|
|
||||||
**캐시 무효화**: `lesson:${lessonId}`, `lessons:*` 패턴
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/lesson/[lessonId]
|
|
||||||
|
|
||||||
레슨 삭제
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manager 메서드**:
|
|
||||||
- `lessonManager.deleteLesson(lessonId)`
|
|
||||||
|
|
||||||
**권한**: 인증 필요, 레슨 소유자
|
|
||||||
|
|
||||||
**캐시 무효화**: `lesson:${lessonId}`, `lessons:*` 패턴
|
|
||||||
|
|||||||
@ -20,6 +20,9 @@ firestore
|
|||||||
├── userReactions/ # 🆕 댓글 반응
|
├── userReactions/ # 🆕 댓글 반응
|
||||||
├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱)
|
├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱)
|
||||||
├── curriculums/ # ✅ 커리큘럼 (시스템 제공 + 팀 생성)
|
├── curriculums/ # ✅ 커리큘럼 (시스템 제공 + 팀 생성)
|
||||||
|
├── teamSessions/ # ✅ 회차 (수업 1회) — 날짜·글감·진행상태·출석
|
||||||
|
│ └── {sessionId}/checkins/ # 출석 체크인 (출석의 유일한 진실)
|
||||||
|
├── teamSessionCursors/ # ✅ 팀별 회차 번호 커서 + 쓰기 직렬화 잠금
|
||||||
├── lessons/ # ✅ 학습 레슨 (theory/quiz/mission/writing_prompt)
|
├── lessons/ # ✅ 학습 레슨 (theory/quiz/mission/writing_prompt)
|
||||||
├── stickers/ # 🔜 스티커 마스터 데이터
|
├── stickers/ # 🔜 스티커 마스터 데이터
|
||||||
└── userStickers/ # 🔜 사용자별 스티커 획득 기록
|
└── userStickers/ # 🔜 사용자별 스티커 획득 기록
|
||||||
@ -66,6 +69,9 @@ interface Team {
|
|||||||
description?: string; // 팀 소개 (공개 팀용)
|
description?: string; // 팀 소개 (공개 팀용)
|
||||||
coverImage?: string; // 🆕 팀 커버 이미지 URL (Firebase Storage)
|
coverImage?: string; // 🆕 팀 커버 이미지 URL (Firebase Storage)
|
||||||
|
|
||||||
|
// 🆕 출석 체크인 유예 분 (기본 10, 0~60 정수) — 팀 설정에서 편집, 회차가 열릴 때 스냅샷됨
|
||||||
|
attendanceGraceMinutes?: number;
|
||||||
|
|
||||||
// 멤버 관리 (uid를 키로 사용)
|
// 멤버 관리 (uid를 키로 사용)
|
||||||
members: {
|
members: {
|
||||||
[uid: string]: TeamMember; // 팀별 메타데이터
|
[uid: string]: TeamMember; // 팀별 메타데이터
|
||||||
@ -321,6 +327,18 @@ interface Writing {
|
|||||||
// 🆕 이미지 왜곡 영역 설정
|
// 🆕 이미지 왜곡 영역 설정
|
||||||
distortionAreas?: DistortionAreaData[];
|
distortionAreas?: DistortionAreaData[];
|
||||||
|
|
||||||
|
// 🆕 스튜디오 플로우 결과 (스튜디오 모드 글에만 존재)
|
||||||
|
studioResult?: {
|
||||||
|
// ...(keywords/sentencesV1/sentencesV2/blanks/grantedInteraction/grantedEffect/completedAt 등)
|
||||||
|
// (guidedWriting 있는 회차만) 결론 축 선택 로그 — 엑셀 6-2 성향 로그.
|
||||||
|
// 서비스 첫날부터 수집(소급 불가)하며, 학생별·주제별 집계 질의의 기준이 된다.
|
||||||
|
conclusionChoice?: {
|
||||||
|
axis: string; // 학생이 고른 마무리 방향 (StudioClosingOption.id)
|
||||||
|
sentence: string; // 그 축으로 쓴 결론 문장 (평문, sanitize됨)
|
||||||
|
topicId: string | null; // 발행 시점 문서 topicId (없으면 null)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 타임스탬프
|
// 타임스탬프
|
||||||
createdAt: Timestamp; // 최초 작성일
|
createdAt: Timestamp; // 최초 작성일
|
||||||
updatedAt: Timestamp; // 최종 수정일
|
updatedAt: Timestamp; // 최종 수정일
|
||||||
@ -387,6 +405,74 @@ interface Writing {
|
|||||||
- `status` + `createdAt` (복합 인덱스)
|
- `status` + `createdAt` (복합 인덱스)
|
||||||
- `teamId` + `visibility` (복합 인덱스, 팀 공개 글 조회용)
|
- `teamId` + `visibility` (복합 인덱스, 팀 공개 글 조회용)
|
||||||
|
|
||||||
|
### 🆕 TeamSession (회차 = 수업 1회) — 수업의 단일 진실
|
||||||
|
|
||||||
|
**컬렉션**: `teamSessions/{sessionId}`
|
||||||
|
|
||||||
|
수업 1회가 문서 1건이다. 그 문서가 날짜·시각·주차·글감·글작성 단계·진행 상태·출석을 **전부** 든다. 그래서 "오늘 무슨 수업인가"는 계산이 아니라 조회 한 줄이고, 교사 표와 학생 알림장이 같은 행을 읽으므로 두 화면이 어긋날 수 없다.
|
||||||
|
|
||||||
|
이 모델이 흡수한 것: 구 `Assignment`(배정 = 주차 묶음) + 구 `ClassSession`(출석 세션) + `teams.classSchedule`(일정 배열). 셋 다 삭제됐다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TeamSession {
|
||||||
|
id: string; // 무작위 ID
|
||||||
|
teamId: string;
|
||||||
|
date: string; // "YYYY-MM-DD" (KST) — 휴강·보강으로 바뀔 수 있다
|
||||||
|
time: string; // "HH:mm" (KST)
|
||||||
|
weekId: string; // 이 회차가 다루는 커리큘럼 주차 ("g3-c1-w01")
|
||||||
|
topicId: string; // 이 회차의 글감. 필수 — null·빈 문자열 불가
|
||||||
|
writingStages: WritingStage[]; // ai_draft | fill_blank | alone, 항상 1개 이상
|
||||||
|
status: "scheduled" | "in_progress" | "done";
|
||||||
|
order: number | null; // 확정 회차 번호. 진행 전환 시 확정, 이후 불변
|
||||||
|
|
||||||
|
openedAt?: Timestamp; // 이 회차가 처음 관찰된 시각
|
||||||
|
closedAt?: Timestamp | null;
|
||||||
|
closedBy?: string | null; // 교사 uid | "auto:sweep" | "auto:open"
|
||||||
|
openType?: "auto" | "manual";
|
||||||
|
openedBy?: string | null;
|
||||||
|
scheduledStartAt?: Timestamp | null; // date + time
|
||||||
|
baselineAt?: Timestamp; // 지각 기준: auto=scheduledStartAt, 수동=max(scheduledStartAt, openedAt)
|
||||||
|
graceMinutes?: number; // 열린 시점의 Team.attendanceGraceMinutes 스냅샷
|
||||||
|
presentCount: number; // 체크인 "최초 생성" 트랜잭션에서만 증가하는 집계 캐시
|
||||||
|
lateCount: number; // 〃
|
||||||
|
reopenedAt?: Timestamp; // 재개 기록용 — 전이 판정에는 쓰지 않는다
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PK는 순번도 날짜도 아니다 — 무작위 ID다.** 순번을 PK로 두면 회차 끼워넣기·순서 변경이 불가능해지고, 날짜를 PK로 두면 휴강으로 날짜를 옮기는 것 자체가 PK 변경이 된다. 둘 다 글·출석이 들고 있는 외래키를 깨뜨린다.
|
||||||
|
|
||||||
|
**`order`와 표의 번호는 다른 값이다.** 교사 표에 보이는 번호는 정렬에서 나오는 파생 인덱스이고, 글 배지에 박히는 번호는 여기 저장된 확정값이다. 회차를 하나 지우면 파생 인덱스는 전부 밀리지만 이미 쓰인 글의 "3회차"는 밀리면 안 된다.
|
||||||
|
|
||||||
|
**문서 존재는 "수업이 열렸다"를 뜻하지 않는다.** 팀 개설 시점에 8주치 회차를 미리 만들기 때문이다. 열림 여부는 `status`로만 판정한다. 구 모델에서는 수업이 열려야 문서가 생겼으므로 이 불변식이 반전됐다.
|
||||||
|
|
||||||
|
**서브컬렉션**: `teamSessions/{sessionId}/checkins/{uid}` — 출석의 유일한 진실.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SessionCheckIn {
|
||||||
|
uid: string;
|
||||||
|
arrivedAt: Timestamp; // 최초 도착 시각 (재방문/재호출 시 갱신 금지)
|
||||||
|
isLate: boolean; // arrivedAt > baselineAt + graceMinutes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**커서 문서**: `teamSessionCursors/{teamId}`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TeamSessionCursor {
|
||||||
|
sessionOrderCursor?: number; // 지금까지 부여된 회차 번호의 최댓값. 다음 번호는 +1
|
||||||
|
dateGuardSeq?: number; // 하루 1회 검증 경로의 배타 잠금 표식
|
||||||
|
openGuardSeq?: number; // 순번을 새로 뽑지 않는 열기 경로(재개)의 잠금 표식
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
회차가 독립 문서가 되면서 "배열 전체를 한 번에 훑어 중복을 막는" 검사 단위가 사라졌고, Firestore에는 컬렉션 수준 유니크 제약이 없다. 그래서 쓰기 경로 전부(생성 배치·단건 생성·날짜 수정·상태 전이)가 이 문서를 **트랜잭션 안에서 읽고 또 쓴다**. 읽기만으로는 직렬화되지 않는다 — Firestore의 트랜잭션 읽기 잠금은 공유라 읽기끼리는 충돌하지 않는다. `teams/{teamId}`를 잠금으로 쓰지 않는 이유는 그 문서가 AI 사용량 집계로 이미 뜨겁기 때문이다.
|
||||||
|
|
||||||
|
**전이에 크론은 없다.** 회차를 실제로 여닫는 것은 조회 요청뿐이다(의도적 GET write-through). 아무도 조회하지 않으면 아무 회차도 열리지 않는다.
|
||||||
|
|
||||||
|
**서버 레이어**: `src/lib/server/teamSession.ts`(회차·체크인·전이·날짜 비교 독점), `src/lib/shared/sessionTransition.ts`(순수 전이 판정), `src/lib/server/attendance.ts`(교사 화면 2곳의 응답 DTO로 집계), `src/lib/server/kst.ts`(KST 고정 오프셋). API 목록은 `API_SPEC.md`의 「회차 API」 절을 참조.
|
||||||
|
|
||||||
|
**인덱스**: `teamSessions(teamId, date)`, `(teamId, status)`, `(teamId, status, date ASC/DESC)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Topic (글쓰기 주제) ✅
|
## 5. Topic (글쓰기 주제) ✅
|
||||||
|
|||||||
@ -1,10 +1,61 @@
|
|||||||
# 라온누리 - 프로젝트 구조
|
# 라온누리 - 프로젝트 구조
|
||||||
|
|
||||||
> 최종 업데이트: 2026-06-04 (스튜디오 플로우 UI 통합 리디자인)
|
> 최종 업데이트: 2026-08-11 (학생 QR 로그인 + 엑셀 배포용 QR 카드 시트, 계정 생성 결과 컴포넌트 통합, 고아 컴포넌트 26개 정리)
|
||||||
|
|
||||||
초등학생을 위한 창작 글쓰기 교육 플랫폼
|
초등학생을 위한 창작 글쓰기 교육 플랫폼
|
||||||
|
|
||||||
**최신 업데이트** (2026-06-02):
|
**최신 업데이트** (2026-08-10):
|
||||||
|
- **학생 QR 로그인** — 학생이 종이 로그인 카드의 QR을 자기 기기로 찍어 입력 없이 로그인
|
||||||
|
- **`src/lib/qrLoginCodec.ts`**: `rn1:` 접두사 + base64(JSON `{v,id,pw}`) 인코딩/디코딩 쌍. 일반 QR 리더에 비밀번호가 평문으로 보이지 않게 감싼다(암호화는 아님 — 실수 노출 방지용). 잘못된 문자열은 예외 대신 `null`
|
||||||
|
- **`src/components/auth/QrLoginScanner.tsx`**: `getUserMedia` + **jsQR** 인라인 스캐너(동적 import — QR 탭을 열어야 내려온다). 브라우저 내장 `BarcodeDetector`는 **쓰지 않는다**: Windows·Linux 데스크톱 Chrome에는 없어서 개발 PC에서 기능을 눌러볼 수 없고, 폴백과 병용하면 개발자가 테스트하는 경로와 학생이 타는 경로가 갈린다. 640px 축소 + 10fps 제한으로 CPU 디코딩 비용을 억제. 실패는 종류를 나누지 않고 "직접 입력하기" 단일 출구로 수렴
|
||||||
|
- **로그인 페이지**: **아이디 로그인 / QR 로그인 탭**. QR 탭을 고르면 입력창 자리에 스캐너가 바로 뜬다(모달 없음 — 저학년에게는 모달을 닫는 법이 단계 하나 더다). 탭을 떠나면 스캐너가 언마운트되어 카메라가 자동으로 꺼진다. 카메라를 못 쓰는 환경에서는 탭 줄 자체를 렌더하지 않고 사유를 콘솔에 남긴다
|
||||||
|
- **신규 백엔드 0**: `authStore.login()`이 이미 `@` 없는 값을 loginId로 처리하므로 QR 경로가 수동 입력과 같은 길을 탄다
|
||||||
|
- **`src/lib/qrImage.ts`**: uqr 모듈 행렬 → 캔버스 → PNG base64. exceljs가 SVG를 못 받아 래스터가 필요하다. 인쇄물 기준으로 모듈 6px(표시 크기 108px보다 크게 그려 확대 인쇄에서 뭉개지지 않게), 여백 4모듈(규격값 — 가위질이 안쪽으로 들어와도 남게), ECC `M`(기본 `L`보다 한 단계 — 접히고 젖는 종이라)
|
||||||
|
- **엑셀 배포용 시트**: `memberCredentialExcel.ts`가 시트를 둘로 나눈다. **학생 계정**(이름·아이디·비밀번호·상태, 교사 보관)과 **배포용 QR**(이름 + QR만, 잘라서 배포). 한 장에 담으면 자를 때 같은 행의 비밀번호가 따라 잘려 "카드에 자격증명을 인쇄하지 않는다"는 전제가 인쇄 단계에서 무너진다. 성공한 계정만 싣는다 — 실패 행은 로그인되지 않는 카드가 된다
|
||||||
|
- 스펙: `.omc/specs/deep-interview-qr-login.md` (deep-interview 9라운드, 최종 모호도 18.5%)
|
||||||
|
|
||||||
|
**이전 업데이트** (2026-08-10):
|
||||||
|
- **학생 계정 생성 결과 통합 + 엑셀 내보내기**
|
||||||
|
- **`MemberCredentialResult`** (`src/components/team/MemberCredentialResult.tsx`): 계정 생성 결과 표(이름·아이디·비밀번호·상태) + 비밀번호 1회성 경고 + 엑셀 내보내기. **팀 만들기 성공 모달**과 **팀 설정 일괄 생성기**가 같은 컴포넌트를 쓴다 — 전에는 두 곳이 각자 다른 목록을 그렸고 내보내기는 어느 쪽에도 없었다
|
||||||
|
- **`src/lib/memberCredentialExcel.ts`**: 브라우저에서 .xlsx 생성. 비밀번호를 서버로 되돌려 보내지 않기 위해 클라이언트에서 만든다. exceljs(~1MB)는 **동적 import**라 내보내기 버튼을 눌러야 내려온다. 아이디는 문자열 셀로 고정(Excel 지수 표기 방지). 향후 아이디별 QR 이미지를 셀에 앵커할 자리를 열 배치에 남겨 둠
|
||||||
|
- **`BulkMemberCreator` 단순화**: 4단계(입력→미리보기→생성중→결과) 스텝 UI와 로그인 카드 인쇄 제거 → 단일 화면. 팀 만들기와 팀 설정이 완전히 같은 모습이 됨
|
||||||
|
- **고아 파일 정리**: 앱 라우터 전환·인증 페이지화로 대체된 컴포넌트 26개 삭제 (`Header`, `Signup*Screen`, `SignupDetailForm`, `SignupRoleSelectForm`, `FindAccountForm`, `Student*`/`Teacher*` 구 페이지 컴포넌트, `useShakeAnimation`, `passwordStrength`, `pendingImageStore`)
|
||||||
|
|
||||||
|
**이전 업데이트** (2026-07-03):
|
||||||
|
- **새 디자인 시안 포팅** - `raonnuri-next-main` 목업을 Chakra UI v3로 전면 포팅 (기존 기능/데이터 흐름은 유지, UI 레이어 리뉴얼)
|
||||||
|
- **디자인 시스템 확장**: `design.*` semantic 토큰 네임스페이스(pageBg/cardBg/text.\*/border.\*/fill.\*/status.\*/pinkTint.\*), 그림자 토큰 6종, textStyles 7종(designPageTitle 등), 버튼 variant 5종(designPrimary/designSecondary/designOutline/designGhost/designDanger), input variant(designDefault), 기본 폰트 SUIT → Pretendard. 상세: `TECH_STACK.md`
|
||||||
|
- **인증 구조를 모달 → 전용 페이지로 전환**: `LoginDialog` 모달 방식 폐기, 아래 9개 페이지 신설
|
||||||
|
- `/login` - 로그인 페이지
|
||||||
|
- `/signup` - 학생/교사 역할 선택
|
||||||
|
- `/signup/student`, `/signup/teacher` - 역할별 가입 폼
|
||||||
|
- `/signup/complete`, `/signup/pending`, `/signup/rejected` - 가입 후 상태 안내
|
||||||
|
- `/find-account` - 계정 찾기
|
||||||
|
- `/sns/role` - 소셜 로그인 신규 유저 역할 선택
|
||||||
|
- `authStore.openLoginDialog()`는 `/login` 리다이렉트 shim으로 하위 호환 유지
|
||||||
|
- 공용 컴포넌트 신설: `src/components/auth/AuthCard.tsx`, `PasswordEyeInput.tsx`, `src/lib/design-variants.ts`
|
||||||
|
- **크롬(레이아웃) 리디자인**: `Navbar` 역할 기반(게스트/학생/교사) 재구성, 교사 전용 셸 신설 (`src/components/layout/TeacherShell.tsx` + `src/app/[locale]/teacher/layout.tsx` - 홈/팀관리/학습관리/공지사항 사이드바), `Footer` 리스타일
|
||||||
|
- **학생 홈 리디자인** (`/student/home`): 히어로 카드 + 알림장(팀 탭·오늘의 주제 실데이터) + 나의 팀 그리드. 신규 컴포넌트: `src/components/home/{StudentHeroCard,StudentNoticeCard,StudentTeamMiniCard}.tsx`
|
||||||
|
- **마이페이지 리디자인** (`/my`): 시안 3섹션(프로필/기본정보/비밀번호) 추가, 기존 통계·구독 섹션 유지. 신규: `src/components/my/MyProfileSection.tsx`
|
||||||
|
- **팀/글 페이지 리디자인**: `/team`, `/team/all`, `/team/[teamId]`, `/writing`, `/writing/[writingId]` 시안 디자인 재구성 (TeamCard/WritingCard 리디자인, 투톤 제목, 캐러셀)
|
||||||
|
- **교사 콘솔 리디자인**: `/teacher/home` 대시보드 신규 구현 (Learning status + 출석현황(목업) + 나의 팀), `/team/[teamId]/manage` 6탭 재구성 (모니터링/출결(목업)/AI/학습과정/주제/멤버), `/team/create` 커리큘럼 연동 폼
|
||||||
|
- **교사 신규 페이지 (목데이터, 백엔드 미연동)**:
|
||||||
|
- `/teacher/learning` - 학습관리 (커리큘럼 테이블 + 월 캘린더), 컴포넌트: `src/components/teacher/learning/`
|
||||||
|
- `/teacher/notice` - 공지사항, 컴포넌트: `src/components/teacher/notice/`
|
||||||
|
- **다국어 지원**: ko/en/ja 3개 로케일에 신규 네임스페이스 추가 (auth, teacherLearning, teacherNotice, teacherShell 등), 키 정합성 2143개 일치 확인
|
||||||
|
- **주의**: 출결현황·학습관리·공지사항은 현재 목데이터 기반이며 백엔드 연동은 예정 작업 (`ROADMAP.md` 참조)
|
||||||
|
|
||||||
|
**이전 업데이트** (2026-06-10):
|
||||||
|
- **QR 휴대폰 사진 업로드 + 이미지 업로드 UI 리디자인** - 공용 `ImageDropzone` 전면 리디자인 + 휴대폰으로 찍어 데스크톱으로 바로 전달하는 QR 핸드오프
|
||||||
|
- **ImageDropzone 리디자인**: 빈 상태를 구름 업로드 아이콘 클러스터(반짝이/구름 장식) + 핑크 점선 테두리 + 흰색 알약 버튼(파일 선택/카메라로 촬영/휴대폰으로)으로 통일, 우하단 카메라 스티커. **모든 사용처 공통 적용**(스튜디오·빈칸채우기·쉬운글쓰기·이미지글·팀 커버), `compact`/`containerAspectRatio`/일반 3개 모드 보존, 미리보기 상태는 기존 손그림 테두리 유지
|
||||||
|
- **QR 사진 핸드오프**: 데스크톱이 QR 표시 → 휴대폰이 스캔해 `/{locale}/upload/{id}`에서 갤러리/카메라로 사진 선택(캔버스로 축소 전송, 최대 1600px JPEG) → 데스크톱이 **RTDB `onValue` 리스너로 즉시 감지**(폴링 아님) → 기존 `ImageCropper`(4:3) 크롭 단계로 연결
|
||||||
|
- **공유 저장소(RTDB)**: 인메모리 대신 RTDB `/handoffs/{id}`에 저장(App Hosting 멀티 인스턴스 대응). 폰 쓰기는 서버(Admin) 경유, 데스크톱 읽기만 클라 리스너. 비인증 `POST/DELETE /api/handoff`(능력 토큰 모델)
|
||||||
|
- **보안**: 128비트 handoffId, IP별 레이트리밋(RTDB 트랜잭션 1분 30회), 매직바이트 MIME 검증 + 디코딩 크기 상한, 작은 이미지 가드(600px 미만 거부), `database.rules.json` handoffs 규칙(읽기 `auth != null`/쓰기 `false`)
|
||||||
|
- **정리**: `functions/cleanupHandoffs`(onSchedule 매시) 버려진 `/handoffs`(15분 경과)·`/handoff_rate`(1시간 경과) 노드 삭제
|
||||||
|
- **신규 파일**: `src/lib/handoff/{config,rateLimit}.ts`, `src/app/api/handoff/{route,store}.ts`, `src/components/writing/QrUploadDialog.tsx`, `src/app/[locale]/upload/[id]/page.tsx`, `functions/src/cleanup/cleanupHandoffs.ts`
|
||||||
|
- **다국어 지원**: `imageUpload.qr`, `uploadPage` 네임스페이스 (ko/en/ja)
|
||||||
|
- **배포 필요**: `firebase deploy --only database`(리스너 규칙 필수), `--only functions`(정리 스케줄)
|
||||||
|
|
||||||
|
**이전 업데이트** (2026-06-02):
|
||||||
- **스튜디오 6단계 선형 글쓰기 플로우** (`/write/studio`) - "그림으로 시작 → 글 완성"을 한 페이지에서 진행하는 신규 라우트
|
- **스튜디오 6단계 선형 글쓰기 플로우** (`/write/studio`) - "그림으로 시작 → 글 완성"을 한 페이지에서 진행하는 신규 라우트
|
||||||
- **단일 페이지 상태머신**: `page.tsx` 오케스트레이터(useReducer)가 단일 진실 공급원(StudioFlowState)을 보유, 7개 스테이지 컴포넌트를 순차 렌더
|
- **단일 페이지 상태머신**: `page.tsx` 오케스트레이터(useReducer)가 단일 진실 공급원(StudioFlowState)을 보유, 7개 스테이지 컴포넌트를 순차 렌더
|
||||||
- **6단계 흐름**: S0 안내(intro) → S1 업로드(upload) → S2 1차 작문(firstWriting) → S3 보상① 인터랙션(rewardInteraction) → S4 2차 작문(secondWriting) → S5 보상② 이펙트(rewardEffect) → S6 완료(complete)
|
- **6단계 흐름**: S0 안내(intro) → S1 업로드(upload) → S2 1차 작문(firstWriting) → S3 보상① 인터랙션(rewardInteraction) → S4 2차 작문(secondWriting) → S5 보상② 이펙트(rewardEffect) → S6 완료(complete)
|
||||||
@ -42,7 +93,7 @@
|
|||||||
**업데이트** (2026-03-09):
|
**업데이트** (2026-03-09):
|
||||||
- **글쓰기 플로우 단순화** - 학생의 글쓰기 도달 경로를 최대 2클릭으로 단축
|
- **글쓰기 플로우 단순화** - 학생의 글쓰기 도달 경로를 최대 2클릭으로 단축
|
||||||
- **`/write` 자동 리다이렉트**: 커리큘럼 파라미터(`topicId`, `startType`, `helpLevel`, `teamId`)를 감지하여 적절한 에디터 페이지로 자동 이동
|
- **`/write` 자동 리다이렉트**: 커리큘럼 파라미터(`topicId`, `startType`, `helpLevel`, `teamId`)를 감지하여 적절한 에디터 페이지로 자동 이동
|
||||||
- **`NextAssignmentCard`** (`src/components/home/NextAssignmentCard.tsx`): 홈 페이지에 다음 과제 카드 표시, 커리큘럼 진행 상황 기반
|
- ~~**`NextAssignmentCard`**~~: 삭제됨 (2026-08). 홈의 수업 안내는 `StudentNoticeCard`가 회차 문서를 읽어 그린다
|
||||||
- **`WritingModeSelector` 단순화**: 2단계(시작 타입 → 도움 레벨) → 1단계(토글 + 카드 선택)로 변경
|
- **`WritingModeSelector` 단순화**: 2단계(시작 타입 → 도움 레벨) → 1단계(토글 + 카드 선택)로 변경
|
||||||
- **`TopicSelector` 인라인 변형**: `variant="inline"` prop 추가, 에디터 페이지에서 다이얼로그 대신 접이식 패널로 주제 선택
|
- **`TopicSelector` 인라인 변형**: `variant="inline"` prop 추가, 에디터 페이지에서 다이얼로그 대신 접이식 패널로 주제 선택
|
||||||
|
|
||||||
@ -546,9 +597,15 @@
|
|||||||
|
|
||||||
**URL 예시**:
|
**URL 예시**:
|
||||||
- 랜딩: `/ko`, `/en`, `/ja`
|
- 랜딩: `/ko`, `/en`, `/ja`
|
||||||
- 홈: `/ko/home`, `/en/home`, `/ja/home`
|
- 홈: `/ko/home`, `/en/home`, `/ja/home` (역할별 리다이렉트)
|
||||||
- 글쓰기: `/ko/write`, `/en/write`, `/ja/write`
|
- 글쓰기: `/ko/student/write`, `/en/student/write`, `/ja/student/write`
|
||||||
- 팀: `/ko/team`, `/en/team`, `/ja/team`
|
- 팀: `/ko/student/teams`, `/ko/teacher/teams`
|
||||||
|
|
||||||
|
**라우트 규칙 (2026-07 시안 전면 적용)**:
|
||||||
|
- **로그인 이전 화면만 루트에 위치**: `/`, `/login`, `/signup/*`, `/find-account`, `/sns/*`, `/pricing`, `/invite/[inviteId]`, `/upload/[id]`(무인증 QR 핸드오프)
|
||||||
|
- **로그인 이후 화면은 전부 역할 하위**: `/student/*` 또는 `/teacher/*`
|
||||||
|
- **구 경로는 리다이렉트 shim 유지** (북마크·공유 링크 하위 호환): `/home`, `/my`, `/team`, `/team/all`, `/team/create`, `/team/[teamId]`, `/team/[teamId]/manage`, `/write/*`, `/writing/*`, `/interaction`, `/imageUpload`
|
||||||
|
- 예외: `/admin/*`, `/studio-admin`은 관리자 영역으로 루트 유지
|
||||||
|
|
||||||
### ✅ 구현 완료
|
### ✅ 구현 완료
|
||||||
|
|
||||||
@ -556,20 +613,31 @@
|
|||||||
|-------|------|------|---------|--------|
|
|-------|------|------|---------|--------|
|
||||||
| **랜딩 페이지** | `/[locale]` | 서비스 소개 및 홍보 (비로그인 전용) | Hero, Features, How It Works, CTA, Footer<br>로그인 시 역할별 홈으로 자동 리다이렉트 | ✅ 완료 |
|
| **랜딩 페이지** | `/[locale]` | 서비스 소개 및 홍보 (비로그인 전용) | Hero, Features, How It Works, CTA, Footer<br>로그인 시 역할별 홈으로 자동 리다이렉트 | ✅ 완료 |
|
||||||
| **홈 리다이렉터** | `/[locale]/home` | 역할별 홈으로 리다이렉트 | student → `/student/home`, teacher → `/teacher/home` | ✅ 완료 |
|
| **홈 리다이렉터** | `/[locale]/home` | 역할별 홈으로 리다이렉트 | student → `/student/home`, teacher → `/teacher/home` | ✅ 완료 |
|
||||||
| **학생 홈** | `/[locale]/student/home` | 학생 대시보드 | 환영 메시지, 빠른 시작, 최근 활동, 알림 | ✅ 완료 |
|
| **학생 홈** | `/[locale]/student/home` | 학생 대시보드 | 🆕 **(2026-07 리디자인)** 히어로 카드 + 알림장(팀 탭·오늘의 주제 실데이터) + 나의 팀 그리드 (`StudentHeroCard`/`StudentNoticeCard`/`StudentTeamMiniCard`) | ✅ 완료 |
|
||||||
| **교사 홈** | `/[locale]/teacher/home` | 교사 대시보드 | 팀 관리, 학생 글쓰기 확인 | ✅ 완료 |
|
| **교사 홈** | `/[locale]/teacher/home` | 교사 대시보드 | 🆕 **(2026-07 리디자인)** `TeacherShell` 사이드바 레이아웃, Learning status + 출석현황(오늘 날짜의 회차 문서가 있는 팀, `teamSessions/*/checkins` 체크인 데이터) + 나의 팀 | ✅ 완료 |
|
||||||
|
| **교사 학습관리** | `/[locale]/teacher/learning` | 🆕 **학습관리 페이지** | 🆕 커리큘럼 테이블 + 월 캘린더 + 수업 시작·종료 + 회차 수정·추가·삭제. 세 탭 모두 회차 문서 실연동, 컴포넌트: `src/components/teacher/learning/` | ✅ 완료 |
|
||||||
|
| **교사 공지사항** | `/[locale]/teacher/notice` | 공지사항 페이지 | 컴포넌트: `src/components/teacher/notice/`, `noticeManager.getNotices()`로 Firestore `notices` 컬렉션 연동 | ✅ 완료 |
|
||||||
| **교사 승인 대기** | `/[locale]/teacher/pending-approval` | 교사 승인 대기 화면 | 승인 대기/거절 상태, 상태 확인, 로그아웃 | ✅ 완료 |
|
| **교사 승인 대기** | `/[locale]/teacher/pending-approval` | 교사 승인 대기 화면 | 승인 대기/거절 상태, 상태 확인, 로그아웃 | ✅ 완료 |
|
||||||
| **내 글 모음** | `/[locale]/writings` | 🆕 **전체 글 목록 페이지** | 🆕 **사용자의 모든 글 표시 (Grid)**<br>🆕 **정렬 Select (최신순/오래된순)**<br>🆕 **WritingCard 컴포넌트 사용**<br>🆕 **Empty state 처리**<br>🆕 **전체 번역 완료** (ko/en/ja) | ✅ 완료 |
|
| **로그인** | `/[locale]/login` | 🆕 **전용 로그인 페이지 (2026-07)** | 🆕 **LoginDialog 모달 → 페이지 전환**, `AuthCard` 공용 레이아웃, 소셜 로그인, loginId/이메일 겸용 | ✅ 완료 |
|
||||||
| **글 상세보기** | `/[locale]/writing/[writingId]` | 🆕 **Server Component 기반 상세 페이지** | 🆕 **SEO 최적화** (서버에서 HTML 생성)<br>🆕 **SNS 공유 미리보기** (카카오톡/페이스북)<br>🆕 **서버 데이터 로딩** (Firebase Admin SDK)<br>제목, 내용, 생성된 이미지 표시<br>주제 및 팀 정보 Badge<br>프롬프트 Collapsible<br>🆕 **CommentList** (Client Component)<br>🆕 **BackButton** 공통 컴포넌트 사용 | ✅ 완료 |
|
| **회원가입 - 역할 선택** | `/[locale]/signup` | 🆕 **학생/교사 역할 선택 페이지** | 🆕 역할 카드 선택 후 `/signup/student` 또는 `/signup/teacher`로 이동 | ✅ 완료 |
|
||||||
| **글쓰기** | `/[locale]/write` | Tiptap 기반 순수 텍스트 에디터 + 주제 선택 | 주제 선택 (자유 주제/개인 주제/팀 주제)<br>🆕 **글 수정 기능 (URL params ?id=xxx)**<br>🆕 **수정 모드 배지 표시**<br>🆕 **주제 변경 경고** (작성 중 내용 초기화 알림, 임시 저장 안내)<br>제목 입력 (Editable), 순수 텍스트 에디터 (포맷팅 없음)<br>🆕 **다중 글조각 관리** (최대 10개), "저장된 글조각" 버튼<br>🆕 **강화된 자동 저장** (2초 debounce, 저장 상태 표시: 저장 중/저장됨)<br>🆕 **저장 플로우 개편** (저장 → 백그라운드 분석 → `/imageUpload` 리다이렉트)<br>🆕 **GenerateImageDialog 제거** (새 플로우로 대체)<br>🆕 **스튜디오 진입 카드** (`/write/studio` 신규 플로우 진입)<br>템플릿 미리채우기 (제목/내용), Firestore 저장<br>비로그인도 접근 가능 (저장 시 로그인 유도) | ✅ 완료 |
|
| **회원가입 - 학생** | `/[locale]/signup/student` | 🆕 **학생 가입 폼** | 🆕 `SignupForm` 재사용, `AuthCard` 레이아웃 | ✅ 완료 |
|
||||||
| **스튜디오 글쓰기** | `/[locale]/write/studio` | 🆕 **그림으로 시작하는 6단계 선형 글쓰기 플로우 (신규)** | 🆕 **단일 페이지 상태머신** (useReducer 오케스트레이터, StudioFlowState 단일 진실 공급원)<br>🆕 **6단계**: S0 안내 → S1 업로드(키워드 5개 추출) → S2 1차 작문(역할별 자유 3문장 + 예문·전구 안내, 학생 단독) → S3 보상① 인터랙션 → S4 글 다듬기(낱말 분해 + 교체·삽입 추천) → S5 보상② 이펙트 → S6 완료(발행)<br>🆕 **StageStepper** 진행 표시기 (점수/숫자 미표시)<br>🆕 **RewardCanvasEditor** 공용 (인터랙션/이펙트, 영역 1개 제한)<br>🆕 **AI 엔드포인트 4개**: analyze / segment / suggest / complete<br>🆕 **다국어 지원** (studio namespace, ko/en/ja)<br>레거시 라우트와 병행 운영 (추후 제거 예정) | ✅ 완료 |
|
| **회원가입 - 교사** | `/[locale]/signup/teacher` | 🆕 **교사 가입 폼** | 🆕 `TeacherSignupForm` 재사용, 승인 대기 안내 | ✅ 완료 |
|
||||||
| **이미지 업로드/선택** | `/[locale]/imageUpload` | 🆕 **이미지 소스 선택 페이지 (글 저장 후 자동 이동)** | 🆕 **2가지 선택지**: AI 생성 (장면 추출 + 선택 + 생성) / 직접 업로드<br>🆕 **드래그앤드롭 파일 업로드** (미리보기, 5MB 제한, JPEG/PNG/WebP)<br>🆕 **Canvas API 클라이언트 사이드 리사이즈** (1920x1080 최대, 85% 품질)<br>🆕 **Firebase Storage 업로드** (WritingManager.uploadUserImage)<br>🆕 **이미 이미지 있으면 자동으로 `/interaction` 리다이렉트**<br>🆕 **다국어 지원** (imageUpload namespace, ko/en/ja) | ✅ 완료 |
|
| **회원가입 완료/대기/거절** | `/[locale]/signup/{complete,pending,rejected}` | 🆕 **가입 후 상태 안내 페이지 3종** | 🆕 완료/승인 대기/거절 상태별 안내 및 다음 액션 버튼 | ✅ 완료 |
|
||||||
| **인터랙션 편집** | `/[locale]/interaction` | 🆕 **이미지 왜곡 편집 페이지 (이미지 생성/업로드 후 이동)** | 🆕 **7:3 그리드 레이아웃** (이미지 70%, 컨트롤러 30%)<br>🆕 **Sticky Header** (InteractionHeader, 스크롤 시 상단 고정)<br>🆕 **framer-motion 애니메이션** (모드 전환, 영역 선택 부드러운 전환)<br>🆕 **영역 목록 일반 모드 표시** (CustomAreaList, 모든 모드에서 접근 가능)<br>🆕 **에디터 버튼 우측 배치** (추가/삭제/숨기기, 가로 3개)<br>🆕 **컨트롤러 내부 스크롤** (커스텀 스크롤바, 높이 80dvh)<br>🆕 **반응형 이미지** (aspectRatio 유지, 찌그러짐 해결)<br>왜곡 영역 편집 (EditorCanvas, DistortionArea)<br>모션 프리셋 선택 (horizontal, vertical, rotate, pulse 등)<br>물리 설정 (stiffness, damping, mass)<br>에디터/인터랙션 모드 전환 (Switch)<br>저장 시 Writing.distortionAreas 업데이트 | ✅ 완료 |
|
| **계정 찾기** | `/[locale]/find-account` | 🆕 **계정 찾기 페이지** | 🆕 이메일/loginId 기반 계정 찾기 폼 | ✅ 완료 |
|
||||||
| **공개 팀 목록** | `/[locale]/team/all` | 🆕 **공개 팀 둘러보기** | 🆕 **공개된 팀 목록 표시** (isPublic=true)<br>🆕 **TeamCard 그리드** (글래스모피즘)<br>🆕 **페이지네이션** (커서 기반)<br>🆕 **Navbar "공개 팀" 메뉴** | ✅ 완료 |
|
| **소셜 로그인 역할 선택** | `/[locale]/sns/role` | 🆕 **소셜 로그인 신규 유저 역할 선택** | 🆕 Google 등 소셜 최초 로그인 시 학생/교사 역할 선택 | ✅ 완료 |
|
||||||
| **내 팀 목록** | `/[locale]/team` | 내가 만든/참여한 팀 목록 (정식 계정 전용) | 팀 카드 그리드, 팀 정보 (코드, 멤버 수, 보안 설정)<br>"새 팀 만들기" 버튼 | ✅ 완료 |
|
| **내 글 모음** | `/[locale]/student/writings` (구 `/writing` shim) | 🆕 **전체 글 목록 페이지** | 🆕 **사용자의 모든 글 표시 (Grid)**<br>🆕 **정렬 Select (최신순/오래된순)**<br>🆕 **WritingCard 컴포넌트 사용**<br>🆕 **Empty state 처리**<br>🆕 **전체 번역 완료** (ko/en/ja) | ✅ 완료 |
|
||||||
| **팀 생성** | `/[locale]/team/create` | 새 팀 만들기 (정식 계정 전용) | 팀 이름 입력<br>생성 후 `/team/[teamId]`로 이동 | ✅ 완료 |
|
| **글 상세보기** | `/[locale]/student/writings/[writingId]` (구 `/writing/[writingId]` shim) | 🆕 **Server Component 기반 상세 페이지** | 🆕 **SEO 최적화** (서버에서 HTML 생성)<br>🆕 **SNS 공유 미리보기** (카카오톡/페이스북)<br>🆕 **서버 데이터 로딩** (Firebase Admin SDK)<br>제목, 내용, 생성된 이미지 표시<br>주제 및 팀 정보 Badge<br>프롬프트 Collapsible<br>🆕 **CommentList** (Client Component)<br>🆕 **BackButton** 공통 컴포넌트 사용<br>🆕 **(2026-07)** 시안 디자인 재구성 (투톤 제목, 캐러셀) | ✅ 완료 |
|
||||||
| **팀 상세 (통합)** | `/[locale]/team/[teamId]` | 🆕 **멤버/공개 뷰 통합 페이지** | 🆕 **멤버인 경우**: 멤버 목록, 관리/나가기 버튼<br>🆕 **비멤버 + 공개 팀**: 팀 정보, 공개 글 목록, 참여 버튼<br>🆕 **비멤버 + 비공개 팀**: 접근 불가 메시지<br>초대 링크 참여 후 기본 이동 페이지 | ✅ 완료 |
|
| **글쓰기** | `/[locale]/student/write` (구 `/write` shim) | Tiptap 기반 순수 텍스트 에디터 + 주제 선택 | 주제 선택 (자유 주제/개인 주제/팀 주제)<br>🆕 **글 수정 기능 (URL params ?id=xxx)**<br>🆕 **수정 모드 배지 표시**<br>🆕 **주제 변경 경고** (작성 중 내용 초기화 알림, 임시 저장 안내)<br>제목 입력 (Editable), 순수 텍스트 에디터 (포맷팅 없음)<br>🆕 **다중 글조각 관리** (최대 10개), "저장된 글조각" 버튼<br>🆕 **강화된 자동 저장** (2초 debounce, 저장 상태 표시: 저장 중/저장됨)<br>🆕 **저장 플로우 개편** (저장 → 백그라운드 분석 → `/imageUpload` 리다이렉트)<br>🆕 **GenerateImageDialog 제거** (새 플로우로 대체)<br>🆕 **스튜디오 진입 카드** (`/write/studio` 신규 플로우 진입)<br>템플릿 미리채우기 (제목/내용), Firestore 저장<br>비로그인도 접근 가능 (저장 시 로그인 유도) | ✅ 완료 |
|
||||||
| **팀 관리** | `/[locale]/team/[teamId]/manage` | 팀 관리 페이지 (소유자 전용) | 팀 정보 및 코드<br>🆕 **팀 공개 설정** (isPublic, allowPublicWritings, description)<br>**팀 주제 관리 (생성/삭제)**<br>🆕 **주제별 학생 분석** (TopicMemberAnalysisSection)<br>멤버 목록 및 관리<br>🆕 **멤버 메뉴 - 팀 내 글 분석** (by-team)<br>"멤버 페이지 보기" 버튼 | ✅ 완료 |
|
| **스튜디오 글쓰기** | `/[locale]/student/write/studio` (구 `/write/studio` shim) | 🆕 **그림으로 시작하는 6단계 선형 글쓰기 플로우 (신규)** | 🆕 **단일 페이지 상태머신** (useReducer 오케스트레이터, StudioFlowState 단일 진실 공급원)<br>🆕 **6단계**: S0 안내 → S1 업로드(키워드 5개 추출) → S2 1차 작문(역할별 자유 3문장 + 예문·전구 안내, 학생 단독) → S3 보상① 인터랙션 → S4 글 다듬기(낱말 분해 + 교체·삽입 추천) → S5 보상② 이펙트 → S6 완료(발행)<br>🆕 **StageStepper** 진행 표시기 (점수/숫자 미표시)<br>🆕 **RewardCanvasEditor** 공용 (인터랙션/이펙트, 영역 1개 제한)<br>🆕 **AI 엔드포인트 4개**: analyze / segment / suggest / complete<br>🆕 **다국어 지원** (studio namespace, ko/en/ja)<br>레거시 라우트와 병행 운영 (추후 제거 예정) | ✅ 완료 |
|
||||||
|
| **이미지 업로드/선택** | `/[locale]/student/imageUpload` (구 `/imageUpload` shim) | 🆕 **이미지 소스 선택 페이지 (글 저장 후 자동 이동)** | 🆕 **2가지 선택지**: AI 생성 (장면 추출 + 선택 + 생성) / 직접 업로드<br>🆕 **드래그앤드롭 파일 업로드** (미리보기, 5MB 제한, JPEG/PNG/WebP)<br>🆕 **Canvas API 클라이언트 사이드 리사이즈** (1920x1080 최대, 85% 품질)<br>🆕 **Firebase Storage 업로드** (WritingManager.uploadUserImage)<br>🆕 **이미 이미지 있으면 자동으로 `/interaction` 리다이렉트**<br>🆕 **다국어 지원** (imageUpload namespace, ko/en/ja) | ✅ 완료 |
|
||||||
|
| **인터랙션 편집** | `/[locale]/student/interaction` (구 `/interaction` shim) | 🆕 **이미지 왜곡 편집 페이지 (이미지 생성/업로드 후 이동)** | 🆕 **7:3 그리드 레이아웃** (이미지 70%, 컨트롤러 30%)<br>🆕 **Sticky Header** (InteractionHeader, 스크롤 시 상단 고정)<br>🆕 **framer-motion 애니메이션** (모드 전환, 영역 선택 부드러운 전환)<br>🆕 **영역 목록 일반 모드 표시** (CustomAreaList, 모든 모드에서 접근 가능)<br>🆕 **에디터 버튼 우측 배치** (추가/삭제/숨기기, 가로 3개)<br>🆕 **컨트롤러 내부 스크롤** (커스텀 스크롤바, 높이 80dvh)<br>🆕 **반응형 이미지** (aspectRatio 유지, 찌그러짐 해결)<br>왜곡 영역 편집 (EditorCanvas, DistortionArea)<br>모션 프리셋 선택 (horizontal, vertical, rotate, pulse 등)<br>물리 설정 (stiffness, damping, mass)<br>에디터/인터랙션 모드 전환 (Switch)<br>저장 시 Writing.distortionAreas 업데이트 | ✅ 완료 |
|
||||||
|
| **공개 팀 목록** | `/[locale]/student/teams/all` (구 `/team/all` shim) | 🆕 **공개 팀 둘러보기** | 🆕 **공개된 팀 목록 표시** (isPublic=true)<br>🆕 **TeamCard 그리드** (글래스모피즘)<br>🆕 **페이지네이션** (커서 기반)<br>🆕 **Navbar "공개 팀" 메뉴** | ✅ 완료 |
|
||||||
|
| **내 팀 목록 (학생)** | `/[locale]/student/teams` (구 `/team`은 역할 shim) | 내가 참여한 팀 목록 | 팀 카드 그리드, 팀 정보 (코드, 멤버 수, 보안 설정)<br>🆕 **(2026-07)** 시안 `TeamCard` 리디자인 | ✅ 완료 |
|
||||||
|
| **팀관리 목록 (교사)** | `/[locale]/teacher/teams` | 교사 팀관리 목록 | 🆕 **(2026-07)** 진행중/종료 탭, TeamCard 그리드, 팀 만들기 링크<br>🆕 **(2026-07)** 비활성화(soft delete)된 팀은 "학습 종료된 팀" 탭에 회색 "종료" 배지로 표시<br>🆕 **(2026-07)** 팀 관리 상세: 참여인원·멤버 목록에서 소유자(선생님) 제외, 비활성 팀은 readonly(종료 배너+재활성화 버튼, 변경 컨트롤 숨김/비활성, 서버 뮤테이션 가드) | ✅ 완료 |
|
||||||
|
| **팀 생성** | `/[locale]/teacher/create-team` (구 `/team/create` shim) | 새 팀 만들기 (교사 전용) | 팀 이름 입력<br>생성 후 `/teacher/teams/[teamId]`로 이동<br>🆕 **(2026-07)** 커리큘럼 연동 폼으로 재구성<br>🆕 **(2026-08)** 학년 선택 → 커리큘럼 자동 결정, 개강일+요일+시간으로 8주치 회차 일괄 생성(쓰기 트랜잭션 1회). **주 N회는 여기서만 정해지고 이후 변경할 수 없다** — 회차 수가 곧 글감 배치이기 때문이다 | ✅ 완료 |
|
||||||
|
| **팀 상세 (학생/공개)** | `/[locale]/student/teams/[teamId]` (구 `/team/[teamId]`는 역할 shim) | 🆕 **멤버/공개 뷰 통합 페이지** | 🆕 **멤버인 경우**: 멤버 목록, 관리/나가기 버튼<br>🆕 **비멤버 + 공개 팀**: 팀 정보, 공개 글 목록, 참여 버튼<br>🆕 **비멤버 + 비공개 팀**: 접근 불가 메시지<br>초대 링크 참여 후 기본 이동 페이지<br>🆕 **(2026-07)** 시안 디자인 재구성 | ✅ 완료 |
|
||||||
|
| **팀 관리** | `/[locale]/teacher/teams/[teamId]` (구 `/team/[teamId]/manage` shim) | 팀 관리 페이지 (소유자 전용, TeacherShell 내부) | 팀 정보 및 코드<br>🆕 **팀 공개 설정** (isPublic, allowPublicWritings, description)<br>**팀 주제 관리 (생성/삭제)**<br>🆕 **주제별 학생 분석** (TopicMemberAnalysisSection)<br>멤버 목록 및 관리<br>🆕 **멤버 메뉴 - 팀 내 글 분석** (by-team)<br>"멤버 페이지 보기" 버튼<br>🆕 **(2026-08)** 모니터링/출결(`teamSessions/*/checkins` 체크인 데이터)/팀원 탭. **수업 제어·학습과정·AI 탭은 여기 없다** — 회차 계획과 수업 시작·종료는 학습 관리가 단독으로 맡는다 | ✅ 완료 |
|
||||||
|
| **수업 대기실** | `/[locale]/student/class/[teamId]` | 🆕 **출석 체크인 + 대기실 (신규)** | 🆕 마운트 시 `useClassCheckIn` 훅으로 체크인 자동 기록(멱등, URL 직접 접근 포함)<br>출석/지각 배지, 오늘 토픽/커리큘럼 회차, "글쓰기 시작" 버튼(기존 write 플로우 진입)<br>실시간 참여 현황(입장한 팀원 + 글쓰는 중 표시, RTDB `sessionPresence/*`)<br>세션 없음(수업 시간 아님) 빈 상태 | ✅ 완료 |
|
||||||
|
|
||||||
### 🚧 구현 예정
|
### 🚧 구현 예정
|
||||||
|
|
||||||
@ -577,7 +645,7 @@
|
|||||||
|-------|------|------|------|
|
|-------|------|------|------|
|
||||||
| **학습하기** | `/[locale]/learn` | 레슨/코스 학습 | ❌ 미구현 (Navbar 링크만 존재) |
|
| **학습하기** | `/[locale]/learn` | 레슨/코스 학습 | ❌ 미구현 (Navbar 링크만 존재) |
|
||||||
| **스티커** | `/[locale]/stickers` | 스티커 컬렉션 | ❌ 미구현 (Navbar 링크만 존재) |
|
| **스티커** | `/[locale]/stickers` | 스티커 컬렉션 | ❌ 미구현 (Navbar 링크만 존재) |
|
||||||
| **마이페이지** | `/[locale]/my` | 사용자 허브 (프로필, 통계, 미리보기, 설정) | ✅ 완료 |
|
| **마이페이지** | `/[locale]/student/mypage` · `/[locale]/teacher/mypage` (구 `/my`는 역할 shim) | 사용자 허브 (프로필, 통계, 미리보기, 설정), 공용 `MyPageContent` 렌더, 🆕 **(2026-07)** 시안 3섹션(프로필/기본정보/비밀번호) 추가 (`MyProfileSection`), 기존 통계·구독 섹션 유지 | ✅ 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -596,6 +664,8 @@
|
|||||||
| **TeacherPendingApprovalScreen** | `TeacherPendingApprovalScreen.tsx` | 교사 승인 대기/거절 화면 | ✅ 완료 |
|
| **TeacherPendingApprovalScreen** | `TeacherPendingApprovalScreen.tsx` | 교사 승인 대기/거절 화면 | ✅ 완료 |
|
||||||
| **SocialLoginButton** | `SocialLoginButton.tsx` | 소셜 로그인 버튼 | ✅ 완료 |
|
| **SocialLoginButton** | `SocialLoginButton.tsx` | 소셜 로그인 버튼 | ✅ 완료 |
|
||||||
| **UserProfileButton** | `UserProfileButton.tsx` | 사용자 프로필/메뉴 버튼 (설정 메뉴 통합) | ✅ 완료 |
|
| **UserProfileButton** | `UserProfileButton.tsx` | 사용자 프로필/메뉴 버튼 (설정 메뉴 통합) | ✅ 완료 |
|
||||||
|
| **AuthCard** | `AuthCard.tsx` | 🆕 **(2026-07)** 인증 페이지 9종 공용 카드 레이아웃 | ✅ 완료 |
|
||||||
|
| **PasswordEyeInput** | `PasswordEyeInput.tsx` | 🆕 **(2026-07)** 비밀번호 표시/숨김 토글 인풋 | ✅ 완료 |
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
- ✅ 이메일/비밀번호 로그인
|
- ✅ 이메일/비밀번호 로그인
|
||||||
@ -683,6 +753,7 @@
|
|||||||
| 컴포넌트 | 파일명 | 설명 | 상태 |
|
| 컴포넌트 | 파일명 | 설명 | 상태 |
|
||||||
|---------|--------|------|------|
|
|---------|--------|------|------|
|
||||||
| **BackButton** | `BackButton.tsx` | 🆕 **뒤로가기 버튼 공통 컴포넌트** (router.back() 또는 특정 경로, 커스텀 라벨 지원) | ✅ 완료 |
|
| **BackButton** | `BackButton.tsx` | 🆕 **뒤로가기 버튼 공통 컴포넌트** (router.back() 또는 특정 경로, 커스텀 라벨 지원) | ✅ 완료 |
|
||||||
|
| **TeacherShell** | `TeacherShell.tsx` | 🆕 **(2026-07)** 교사 전용 셸 (사이드바: 홈/팀관리/학습관리/공지사항), `src/app/[locale]/teacher/layout.tsx`에서 사용 | ✅ 완료 |
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
- ✅ `router.back()` 기본 동작
|
- ✅ `router.back()` 기본 동작
|
||||||
@ -753,6 +824,7 @@
|
|||||||
| **StudioImageCard** | `studio/StudioImageCard.tsx` | 🆕 **고정 비율 이미지 카드** (선택적 children, 예: 키워드 칩) | ✅ 완료 |
|
| **StudioImageCard** | `studio/StudioImageCard.tsx` | 🆕 **고정 비율 이미지 카드** (선택적 children, 예: 키워드 칩) | ✅ 완료 |
|
||||||
| **RewardBanner** | `studio/RewardBanner.tsx` | 🆕 **보상 축하 배너** (S3/S5 보상 단계 공용) | ✅ 완료 |
|
| **RewardBanner** | `studio/RewardBanner.tsx` | 🆕 **보상 축하 배너** (S3/S5 보상 단계 공용) | ✅ 완료 |
|
||||||
| **SentenceTokenEditor** | `studio/SentenceTokenEditor.tsx` | 🆕 **S4 낱말 칩 편집기** (낱말 탭→교체 Popover, +자리→삽입 Popover, 갱신 낱말 그라디언트, `sentenceTokens.ts` 조립/폴백 유틸) | ✅ 완료 |
|
| **SentenceTokenEditor** | `studio/SentenceTokenEditor.tsx` | 🆕 **S4 낱말 칩 편집기** (낱말 탭→교체 Popover, +자리→삽입 Popover, 갱신 낱말 그라디언트, `sentenceTokens.ts` 조립/폴백 유틸) | ✅ 완료 |
|
||||||
|
| **DraftSentenceList** | `studio/DraftSentenceList.tsx` | 🆕 **(aiDraft) 초안 문장 목록 + 다듬기 팝업** (번호+타입 배지+하이라이트 문장 행, 클릭 → AI 제안 후보/직접 고쳐 쓰기 Popover, role.color 배지·하이라이트) | ✅ 완료 |
|
||||||
| **studioMotion** | `studio/studioMotion.ts` | 🆕 **스튜디오 공용 애니메이션** (studioFadeIn/Pop/Breathe/EmptyBreathe @keyframes + STUDIO_REDUCED_MOTION prefers-reduced-motion 게이트) | ✅ 완료 |
|
| **studioMotion** | `studio/studioMotion.ts` | 🆕 **스튜디오 공용 애니메이션** (studioFadeIn/Pop/Breathe/EmptyBreathe @keyframes + STUDIO_REDUCED_MOTION prefers-reduced-motion 게이트) | ✅ 완료 |
|
||||||
| **studioRoleMeta** | `studio/studioRoleMeta.ts` | 🆕 **역할 메타 정의** (상황/마음/까닭 아이콘·라벨, RolePill 공용) | ✅ 완료 |
|
| **studioRoleMeta** | `studio/studioRoleMeta.ts` | 🆕 **역할 메타 정의** (상황/마음/까닭 아이콘·라벨, RolePill 공용) | ✅ 완료 |
|
||||||
| **ImageDropzone** | `ImageDropzone.tsx` | 🆕 **범용 이미지 드롭존** (드래그앤드롭 업로드, 미리보기, 4:3 크롭 통합, 호버 오버레이, 삭제 버튼, AspectRatio 지원, 커스텀 라벨 — 글쓰기/팀 커버 이미지 공용) | ✅ 완료 |
|
| **ImageDropzone** | `ImageDropzone.tsx` | 🆕 **범용 이미지 드롭존** (드래그앤드롭 업로드, 미리보기, 4:3 크롭 통합, 호버 오버레이, 삭제 버튼, AspectRatio 지원, 커스텀 라벨 — 글쓰기/팀 커버 이미지 공용) | ✅ 완료 |
|
||||||
@ -873,7 +945,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📁 `src/app/[locale]/write/studio/` - 🆕 스튜디오 6단계 플로우
|
### 📁 `src/app/[locale]/student/write/studio/` - 🆕 스튜디오 6단계 플로우
|
||||||
|
|
||||||
| 파일 | 경로 | 설명 | 상태 |
|
| 파일 | 경로 | 설명 | 상태 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
@ -884,15 +956,20 @@
|
|||||||
| **FirstWritingStage** | `_stages/FirstWritingStage.tsx` | 🆕 **S2 1차 작문** (그림 + 키워드 5개 + 역할별 자유 3문장 + 전구 안내 + 예문, 학생 단독·AI 없음) | ✅ 완료 |
|
| **FirstWritingStage** | `_stages/FirstWritingStage.tsx` | 🆕 **S2 1차 작문** (그림 + 키워드 5개 + 역할별 자유 3문장 + 전구 안내 + 예문, 학생 단독·AI 없음) | ✅ 완료 |
|
||||||
| **RewardInteractionStage** | `_stages/RewardInteractionStage.tsx` | 🆕 **S3 보상①** (인터랙션/움직임 1개 부여, RewardCanvasEditor) | ✅ 완료 |
|
| **RewardInteractionStage** | `_stages/RewardInteractionStage.tsx` | 🆕 **S3 보상①** (인터랙션/움직임 1개 부여, RewardCanvasEditor) | ✅ 완료 |
|
||||||
| **SecondWritingStage** | `_stages/SecondWritingStage.tsx` | 🆕 **S4 글 다듬기** (낱말 분해 + 교체·삽입 추천, SentenceTokenEditor) | ✅ 완료 |
|
| **SecondWritingStage** | `_stages/SecondWritingStage.tsx` | 🆕 **S4 글 다듬기** (낱말 분해 + 교체·삽입 추천, SentenceTokenEditor) | ✅ 완료 |
|
||||||
|
| **DraftRefineStage** | `_stages/DraftRefineStage.tsx` | 🆕 **(aiDraft 모드) AI 문단 초안 + 문장 다듬기** (문장 타입별 한 줄, 문장 클릭 → AI 제안/직접 고쳐 쓰기 팝업, DraftSentenceList) | ✅ 완료 |
|
||||||
| **RewardEffectStage** | `_stages/RewardEffectStage.tsx` | 🆕 **S5 보상②** (스프라이트 이펙트 1개 부여, RewardCanvasEditor) | ✅ 완료 |
|
| **RewardEffectStage** | `_stages/RewardEffectStage.tsx` | 🆕 **S5 보상②** (스프라이트 이펙트 1개 부여, RewardCanvasEditor) | ✅ 완료 |
|
||||||
| **CompletionStage** | `_stages/CompletionStage.tsx` | 🆕 **S6 완료** (1회차 수업 종료, complete API 호출 → 발행) | ✅ 완료 |
|
| **CompletionStage** | `_stages/CompletionStage.tsx` | 🆕 **S6 완료** (1회차 수업 종료, complete API 호출 → 발행) | ✅ 완료 |
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
- ✅ **단일 페이지 선형 상태머신** (useReducer, 단계 전환 부수효과는 오케스트레이터 goNext에서 처리)
|
- ✅ **단일 페이지 선형 상태머신** (useReducer, 단계 전환 부수효과는 오케스트레이터 goNext에서 처리)
|
||||||
|
- ✅ **수업 방식(모드) 분기**: 회차 `mode`가 스테이지 구성을 결정 (`getStudioStageOrder`)
|
||||||
|
- `selfWriting`(기본): 기존 6단계 (직접 쓰기 + 낱말 다듬기)
|
||||||
|
- `aiDraft`: 5단계 — 업로드 → **AI 문단 초안 생성**(`POST /api/studio/draft`, 교사 정의 문장 타입당 1문장 + 핵심 구절 하이라이트) → **문장 다듬기**(문장 클릭 → AI 제안 `POST /api/studio/rewrite` 프리페치 / 직접 고쳐 쓰기) → 보상① → 보상② → 완성. frame.roles를 "문장 타입"(중심 문장·이유·장면 설명…)으로 재해석, role.color(배지·하이라이트 색)/role.aiHint(타입별 AI 지시문) 사용. 초안 원문은 sentencesV1로 저장
|
||||||
|
- **(aiDraft) 교사 개수 설정**: `ai.rewriteCount`(1~4) 문장당 AI 제안 필수 제시 개수(부족 시 서버 1회 재시도·병합), `ai.requiredEditCount`(0~문장 타입 수) 필수 수정 문장 수 — 초안 원문과 달라진 문장이 기준 이상이어야 다음 단계 전진/발행 가능(클라 가드 + complete API 검증, "고친 문장 n/N" 진행 칩 + 행 체크 표시)
|
||||||
- ✅ **역할별 자유 3문장**: 상황/감정/이유를 한 문장씩 자유 작성 (고정 빈칸 템플릿 제거, 선생님 직접 교육 + 예문·전구 안내로 지도)
|
- ✅ **역할별 자유 3문장**: 상황/감정/이유를 한 문장씩 자유 작성 (고정 빈칸 템플릿 제거, 선생님 직접 교육 + 예문·전구 안내로 지도)
|
||||||
- ✅ **S2→S3 전환**: 모든 입력 채움 검증 → draft Writing 생성(createWriting) + 이미지 업로드(uploadUserImage), 1차 문장을 2차 작문 초기값으로 시드
|
- ✅ **S2→S3 전환**: 모든 입력 채움 검증 → draft Writing 생성(createWriting) + 이미지 업로드(uploadUserImage), 1차 문장을 2차 작문 초기값으로 시드
|
||||||
- ✅ **S3/S5 보상**: 공용 RewardCanvasEditor (영역 1개 제한), 적용 시 distortionAreas/spriteEffectAreas 저장 후 자동 전진
|
- ✅ **S3/S5 보상**: 공용 RewardCanvasEditor (영역 1개 제한), 적용 시 distortionAreas/spriteEffectAreas 저장 후 자동 전진
|
||||||
- ✅ **AI 엔드포인트 4개**: `/api/studio/analyze`(키워드), `/api/studio/segment`(S4 분해), `/api/studio/suggest`(S4 교체·삽입 추천), `/api/studio/complete`(발행)
|
- ✅ **AI 엔드포인트 6개**: `/api/studio/analyze`(키워드), `/api/studio/segment`(S4 분해), `/api/studio/suggest`(S4 교체·삽입 추천), `/api/studio/draft`(aiDraft 문단 초안), `/api/studio/rewrite`(aiDraft 문장 고쳐 쓰기 후보), `/api/studio/complete`(발행)
|
||||||
- ✅ **데이터 저장**: writings.keywords + writings.studioResult (sentencesV1/V2, blanks, grantedInteraction/Effect, completedAt), status draft→published, rewardType `participation`
|
- ✅ **데이터 저장**: writings.keywords + writings.studioResult (sentencesV1/V2, blanks, grantedInteraction/Effect, completedAt), status draft→published, rewardType `participation`
|
||||||
- ✅ **다국어 지원** (studio namespace, pageTitles.writeStudio, ko/en/ja)
|
- ✅ **다국어 지원** (studio namespace, pageTitles.writeStudio, ko/en/ja)
|
||||||
|
|
||||||
@ -937,7 +1014,8 @@
|
|||||||
| **TopicMemberAnalysisSection** | `TopicMemberAnalysisSection.tsx` | 🆕 **주제별 학생 글쓰기 분석** (Accordion, 주제별 학생 목록, 글 개수, by-topic 분석 연동) | ✅ 완료 |
|
| **TopicMemberAnalysisSection** | `TopicMemberAnalysisSection.tsx` | 🆕 **주제별 학생 글쓰기 분석** (Accordion, 주제별 학생 목록, 글 개수, by-topic 분석 연동) | ✅ 완료 |
|
||||||
| **LiveWritingMonitor** | `LiveWritingMonitor.tsx` | 🆕 **실시간 글쓰기 모니터링** (Firebase Realtime DB, 5초 주기) | ✅ 완료 |
|
| **LiveWritingMonitor** | `LiveWritingMonitor.tsx` | 🆕 **실시간 글쓰기 모니터링** (Firebase Realtime DB, 5초 주기) | ✅ 완료 |
|
||||||
| **EmailInviteSection** | `EmailInviteSection.tsx` | 🆕 **이메일 초대 관리 UI** (이메일로 팀 초대 전송, 대기 초대 목록, 수락/거절 상태 표시) | ✅ 완료 |
|
| **EmailInviteSection** | `EmailInviteSection.tsx` | 🆕 **이메일 초대 관리 UI** (이메일로 팀 초대 전송, 대기 초대 목록, 수락/거절 상태 표시) | ✅ 완료 |
|
||||||
| **BulkMemberCreator** | `BulkMemberCreator.tsx` | 🆕 **학생 일괄 생성 UI** (4단계: CSV 입력→미리보기→생성중→결과, 템플릿 ID/PW, 로그인 카드 인쇄) | ✅ 완료 |
|
| **BulkMemberCreator** | `BulkMemberCreator.tsx` | **학생 일괄 생성 UI** (단일 화면: 이름 로스터 + 템플릿 ID/PW + 라이브 미리보기). 팀 만들기(드래프트 모드)와 팀 설정(즉시 생성 모드)이 **같은 화면**을 쓴다 | ✅ 완료 |
|
||||||
|
| **🆕 MemberCredentialResult** | `MemberCredentialResult.tsx` | **계정 생성 결과 표 + 엑셀 내보내기** (이름·아이디·비밀번호·상태, 비밀번호 1회성 경고). 팀 만들기 성공 모달과 팀 설정 일괄 생성기가 공유 | ✅ 완료 |
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
- ✅ **AIConfigDialog** (2025-11-17):
|
- ✅ **AIConfigDialog** (2025-11-17):
|
||||||
@ -986,6 +1064,10 @@
|
|||||||
| **ViewAllWritingsCard** | `ViewAllWritingsCard.tsx` | "모든 글 보기" 액션 카드 (화살표 아이콘, 클릭 가능) | ✅ 완료 |
|
| **ViewAllWritingsCard** | `ViewAllWritingsCard.tsx` | "모든 글 보기" 액션 카드 (화살표 아이콘, 클릭 가능) | ✅ 완료 |
|
||||||
| **EmptyStateCard** | `EmptyStateCard.tsx` | 글이 없을 때 빈 상태 표시 (이모티콘, CTA 버튼, 패턴 배경) | ✅ 완료 |
|
| **EmptyStateCard** | `EmptyStateCard.tsx` | 글이 없을 때 빈 상태 표시 (이모티콘, CTA 버튼, 패턴 배경) | ✅ 완료 |
|
||||||
| **RecentActivitySection** | `RecentActivitySection.tsx` | 최근 활동 섹션 전체 (글 목록, 로딩, 빈 상태 처리) | ✅ 완료 |
|
| **RecentActivitySection** | `RecentActivitySection.tsx` | 최근 활동 섹션 전체 (글 목록, 로딩, 빈 상태 처리) | ✅ 완료 |
|
||||||
|
| **StudentHeroCard** | `StudentHeroCard.tsx` | 🆕 **(2026-07)** 학생 홈 히어로 카드 | ✅ 완료 |
|
||||||
|
| **StudentNoticeCard** | `StudentNoticeCard.tsx` | 🆕 **(2026-07)** 알림장 카드 (팀 탭, 오늘의 주제 실데이터) | ✅ 완료 |
|
||||||
|
| **StudentTeamMiniCard** | `StudentTeamMiniCard.tsx` | 🆕 **(2026-07)** 나의 팀 미니 카드 (홈 그리드용) | ✅ 완료 |
|
||||||
|
| **NotificationSection / NotificationCard** | `NotificationSection.tsx` / `NotificationCard.tsx` | 🆕 **(2026-07)** 알림 섹션/카드 (`mockNotifications.ts` 목데이터) | ✅ 완료(UI) |
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
- ✅ **모듈화** (2025-11-21):
|
- ✅ **모듈화** (2025-11-21):
|
||||||
@ -1005,6 +1087,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 📁 `src/components/my/` - 마이페이지
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일명 | 설명 | 상태 |
|
||||||
|
|---------|--------|------|------|
|
||||||
|
| **MyProfileHero** | `MyProfileHero.tsx` | 프로필 히어로 (아바타, 이름 인라인 편집, 플랜 뱃지) | ✅ 완료 |
|
||||||
|
| **MyStatsRow** | `MyStatsRow.tsx` | 통계 카드 행 (작성 글 수, 팀 수, AI 크레딧, 주간 목표) | ✅ 완료 |
|
||||||
|
| **MyWritingsPreview / MyTeamsPreview** | `MyWritingsPreview.tsx` / `MyTeamsPreview.tsx` | 내 글/팀 최근 3개 미리보기 + "모두 보기" | ✅ 완료 |
|
||||||
|
| **MyPreferencesSection** | `MyPreferencesSection.tsx` | 환경 설정 (테마 토글, 주간 목표 슬라이더) | ✅ 완료 |
|
||||||
|
| **MyAccountSection** | `MyAccountSection.tsx` | 계정 섹션 (구독 정보, AI 사용량, 계정 삭제) | ✅ 완료 |
|
||||||
|
| **MyProfileSection** | `MyProfileSection.tsx` | 🆕 **(2026-07)** 시안 3섹션(프로필/기본정보/비밀번호) | ✅ 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 `src/components/teacher/` - 교사 콘솔 (신규, 2026-07)
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일명 | 설명 | 상태 |
|
||||||
|
|---------|--------|------|------|
|
||||||
|
| **LearningManagement** | `learning/LearningManagement.tsx` | 🆕 학습관리 — 커리큘럼·스케줄·캘린더 탭이 전부 회차 문서 하나에서 파생된다(`learning/useCurriculumRows.ts`+`learning/sessionRows.ts`+`learning/scheduleView.ts`+`utils/sessionSchedule.ts`). 수업 시작·종료(`learning/ClassControls.tsx`)와 회차 수정·추가·삭제(`learning/EditSessionDialog.tsx`)도 여기 단독으로 있다 — 팀 상세에는 수업 제어가 없다 | ✅ 완료 |
|
||||||
|
| **NoticePage** | `notice/NoticePage.tsx` | 공지사항, `noticeManager`로 Firestore `notices` 연동 (목데이터 제거) | ✅ 완료 |
|
||||||
|
|
||||||
|
**참고**: LearningManagement는 커리큘럼 탭·스케줄 탭 모두 백엔드 연동 완료(`learning/mockData.ts` 제거됨). NoticePage는 공지사항 백엔드 연동 완료. 팀 관리 페이지(`teacher/teams/[teamId]`)의 출결관리 탭 및 `/teacher/home` 출석현황 패널도 백엔드 연동 완료 — 별도 컬렉션 없이 글쓰기 활동 기반으로 파생(`DATA_MODELS.md` 참조)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 📁 예정된 컴포넌트 디렉토리
|
### 📁 예정된 컴포넌트 디렉토리
|
||||||
|
|
||||||
| 디렉토리 | 설명 | 컴포넌트 |
|
| 디렉토리 | 설명 | 컴포넌트 |
|
||||||
@ -1037,6 +1143,7 @@
|
|||||||
| **AIUsageManager** | `AIUsageManager.ts` | 🆕 **AI 사용량 관리** (플랜 정보, 사용량 조회, 기능 사용 가능 여부 체크, 1분 캐싱) | ✅ 완료 |
|
| **AIUsageManager** | `AIUsageManager.ts` | 🆕 **AI 사용량 관리** (플랜 정보, 사용량 조회, 기능 사용 가능 여부 체크, 1분 캐싱) | ✅ 완료 |
|
||||||
| **OrganizationManager** | `OrganizationManager.ts` | 🆕 **조직 관리** (Organization CRUD, 멤버 조회, 2분 캐싱) | ✅ 완료 |
|
| **OrganizationManager** | `OrganizationManager.ts` | 🆕 **조직 관리** (Organization CRUD, 멤버 조회, 2분 캐싱) | ✅ 완료 |
|
||||||
| **FeedManager** | `FeedManager.ts` | 피드 관련 API 호출 (영감, 주간 목표, 팀 활동) | ✅ 완료 |
|
| **FeedManager** | `FeedManager.ts` | 피드 관련 API 호출 (영감, 주간 목표, 팀 활동) | ✅ 완료 |
|
||||||
|
| **TeamSessionManager** | `TeamSessionManager.ts` | 🆕 **회차 API 호출** (조회·생성·수정·열기/종료·삭제·일정 재생성·체크인). ⚠️ 캐시 TTL이 성능 손잡이가 아니라 **전환 지연 손잡이**다 — 크론이 없어 조회가 곧 전이이므로 TTL을 늘리면 수업이 늦게 열린다 (구 `ClassSessionManager` 흡수) | ✅ 완료 |
|
||||||
| **index.ts** | `index.ts` | 모든 매니저 export | ✅ 완료 |
|
| **index.ts** | `index.ts` | 모든 매니저 export | ✅ 완료 |
|
||||||
| **LevelManager** | `LevelManager.ts` | 레벨/경험치 관리 | ❌ 미구현 |
|
| **LevelManager** | `LevelManager.ts` | 레벨/경험치 관리 | ❌ 미구현 |
|
||||||
| **StickerManager** | `StickerManager.ts` | 스티커 획득/관리 | ❌ 미구현 |
|
| **StickerManager** | `StickerManager.ts` | 스티커 획득/관리 | ❌ 미구현 |
|
||||||
@ -1116,7 +1223,7 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
| **Pattern Analysis** | `patternAnalysisService.ts` | 🆕 **글 작성 패턴 분석 서비스** (10개 글 종합 분석, AI 평가) | ✅ 완료 |
|
| **Pattern Analysis** | `patternAnalysisService.ts` | 🆕 **글 작성 패턴 분석 서비스** (10개 글 종합 분석, AI 평가) | ✅ 완료 |
|
||||||
| **Spelling Check** | `spellingService.ts` | **맞춤법 검사 서비스** (Gemini 기반, 초등학생 눈높이) | ✅ 완료 |
|
| **Spelling Check** | `spellingService.ts` | **맞춤법 검사 서비스** (Gemini 기반, 초등학생 눈높이) | ✅ 완료 |
|
||||||
| **Writing Assistance** | `writingAssistanceService.ts` | 🆕 **AI 글쓰기 도우미 서비스** (4단계 힌트 생성, 주제 맥락 활용, 서버 캐싱) | ✅ 완료 |
|
| **Writing Assistance** | `writingAssistanceService.ts` | 🆕 **AI 글쓰기 도우미 서비스** (4단계 힌트 생성, 주제 맥락 활용, 서버 캐싱) | ✅ 완료 |
|
||||||
| **Studio Service** | `studioService.ts` | 🆕 **스튜디오 플로우 AI 서비스** (extractStudioKeywords: 그림→키워드 5개, segmentSentences: 띄어쓰기 교정+낱말 분해, suggestReplacements: 교체·삽입 추천, _ai 사용량 메타 전달) | ✅ 완료 |
|
| **Studio Service** | `studioService.ts` | 🆕 **스튜디오 플로우 AI 서비스** (extractStudioKeywords: 그림→키워드 5개, segmentSentences: 띄어쓰기 교정+낱말 분해, suggestReplacements: 교체·삽입 추천, generateStudioDraft: aiDraft 문단 초안+하이라이트, suggestSentenceRewrites: aiDraft 문장 고쳐 쓰기 후보, _ai 사용량 메타 전달) | ✅ 완료 |
|
||||||
| **Region Health** | `regionHealthManager.ts` | **Region 과부하 상태 추적** (자동 복구, 1분 TTL) | ✅ 완료 |
|
| **Region Health** | `regionHealthManager.ts` | **Region 과부하 상태 추적** (자동 복구, 1분 TTL) | ✅ 완료 |
|
||||||
| ~~**Team Service**~~ | ~~`teamService.ts`~~ | ~~팀 CRUD~~ | ⚠️ Deprecated (TeamManager로 이동) |
|
| ~~**Team Service**~~ | ~~`teamService.ts`~~ | ~~팀 CRUD~~ | ⚠️ Deprecated (TeamManager로 이동) |
|
||||||
| ~~**Student Service**~~ | ~~`studentService.ts`~~ | ~~학생 CRUD, PIN 해시/검증~~ | ⚠️ Deprecated (UserManager로 대체) |
|
| ~~**Student Service**~~ | ~~`studentService.ts`~~ | ~~학생 CRUD, PIN 해시/검증~~ | ⚠️ Deprecated (UserManager로 대체) |
|
||||||
@ -1170,12 +1277,10 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
| **🆕 Validation** | `validation.ts` | 폼 검증 유틸리티 (이메일/비밀번호/이름/비밀번호 확인), 타입 안전, 다국어 에러 메시지 | ✅ 완료 |
|
| **🆕 Validation** | `validation.ts` | 폼 검증 유틸리티 (이메일/비밀번호/이름/비밀번호 확인), 타입 안전, 다국어 에러 메시지 | ✅ 완료 |
|
||||||
| **🆕 SSE Stream Processor** | `sseStreamProcessor.ts` | Server-Sent Events 스트리밍 처리 (계정 병합 진행률 표시) | ✅ 완료 |
|
| **🆕 SSE Stream Processor** | `sseStreamProcessor.ts` | Server-Sent Events 스트리밍 처리 (계정 병합 진행률 표시) | ✅ 완료 |
|
||||||
| **Password Security** | `passwordSecurity.ts` | HIBP API 연동 (유출된 비밀번호 차단) | ✅ 완료 |
|
| **Password Security** | `passwordSecurity.ts` | HIBP API 연동 (유출된 비밀번호 차단) | ✅ 완료 |
|
||||||
| **Password Strength** | `passwordStrength.ts` | 비밀번호 강도 계산 | ✅ 완료 |
|
|
||||||
| **Content Hash** | `contentHash.ts` | 🆕 **SHA-256 해시 생성** (글 목록 → 해시, id+updatedAt 조합), 🆕 **generateWritingContentHash** (개별 글 content 해시), 서버/클라이언트 지원 | ✅ 완료 |
|
| **Content Hash** | `contentHash.ts` | 🆕 **SHA-256 해시 생성** (글 목록 → 해시, id+updatedAt 조합), 🆕 **generateWritingContentHash** (개별 글 content 해시), 서버/클라이언트 지원 | ✅ 완료 |
|
||||||
| **Pattern Cache Manager** | `patternCacheManager.ts` | 🆕 **패턴 분석 localStorage 캐싱** (LRU 최대 10개, 캐시 통계) | ✅ 완료 |
|
| **Pattern Cache Manager** | `patternCacheManager.ts` | 🆕 **패턴 분석 localStorage 캐싱** (LRU 최대 10개, 캐시 통계) | ✅ 완료 |
|
||||||
| **Image Storage** | `imageStorage.ts` | 🆕 **Firebase Storage 이미지 업로드** (base64 → Storage, 공개 URL 반환) | ✅ 완료 |
|
| **Image Storage** | `imageStorage.ts` | 🆕 **Firebase Storage 이미지 업로드** (base64 → Storage, 공개 URL 반환) | ✅ 완료 |
|
||||||
| **Area Limit** | `areaLimit.ts` | 🆕 **이미지 왜곡 영역 제한** (점수 기반 해금 로직, 좌표 계산) | ✅ 완료 |
|
| **Area Limit** | `areaLimit.ts` | 🆕 **이미지 왜곡 영역 제한** (점수 기반 해금 로직, 좌표 계산) | ✅ 완료 |
|
||||||
| **Pending Image Store** | `pendingImageStore.ts` | 🆕 **이미지 임시 저장** (IndexedDB 기반, 새로고침해도 유지, CRUD 메서드) | ✅ 완료 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1185,8 +1290,8 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
|------|--------|------|------|
|
|------|--------|------|------|
|
||||||
| **useRequireAuth** | `useRequireAuth.ts` | **인증 필수 페이지 훅** (비인증 시 자동 리다이렉트, additionalLoading 지원) | ✅ 완료 |
|
| **useRequireAuth** | `useRequireAuth.ts` | **인증 필수 페이지 훅** (비인증 시 자동 리다이렉트, additionalLoading 지원) | ✅ 완료 |
|
||||||
| **useTeamData** | `useTeamData.ts` | 🆕 **팀 데이터 로딩 훅** (팀 + 멤버 로딩, 권한 체크, refreshMembers) | ✅ 완료 |
|
| **useTeamData** | `useTeamData.ts` | 🆕 **팀 데이터 로딩 훅** (팀 + 멤버 로딩, 권한 체크, refreshMembers) | ✅ 완료 |
|
||||||
| **🆕 useShakeAnimation** | `useShakeAnimation.ts` | **에러 Shake 애니메이션 훅** (에러 발생 시 자동 shake, getShakeAnimateProps 헬퍼, 중복 제거) | ✅ 완료 |
|
|
||||||
| **useWritingInactivityDetection** | `useWritingInactivityDetection.ts` | **작성 멈춤 감지 훅** (N분 입력 없을 시 콜백, 타이머 리셋, 남은 시간) | ✅ 완료 |
|
| **useWritingInactivityDetection** | `useWritingInactivityDetection.ts` | **작성 멈춤 감지 훅** (N분 입력 없을 시 콜백, 타이머 리셋, 남은 시간) | ✅ 완료 |
|
||||||
|
| **🆕 useClassCheckIn** | `useClassCheckIn.ts` | **수업 체크인 훅** (마운트 시 체크인 API 호출, 페이지와 분리된 독립 모듈 — 대기실→글쓰기 직행 전환 시 이식 가능) | ✅ 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1202,6 +1307,7 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
| **Studio Templates** | `studio/templates.ts` | 🆕 **스튜디오 역할별 자유 슬롯** (상황/감정/이유 1슬롯씩, STUDIO_BLANK_IDS, getStudioBlanks, composeStudioSentences, studioBlankId) | ✅ 완료 |
|
| **Studio Templates** | `studio/templates.ts` | 🆕 **스튜디오 역할별 자유 슬롯** (상황/감정/이유 1슬롯씩, STUDIO_BLANK_IDS, getStudioBlanks, composeStudioSentences, studioBlankId) | ✅ 완료 |
|
||||||
| **Studio Segmentation** | `studio/segmentation.ts` | 🆕 **S4 분해 프롬프트** (의미 단위 낱말 덩어리 + 띄어쓰기 교정, ko/en/ja) | ✅ 완료 |
|
| **Studio Segmentation** | `studio/segmentation.ts` | 🆕 **S4 분해 프롬프트** (의미 단위 낱말 덩어리 + 띄어쓰기 교정, ko/en/ja) | ✅ 완료 |
|
||||||
| **Studio Suggestion** | `studio/suggestion.ts` | 🆕 **S4 추천 프롬프트** (낱말 교체 + 자리 삽입 추천, ko/en/ja) | ✅ 완료 |
|
| **Studio Suggestion** | `studio/suggestion.ts` | 🆕 **S4 추천 프롬프트** (낱말 교체 + 자리 삽입 추천, ko/en/ja) | ✅ 완료 |
|
||||||
|
| **Studio Draft** | `studio/draft.ts` | 🆕 **(aiDraft) 문단 초안·문장 고쳐 쓰기 프롬프트** (문장 타입 정의·그림 맥락 기반 초안 + 타입별 완성 문장 후보, ko/en/ja) | ✅ 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1216,6 +1322,8 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
| **스튜디오 그림 분석** | `/api/studio/analyze` | POST | 🆕 **S1 그림 키워드 추출** (analyzeImage 재사용, 키워드 5개 + 분석 결과, 플랜 검증) | ✅ 완료 |
|
| **스튜디오 그림 분석** | `/api/studio/analyze` | POST | 🆕 **S1 그림 키워드 추출** (analyzeImage 재사용, 키워드 5개 + 분석 결과, 플랜 검증) | ✅ 완료 |
|
||||||
| **스튜디오 분해** | `/api/studio/segment` | POST | 🆕 **S4 띄어쓰기 교정 + 낱말 분해** (의미 단위 낱말 덩어리, 무결성 가드) | ✅ 완료 |
|
| **스튜디오 분해** | `/api/studio/segment` | POST | 🆕 **S4 띄어쓰기 교정 + 낱말 분해** (의미 단위 낱말 덩어리, 무결성 가드) | ✅ 완료 |
|
||||||
| **스튜디오 추천** | `/api/studio/suggest` | POST | 🆕 **S4 교체·삽입 추천** (낱말 교체 + 자리 삽입, Response Schema) | ✅ 완료 |
|
| **스튜디오 추천** | `/api/studio/suggest` | POST | 🆕 **S4 교체·삽입 추천** (낱말 교체 + 자리 삽입, Response Schema) | ✅ 완료 |
|
||||||
|
| **스튜디오 초안** | `/api/studio/draft` | POST | 🆕 **(aiDraft) 문단 초안 생성** (회차 문장 타입당 1문장 + 핵심 구절 하이라이트, 회차 Firestore 우선 해석) | ✅ 완료 |
|
||||||
|
| **스튜디오 고쳐 쓰기** | `/api/studio/rewrite` | POST | 🆕 **(aiDraft) 문장 고쳐 쓰기 후보** (타입당 완성 문장 2~3개, 실패 시 빈 후보) | ✅ 완료 |
|
||||||
| **스튜디오 완료** | `/api/studio/complete` | POST | 🆕 **S6 발행** (draft→published, studioResult 저장, HTML sanitize, AI 호출 없음, 주제 사용량 증가) | ✅ 완료 |
|
| **스튜디오 완료** | `/api/studio/complete` | POST | 🆕 **S6 발행** (draft→published, studioResult 저장, HTML sanitize, AI 호출 없음, 주제 사용량 증가) | ✅ 완료 |
|
||||||
| **글 생성** | `/api/writing` | POST | 🆕 **글 생성** (wordCount/charCount 자동 계산, 🆕 **analysis 저장**) | ✅ 완료 |
|
| **글 생성** | `/api/writing` | POST | 🆕 **글 생성** (wordCount/charCount 자동 계산, 🆕 **analysis 저장**) | ✅ 완료 |
|
||||||
| **글 조회** | `/api/writing/[id]` | GET | 🆕 **글 조회** (작성자만 접근) | ✅ 완료 |
|
| **글 조회** | `/api/writing/[id]` | GET | 🆕 **글 조회** (작성자만 접근) | ✅ 완료 |
|
||||||
@ -1239,6 +1347,11 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
| **대기 초대 응답** | `/api/pending-invite/[id]/respond` | POST | 🆕 **초대 수락/거절** | ✅ 완료 |
|
| **대기 초대 응답** | `/api/pending-invite/[id]/respond` | POST | 🆕 **초대 수락/거절** | ✅ 완료 |
|
||||||
| **학생 일괄 생성** | `/api/team/[teamId]/bulk-create-members` | POST | 🆕 **CSV 기반 학생 계정 일괄 생성** (소유자 전용, 템플릿 ID/PW, Firebase Auth 생성) | ✅ 완료 |
|
| **학생 일괄 생성** | `/api/team/[teamId]/bulk-create-members` | POST | 🆕 **CSV 기반 학생 계정 일괄 생성** (소유자 전용, 템플릿 ID/PW, Firebase Auth 생성) | ✅ 완료 |
|
||||||
| **loginId 변환** | `/api/auth/resolve-login-id` | POST | 🆕 **loginId→시스템 이메일 변환** (인증 불필요, 로그인 시 사용) | ✅ 완료 |
|
| **loginId 변환** | `/api/auth/resolve-login-id` | POST | 🆕 **loginId→시스템 이메일 변환** (인증 불필요, 로그인 시 사용) | ✅ 완료 |
|
||||||
|
| **선생님 홈 통계** | `/api/teacher/stats` | GET | 🆕 **미확인/미평가 과제 수** (내 팀의 published 글 중 내 댓글이 없는 글 개수, `teamId in` 청크 조회) | ✅ 완료 |
|
||||||
|
| **수업 시작(수동)** | `/api/team/[teamId]/session` | POST | 🆕 **교사 수동 수업 세션 오픈** (소유자 전용, 이미 열려 있으면 기존 세션 반환) | ✅ 완료 |
|
||||||
|
| **학생 체크인** | `/api/team/[teamId]/session/checkin` | POST | 🆕 **대기실 체크인** (멤버 전용, lazy 오픈 후 기록, 멱등) | ✅ 완료 |
|
||||||
|
| **수업 대기실 조회** | `/api/team/[teamId]/session/current` | GET | 🆕 **세션+체크인 목록+내 체크인 조회** (멤버 전용) | ✅ 완료 |
|
||||||
|
| **학생 입장 카드 알림** | `/api/student/sessions` | GET | 🆕 **내 팀들의 입장 가능 세션 알림** (`getClassNotificationsForUser` 단일 파생 지점) | ✅ 완료 |
|
||||||
|
|
||||||
**서버 레이어** (`src/lib/server/`):
|
**서버 레이어** (`src/lib/server/`):
|
||||||
- `team.ts` - 팀 Firestore CRUD, 🆕 **getTeamAIConfig/updateTeamAIConfig** (AI 설정 관리)
|
- `team.ts` - 팀 Firestore CRUD, 🆕 **getTeamAIConfig/updateTeamAIConfig** (AI 설정 관리)
|
||||||
@ -1252,6 +1365,12 @@ firebase functions:log --only cleanupExpiredReservations
|
|||||||
- 🆕 `organization.ts` - **Organization CRUD** (getOrganization, createOrganization, updateOrganization, deleteOrganization, isOrganizationPlanValid)
|
- 🆕 `organization.ts` - **Organization CRUD** (getOrganization, createOrganization, updateOrganization, deleteOrganization, isOrganizationPlanValid)
|
||||||
- 🆕 `pending-invite.ts` - **대기 초대 Firestore CRUD** (이메일 기반 초대 생성/조회/수락/거절/취소)
|
- 🆕 `pending-invite.ts` - **대기 초대 Firestore CRUD** (이메일 기반 초대 생성/조회/수락/거절/취소)
|
||||||
- 🆕 `bulk-member.ts` - **학생 일괄 생성** (템플릿 처리, loginId 해석, Firebase Auth 계정 생성, 팀 멤버 추가)
|
- 🆕 `bulk-member.ts` - **학생 일괄 생성** (템플릿 처리, loginId 해석, Firebase Auth 계정 생성, 팀 멤버 추가)
|
||||||
|
- 🆕 `teamSession.ts` - **회차 CRUD·전이·체크인** (구 `classSession.ts`·`assignment.ts` 흡수). **날짜 비교를 독점한다** — 라우트에 날짜 비교가 한 줄이라도 남으면 그 위반은 정적 검사에 잡히지 않은 채 퍼지므로, 입력 검증(`assertSessionIsToday`)까지 이 파일 안에 있다. 쓰기 경로 전부가 `teamSessionCursors/{teamId}`를 트랜잭션 안에서 읽고 **또 쓴다**(읽기만으로는 직렬화되지 않는다)
|
||||||
|
- 🆕 `kst.ts` - **KST(UTC+9 고정 오프셋) 공용 유틸** (날짜/시각 문자열 변환, scheduledStartAt 등 실제 Timestamp 구성용 `kstDateTimeUtc`)
|
||||||
|
- `attendance.ts` - 출결 응답 DTO 집계 (회차+체크인 → 교사 화면 2곳의 응답 형태로 변환, 활동-파생 로직은 완전히 제거됨)
|
||||||
|
|
||||||
|
**공유 레이어** (`src/lib/shared/`):
|
||||||
|
- 🆕 `sessionTransition.ts` - **회차 전이 판정 순수 함수** (`shouldOpen`/`shouldClose`). Firestore·Timestamp·`new Date()` 의존 0. 전이 규칙은 앱 전체에서 이 파일 하나에만 있고 — 크론을 지운 이유가 바로 규칙 복제본을 0개로 만들기 위해서다 — 실제 쓰기·`order` 부여·트랜잭션은 `teamSession.ts`가 한다
|
||||||
|
|
||||||
**서버 레이어 테스트** (`src/lib/server/__tests__/`):
|
**서버 레이어 테스트** (`src/lib/server/__tests__/`):
|
||||||
- 🆕 `planLimits.test.ts` - 플랜별 제한 상수 검증, 헬퍼 함수 테스트
|
- 🆕 `planLimits.test.ts` - 플랜별 제한 상수 검증, 헬퍼 함수 테스트
|
||||||
@ -1442,7 +1561,6 @@ project_w/
|
|||||||
│ └── [미구현]
|
│ └── [미구현]
|
||||||
│
|
│
|
||||||
└── utils/ # 유틸리티 함수
|
└── utils/ # 유틸리티 함수
|
||||||
├── passwordStrength.ts # ✅ 비밀번호 강도 계산
|
|
||||||
├── passwordSecurity.ts # ✅ HIBP API 연동
|
├── passwordSecurity.ts # ✅ HIBP API 연동
|
||||||
└── classCodeGenerator.ts # ❌ 삭제 예정 (팀 코드 시스템 제거됨)
|
└── classCodeGenerator.ts # ❌ 삭제 예정 (팀 코드 시스템 제거됨)
|
||||||
```
|
```
|
||||||
|
|||||||
106
REFACTOR-NOTES.md
Normal file
106
REFACTOR-NOTES.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Studio Curriculum Refactor — Notes
|
||||||
|
|
||||||
|
Branch: `feat/studio-curriculum`
|
||||||
|
|
||||||
|
## Phase 1 (prior, by remover)
|
||||||
|
- `c977e0b chore: remove gamification curriculum/lesson feature` — removed the
|
||||||
|
unrelated gamification Curriculum/Lesson feature, freeing the `Curriculum`
|
||||||
|
name and `src/types/curriculum.ts` path. tsc/build green.
|
||||||
|
|
||||||
|
## Phase 2 (this work) — data-driven studio Curriculum (회차) entity
|
||||||
|
Three green-gated steps, commit after each (tsc `--noEmit` = 0):
|
||||||
|
|
||||||
|
- **2-1** `a4aa1db feat(studio): add Curriculum entity, role icon registry, 3학년 1회차 seed`
|
||||||
|
- `src/types/curriculum.ts` — `Curriculum` + `CurriculumRole/Meta/AI/Rewards/Copy`
|
||||||
|
(+ `Localized<T>`). Stages are NOT modeled here (fixed at 6 — locked decision).
|
||||||
|
- `src/components/writing/studio/roleIconRegistry.ts` — string icon key →
|
||||||
|
`IconType` (`mapPin`/`heart`/`question`/`default`).
|
||||||
|
- `src/data/studioCurricula/grade3-session1.ts` — seed; role
|
||||||
|
label/guide/placeholder/examples copied verbatim from `messages/*.json`;
|
||||||
|
`ai.keywordCount = 5`; `meta` from the lesson plan (ko; en/ja = ko + `// TODO i18n`).
|
||||||
|
- Pure addition; studio did not consume it yet.
|
||||||
|
|
||||||
|
- **2-2** `3536c8d feat(studio): make writing frame data-driven via Curriculum roles`
|
||||||
|
- Removed the `StudioSentenceKey` union + `STUDIO_SENTENCE_ORDER`.
|
||||||
|
- Generalized every `Record<StudioSentenceKey, …>` → `Record<string, …>`
|
||||||
|
(`StudioSegment/SuggestResponse.bySentence`, `StudioResult.sentencesV1/V2`,
|
||||||
|
`StudioCompleteRequest.sentencesV1/V2`, segment/suggest request `key`,
|
||||||
|
flow state `sentencesV2`, `tokensBySentence`, stage props).
|
||||||
|
- `StudioBlank.sentenceKey` → `roleKey: string` (no back-compat — locked decision).
|
||||||
|
- `templates.ts` (`studioBlankId`/`getStudioBlanks`/`composeStudioSentences`) now
|
||||||
|
takes `CurriculumRole[]` (slotId-driven). `studioRoleMeta.ts` deleted.
|
||||||
|
- `studioService.ts`: `SEGMENT_SCHEMA`/`SUGGEST_SCHEMA` built **at runtime** from
|
||||||
|
role keys (Gemini structured output); `emptyTokenMap`/`emptySuggestionMap` and
|
||||||
|
all loops derive from the role keys carried by the request; `keywordCount`
|
||||||
|
injected (was `STUDIO_KEYWORD_COUNT`, `pickFiveKeywords`→`pickKeywords`).
|
||||||
|
- `segmentation.ts`/`suggestion.ts`: output-key instruction generated from role keys.
|
||||||
|
- Routes resolve the Curriculum from `curriculumId` and add **manual runtime
|
||||||
|
key-validation** (repo's existing `validationErrorResponse` style, not zod):
|
||||||
|
- `analyze` — feeds `ai.keywordCount`.
|
||||||
|
- `segment`/`suggest` — reject sentences whose `key` is not a curriculum role key.
|
||||||
|
- `complete` — require all role keys present in `sentencesV1/V2`; `<p>` body
|
||||||
|
assembly + `safeSentences` iterate role keys in frame order; `roleKey` saved.
|
||||||
|
- `page.tsx` + stages render from `state.curriculum.frame.roles` via
|
||||||
|
`roleIconRegistry`; studio loads the Curriculum with
|
||||||
|
`getCurriculum(?curriculumId=)` → falls back to the `grade3-session1` seed.
|
||||||
|
- 3학년 1회차 renders identically to before. tsc 0, `npm run build` exit 0.
|
||||||
|
- New code-seed registry: `src/data/studioCurricula/index.ts`
|
||||||
|
(`getCurriculum`, `getCurriculumRoleKeys`, `DEFAULT_CURRICULUM_ID`).
|
||||||
|
|
||||||
|
- **2-3** `feat: data-driven studio Curriculum entity (3학년 1회차 seed)`
|
||||||
|
- ICU-templated copy in `messages/{ko,en,ja}.json`, values fed from data:
|
||||||
|
- `complete.title` `{session}` ← `meta.sessionOrdinal`
|
||||||
|
- `keywords.title` `{count}` ← `ai.keywordCount`
|
||||||
|
- `secondWriting.subtitle` `{count}` ← `frame.roles.length`
|
||||||
|
- Removed migrated keys from all three locales: `studio.roles.*`,
|
||||||
|
`studio.firstWriting.{guides,placeholders,examples}`. Platform-chrome keys
|
||||||
|
kept (stepper, reward, actions, imageUpload, etc.). CRLF + tab + trailing
|
||||||
|
newline preserved; key sets identical across the 3 locales (2080 each).
|
||||||
|
- tsc 0, build exit 0.
|
||||||
|
|
||||||
|
## What is data-driven now
|
||||||
|
- Writing-frame roles (key/slotId/icon/label/guide/placeholder/examples) — variable
|
||||||
|
count & names, sourced from `Curriculum.frame.roles`.
|
||||||
|
- AI keyword count (`ai.keywordCount`), AI structured-output schemas & prompt output
|
||||||
|
keys (built from role keys at runtime).
|
||||||
|
- Session/count copy (`complete.title`/`keywords.title`/`secondWriting.subtitle`) via
|
||||||
|
ICU + data.
|
||||||
|
- Lesson-plan `meta` (sessionLabel/topic/objective/achievementStandard/flow/materials/
|
||||||
|
emotionCards) — present as data on the entity.
|
||||||
|
|
||||||
|
## What is intentionally FIXED (not data-driven)
|
||||||
|
- The 6-stage flow `STUDIO_STAGE_ORDER` / `goNext` / `StageStepper` and `stepper.*`
|
||||||
|
i18n labels (locked decision: stages fixed at 6).
|
||||||
|
- Reward UI engine + default motion/sprite preset pools (`rewards` left undefined →
|
||||||
|
shared platform pool).
|
||||||
|
|
||||||
|
## STOP points (product decisions — NOT built, per locked decisions)
|
||||||
|
1. **Management layer** — no Firestore `curriculums` collection, CRUD API, teacher
|
||||||
|
authoring console, or security rules. Curricula live only as code seeds today.
|
||||||
|
A real authoring flow needs a schema + rules + UI decision.
|
||||||
|
2. **Curriculum entry/routing** — studio reads `?curriculumId=` and falls back to
|
||||||
|
`grade3-session1`. How students actually receive a `curriculumId` (team
|
||||||
|
assignment link / home card / Topic linkage) is undecided.
|
||||||
|
> **Resolved 2026-08-10** — a student arrives from the class session. The notice
|
||||||
|
> board and the classroom both link with the in-progress session's `teamId`+`topicId`,
|
||||||
|
> and `_flow/liveCurriculum.ts` overlays the live week/topic onto the code seed. Note
|
||||||
|
> that `meta.sessionOrdinal` (2-3 above) is now **seeded from `TeamSession.order`** —
|
||||||
|
> the confirmed class number — rather than being the seed's own constant. There is no
|
||||||
|
> "assignment link"; assignments were deleted (see `DATA_MODELS.md` → TeamSession).
|
||||||
|
3. **Existing published works** — NO back-compat/migration was built (locked
|
||||||
|
decision #2). Old studio writings stored with `situation/emotion/reason` keys and
|
||||||
|
`blanks[].sentenceKey` may not render correctly under the new `roleKey` shape.
|
||||||
|
If preserving old works matters, a migration/back-fill is required.
|
||||||
|
4. **Multilingual authorship for `meta`** — en/ja for `meta` are ko + `// TODO i18n`
|
||||||
|
(meta is data-only, not surfaced in UI yet). Translation authorship is out of scope.
|
||||||
|
5. **`copy` overrides** — `CurriculumCopy` type exists but the seed leaves it
|
||||||
|
undefined; ICU templates live in i18n. Per-curriculum copy override wiring is not
|
||||||
|
built (not needed for the seed).
|
||||||
|
6. **Topic relationship** — `Curriculum.topicId?` left as an optional aggregation FK;
|
||||||
|
Topic itself unchanged.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
- `npx tsc --noEmit` → 0 errors.
|
||||||
|
- `npm run build` → exit 0 (run after `Remove-Item -Recurse -Force .next` once to
|
||||||
|
clear a stale route-validator cache).
|
||||||
|
- Pre-existing unrelated lint warnings may remain; none introduced.
|
||||||
23
ROADMAP.md
23
ROADMAP.md
@ -1,6 +1,6 @@
|
|||||||
# 라온누리 - 개발 로드맵
|
# 라온누리 - 개발 로드맵
|
||||||
|
|
||||||
> 최종 업데이트: 2026-06-04 (S2 첫 글쓰기 자유 작성 전환)
|
> 최종 업데이트: 2026-07-03 (새 디자인 시안 포팅)
|
||||||
|
|
||||||
초등학생을 위한 창작 글쓰기 교육 플랫폼 개발 계획
|
초등학생을 위한 창작 글쓰기 교육 플랫폼 개발 계획
|
||||||
|
|
||||||
@ -159,6 +159,9 @@
|
|||||||
| **AI 이미지 생성 에러 처리 개선** | **에러 코드별 토스트 메시지 처리 로직 개선, plan_not_supported/limit_exceeded/ai_disabled 등 에러 코드 메시지 추가, 에러 발생 시 'selecting' 단계로 되돌아가는 로직 유지, 다국어 지원 (ko/en/ja)** | **2025-12-19** |
|
| **AI 이미지 생성 에러 처리 개선** | **에러 코드별 토스트 메시지 처리 로직 개선, plan_not_supported/limit_exceeded/ai_disabled 등 에러 코드 메시지 추가, 에러 발생 시 'selecting' 단계로 되돌아가는 로직 유지, 다국어 지원 (ko/en/ja)** | **2025-12-19** |
|
||||||
| **의존성 업데이트** | **@ark-ui/react 및 @zag-js 패키지 버전 업데이트 (v1.29.1 → v1.31.1)** | **2025-12-19** |
|
| **의존성 업데이트** | **@ark-ui/react 및 @zag-js 패키지 버전 업데이트 (v1.29.1 → v1.31.1)** | **2025-12-19** |
|
||||||
| **인터랙션 페이지 UX 대폭 개선** | **7:3 그리드 레이아웃 (이미지 70%, 컨트롤러 30%), 한 화면에서 이미지 보면서 편집 가능, 컨트롤러 내부 스크롤 (커스텀 스크롤바), InteractionHeader 컴포넌트 (IntersectionObserver + Sentinel 패턴, sticky header, glassmorphism 효과, 제목 크기 동적 변경 2xl→xl, 버튼 텍스트 숨김), framer-motion 애니메이션 (AnimatePresence, MotionBox/MotionVStack, 모드 전환/영역 선택 부드러운 전환, 0.2~0.3초 ease curve), 영역 목록을 일반 모드에서도 표시 (CustomAreaList, 고급 모드에서만 CustomParameterPanel), 에디터 버튼 우측 패널 상단 이동 (추가/삭제/숨기기, 가로 배치 flex:1), Container maxW 확장 (1400px→95vw), 이미지 반응형 수정 (InteractiveImageViewer width:100%, VStack maxH 제거, aspectRatio 충돌 해결), 높이 제한 80dvh (우측만 스크롤), 타입 체크 통과** | **2025-12-24** |
|
| **인터랙션 페이지 UX 대폭 개선** | **7:3 그리드 레이아웃 (이미지 70%, 컨트롤러 30%), 한 화면에서 이미지 보면서 편집 가능, 컨트롤러 내부 스크롤 (커스텀 스크롤바), InteractionHeader 컴포넌트 (IntersectionObserver + Sentinel 패턴, sticky header, glassmorphism 효과, 제목 크기 동적 변경 2xl→xl, 버튼 텍스트 숨김), framer-motion 애니메이션 (AnimatePresence, MotionBox/MotionVStack, 모드 전환/영역 선택 부드러운 전환, 0.2~0.3초 ease curve), 영역 목록을 일반 모드에서도 표시 (CustomAreaList, 고급 모드에서만 CustomParameterPanel), 에디터 버튼 우측 패널 상단 이동 (추가/삭제/숨기기, 가로 배치 flex:1), Container maxW 확장 (1400px→95vw), 이미지 반응형 수정 (InteractiveImageViewer width:100%, VStack maxH 제거, aspectRatio 충돌 해결), 높이 제한 80dvh (우측만 스크롤), 타입 체크 통과** | **2025-12-24** |
|
||||||
|
| **학생 QR 로그인** | **로그인 페이지 아이디/QR 탭 전환 + QrLoginScanner(인라인)(getUserMedia + jsQR 동적 import·640px 축소·10fps 제한, BarcodeDetector는 Windows/Linux 데스크톱 미지원이라 채택하지 않음), qrLoginCodec.ts(rn1: 접두사 + base64 페이로드, 잘못된 입력은 null 반환), 실패 단일 출구(권한 거부·미지원·인식 실패·로그인 실패 → "직접 입력하기"), 카메라 불가 환경에서는 QR 탭 미렌더 + 사유 콘솔 출력, 신규 백엔드 0(authStore.login의 loginId 경로 재사용), 다국어 지원 (ko/en/ja)** | **2026-08-10** |
|
||||||
|
| **엑셀 배포용 QR 카드 시트** | **memberCredentialExcel.ts 시트 2분할(학생 계정=교사 보관 · 배포용 QR=이름+QR만 잘라서 배포 — 한 장이면 자를 때 비밀번호가 따라 잘려 나감), qrImage.ts 신설(uqr 행렬 → 캔버스 → PNG base64, exceljs는 SVG 미지원), 인쇄 기준 상수(모듈 6px·여백 4모듈·ECC M), 셀 좌표 앵커에 맞춘 행 높이/열 너비 환산, 안내 문구 A1:G1 병합, 성공 계정만 카드 생성, uqr 직접 의존성 승격, 다국어 지원 (ko/en/ja)** | **2026-08-11** |
|
||||||
|
| **학생 계정 결과 통합 + 엑셀 내보내기** | **MemberCredentialResult 신설 (계정 생성 결과 표 + 비밀번호 1회성 경고 + .xlsx 내보내기), 팀 만들기 성공 모달과 팀 설정 일괄 생성기가 같은 컴포넌트 사용, memberCredentialExcel.ts (브라우저 생성 · exceljs 동적 import · 아이디 문자열 셀 고정 · QR 열 확장 여지), BulkMemberCreator 4단계 스텝/카드 인쇄 제거해 단일 화면화, 고아 컴포넌트 26개 및 미사용 i18n 키 14개 정리, 다국어 지원 (ko/en/ja)** | **2026-08-10** |
|
||||||
| **글쓰기 플로우 단순화** | **/write 커리큘럼 파라미터 자동 리다이렉트, NextAssignmentCard (홈 다음 과제 카드), WritingModeSelector 2단계→1단계 단순화 (토글+카드), TopicSelector 인라인 변형 (variant="inline"), 전체 에디터 페이지 topicId searchParam 처리, 다국어 지원 (ko/en/ja)** | **2026-03-09** |
|
| **글쓰기 플로우 단순화** | **/write 커리큘럼 파라미터 자동 리다이렉트, NextAssignmentCard (홈 다음 과제 카드), WritingModeSelector 2단계→1단계 단순화 (토글+카드), TopicSelector 인라인 변형 (variant="inline"), 전체 에디터 페이지 topicId searchParam 처리, 다국어 지원 (ko/en/ja)** | **2026-03-09** |
|
||||||
| **ImageDropzone 범용화** | **ImageDropzone을 범용 이미지 업로드 컴포넌트로 리팩토링, TeamCoverImageUploader 삭제 및 통합, 새 props 추가 (currentImageUrl, onDelete, containerAspectRatio, acceptedTypes, maxFileSize, labels, showHoverOverlay, label, helperText), document.getElementById→useRef 전환, 팀 생성/공개설정/보안설정 3곳 마이그레이션, 기존 글쓰기 5곳은 변경 없음 (모든 새 props optional + 기본값 동일)** | **2026-02-26** |
|
| **ImageDropzone 범용화** | **ImageDropzone을 범용 이미지 업로드 컴포넌트로 리팩토링, TeamCoverImageUploader 삭제 및 통합, 새 props 추가 (currentImageUrl, onDelete, containerAspectRatio, acceptedTypes, maxFileSize, labels, showHoverOverlay, label, helperText), document.getElementById→useRef 전환, 팀 생성/공개설정/보안설정 3곳 마이그레이션, 기존 글쓰기 5곳은 변경 없음 (모든 새 props optional + 기본값 동일)** | **2026-02-26** |
|
||||||
| **ImageDropzone 카메라 촬영 기능** | **CameraCaptureDialog 컴포넌트 신규 생성 (GlassDialog 기반, getUserMedia 카메라 스트림, 4:3 프레임 가이드 오버레이, 전/후면 카메라 전환), ImageDropzone에 "카메라로 촬영" 버튼 추가 (파일 선택과 나란히 배치), 촬영 → ImageCropper 크롭 플로우 연동, 카메라 권한 거부/미지원 에러 처리, 다국어 지원 (camera namespace ko/en/ja 7개 키), 모든 사용처(글쓰기 5곳, 팀 3곳) 자동 활성화** | **2026-03-03** |
|
| **ImageDropzone 카메라 촬영 기능** | **CameraCaptureDialog 컴포넌트 신규 생성 (GlassDialog 기반, getUserMedia 카메라 스트림, 4:3 프레임 가이드 오버레이, 전/후면 카메라 전환), ImageDropzone에 "카메라로 촬영" 버튼 추가 (파일 선택과 나란히 배치), 촬영 → ImageCropper 크롭 플로우 연동, 카메라 권한 거부/미지원 에러 처리, 다국어 지원 (camera namespace ko/en/ja 7개 키), 모든 사용처(글쓰기 5곳, 팀 3곳) 자동 활성화** | **2026-03-03** |
|
||||||
@ -170,6 +173,21 @@
|
|||||||
| **스튜디오 6단계 선형 글쓰기 플로우** | **"그림으로 시작 → 글 완성" 신규 라우트 /write/studio (기존 fill-in-blank/image/easy 라우트와 병행 운영, 추후 제거 예정), 단일 페이지 상태머신 (page.tsx 오케스트레이터 useReducer, StudioFlowState 단일 진실 공급원, _flow/types.ts에 StudioStageProps 계약 정의), 7개 스테이지 컴포넌트 (_stages/Intro·Upload·FirstWriting·RewardInteraction·SecondWriting·RewardEffect·CompletionStage), StageStepper 진행 표시기 (점수·숫자 미표시, 참여 기반), 6단계: S0 안내(종이 그림)→S1 업로드(AI 키워드 5개 추출)→S2 1차 작문(3문장 빈칸: 상황 2칸/감정/이유, 학생 단독 AI 없음)→S3 보상① 인터랙션→S4 2차 작문(AI 추천 표현 종류별)→S5 보상② 이펙트→S6 완료(발행), S2→S3 전환 시 draft Writing 생성(createWriting)+이미지 업로드(uploadUserImage), 공용 RewardCanvasEditor (인터랙션/이펙트 공용, 영역 1개 제한, useDistortionEditor 기반), AI 엔드포인트 3개 (POST /api/studio/analyze·recommend-expressions·complete), studioService.ts (extractStudioKeywords·recommendExpressions), 프롬프트 studio/templates.ts·expressionRecommendation.ts, 타입 studio.ts (StudioStage/SentenceKey/Blank/Result 등), writing.ts에 keywords?·studioResult? 추가, writings 컬렉션에 keywords·studioResult(sentencesV1/V2·blanks·grantedInteraction/Effect·completedAt) 저장, 보상은 distortionAreas/spriteEffectAreas, status draft→published, rewardType participation, /write 진입 카드 추가, 다국어 지원 (studio namespace, pageTitles.writeStudio, ko/en/ja)** | **2026-06-02** |
|
| **스튜디오 6단계 선형 글쓰기 플로우** | **"그림으로 시작 → 글 완성" 신규 라우트 /write/studio (기존 fill-in-blank/image/easy 라우트와 병행 운영, 추후 제거 예정), 단일 페이지 상태머신 (page.tsx 오케스트레이터 useReducer, StudioFlowState 단일 진실 공급원, _flow/types.ts에 StudioStageProps 계약 정의), 7개 스테이지 컴포넌트 (_stages/Intro·Upload·FirstWriting·RewardInteraction·SecondWriting·RewardEffect·CompletionStage), StageStepper 진행 표시기 (점수·숫자 미표시, 참여 기반), 6단계: S0 안내(종이 그림)→S1 업로드(AI 키워드 5개 추출)→S2 1차 작문(3문장 빈칸: 상황 2칸/감정/이유, 학생 단독 AI 없음)→S3 보상① 인터랙션→S4 2차 작문(AI 추천 표현 종류별)→S5 보상② 이펙트→S6 완료(발행), S2→S3 전환 시 draft Writing 생성(createWriting)+이미지 업로드(uploadUserImage), 공용 RewardCanvasEditor (인터랙션/이펙트 공용, 영역 1개 제한, useDistortionEditor 기반), AI 엔드포인트 3개 (POST /api/studio/analyze·recommend-expressions·complete), studioService.ts (extractStudioKeywords·recommendExpressions), 프롬프트 studio/templates.ts·expressionRecommendation.ts, 타입 studio.ts (StudioStage/SentenceKey/Blank/Result 등), writing.ts에 keywords?·studioResult? 추가, writings 컬렉션에 keywords·studioResult(sentencesV1/V2·blanks·grantedInteraction/Effect·completedAt) 저장, 보상은 distortionAreas/spriteEffectAreas, status draft→published, rewardType participation, /write 진입 카드 추가, 다국어 지원 (studio namespace, pageTitles.writeStudio, ko/en/ja)** | **2026-06-02** |
|
||||||
| **스튜디오 플로우 UI 통합 리디자인** | **공용 프리미티브 + 단계별 일관 레이아웃, S2 빈칸 입력 버그 수정 (src/components/writing/studio/에 표현 전용 공용 프리미티브 정리: StudioCard·StudioStageHeader·RolePill+studioRoleMeta·FillBlankSentence·StudioImageCard·RewardBanner·ExpressionChip·studioMotion, 전 단계가 하나의 카드/헤더/애니메이션 시스템 공유, FillBlankSentence 고정 폭 빈칸 입력 + 빈칸별 힌트로 입력 늘어남 버그 수정, studio namespace i18n 키 추가 roles.{situation,emotion,reason}·firstWriting.blankStub·rewardInteraction.tip·rewardEffect.tip·reward.{controlsTitle,dragHint,applyButton}·secondWriting.contextTitle, ko/en/ja)** | **2026-06-04** |
|
| **스튜디오 플로우 UI 통합 리디자인** | **공용 프리미티브 + 단계별 일관 레이아웃, S2 빈칸 입력 버그 수정 (src/components/writing/studio/에 표현 전용 공용 프리미티브 정리: StudioCard·StudioStageHeader·RolePill+studioRoleMeta·FillBlankSentence·StudioImageCard·RewardBanner·ExpressionChip·studioMotion, 전 단계가 하나의 카드/헤더/애니메이션 시스템 공유, FillBlankSentence 고정 폭 빈칸 입력 + 빈칸별 힌트로 입력 늘어남 버그 수정, studio namespace i18n 키 추가 roles.{situation,emotion,reason}·firstWriting.blankStub·rewardInteraction.tip·rewardEffect.tip·reward.{controlsTitle,dragHint,applyButton}·secondWriting.contextTitle, ko/en/ja)** | **2026-06-04** |
|
||||||
| **S2 첫 글쓰기 자유 작성 전환** | **고정 빈칸 템플릿("나는 ___ 때 ___ 일이 있었습니다" 등) 제거 → 선생님 직접 교육 방식. 역할(상황/감정/이유)별 자유 Textarea 1문장 + 전구 안내(guides) + 예문 2개(examples)를 FirstWritingStage가 직접 렌더, FillBlankSentence 컴포넌트 삭제, templates.ts 토큰 템플릿 제거하고 역할당 1슬롯 모델로 단순화 (STUDIO_BLANK_IDS 3개·getStudioBlanks·composeStudioSentences(values)·studioBlankId), StudioTemplateToken/StudioSentenceTemplate 타입 제거, 다운스트림(sentencesV1/V2·S4 분해) 불변, 예문/안내는 현재 i18n 하드코딩(주제 연동 시 주제 데이터로 변수화 예정), firstWriting i18n 재구성 freePlaceholder·examplesLabel·guides.{situation,emotion,reason}·examples.{role}.{0,1} (ko/en/ja). 부수: TECH_STACK 스튜디오 섹션을 이미 반영된 S4 segment/suggest 파이프라인 기준으로 정정** | **2026-06-04** |
|
| **S2 첫 글쓰기 자유 작성 전환** | **고정 빈칸 템플릿("나는 ___ 때 ___ 일이 있었습니다" 등) 제거 → 선생님 직접 교육 방식. 역할(상황/감정/이유)별 자유 Textarea 1문장 + 전구 안내(guides) + 예문 2개(examples)를 FirstWritingStage가 직접 렌더, FillBlankSentence 컴포넌트 삭제, templates.ts 토큰 템플릿 제거하고 역할당 1슬롯 모델로 단순화 (STUDIO_BLANK_IDS 3개·getStudioBlanks·composeStudioSentences(values)·studioBlankId), StudioTemplateToken/StudioSentenceTemplate 타입 제거, 다운스트림(sentencesV1/V2·S4 분해) 불변, 예문/안내는 현재 i18n 하드코딩(주제 연동 시 주제 데이터로 변수화 예정), firstWriting i18n 재구성 freePlaceholder·examplesLabel·guides.{situation,emotion,reason}·examples.{role}.{0,1} (ko/en/ja). 부수: TECH_STACK 스튜디오 섹션을 이미 반영된 S4 segment/suggest 파이프라인 기준으로 정정** | **2026-06-04** |
|
||||||
|
| **스튜디오 aiDraft 모드 (AI 초안 + 문장 다듬기)** | **회차(Curriculum)에 수업 방식 mode 필드 도입 (CurriculumMode "selfWriting"\|"aiDraft", 미지정=selfWriting, 모드별 스테이지 순서는 getStudioStageOrder가 코드로 고정), aiDraft 5단계 플로우 (업로드→AI 문단 초안 생성→문장 다듬기→보상①→보상②→완성), frame.roles를 "문장 타입"(중심 문장·이유·장면 설명·예시·마무리)으로 재해석 (CurriculumRole.color 배지·하이라이트 파스텔색 + aiHint 타입별 AI 지시문 추가), AI 엔드포인트 2개 신설 (POST /api/studio/draft 타입당 1문장 초안+핵심 구절 highlight, POST /api/studio/rewrite 타입당 완성 문장 후보 2~3개·백그라운드 프리페치), DraftRefineStage+DraftSentenceList (번호+타입 배지+하이라이트 문장 행, 클릭→팝업: 고치기 안내 guide+AI 제안 선택+직접 고쳐 쓰기 Textarea), 초안 원문은 studioResult.sentencesV1로 저장 (초안→다듬은 글 비교), StageStepper stages prop (모드별 단계 표시), 교사 콘솔 확장 (CurriculumFormModal 수업 방식 선택, CurriculumRoleEditor aiDraft 모드 시 배지 색 스와치 피커+AI 문장 조건 입력·placeholder/examples 숨김, CurriculumList 모드 배지), 교사 개수 설정 (ai.rewriteCount 1~4 문장당 AI 제안 필수 제시 개수 — 부족 시 서버 1회 재시도·병합·재시도 사용량 별도 로깅, ai.requiredEditCount 0~문장 타입 수 필수 수정 문장 수 — 초안 원문 대비 달라진 문장 기준·goNext 가드+nextBlockedReason+complete API 서버 검증·"고친 문장 n/N" 진행 칩·고친 행 체크 표시, validateCurriculumAiCounts 서버 범위 검증), 시드 회차 grade3-session2 (aiDraft, 문장 타입 5종, rewriteCount 3·requiredEditCount 2), 서버 resolveCurriculum (Firestore 우선+코드 시드 폴백 — segment/suggest/complete 라우트도 교사 생성 회차 키 검증 가능하도록 교체), studioService generateStudioDraft/suggestSentenceRewrites + prompts/studio/draft.ts, 다국어 지원 (studio.draftRefine·stepper.draftRefine·error.draftFailed/sentencesRequired, ko/en/ja)** | **2026-06-10** |
|
||||||
|
| **QR 휴대폰 사진 업로드 + 이미지 업로드 UI 리디자인** | **공용 ImageDropzone 전면 리디자인(구름 업로드 클러스터+핑크 점선 테두리+흰 알약 버튼[파일 선택/카메라로 촬영/휴대폰으로]+우하단 카메라 스티커, 전 사용처 공통 적용, compact/containerAspectRatio/일반 3모드 보존) + QR 휴대폰 사진 핸드오프(데스크톱 QR↔휴대폰 /{locale}/upload/{id}, 폰 캔버스 축소 전송[최대 1600px JPEG], 공유 저장소 RTDB /handoffs/{id}, 데스크톱 onValue 실시간 리스너로 즉시 감지→기존 ImageCropper 4:3 크롭 연결). 비인증 POST/DELETE /api/handoff(폰 쓰기 Admin 경유·데스크톱 읽기만 클라 리스너). 보안: 128bit handoffId·IP별 레이트리밋(RTDB 트랜잭션 1분 30회)·매직바이트 MIME 검증·작은 이미지 가드(600px 미만 거부)·database.rules.json handoffs(읽기 auth/쓰기 false). 정리: functions cleanupHandoffs(onSchedule 매시, 버려진 /handoffs·/handoff_rate 삭제). 신규 src/lib/handoff/{config,rateLimit}.ts·src/app/api/handoff/{route,store}.ts·QrUploadDialog.tsx·app/[locale]/upload/[id]/page.tsx·functions/src/cleanup/cleanupHandoffs.ts. 다국어 imageUpload.qr·uploadPage(ko/en/ja). 배포: firebase deploy --only database·functions** | **2026-06-10** |
|
||||||
|
| **새 디자인 시안 포팅** | **`raonnuri-next-main` 목업을 Chakra UI v3로 전면 포팅. 디자인 시스템 확장(design.* semantic 토큰 네임스페이스, 그림자 토큰 6종, textStyles 7종, 버튼 variant 5종, input variant, 기본 폰트 SUIT→Pretendard, package.json postinstall typegen). 인증 구조를 LoginDialog 모달→전용 페이지 9개로 전환(/login, /signup 역할선택, /signup/{student,teacher,complete,pending,rejected}, /find-account, /sns/role, authStore.openLoginDialog는 /login 리다이렉트 shim), 공용 AuthCard/PasswordEyeInput 컴포넌트 신설. 크롬 리디자인(Navbar 역할 기반, TeacherShell+teacher/layout.tsx 사이드바 셸, Footer). 학생 홈 리디자인(히어로 카드+알림장+나의 팀 그리드, StudentHeroCard/StudentNoticeCard/StudentTeamMiniCard). 마이페이지 리디자인(시안 3섹션 MyProfileSection 추가, 기존 통계·구독 유지). 팀/글 페이지 리디자인(/team, /team/all, /team/[teamId], /writing, /writing/[writingId] - TeamCard/WritingCard 리디자인, 투톤 제목, 캐러셀). 교사 콘솔 리디자인(/teacher/home 대시보드 신규, /team/[teamId]/manage 6탭 재구성, /team/create 커리큘럼 연동 폼). 교사 신규 페이지 2개(/teacher/learning 학습관리 - 커리큘럼 테이블+월 캘린더, /teacher/notice 공지사항 - 현재 목데이터, 백엔드 미연동). i18n ko/en/ja 신규 네임스페이스(auth, teacherLearning, teacherNotice, teacherShell 등), 키 정합성 2143개 일치** | **2026-07-03** |
|
||||||
|
|
||||||
|
| **시안 라우트 규칙 전면 적용** | **로그인 이전 화면만 루트, 이후는 전부 역할 하위로 재구성. 이동: /my→/student·teacher/mypage(공용 MyPageContent), /team/all→/student/teams/all, /team/create→/teacher/create-team, /team/[teamId]→/student/teams/[teamId], /team/[teamId]/manage→/teacher/teams/[teamId], /write/*→/student/write/*, /writing/*→/student/writings/*, /interaction·/imageUpload→/student/*. 구 경로는 전부 리다이렉트 shim(쿼리 보존, 역할 분기)으로 하위 호환. 루트 유지: 인증 페이지, /pricing, /invite, /upload/[id](무인증 QR), /admin/*, /studio-admin. Navbar /my 링크 역할 분기, TeacherShell 레거시 /team 활성 매칭 제거** | **2026-07-03** |
|
||||||
|
| **공지사항 백엔드 연동** | **`/teacher/notice` 목데이터 → Firestore `notices` 컬렉션 연동. `Notice` 타입(src/types/notice.ts), 서버 레이어(src/lib/server/notice.ts), REST API(GET/POST /api/notice, PUT/DELETE /api/notice/[noticeId], 조회는 인증 사용자, 생성/수정/삭제는 관리자 전용), `NoticeManager` 싱글톤 추가. NoticePage 클라이언트에서 로딩/빈 상태 처리 후 목록 렌더 (고정 공지 우선 정렬, No.는 목록 순서 기반 표시값)** | **2026-07-03** |
|
||||||
|
| **학습관리 커리큘럼 탭 백엔드 연동** | **`/teacher/learning` 커리큘럼 탭 목데이터 → 실 데이터 연동 (`useCurriculumRows` 훅에서 `teamManager.getMyTeams()` + `curriculumManager.getCurriculum()` 결합, 데스크톱/모바일 렌더 경로가 동일 훅 결과 공유). 행 매핑: teamName←team.name, curriculum←현재/최신 배정 회차의 meta.sessionLabel(미배정 시 "—"), weeks←team.studioCurriculumIds 회차를 meta.sessionOrdinal 순 정렬(week/topic/subtitle=sessionLabel/topic/objective, competency 필드 없어 "—" 고정), status←배정 여부로 in_progress/scheduled, dateRange←"—"(일정 모델 부재), No.는 목록 역순 번호. 로딩(Spinner)/빈 상태(팀 없음)/주차 빈 상태(배정 없음) i18n 키 추가(teacherLearning.curriculumEmpty·weeksTable.empty, ko/en/ja). 스케줄/캘린더 탭은 목데이터 유지(SCHEDULE_EVENTS 등)** | **2026-07-03** |
|
||||||
|
| **팀 수업 일정(classSchedule) 모델 + 학습 스케줄 연동** | **`Team.classSchedule`(days/time/startDate/endDate) 신규 필드 + `UpdateTeamData.classSchedule`(null=삭제), PUT `/api/team/:id` 서버 검증(`validateTeamClassSchedule` — 요일 0~6 중복없이 1개 이상, time HH:mm, 날짜 YYYY-MM-DD, startDate≤endDate) + `updateTeam`에서 null→Firestore FieldValue.delete(). 설정 UI: 팀 관리 페이지 설정 다이얼로그(`TeamSettingsDialog`)에 "수업 일정" 섹션 추가(요일 칩 다중선택+시간/시작일/종료일 입력, 일부만 입력 시 저장 차단). 소비처 3곳 연동: (1) `/teacher/learning` 스케줄 탭 — `mockData.ts` 삭제, `scheduleTypes.ts`+`scheduleView.ts`+`utils/schedule.ts`(로컬 타임존 안전 날짜 유틸)로 캘린더 이벤트/팀 범례를 실 팀 데이터로 생성(일정 팀 0개면 빈 상태), (2) `useCurriculumRows` dateRange/status를 classSchedule 기반으로 우선 사용(없으면 기존 배정 기반 로직 유지), (3) `/teacher/home` studyCount를 오늘 요일+기간 매칭 팀 수로 계산(출결 패널은 별도 스토리로 목데이터 유지)** | **2026-07-03** |
|
||||||
|
| **출결관리 백엔드 연동 (마지막 목데이터 스토리 완료)** | **별도 attendance 컬렉션 없이 글쓰기 활동 기반으로 출결을 파생(derive)하는 방식으로 구현. 서버 레이어 `src/lib/server/attendance.ts`(KST 고정 +09:00 오프셋으로 날짜/시각 계산, `getRecentClassDays`로 classSchedule 기준 최근 수업일 산출, `computeTeamAttendance`로 `(teamId, createdAt)` range 쿼리 1회 후 날짜별/유저별 그룹핑 — 팀원 그 날 첫 글쓰기 시각=출석, `classSchedule.time + 10분` 초과 시 지각, 팀 소유자는 로스터에서 제외). API 2개 신설: `GET /api/team/:teamId/attendance`(팀 소유자 전용, 오늘/최근 수업일 출결 + 최근 5개 수업일 이력), `GET /api/teacher/attendance`(오늘이 수업일인 내 팀만, 수업 시작 전이면 `attendance: null`로 "수업 전" 표시). `TeamManager.getTeamAttendance`/`getTodayAttendance` 1분 캐싱. UI: `/teacher/home` 출석현황 패널과 `/teacher/teams/[teamId]` 출결관리 탭에서 `MOCK_ATTENDANCE_*` 전량 제거 후 실 데이터 연동(로딩/일정 미설정 빈 상태 추가), 출결 이력 확장 시 노출되던 목데이터 안내 문구를 "글쓰기 활동 기반 자동 집계" 안내로 교체(다국어 ko/en/ja). `writings(teamId, createdAt)` 복합 인덱스 추가** | **2026-07-03** |
|
||||||
|
| **출석 체크인 시스템 (수업 세션 + 인앱 입장 카드 + 수업 대기실)** | **활동-파생 출석을 완전 폐기하고 체크인 레코드 단일 진실로 전환. `classSessions/{teamId}_{date}`(결정적 ID) + `checkins/{uid}` 서브컬렉션 신설(`src/types/classSession.ts`, `src/lib/server/classSession.ts`). 세션 오픈은 시간표 경과 후 첫 관찰 요청의 lazy 오픈(`openSessionIfDue`, 트랜잭션 create-if-absent) 또는 교사 수동 시작(`openSessionManually`, 중복 방지) 2가지, 지각 기준 `baselineAt`=시간표 날 max(시간표 시각, 오픈 시각)·무시간표 수동=오픈 시각, 유예는 `Team.attendanceGraceMinutes`(기본 10, 0~60) 스냅샷. 체크인은 단일 트랜잭션(`recordCheckIn`)으로 최초 생성 시에만 `presentCount`/`lateCount` 증가(재호출·동시 요청에도 카운터 불변). API 4개 신설: `POST /team/:teamId/session`(교사 수동 시작), `POST /team/:teamId/session/checkin`(학생 체크인, 멱등), `GET /team/:teamId/session/current`(대기실 데이터), `GET /student/sessions`(입장 카드 알림, `getClassNotificationsForUser` 단일 파생 지점). 기존 `GET /team/:teamId/attendance`·`GET /teacher/attendance`는 응답 계약 동결한 채 세션+체크인 집계로 재작성(히스토리는 시간표 역산 대신 세션 문서 쿼리 — 보강 세션도 노출, 홈 패널 대상 팀도 세션 기반 합집합으로 통일). 학생 UI: 홈 알림장 최상단 입장 카드(dead code이던 assignment 카드 개조) → `/student/class/[teamId]` 대기실 신규 페이지(출석/지각 배지, 오늘 토픽/회차, "글쓰기 시작" 버튼, RTDB `sessionPresence/*` 실시간 참여 현황), 체크인/딥링크는 페이지 밖 독립 모듈(`useClassCheckIn` 훅, `getClassSessionHref` 단일 지점)로 분리해 대기실→글쓰기 직행 전환 대비. 교사 UI: 출결관리 탭 "수업 시작" 버튼/진행 중 표시, `TeamSettingsDialog`에 유예 분 입력. `ClassSessionManager` 신설(30초 캐싱). `firestore.indexes.json`에 `classSessions(teamId, date desc)` 추가·불필요해진 `writings(teamId, createdAt)` 제거, `database.rules.json`에 `sessionPresence/*` 규칙(읽기=인증 사용자, 쓰기=본인 uid) 추가 — 배포 필요. `derivationNote` 안내 문구를 체크인 기반 설명으로 교체(ko/en/ja)** | **2026-07-04** |
|
||||||
|
| **팀 생성 폼에 AI 설정·학습과정·주제 사전 입력** | **팀 관리 페이지의 AI 설정(마스터 스위치+글쓰기 도우미+채점 가중치+루브릭)·학습과정(회차 다중 배정+현재 회차 지정)·주제(팀 주제 초안) 기능을 `/teacher/create-team` 폼에서 미리 입력받도록 확장. teamId가 없는 생성 전 시점이므로 로컬 드래프트로 수집 후 팀 생성 직후 순차 API 호출로 일괄 적용(`applyDraftSettings` — 섹션별 실패는 non-blocking 경고 토스트로 안내, 팀 생성은 유지). `CreateTeamSetupSections.tsx` 신설(`CreateTeamAISection`/`CreateTeamCurriculumSection`/`CreateTeamTopicsSection`), 기존 `TeamScoringSettings`·`TeamRubricSettings`에서 프레젠테이션 에디터(`ScoringWeightsEditor`/`RubricEditor`) 추출해 재사용, `AIConfigDialog`·`CreateTopicDialog`도 드래프트 모드로 재사용. 회차 후보는 관리 탭과 동일하게 browsable+내 회차 합집합. `UpdateTeamData.scoringConfig` 타입 필드 추가로 기존 `as any` 캐스트 제거. i18n `team.create.setup.*` 추가(ko/en/ja)** | **2026-07-14** |
|
||||||
|
|
||||||
|
| **비활성 팀 readonly 처리 + 재활성화 + 서버 가드** | **비활성(soft delete, isActive=false) 팀의 관리 페이지를 readonly로 전환: 상단 "종료된 팀" 배너+재활성화 버튼, 수업 시작/초대 숨김, AI·채점·루브릭 설정 비활성, 회차 배정/주제/멤버 변경 액션 숨김(조회성 메뉴 유지), 팀 설정의 비활성화 버튼을 재활성화 버튼으로 교체·수정하기 비활성·초대/일괄생성 도구 숨김. 서버 방어: `/api/team/**` 뮤테이션 11개 라우트(PUT team은 isActive:true 재활성화만 허용, cover-image, ai-config, curriculum 배정/해제, session 시작, checkin, transfer-ownership, bulk-create-members, add-member, 팀 주제 수정/삭제)에 isActive 가드 추가 — remove-member는 탈퇴 동선이라 의도적으로 허용, 수업 종료(PATCH)도 열린 세션 정리를 위해 허용. 부가: 관리 페이지 참여인원·멤버 목록·실시간 모니터에서 소유자(선생님) 본인 제외. i18n team.manage.inactive.* 추가(ko/en/ja)** | **2026-07-14** |
|
||||||
|
|
||||||
|
| **회차(TeamSession) 모델 재구성 — 수업 1회 = 문서 1건** | **"오늘 무슨 수업인가"의 정의가 4곳에 흩어져 서로 어긋나던 것을 문서 하나로 합쳤다. 흡수 대상 3종: `Assignment`(배정=주차 묶음) · `ClassSession`(출석 세션) · `teams.classSchedule`(일정 배열) — 컬렉션·타입·라우트·매니저·인덱스 전부 삭제하고 기존 데이터는 일회성 스크립트로 퍼지했다. 신설: `teamSessions/{randomId}`(날짜·시각·주차·글감·글작성 단계·진행 상태·출석 카운터) + `checkins/{uid}` 서브컬렉션 + `teamSessionCursors/{teamId}`(회차 번호 커서 + 쓰기 직렬화 잠금). PK를 무작위 ID로 둔 이유는 순번 PK가 회차 끼워넣기를, 날짜 PK가 휴강 이동을 불가능하게 만들고 둘 다 글·출석의 외래키를 깨기 때문이다. 크론(`transitionAssignments`)을 삭제하고 전이를 조회 요청 경로로 일원화 — 전이 판정은 순수 함수 2개(`shouldOpen`/`shouldClose`, `lib/shared/sessionTransition.ts`)에만 있어 규칙 복제본이 0개다. 날짜 비교는 `lib/server/teamSession.ts`가 독점하고 라우트에는 한 줄도 없다(정적 grep으로 검증). API: 배정 4종·`POST /team/:id/session` 폐기 → `GET/POST /team/:id/sessions`, `PUT/PATCH/DELETE .../sessions/:sid`, `POST .../sessions/regenerate`, `GET /teacher/sessions` 신설. 체크인은 클라이언트가 보낸 `sessionId`를 믿지 않고 소속 팀·진행 상태·오늘 날짜를 재검증해 409를 낸다(교사가 날짜를 옮기면 학생이 들고 있던 ID가 낡기 때문). 글에 `sessionOrder`를 박아 배지가 회차를 추가 조회하지 않는다. 수업 제어는 학습 관리 단독(팀 상세에서 제거), 주 N회는 팀 개설 시점 고정. 11커밋** | **2026-08-10** |
|
||||||
|
|
||||||
### 🚧 진행 중
|
### 🚧 진행 중
|
||||||
|
|
||||||
@ -181,6 +199,9 @@
|
|||||||
| 항목 | 설명 | 우선순위 | 예상 일정 |
|
| 항목 | 설명 | 우선순위 | 예상 일정 |
|
||||||
|-----|------|---------|---------|
|
|-----|------|---------|---------|
|
||||||
| **서버 사이드 Redis 캐싱** | **API Routes에 Redis 캐싱, Rate Limiting 추가** | 🟡 중간 | 2025-11-19 |
|
| **서버 사이드 Redis 캐싱** | **API Routes에 Redis 캐싱, Rate Limiting 추가** | 🟡 중간 | 2025-11-19 |
|
||||||
|
| **인터렉션·글쓰기 플로우 상세 시안 반영** | **`/student/write`, `/student/write/studio`, `/student/imageUpload`, `/student/interaction` 등 글쓰기 플로우 페이지에 새 디자인 시안의 인터렉션·시각 디테일 추가 반영** | 🟡 중간 | 2026-07 이후 |
|
||||||
|
| **출석 체크인 FCM 푸시 채널 (2차)** | **현재 인앱 카드 전용인 수업 입장 알림에 FCM 푸시 발송 채널 추가. `getTeamSessionNotificationsForUser`(단일 파생 지점)의 프로듀서는 그대로 두고 발송 워커(채널 소비자)만 신설 — 회차 모델 재사용. 미접속 학생에게도 알림 도달 가능하도록 확장** | 🟡 중간 | 2026-07 이후 |
|
||||||
|
| **글작성 단계의 라우트 바인딩** | **회차의 `writingStages`(Ai초안·빈칸 채우기·혼자 쓰기)는 지금 "어떤 버튼이 켜졌는지" 저장만 한다. 학생 글쓰기 라우트(fill-in-blank/easy/text)로의 바인딩·강제 로직은 미구현** | 🟡 중간 | 미정 |
|
||||||
|
|
||||||
**Phase 1 완료 목표**: 2025년 11월 15일
|
**Phase 1 완료 목표**: 2025년 11월 15일
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,42 @@
|
|||||||
const bg = useColorModeValue("white", "gray.800");
|
const bg = useColorModeValue("white", "gray.800");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 색상 하드코딩 금지 (2026-07 전면 적용)
|
||||||
|
|
||||||
|
**컴포넌트 코드에 raw hex/rgba 사용 금지.** 전체 코드베이스가 토큰화 완료된 상태이며, 새 색상이 필요하면 컴포넌트에 hex를 쓰지 말고 `src/theme/system.ts`의 `design.*` 네임스페이스에 semantic 토큰을 추가(additive)한 뒤 사용한다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Good
|
||||||
|
color="design.text.muted"
|
||||||
|
bg="design.pageBg"
|
||||||
|
boxShadow="cardHover"
|
||||||
|
|
||||||
|
// ✅ Good - react-icons 등 토큰 문자열을 못 받는 prop
|
||||||
|
<FaCrown color="var(--chakra-colors-design-misc-crownIcon)" />
|
||||||
|
|
||||||
|
// ✅ Good - 다크모드에서도 밝게 유지되는 고정 서피스(밝은 배경 이미지 등) 위 텍스트
|
||||||
|
color="design.onImage.strong" // 양 모드 동일값 토큰 그룹
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
color="#848990"
|
||||||
|
bg="rgba(255,94,159,0.08)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**허용 예외 (부득이한 경우)** — 아래 카테고리만 raw 값 유지가 허용된다:
|
||||||
|
|
||||||
|
| 카테고리 | 예시 | 사유 |
|
||||||
|
|---|---|---|
|
||||||
|
| 외부 브랜드 고정색 | 카카오 `#FDDE00`/`#FEE500`, 구글 로고 SVG·버튼 `#F8F9FA` | 브랜드 가이드라인 고정값 |
|
||||||
|
| 데이터성 색상 | `src/prompts/studio/tagColors.ts`, `src/data/studioCurricula/*`, `src/utils/passwordStrength.ts` | UI 스타일이 아닌 콘텐츠/프롬프트/반환 데이터 |
|
||||||
|
| Canvas 2D 렌더링 | `src/components/writing/editorCanvasStyle.ts` | 캔버스 컨텍스트는 CSS 변수 미지원 |
|
||||||
|
| 서드파티 CSS 오버라이드 | `ImageCropper.tsx`(cropper.js), `WritingEditor.tsx`(TipTap placeholder), `BulkMemberCreator.tsx`(인쇄용 HTML 템플릿) | 외부 라이브러리/별도 문서 컨텍스트 |
|
||||||
|
| CSS 문자열 내 색상 | `filter: drop-shadow(...)`, `textShadow`, `mask` 등 문자열 prop | 토큰 문자열 삽입 불가 (가능하면 `var()` 사용) |
|
||||||
|
| SVG 일러스트 내부 색 | `ProblemSection.tsx` 등 아트워크 fill/gradient stop | 그림 데이터이지 UI 스타일이 아님 |
|
||||||
|
| 1회성 장식 그라데이션 | 오로라 블러(`MyProfileHero`), 글래스 오버레이(`PublicWritingCard`), 무지개(`EmptyStateCard`) 등 | 재사용 없는 장식 값 — 재사용이 생기면 즉시 토큰화 |
|
||||||
|
| 메타데이터 | `app/layout.tsx`의 `viewport.themeColor` | Chakra 스타일 시스템 밖 |
|
||||||
|
|
||||||
|
예외에 해당하더라도 같은 값이 2곳 이상에서 반복되기 시작하면 토큰으로 승격할 것.
|
||||||
|
|
||||||
### Landing 페이지 전용 토큰
|
### Landing 페이지 전용 토큰
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
166
TECH_STACK.md
166
TECH_STACK.md
@ -1,6 +1,6 @@
|
|||||||
# 라온누리 - 기술 스택 및 개발 환경
|
# 라온누리 - 기술 스택 및 개발 환경
|
||||||
|
|
||||||
> 최종 업데이트: 2026-06-04 (스튜디오 플로우 UI 통합 리디자인)
|
> 최종 업데이트: 2026-07-03 (새 디자인 시안 포팅 - design.* 토큰 체계, typegen 파이프라인)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -204,6 +204,21 @@ NEXT_PUBLIC_API_URL=/api
|
|||||||
- `brand.*`: 브랜드 컬러 시맨틱 토큰
|
- `brand.*`: 브랜드 컬러 시맨틱 토큰
|
||||||
- `navbar.*`, `menu.*`: 컴포넌트별 시맨틱 토큰
|
- `navbar.*`, `menu.*`: 컴포넌트별 시맨틱 토큰
|
||||||
- `landing.*`: 랜딩 페이지 전용 토큰
|
- `landing.*`: 랜딩 페이지 전용 토큰
|
||||||
|
- 🆕 **`design.*`** (2026-07, `raonnuri-next-main` 시안 포팅): 새 디자인 시안 전용 네임스페이스
|
||||||
|
- `design.pageBg` / `design.cardBg`: 페이지/카드 배경
|
||||||
|
- `design.text.*`: 텍스트 계층 (strong/body/secondary/muted/disabled/twotone/placeholder/tableHeader/ink)
|
||||||
|
- `design.border.*`: input/hairline/divider/headerBottom
|
||||||
|
- `design.fill.*`: track/secondaryBtn/secondaryBtnHover/pillInactive
|
||||||
|
- `design.status.*`: progress/scheduled/late/danger/offline/writing/done/viewBtn (fg/bg 쌍)
|
||||||
|
- `design.pinkTint.*`: text/bg/selected/banner
|
||||||
|
- 라이트/다크 모드 값을 `_light`/`_dark`로 개별 정의 (다크는 대부분 `{colors.gray.*}` 계열로 매핑)
|
||||||
|
- 🆕 **그림자 토큰** (2026-07): `cardHover`, `dropdown`, `popover`, `pinkToast`, `modal`, `segmentThumb`
|
||||||
|
- 🆕 **textStyles** (2026-07, 7종): `designPageTitle`, `designSectionTitle` 등 — 반응형 fontSize + letterSpacing 프리셋
|
||||||
|
- 🆕 **버튼/인풋 variant** (2026-07):
|
||||||
|
- Button: `designPrimary`, `designSecondary`, `designOutline`, `designGhost`, `designDanger` (기존 variant와 공존, 기본 Button 동작에 영향 없음)
|
||||||
|
- Input: `designDefault`
|
||||||
|
- 🆕 **기본 폰트**: SUIT → **Pretendard**로 변경
|
||||||
|
- 🆕 **타입 생성 파이프라인**: `package.json` `postinstall` 스크립트로 `npx @chakra-ui/cli@next typegen src/theme/system.ts` 실행 (의존성 설치 후 자동으로 커스텀 토큰/레시피 타입 생성)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -360,21 +375,22 @@ const nickname = teamManager.getMemberNickname(team, uid, user?.name);
|
|||||||
### 3. 글쓰기 및 저장 로직 (Manager 패턴 적용)
|
### 3. 글쓰기 및 저장 로직 (Manager 패턴 적용)
|
||||||
|
|
||||||
```
|
```
|
||||||
🆕 Write 페이지 라우트 구조 (2025-12-04 분리)
|
🆕 Write 페이지 라우트 구조 (2025-12-04 분리, 2026-07-03 /student 하위 이동)
|
||||||
/write → 모드 선택 화면
|
/student/write → 모드 선택 화면
|
||||||
/write/text → 글 먼저 모드 (글쓰기 → 이미지)
|
/student/write/text → 글 먼저 모드 (글쓰기 → 이미지)
|
||||||
/write/image → 그림 먼저 모드 (이미지 → 글쓰기)
|
/student/write/image → 그림 먼저 모드 (이미지 → 글쓰기)
|
||||||
/write/edit/[id] → 수정 모드
|
/student/write/edit/[id] → 수정 모드
|
||||||
|
(구 /write/* 경로는 쿼리 보존 리다이렉트 shim)
|
||||||
|
|
||||||
1. 사용자가 /write 페이지 접근
|
1. 사용자가 /student/write 페이지 접근
|
||||||
├─> 모드 선택 화면 표시 (글 먼저 / 그림 먼저)
|
├─> 모드 선택 화면 표시 (글 먼저 / 그림 먼저)
|
||||||
├─> 기존 URL 호환성 리다이렉트:
|
├─> 기존 URL 호환성 리다이렉트:
|
||||||
│ ├─> /write?mode=wrt → /write/text
|
│ ├─> ?mode=wrt → /student/write/text
|
||||||
│ ├─> /write?mode=img → /write/image
|
│ ├─> ?mode=img → /student/write/image
|
||||||
│ └─> /write?id=xxx → /write/edit/xxx
|
│ └─> ?id=xxx → /student/write/edit/xxx
|
||||||
└─> 사용자가 모드 선택 → 해당 라우트로 이동
|
└─> 사용자가 모드 선택 → 해당 라우트로 이동
|
||||||
|
|
||||||
1-1. 글쓰기 모드 진입 (/write/text 또는 /write/image)
|
1-1. 글쓰기 모드 진입 (/student/write/text 또는 /student/write/image)
|
||||||
├─> LocalStorage에서 임시 저장된 글 불러오기 (DraftManager)
|
├─> LocalStorage에서 임시 저장된 글 불러오기 (DraftManager)
|
||||||
└─> 에디터에 복원
|
└─> 에디터에 복원
|
||||||
|
|
||||||
@ -408,14 +424,14 @@ const nickname = teamManager.getMemberNickname(team, uid, user?.name);
|
|||||||
├─> LocalStorage draft 삭제
|
├─> LocalStorage draft 삭제
|
||||||
├─> writingManager.analyzeWritingBackground(writingId, locale)
|
├─> writingManager.analyzeWritingBackground(writingId, locale)
|
||||||
│ └─> Fire-and-forget 패턴 (응답 무시, .catch(() => {}))
|
│ └─> Fire-and-forget 패턴 (응답 무시, .catch(() => {}))
|
||||||
└─> router.push(`/imageUpload?writingId=${writingId}`)
|
└─> router.push(`/student/imageUpload?writingId=${writingId}`)
|
||||||
|
|
||||||
5. 이미지 업로드/선택 플로우 (🆕 2025-11-28)
|
5. 이미지 업로드/선택 플로우 (🆕 2025-11-28)
|
||||||
```
|
```
|
||||||
/imageUpload 페이지 (글 저장 후 자동 이동)
|
/student/imageUpload 페이지 (글 저장 후 자동 이동, 구 /imageUpload shim)
|
||||||
|
|
||||||
├─> 이미 이미지 있음?
|
├─> 이미 이미지 있음?
|
||||||
│ └─> router.replace(`/interaction?writingId=${writingId}`)
|
│ └─> router.replace(`/student/interaction?writingId=${writingId}`)
|
||||||
│
|
│
|
||||||
└─> 이미지 선택 (2가지 옵션)
|
└─> 이미지 선택 (2가지 옵션)
|
||||||
├─> AI 생성
|
├─> AI 생성
|
||||||
@ -423,7 +439,7 @@ const nickname = teamManager.getMemberNickname(team, uid, user?.name);
|
|||||||
│ ├─> 2. 장면 선택 (SceneSelector 컴포넌트)
|
│ ├─> 2. 장면 선택 (SceneSelector 컴포넌트)
|
||||||
│ ├─> 3. 프롬프트 최적화 (PromptOptimization API)
|
│ ├─> 3. 프롬프트 최적화 (PromptOptimization API)
|
||||||
│ ├─> 4. 이미지 생성 (Imagen 4.0 Fast)
|
│ ├─> 4. 이미지 생성 (Imagen 4.0 Fast)
|
||||||
│ └─> 5. router.push(`/interaction?writingId=${writingId}`)
|
│ └─> 5. router.push(`/student/interaction?writingId=${writingId}`)
|
||||||
│
|
│
|
||||||
└─> 직접 업로드
|
└─> 직접 업로드
|
||||||
├─> 드래그앤드롭 또는 파일 선택
|
├─> 드래그앤드롭 또는 파일 선택
|
||||||
@ -434,15 +450,15 @@ const nickname = teamManager.getMemberNickname(team, uid, user?.name);
|
|||||||
│ └─> data URL 생성
|
│ └─> data URL 생성
|
||||||
├─> Firebase Storage 업로드
|
├─> Firebase Storage 업로드
|
||||||
│ └─> writingManager.uploadUserImage(writingId, file)
|
│ └─> writingManager.uploadUserImage(writingId, file)
|
||||||
└─> router.push(`/interaction?writingId=${writingId}`)
|
└─> router.push(`/student/interaction?writingId=${writingId}`)
|
||||||
```
|
```
|
||||||
|
|
||||||
6. 인터랙션 편집 플로우 (🆕 2025-11-28 리다이렉트 추가)
|
6. 인터랙션 편집 플로우 (🆕 2025-11-28 리다이렉트 추가)
|
||||||
```
|
```
|
||||||
/interaction 페이지
|
/student/interaction 페이지 (구 /interaction shim)
|
||||||
|
|
||||||
├─> 이미지 없음?
|
├─> 이미지 없음?
|
||||||
│ └─> router.replace(`/imageUpload?writingId=${writingId}`)
|
│ └─> router.replace(`/student/imageUpload?writingId=${writingId}`)
|
||||||
│
|
│
|
||||||
└─> 이미지 있음:
|
└─> 이미지 있음:
|
||||||
├─> 왜곡 영역 편집 (EditorCanvas)
|
├─> 왜곡 영역 편집 (EditorCanvas)
|
||||||
@ -874,9 +890,9 @@ interface Draft {
|
|||||||
- `src/app/api/spelling/check/route.ts` - 맞춤법 검사 API
|
- `src/app/api/spelling/check/route.ts` - 맞춤법 검사 API
|
||||||
- ~~`src/components/writing/ScoreDisplay.tsx`~~ - ❌ 삭제됨 (하이라이트로 대체)
|
- ~~`src/components/writing/ScoreDisplay.tsx`~~ - ❌ 삭제됨 (하이라이트로 대체)
|
||||||
- ~~`src/components/writing/SpellingErrorDisplay.tsx`~~ - ❌ 삭제됨 (하이라이트로 대체)
|
- ~~`src/components/writing/SpellingErrorDisplay.tsx`~~ - ❌ 삭제됨 (하이라이트로 대체)
|
||||||
- `src/app/[locale]/write/text/page.tsx` - Delta 추적 + 통합 + Toast 알림
|
- `src/app/[locale]/student/write/text/page.tsx` - Delta 추적 + 통합 + Toast 알림
|
||||||
- `src/app/[locale]/write/image/page.tsx` - 이미지 먼저 모드 + Toast 알림
|
- `src/app/[locale]/student/write/image/page.tsx` - 이미지 먼저 모드 + Toast 알림
|
||||||
- `src/app/[locale]/write/edit/[id]/page.tsx` - 수정 모드
|
- `src/app/[locale]/student/write/edit/[id]/page.tsx` - 수정 모드
|
||||||
|
|
||||||
**타입 정의**:
|
**타입 정의**:
|
||||||
- `src/types/draft.ts` - Draft, AnalysisHistoryItem
|
- `src/types/draft.ts` - Draft, AnalysisHistoryItem
|
||||||
@ -1019,8 +1035,8 @@ interface Draft {
|
|||||||
- 성공/실패 시 기존 loading toast 제거 후 새 toast 표시
|
- 성공/실패 시 기존 loading toast 제거 후 새 toast 표시
|
||||||
|
|
||||||
**참고 파일**:
|
**참고 파일**:
|
||||||
- `src/app/[locale]/write/text/page.tsx` - Toast 알림 통합
|
- `src/app/[locale]/student/write/text/page.tsx` - Toast 알림 통합
|
||||||
- `src/app/[locale]/write/image/page.tsx` - Toast 알림 통합
|
- `src/app/[locale]/student/write/image/page.tsx` - Toast 알림 통합
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1384,8 +1400,8 @@ interface WritingAnalysis {
|
|||||||
- `src/app/api/analyze-pattern/route.ts` - analysis 재사용 로직
|
- `src/app/api/analyze-pattern/route.ts` - analysis 재사용 로직
|
||||||
|
|
||||||
**클라이언트**:
|
**클라이언트**:
|
||||||
- `src/app/[locale]/write/text/page.tsx` - 저장 시 분석 수행
|
- `src/app/[locale]/student/write/text/page.tsx` - 저장 시 분석 수행
|
||||||
- `src/app/[locale]/write/image/page.tsx` - 저장 시 분석 수행
|
- `src/app/[locale]/student/write/image/page.tsx` - 저장 시 분석 수행
|
||||||
- `src/managers/WritingManager.ts` - `CreateWritingParams.analysis`
|
- `src/managers/WritingManager.ts` - `CreateWritingParams.analysis`
|
||||||
|
|
||||||
#### 비용 절감 효과
|
#### 비용 절감 효과
|
||||||
@ -1443,7 +1459,7 @@ const improvementRate = calculateImprovementRate(spellingErrorsHistory);
|
|||||||
**타입**: `src/types/writing.ts`
|
**타입**: `src/types/writing.ts`
|
||||||
**유틸**: `src/utils/contentHash.ts`
|
**유틸**: `src/utils/contentHash.ts`
|
||||||
**서버**: `src/lib/server/writing.ts`, `src/app/api/analyze-pattern/route.ts`
|
**서버**: `src/lib/server/writing.ts`, `src/app/api/analyze-pattern/route.ts`
|
||||||
**클라이언트**: `src/app/[locale]/write/{text,image,edit}/page.tsx`, `src/managers/WritingManager.ts`
|
**클라이언트**: `src/app/[locale]/student/write/{text,image,edit}/page.tsx`, `src/managers/WritingManager.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -2654,7 +2670,7 @@ interface BulkCreateResult {
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ 오케스트레이터 src/app/[locale]/write/studio/page.tsx │
|
│ 오케스트레이터 src/app/[locale]/student/write/studio/page.tsx │
|
||||||
│ - useReducer(flowReducer, StudioFlowState) ← 단일 진실 │
|
│ - useReducer(flowReducer, StudioFlowState) ← 단일 진실 │
|
||||||
│ - goNext(): 현재 단계별 부수효과 실행 후 advance() │
|
│ - goNext(): 현재 단계별 부수효과 실행 후 advance() │
|
||||||
│ - AI 호출 / draft 생성 / 보상 저장 / 발행 모두 여기서 처리 │
|
│ - AI 호출 / draft 생성 / 보상 저장 / 발행 모두 여기서 처리 │
|
||||||
@ -2663,7 +2679,7 @@ interface BulkCreateResult {
|
|||||||
│ Pick<StudioStageProps, ...> 으로 필요한 props만 전달
|
│ Pick<StudioStageProps, ...> 으로 필요한 props만 전달
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ 스테이지 컴포넌트 src/app/[locale]/write/studio/_stages/ │
|
│ 스테이지 컴포넌트 src/app/[locale]/student/write/studio/_stages/ │
|
||||||
│ IntroStage · UploadStage · FirstWritingStage · │
|
│ IntroStage · UploadStage · FirstWritingStage · │
|
||||||
│ RewardInteractionStage · SecondWritingStage · │
|
│ RewardInteractionStage · SecondWritingStage · │
|
||||||
│ RewardEffectStage · CompletionStage │
|
│ RewardEffectStage · CompletionStage │
|
||||||
@ -2680,7 +2696,7 @@ interface BulkCreateResult {
|
|||||||
└──────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**계약 파일**: `src/app/[locale]/write/studio/_flow/types.ts` — `StudioFlowState`(전역 상태) / `StudioStageProps`(스테이지 prop 시그니처) / `deriveBlankValues()` 헬퍼.
|
**계약 파일**: `src/app/[locale]/student/write/studio/_flow/types.ts` — `StudioFlowState`(전역 상태) / `StudioStageProps`(스테이지 prop 시그니처) / `deriveBlankValues()` 헬퍼.
|
||||||
|
|
||||||
#### S0 → S6 단계 흐름
|
#### S0 → S6 단계 흐름
|
||||||
|
|
||||||
@ -2705,22 +2721,50 @@ S5 rewardEffect 스프라이트 이펙트 1개 부여 → 적용 (RewardCanv
|
|||||||
S6 complete 1회차 수업 종료 → onComplete() → POST /api/studio/complete (발행)
|
S6 complete 1회차 수업 종료 → onComplete() → POST /api/studio/complete (발행)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### AI 엔드포인트 4개
|
#### 수업 방식(모드) 분기 — aiDraft (2026-06-10)
|
||||||
|
|
||||||
|
회차(Curriculum)에 `mode: "selfWriting" | "aiDraft"`(CurriculumMode)를 도입했다. 모드는 스테이지 구성을 정하며, 순서는 코드가 고정한다(`getStudioStageOrder` — 회차는 stage 배열을 데이터로 갖지 않음). 미지정 회차는 `selfWriting`(기존 6단계).
|
||||||
|
|
||||||
|
**aiDraft 5단계**: `upload → draftRefine → rewardInteraction → rewardEffect → complete`
|
||||||
|
|
||||||
|
```
|
||||||
|
S1 upload 그림 업로드 + 제목 (다음 시 로그인 가드 → 분석 → 초안 생성까지 연속 실행)
|
||||||
|
↓ runAnalyze() → POST /api/studio/analyze
|
||||||
|
↓ runGenerateDraft() → POST /api/studio/draft (문장 타입당 1문장 + 핵심 구절 highlight)
|
||||||
|
↓ 백그라운드: fetchRewrites() → POST /api/studio/rewrite (타입당 완성 문장 후보 2~3개 프리페치)
|
||||||
|
S2 draftRefine AI 문단 초안을 문장 단위로 다듬기 (DraftRefineStage + DraftSentenceList)
|
||||||
|
· 행 = 번호 + 타입 배지(role.color) + 문장(highlight를 타입 색으로 칠함)
|
||||||
|
· 문장 클릭 → 팝업: 고치기 안내(role.guide) + AI 제안 선택 + 직접 고쳐 쓰기
|
||||||
|
S3/S4 보상① ② selfWriting과 동일 (RewardCanvasEditor)
|
||||||
|
S5 complete 발행 — sentencesV1 = AI 초안 원문, sentencesV2 = 다듬은 문장
|
||||||
|
```
|
||||||
|
|
||||||
|
- **frame.roles 재해석**: aiDraft에서 role = "문장 타입"(중심 문장·이유·장면 설명·예시·마무리). `CurriculumRole`에 `color?`(배지·하이라이트 파스텔 hex, 미지정 시 STUDIO_TAG_PALETTE 순환)와 `aiHint?`(타입별 AI 지시문) 옵션 필드 추가 — 역할 키 기반 검증/저장(sentencesV2, complete API)은 두 모드가 공유한다.
|
||||||
|
- **회차 서버 해석**: `resolveCurriculum(id)`(lib/server/curriculum.ts) — Firestore 우선 + 코드 시드 폴백. segment/suggest/complete 라우트도 이것으로 교체해 **교사 생성 회차의 역할 키 검증**이 가능해졌다(기존엔 코드 시드만 봐서 폴백 키와 비교됐음).
|
||||||
|
- **교사 콘솔**: CurriculumFormModal 수업 방식 선택, CurriculumRoleEditor가 aiDraft일 때 배지 색 스와치 + AI 문장 조건 입력(placeholder/examples 숨김), CurriculumList 모드 배지. 시드: `grade3-session2`(aiDraft).
|
||||||
|
- **교사 개수 설정 (aiDraft)**: `ai.rewriteCount`(1~4, 기본 3) — 문장당 AI 제안 **필수 제시 개수**. 프롬프트가 "정확히 N개"를 요구하고, 정제 후 부족하면 서비스가 1회 재시도해 두 응답을 병합(중복 제거)으로 채운다(본호출+재시도 각각 logAIUsage). `ai.requiredEditCount`(0~문장 타입 수, 기본 0) — **필수 수정 문장 수**. "고침" = 문장이 초안 원문(sentencesV1)과 달라진 상태이며, 기준을 채워야 다음 단계로 전진(클라 가드: goNext + nextBlockedReason) 및 발행 가능(complete API 서버 검증). UI는 초안 카드에 "고친 문장 n/N" 진행 칩 + 고친 행에 체크 아이콘 표시. 범위 검증은 POST/PUT /api/curriculum의 `validateCurriculumAiCounts`가 담당.
|
||||||
|
- **초안 재생성 가드**: `draftOriginal`이 캐시 역할(같은 그림 재진입 시 재생성 안 함), SET_IMAGE 시 초안·문장·후보 모두 무효화.
|
||||||
|
|
||||||
|
#### AI 엔드포인트 6개
|
||||||
|
|
||||||
| API | 경로 | AI | 역할 |
|
| API | 경로 | AI | 역할 |
|
||||||
|-----|------|----|------|
|
|-----|------|----|------|
|
||||||
| **분석** | `POST /api/studio/analyze` | ✅ | 그림 분석(analyzeImage 재사용) → 키워드 5개 + ImageAnalysisResult |
|
| **분석** | `POST /api/studio/analyze` | ✅ | 그림 분석(analyzeImage 재사용) → 키워드 5개 + ImageAnalysisResult |
|
||||||
| **분해** | `POST /api/studio/segment` | ✅ | S4: 3문장 → 띄어쓰기 교정 + 의미 단위 낱말 분해 (낱말 텍스트 그대로, 글자 변조 금지 가드) |
|
| **분해** | `POST /api/studio/segment` | ✅ | S4: 3문장 → 띄어쓰기 교정 + 의미 단위 낱말 분해 (낱말 텍스트 그대로, 글자 변조 금지 가드) |
|
||||||
| **추천** | `POST /api/studio/suggest` | ✅ | S4: 분해된 낱말 → 낱말 교체 추천 + 자리 삽입 추천 (분해와 분리된 별도 호출) |
|
| **추천** | `POST /api/studio/suggest` | ✅ | S4: 분해된 낱말 → 낱말 교체 추천 + 자리 삽입 추천 (분해와 분리된 별도 호출) |
|
||||||
|
| **초안** | `POST /api/studio/draft` | ✅ | (aiDraft) 회차 문장 타입당 1문장 문단 초안 + highlight (mode 검증, 실패 시 에러 → 재시도 안내) |
|
||||||
|
| **고쳐 쓰기** | `POST /api/studio/rewrite` | ✅ | (aiDraft) 타입당 완성 문장 후보 2~3개 (실패 시 빈 후보 — 직접 고쳐 쓰기는 계속 가능) |
|
||||||
| **완료** | `POST /api/studio/complete` | ❌ | draft→published 발행, studioResult 저장, HTML sanitize |
|
| **완료** | `POST /api/studio/complete` | ❌ | draft→published 발행, studioResult 저장, HTML sanitize |
|
||||||
|
|
||||||
**서비스 레이어**: `src/services/studioService.ts`
|
**서비스 레이어**: `src/services/studioService.ts`
|
||||||
- `extractStudioKeywords(imageBase64, mimeType, locale)` — imageAnalysisService.analyzeImage 재사용, suggestedKeywords→(부족 시)objects 순으로 정확히 5개 확정 (패딩 없음)
|
- `extractStudioKeywords(imageBase64, mimeType, locale)` — imageAnalysisService.analyzeImage 재사용, suggestedKeywords→(부족 시)objects 순으로 정확히 5개 확정 (패딩 없음)
|
||||||
- `segmentSentences(sentences, locale)` — AI가 의미 단위 낱말 덩어리로 반환 → 결정적 분해/조립, `stripSpaces` 무결성 가드 통과 시 채택, 실패 시 공백 분리 폴백
|
- `segmentSentences(sentences, locale)` — AI가 의미 단위 낱말 덩어리로 반환 → 결정적 분해/조립, `stripSpaces` 무결성 가드 통과 시 채택, 실패 시 공백 분리 폴백
|
||||||
- `suggestReplacements(sentences, keywords, locale)` — generateContent + Response Schema, 문장별 `{replacements, insertions}`(교체·삽입) 클램프/정제, 실패 시 빈 추천 폴백
|
- `suggestReplacements(sentences, keywords, locale)` — generateContent + Response Schema, 문장별 `{replacements, insertions}`(교체·삽입) 클램프/정제, 실패 시 빈 추천 폴백
|
||||||
|
- `generateStudioDraft(types, analysis, keywords, title, locale)` — (aiDraft) 문장 타입당 1문장 + highlight(text의 부분 문자열일 때만 채택), 문장 누락 시 throw
|
||||||
|
- `suggestSentenceRewrites(types, sentences, keywords, locale)` — (aiDraft) 타입당 완성 문장 후보(현재 문장·중복 제외, 3개 클램프), 실패 시 빈 후보 폴백
|
||||||
- 사용량 메타데이터(`_ai`)는 라우트로 그대로 전달 → 라우트가 logAIUsage 기록
|
- 사용량 메타데이터(`_ai`)는 라우트로 그대로 전달 → 라우트가 logAIUsage 기록
|
||||||
|
|
||||||
**프롬프트**: `src/prompts/studio/templates.ts`(역할별 자유 슬롯 — STUDIO_BLANK_IDS/getStudioBlanks/composeStudioSentences/studioBlankId), `src/prompts/studio/segmentation.ts`·`suggestion.ts`(ko/en/ja)
|
**프롬프트**: `src/prompts/studio/templates.ts`(역할별 자유 슬롯 — STUDIO_BLANK_IDS/getStudioBlanks/composeStudioSentences/studioBlankId), `src/prompts/studio/segmentation.ts`·`suggestion.ts`·`draft.ts`(ko/en/ja)
|
||||||
|
|
||||||
#### 데이터 / 영속화 모델
|
#### 데이터 / 영속화 모델
|
||||||
|
|
||||||
@ -2775,16 +2819,60 @@ interface StudioResult {
|
|||||||
|
|
||||||
#### 참고 파일
|
#### 참고 파일
|
||||||
|
|
||||||
- `src/app/[locale]/write/studio/page.tsx` - 오케스트레이터 (useReducer 상태머신)
|
- `src/app/[locale]/student/write/studio/page.tsx` - 오케스트레이터 (useReducer 상태머신)
|
||||||
- `src/app/[locale]/write/studio/_flow/types.ts` - StudioFlowState / StudioStageProps
|
- `src/app/[locale]/student/write/studio/_flow/types.ts` - StudioFlowState / StudioStageProps
|
||||||
- `src/app/[locale]/write/studio/_stages/*.tsx` - 7개 스테이지 컴포넌트
|
- `src/app/[locale]/student/write/studio/_stages/*.tsx` - 7개 스테이지 컴포넌트
|
||||||
- `src/components/ui/StageStepper.tsx` - 6단계 진행 표시기
|
- `src/components/ui/StageStepper.tsx` - 6단계 진행 표시기
|
||||||
- `src/components/writing/studio/RewardCanvasEditor.tsx` - 인터랙션/이펙트 공용 편집기
|
- `src/components/writing/studio/RewardCanvasEditor.tsx` - 인터랙션/이펙트 공용 편집기
|
||||||
- `src/components/writing/studio/{StudioCard,StudioStageHeader,RolePill,StudioImageCard,RewardBanner,SentenceTokenEditor}.tsx` + `{sentenceTokens,studioMotion,studioRoleMeta}.ts` - 공용 프리미티브 (통합 비주얼 언어)
|
- `src/components/writing/studio/{StudioCard,StudioStageHeader,RolePill,StudioImageCard,RewardBanner,SentenceTokenEditor,DraftSentenceList}.tsx` + `{sentenceTokens,studioMotion,studioRoleMeta}.ts` - 공용 프리미티브 (통합 비주얼 언어)
|
||||||
- `src/services/studioService.ts` - extractStudioKeywords / segmentSentences / suggestReplacements
|
- `src/services/studioService.ts` - extractStudioKeywords / segmentSentences / suggestReplacements / generateStudioDraft / suggestSentenceRewrites
|
||||||
- `src/app/api/studio/{analyze,segment,suggest,complete}/route.ts` - AI 엔드포인트 4개
|
- `src/app/api/studio/{analyze,segment,suggest,draft,rewrite,complete}/route.ts` - AI 엔드포인트 6개
|
||||||
- `src/prompts/studio/{templates.ts,segmentation.ts,suggestion.ts}` - 프롬프트
|
- `src/prompts/studio/{templates.ts,segmentation.ts,suggestion.ts,draft.ts}` - 프롬프트
|
||||||
- `src/types/studio.ts` - 플로우 타입, `src/types/writing.ts` - keywords?/studioResult?
|
- `src/types/studio.ts` - 플로우 타입(StudioStage/getStudioStageOrder/StudioDraftSentence 등), `src/types/curriculum.ts` - CurriculumMode/CurriculumRole(color·aiHint), `src/types/writing.ts` - keywords?/studioResult?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 28. QR 사진 핸드오프 시스템 (휴대폰 → 데스크톱 사진 전달)
|
||||||
|
|
||||||
|
**목적**: 데스크톱에서 종이 그림을 올릴 때, 옆에 둔 휴대폰으로 찍어 즉시 전달한다. 공용 `ImageDropzone` 빈 상태의 "휴대폰으로" 버튼에서 진입.
|
||||||
|
|
||||||
|
**핵심 설계 — 공유 저장소 + 실시간 리스너**
|
||||||
|
- 데모는 인메모리 `Map`(단일 인스턴스 가정). App Hosting(Cloud Run)은 오토스케일/콜드스타트로 인스턴스가 여러 개라, 폰 POST와 데스크톱이 다른 프로세스에 도달하면 인메모리 항목을 못 찾는다 → **RTDB 공유 저장소**로 전환.
|
||||||
|
- 폰은 비로그인이라 **쓰기는 서버(Admin) 경유**, 로그인된 데스크톱만 **클라이언트 `onValue` 리스너**로 직접 읽는다(폴링 아님 → 즉시 감지).
|
||||||
|
|
||||||
|
**데이터 흐름**
|
||||||
|
```
|
||||||
|
데스크톱: "휴대폰으로" → QrUploadDialog 마운트(새 128bit handoffId)
|
||||||
|
→ QR(value = {SHARE_BASE_URL||origin}/{locale}/upload/{id}) 표시
|
||||||
|
→ onValue(ref(fbRealtimeDb, handoffs/{id})) 구독 ─────────┐
|
||||||
|
휴대폰: QR 스캔 → /{locale}/upload/{id} │
|
||||||
|
→ 갤러리/카메라 선택 → 캔버스 축소(최대 1600px, JPEG) │
|
||||||
|
→ POST /api/handoff {handoffId, imageDataUrl} │
|
||||||
|
서버(Admin): 레이트리밋·검증 → set RTDB /handoffs/{id} │
|
||||||
|
↓ RTDB가 모든 인스턴스의 리스너에 push ────────────────────┘
|
||||||
|
데스크톱: onValue 발화 → onImageReceived(dataUrl)
|
||||||
|
→ DELETE /api/handoff?id (정리) → 기존 ImageCropper(4:3) → onFileSelect
|
||||||
|
```
|
||||||
|
|
||||||
|
**보안(비인증 엔드포인트)**
|
||||||
|
- 능력 토큰: 추측 불가능한 **128비트 handoffId**(`crypto.randomUUID()` → 32 hex)
|
||||||
|
- **IP별 레이트리밋**: RTDB 트랜잭션 고정 윈도우(`handoff_rate/{ip}`, 기본 1분 30회) — 멀티 인스턴스 공유
|
||||||
|
- **매직바이트 검증**: data URL 헤더가 아닌 실제 바이트 시그니처(JPEG `FF D8 FF`/PNG/WEBP)로 MIME 확인 + 디코딩 바이트 상한
|
||||||
|
- **RTDB 규칙**(`database.rules.json`): `handoffs/$id` 읽기 `auth != null` / 쓰기 `false`(이미지는 Admin만 기록), `handoff_rate`는 미지정(클라 기본 차단)
|
||||||
|
- 폰 작은 이미지 가드(긴 변 600px 미만 거부)
|
||||||
|
|
||||||
|
**정리**: `functions/cleanupHandoffs`(`onSchedule` 매시) — 버려진 `/handoffs`(15분 경과)·`/handoff_rate`(1시간 경과) 일괄 삭제. RTDB 네이티브 TTL 부재 대응(기존 `cleanupExpiredCommentCache` 패턴).
|
||||||
|
|
||||||
|
**관련 파일**
|
||||||
|
- `src/lib/handoff/{config,rateLimit}.ts` - 상수, IP 레이트리밋(RTDB 트랜잭션)
|
||||||
|
- `src/app/api/handoff/{route,store}.ts` - POST(업로드)/DELETE(정리), RTDB save/delete
|
||||||
|
- `src/components/writing/QrUploadDialog.tsx` - QR(Chakra `QrCode`) + `onValue` 리스너
|
||||||
|
- `src/app/[locale]/upload/[id]/page.tsx` - 폰 업로드 페이지(캔버스 축소 전송)
|
||||||
|
- `src/components/writing/ImageDropzone.tsx` - "휴대폰으로" 버튼 + 다이얼로그 연결(받은 사진은 크롭 단계로)
|
||||||
|
- `functions/src/cleanup/cleanupHandoffs.ts` - 스케줄 정리
|
||||||
|
- `database.rules.json` - handoffs 규칙 / i18n: `imageUpload.qr.*`·`uploadPage.*` (ko/en/ja)
|
||||||
|
|
||||||
|
**배포**: `firebase deploy --only database`(리스너 규칙 필수), `--only functions`(정리 스케줄)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
164
기획서.md
Normal file
164
기획서.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# 라온누리 기획서
|
||||||
|
|
||||||
|
> 초등 저학년이 **종이에 직접 그린 그림을 디지털로 가져와 마법처럼 가지고 놀고, 그것에 대해 글을 쓰는** B2B 글쓰기 액티비티 플랫폼
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 한 줄 소개
|
||||||
|
|
||||||
|
**라온누리는 초등학교 1~3학년 아이들이 "글쓰기를 놀이처럼" 경험하도록 만든 교육 플랫폼입니다.**
|
||||||
|
|
||||||
|
아이는 종이에 색연필로 그림을 그리고, 카메라로 찍어 태블릿에 가져옵니다. 그 그림을 손가락으로 만지고 흔들며 가지고 놀고, 자기 그림에 대해 짧은 글을 씁니다. 교사는 한 화면에서 우리 반 아이들의 활동을 실시간으로 지켜보고 도와줍니다.
|
||||||
|
|
||||||
|
학교·학원·교사가 도입하는 **B2B 제품**입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 왜 만드는가 — 우리가 푸는 문제
|
||||||
|
|
||||||
|
초등 저학년(만 7~9세)에게 글쓰기는 어렵습니다. 하지만 그 이유는 **"생각이 없어서"가 아닙니다.**
|
||||||
|
|
||||||
|
| 저학년 아이의 진짜 모습 | |
|
||||||
|
|------------------------|---|
|
||||||
|
| 상상력과 하고 싶은 말 | 넘쳐난다 |
|
||||||
|
| 손으로 그림 그리기 | 좋아하고 잘한다 |
|
||||||
|
| 말로 표현하기 | 잘한다 |
|
||||||
|
| 키보드 타이핑 | 거의 못 한다 (독수리 타법) |
|
||||||
|
| 맞춤법·받침 | 한창 배우는 중 (틀리는 게 정상) |
|
||||||
|
| 긴 글 구성 | 한두 문장이 한계 |
|
||||||
|
|
||||||
|
즉, 아이들의 병목은 **"무엇을 표현할지"가 아니라 "어떻게 글로 옮길지"** 라는 기계적인 부분입니다. 그런데 기존의 글쓰기 도구는 빈 종이(또는 빈 입력창)를 던져주고 "자, 써 봐"라고 합니다. 아이는 시작도 하기 전에 막막해지고, 글쓰기를 **"어렵고 재미없는 것"**으로 기억하게 됩니다.
|
||||||
|
|
||||||
|
### 우리가 직접 확인한 두 가지 사실 (사용자 테스트)
|
||||||
|
1. **아이들은 짧은 글조차 "처음부터" 쓰는 걸 어려워한다.** → 글쓰기를 *시작점*에 두면 안 된다. 글은 *입구*가 아니라 *결과*여야 한다.
|
||||||
|
2. **아이들은 자기가 직접 그린 그림과, 그걸 만지며 노는 것에 가장 몰입한다.** AI가 대신 그려준 그림에는 관심이 없다. → 몰입은 **"내가 만든 것"**에서 나온다.
|
||||||
|
|
||||||
|
**라온누리의 답:**
|
||||||
|
- 가장 쉽고 즐거운 **"종이에 그림 그리기"를 시작점**으로 삼아 막막함을 없애고,
|
||||||
|
- 그 그림을 **"내 것"으로 가지고 노는 즐거움**을 주고,
|
||||||
|
- 글쓰기는 *내 그림에 대해 이야기하는* 마지막 단계로 자연스럽게 유도합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 누구를 위한 제품인가
|
||||||
|
|
||||||
|
### 최종 사용자 — 초등 저학년 (1~3학년)
|
||||||
|
활동의 주인공입니다. 한글을 막 떼고, 타이핑이 서툴고, 그림 그리기를 좋아하고, 칭찬에 크게 반응하는 나이입니다. 그래서 UI는 **큰 글씨·큰 버튼·아이콘 중심**으로, 말투는 **따뜻하고 격려하는 톤**으로 설계합니다.
|
||||||
|
|
||||||
|
### 구매·운영자 — 학교 / 학원 / 교사 (B2B)
|
||||||
|
실제로 돈을 내고 도입하는 주체입니다. 교사는 우리 반을 만들고, 학생을 등록하고, 오늘의 주제를 정하고, 아이들의 활동을 실시간으로 지켜봅니다. 라온누리는 교사에게 **"우리 반 글쓰기 수업을 한 화면에서 운영하는 도구"**가 됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 핵심 컨셉 — 무엇이 다른가
|
||||||
|
|
||||||
|
핵심은 **"그림에서 글로"** 이어지는 흐름입니다. 글쓰기를 시작점이 아니라 도착점에 둡니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 그린다 (종이 + 색연필) → 가장 쉽고 즐거운 시작. "내 그림"이라 애착이 생긴다
|
||||||
|
2. 가져온다 (카메라로 촬영) → 내 그림이 태블릿 속으로 들어온다
|
||||||
|
3. 논다 (만지고 변형) → 내 그림을 가지고 노는 인터랙티브 액티비티
|
||||||
|
4. 쓴다 (말 또는 짧은 글) → 내 그림에 대해 이야기하다 보니 글이 된다
|
||||||
|
```
|
||||||
|
|
||||||
|
특히 **"내가 직접 그린 그림"**으로 논다는 점이 라온누리만의 차별점입니다. 아이는 남이(혹은 AI가) 만든 것이 아니라 *자기 작품*을 가지고 놀기 때문에 깊이 몰입하고, 그 애착이 글쓰기로 이어집니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 기능
|
||||||
|
|
||||||
|
### A. 그림으로 시작하기 (종이 → 카메라 → 태블릿)
|
||||||
|
- 아이는 **종이에 색연필로 자유롭게 그림**을 그립니다. (디지털 드로잉 학습 불필요, 가장 낮은 진입 장벽)
|
||||||
|
- 완성한 그림을 **카메라로 찍어** 태블릿으로 가져옵니다.
|
||||||
|
- 내 손그림이 디지털 세계로 들어와 살아 움직일 준비를 합니다.
|
||||||
|
|
||||||
|
### B. 인터랙션 액티비티 — 메인 차별점
|
||||||
|
- 가져온 **내 그림 전체를 손가락으로 만지고 흔들면 출렁이고 일그러지는** 이미지 왜곡 효과.
|
||||||
|
- **스프라이트와 글자를 활용한 파티클 효과**로 화면이 살아 움직입니다.
|
||||||
|
- "내가 그린 것"이 마법처럼 반응하는 경험 → 아이가 가장 몰입하는 핵심 활동.
|
||||||
|
- 글쓰기를 "끝내야 하는 숙제"가 아니라 **"하고 싶은 활동"**으로 만드는 장치.
|
||||||
|
|
||||||
|
> 현재 방식은 **이미지 전체 왜곡 + 스프라이트/글자 파티클 시스템**입니다. 그림 속 개별 객체를 분리해 따로 움직이게 하는 기능은 아직 계획에 없습니다.
|
||||||
|
|
||||||
|
### C. 글쓰기 — 마지막에, 자연스럽게
|
||||||
|
- 내 그림에 *대해* 이야기하면서 글이 시작됩니다. 빈 화면의 막막함이 없습니다.
|
||||||
|
- **키보드 입력이 기본**이며, 쓰기 어려운 아이는 화면의 **음성 버튼**을 눌러 말로 입력할 수 있습니다.
|
||||||
|
- **읽어주기(TTS)** 로 자기 글을 귀로 들으며 다듬습니다.
|
||||||
|
|
||||||
|
### D. 도우미 AI — "그려주는 화가"가 아니라 "물어봐 주는 친구"
|
||||||
|
- 내 그림을 보고 **알아보고 반응**합니다: "우와, 무지개를 그렸구나! 누가 살고 있어?"
|
||||||
|
- 아이의 말을 **글로 옮겨주고**, 변형·꾸미기를 **도와줍니다**.
|
||||||
|
- **점수를 매기지 않습니다.** 대신 **칭찬과 별**로 격려합니다: "멋진 표현이야!"
|
||||||
|
- 막힐 땐 **부드러운 한 가지 제안**만 합니다. 맞춤법은 **칭찬 먼저, 살짝만** 도와줍니다.
|
||||||
|
|
||||||
|
### E. 교사 도구 (B2B 핵심 가치)
|
||||||
|
- **우리 반 만들기**: 초대 링크 또는 학생 일괄 등록(로그인 카드 인쇄 지원).
|
||||||
|
- **실시간 모니터링**: 아이들의 활동을 한 화면에서 관찰하고, 막힌 아이를 바로 도와줍니다.
|
||||||
|
- **학년별 적응 설정**: 1/2/3학년에 맞춰 글 분량·AI 난이도·맞춤법 엄격도가 자동 조정됩니다.
|
||||||
|
- **주제 배정**과 **학급 진도·리포트**로 수업을 운영합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 사용자 여정
|
||||||
|
|
||||||
|
### 아이의 하루 (학생)
|
||||||
|
```
|
||||||
|
종이에 색연필로 그림 그리기 → 카메라로 찰칵 → 태블릿에서 내 그림 가지고 놀기
|
||||||
|
→ AI 친구가 "뭘 그렸어?" 하고 물어봄 → 말하거나 짧게 쓰기
|
||||||
|
→ 들어보고 다듬기 → "잘했어!" 칭찬과 별 받기
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수업 흐름 (교사)
|
||||||
|
```
|
||||||
|
우리 반 생성 → 학생 일괄 등록 → 학년·AI 난이도 설정
|
||||||
|
→ 오늘의 주제 배정 → 실시간으로 관찰하며 도움
|
||||||
|
→ 학급 리포트로 진행 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 차별점 정리
|
||||||
|
|
||||||
|
| 보통의 글쓰기 학습 도구 | 라온누리 |
|
||||||
|
|------------------------|----------|
|
||||||
|
| 빈 화면에 "써 봐" | 종이 그림으로 시작해 막막함 제거 |
|
||||||
|
| 글이 시작점 (어렵다) | 글은 도착점 (그림에서 자연스럽게) |
|
||||||
|
| AI/남이 만든 콘텐츠 | 내가 직접 그린 "내 그림"으로 논다 |
|
||||||
|
| 타이핑 필수 | 키보드 기본 + 음성 버튼 보조 |
|
||||||
|
| 점수·등급으로 평가 | 칭찬·별로 격려 |
|
||||||
|
| 교사는 결과만 확인 | 교사가 과정을 실시간으로 관찰·지원 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 사업 모델
|
||||||
|
|
||||||
|
- **B2B**: 학교·학원·교사 단위로 도입합니다.
|
||||||
|
- 요금제(반 규모·학교 단위 등)에 따라 계약하며, **관리자가 플랜을 부여**하는 방식입니다. (개별 학생의 셀프 결제는 없습니다.)
|
||||||
|
- 핵심 구매 명분은 **"교사가 저학년 글쓰기 수업을 쉽고 효과적으로 운영하는 도구"** 입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 향후 방향 (요약)
|
||||||
|
|
||||||
|
- 인터랙션 고도화 (예: 그림 속 객체 분리해 따로 움직이기 — 현재 미계획)
|
||||||
|
- 음성 입력 정확도 향상 (교실 환경 대응)
|
||||||
|
- 학급 단위 리포트·성장 기록 강화
|
||||||
|
- 콘텐츠(주제·그림 가이드) 라이브러리 확충
|
||||||
|
- (선택) 학교 단위 조직 관리, 가정 연계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 기술 개요 (내부용)
|
||||||
|
|
||||||
|
- **프론트엔드**: Next.js 16, React 19, Chakra UI v3, TypeScript
|
||||||
|
- **백엔드/인프라**: Firebase (Auth, Firestore, Functions)
|
||||||
|
- **핵심 입력**: 카메라 촬영을 통한 손그림 캡처 → 인터랙션 레이어
|
||||||
|
- **인터랙션 레이어**: 이미지 전체 왜곡(distortion) + 스프라이트·글자 기반 파티클 시스템 (그림 속 객체 분리는 미계획)
|
||||||
|
- **AI**: 손그림 반응 및 음성→글 변환에 Google Vertex AI(Gemini) 활용, 다중 리전 페일오버로 안정성 확보
|
||||||
|
- **다국어**: 한국어 기본 (필요 시 확장)
|
||||||
|
|
||||||
|
> 참고: 기존의 "글 입력 → AI 이미지 생성(텍스트→그림)" 방향은 사용자 테스트 결과 폐기되었습니다. 아이들은 AI가 그려준 그림에 관심이 없었고, 글을 먼저 쓰는 것을 어려워했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2024 BlueNovaLab. All rights reserved.
|
||||||
Loading…
x
Reference in New Issue
Block a user