← back to ClawCoder

src/app/my-files/page.tsx

521 lines

'use client'

import { useEffect, useState, useMemo, useCallback } from 'react'

interface ClaudeMdFile {
  id: string
  path: string
  project: string
  lines: number
  hasRules: boolean
  lastModified: string
  preview: string
  exists: boolean
}

interface FileContent {
  path: string
  project: string
  content: string
  lines: number
  lastModified: string
}

interface AnalyzeResult {
  score: number
  grade: string
  criteria: { name: string; score: number; max: number; feedback: string }[]
}

type SortKey = 'project' | 'lines' | 'lastModified'
type SortDir = 'asc' | 'desc'

function Toast({ message, onClose }: { message: string; onClose: () => void }) {
  useEffect(() => {
    const t = setTimeout(onClose, 3000)
    return () => clearTimeout(t)
  }, [onClose])

  return (
    <div className="fixed bottom-6 right-6 z-50 rounded-lg bg-green-600 px-5 py-3 text-sm font-medium text-white shadow-lg">
      {message}
    </div>
  )
}

function lineColor(lines: number): string {
  if (lines > 500) return 'text-red-600 font-semibold'
  if (lines > 200) return 'text-amber-600 font-semibold'
  return 'text-slate-700'
}

function lineBadge(lines: number): string {
  if (lines > 500) return 'bg-red-100 text-red-700'
  if (lines > 200) return 'bg-amber-100 text-amber-700'
  return 'bg-slate-100 text-slate-600'
}

function gradeColor(grade: string): string {
  if (grade.startsWith('A')) return 'bg-green-100 text-green-700'
  if (grade.startsWith('B')) return 'bg-blue-100 text-blue-700'
  if (grade.startsWith('C')) return 'bg-amber-100 text-amber-700'
  return 'bg-red-100 text-red-700'
}

