Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/(main)/external-feedback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client"

import { ExternalProblemFeedback } from "@/components/external-problem-feedback"
import { withMentorAccessGuard } from "@/components/with-mentor-access-guard"
import { PageTransition } from "@/components/page-transition"
import { usePageEntryAnimation } from "@/lib/use-page-entry-animation"

function ExternalFeedbackPage() {
const shouldAnimateOnMount = usePageEntryAnimation()

return (
<PageTransition animateOnMount={shouldAnimateOnMount}>
<ExternalProblemFeedback />
</PageTransition>
)
}

export default withMentorAccessGuard(ExternalFeedbackPage, {
fallbackPath: "/",
})
60 changes: 59 additions & 1 deletion app/(main)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
"use client"

import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronDown } from "lucide-react"
import { HeroSection } from "@/components/hero-section"
import { VirtualizedProblemList } from "@/components/virtualized-problem-list"
import { StreakWidget } from "@/components/streak-widget"
import { StatsWidget } from "@/components/stats-widget"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { PageTransition } from "@/components/page-transition"
import { problems } from "@/lib/problems"
import { localizeCategory } from "@/lib/problems"
import type { Difficulty, Problem } from "@/lib/problems"
import {
getApiSettings,
getSolvedProblemIds,
subscribeToProgressUpdates,
} from "@/lib/local-progress"
import { isMentorConfigured as resolveMentorConfigured } from "@/lib/mentor-access"
import { useAppLanguage } from "@/lib/use-app-language"
import { usePageEntryAnimation } from "@/lib/use-page-entry-animation"
import { useRestoreScroll } from "@/lib/use-restore-scroll"
Expand Down Expand Up @@ -49,6 +62,7 @@ function parseHomeViewState(value: unknown): HomeViewState | null {
}

