← back to ClawCoder
src/app/analyzer/page.tsx
566 lines
'use client'
import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { useStore } from '@/lib/store'
import { GradeBadge } from '@/components/ui/grade-badge'
import { RadarChart } from '@/components/ui/radar-chart'
import { DiffViewer } from '@/components/ui/diff-viewer'
import type { AnalysisResult, Suggestion } from '@/lib/analyzer/types'
export default function AnalyzerPage() {
const [inputContent, setInputContent] = useState('')
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedSuggestions, setExpandedSuggestions] = useState<Set<string>>(new Set())
const [showPreview, setShowPreview] = useState(false)
const [previewContent, setPreviewContent] = useState('')
const [isApplying, setIsApplying] = useState(false)
const [dragActive, setDragActive] = useState(false)
const {
selectedSuggestions,
toggleSuggestion,
selectAllSuggestions,
clearSuggestions,
} = useStore()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAnalyze = useCallback(async () => {
if (!inputContent.trim()) return
setIsAnalyzing(true)
setError(null)
setAnalysisResult(null)
clearSuggestions()
try {
const res = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: inputContent, source: 'paste' }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Analysis failed')
return
}
setAnalysisResult(data as AnalysisResult)
} catch {
setError('Network error. Please try again.')
} finally {
setIsAnalyzing(false)
}
}, [inputContent, clearSuggestions])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
setInputContent(text)
}
reader.readAsText(file)
}
}, [])
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
setInputContent(text)
}
reader.readAsText(file)
}
},
[]
)
const toggleExpanded = (id: string) => {
setExpandedSuggestions((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handlePreview = useCallback(async () => {
if (selectedSuggestions.size === 0 || !analysisResult) return
const selected = analysisResult.suggestions.filter((s) =>
selectedSuggestions.has(s.id)
)
try {
const res = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: inputContent,
selectedSuggestions: selected,
}),
})
const data = await res.json()
if (res.ok) {
setPreviewContent(data.updatedContent)
setShowPreview(true)
}
} catch {
// silent
}
}, [selectedSuggestions, analysisResult, inputContent])
const handleApply = useCallback(async () => {
if (selectedSuggestions.size === 0 || !analysisResult) return
setIsApplying(true)
const selected = analysisResult.suggestions.filter((s) =>
selectedSuggestions.has(s.id)
)
try {
const res = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: inputContent,
selectedSuggestions: selected,
}),
})
const data = await res.json()
if (res.ok) {
setInputContent(data.updatedContent)
setPreviewContent(data.updatedContent)
clearSuggestions()
// Re-analyze with updated content
const res2 = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: data.updatedContent,
source: 'applied',
}),
})
const data2 = await res2.json()
if (res2.ok) {
setAnalysisResult(data2 as AnalysisResult)
}
}
} catch {
// silent
} finally {
setIsApplying(false)
}
}, [selectedSuggestions, analysisResult, inputContent, clearSuggestions])
const handleDownload = () => {
const content = previewContent || inputContent
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'CLAUDE.md'
a.click()
URL.revokeObjectURL(url)
}
const handleCopy = async () => {
const content = previewContent || inputContent
await navigator.clipboard.writeText(content)
}
function priorityBadge(priority: Suggestion['priority']) {
const colors = {
high: 'bg-red-100 text-red-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-slate-100 text-slate-600',
}
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[priority]}`}
>
{priority}
</span>
)
}
return (
<div className="mx-auto w-full max-w-4xl px-4 py-10 sm:px-6">
<h1 className="text-3xl font-bold text-slate-900">
CLAUDE.md Analyzer
</h1>
<p className="mt-2 text-slate-600">
Drop your CLAUDE.md file or paste its contents to get an instant
analysis with actionable suggestions.
</p>
{/* Upload / Paste Section */}
{!analysisResult && (
<div className="mt-8 space-y-4">
{/* Drop zone */}
<div
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`flex min-h-[120px] cursor-pointer items-center justify-center rounded-xl border-2 border-dashed p-6 text-center transition-colors ${
dragActive
? 'border-blue-400 bg-blue-50'
: 'border-slate-300 hover:border-slate-400'
}`}
>
<div>
<p className="text-sm font-medium text-slate-600">
Drop your CLAUDE.md here or click to browse
</p>
<p className="mt-1 text-xs text-slate-400">
Supports .md and plain text files
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".md,.txt,.markdown"
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* Textarea */}
<div>
<label
htmlFor="claude-md-input"
className="mb-2 block text-sm font-medium text-slate-700"
>
Or paste your content below
</label>
<textarea
id="claude-md-input"
value={inputContent}
onChange={(e) => setInputContent(e.target.value)}
placeholder="# My Project — CLAUDE.md ## Commands install: npm install dev: npm run dev ..."
rows={12}
className="w-full rounded-xl border border-slate-300 p-4 font-mono text-sm text-slate-800 placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{error && (
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
)}
<button
type="button"
onClick={handleAnalyze}
disabled={!inputContent.trim() || isAnalyzing}
className="inline-flex min-h-[48px] items-center justify-center rounded-xl bg-blue-600 px-8 py-3 text-base font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isAnalyzing ? (
<>
<span className="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Analyzing...
</>
) : (
'Analyze'
)}
</button>
</div>
)}
{/* Results Section */}
{analysisResult && (
<div className="mt-10 space-y-8">
{/* Grade + Radar */}
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col items-center justify-center">
<GradeBadge
grade={analysisResult.letterGrade}
percentage={analysisResult.percentage}
/>
<p className="mt-3 text-sm text-slate-500">
{analysisResult.lineCount} lines analyzed
</p>
{analysisResult.detectedProfileSlug && (
<p className="mt-2 text-sm text-slate-600">
Matches profile:{' '}
<Link
href={`/builder?profile=${analysisResult.detectedProfileSlug}`}
className="font-medium text-blue-600 hover:underline"
>
{analysisResult.detectedProfileSlug}
</Link>
</p>
)}
</div>
<RadarChart criteria={analysisResult.criteria} />
</div>
{/* Strengths & Issues */}
<div className="grid gap-4 md:grid-cols-2">
{/* Strengths */}
<div className="rounded-xl border border-green-200 bg-green-50/50">
<button
type="button"
className="flex w-full items-center justify-between px-5 py-4 text-left"
onClick={() => {
const el = document.getElementById('strengths-list')
el?.classList.toggle('hidden')
}}
>
<span className="text-sm font-semibold text-green-700">
Strengths ({analysisResult.pros.length})
</span>
<svg
className="h-4 w-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<ul id="strengths-list" className="space-y-2 px-5 pb-4">
{analysisResult.pros.map((pro, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-green-800"
>
<span className="mt-0.5 flex-shrink-0">+</span>
<span>{pro}</span>
</li>
))}
{analysisResult.pros.length === 0 && (
<li className="text-sm text-green-600">
No full-score criteria yet. Keep improving!
</li>
)}
</ul>
</div>
{/* Issues */}
<div className="rounded-xl border border-red-200 bg-red-50/50">
<button
type="button"
className="flex w-full items-center justify-between px-5 py-4 text-left"
onClick={() => {
const el = document.getElementById('issues-list')
el?.classList.toggle('hidden')
}}
>
<span className="text-sm font-semibold text-red-700">
Issues ({analysisResult.cons.length})
</span>
<svg
className="h-4 w-4 text-red-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<ul id="issues-list" className="space-y-2 px-5 pb-4">
{analysisResult.cons.map((con, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-red-800"
>
<span className="mt-0.5 flex-shrink-0">-</span>
<span>{con}</span>
</li>
))}
{analysisResult.cons.length === 0 && (
<li className="text-sm text-red-600">
No zero-score criteria. Great job!
</li>
)}
</ul>
</div>
</div>
{/* Suggestions */}
{analysisResult.suggestions.length > 0 && (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-bold text-slate-900">
Suggestions ({analysisResult.suggestions.length})
</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
selectAllSuggestions(analysisResult.suggestions)
}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
Select All
</button>
<button
type="button"
onClick={clearSuggestions}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
Deselect All
</button>
</div>
</div>
<div className="space-y-3">
{analysisResult.suggestions.map((s) => (
<div
key={s.id}
className="rounded-xl border border-slate-200 bg-white shadow-sm"
>
<div className="flex items-start gap-3 p-4">
<input
type="checkbox"
checked={selectedSuggestions.has(s.id)}
onChange={() => toggleSuggestion(s.id)}
className="mt-0.5 min-h-[20px] min-w-[20px] rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label={`Select suggestion: ${s.description}`}
/>
<div className="flex-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-slate-800">
{s.description}
</p>
{priorityBadge(s.priority)}
</div>
<p className="mt-1 text-xs text-slate-500">
{s.criterion}
</p>
</div>
<button
type="button"
onClick={() => toggleExpanded(s.id)}
className="min-h-[44px] min-w-[44px] rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600"
aria-label={
expandedSuggestions.has(s.id)
? 'Collapse diff'
: 'Expand diff'
}
>
<svg
className={`mx-auto h-4 w-4 transition-transform ${
expandedSuggestions.has(s.id) ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</div>
{expandedSuggestions.has(s.id) && (
<div className="border-t border-slate-200 p-4">
<DiffViewer before={s.before} after={s.after} />
</div>
)}
</div>
))}
</div>
{/* Action buttons */}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={handlePreview}
disabled={selectedSuggestions.size === 0}
className="min-h-[44px] rounded-xl border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Preview Changes
</button>
<button
type="button"
onClick={handleApply}
disabled={selectedSuggestions.size === 0 || isApplying}
className="min-h-[44px] rounded-xl bg-blue-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isApplying ? 'Applying...' : 'Apply Selected'}
</button>
</div>
</div>
)}
{/* Preview Modal */}
{showPreview && (
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-900">
Preview Changes
</h2>
<button
type="button"
onClick={() => setShowPreview(false)}
className="min-h-[44px] min-w-[44px] rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
Close
</button>
</div>
<DiffViewer before={inputContent} after={previewContent} />
</div>
)}
{/* Download / Copy */}
<div className="flex flex-wrap gap-3 border-t border-slate-200 pt-6">
<button
type="button"
onClick={handleDownload}
className="min-h-[44px] rounded-xl bg-slate-800 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-slate-700"
>
Download Result
</button>
<button
type="button"
onClick={handleCopy}
className="min-h-[44px] rounded-xl border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Copy to Clipboard
</button>
<button
type="button"
onClick={() => {
setAnalysisResult(null)
clearSuggestions()
setShowPreview(false)
setPreviewContent('')
}}
className="min-h-[44px] rounded-xl border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Analyze Another
</button>
</div>
</div>
)}
</div>
)
}