← back to Patty

components/sources/SourcesTab.tsx

305 lines

'use client';

import { useState, useEffect } from 'react';
import { Loader2, Radio, ExternalLink, Edit2, Check, X, Trash2, Plus, AlertCircle } from 'lucide-react';

const S = {
  bg:'#0E0E10', surface:'#141419', surfaceEl:'#1C1C24', border:'#2A2A35',
  cyan:'#00F0FF', cyanDim:'rgba(0,240,255,0.12)', green:'#00C389',
  red:'#FF5C5C', orange:'#F7931A', yellow:'#FBBF24', blue:'#3B82F6',
  text:'#E0E0E0', textDim:'#A8A8A8', textMuted:'#6B7280',
  fontHead:"'Space Grotesk', system-ui, sans-serif",
  fontMono:"'JetBrains Mono', monospace",
  rSm:'6px', rMd:'10px',
};

const STATUS_COLOR: Record<string,string> = {
  healthy:'#00C389', stale:'#FBBF24', error:'#F7931A', failing:'#FF5C5C',
  dead:'#6B7280', never_fetched:'#374151',
};

const CAT_COLORS: Record<string,string> = {
  advocacy:'#A855F7', blog:'#3B82F6', economy:'#F7931A', government:'#64748B',
  local_tv:'#EC4899', national_tv:'#EF4444', newspaper:'#14B8A6',
  tech:'#00F0FF', health:'#00C389', social:'#8B5CF6', wire:'#F59E0B',
  substack:'#06B6D4', magazine:'#F97316',
};

const ALL_CATS = ['advocacy','blog','economy','government','health','local_tv','magazine','national_tv','newspaper','social','substack','tech','wire'];

function catColor(cat:string){return CAT_COLORS[cat]||S.textMuted;}

interface Feed {
  id:string; name:string; url:string; category:string; is_active:boolean;
  health_status:string; article_count:number; error_count:number;
  last_error:string|null; last_fetched_at:string|null; avg_articles_per_day:number|null;
}
interface EditState{id:string;name:string;url:string;category:string;}

