← 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>
);
}