Skip to content

Commit f70ff09

Browse files
authored
Add external problem mentor workspace with guarded access (#11)
* feat: add paste-in external problem feedback card * feat: move external feedback to dedicated page * refactor: move external feedback strings into i18n * feat: guard external feedback page with mentor setup modal * refactor: move external feedback CTA to sidebar section * feat: block external feedback navigation when mentor is unconfigured * feat: add chat workspace and code editor to external feedback * refactor: align external feedback UI with problem solving layout * refactor: switch external feedback to full-screen problem layout * refactor: keep global nav and pin mentor chat as right panel * refactor: rename section and match mac-style editor with fixed chat width
1 parent 47d787d commit f70ff09

7 files changed

Lines changed: 398 additions & 6 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use client"
2+
3+
import { ExternalProblemFeedback } from "@/components/external-problem-feedback"
4+
import { withMentorAccessGuard } from "@/components/with-mentor-access-guard"
5+
import { PageTransition } from "@/components/page-transition"
6+
import { usePageEntryAnimation } from "@/lib/use-page-entry-animation"
7+
8+
function ExternalFeedbackPage() {
9+
const shouldAnimateOnMount = usePageEntryAnimation()
10+
11+
return (
12+
<PageTransition animateOnMount={shouldAnimateOnMount}>
13+
<ExternalProblemFeedback />
14+
</PageTransition>
15+
)
16+
}
17+
18+
export default withMentorAccessGuard(ExternalFeedbackPage, {
19+
fallbackPath: "/",
20+
})

app/(main)/page.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
"use client"
22

33
import { useEffect, useMemo, useState } from "react"
4+
import { useRouter } from "next/navigation"
45
import { ChevronDown } from "lucide-react"
56
import { HeroSection } from "@/components/hero-section"
67
import { VirtualizedProblemList } from "@/components/virtualized-problem-list"
78
import { StreakWidget } from "@/components/streak-widget"
89
import { StatsWidget } from "@/components/stats-widget"
10+
import {
11+
AlertDialog,
12+
AlertDialogAction,
13+
AlertDialogCancel,
14+
AlertDialogContent,
15+
AlertDialogDescription,
16+
AlertDialogFooter,
17+
AlertDialogHeader,
18+
AlertDialogTitle,
19+
} from "@/components/ui/alert-dialog"
920
import { PageTransition } from "@/components/page-transition"
1021
import { problems } from "@/lib/problems"
1122
import { localizeCategory } from "@/lib/problems"
1223
import type { Difficulty, Problem } from "@/lib/problems"
1324
import {
25+
getApiSettings,
1426
getSolvedProblemIds,
1527
subscribeToProgressUpdates,
1628
} from "@/lib/local-progress"
29+
import { isMentorConfigured as resolveMentorConfigured } from "@/lib/mentor-access"
1730
import { useAppLanguage } from "@/lib/use-app-language"
1831
import { usePageEntryAnimation } from "@/lib/use-page-entry-animation"
1932
import { useRestoreScroll } from "@/lib/use-restore-scroll"
@@ -49,6 +62,7 @@ function parseHomeViewState(value: unknown): HomeViewState | null {
4962
}
5063

5164
export default function HomePage() {
65+
const router = useRouter()
5266
const { language, copy } = useAppLanguage()
5367
const shouldAnimateOnMount = usePageEntryAnimation()
5468
const [viewState, setViewState] = useRestoreScroll<HomeViewState>({
@@ -62,13 +76,23 @@ export default function HomePage() {
6276
})
6377
const { selectedCategory, selectedDifficulty, sortBy } = viewState
6478
const [solvedIds, setSolvedIds] = useState<Set<string>>(new Set())
79+
const [isMentorReady, setIsMentorReady] = useState(false)
80+
const [isMentorAlertOpen, setIsMentorAlertOpen] = useState(false)
6581

6682
useEffect(() => {
6783
const sync = () => setSolvedIds(getSolvedProblemIds())
6884
sync()
6985
return subscribeToProgressUpdates(sync)
7086
}, [])
7187

88+
useEffect(() => {
89+
const sync = () => {
90+
setIsMentorReady(resolveMentorConfigured(getApiSettings()))
91+
}
92+
sync()
93+
return subscribeToProgressUpdates(sync)
94+
}, [])
95+
7296
const categories = useMemo(() => {
7397
const map = new Map<string, number>()
7498
for (const problem of problems) {
@@ -87,7 +111,6 @@ export default function HomePage() {
87111
language === "ko" ? "난이도 오름차순" : "Difficulty Ascending"
88112
const sortDifficultyDescLabel =
89113
language === "ko" ? "난이도 내림차순" : "Difficulty Descending"
90-
91114
const difficultyRank: Record<Difficulty, number> = {
92115
Easy: 0,
93116
Medium: 1,
@@ -123,6 +146,14 @@ export default function HomePage() {
123146
return source.filter((problem) => problem.difficulty === difficulty).length
124147
}
125148

149+
const handleExternalFeedbackOpen = () => {
150+
if (!isMentorReady) {
151+
setIsMentorAlertOpen(true)
152+
return
153+
}
154+
router.push("/external-feedback")
155+
}
156+
126157
return (
127158
<PageTransition animateOnMount={shouldAnimateOnMount}>
128159
<HeroSection />
@@ -204,10 +235,37 @@ export default function HomePage() {
204235
<div className="sticky top-24 flex flex-col gap-4">
205236
<StreakWidget />
206237
<StatsWidget />
238+
<section className="rounded-2xl border border-border/70 bg-card p-4 shadow-sm">
239+
<p className="text-sm font-semibold text-foreground">{copy.home.externalFeedbackTitle}</p>
240+
<p className="mt-1 text-xs text-muted-foreground">{copy.home.externalFeedbackDescription}</p>
241+
<button
242+
type="button"
243+
onClick={handleExternalFeedbackOpen}
244+
className="mt-3 inline-flex w-full items-center justify-center rounded-full bg-[#3182F6] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#2870d8]"
245+
>
246+
{copy.home.externalFeedbackAction}
247+
</button>
248+
</section>
207249
</div>
208250
</aside>
209251
</div>
210252
</main>
253+
<AlertDialog open={isMentorAlertOpen} onOpenChange={setIsMentorAlertOpen}>
254+
<AlertDialogContent overlayClassName="bg-black/45">
255+
<AlertDialogHeader>
256+
<AlertDialogTitle>{copy.problem.mentorSetupTitle}</AlertDialogTitle>
257+
<AlertDialogDescription>
258+
{copy.problem.mentorSetupDescription}
259+
</AlertDialogDescription>
260+
</AlertDialogHeader>
261+
<AlertDialogFooter>
262+
<AlertDialogCancel>{copy.problem.mentorSetupCancel}</AlertDialogCancel>
263+
<AlertDialogAction onClick={() => router.push("/settings")}>
264+
{copy.problem.mentorSetupGoToSettings}
265+
</AlertDialogAction>
266+
</AlertDialogFooter>
267+
</AlertDialogContent>
268+
</AlertDialog>
211269
</PageTransition>
212270
)
213271
}

app/problem/[id]/problem-page-client.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
} from "@/lib/local-progress";
3838
import { useAppLanguage } from "@/lib/use-app-language";
3939
import { useIsMobile } from "@/components/ui/use-mobile";
40+
import { isMentorConfigured as resolveMentorConfigured } from "@/lib/mentor-access";
4041

4142
interface ProblemPageClientProps {
4243
problem: Problem;
@@ -90,11 +91,7 @@ export function ProblemPageClient({ problem }: ProblemPageClientProps) {
9091

9192
useEffect(() => {
9293
const sync = () => {
93-
const settings = getApiSettings();
94-
const provider = settings.provider;
95-
const hasModel = Boolean(settings.models[provider]?.trim());
96-
const hasApiKey = Boolean(settings.apiKeys[provider]?.trim());
97-
setIsMentorConfigured(hasModel && hasApiKey);
94+
setIsMentorConfigured(resolveMentorConfigured(getApiSettings()));
9895
};
9996
sync();
10097
return subscribeToProgressUpdates(sync);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import Editor from "@monaco-editor/react"
5+
import { RotateCcw } from "lucide-react"
6+
import {
7+
ResizableHandle,
8+
ResizablePanel,
9+
ResizablePanelGroup,
10+
} from "@/components/ui/resizable"
11+
import { ScrollArea } from "@/components/ui/scroll-area"
12+
import { Input } from "@/components/ui/input"
13+
import { Textarea } from "@/components/ui/textarea"
14+
import { useIsMobile } from "@/components/ui/use-mobile"
15+
import { CodeAssistantChat } from "@/components/code-assistant-chat"
16+
import { useAppLanguage } from "@/lib/use-app-language"
17+
18+
export function ExternalProblemFeedback() {
19+
const { copy } = useAppLanguage()
20+
const isMobile = useIsMobile()
21+
const text = copy.externalFeedback
22+
const [problemTitle, setProblemTitle] = useState("")
23+
const [problemText, setProblemText] = useState("")
24+
const [code, setCode] = useState("")
25+
26+
const normalizedProblemDescription =
27+
problemText.trim() || text.chatMissingProblemContext
28+
const normalizedProblemTitle = problemTitle.trim() || text.defaultProblemTitle
29+
const handleReset = () => {
30+
setCode("")
31+
}
32+
33+
return (
34+
<div className="h-[calc(100dvh-64px)] overflow-hidden bg-background">
35+
<div className={`flex h-full ${isMobile ? "flex-col" : "flex-row"}`}>
36+
<div className={`min-w-0 ${isMobile ? "h-[60dvh]" : "flex-1"}`}>
37+
<ResizablePanelGroup direction={isMobile ? "vertical" : "horizontal"}>
38+
<ResizablePanel defaultSize={isMobile ? 45 : 48} minSize={isMobile ? 24 : 24}>
39+
<ScrollArea className="h-full border-r border-border/70">
40+
<div className="space-y-4 p-6">
41+
<div>
42+
<h1 className="text-2xl font-bold text-foreground">{text.title}</h1>
43+
<p className="mt-2 text-sm text-muted-foreground">{text.description}</p>
44+
</div>
45+
<div>
46+
<label className="mb-1.5 block text-xs font-semibold text-muted-foreground">
47+
{text.titleLabel}
48+
</label>
49+
<Input
50+
value={problemTitle}
51+
onChange={(event) => setProblemTitle(event.target.value)}
52+
placeholder={text.titlePlaceholder}
53+
/>
54+
</div>
55+
<div>
56+
<label className="mb-1.5 block text-xs font-semibold text-muted-foreground">
57+
{text.problemLabel}
58+
</label>
59+
<Textarea
60+
value={problemText}
61+
onChange={(event) => setProblemText(event.target.value)}
62+
placeholder={text.problemPlaceholder}
63+
className="min-h-[420px] resize-none"
64+
/>
65+
</div>
66+
</div>
67+
</ScrollArea>
68+
</ResizablePanel>
69+
70+
<ResizableHandle withHandle={!isMobile} />
71+
72+
<ResizablePanel defaultSize={isMobile ? 55 : 52} minSize={isMobile ? 24 : 26}>
73+
<div className="relative flex h-full max-h-full flex-col overflow-hidden bg-background">
74+
<div className="flex-shrink-0 flex items-center justify-between border-b border-border/60 px-4 py-3">
75+
<div className="flex items-center gap-2">
76+
<div className="h-3 w-3 rounded-full bg-destructive/60" />
77+
<div className="h-3 w-3 rounded-full bg-warning/60" />
78+
<div className="h-3 w-3 rounded-full bg-success/60" />
79+
<span className="ml-2 text-xs font-medium text-muted-foreground">
80+
solution.js
81+
</span>
82+
</div>
83+
<button
84+
onClick={handleReset}
85+
className="flex items-center gap-1.5 rounded-[16px] px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
86+
>
87+
<RotateCcw className="h-3 w-3" />
88+
Reset
89+
</button>
90+
</div>
91+
<div className="min-h-0 flex-1">
92+
<Editor
93+
height="100%"
94+
defaultLanguage="javascript"
95+
value={code}
96+
onChange={(value) => setCode(value ?? "")}
97+
options={{
98+
minimap: { enabled: false },
99+
fontSize: 14,
100+
lineNumbers: "on",
101+
scrollBeyondLastLine: false,
102+
wordWrap: "on",
103+
automaticLayout: true,
104+
}}
105+
/>
106+
</div>
107+
</div>
108+
</ResizablePanel>
109+
</ResizablePanelGroup>
110+
</div>
111+
112+
<aside className={`border-l border-border/70 ${isMobile ? "h-[40dvh] w-full" : "w-[420px] flex-shrink-0"}`}>
113+
<div className="flex h-full flex-col">
114+
<div className="border-b border-border/70 px-4 py-3">
115+
<p className="text-sm font-semibold text-foreground">{text.chatTitle}</p>
116+
<p className="mt-1 text-xs text-muted-foreground">{text.chatDescription}</p>
117+
</div>
118+
{!problemText.trim() ? (
119+
<div className="border-b border-border/70 bg-amber-50/70 px-4 py-2 text-xs font-medium text-amber-700">
120+
{text.chatMissingProblem}
121+
</div>
122+
) : null}
123+
<div className="min-h-0 flex-1">
124+
<CodeAssistantChat
125+
code={code}
126+
problemTitle={normalizedProblemTitle}
127+
problemDescription={normalizedProblemDescription}
128+
/>
129+
</div>
130+
</div>
131+
</aside>
132+
</div>
133+
</div>
134+
)
135+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"use client"
2+
3+
import { useEffect, useState, type ComponentType } from "react"
4+
import { useRouter } from "next/navigation"
5+
import {
6+
AlertDialog,
7+
AlertDialogAction,
8+
AlertDialogCancel,
9+
AlertDialogContent,
10+
AlertDialogDescription,
11+
AlertDialogFooter,
12+
AlertDialogHeader,
13+
AlertDialogTitle,
14+
} from "@/components/ui/alert-dialog"
15+
import { getApiSettings, subscribeToProgressUpdates } from "@/lib/local-progress"
16+
import { isMentorConfigured } from "@/lib/mentor-access"
17+
import { useAppLanguage } from "@/lib/use-app-language"
18+
19+
interface MentorAccessGuardOptions {
20+
fallbackPath?: string
21+
}
22+
23+
export function withMentorAccessGuard<P extends object>(
24+
WrappedComponent: ComponentType<P>,
25+
options?: MentorAccessGuardOptions
26+
) {
27+
const fallbackPath = options?.fallbackPath ?? "/"
28+
29+
const GuardedComponent = (props: P) => {
30+
const router = useRouter()
31+
const { copy } = useAppLanguage()
32+
const [ready, setReady] = useState(false)
33+
const [allowed, setAllowed] = useState(false)
34+
35+
useEffect(() => {
36+
const sync = () => {
37+
setAllowed(isMentorConfigured(getApiSettings()))
38+
setReady(true)
39+
}
40+
41+
sync()
42+
return subscribeToProgressUpdates(sync)
43+
}, [])
44+
45+
if (ready && allowed) {
46+
return <WrappedComponent {...props} />
47+
}
48+
49+
return (
50+
<AlertDialog
51+
open={ready && !allowed}
52+
onOpenChange={(open) => {
53+
if (!open) {
54+
router.push(fallbackPath)
55+
}
56+
}}
57+
>
58+
<AlertDialogContent overlayClassName="bg-black/45">
59+
<AlertDialogHeader>
60+
<AlertDialogTitle>{copy.problem.mentorSetupTitle}</AlertDialogTitle>
61+
<AlertDialogDescription>
62+
{copy.problem.mentorSetupDescription}
63+
</AlertDialogDescription>
64+
</AlertDialogHeader>
65+
<AlertDialogFooter>
66+
<AlertDialogCancel onClick={() => router.push(fallbackPath)}>
67+
{copy.problem.mentorSetupCancel}
68+
</AlertDialogCancel>
69+
<AlertDialogAction onClick={() => router.push("/settings")}>
70+
{copy.problem.mentorSetupGoToSettings}
71+
</AlertDialogAction>
72+
</AlertDialogFooter>
73+
</AlertDialogContent>
74+
</AlertDialog>
75+
)
76+
}
77+
78+
GuardedComponent.displayName = `withMentorAccessGuard(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`
79+
80+
return GuardedComponent
81+
}

0 commit comments

Comments
 (0)