Initial commit — Electron + React frontend, Express/Docker backend, members & project management

This commit is contained in:
Ryan Lancaster
2026-03-15 13:40:01 -07:00
commit a3949c32ee
24 changed files with 12583 additions and 0 deletions

504
index.html Normal file
View File

@@ -0,0 +1,504 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>⚡ Project Hub</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#090910;color:#e2e8f0;font-family:system-ui,sans-serif}
select,input{color-scheme:dark}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:#090910}
::-webkit-scrollbar-thumb{background:#1e2030;border-radius:3px}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const{useState,useEffect}=React;
// ── Dual storage: Claude artifacts → window.storage | Standalone → localStorage ──
const persist={
get:async k=>{try{if(window.storage)return await window.storage.get(k);const v=localStorage.getItem(k);return v?{value:v}:null}catch{try{const v=localStorage.getItem(k);return v?{value:v}:null}catch{return null}}},
set:async(k,v)=>{try{if(window.storage){await window.storage.set(k,v);return}localStorage.setItem(k,v)}catch{try{localStorage.setItem(k,v)}catch{}}}
};
// ── Inline SVG Icons ──
const Ico=({path,size=14,stroke="currentColor",...p})=><svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}} {...p}>{path}</svg>;
const Plus=p=><Ico {...p} path={<><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>}/>;
const X=p=><Ico {...p} path={<><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>}/>;
const Check=p=><Ico {...p} path={<polyline points="20 6 9 17 4 12"/>}/>;
const Search=p=><Ico {...p} path={<><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></>}/>;
const Calendar=p=><Ico {...p} path={<><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></>}/>;
const CheckSq=p=><Ico {...p} path={<><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></>}/>;
const Edit=p=><Ico {...p} path={<path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>}/>;
const Trash=p=><Ico {...p} path={<><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a1 1 0 011-1h4a1 1 0 011 1v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></>}/>;
const More=p=><Ico {...p} path={<><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></>}/>;
// ── Constants ──
const SKEY="pm_hub_v1";
const COLORS=["#6366f1","#8b5cf6","#ec4899","#f97316","#10b981","#3b82f6","#f59e0b","#ef4444"];
const STATUS={planning:{label:"Planning",color:"#f59e0b",bg:"rgba(245,158,11,0.12)"},active:{label:"Active",color:"#10b981",bg:"rgba(16,185,129,0.12)"},"on-hold":{label:"On Hold",color:"#f97316",bg:"rgba(249,115,22,0.12)"},completed:{label:"Completed",color:"#818cf8",bg:"rgba(129,140,248,0.12)"}};
const PRI={low:{label:"Low",color:"#10b981"},medium:{label:"Med",color:"#f59e0b"},high:{label:"High",color:"#ef4444"}};
const uid=()=>Math.random().toString(36).slice(2,9);
const prog=tasks=>!tasks.length?0:Math.round(tasks.filter(t=>t.status==="done").length/tasks.length*100);
const overdue=d=>d&&new Date(d)<new Date();
const fmt=d=>d?new Date(d).toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"}):"—";
// ── AuthentiPol project ──
const AP={id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA delivering 0-100 Authenticity Score from public data. Non-partisan Say vs Do vs Funded.",status:"active",color:"#6366f1",startDate:"2026-03-14",dueDate:"2026-06-12",
members:[{id:"am1",name:"You",role:"Founder / Product",initials:"YO"},{id:"am2",name:"AP Sidekick",role:"AI Orchestrator",initials:"AP"},{id:"am3",name:"Freelance RN Dev",role:"React Native (Upwork)",initials:"RN"},{id:"am4",name:"Content VA",role:"Content & Social (Fiverr)",initials:"VA"}],
tasks:[
{id:"at1",title:"Project setup: stack, repos, Supabase, Vercel, domain",status:"in-progress",priority:"high",dueDate:"2026-03-21",subtasks:[{id:"s1",title:"GitHub repo + CI/CD",done:false},{id:"s2",title:"Supabase project + .env secrets",done:false},{id:"s3",title:"authentiPol.com DNS/SSL via Cloudflare",done:false},{id:"s4",title:"Figma design system kickoff",done:false}]},
{id:"at2",title:"APIs & data sources: OpenFEC, Congress.gov, OpenSecrets",status:"todo",priority:"high",dueDate:"2026-03-28",subtasks:[{id:"s5",title:"OpenFEC API key + rate-limit upgrade",done:false},{id:"s6",title:"Congress.gov key via api.data.gov",done:false},{id:"s7",title:"OpenSecrets bulk CSV educational signup",done:false},{id:"s8",title:"unitedstates/congress-legislators clone",done:false},{id:"s9",title:"X API v2 OAuth setup",done:false}]},
{id:"at3",title:"ETL pipeline: Python nightly FEC/Congress pulls",status:"todo",priority:"high",dueDate:"2026-04-04",subtasks:[{id:"s10",title:"Seed Tier 1 politicians",done:false},{id:"s11",title:"Tier 2: 35 Senate 2026 races pipeline",done:false},{id:"s12",title:"Tier 4 viral: AOC + MTG",done:false}]},
{id:"at4",title:"Scoring engine: auditable 0-100 formula",status:"todo",priority:"high",dueDate:"2026-04-11",subtasks:[{id:"s13",title:"40% Funded alignment logic",done:false},{id:"s14",title:"35% Say/Do promise vs vote matching",done:false},{id:"s15",title:"25% consistency scoring",done:false},{id:"s16",title:"Red-flag: single-industry >$X + vote flip",done:false},{id:"s17",title:"Public methodology GitHub repo",done:false}]},
{id:"at5",title:"Legal & compliance",status:"todo",priority:"high",dueDate:"2026-04-07",subtasks:[{id:"s18",title:"GDPR/CCPA privacy policy",done:false},{id:"s19",title:"Terms of service (educational use)",done:false},{id:"s20",title:"LegalZoom trademark filing (~$300)",done:false},{id:"s21",title:"Cyber + liability insurance (~$500/yr)",done:false}]},
{id:"at6",title:"Website / PWA: Next.js 15 + Tailwind + shadcn",status:"todo",priority:"high",dueDate:"2026-04-25",subtasks:[{id:"s22",title:"Hero + live demo page",done:false},{id:"s23",title:"/[slug] full scorecard page",done:false},{id:"s24",title:"/about scoring math page",done:false},{id:"s25",title:"Widget iframe embed (/widgets)",done:false},{id:"s26",title:"SEO: schema.org Politician markup",done:false}]},
{id:"at7",title:"Mobile app: React Native + Expo features",status:"todo",priority:"medium",dueDate:"2026-05-09",subtasks:[{id:"s27",title:"Search + trending + alerts home",done:false},{id:"s28",title:"Sparklines + timeline profile view",done:false},{id:"s29",title:"Push notifications on FEC filing",done:false},{id:"s30",title:"Offline: last 50 via Expo SQLite",done:false}]},
{id:"at8",title:"Analytics & monitoring setup",status:"todo",priority:"medium",dueDate:"2026-04-18",subtasks:[{id:"s31",title:"PostHog self-hosted",done:false},{id:"s32",title:"Sentry + LogRocket session replays",done:false},{id:"s33",title:"Upstash Redis rate-limit caching",done:false}]},
{id:"at9",title:"Beta test: 200 users TestFlight + web",status:"todo",priority:"high",dueDate:"2026-05-30",subtasks:[{id:"s34",title:"Jest unit tests",done:false},{id:"s35",title:"Detox E2E tests",done:false},{id:"s36",title:"WCAG 2.1 AA accessibility audit",done:false},{id:"s37",title:"Load test: 10k concurrent",done:false}]},
{id:"at10",title:"Content & social: Airtable calendar, 3 pieces/week",status:"todo",priority:"medium",dueDate:"2026-04-21",subtasks:[{id:"s38",title:"Mon deep-dives / Wed myth-busters / Fri guides",done:false},{id:"s39",title:"Blog → X thread → Reel → LinkedIn pipeline",done:false},{id:"s40",title:"ConvertKit weekly digest setup",done:false}]},
{id:"at11",title:"Pre-launch marketing: press kit + 100 journalist DMs",status:"todo",priority:"medium",dueDate:"2026-06-05",subtasks:[{id:"s41",title:"Press kit PDF + widget code + CSV",done:false},{id:"s42",title:"Ballotpedia + civic-tech Slack partnerships",done:false},{id:"s43",title:"$5k X/TikTok paid targeting setup",done:false}]},
{id:"at12",title:"App Store submission",status:"todo",priority:"high",dueDate:"2026-06-08",subtasks:[{id:"s44",title:"Privacy nutrition label (zero tracking)",done:false},{id:"s45",title:"PWA-first fallback if rejection risk",done:false}]},
],
milestones:[
{id:"ms1",title:"Legal consult + methodology repo public",date:"2026-03-21",completed:false},
{id:"ms2",title:"ETL pipeline live — Tier 1 politicians scoring",date:"2026-04-11",completed:false},
{id:"ms3",title:"Scoring engine auditable + on GitHub",date:"2026-04-18",completed:false},
{id:"ms4",title:"PWA soft launch (Day -7)",date:"2026-06-05",completed:false},
{id:"ms5",title:"🚀 App Store + X live thread + email blast",date:"2026-06-12",completed:false},
{id:"ms6",title:"Press round-up + PostHog iteration (Day +7)",date:"2026-06-19",completed:false},
]
};
const SAMPLES=[
{id:"s1",name:"Website Redesign",description:"Full overhaul with new branding and improved UX",status:"active",color:"#8b5cf6",startDate:"2026-01-15",dueDate:"2026-04-30",
members:[{id:"m1",name:"Alice Chen",role:"Designer",initials:"AC"},{id:"m2",name:"Bob Smith",role:"Developer",initials:"BS"}],
tasks:[{id:"t1",title:"Wireframes",status:"done",priority:"high",dueDate:"2026-02-01",subtasks:[]},{id:"t2",title:"Design system",status:"done",priority:"high",dueDate:"2026-02-15",subtasks:[]},{id:"t3",title:"Frontend dev",status:"in-progress",priority:"high",dueDate:"2026-03-30",subtasks:[{id:"ss1",title:"Homepage",done:true},{id:"ss2",title:"About page",done:false}]},{id:"t4",title:"QA Testing",status:"todo",priority:"medium",dueDate:"2026-04-25",subtasks:[]}],
milestones:[{id:"ms1",title:"Design approval",date:"2026-02-20",completed:true},{id:"ms2",title:"Go live",date:"2026-04-30",completed:false}]},
{id:"s2",name:"Q2 Marketing Campaign",description:"Multi-channel campaign for Q2 product launch",status:"planning",color:"#ec4899",startDate:"2026-03-01",dueDate:"2026-05-31",
members:[{id:"m3",name:"Frank Johnson",role:"Marketing Lead",initials:"FJ"},{id:"m4",name:"Grace Kim",role:"Content Writer",initials:"GK"}],
tasks:[{id:"t5",title:"Strategy document",status:"done",priority:"high",dueDate:"2026-03-10",subtasks:[]},{id:"t6",title:"Content calendar",status:"in-progress",priority:"medium",dueDate:"2026-03-20",subtasks:[]},{id:"t7",title:"Ad creatives",status:"todo",priority:"high",dueDate:"2026-04-01",subtasks:[]}],
milestones:[{id:"ms3",title:"Campaign kickoff",date:"2026-04-01",completed:false}]},
];
// ── Styles helpers ──
const inp=(x={})=>({width:"100%",background:"#090910",border:"1px solid #1e2030",borderRadius:7,padding:"8px 11px",color:"#e2e8f0",fontSize:13,outline:"none",boxSizing:"border-box",...x});
const btn=(x={})=>({border:"none",borderRadius:7,cursor:"pointer",fontSize:13,fontWeight:600,...x});
// ════════════════════════════════════════════════════════════
// APP
// ════════════════════════════════════════════════════════════
function App(){
const[projects,setProjects]=useState([]);
const[loaded,setLoaded]=useState(false);
const[sel,setSel]=useState(null);
const[editing,setEditing]=useState(null);
const[tab,setTab]=useState("tasks");
const[search,setSearch]=useState("");
const[fSt,setFSt]=useState("all");
const[delId,setDelId]=useState(null);
useEffect(()=>{
(async()=>{
try{const r=await persist.get(SKEY);
if(r?.value){const saved=JSON.parse(r.value);const hasAP=saved.some(p=>p.id==="authentiapol");setProjects(hasAP?saved:[AP,...saved]);}
else setProjects([AP,...SAMPLES]);
}catch{setProjects([AP,...SAMPLES]);}
setLoaded(true);
})();
},[]);
useEffect(()=>{if(!loaded)return;(async()=>{try{await persist.set(SKEY,JSON.stringify(projects));}catch{}})();},[projects,loaded]);
const save=p=>{
if(p.id){setProjects(ps=>ps.map(x=>x.id===p.id?p:x));if(sel?.id===p.id)setSel(p);}
else{const n={...p,id:uid()};setProjects(ps=>[...ps,n]);}
setEditing(null);
};
const del=id=>{setProjects(ps=>ps.filter(p=>p.id!==id));if(sel?.id===id)setSel(null);setDelId(null);};
const change=u=>{setProjects(ps=>ps.map(p=>p.id===u.id?u:p));setSel(u);};
const filtered=projects.filter(p=>{
const ms=p.name.toLowerCase().includes(search.toLowerCase())||p.description.toLowerCase().includes(search.toLowerCase());
return ms&&(fSt==="all"||p.status===fSt);
});
const stats={total:projects.length,active:projects.filter(p=>p.status==="active").length,completed:projects.filter(p=>p.status==="completed").length,overdue:projects.filter(p=>overdue(p.dueDate)&&p.status!=="completed").length};
if(!loaded)return<div style={{background:"#090910",minHeight:"100vh",display:"flex",alignItems:"center",justifyContent:"center",color:"#6366f1",fontSize:16}}>Loading</div>;
return(
<div style={{background:"#090910",minHeight:"100vh"}}>
{/* Header */}
<div style={{borderBottom:"1px solid #181828",padding:"14px 24px",display:"flex",alignItems:"center",justifyContent:"space-between",background:"#0d0d1a",position:"sticky",top:0,zIndex:50}}>
<div>
<div style={{fontSize:20,fontWeight:800,color:"#f1f5f9"}}> Project Hub</div>
<div style={{fontSize:11,color:"#475569",marginTop:1}}>All your projects, one place</div>
</div>
<button onClick={()=>setEditing({name:"",description:"",status:"planning",color:COLORS[0],startDate:"",dueDate:"",members:[],tasks:[],milestones:[]})}
style={{...btn(),background:"#6366f1",color:"#fff",padding:"8px 15px",display:"flex",alignItems:"center",gap:6}}>
<Plus size={14}/> New Project
</button>
</div>
<div style={{padding:"22px 24px",maxWidth:1400,margin:"0 auto"}}>
{/* Stats */}
<div style={{display:"grid",gridTemplateColumns:"repeat(4,1fr)",gap:14,marginBottom:24}}>
{[{l:"Total",v:stats.total,c:"#818cf8",i:"📁"},{l:"Active",v:stats.active,c:"#10b981",i:"🚀"},{l:"Completed",v:stats.completed,c:"#a78bfa",i:"✅"},{l:"Overdue",v:stats.overdue,c:"#ef4444",i:"⚠️"}].map(s=>(
<div key={s.l} style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:12,padding:"16px 18px"}}>
<div style={{fontSize:22}}>{s.i}</div>
<div style={{fontSize:28,fontWeight:800,color:s.c,lineHeight:1,marginTop:6}}>{s.v}</div>
<div style={{fontSize:11,color:"#475569",marginTop:3}}>{s.l}</div>
</div>
))}
</div>
{/* Search + Filter */}
<div style={{display:"flex",gap:10,marginBottom:20,flexWrap:"wrap"}}>
<div style={{flex:1,minWidth:200,position:"relative"}}>
<Search size={13} style={{position:"absolute",left:11,top:"50%",transform:"translateY(-50%)",color:"#475569"}}/>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search projects…" style={{...inp(),paddingLeft:32}}/>
</div>
<div style={{display:"flex",gap:5,flexWrap:"wrap"}}>
{["all","planning","active","on-hold","completed"].map(s=>(
<button key={s} onClick={()=>setFSt(s)} style={{...btn(),padding:"7px 12px",fontSize:11,fontWeight:500,border:"1px solid",borderColor:fSt===s?"#6366f1":"#181828",background:fSt===s?"rgba(99,102,241,0.12)":"#0d0d1a",color:fSt===s?"#818cf8":"#64748b"}}>
{s==="all"?"All":STATUS[s]?.label||s}
</button>
))}
</div>
</div>
{/* Cards */}
{!filtered.length?(
<div style={{textAlign:"center",padding:"80px 0",color:"#475569"}}>
<div style={{fontSize:36,marginBottom:10}}>📋</div>
<div style={{fontSize:15,fontWeight:600,color:"#64748b"}}>No projects found</div>
</div>
):(
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(310px,1fr))",gap:18}}>
{filtered.map(p=><Card key={p.id} project={p} onOpen={()=>{setSel(p);setTab("tasks");}} onEdit={()=>setEditing(p)} onDel={()=>setDelId(p.id)}/>)}
</div>
)}
</div>
{sel&&<Panel project={sel} tab={tab} setTab={setTab} onClose={()=>setSel(null)} onEdit={()=>setEditing(sel)} onChange={change}/>}
{editing!==null&&<FormModal project={editing} onSave={save} onClose={()=>setEditing(null)}/>}
{delId&&(
<Overlay>
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:14,padding:26,width:340}}>
<div style={{fontSize:16,fontWeight:700,color:"#f1f5f9",marginBottom:6}}>Delete project?</div>
<div style={{fontSize:13,color:"#64748b",marginBottom:20}}>This cannot be undone.</div>
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
<button onClick={()=>setDelId(null)} style={{...btn(),padding:"8px 14px",background:"transparent",border:"1px solid #181828",color:"#94a3b8",fontWeight:500}}>Cancel</button>
<button onClick={()=>del(delId)} style={{...btn(),padding:"8px 14px",background:"#ef4444",color:"#fff"}}>Delete</button>
</div>
</div>
</Overlay>
)}
</div>
);
}
// ── Card ──
function Card({project:p,onOpen,onEdit,onDel}){
const pr=prog(p.tasks),s=STATUS[p.status]||STATUS.planning,ov=overdue(p.dueDate)&&p.status!=="completed";
const[menu,setMenu]=useState(false);
return(
<div onClick={onOpen} onMouseEnter={e=>e.currentTarget.style.borderColor=p.color} onMouseLeave={e=>e.currentTarget.style.borderColor="#181828"}
style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:14,padding:"18px 18px 16px",cursor:"pointer",transition:"border-color 0.2s",position:"relative",overflow:"hidden"}}>
<div style={{position:"absolute",top:0,left:0,right:0,height:3,background:p.color,borderRadius:"14px 14px 0 0"}}/>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginTop:2}}>
<div style={{flex:1,minWidth:0}}>
<div style={{fontSize:15,fontWeight:700,color:"#f1f5f9",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.name}</div>
<div style={{fontSize:11,color:"#475569",marginTop:3,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.description}</div>
</div>
<div style={{position:"relative",marginLeft:8}} onClick={e=>e.stopPropagation()}>
<button onClick={()=>setMenu(!menu)} style={{...btn(),background:"transparent",color:"#475569",padding:4,lineHeight:1}}><More size={15}/></button>
{menu&&(
<div style={{position:"absolute",right:0,top:"100%",background:"#181828",border:"1px solid #252540",borderRadius:8,padding:4,zIndex:10,width:110}}>
<button onClick={()=>{onEdit();setMenu(false);}} style={{...btn(),width:"100%",background:"none",color:"#94a3b8",padding:"7px 10px",textAlign:"left",fontWeight:500,display:"flex",alignItems:"center",gap:7,fontSize:12}}><Edit size={11}/>Edit</button>
<button onClick={()=>{onDel();setMenu(false);}} style={{...btn(),width:"100%",background:"none",color:"#ef4444",padding:"7px 10px",textAlign:"left",fontWeight:500,display:"flex",alignItems:"center",gap:7,fontSize:12}}><Trash size={11}/>Delete</button>
</div>
)}
</div>
</div>
<div style={{display:"inline-flex",alignItems:"center",gap:5,marginTop:10,padding:"3px 9px",borderRadius:999,background:s.bg,color:s.color,fontSize:10,fontWeight:700}}>
<div style={{width:5,height:5,borderRadius:"50%",background:s.color}}/>{s.label}{ov&&<span style={{color:"#ef4444",marginLeft:3}}>· Overdue</span>}
</div>
<div style={{marginTop:14}}>
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#475569",marginBottom:5}}><span>Progress</span><span style={{color:p.color,fontWeight:700}}>{pr}%</span></div>
<div style={{height:4,background:"#181828",borderRadius:999}}><div style={{height:"100%",width:`${pr}%`,background:p.color,borderRadius:999,transition:"width 0.4s"}}/></div>
</div>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginTop:14}}>
<div style={{display:"flex",gap:10,fontSize:11,color:"#475569"}}>
<span style={{display:"flex",alignItems:"center",gap:3}}><CheckSq size={10}/>{p.tasks.filter(t=>t.status==="done").length}/{p.tasks.length}</span>
<span style={{display:"flex",alignItems:"center",gap:3,color:ov?"#ef4444":"#475569"}}><Calendar size={10}/>{fmt(p.dueDate)}</span>
</div>
<div style={{display:"flex"}}>
{p.members.slice(0,3).map((m,i)=><div key={m.id} style={{width:22,height:22,borderRadius:"50%",background:p.color,color:"#fff",fontSize:8,fontWeight:700,display:"flex",alignItems:"center",justifyContent:"center",marginLeft:i>0?-5:0,border:"2px solid #0d0d1a"}}>{m.initials}</div>)}
{p.members.length>3&&<div style={{width:22,height:22,borderRadius:"50%",background:"#181828",color:"#64748b",fontSize:8,fontWeight:700,display:"flex",alignItems:"center",justifyContent:"center",marginLeft:-5,border:"2px solid #0d0d1a"}}>+{p.members.length-3}</div>}
</div>
</div>
</div>
);
}
// ── Detail Panel ──
function Panel({project:p,tab,setTab,onClose,onEdit,onChange}){
const pr=prog(p.tasks),s=STATUS[p.status]||STATUS.planning;
const upTask=(id,u)=>onChange({...p,tasks:p.tasks.map(t=>t.id===id?{...t,...u}:t)});
const addTask=t=>onChange({...p,tasks:[...p.tasks,{...t,id:uid(),subtasks:[]}]});
const delTask=id=>onChange({...p,tasks:p.tasks.filter(t=>t.id!==id)});
const togMs=id=>onChange({...p,milestones:p.milestones.map(m=>m.id===id?{...m,completed:!m.completed}:m)});
const addMs=m=>onChange({...p,milestones:[...p.milestones,{...m,id:uid(),completed:false}]});
const delMs=id=>onChange({...p,milestones:p.milestones.filter(m=>m.id!==id)});
return(
<div style={{position:"fixed",inset:0,zIndex:100,display:"flex"}}>
<div style={{flex:1,background:"rgba(0,0,0,0.55)"}} onClick={onClose}/>
<div style={{width:540,background:"#090910",borderLeft:"1px solid #181828",overflowY:"auto",display:"flex",flexDirection:"column"}}>
<div style={{padding:"18px 22px",borderBottom:"1px solid #181828",position:"sticky",top:0,background:"#090910",zIndex:10}}>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between"}}>
<div style={{display:"flex",alignItems:"center",gap:8}}><div style={{width:9,height:9,borderRadius:"50%",background:p.color}}/><div style={{fontSize:16,fontWeight:700,color:"#f1f5f9"}}>{p.name}</div></div>
<div style={{display:"flex",gap:6}}>
<button onClick={onEdit} style={{...btn(),background:"#181828",color:"#94a3b8",padding:"5px 11px",display:"flex",alignItems:"center",gap:5,fontSize:12,fontWeight:500}}><Edit size={11}/>Edit</button>
<button onClick={onClose} style={{...btn(),background:"transparent",color:"#64748b",padding:5}}><X size={17}/></button>
</div>
</div>
<div style={{fontSize:12,color:"#475569",marginTop:5}}>{p.description}</div>
<div style={{display:"flex",gap:10,marginTop:10,flexWrap:"wrap",alignItems:"center"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:4,padding:"2px 9px",borderRadius:999,background:s.bg,color:s.color,fontSize:10,fontWeight:700}}><div style={{width:5,height:5,borderRadius:"50%",background:s.color}}/>{s.label}</span>
{p.startDate&&<span style={{fontSize:11,color:"#475569",display:"flex",alignItems:"center",gap:4}}><Calendar size={10}/>{fmt(p.startDate)} {fmt(p.dueDate)}</span>}
</div>
<div style={{marginTop:12}}>
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#475569",marginBottom:5}}><span>Progress</span><span style={{color:p.color,fontWeight:700}}>{pr}%</span></div>
<div style={{height:5,background:"#181828",borderRadius:999}}><div style={{height:"100%",width:`${pr}%`,background:p.color,borderRadius:999}}/></div>
</div>
<div style={{display:"flex",gap:2,marginTop:14}}>
{["tasks","milestones","team"].map(t=>(
<button key={t} onClick={()=>setTab(t)} style={{...btn(),padding:"6px 14px",fontSize:12,fontWeight:500,background:tab===t?"rgba(99,102,241,0.12)":"transparent",color:tab===t?"#818cf8":"#64748b"}}>
{t.charAt(0).toUpperCase()+t.slice(1)} <span style={{marginLeft:3,background:"#181828",padding:"1px 6px",borderRadius:999,fontSize:10}}>{t==="tasks"?p.tasks.length:t==="milestones"?p.milestones.length:p.members.length}</span>
</button>
))}
</div>
</div>
<div style={{padding:"18px 22px",flex:1}}>
{tab==="tasks"&&<TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask}/>}
{tab==="milestones"&&<MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs}/>}
{tab==="team"&&<TeamTab project={p} onChange={onChange}/>}
</div>
</div>
</div>
);
}
// ── Tasks ──
function TasksTab({project:p,onUpdate,onAdd,onDel}){
const[adding,setAdding]=useState(false);
const[nt,setNt]=useState({title:"",status:"todo",priority:"medium",dueDate:""});
const[exp,setExp]=useState(null);
const submit=()=>{if(!nt.title.trim())return;onAdd(nt);setNt({title:"",status:"todo",priority:"medium",dueDate:""});setAdding(false);};
const groups={todo:p.tasks.filter(t=>t.status==="todo"),"in-progress":p.tasks.filter(t=>t.status==="in-progress"),done:p.tasks.filter(t=>t.status==="done")};
return(
<div>
<button onClick={()=>setAdding(true)} style={{width:"100%",padding:"8px",border:"1px dashed #1e2030",borderRadius:8,background:"transparent",color:"#475569",cursor:"pointer",fontSize:12,display:"flex",alignItems:"center",justifyContent:"center",gap:6,marginBottom:18}}>
<Plus size={12}/> Add Task
</button>
{adding&&(
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:10,padding:13,marginBottom:14}}>
<input value={nt.title} onChange={e=>setNt({...nt,title:e.target.value})} placeholder="Task title…" style={inp({marginBottom:8})} autoFocus onKeyDown={e=>e.key==="Enter"&&submit()}/>
<div style={{display:"flex",gap:7,marginBottom:9}}>
<select value={nt.status} onChange={e=>setNt({...nt,status:e.target.value})} style={{...inp(),flex:1,padding:"6px 8px"}}>
<option value="todo">To Do</option><option value="in-progress">In Progress</option><option value="done">Done</option>
</select>
<select value={nt.priority} onChange={e=>setNt({...nt,priority:e.target.value})} style={{...inp(),flex:1,padding:"6px 8px"}}>
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
</select>
<input type="date" value={nt.dueDate} onChange={e=>setNt({...nt,dueDate:e.target.value})} style={{...inp(),flex:1,padding:"6px 8px"}}/>
</div>
<div style={{display:"flex",gap:7}}>
<button onClick={submit} style={{...btn(),flex:1,padding:"7px",background:"#6366f1",color:"#fff"}}>Add Task</button>
<button onClick={()=>setAdding(false)} style={{...btn(),flex:1,padding:"7px",background:"transparent",border:"1px solid #181828",color:"#64748b",fontWeight:500}}>Cancel</button>
</div>
</div>
)}
{[["in-progress","In Progress"],["todo","To Do"],["done","Done"]].map(([st,lbl])=>
groups[st].length>0&&(
<div key={st} style={{marginBottom:18}}>
<div style={{fontSize:10,fontWeight:700,color:"#475569",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:7}}>{lbl} · {groups[st].length}</div>
{groups[st].map(t=><TaskRow key={t.id} task={t} color={p.color} onUpdate={onUpdate} onDel={onDel} expanded={exp===t.id} onToggle={()=>setExp(exp===t.id?null:t.id)}/>)}
</div>
)
)}
{!p.tasks.length&&!adding&&<div style={{textAlign:"center",padding:"40px 0",color:"#475569",fontSize:12}}>No tasks yet.</div>}
</div>
);
}
function TaskRow({task:t,color,onUpdate,onDel,expanded,onToggle}){
const pr=PRI[t.priority]||PRI.medium,ov=overdue(t.dueDate);
const cycle=e=>{e.stopPropagation();const c={todo:"in-progress","in-progress":"done",done:"todo"};onUpdate(t.id,{status:c[t.status]});};
const togSub=sid=>onUpdate(t.id,{subtasks:t.subtasks.map(s=>s.id===sid?{...s,done:!s.done}:s)});
const addSub=title=>onUpdate(t.id,{subtasks:[...t.subtasks,{id:uid(),title,done:false}]});
const delSub=sid=>onUpdate(t.id,{subtasks:t.subtasks.filter(s=>s.id!==sid)});
return(
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:8,marginBottom:5,overflow:"hidden"}}>
<div style={{padding:"9px 11px",display:"flex",alignItems:"center",gap:9,cursor:"pointer"}} onClick={onToggle}>
<button onClick={cycle} style={{width:18,height:18,borderRadius:"50%",border:"2px solid",borderColor:t.status==="done"?color:"#252540",background:t.status==="done"?color:"transparent",display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer",flexShrink:0,padding:0}}>
{t.status==="done"&&<Check size={9} stroke="#fff"/>}
{t.status==="in-progress"&&<div style={{width:5,height:5,borderRadius:"50%",background:color}}/>}
</button>
<div style={{flex:1,fontSize:12,color:t.status==="done"?"#475569":"#e2e8f0",textDecoration:t.status==="done"?"line-through":"none",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{t.title}</div>
<div style={{display:"flex",alignItems:"center",gap:7,flexShrink:0}}>
<span style={{fontSize:10,fontWeight:700,color:pr.color}}>{pr.label}</span>
{t.dueDate&&<span style={{fontSize:10,color:ov?"#ef4444":"#475569"}}>{fmt(t.dueDate)}</span>}
{t.subtasks.length>0&&<span style={{fontSize:10,color:"#475569"}}>{t.subtasks.filter(s=>s.done).length}/{t.subtasks.length}</span>}
<button onClick={e=>{e.stopPropagation();onDel(t.id);}} style={{background:"none",border:"none",color:"#475569",cursor:"pointer",padding:0}}><X size={11}/></button>
</div>
</div>
{expanded&&<SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub}/>}
</div>
);
}
function SubList({task,onToggle,onAdd,onDel}){
const[val,setVal]=useState("");
return(
<div style={{borderTop:"1px solid #181828",padding:"8px 11px 10px 38px"}}>
{task.subtasks.map(s=>(
<div key={s.id} style={{display:"flex",alignItems:"center",gap:7,padding:"3px 0"}}>
<button onClick={()=>onToggle(s.id)} style={{width:13,height:13,borderRadius:3,border:"1px solid #252540",background:s.done?"#6366f1":"transparent",display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer",padding:0,flexShrink:0}}>
{s.done&&<Check size={8} stroke="#fff"/>}
</button>
<span style={{flex:1,fontSize:11,color:s.done?"#475569":"#94a3b8",textDecoration:s.done?"line-through":"none"}}>{s.title}</span>
<button onClick={()=>onDel(s.id)} style={{background:"none",border:"none",color:"#475569",cursor:"pointer",padding:0}}><X size={10}/></button>
</div>
))}
<input value={val} onChange={e=>setVal(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&val.trim()){onAdd(val.trim());setVal("");}}} placeholder="Add subtask (Enter)…" style={{...inp({marginTop:6,fontSize:11,padding:"5px 8px"})}}/>
</div>
);
}
// ── Milestones ──
function MsTab({project:p,onToggle,onAdd,onDel}){
const[adding,setAdding]=useState(false);
const[nm,setNm]=useState({title:"",date:""});
const sorted=[...p.milestones].sort((a,b)=>new Date(a.date)-new Date(b.date));
return(
<div>
<button onClick={()=>setAdding(true)} style={{width:"100%",padding:"8px",border:"1px dashed #1e2030",borderRadius:8,background:"transparent",color:"#475569",cursor:"pointer",fontSize:12,display:"flex",alignItems:"center",justifyContent:"center",gap:6,marginBottom:18}}>
<Plus size={12}/> Add Milestone
</button>
{adding&&(
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:10,padding:13,marginBottom:14}}>
<input value={nm.title} onChange={e=>setNm({...nm,title:e.target.value})} placeholder="Milestone name…" style={inp({marginBottom:7})}/>
<input type="date" value={nm.date} onChange={e=>setNm({...nm,date:e.target.value})} style={inp({marginBottom:9})}/>
<div style={{display:"flex",gap:7}}>
<button onClick={()=>{if(!nm.title.trim())return;onAdd(nm);setNm({title:"",date:""});setAdding(false);}} style={{...btn(),flex:1,padding:"7px",background:"#6366f1",color:"#fff"}}>Add</button>
<button onClick={()=>setAdding(false)} style={{...btn(),flex:1,padding:"7px",background:"transparent",border:"1px solid #181828",color:"#64748b",fontWeight:500}}>Cancel</button>
</div>
</div>
)}
{!sorted.length&&!adding&&<div style={{textAlign:"center",padding:"40px 0",color:"#475569",fontSize:12}}>No milestones yet.</div>}
{sorted.length>0&&(
<div style={{position:"relative"}}>
<div style={{position:"absolute",left:8,top:14,bottom:14,width:2,background:"#181828"}}/>
{sorted.map(ms=>{
const past=ms.date&&new Date(ms.date)<new Date();
return(
<div key={ms.id} style={{display:"flex",gap:14,marginBottom:14,position:"relative"}}>
<div onClick={()=>onToggle(ms.id)} style={{width:18,height:18,borderRadius:"50%",background:ms.completed?p.color:"#181828",border:`2px solid ${ms.completed?p.color:"#252540"}`,display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer",flexShrink:0,marginTop:3,zIndex:1}}>
{ms.completed&&<Check size={9} stroke="#fff"/>}
</div>
<div style={{flex:1,background:"#0d0d1a",border:"1px solid #181828",borderRadius:8,padding:"9px 12px",display:"flex",alignItems:"center",justifyContent:"space-between"}}>
<div>
<div style={{fontSize:12,color:ms.completed?"#475569":"#e2e8f0",textDecoration:ms.completed?"line-through":"none"}}>{ms.title}</div>
<div style={{fontSize:10,color:past&&!ms.completed?"#ef4444":"#475569",marginTop:2}}>{fmt(ms.date)}{past&&!ms.completed?" · Passed":""}</div>
</div>
<button onClick={()=>onDel(ms.id)} style={{background:"none",border:"none",color:"#475569",cursor:"pointer",padding:0}}><X size={11}/></button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Team ──
function TeamTab({project:p,onChange}){
const[adding,setAdding]=useState(false);
const[nm,setNm]=useState({name:"",role:""});
const add=()=>{if(!nm.name.trim())return;const initials=nm.name.split(" ").map(w=>w[0]).join("").toUpperCase().slice(0,2);onChange({...p,members:[...p.members,{id:uid(),...nm,initials}]});setNm({name:"",role:""});setAdding(false);};
const remove=id=>onChange({...p,members:p.members.filter(m=>m.id!==id)});
return(
<div>
<button onClick={()=>setAdding(true)} style={{width:"100%",padding:"8px",border:"1px dashed #1e2030",borderRadius:8,background:"transparent",color:"#475569",cursor:"pointer",fontSize:12,display:"flex",alignItems:"center",justifyContent:"center",gap:6,marginBottom:18}}>
<Plus size={12}/> Add Member
</button>
{adding&&(
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:10,padding:13,marginBottom:14}}>
<input value={nm.name} onChange={e=>setNm({...nm,name:e.target.value})} placeholder="Full name…" style={inp({marginBottom:7})}/>
<input value={nm.role} onChange={e=>setNm({...nm,role:e.target.value})} placeholder="Role…" style={inp({marginBottom:9})} onKeyDown={e=>e.key==="Enter"&&add()}/>
<div style={{display:"flex",gap:7}}>
<button onClick={add} style={{...btn(),flex:1,padding:"7px",background:"#6366f1",color:"#fff"}}>Add</button>
<button onClick={()=>setAdding(false)} style={{...btn(),flex:1,padding:"7px",background:"transparent",border:"1px solid #181828",color:"#64748b",fontWeight:500}}>Cancel</button>
</div>
</div>
)}
{!p.members.length&&!adding&&<div style={{textAlign:"center",padding:"40px 0",color:"#475569",fontSize:12}}>No team members yet.</div>}
{p.members.map(m=>(
<div key={m.id} style={{display:"flex",alignItems:"center",gap:11,padding:"10px 12px",background:"#0d0d1a",border:"1px solid #181828",borderRadius:8,marginBottom:7}}>
<div style={{width:34,height:34,borderRadius:"50%",background:p.color,color:"#fff",fontSize:11,fontWeight:700,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>{m.initials}</div>
<div style={{flex:1}}><div style={{fontSize:13,fontWeight:600,color:"#e2e8f0"}}>{m.name}</div><div style={{fontSize:11,color:"#475569"}}>{m.role||"Team Member"}</div></div>
<button onClick={()=>remove(m.id)} style={{background:"none",border:"none",color:"#475569",cursor:"pointer",padding:0}}><X size={13}/></button>
</div>
))}
</div>
);
}
// ── Form Modal ──
function FormModal({project,onSave,onClose}){
const[f,setF]=useState({...project});
const set=(k,v)=>setF(x=>({...x,[k]:v}));
return(
<Overlay>
<div style={{background:"#0d0d1a",border:"1px solid #181828",borderRadius:14,padding:26,width:"100%",maxWidth:460,maxHeight:"88vh",overflowY:"auto"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:18}}>
<div style={{fontSize:16,fontWeight:700,color:"#f1f5f9"}}>{project.id?"Edit Project":"New Project"}</div>
<button onClick={onClose} style={{...btn(),background:"transparent",color:"#64748b",padding:4}}><X size={17}/></button>
</div>
<div style={{marginBottom:14}}>
<Lbl>Color</Lbl>
<div style={{display:"flex",gap:7,flexWrap:"wrap"}}>{COLORS.map(c=><button key={c} onClick={()=>set("color",c)} style={{width:26,height:26,borderRadius:"50%",background:c,border:f.color===c?"3px solid #fff":"3px solid transparent",cursor:"pointer",padding:0}}/>)}</div>
</div>
<div style={{marginBottom:13}}><Lbl>Project Name</Lbl><input value={f.name||""} onChange={e=>set("name",e.target.value)} placeholder="e.g. Website Redesign" style={inp()}/></div>
<div style={{marginBottom:13}}><Lbl>Description</Lbl><input value={f.description||""} onChange={e=>set("description",e.target.value)} placeholder="Brief description…" style={inp()}/></div>
<div style={{marginBottom:13}}><Lbl>Status</Lbl><select value={f.status} onChange={e=>set("status",e.target.value)} style={inp()}><option value="planning">Planning</option><option value="active">Active</option><option value="on-hold">On Hold</option><option value="completed">Completed</option></select></div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:11,marginBottom:13}}>
<div><Lbl>Start Date</Lbl><input type="date" value={f.startDate||""} onChange={e=>set("startDate",e.target.value)} style={inp()}/></div>
<div><Lbl>Due Date</Lbl><input type="date" value={f.dueDate||""} onChange={e=>set("dueDate",e.target.value)} style={inp()}/></div>
</div>
<div style={{display:"flex",gap:9,marginTop:18}}>
<button onClick={()=>{if(!f.name?.trim())return;onSave(f);}} style={{...btn(),flex:1,padding:"9px",background:"#6366f1",color:"#fff"}}>{project.id?"Save Changes":"Create Project"}</button>
<button onClick={onClose} style={{...btn(),flex:1,padding:"9px",background:"transparent",border:"1px solid #181828",color:"#64748b",fontWeight:500}}>Cancel</button>
</div>
</div>
</Overlay>
);
}
const Lbl=({children})=><div style={{fontSize:10,color:"#64748b",fontWeight:700,textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:6}}>{children}</div>;
const Overlay=({children})=><div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.7)",zIndex:200,display:"flex",alignItems:"center",justifyContent:"center",padding:16}}>{children}</div>;
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>