Initial commit — Electron + React frontend, Express/Docker backend, members & project management
This commit is contained in:
6
.env
Normal file
6
.env
Normal 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
9
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
electron_builder_binaries_mirror=https://github.com/electron/electron/releases/download/
|
||||
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal 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
106
backend/index.js
Normal 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
1259
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/package.json
Normal file
18
backend/package.json
Normal 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
14
docker-compose.yml
Normal 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
31
frontend/README.md
Normal 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.
|
||||
82
frontend/electron/main.cjs
Normal file
82
frontend/electron/main.cjs
Normal 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()
|
||||
})
|
||||
14
frontend/electron/preload.cjs
Normal file
14
frontend/electron/preload.cjs
Normal 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
12
frontend/index.html
Normal 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
5951
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/package.json
Normal file
43
frontend/package.json
Normal 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
661
frontend/src/App.jsx
Normal 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
17
frontend/src/api.js
Normal 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
11
frontend/src/main.jsx
Normal 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
8
frontend/src/styles.css
Normal 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
15
frontend/vite.config.js
Normal 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
504
index.html
Normal 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
15
main.js
Normal 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
3779
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
Normal file
13
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user