← 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&#10;&#10;## Commands&#10;install: npm install&#10;dev: npm run dev&#10;..."
              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>
  )
}