'use client'; import { useState, useEffect, useCallback } from 'react'; import { useSession, signIn } from 'next-auth/react'; import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'; import { fetchWithCsrf } from '@/lib/csrf-client'; const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false'; const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; // Validate GitHub URL format function isValidGitHubUrl(url: string): boolean { try { const urlObj = new URL(url); return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2; } catch { return false; } } interface ClaimFormProps { translations: { title: string; subtitle: string; loginRequired: string; signIn: string; optional: string; mirror: { title: string; description: string; button: string; }; tabs: { remove: string; add: string; }; form: { skillId: string; skillIdPlaceholder: string; skillIdHelp: string; reason: string; reasonPlaceholder: string; submit: string; submitting: string; }; addForm: { repositoryUrl: string; repositoryUrlPlaceholder: string; repositoryUrlHelp: string; reason: string; reasonPlaceholder: string; submit: string; submitting: string; }; success: { title: string; description: string; pendingTitle: string; pendingDescription: string; viewRequests: string; }; addSuccess: { title: string; description: string; descriptionNoSkillMd: string; descriptionMultiplePrefix: string; descriptionMultipleSuffix: string; viewRequests: string; foundSkillsIn: string; root: string; andMore: string; }; error: { notOwner: string; skillNotFound: string; alreadyPending: string; githubError: string; invalidSkill: string; invalidUrl: string; invalidRepo: string; rateLimitExceeded: string; networkTimeout: string; generic: string; }; myRequests: { title: string; empty: string; status: { pending: string; approved: string; rejected: string; indexed: string; }; skillsFoundPrefix: string; skillsFoundSuffix: string; showLess: string; showAllPrefix: string; showAllSuffix: string; }; }; } interface RemovalRequest { id: string; skillId: string; reason: string; status: string; createdAt: string; } interface AddRequest { id: string; repositoryUrl: string; skillPath: string | null; reason: string; status: string; hasSkillMd: boolean; createdAt: string; indexedSkillId: string | null; errorMessage: string | null; } export function ClaimForm({ translations }: ClaimFormProps) { const { data: session, status } = useSession(); const [activeTab, setActiveTab] = useState<'remove' | 'add'>('add'); // Default to 'add' tab const [expandedRequests, setExpandedRequests] = useState>(new Set()); // Read tab from URL hash on mount and update on hash change useEffect(() => { const readHashTab = () => { const hash = window.location.hash.slice(1); if (hash === 'remove' || hash === 'add') { setActiveTab(hash); } }; readHashTab(); window.addEventListener('hashchange', readHashTab); return () => window.removeEventListener('hashchange', readHashTab); }, []); // Update URL hash when tab changes const handleTabChange = useCallback((tab: 'remove' | 'add') => { setActiveTab(tab); window.history.replaceState(null, '', `#${tab}`); }, []); // Toggle expanded state for a request const toggleExpanded = useCallback((requestId: string) => { setExpandedRequests(prev => { const next = new Set(prev); if (next.has(requestId)) { next.delete(requestId); } else { next.add(requestId); } return next; }); }, []); // Remove form state const [skillId, setSkillId] = useState(''); const [removeReason, setRemoveReason] = useState(''); const [isSubmittingRemove, setIsSubmittingRemove] = useState(false); const [removeError, setRemoveError] = useState(''); const [removeSuccess, setRemoveSuccess] = useState(false); const [removePending, setRemovePending] = useState(false); const [removalRequests, setRemovalRequests] = useState([]); const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false); // Add form state const [repositoryUrl, setRepositoryUrl] = useState(''); const [repositoryUrlError, setRepositoryUrlError] = useState(''); const [addReason, setAddReason] = useState(''); const [isSubmittingAdd, setIsSubmittingAdd] = useState(false); const [addError, setAddError] = useState(''); const [addSuccess, setAddSuccess] = useState(false); const [addHasSkillMd, setAddHasSkillMd] = useState(true); const [addSkillCount, setAddSkillCount] = useState(0); const [addSkillPaths, setAddSkillPaths] = useState([]); const [addRequests, setAddRequests] = useState([]); const [loadingAddRequests, setLoadingAddRequests] = useState(false); // Fetch user's existing requests useEffect(() => { if (session) { fetchRemovalRequests(); fetchAddRequests(); } }, [session]); const fetchRemovalRequests = async () => { setLoadingRemovalRequests(true); try { const res = await fetch('/api/skills/removal-request'); if (res.ok) { const data = await res.json(); setRemovalRequests(data.requests || []); } } catch { // Ignore errors } finally { setLoadingRemovalRequests(false); } }; const fetchAddRequests = async () => { setLoadingAddRequests(true); try { const res = await fetch('/api/skills/add-request'); if (res.ok) { const data = await res.json(); setAddRequests(data.requests || []); } } catch { // Ignore errors } finally { setLoadingAddRequests(false); } }; const handleRemoveSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!session) { signIn('github'); return; } if (!skillId.trim()) { return; } setIsSubmittingRemove(true); setRemoveError(''); setRemoveSuccess(false); try { const res = await fetchWithCsrf('/api/skills/removal-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skillId: skillId.trim(), reason: removeReason.trim() }), }); const data = await res.json(); if (!res.ok) { switch (data.code) { case 'NOT_OWNER': setRemoveError(translations.error.notOwner); break; case 'SKILL_NOT_FOUND': setRemoveError(translations.error.skillNotFound); break; case 'ALREADY_PENDING': setRemoveError(translations.error.alreadyPending); break; case 'GITHUB_ERROR': setRemoveError(translations.error.githubError); break; case 'INVALID_SKILL': setRemoveError(translations.error.invalidSkill); break; case 'AUTH_REQUIRED': signIn('github'); return; default: console.error('Claim form error:', data); setRemoveError(translations.error.generic); } return; } setRemoveSuccess(true); setRemovePending(data.pending || false); setSkillId(''); setRemoveReason(''); fetchRemovalRequests(); } catch { setRemoveError(translations.error.generic); } finally { setIsSubmittingRemove(false); } }; const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!session) { signIn('github'); return; } if (!repositoryUrl.trim()) { return; } // Client-side validation if (!isValidGitHubUrl(repositoryUrl.trim())) { setAddError(translations.error.invalidUrl); return; } setIsSubmittingAdd(true); setAddError(''); setAddSuccess(false); try { const res = await fetchWithCsrf('/api/skills/add-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repositoryUrl: repositoryUrl.trim(), reason: addReason.trim() || 'No reason provided', }), }); const data = await res.json(); if (!res.ok) { switch (data.code) { case 'INVALID_URL': setAddError(translations.error.invalidUrl); break; case 'INVALID_REPO': setAddError(translations.error.invalidRepo); break; case 'RATE_LIMIT_EXCEEDED': setAddError(translations.error.rateLimitExceeded); break; case 'NETWORK_TIMEOUT': setAddError(translations.error.networkTimeout); break; case 'ALREADY_PENDING': setAddError(translations.error.alreadyPending); break; case 'AUTH_REQUIRED': signIn('github'); return; default: console.error('Add form error:', data); setAddError(translations.error.generic); } return; } setAddSuccess(true); setAddHasSkillMd(data.hasSkillMd ?? true); setAddSkillCount(data.skillCount ?? 0); setAddSkillPaths(data.skillPaths ?? []); setRepositoryUrl(''); setAddReason(''); fetchAddRequests(); } catch { setAddError(translations.error.generic); } finally { setIsSubmittingAdd(false); } }; const getStatusBadge = (requestStatus: string) => { switch (requestStatus) { case 'pending': return ( {translations.myRequests.status.pending} ); case 'approved': return ( {translations.myRequests.status.approved} ); case 'rejected': return ( {translations.myRequests.status.rejected} ); case 'indexed': return ( {translations.myRequests.status.indexed} ); default: return null; } }; // Loading state if (status === 'loading') { return (
); } // Mirror server - redirect to primary if (isMirror) { return (

