← back to ClawCoder
src/app/mass-update/page.tsx
449 lines
'use client'
import { useCallback, useRef, useState } from 'react'
import { useStore } from '@/lib/store'
import { GradeBadge } from '@/components/ui/grade-badge'
import type { AnalysisResult } from '@/lib/analyzer/types'
function detectFileType(
name: string
): 'claude_md' | 'rule' | 'skill' | 'hook' {
const lower = name.toLowerCase()
if (lower.includes('claude') && lower.endsWith('.md')) return 'claude_md'
if (lower.includes('hook') || lower.includes('settings.json')) return 'hook'
if (lower.includes('skill') || lower.includes('agent')) return 'skill'
return 'rule'
}
function typeBadge(type: string) {
const colors: Record<string, string> = {
claude_md: 'bg-blue-100 text-blue-700',
rule: 'bg-slate-100 text-slate-700',
skill: 'bg-purple-100 text-purple-700',
hook: 'bg-amber-100 text-amber-700',
}
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[type] || colors.rule}`}
>
{type}
</span>
)
}
export default function MassUpdatePage() {
const {
massFiles,
addMassFiles,
removeMassFile,
updateMassFile,
clearMassFiles,
selectedMassFileIds,
toggleMassFileSelection,
selectAllMassFiles,
clearMassFileSelection,
} = useStore()
const [analyzing, setAnalyzing] = useState(false)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
const newFiles: {
id: string
name: string
content: string
type: 'claude_md' | 'rule' | 'skill' | 'hook'
analysis: null
lastAnalyzed: null
}[] = []
const promises = Array.from(files).map(
(file) =>
new Promise<void>((resolve) => {
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
newFiles.push({
id: `${file.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
content: text,
type: detectFileType(file.name),
analysis: null,
lastAnalyzed: null,
})
resolve()
}
reader.readAsText(file)
})
)
Promise.all(promises).then(() => {
addMassFiles(newFiles)
})
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
[addMassFiles]
)
const analyzeFile = useCallback(
async (id: string, content: string) => {
try {
const res = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, source: 'mass-update' }),
})
const data = await res.json()
if (res.ok) {
updateMassFile(id, {
analysis: data as AnalysisResult,
lastAnalyzed: new Date().toISOString(),
})
}
} catch {
// silent
}
},
[updateMassFile]
)
const handleAnalyzeAll = useCallback(async () => {
const selected = massFiles.filter((f) => selectedMassFileIds.has(f.id))
if (selected.length === 0) return
setAnalyzing(true)
for (const file of selected) {
await analyzeFile(file.id, file.content)
}
setAnalyzing(false)
}, [massFiles, selectedMassFileIds, analyzeFile])
const handleExportBundle = useCallback(() => {
const selected = massFiles.filter((f) => selectedMassFileIds.has(f.id))
if (selected.length === 0) return
let content = ''
for (const file of selected) {
content += `=== ${file.name} (${file.type}) ===\n`
if (file.analysis) {
content += `Score: ${file.analysis.letterGrade} (${file.analysis.percentage}%)\n`
}
content += '\n' + file.content + '\n\n'
}
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'clawcoder-bundle-export.txt'
a.click()
URL.revokeObjectURL(url)
}, [massFiles, selectedMassFileIds])
const toggleRow = (id: string) => {
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectedCount = selectedMassFileIds.size
return (
<div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-slate-900">Mass Update</h1>
<p className="mt-1 text-slate-600">
Upload and analyze multiple configuration files at once.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
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"
>
Upload Files
</button>
{massFiles.length > 0 && (
<button
type="button"
onClick={clearMassFiles}
className="min-h-[44px] rounded-xl border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
Clear All
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.txt,.json,.markdown"
onChange={handleFileUpload}
className="hidden"
/>
</div>
{/* Bulk Actions Bar */}
{selectedCount > 0 && (
<div className="mb-6 flex flex-wrap items-center gap-3 rounded-xl border border-blue-200 bg-blue-50 p-4">
<span className="text-sm font-medium text-blue-700">
{selectedCount} selected
</span>
<button
type="button"
onClick={handleAnalyzeAll}
disabled={analyzing}
className="min-h-[36px] rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{analyzing ? 'Analyzing...' : 'Analyze All'}
</button>
<button
type="button"
onClick={handleExportBundle}
className="min-h-[36px] rounded-lg border border-blue-300 px-4 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Export Bundle
</button>
<button
type="button"
onClick={clearMassFileSelection}
className="min-h-[36px] rounded-lg border border-blue-300 px-4 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Clear Selection
</button>
</div>
)}
{/* Empty State */}
{massFiles.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 py-20 text-center">
<svg
className="mb-4 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 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9.75m3-3H9.75m10.5-4.875V18a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18V6.75A2.25 2.25 0 015.25 4.5h4.5"
/>
</svg>
<p className="text-sm font-medium text-slate-600">
No files uploaded yet
</p>
<p className="mt-1 text-xs text-slate-400">
Upload .md, .txt, or .json configuration files to get started
</p>
</div>
)}
{/* Files Table */}
{massFiles.length > 0 && (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
{/* Table header */}
<div className="grid grid-cols-[44px_1fr_100px_100px_140px_44px] items-center gap-2 border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-medium uppercase tracking-wider text-slate-500">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={
selectedCount === massFiles.length && massFiles.length > 0
}
onChange={() => {
if (selectedCount === massFiles.length) {
clearMassFileSelection()
} else {
selectAllMassFiles()
}
}}
className="min-h-[20px] min-w-[20px] rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label="Select all files"
/>
</div>
<div>File Name</div>
<div>Type</div>
<div>Score</div>
<div>Last Analyzed</div>
<div />
</div>
{/* Table rows */}
{massFiles.map((file) => (
<div key={file.id}>
<div className="grid grid-cols-[44px_1fr_100px_100px_140px_44px] items-center gap-2 border-b border-slate-100 px-4 py-3 hover:bg-slate-50">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={selectedMassFileIds.has(file.id)}
onChange={() => toggleMassFileSelection(file.id)}
className="min-h-[20px] min-w-[20px] rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label={`Select ${file.name}`}
/>
</div>
<div className="truncate text-sm font-medium text-slate-800">
{file.name}
</div>
<div>{typeBadge(file.type)}</div>
<div>
{file.analysis ? (
<GradeBadge
grade={file.analysis.letterGrade}
percentage={file.analysis.percentage}
/>
) : (
<span className="text-xs text-slate-400">--</span>
)}
</div>
<div className="text-xs text-slate-500">
{file.lastAnalyzed
? new Date(file.lastAnalyzed).toLocaleString()
: '--'}
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => toggleRow(file.id)}
className="min-h-[44px] min-w-[44px] rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600"
aria-label={
expandedRows.has(file.id)
? 'Collapse details'
: 'Expand details'
}
>
<svg
className={`mx-auto h-4 w-4 transition-transform ${
expandedRows.has(file.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>
</div>
{/* Expanded details */}
{expandedRows.has(file.id) && (
<div className="border-b border-slate-200 bg-slate-50 px-8 py-4">
{file.analysis ? (
<div className="space-y-3">
<div className="flex flex-wrap gap-4">
<div>
<span className="text-xs font-medium text-slate-500">
Score
</span>
<p className="text-sm text-slate-800">
{file.analysis.overallScore}/{file.analysis.maxScore}{' '}
({file.analysis.percentage}%)
</p>
</div>
<div>
<span className="text-xs font-medium text-slate-500">
Lines
</span>
<p className="text-sm text-slate-800">
{file.analysis.lineCount}
</p>
</div>
<div>
<span className="text-xs font-medium text-slate-500">
Suggestions
</span>
<p className="text-sm text-slate-800">
{file.analysis.suggestions.length}
</p>
</div>
</div>
{file.analysis.pros.length > 0 && (
<div>
<p className="text-xs font-medium text-green-600">
Strengths:
</p>
<ul className="mt-1 space-y-0.5">
{file.analysis.pros.slice(0, 3).map((p, i) => (
<li
key={i}
className="text-xs text-green-700"
>
+ {p}
</li>
))}
</ul>
</div>
)}
{file.analysis.cons.length > 0 && (
<div>
<p className="text-xs font-medium text-red-600">
Issues:
</p>
<ul className="mt-1 space-y-0.5">
{file.analysis.cons.slice(0, 3).map((c, i) => (
<li
key={i}
className="text-xs text-red-700"
>
- {c}
</li>
))}
</ul>
</div>
)}
</div>
) : (
<p className="text-xs text-slate-400">
Not yet analyzed. Select this file and click
"Analyze All".
</p>
)}
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => analyzeFile(file.id, file.content)}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-white"
>
Analyze
</button>
<button
type="button"
onClick={() => removeMassFile(file.id)}
className="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
>
Remove
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}