Initial commit — Electron + React frontend, Express/Docker backend, members & project management
This commit is contained in:
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,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user