export default function SourcesTab() {
  const [feeds,setFeeds]=useState<Feed[]>([]);
  const [loading,setLoading]=useState(true);
  const [filterCat,setFilterCat]=useState('all');
  const [filterStatus,setFilterStatus]=useState('all');
  const [search,setSearch]=useState('');
  const [editing,setEditing]=useState<EditState|null>(null);
  const [saving,setSaving]=useState<string|null>(null);
  const [addMode,setAddMode]=useState(false);
  const [newFeed,setNewFeed]=useState({name:'',url:'',category:'blog'});
  const [err,setErr]=useState<string|null>(null);
  const [collapsed,setCollapsed]=useState<Record<string,boolean>>({});

  async function load(){
    setLoading(true);
    try{const r=await fetch('/api/pulse/feeds');if(r.ok)setFeeds(await r.json());}catch{}
    setLoading(false);
  }
  useEffect(()=>{load();},[]);

  const categories=[...new Set(feeds.map(f=>f.category))].sort();
  const filtered=feeds.filter(f=>{
    if(filterCat!=='all'&&f.category!==filterCat)return false;
    const st=f.health_status||'never_fetched';
    if(filterStatus!=='all'&&st!==filterStatus)return false;
    if(search){const q=search.toLowerCase();if(!f.name.toLowerCase().includes(q)&&!f.url.toLowerCase().includes(q))return false;}
    return true;
  });
  const grouped:Record<string,Feed[]>={};
  for(const f of filtered){if(!grouped[f.category])grouped[f.category]=[];grouped[f.category].push(f);}

  async function saveEdit(){
    if(!editing)return;
    setSaving(editing.id);
    try{
      const r=await fetch('/api/pulse/feeds/'+editing.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:editing.name,url:editing.url,category:editing.category})});
      if(!r.ok)throw new Error((await r.json()).error);
      await load();setEditing(null);
    }catch(e:any){setErr(e.message);}
    setSaving(null);
  }
  async function toggleActive(f:Feed){
    setSaving(f.id);
    try{await fetch('/api/pulse/feeds/'+f.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:!f.is_active})});await load();}catch{}
    setSaving(null);
  }
  async function deleteFeed(id:string){
    if(!confirm('Delete this feed?'))return;
    try{await fetch('/api/pulse/feeds/'+id,{method:'DELETE'});await load();}catch{}
  }
  async function addFeed(){
    if(!newFeed.name||!newFeed.url)return;
    try{
      const r=await fetch('/api/pulse/feeds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(newFeed)});
      if(!r.ok)throw new Error((await r.json()).error);
      setNewFeed({name:'',url:'',category:'blog'});setAddMode(false);await load();
    }catch(e:any){setErr(e.message);}
  }

  const totalActive=feeds.filter(f=>f.is_active).length;
  const totalHealthy=feeds.filter(f=>f.health_status==='healthy').length;
  const totalError=feeds.filter(f=>f.health_status==='error'||f.health_status==='failing').length;

  if(loading)return(
    <div style={{padding:40,display:'flex',alignItems:'center',gap:10,color:S.textMuted,fontFamily:S.fontMono}}>
      <Loader2 size={16} style={{animation:'spin 1s linear infinite'}}/>Loading sources…
    </div>
  );

  return(
    <div style={{fontFamily:S.fontHead,color:S.text,paddingBottom:40}}>
      {/* Header */}
      <div style={{padding:'20px 24px 16px',display:'flex',alignItems:'flex-start',gap:16,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
        <div style={{flex:1}}>
          <div style={{display:'flex',alignItems:'center',gap:10}}>
            <Radio size={18} color={S.cyan}/>
            <span style={{fontSize:18,fontWeight:700}}>Sources</span>
            <span style={{fontFamily:S.fontMono,fontSize:11,color:S.cyan,background:S.cyanDim,padding:'2px 8px',borderRadius:4,border:`1px solid ${S.cyan}44`}}>{feeds.length} feeds</span>
          </div>
          <div style={{display:'flex',gap:20,marginTop:6,fontSize:12,color:S.textMuted}}>
            <span>Active: <strong style={{color:S.green}}>{totalActive}</strong></span>
            <span>Healthy: <strong style={{color:S.green}}>{totalHealthy}</strong></span>
            <span>Error: <strong style={{color:S.red}}>{totalError}</strong></span>
            <span>Inactive: <strong style={{color:S.textMuted}}>{feeds.filter(f=>!f.is_active).length}</strong></span>
          </div>
        </div>
        <button onClick={()=>{setAddMode(!addMode);setErr(null);}}
          style={{display:'flex',alignItems:'center',gap:6,padding:'7px 14px',background:addMode?S.cyanDim:'transparent',border:`1px solid ${addMode?S.cyan:S.border}`,borderRadius:S.rSm,color:addMode?S.cyan:S.textDim,cursor:'pointer',fontSize:12,fontWeight:600}}>
          <Plus size={14}/> Add Feed
        </button>
      </div>

      {/* Error */}
      {err&&(
        <div style={{margin:'12px 24px',padding:'8px 12px',background:'rgba(255,92,92,0.1)',border:`1px solid ${S.red}44`,borderRadius:S.rSm,fontSize:12,color:S.red,display:'flex',gap:8,alignItems:'center'}}>
          <AlertCircle size={13}/>{err}
          <button onClick={()=>setErr(null)} style={{marginLeft:'auto',background:'none',border:'none',color:S.red,cursor:'pointer'}}><X size={13}/></button>
        </div>
      )}

      {/* Add Form */}
      {addMode&&(
        <div style={{margin:'12px 24px',padding:16,background:S.surfaceEl,border:`1px solid ${S.cyan}44`,borderRadius:S.rMd}}>
          <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:10,alignItems:'end'}}>
            <div>
              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Name</div>
              <input value={newFeed.name} onChange={e=>setNewFeed(p=>({...p,name:e.target.value}))} placeholder="Source name"
                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}/>
            </div>
            <div>
              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>RSS URL</div>
              <input value={newFeed.url} onChange={e=>setNewFeed(p=>({...p,url:e.target.value}))} placeholder="https://example.com/feed.rss"
                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none',boxSizing:'border-box' as any}}/>
            </div>
            <div>
              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Category</div>
              <select value={newFeed.category} onChange={e=>setNewFeed(p=>({...p,category:e.target.value}))}
                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}>
                {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
              </select>
            </div>
            <div style={{display:'flex',gap:6}}>
              <button onClick={addFeed} disabled={!newFeed.name||!newFeed.url}
                style={{padding:'6px 14px',background:S.cyan,color:S.bg,border:'none',borderRadius:S.rSm,fontSize:12,fontWeight:700,cursor:'pointer',opacity:(!newFeed.name||!newFeed.url)?0.4:1}}>Add</button>
              <button onClick={()=>setAddMode(false)}
                style={{padding:'6px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',fontSize:12}}>Cancel</button>
            </div>
          </div>
        </div>
      )}

      {/* Status Filters */}
      <div style={{padding:'10px 24px',display:'flex',gap:8,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`,alignItems:'center'}}>
        {(['all','healthy','stale','error','failing','dead','never_fetched'] as const).map(s=>{
          const col=s==='all'?S.cyan:(STATUS_COLOR[s]||S.textMuted);
          const cnt=s==='all'?feeds.length:feeds.filter(f=>(f.health_status||'never_fetched')===s).length;
          if(s!=='all'&&cnt===0)return null;
          return(
            <button key={s} onClick={()=>setFilterStatus(s)}
              style={{padding:'3px 10px',borderRadius:20,fontSize:11,cursor:'pointer',fontFamily:S.fontMono,border:filterStatus===s?`1px solid ${col}`:`1px solid ${S.border}`,background:filterStatus===s?col+'22':'transparent',color:filterStatus===s?col:S.textMuted}}>
              {s.replace(/_/g,' ')} ({cnt})
            </button>
          );
        })}
        <input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search name or URL…"
          style={{marginLeft:'auto',padding:'4px 10px',background:S.surfaceEl,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,fontFamily:S.fontMono,outline:'none',width:220}}/>
      </div>

      {/* Category Pills */}
      <div style={{padding:'10px 24px',display:'flex',gap:6,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
        <button onClick={()=>setFilterCat('all')}
          style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat==='all'?`1px solid ${S.cyan}`:`1px solid ${S.border}`,background:filterCat==='all'?S.cyanDim:'transparent',color:filterCat==='all'?S.cyan:S.textMuted}}>
          All
        </button>
        {categories.map(cat=>{
          const col=catColor(cat);
          const cnt=feeds.filter(f=>f.category===cat).length;
          return(
            <button key={cat} onClick={()=>setFilterCat(filterCat===cat?'all':cat)}
              style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat===cat?`1px solid ${col}`:`1px solid ${S.border}`,background:filterCat===cat?col+'22':'transparent',color:filterCat===cat?col:S.textMuted}}>
              {cat} ({cnt})
            </button>
          );
        })}
      </div>

      {/* Feed Groups */}
      <div style={{padding:'16px 24px 0'}}>
        {Object.keys(grouped).sort().map(cat=>{
          const isCol=!!collapsed[cat];
          const col=catColor(cat);
          const catFeeds=grouped[cat];
          return(
            <div key={cat} style={{marginBottom:12,background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rMd,overflow:'hidden'}}>
              <div onClick={()=>setCollapsed(p=>({...p,[cat]:!p[cat]}))}
                style={{padding:'10px 16px',display:'flex',alignItems:'center',gap:10,cursor:'pointer',userSelect:'none',borderBottom:isCol?'none':`1px solid ${S.border}`}}>
                <div style={{width:8,height:8,borderRadius:'50%',background:col,flexShrink:0}}/>
                <span style={{fontSize:13,fontWeight:700,flex:1,textTransform:'capitalize'}}>{cat}</span>
                <span style={{fontFamily:S.fontMono,fontSize:11,color:S.textMuted}}>{catFeeds.length} feeds</span>
                <span style={{fontSize:10,color:S.textMuted,display:'inline-block',transform:isCol?'rotate(-90deg)':'rotate(0deg)',transition:'transform 0.2s'}}>▼</span>
              </div>
              {!isCol&&(
                <div>
                  {catFeeds.map((f,idx)=>{
                    const isEdit=editing?.id===f.id;
                    const isSave=saving===f.id;
                    const sCol=STATUS_COLOR[f.health_status||'never_fetched']||S.textMuted;
                    return(
                      <div key={f.id} style={{padding:'10px 16px',background:isEdit?S.surfaceEl:'transparent',borderBottom:idx<catFeeds.length-1?`1px solid ${S.border}22`:'none'}}>
                        {isEdit?(
                          <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:8,alignItems:'center'}}>
                            <input value={editing.name} onChange={e=>setEditing(p=>p&&({...p,name:e.target.value}))}
                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none'}}/>
                            <input value={editing.url} onChange={e=>setEditing(p=>p&&({...p,url:e.target.value}))}
                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none'}}/>
                            <select value={editing.category} onChange={e=>setEditing(p=>p&&({...p,category:e.target.value}))}
                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:11,outline:'none'}}>
                              {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
                            </select>
                            <div style={{display:'flex',gap:6}}>
                              <button onClick={saveEdit} disabled={isSave}
                                style={{padding:'5px 10px',background:S.green+'22',border:`1px solid ${S.green}`,borderRadius:S.rSm,color:S.green,cursor:'pointer',display:'flex',alignItems:'center'}}>
                                {isSave?<Loader2 size={12} style={{animation:'spin 1s linear infinite'}}/>:<Check size={12}/>}
                              </button>
                              <button onClick={()=>setEditing(null)}
                                style={{padding:'5px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
                                <X size={12}/>
                              </button>
                            </div>
                          </div>
                        ):(
                          <div style={{display:'grid',gridTemplateColumns:'20px 1fr 2fr auto',gap:12,alignItems:'center'}}>
                            <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:4}}>
                              <div style={{width:7,height:7,borderRadius:'50%',background:sCol}} title={f.health_status||'never_fetched'}/>
                              <div onClick={()=>toggleActive(f)} title={f.is_active?'Active — click to disable':'Inactive — click to enable'}
                                style={{width:20,height:11,borderRadius:6,background:f.is_active?S.green+'44':S.border,cursor:'pointer',position:'relative',transition:'background 0.2s'}}>
                                <div style={{position:'absolute',top:2,right:f.is_active?2:undefined,left:f.is_active?undefined:2,width:7,height:7,borderRadius:'50%',background:f.is_active?S.green:S.textMuted,transition:'all 0.2s'}}/>
                              </div>
                            </div>
                            <div style={{overflow:'hidden',minWidth:0}}>
                              <div style={{fontSize:13,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.name}</div>
                              <div style={{display:'flex',gap:12,marginTop:2,fontSize:10,color:S.textMuted,fontFamily:S.fontMono}}>
                                <span>{(f.article_count??0).toLocaleString()} arts</span>
                                {(f.error_count??0)>0&&<span style={{color:S.red}}>{f.error_count} err</span>}
                                {f.avg_articles_per_day!=null&&<span>{f.avg_articles_per_day.toFixed(1)}/day</span>}
                              </div>
                              {f.last_error&&<div style={{fontSize:10,color:S.red,marginTop:2,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.last_error}</div>}
                            </div>
                            <div style={{overflow:'hidden',minWidth:0}}>
                              <a href={f.url} target="_blank" rel="noopener noreferrer"
                                style={{fontSize:11,color:S.cyan,fontFamily:S.fontMono,textDecoration:'none',display:'flex',alignItems:'center',gap:4,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}} title={f.url}>
                                <ExternalLink size={10} style={{flexShrink:0}}/>{f.url}
                              </a>
                              {f.last_fetched_at&&(
                                <div style={{fontSize:10,color:S.textMuted,marginTop:2}}>
                                  Last: {new Date(f.last_fetched_at).toLocaleString('en-US',{timeZone:'America/Los_Angeles',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',hour12:true})} PT
                                </div>
                              )}
                            </div>
                            <div style={{display:'flex',gap:5}}>
                              <button onClick={()=>{setEditing({id:f.id,name:f.name,url:f.url,category:f.category});setErr(null);}} title="Edit"
                                style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
                                <Edit2 size={12}/>
                              </button>
                              <button onClick={()=>deleteFeed(f.id)} title="Delete"
                                style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
                                <Trash2 size={12}/>
                              </button>
                            </div>
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          );
        })}
        {filtered.length===0&&(
          <div style={{padding:40,textAlign:'center',color:S.textMuted,fontFamily:S.fontMono,fontSize:13}}>No feeds match the current filters.</div>
        )}
      </div>
    </div>
  );
}