← 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
                      &quot;Analyze All&quot;.
                    </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>
  )
}