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

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# ── Dedicated Ports ──────────────────────────────────────────────
# All port assignments for this project live here.
# Change a value here and it propagates to Docker and the dev server.
PORT_BACKEND=4000 # Express REST API → http://localhost:4000
PORT_FRONTEND_DEV=5173 # Vite dev server → http://localhost:5173

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
dist/
build/
node_modules/
.DS_Store
*.log
# Database files (live data lives in Docker volume)
backend/db.json
backend/data/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
electron_builder_binaries_mirror=https://github.com/electron/electron/releases/download/

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 4000
CMD ["node", "index.js"]

106
backend/index.js Normal file
View File

@@ -0,0 +1,106 @@
import express from 'express';
import cors from 'cors';
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, 'data');
import { mkdirSync } from 'fs';
mkdirSync(dataDir, { recursive: true });
const dbFile = join(dataDir, 'db.json');
const adapter = new JSONFile(dbFile);
const defaultData = { members: [], projects: [], tasks: [] };
const db = new Low(adapter, defaultData);
await db.read();
await db.write();
const app = express();
app.use(cors());
app.use(express.json());
// Member CRUD
app.get('/api/members', (req, res) => {
res.json(db.data.members);
});
app.post('/api/members', (req, res) => {
const member = { id: Date.now().toString(), ...req.body };
db.data.members.push(member);
db.write();
res.status(201).json(member);
});
app.delete('/api/members/:id', (req, res) => {
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
db.write();
res.status(204).end();
});
app.put('/api/members/:id', async (req, res) => {
const idx = db.data.members.findIndex(m => m.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
await db.write();
res.json(db.data.members[idx]);
});
// Project CRUD
app.get('/api/projects', (req, res) => {
res.json(db.data.projects);
});
app.post('/api/projects', (req, res) => {
const project = { id: Date.now().toString(), members: [], ...req.body };
db.data.projects.push(project);
db.write();
res.status(201).json(project);
});
app.put('/api/projects/:id', async (req, res) => {
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
const updated = { ...req.body, id: req.params.id };
if (idx === -1) db.data.projects.push(updated);
else db.data.projects[idx] = updated;
await db.write();
res.json(updated);
});
app.delete('/api/projects/:id', (req, res) => {
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
db.write();
res.status(204).end();
});
// Assign member to project
app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (!project.members.includes(req.params.memberId)) {
project.members.push(req.params.memberId);
db.write();
}
res.json(project);
});
// Tasks (assign to member in project)
app.post('/api/projects/:projectId/tasks', (req, res) => {
const { title, description, memberId } = req.body;
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
db.data.tasks.push(task);
db.write();
res.status(201).json(task);
});
app.get('/api/projects/:projectId/tasks', (req, res) => {
const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
res.json(tasks);
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));

1259
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
backend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "project-manager-backend",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.18.2",
"lowdb": "^6.0.1",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
backend:
build: ./backend
container_name: project-manager-api
ports:
- "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}"
environment:
- PORT=${PORT_BACKEND:-4000}
volumes:
- db_data:/app/data
restart: unless-stopped
volumes:
db_data:

31
frontend/README.md Normal file
View File

@@ -0,0 +1,31 @@
Build instructions (macOS)
1. Install deps:
```bash
cd "$(pwd)/frontend"
npm install
```
2. Build the app (Vite):
```bash
npm run build
```
3. Copy the build into the Electron app root (overwrite `dist/`):
```bash
# from project root
cp -R frontend/dist/* ./dist/
```
4. Start Electron:
```bash
npm start
```
Notes
- Ensure `vite` and `@vitejs/plugin-react` are in `frontend/package.json` devDependencies.
- If your frontend source is in a different folder, place `vite.config.js` next to that source or update the path above.

View File