export default function MyFilesPage() {
  const [files, setFiles] = useState<ClaudeMdFile[]>([])
  const [loading, setLoading] = useState(true)
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const [fileContent, setFileContent] = useState<FileContent | null>(null)
  const [editorContent, setEditorContent] = useState('')
  const [originalContent, setOriginalContent] = useState('')
  const [saving, setSaving] = useState(false)
  const [toast, setToast] = useState<string | null>(null)
  const [analyzing, setAnalyzing] = useState(false)
  const [analyzeResult, setAnalyzeResult] = useState<AnalyzeResult | null>(null)
  const [search, setSearch] = useState('')
  const [sortKey, setSortKey] = useState<SortKey>('project')
  const [sortDir, setSortDir] = useState<SortDir>('asc')
  const [loadingFile, setLoadingFile] = useState(false)

  // Fetch file list
  useEffect(() => {
    fetch('/api/files')
      .then(r => r.json())
      .then(data => {
        setFiles(data)
        setLoading(false)
      })
      .catch(() => setLoading(false))
  }, [])

  // Filtered and sorted files
  const filteredFiles = useMemo(() => {
    let result = files.filter(f => f.exists)
    if (search) {
      const q = search.toLowerCase()
      result = result.filter(
        f =>
          f.project.toLowerCase().includes(q) ||
          f.path.toLowerCase().includes(q)
      )
    }
    result.sort((a, b) => {
      let cmp = 0
      if (sortKey === 'project') cmp = a.project.localeCompare(b.project)
      else if (sortKey === 'lines') cmp = a.lines - b.lines
      else if (sortKey === 'lastModified') cmp = new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
      return sortDir === 'desc' ? -cmp : cmp
    })
    return result
  }, [files, search, sortKey, sortDir])

  // Stats
  const stats = useMemo(() => {
    const existing = files.filter(f => f.exists)
    const totalLines = existing.reduce((s, f) => s + f.lines, 0)
    return {
      total: existing.length,
      avgLines: existing.length ? Math.round(totalLines / existing.length) : 0,
      over200: existing.filter(f => f.lines > 200).length,
      withRules: existing.filter(f => f.hasRules).length,
    }
  }, [files])

  // Load a file
  const loadFile = useCallback(async (id: string) => {
    setSelectedId(id)
    setLoadingFile(true)
    setAnalyzeResult(null)
    try {
      const res = await fetch(`/api/files/${encodeURIComponent(id)}`)
      if (!res.ok) throw new Error('Failed to load file')
      const data: FileContent = await res.json()
      setFileContent(data)
      setEditorContent(data.content)
      setOriginalContent(data.content)
    } catch {
      setFileContent(null)
      setEditorContent('')
      setOriginalContent('')
    } finally {
      setLoadingFile(false)
    }
  }, [])

  // Save file
  const saveFile = async () => {
    if (!selectedId) return
    setSaving(true)
    try {
      const res = await fetch(`/api/files/${encodeURIComponent(selectedId)}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content: editorContent }),
      })
      if (!res.ok) throw new Error('Save failed')
      const data = await res.json()
      setOriginalContent(editorContent)
      setToast(`Saved! ${data.lines} lines written.`)
      // Update line count in the list
      setFiles(prev =>
        prev.map(f =>
          f.id === selectedId ? { ...f, lines: data.lines } : f
        )
      )
    } catch {
      setToast('Save failed. Check permissions.')
    } finally {
      setSaving(false)
    }
  }

  // Analyze file
  const analyzeFile = async () => {
    setAnalyzing(true)
    try {
      const res = await fetch('/api/analyze', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content: editorContent }),
      })
      if (!res.ok) throw new Error('Analyze failed')
      const data = await res.json()
      // Map API response format to component format
      setAnalyzeResult({
        score: data.percentage ?? data.score ?? 0,
        grade: data.letterGrade ?? data.grade ?? 'F',
        criteria: (data.criteria || []).map((c: any) => ({
          name: c.name || c.id || '',
          score: c.score ?? 0,
          max: c.maxScore ?? c.max ?? 0,
          feedback: c.findings?.[0]?.message || c.feedback || (c.score === c.maxScore ? 'Pass' : 'Needs improvement'),
        })),
      })
    } catch {
      setToast('Analysis failed.')
    } finally {
      setAnalyzing(false)
    }
  }

  // Download file
  const downloadFile = () => {
    if (!fileContent) return
    const blob = new Blob([editorContent], { 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)
  }

  // Revert
  const revertFile = () => {
    setEditorContent(originalContent)
    setToast('Reverted to original content.')
  }

  const editorLines = editorContent.split('\n').length
  const isDirty = editorContent !== originalContent

  function toggleSort(key: SortKey) {
    if (sortKey === key) {
      setSortDir(d => (d === 'asc' ? 'desc' : 'asc'))
    } else {
      setSortKey(key)
      setSortDir('asc')
    }
  }

  function sortArrow(key: SortKey) {
    if (sortKey !== key) return ''
    return sortDir === 'asc' ? ' \u2191' : ' \u2193'
  }

  if (loading) {
    return (
      <div className="flex min-h-[60vh] items-center justify-center">
        <div className="text-slate-500">Loading files...</div>
      </div>
    )
  }

  return (
    <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6">
      {toast && <Toast message={toast} onClose={() => setToast(null)} />}

      {/* Header */}
      <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <div>
          <h1 className="text-2xl font-bold text-slate-900">My CLAUDE.md Files</h1>
          <p className="mt-1 text-sm text-slate-500">
            Manage all {stats.total} CLAUDE.md configuration files across the server
          </p>
        </div>
      </div>

      {/* Stats bar */}
      <div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
        <div className="rounded-lg border border-slate-200 bg-white p-4 text-center">
          <div className="text-2xl font-bold text-slate-800">{stats.total}</div>
          <div className="text-xs text-slate-500">Total Files</div>
        </div>
        <div className="rounded-lg border border-slate-200 bg-white p-4 text-center">
          <div className="text-2xl font-bold text-slate-800">{stats.avgLines}</div>
          <div className="text-xs text-slate-500">Avg Lines</div>
        </div>
        <div className={`rounded-lg border p-4 text-center ${stats.over200 > 0 ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-white'}`}>
          <div className={`text-2xl font-bold ${stats.over200 > 0 ? 'text-amber-700' : 'text-slate-800'}`}>
            {stats.over200}
          </div>
          <div className="text-xs text-slate-500">Over 200 Lines</div>
        </div>
        <div className="rounded-lg border border-slate-200 bg-white p-4 text-center">
          <div className="text-2xl font-bold text-blue-600">{stats.withRules}</div>
          <div className="text-xs text-slate-500">With .claude/rules/</div>
        </div>
      </div>

      <div className="flex flex-col gap-6 lg:flex-row">
        {/* File list panel */}
        <div className={`w-full shrink-0 ${selectedId ? 'lg:w-[420px]' : ''}`}>
          {/* Search */}
          <div className="mb-3">
            <input
              type="text"
              placeholder="Search by project or path..."
              value={search}
              onChange={e => setSearch(e.target.value)}
              className="w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
            />
          </div>

          {/* Table */}
          <div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
            <div className="max-h-[calc(100vh-320px)] overflow-y-auto">
              <table className="w-full text-left text-sm">
                <thead className="sticky top-0 bg-slate-50 text-xs uppercase text-slate-500">
                  <tr>
                    <th
                      className="cursor-pointer px-4 py-3 hover:text-slate-700"
                      onClick={() => toggleSort('project')}
                    >
                      Project{sortArrow('project')}
                    </th>
                    <th
                      className="cursor-pointer px-3 py-3 text-right hover:text-slate-700"
                      onClick={() => toggleSort('lines')}
                    >
                      Lines{sortArrow('lines')}
                    </th>
                    {!selectedId && (
                      <>
                        <th className="px-3 py-3 text-center">Rules</th>
                        <th
                          className="cursor-pointer px-3 py-3 hover:text-slate-700"
                          onClick={() => toggleSort('lastModified')}
                        >
                          Modified{sortArrow('lastModified')}
                        </th>
                      </>
                    )}
                  </tr>
                </thead>
                <tbody className="divide-y divide-slate-100">
                  {filteredFiles.map(f => (
                    <tr
                      key={f.id}
                      onClick={() => loadFile(f.id)}
                      className={`cursor-pointer transition-colors hover:bg-blue-50 ${
                        selectedId === f.id ? 'bg-blue-50' : ''
                      }`}
                    >
                      <td className="px-4 py-3">
                        <div className="font-medium text-slate-800">{f.project}</div>
                        <div className="mt-0.5 truncate text-xs text-slate-400" title={f.path}>
                          {f.path.length > 45 ? '...' + f.path.slice(-42) : f.path}
                        </div>
                      </td>
                      <td className="px-3 py-3 text-right">
                        <span className={`inline-block rounded-full px-2 py-0.5 text-xs ${lineBadge(f.lines)}`}>
                          {f.lines}
                        </span>
                      </td>
                      {!selectedId && (
                        <>
                          <td className="px-3 py-3 text-center">
                            {f.hasRules && (
                              <span className="inline-block rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
                                Yes
                              </span>
                            )}
                          </td>
                          <td className="whitespace-nowrap px-3 py-3 text-xs text-slate-500">
                            {f.lastModified
                              ? new Date(f.lastModified).toLocaleDateString('en-US', {
                                  month: 'short',
                                  day: 'numeric',
                                  year: 'numeric',
                                })
                              : '-'}
                          </td>
                        </>
                      )}
                    </tr>
                  ))}
                  {filteredFiles.length === 0 && (
                    <tr>
                      <td colSpan={4} className="px-4 py-8 text-center text-sm text-slate-400">
                        No files found matching &quot;{search}&quot;
                      </td>
                    </tr>
                  )}
                </tbody>
              </table>
            </div>
          </div>
        </div>

        {/* Editor panel */}
        {selectedId && (
          <div className="flex min-w-0 flex-1 flex-col">
            {loadingFile ? (
              <div className="flex h-64 items-center justify-center rounded-lg border border-slate-200 bg-white">
                <div className="text-sm text-slate-500">Loading file...</div>
              </div>
            ) : fileContent ? (
              <>
                {/* Editor header */}
                <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                  <div className="min-w-0">
                    <h2 className="truncate text-lg font-semibold text-slate-800">
                      {fileContent.project}
                    </h2>
                    <p className="truncate text-xs text-slate-400" title={fileContent.path}>
                      {fileContent.path}
                    </p>
                  </div>
                  <div className="flex shrink-0 items-center gap-2">
                    <span className={`rounded-full px-2.5 py-1 text-xs font-medium ${lineBadge(editorLines)}`}>
                      {editorLines} lines
                    </span>
                    {analyzeResult && (
                      <span className={`rounded-full px-2.5 py-1 text-xs font-bold ${gradeColor(analyzeResult.grade)}`}>
                        {analyzeResult.grade} ({analyzeResult.score}%)
                      </span>
                    )}
                    {isDirty && (
                      <span className="rounded-full bg-orange-100 px-2.5 py-1 text-xs font-medium text-orange-700">
                        Unsaved
                      </span>
                    )}
                  </div>
                </div>

                {/* Line count warning */}
                {editorLines > 200 && (
                  <div className={`mb-3 rounded-lg border px-4 py-2.5 text-sm ${
                    editorLines > 500
                      ? 'border-red-300 bg-red-50 text-red-700'
                      : 'border-amber-300 bg-amber-50 text-amber-700'
                  }`}>
                    This file is {editorLines} lines. Claude Code recommends under 200 lines for optimal performance.
                    {editorLines > 500 && ' Consider moving sections into .claude/rules/ files.'}
                  </div>
                )}

                {/* Textarea editor */}
                <textarea
                  value={editorContent}
                  onChange={e => setEditorContent(e.target.value)}
                  spellCheck={false}
                  className="min-h-[400px] flex-1 resize-y rounded-lg border border-slate-300 bg-slate-50 p-4 font-mono text-sm leading-relaxed text-slate-800 outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-100 lg:min-h-[500px]"
                />

                {/* Action buttons */}
                <div className="mt-3 flex flex-wrap gap-2">
                  <button
                    onClick={saveFile}
                    disabled={saving || !isDirty}
                    className="rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
                  >
                    {saving ? 'Saving...' : 'Save'}
                  </button>
                  <button
                    onClick={analyzeFile}
                    disabled={analyzing}
                    className="rounded-lg bg-slate-800 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-slate-900 disabled:cursor-not-allowed disabled:opacity-50"
                  >
                    {analyzing ? 'Analyzing...' : 'Analyze'}
                  </button>
                  <button
                    onClick={revertFile}
                    disabled={!isDirty}
                    className="rounded-lg border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
                  >
                    Revert
                  </button>
                  <button
                    onClick={downloadFile}
                    className="rounded-lg border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
                  >
                    Download
                  </button>
                  <button
                    onClick={() => {
                      setSelectedId(null)
                      setFileContent(null)
                      setAnalyzeResult(null)
                    }}
                    className="rounded-lg border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50"
                  >
                    Close
                  </button>
                </div>

                {/* Analysis results */}
                {analyzeResult && (
                  <div className="mt-4 rounded-lg border border-slate-200 bg-white p-4">
                    <h3 className="mb-3 text-sm font-semibold text-slate-700">
                      Analysis: {analyzeResult.grade} ({analyzeResult.score}%)
                    </h3>
                    <div className="space-y-2">
                      {analyzeResult.criteria.map(c => (
                        <div key={c.name} className="flex items-start gap-3 text-sm">
                          <span className="shrink-0 font-mono text-xs text-slate-400">
                            {c.score}/{c.max}
                          </span>
                          <div>
                            <span className="font-medium text-slate-700">{c.name}</span>
                            <span className="ml-2 text-slate-500">{c.feedback}</span>
                          </div>
                        </div>
                      ))}
                    </div>
                  </div>
                )}
              </>
            ) : (
              <div className="flex h-64 items-center justify-center rounded-lg border border-slate-200 bg-white">
                <div className="text-sm text-slate-400">File not found or unreadable.</div>
              </div>
            )}
          </div>
        )}

        {/* Empty state when no file selected */}
        {!selectedId && (
          <div className="hidden flex-1 items-center justify-center rounded-lg border-2 border-dashed border-slate-200 bg-slate-50 lg:flex">
            <div className="text-center">
              <svg className="mx-auto mb-2 h-12 w-12 text-slate-300" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
              <p className="text-sm text-slate-400">Select a file from the list to view and edit</p>
            </div>
          </div>
        )}
      </div>
    </div>
  )
}