{translations.mirror.title}

{translations.mirror.description}

{translations.mirror.button}
); } // Not logged in if (!session) { return (

{translations.loginRequired}

); } // Remove success state if (removeSuccess) { return (

{removePending ? translations.success.pendingTitle : translations.success.title}

{removePending ? translations.success.pendingDescription : translations.success.description}

); } // Add success state if (addSuccess) { // Determine which message to show let successDescription: string | React.ReactNode; if (addSkillCount > 1) { successDescription = ( <> {translations.addSuccess.descriptionMultiplePrefix} {addSkillCount} {translations.addSuccess.descriptionMultipleSuffix} ); } else if (addHasSkillMd) { successDescription = translations.addSuccess.description; } else { successDescription = translations.addSuccess.descriptionNoSkillMd; } return (

{translations.addSuccess.title}

{successDescription}

{addSkillCount > 1 && addSkillPaths.length > 0 && (

{translations.addSuccess.foundSkillsIn}

    {addSkillPaths.slice(0, 10).map((path, index) => (
  • {path === '' ? translations.addSuccess.root : path}
  • ))} {addSkillPaths.length > 10 && (
  • {translations.addSuccess.andMore.replace('{count}', String(addSkillPaths.length - 10))}
  • )}
)}
); } return (
{/* Tabs */}
{/* Remove Form */} {activeTab === 'remove' && ( <>
setSkillId(e.target.value)} placeholder={translations.form.skillIdPlaceholder} className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500" dir="ltr" required />

{translations.form.skillIdHelp}