@@ -0,0 +1,82 @@
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const isDev = process.env.NODE_ENV !== 'production'
const devUrl = 'http://localhost:5173'
let mainWindow
function getStoragePath() {
return path.join(app.getPath('userData'), 'storage.json')
}
function readStorage() {
try {
const p = getStoragePath()
if (!fs.existsSync(p)) return {}
const raw = fs.readFileSync(p, 'utf8') || '{}'
return JSON.parse(raw)
} catch (e) {
return {}
}
}
function writeStorage(obj) {
try {
fs.mkdirSync(path.dirname(getStoragePath()), { recursive: true })
fs.writeFileSync(getStoragePath(), JSON.stringify(obj, null, 2), 'utf8')
return true
} catch (e) {
return false
}
}
ipcMain.handle('storage-get', () => {
const s = readStorage()
return s
})
ipcMain.handle('storage-set', (event, key, value) => {
const s = readStorage()
s[key] = value
writeStorage(s)
return true
})
ipcMain.handle('storage-remove', (event, key) => {
const s = readStorage()
delete s[key]
writeStorage(s)
return true
})
ipcMain.handle('get-api-url', () => process.env.VITE_API_URL || 'http://localhost:4000/api')
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false
}
})
if (isDev) {
mainWindow.loadURL(devUrl)
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
}
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})

View File

@@ -0,0 +1,14 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('ENV', {
API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api'
})
contextBridge.exposeInMainWorld('app', {
storage: {
get: (key) => ipcRenderer.invoke('storage-get', key),
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
remove: (key) => ipcRenderer.invoke('storage-remove', key)
},
getAPIUrl: () => ipcRenderer.invoke('get-api-url')
})

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Project Hub (frontend)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5951
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "project-hub-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"build:renderer": "vite build",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "vite build && electron-builder",
"start": "electron ."
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"vite": "5.0.0",
"@vitejs/plugin-react": "5.0.0",
"electron": "^26.0.0",
"electron-builder": "^24.0.0",
"concurrently": "^8.0.0",
"wait-on": "^7.0.0"
}
,
"build": {
"appId": "com.projecthub.app",
"productName": "Project Hub",
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"directories": {
"buildResources": "build"
}
},
"devDependenciesMeta": {
}
}

