Skip to content

Commit 261e958

Browse files
jammy0903claude
andcommitted
feat: add ErrorBoundary, 404 page, Lesson Only badges, and API error toasts
- Add React ErrorBoundary component wrapping RootLayout for graceful crash recovery - Add 404 NotFoundPage with catch-all route for unknown paths - Add "Lesson Only" badge on ChapterCard for chapters without Playground support - Wire axios response interceptor to handleAPIError for automatic error toasts - Update Ralph prompt and fix_plan for English translation automation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c9e38a commit 261e958

8 files changed

Lines changed: 287 additions & 246 deletions

File tree

.ralph/PROMPT.md

Lines changed: 120 additions & 223 deletions
Large diffs are not rendered by default.

.ralph/fix_plan.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,29 +39,29 @@ Remaining: 233
3939
- [x] js-4-4.en.json
4040

4141
### Chapter 5: Memory & Immutability (js-5)
42-
- [ ] js-5-1.en.json
43-
- [ ] js-5-2.en.json
44-
- [ ] js-5-3.en.json
42+
- [x] js-5-1.en.json
43+
- [x] js-5-2.en.json
44+
- [x] js-5-3.en.json
4545

4646
### Chapter 6: Prototypes & Classes (js-6)
47-
- [ ] js-6-1.en.json
48-
- [ ] js-6-2.en.json
49-
- [ ] js-6-3.en.json
47+
- [x] js-6-1.en.json
48+
- [x] js-6-2.en.json
49+
- [x] js-6-3.en.json
5050

5151
### Chapter 7: V8 Internals (js-7)
52-
- [ ] js-7-1.en.json
53-
- [ ] js-7-2.en.json
54-
- [ ] js-7-3.en.json
52+
- [x] js-7-1.en.json
53+
- [x] js-7-2.en.json
54+
- [x] js-7-3.en.json
5555

5656
### Chapter 8: Rendering Performance (js-8)
57-
- [ ] js-8-1.en.json
58-
- [ ] js-8-2.en.json
59-
- [ ] js-8-3.en.json
57+
- [x] js-8-1.en.json
58+
- [x] js-8-2.en.json
59+
- [x] js-8-3.en.json
6060

6161
### Chapter 9: Metaprogramming (js-9)
62-
- [ ] js-9-1.en.json
63-
- [ ] js-9-2.en.json
64-
- [ ] js-9-3.en.json
62+
- [x] js-9-1.en.json
63+
- [x] js-9-2.en.json
64+
- [x] js-9-3.en.json
6565

6666
---
6767

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Component } from 'react';
2+
import type { ReactNode, ErrorInfo } from 'react';
3+
import { logger } from '@/utils/logger';
4+
5+
interface Props {
6+
children: ReactNode;
7+
}
8+
9+
interface State {
10+
hasError: boolean;
11+
error: Error | null;
12+
}
13+
14+
export class ErrorBoundary extends Component<Props, State> {
15+
constructor(props: Props) {
16+
super(props);
17+
this.state = { hasError: false, error: null };
18+
}
19+
20+
static getDerivedStateFromError(error: Error): State {
21+
return { hasError: true, error };
22+
}
23+
24+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
25+
logger.error('ErrorBoundary caught:', error, errorInfo);
26+
}
27+
28+
handleReset = () => {
29+
this.setState({ hasError: false, error: null });
30+
};
31+
32+
handleGoHome = () => {
33+
window.location.href = '/';
34+
};
35+
36+
render() {
37+
if (this.state.hasError) {
38+
return (
39+
<div className="min-h-screen flex items-center justify-center bg-[var(--theme-layout-bg,#f9fafb)] px-4">
40+
<div className="text-center max-w-md">
41+
<div className="text-6xl mb-6">
42+
<span role="img" aria-label="warning">&#x26A0;&#xFE0F;</span>
43+
</div>
44+
<h1 className="text-2xl font-bold text-[var(--theme-dashboard-title,#111)] mb-3">
45+
Something went wrong
46+
</h1>
47+
<p className="text-[var(--theme-dashboard-text-muted,#666)] mb-6">
48+
An unexpected error occurred. Please try again.
49+
</p>
50+
{import.meta.env.DEV && this.state.error && (
51+
<pre className="text-left text-xs bg-red-50 text-red-700 p-3 rounded-lg mb-6 overflow-auto max-h-32 border border-red-200">
52+
{this.state.error.message}
53+
</pre>
54+
)}
55+
<div className="flex gap-3 justify-center">
56+
<button
57+
onClick={this.handleReset}
58+
className="px-5 py-2.5 rounded-lg bg-[var(--theme-dashboard-accent,#3b82f6)] text-white font-medium hover:opacity-90 transition-opacity"
59+
>
60+
Try Again
61+
</button>
62+
<button
63+
onClick={this.handleGoHome}
64+
className="px-5 py-2.5 rounded-lg border border-[var(--theme-dashboard-card-border,#e5e7eb)] text-[var(--theme-dashboard-text-muted,#666)] font-medium hover:bg-[var(--theme-dashboard-section-header-bg,#f3f4f6)] transition-colors"
65+
>
66+
Go Home
67+
</button>
68+
</div>
69+
</div>
70+
</div>
71+
);
72+
}
73+
74+
return this.props.children;
75+
}
76+
}

