← 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 "{search}"
</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>
)
}