661
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,661 @@
import React, { useState, useEffect } from 'react'
import { api } from './api.js'
// The full App adapted from your original inline HTML (keeps UI and behavior).
// Dual storage: window.storage if available, else 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'}) : '—'
// Sample data (full from original inline HTML)
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)
const [view, setView] = useState('projects')
const [allMembers, setAllMembers] = useState([])
useEffect(()=>{
(async()=>{
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
try {
const bp = await api.getProjects()
if(bp.length > 0){ setProjects(bp); setLoaded(true); return }
} catch {}
let ps
try{
const r = await persist.get(SKEY)
if(r?.value){
const saved = JSON.parse(r.value)
ps = saved.some(p => p.id === 'authentiapol') ? saved : [AP, ...saved]
} else ps = [AP, ...SAMPLES]
} catch { ps = [AP, ...SAMPLES] }
setProjects(ps)
try { for(const p of ps) await api.createProject(p) } catch {}
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)
api.updateProject(p.id, p).catch(() => {})
} else {
const n = {...p, id: uid()}
setProjects(ps => [...ps, n])
api.createProject(n).catch(() => {})
}
setEditing(null)
}
const del = id => {
setProjects(ps => ps.filter(p => p.id !== id))
if(sel?.id === id) setSel(null)
setDelId(null)
api.deleteProject(id).catch(() => {})
}
const change = u => {
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
setSel(u)
api.updateProject(u.id, u).catch(() => {})
}
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 style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
<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>
<div style={{ display: 'flex', gap: 3 }}>
<button onClick={()=>setView('projects')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='projects' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='projects' ? '#818cf8' : '#64748b' }}>Projects</button>
<button onClick={()=>setView('members')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='members' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='members' ? '#818cf8' : '#64748b' }}>Members <span style={{ background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10, marginLeft: 3 }}>{allMembers.length}</span></button>
</div>
</div>
{view === 'projects' && <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' }}>
{view === 'members' && <MembersPage members={allMembers}
onAdd={async m => {
const temp = { ...m, id: uid() }
setAllMembers(ms => [...ms, temp])
try { const c = await api.addMember(m); setAllMembers(ms => ms.map(x => x.id === temp.id ? c : x)) } catch {}
}}
onUpdate={async (id, d) => {
setAllMembers(ms => ms.map(m => m.id === id ? { ...m, ...d } : m))
try { await api.updateMember(id, d) } catch {}
}}
onDelete={async id => {
setAllMembers(ms => ms.filter(m => m.id !== id))
try { await api.deleteMember(id) } catch {}
}}
/>}
{/* Stats */}
{view === 'projects' && <>
<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} allMembers={allMembers} />}
{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, allMembers = [] }){
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} allMembers={allMembers} />}
</div>
</div>
</div>
)
}
// (Remaining components: TasksTab, TaskRow, SubList, MsTab, TeamTab, FormModal, Lbl, Overlay)
// For brevity these components are preserved from the original inline app; include full implementations below.
function TasksTab({ project: p, onUpdate, onAdd, onDel }){
const [adding, setAdding] = useState(false)
const [nt, setNt] = useState({ title: '', status: 'todo', priority: 'medium', dueDate: '', assignedTo: '' })
const [exp, setExp] = useState(null)
const submit = () => { if(!nt.title.trim()) return; onAdd(nt); setNt({ title:'', status:'todo', priority:'medium', dueDate:'', assignedTo:'' }); 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: 8 }}>
<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>
{p.members.length > 0 && (
<select value={nt.assignedTo} onChange={e => setNt({ ...nt, assignedTo: e.target.value })} style={{ ...inp(), marginBottom: 9, padding: '6px 8px' }}>
<option value="">Unassigned</option>
{p.members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
)}
<div style={{ display: 'flex', gap: 7, marginTop: p.members.length > 0 ? 0 : 9 }}>
<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','todo','done'].map((st, idx) => (
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 }}>{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}</div>
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} 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, members = [], 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>}
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ? <div title={m.name} style={{ width: 16, height: 16, borderRadius: '50%', background: color, color: '#fff', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div> : null })()}
<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>
)
}
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>
)
}
function TeamTab({ project: p, onChange, allMembers = [] }){
const [adding, setAdding] = useState(false)
const [mode, setMode] = useState('roster')
const [nm, setNm] = useState({ name: '', role: '' })
const [rSearch, setRSearch] = useState('')
const assigned = new Set(p.members.map(m => m.id))
const available = allMembers.filter(m => !assigned.has(m.id) &&
(!rSearch || m.name.toLowerCase().includes(rSearch.toLowerCase()) || (m.role||'').toLowerCase().includes(rSearch.toLowerCase())))
const addManual = () => {
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 assignFromRoster = m => { onChange({ ...p, members: [...p.members, m] }); setAdding(false); setRSearch('') }
const remove = id => onChange({ ...p, members: p.members.filter(m => m.id !== id) })
return (
<div>
<button onClick={() => { setAdding(true); setMode('roster') }} 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}/> Assign Member</button>
{adding && (
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
<button onClick={() => setMode('roster')} style={{ ...btn(), flex: 1, padding: '5px', fontSize: 11, border: '1px solid', borderColor: mode==='roster' ? '#6366f1' : '#1e2030', background: mode==='roster' ? 'rgba(99,102,241,0.12)' : 'transparent', color: mode==='roster' ? '#818cf8' : '#64748b' }}>From Roster</button>
<button onClick={() => setMode('manual')} style={{ ...btn(), flex: 1, padding: '5px', fontSize: 11, border: '1px solid', borderColor: mode==='manual' ? '#6366f1' : '#1e2030', background: mode==='manual' ? 'rgba(99,102,241,0.12)' : 'transparent', color: mode==='manual' ? '#818cf8' : '#64748b' }}>Add Manually</button>
</div>
{mode === 'roster' ? (
<>
<input value={rSearch} onChange={e => setRSearch(e.target.value)} placeholder="Search roster…" style={inp({ marginBottom: 8 })} autoFocus />
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{available.length === 0 && <div style={{ textAlign: 'center', padding: '18px 0', color: '#475569', fontSize: 12 }}>{allMembers.length === 0 ? 'No members in roster. Add them from the Members tab.' : 'All roster members already assigned.'}</div>}
{available.map(m => (
<div key={m.id} onClick={() => assignFromRoster(m)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '7px 9px', borderRadius: 7, cursor: 'pointer', marginBottom: 3 }} onMouseEnter={e => e.currentTarget.style.background='#181828'} onMouseLeave={e => e.currentTarget.style.background='transparent'}>
<div style={{ width: 26, height: 26, borderRadius: '50%', background: '#6366f1', color: '#fff', fontSize: 9, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div>
<div><div style={{ fontSize: 12, color: '#e2e8f0', fontWeight: 600 }}>{m.name}</div><div style={{ fontSize: 10, color: '#475569' }}>{m.role || 'Team Member'}</div></div>
</div>
))}
</div>
</>
) : (
<>
<input value={nm.name} onChange={e => setNm({ ...nm, name: e.target.value })} placeholder="Full name…" style={inp({ marginBottom: 7 })} autoFocus />
<input value={nm.role} onChange={e => setNm({ ...nm, role: e.target.value })} placeholder="Role…" style={inp({ marginBottom: 9 })} onKeyDown={e => e.key === 'Enter' && addManual()} />
<button onClick={addManual} style={{ ...btn(), width: '100%', padding: '7px', background: '#6366f1', color: '#fff', marginBottom: 7 }}>Add</button>
</>
)}
<button onClick={() => { setAdding(false); setRSearch('') }} style={{ ...btn(), width: '100%', padding: '6px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500, fontSize: 12 }}>Cancel</button>
</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>
// ── Members Page ──
function MembersPage({ members, onAdd, onUpdate, onDelete }){
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState(null) // member id being edited
const [nm, setNm] = useState({ name: '', role: '', email: '' })
const [ed, setEd] = useState({ name: '', role: '', email: '' })
const add = () => {
if(!nm.name.trim()) return
const initials = nm.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2)
onAdd({ ...nm, initials })
setNm({ name: '', role: '', email: '' }); setAdding(false)
}
const startEdit = m => { setEditing(m.id); setEd({ name: m.name, role: m.role || '', email: m.email || '' }) }
const saveEdit = m => {
if(!ed.name.trim()) return
const initials = ed.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2)
onUpdate(m.id, { ...ed, initials })
setEditing(null)
}
return (
<div style={{ padding: '22px 24px', maxWidth: 640, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 22 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9' }}>Team Roster <span style={{ fontSize: 12, color: '#475569', fontWeight: 400, marginLeft: 6 }}>{members.length} member{members.length !== 1 ? 's' : ''}</span></div>
<button onClick={() => setAdding(true)} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}><Plus size={13}/> Add Member</button>
</div>
{adding && (
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 16, marginBottom: 18 }}>
<input value={nm.name} onChange={e => setNm({ ...nm, name: e.target.value })} placeholder="Full name…" style={inp({ marginBottom: 9 })} autoFocus />
<input value={nm.role} onChange={e => setNm({ ...nm, role: e.target.value })} placeholder="Role (e.g. Developer)…" style={inp({ marginBottom: 9 })} />
<input type="email" value={nm.email} onChange={e => setNm({ ...nm, email: e.target.value })} placeholder="Email (optional)…" style={inp({ marginBottom: 13 })} onKeyDown={e => e.key === 'Enter' && add()} />
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={add} style={{ ...btn(), flex: 1, padding: '8px', background: '#6366f1', color: '#fff' }}>Add Member</button>
<button onClick={() => setAdding(false)} style={{ ...btn(), flex: 1, padding: '8px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
</div>
</div>
)}
{!members.length && !adding && (
<div style={{ textAlign: 'center', padding: '70px 0', color: '#475569' }}>
<div style={{ fontSize: 36, marginBottom: 10 }}>👥</div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#64748b' }}>No members yet</div>
<div style={{ fontSize: 12, color: '#475569', marginTop: 5 }}>Add members here, then assign them to projects and tasks</div>
</div>
)}
{members.map(m => (
<div key={m.id} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, marginBottom: 8, overflow: 'hidden' }}>
{editing === m.id ? (
<div style={{ padding: '13px 15px' }}>
<input value={ed.name} onChange={e => setEd({ ...ed, name: e.target.value })} placeholder="Full name…" style={inp({ marginBottom: 8 })} autoFocus />
<input value={ed.role} onChange={e => setEd({ ...ed, role: e.target.value })} placeholder="Role…" style={inp({ marginBottom: 8 })} />
<input type="email" value={ed.email} onChange={e => setEd({ ...ed, email: e.target.value })} placeholder="Email (optional)…" style={inp({ marginBottom: 12 })} onKeyDown={e => e.key === 'Enter' && saveEdit(m)} />
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => saveEdit(m)} style={{ ...btn(), flex: 1, padding: '7px', background: '#6366f1', color: '#fff' }}>Save</button>
<button onClick={() => setEditing(null)} style={{ ...btn(), flex: 1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '13px 15px' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#6366f1', color: '#fff', fontSize: 13, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#e2e8f0' }}>{m.name}</div>
<div style={{ fontSize: 12, color: '#475569' }}>{m.role || 'Team Member'}{m.email ? ` · ${m.email}` : ''}</div>
</div>
<button onClick={() => startEdit(m)} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 4 }}><Edit size={13} /></button>
<button onClick={() => onDelete(m.id)} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 4 }}><Trash size={13} /></button>
</div>
)}
</div>
))}
</div>
)
}
export default App

17
frontend/src/api.js Normal file
View File

@@ -0,0 +1,17 @@
const BASE = (typeof window !== 'undefined' && window.ENV && window.ENV.API_URL) || import.meta.env.VITE_API_URL || 'http://localhost:4000/api'
const h = { 'Content-Type': 'application/json' }
const json = r => { if (!r.ok) throw new Error(r.status); return r.json() }
export const api = {
// Members
getMembers: () => fetch(`${BASE}/members`).then(json),
addMember: d => fetch(`${BASE}/members`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
updateMember: (id, d) => fetch(`${BASE}/members/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }).then(json),
deleteMember: id => fetch(`${BASE}/members/${id}`, { method: 'DELETE' }),
// Projects
getProjects: () => fetch(`${BASE}/projects`).then(json),
createProject: d => fetch(`${BASE}/projects`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
updateProject: (id,d) => fetch(`${BASE}/projects/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }),
deleteProject: id => fetch(`${BASE}/projects/${id}`, { method: 'DELETE' }),
}

11
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

8
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,8 @@
*{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}
#root{min-height:100vh}

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// Use relative base so built assets load correctly from filesystem/Electron
// Ports are defined in the root .env file (PORT_FRONTEND_DEV, PORT_BACKEND)
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '../', '')
return {
base: './',
plugins: [react()],
server: {
port: parseInt(env.PORT_FRONTEND_DEV) || 5173,
},
}
})

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>

15
main.js Normal file
View File

@@ -0,0 +1,15 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: { nodeIntegration: false },
titleBarStyle: 'hiddenInset', // mac-native title bar
icon: path.join(__dirname, 'icon.png')
});
win.loadFile(path.join(__dirname, 'dist', 'index.html'));
});
app.on('window-all-closed', () => process.platform !== 'darwin' && app.quit());

3779
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "project-hub",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder"
},
"dependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.0.0"
}
}