export default function HomePage() {
const router = useRouter()
const { language, copy } = useAppLanguage()
const shouldAnimateOnMount = usePageEntryAnimation()
const [viewState, setViewState] = useRestoreScroll<HomeViewState>({
Expand All @@ -62,13 +76,23 @@ export default function HomePage() {
})
const { selectedCategory, selectedDifficulty, sortBy } = viewState
const [solvedIds, setSolvedIds] = useState<Set<string>>(new Set())
const [isMentorReady, setIsMentorReady] = useState(false)
const [isMentorAlertOpen, setIsMentorAlertOpen] = useState(false)

useEffect(() => {
const sync = () => setSolvedIds(getSolvedProblemIds())
sync()
return subscribeToProgressUpdates(sync)
}, [])

useEffect(() => {
const sync = () => {
setIsMentorReady(resolveMentorConfigured(getApiSettings()))
}
sync()
return subscribeToProgressUpdates(sync)
}, [])

const categories = useMemo(() => {
const map = new Map<string, number>()
for (const problem of problems) {
Expand All @@ -87,7 +111,6 @@ export default function HomePage() {
language === "ko" ? "난이도 오름차순" : "Difficulty Ascending"
const sortDifficultyDescLabel =
language === "ko" ? "난이도 내림차순" : "Difficulty Descending"

const difficultyRank: Record<Difficulty, number> = {
Easy: 0,
Medium: 1,
Expand Down Expand Up @@ -123,6 +146,14 @@ export default function HomePage() {
return source.filter((problem) => problem.difficulty === difficulty).length
}

const handleExternalFeedbackOpen = () => {
if (!isMentorReady) {
setIsMentorAlertOpen(true)
return
}
router.push("/external-feedback")
}

return (
<PageTransition animateOnMount={shouldAnimateOnMount}>
<HeroSection />
Expand Down Expand Up @@ -204,10 +235,37 @@ export default function HomePage() {
<div className="sticky top-24 flex flex-col gap-4">
<StreakWidget />
<StatsWidget />
<section className="rounded-2xl border border-border/70 bg-card p-4 shadow-sm">
<p className="text-sm font-semibold text-foreground">{copy.home.externalFeedbackTitle}</p>
<p className="mt-1 text-xs text-muted-foreground">{copy.home.externalFeedbackDescription}</p>
<button
type="button"
onClick={handleExternalFeedbackOpen}
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]"
>
{copy.home.externalFeedbackAction}
</button>
</section>
</div>
</aside>
</div>
</main>
<AlertDialog open={isMentorAlertOpen} onOpenChange={setIsMentorAlertOpen}>
<AlertDialogContent overlayClassName="bg-black/45">
<AlertDialogHeader>
<AlertDialogTitle>{copy.problem.mentorSetupTitle}</AlertDialogTitle>
<AlertDialogDescription>
{copy.problem.mentorSetupDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{copy.problem.mentorSetupCancel}</AlertDialogCancel>
<AlertDialogAction onClick={() => router.push("/settings")}>
{copy.problem.mentorSetupGoToSettings}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageTransition>
)
}
7 changes: 2 additions & 5 deletions app/problem/[id]/problem-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "@/lib/local-progress";
import { useAppLanguage } from "@/lib/use-app-language";
import { useIsMobile } from "@/components/ui/use-mobile";
import { isMentorConfigured as resolveMentorConfigured } from "@/lib/mentor-access";

interface ProblemPageClientProps {
problem: Problem;
Expand Down Expand Up @@ -90,11 +91,7 @@ export function ProblemPageClient({ problem }: ProblemPageClientProps) {

useEffect(() => {
const sync = () => {
const settings = getApiSettings();
const provider = settings.provider;
const hasModel = Boolean(settings.models[provider]?.trim());
const hasApiKey = Boolean(settings.apiKeys[provider]?.trim());
setIsMentorConfigured(hasModel && hasApiKey);
setIsMentorConfigured(resolveMentorConfigured(getApiSettings()));
};
sync();
return subscribeToProgressUpdates(sync);
Expand Down
135 changes: 135 additions & 0 deletions components/external-problem-feedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client"

import { useState } from "react"
import Editor from "@monaco-editor/react"
import { RotateCcw } from "lucide-react"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { useIsMobile } from "@/components/ui/use-mobile"
import { CodeAssistantChat } from "@/components/code-assistant-chat"
import { useAppLanguage } from "@/lib/use-app-language"

export function ExternalProblemFeedback() {
const { copy } = useAppLanguage()
const isMobile = useIsMobile()
const text = copy.externalFeedback
const [problemTitle, setProblemTitle] = useState("")
const [problemText, setProblemText] = useState("")
const [code, setCode] = useState("")

const normalizedProblemDescription =
problemText.trim() || text.chatMissingProblemContext
const normalizedProblemTitle = problemTitle.trim() || text.defaultProblemTitle
const handleReset = () => {
setCode("")
}

return (
<div className="h-[calc(100dvh-64px)] overflow-hidden bg-background">
<div className={`flex h-full ${isMobile ? "flex-col" : "flex-row"}`}>
<div className={`min-w-0 ${isMobile ? "h-[60dvh]" : "flex-1"}`}>
<ResizablePanelGroup direction={isMobile ? "vertical" : "horizontal"}>
<ResizablePanel defaultSize={isMobile ? 45 : 48} minSize={isMobile ? 24 : 24}>
<ScrollArea className="h-full border-r border-border/70">
<div className="space-y-4 p-6">
<div>
<h1 className="text-2xl font-bold text-foreground">{text.title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{text.description}</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold text-muted-foreground">
{text.titleLabel}
</label>
<Input
value={problemTitle}
onChange={(event) => setProblemTitle(event.target.value)}
placeholder={text.titlePlaceholder}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold text-muted-foreground">
{text.problemLabel}
</label>
<Textarea
value={problemText}
onChange={(event) => setProblemText(event.target.value)}
placeholder={text.problemPlaceholder}
className="min-h-[420px] resize-none"
/>
</div>
</div>
</ScrollArea>
</ResizablePanel>

<ResizableHandle withHandle={!isMobile} />

<ResizablePanel defaultSize={isMobile ? 55 : 52} minSize={isMobile ? 24 : 26}>
<div className="relative flex h-full max-h-full flex-col overflow-hidden bg-background">
<div className="flex-shrink-0 flex items-center justify-between border-b border-border/60 px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive/60" />
<div className="h-3 w-3 rounded-full bg-warning/60" />
<div className="h-3 w-3 rounded-full bg-success/60" />
<span className="ml-2 text-xs font-medium text-muted-foreground">
solution.js
</span>
</div>
<button
onClick={handleReset}
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"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
</div>
<div className="min-h-0 flex-1">
<Editor
height="100%"
defaultLanguage="javascript"
value={code}
onChange={(value) => setCode(value ?? "")}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
}}
/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>

<aside className={`border-l border-border/70 ${isMobile ? "h-[40dvh] w-full" : "w-[420px] flex-shrink-0"}`}>
<div className="flex h-full flex-col">
<div className="border-b border-border/70 px-4 py-3">
<p className="text-sm font-semibold text-foreground">{text.chatTitle}</p>
<p className="mt-1 text-xs text-muted-foreground">{text.chatDescription}</p>
</div>
{!problemText.trim() ? (
<div className="border-b border-border/70 bg-amber-50/70 px-4 py-2 text-xs font-medium text-amber-700">
{text.chatMissingProblem}
</div>
) : null}
<div className="min-h-0 flex-1">
<CodeAssistantChat
code={code}
problemTitle={normalizedProblemTitle}
problemDescription={normalizedProblemDescription}
/>
</div>
</div>
</aside>
</div>
</div>
)
}
81 changes: 81 additions & 0 deletions components/with-mentor-access-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client"

import { useEffect, useState, type ComponentType } from "react"
import { useRouter } from "next/navigation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { getApiSettings, subscribeToProgressUpdates } from "@/lib/local-progress"
import { isMentorConfigured } from "@/lib/mentor-access"
import { useAppLanguage } from "@/lib/use-app-language"

interface MentorAccessGuardOptions {
fallbackPath?: string
}

export function withMentorAccessGuard<P extends object>(
WrappedComponent: ComponentType<P>,
options?: MentorAccessGuardOptions
) {
const fallbackPath = options?.fallbackPath ?? "/"

const GuardedComponent = (props: P) => {
const router = useRouter()
const { copy } = useAppLanguage()
const [ready, setReady] = useState(false)
const [allowed, setAllowed] = useState(false)

useEffect(() => {
const sync = () => {
setAllowed(isMentorConfigured(getApiSettings()))
setReady(true)
}

sync()
return subscribeToProgressUpdates(sync)
}, [])

if (ready && allowed) {
return <WrappedComponent {...props} />
}

return (
<AlertDialog
open={ready && !allowed}
onOpenChange={(open) => {
if (!open) {
router.push(fallbackPath)
}
}}
>
<AlertDialogContent overlayClassName="bg-black/45">
<AlertDialogHeader>
<AlertDialogTitle>{copy.problem.mentorSetupTitle}</AlertDialogTitle>
<AlertDialogDescription>
{copy.problem.mentorSetupDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => router.push(fallbackPath)}>
{copy.problem.mentorSetupCancel}
</AlertDialogCancel>
<AlertDialogAction onClick={() => router.push("/settings")}>
{copy.problem.mentorSetupGoToSettings}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

GuardedComponent.displayName = `withMentorAccessGuard(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`

return GuardedComponent
}
Loading