packages/frontend/src/features/courses/components/ChapterCard.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@
66

77
import { useNavigate } from 'react-router-dom';
88
import type { Chapter } from '@/types';
9-
import { Star, Target, Lock, ChevronRight, BookOpen } from 'lucide-react';
9+
import { Star, Target, Lock, ChevronRight, BookOpen, BookMarked } from 'lucide-react';
10+
11+
/**
12+
* Chapters where Playground simulator cannot fully support the topic.
13+
* These chapters work perfectly in Lesson mode (pre-scripted JSON),
14+
* but user code in Playground will produce incomplete/incorrect visualization.
15+
*/
16+
const LESSON_ONLY_CHAPTERS: Record<string, Set<string>> = {
17+
javascript: new Set(['js-7', 'js-8', 'js-9']),
18+
python: new Set(['py-8', 'py-9', 'py-10']),
19+
java: new Set(['java-7', 'java-8', 'java-9', 'java-10']),
20+
};
1021

1122
// 언어별 색상 테마 (밝은 파스텔 톤)
1223
const LANGUAGE_THEMES: Record<string, {
@@ -89,6 +100,7 @@ export function ChapterCard({
89100
}: ChapterCardProps) {
90101
const navigate = useNavigate();
91102
const theme = LANGUAGE_THEMES[languageId] || DEFAULT_THEME;
103+
const isLessonOnly = LESSON_ONLY_CHAPTERS[languageId]?.has(chapter.id) ?? false;
92104

93105
const progressPercent = lessonCount > 0 ? Math.round((completedCount / lessonCount) * 100) : 0;
94106
const isComplete = completedCount === lessonCount && lessonCount > 0;
@@ -199,6 +211,12 @@ export function ChapterCard({
199211
Login
200212
</span>
201213
)}
214+
{isLessonOnly && (
215+
<span className="inline-flex items-center gap-1 px-1.5 md:px-2 py-0.5 rounded text-[9px] md:text-[10px] font-bold uppercase tracking-wider bg-amber-50 text-amber-600 border border-amber-200">
216+
<BookMarked className="w-2.5 h-2.5 md:w-3 md:h-3" />
217+
Lesson Only
218+
</span>
219+
)}
202220
</div>
203221

204222
{/* 챕터 제목 */}

packages/frontend/src/layouts/RootLayout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
22
import { useEffect } from 'react';
33
import { Outlet } from 'react-router-dom';
44
import { MainLayout } from './MainLayout';
5+
import { ErrorBoundary } from '@/components/ErrorBoundary';
56
import { initializeAuthListener } from '@/services/firebase';
67
import { useTheme } from '@/hooks/useTheme';
78

@@ -19,10 +20,12 @@ function AuthProvider({ children }: { children: ReactNode }) {
1920

2021
export function RootLayout() {
2122
return (
22-
<AuthProvider>
23-
<MainLayout>
24-
<Outlet />
25-
</MainLayout>
26-
</AuthProvider>
23+
<ErrorBoundary>
24+
<AuthProvider>
25+
<MainLayout>
26+
<Outlet />
27+
</MainLayout>
28+
</AuthProvider>
29+
</ErrorBoundary>
2730
);
2831
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useNavigate } from 'react-router-dom';
2+
import { useTranslation } from 'react-i18next';
3+
import { Home, ArrowLeft } from 'lucide-react';
4+
5+
export function NotFoundPage() {
6+
const navigate = useNavigate();
7+
const { t } = useTranslation();
8+
9+
return (
10+
<div className="min-h-[60vh] flex items-center justify-center px-4">
11+
<div className="text-center max-w-md">
12+
<div className="text-8xl font-bold text-[var(--theme-dashboard-accent,#3b82f6)] opacity-20 mb-4">
13+
404
14+
</div>
15+
<h1 className="text-2xl font-bold text-[var(--theme-dashboard-title,#111)] mb-3">
16+
{t('errors.not_found')}
17+
</h1>
18+
<p className="text-[var(--theme-dashboard-text-muted,#666)] mb-8">
19+
The page you're looking for doesn't exist or has been moved.
20+
</p>
21+
<div className="flex gap-3 justify-center">
22+
<button
23+
onClick={() => navigate(-1)}
24+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border border-[var(--theme-dashboard-card-border,#e5e7eb)] text-[var(--theme-dashboard-text-muted,#666)] font-medium hover:bg-[var(--theme-dashboard-section-header-bg,#f3f4f6)] transition-colors"
25+
>
26+
<ArrowLeft className="w-4 h-4" />
27+
Go Back
28+
</button>
29+
<button
30+
onClick={() => navigate('/')}
31+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-[var(--theme-dashboard-accent,#3b82f6)] text-white font-medium hover:opacity-90 transition-opacity"
32+
>
33+
<Home className="w-4 h-4" />
34+
Home
35+
</button>
36+
</div>
37+
</div>
38+
</div>
39+
);
40+
}

packages/frontend/src/router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { DashboardPage } from './features/dashboard';
1616
import { ReportPage } from './features/report';
1717
import { ProtectedRoute } from './components/ProtectedRoute';
1818
import { CoursesPage } from './features/courses/CoursesPage';
19+
import { NotFoundPage } from './pages/NotFoundPage';
1920

2021
/**
2122
* 라우터 설정
@@ -77,6 +78,7 @@ export const router = createBrowserRouter([
7778
return { Component: TermsPage };
7879
}
7980
},
81+
{ path: '*', element: <NotFoundPage /> },
8082
],
8183
},
8284
]);

packages/frontend/src/services/api/axios.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import axios from 'axios';
1313
import { config } from '../../config';
1414
import { logger } from '@/utils/logger';
1515
import { getAuthToken } from './tokenManager';
16+
import { handleAPIError } from '@/components/common/Toast/notifications';
1617

1718
// API 기본 URL (버전 포함)
1819
const BASE_URL = config.api.baseUrl;
@@ -42,11 +43,15 @@ api.interceptors.request.use(
4243
}
4344
);
4445

45-
// Response Interceptor: 에러 처리는 errors.ts에서
46+
// Response Interceptor: 에러 시 toast 표시 + errors.ts로 위임
4647
api.interceptors.response.use(
4748
(response) => response,
4849
(error) => {
49-
// errors.ts의 handleError로 위임
50+
if (axios.isAxiosError(error)) {
51+
const status = error.response?.status ?? 0;
52+
const message = error.response?.data?.message;
53+
handleAPIError(status, message);
54+
}
5055
return Promise.reject(error);
5156
}
5257
);

0 commit comments

Comments
 (0)