Skip to content

Commit 06dda19

Browse files
feat: implement error boundaries, bookmarks, version control, and instructor analytics dashboard (#643)
Implements four assigned features: - Error Boundary Handling (#602): React ErrorBoundary class component with retry/go-back/home fallback UI and inline error fallback variants - Bookmarking Functionality (#595): BookmarkButton component, dedicated bookmarks page with search/filter/tags, tagging support in userStore - Version Control for Learning Materials (#596): Git-like versioning engine with localStorage persistence, VersionHistory component with diff comparison and rollback - Analytics Dashboard for Instructor View (#598): Instructor analytics page showing student progress, engagement, course performance, and learning outcomes Closes #595, Closes #596, Closes #598, Closes #602 Co-authored-by: Ebuka321 <corneliusemmanuel55@gmail.com>
1 parent 29e37f2 commit 06dda19

11 files changed

Lines changed: 1678 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
'use client';
2+
3+
import { useMemo, useState } from 'react';
4+
import { BookmarkCheck, ExternalLink, Search, Tag, Trash2, X } from 'lucide-react';
5+
import Link from 'next/link';
6+
import { useUserStore } from '@/stores/userStore';
7+
import { formatDistanceToNow } from '@/lib/utils';
8+
9+
const BOOKMARK_TYPES = ['lesson', 'exercise', 'resource'] as const;
10+
11+
export default function BookmarksPage() {
12+
const bookmarks = useUserStore((state) => state.learningPath.bookmarks);
13+
const removeBookmark = useUserStore((state) => state.removeBookmark);
14+
const [searchQuery, setSearchQuery] = useState('');
15+
const [selectedType, setSelectedType] = useState<string | null>(null);
16+
const [selectedTag, setSelectedTag] = useState<string | null>(null);
17+
18+
const allTags = useMemo(() => {
19+
const tagSet = new Set<string>();
20+
bookmarks.forEach((b) => b.tags?.forEach((t) => tagSet.add(t)));
21+
return Array.from(tagSet).sort();
22+
}, [bookmarks]);
23+
24+
const filteredBookmarks = useMemo(() => {
25+
return bookmarks.filter((b) => {
26+
if (searchQuery && !b.title.toLowerCase().includes(searchQuery.toLowerCase())) {
27+
return false;
28+
}
29+
if (selectedType && b.type !== selectedType) {
30+
return false;
31+
}
32+
if (selectedTag && !b.tags?.includes(selectedTag)) {
33+
return false;
34+
}
35+
return true;
36+
});
37+
}, [bookmarks, searchQuery, selectedType, selectedTag]);
38+
39+
return (
40+
<div className="mx-auto max-w-7xl px-4 pb-20 pt-12 sm:px-6 lg:px-8">
41+
<div className="mb-10">
42+
<span className="eyebrow">Learning tools</span>
43+
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-[var(--text-strong)] sm:text-5xl">
44+
Bookmarks
45+
</h1>
46+
<p className="mt-3 max-w-2xl text-base leading-8 text-[var(--muted)]">
47+
Save and organize your favorite learning resources, code examples, and documentation pages.
48+
</p>
49+
</div>
50+
51+
<div className="mb-8 space-y-4">
52+
<div className="relative">
53+
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
54+
<input
55+
type="text"
56+
value={searchQuery}
57+
onChange={(e) => setSearchQuery(e.target.value)}
58+
placeholder="Search bookmarks..."
59+
className="w-full rounded-2xl border border-white/8 bg-white/4 py-3 pl-11 pr-4 text-sm text-[var(--text-strong)] outline-none placeholder:text-[var(--muted)] focus:border-white/15"
60+
/>
61+
</div>
62+
63+
<div className="flex flex-wrap items-center gap-2">
64+
<button
65+
onClick={() => setSelectedType(null)}
66+
className={`rounded-xl border px-3.5 py-1.5 text-xs font-medium transition-colors ${
67+
!selectedType
68+
? 'border-[var(--brand-strong)]/40 bg-[var(--brand-strong)]/10 text-[var(--brand-strong)]'
69+
: 'border-white/8 bg-white/4 text-[var(--muted)] hover:text-[var(--text-strong)]'
70+
}`}
71+
>
72+
All
73+
</button>
74+
{BOOKMARK_TYPES.map((type) => (
75+
<button
76+
key={type}
77+
onClick={() => setSelectedType(selectedType === type ? null : type)}
78+
className={`rounded-xl border px-3.5 py-1.5 text-xs font-medium capitalize transition-colors ${
79+
selectedType === type
80+
? 'border-[var(--brand-strong)]/40 bg-[var(--brand-strong)]/10 text-[var(--brand-strong)]'
81+
: 'border-white/8 bg-white/4 text-[var(--muted)] hover:text-[var(--text-strong)]'
82+
}`}
83+
>
84+
{type}s
85+
</button>
86+
))}
87+
</div>
88+
89+
{allTags.length > 0 && (
90+
<div className="flex flex-wrap items-center gap-2">
91+
<Tag className="h-3.5 w-3.5 text-[var(--muted)]" />
92+
{allTags.map((tag) => (
93+
<button
94+
key={tag}
95+
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
96+
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
97+
selectedTag === tag
98+
? 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400'
99+
: 'border-white/8 bg-white/4 text-[var(--muted)] hover:text-[var(--text-strong)]'
100+
}`}
101+
>
102+
{tag}
103+
{selectedTag === tag && <X className="h-3 w-3" />}
104+
</button>
105+
))}
106+
</div>
107+
)}
108+
</div>
109+
110+
{bookmarks.length === 0 && (
111+
<div className="surface-card flex flex-col items-center rounded-2xl p-12 text-center">
112+
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-yellow-500/10">
113+
<BookmarkCheck className="h-7 w-7 text-yellow-400" />
114+
</div>
115+
<h2 className="text-xl font-semibold text-[var(--text-strong)]">No bookmarks yet</h2>
116+
<p className="mt-2 max-w-md text-sm leading-7 text-[var(--muted)]">
117+
Start bookmarking lessons, exercises, and resources as you learn. Use the bookmark button
118+
on any page to save it here.
119+
</p>
120+
<Link
121+
href="/courses"
122+
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-5 py-2.5 text-sm font-medium text-[var(--text-strong)] transition-colors hover:bg-white/10"
123+
>
124+
Browse courses
125+
</Link>
126+
</div>
127+
)}
128+
129+
{bookmarks.length > 0 && filteredBookmarks.length === 0 && (
130+
<div className="surface-card flex flex-col items-center rounded-2xl p-12 text-center">
131+
<Search className="mb-4 h-8 w-8 text-[var(--muted)]" />
132+
<h2 className="text-xl font-semibold text-[var(--text-strong)]">No matching bookmarks</h2>
133+
<p className="mt-2 text-sm text-[var(--muted)]">
134+
Try adjusting your search or filters.
135+
</p>
136+
</div>
137+
)}
138+
139+
{filteredBookmarks.length > 0 && (
140+
<div className="grid gap-4">
141+
{filteredBookmarks.map((bookmark) => (
142+
<div
143+
key={bookmark.id}
144+
className="surface-card flex items-start justify-between gap-4 rounded-2xl border border-white/8 bg-white/4 p-5 transition-colors hover:border-white/15"
145+
>
146+
<div className="min-w-0 flex-1">
147+
<div className="flex items-center gap-3">
148+
<span
149+
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase ${
150+
bookmark.type === 'lesson'
151+
? 'bg-blue-500/10 text-blue-400'
152+
: bookmark.type === 'exercise'
153+
? 'bg-green-500/10 text-green-400'
154+
: 'bg-purple-500/10 text-purple-400'
155+
}`}
156+
>
157+
{bookmark.type}
158+
</span>
159+
<span className="text-xs text-[var(--muted)]">
160+
{formatDistanceToNow(bookmark.addedAt)}
161+
</span>
162+
</div>
163+
164+
<h3 className="mt-2 text-lg font-semibold text-[var(--text-strong)]">
165+
{bookmark.title}
166+
</h3>
167+
168+
{bookmark.tags && bookmark.tags.length > 0 && (
169+
<div className="mt-2 flex flex-wrap gap-1.5">
170+
{bookmark.tags.map((tag) => (
171+
<span
172+
key={tag}
173+
className="inline-flex items-center gap-1 rounded-full border border-white/8 bg-white/4 px-2 py-0.5 text-[10px] text-[var(--muted)]"
174+
>
175+
<Tag className="h-2.5 w-2.5" />
176+
{tag}
177+
</span>
178+
))}
179+
</div>
180+
)}
181+
182+
<a
183+
href={bookmark.url}
184+
target="_blank"
185+
rel="noopener noreferrer"
186+
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-[var(--brand-strong)] hover:underline"
187+
>
188+
Open resource
189+
<ExternalLink className="h-3 w-3" />
190+
</a>
191+
</div>
192+
193+
<button
194+
onClick={() => removeBookmark(bookmark.id)}
195+
className="shrink-0 rounded-xl border border-white/8 bg-white/4 p-2.5 text-[var(--muted)] transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
196+
aria-label={`Remove bookmark: ${bookmark.title}`}
197+
>
198+
<Trash2 className="h-4 w-4" />
199+
</button>
200+
</div>
201+
))}
202+
</div>
203+
)}
204+
</div>
205+
);
206+
}

0 commit comments

Comments
 (0)